diff --git a/.cursor/rules/backend-api-routers.mdc b/.cursor/rules/backend-api-routers.mdc
index 6836a7e..81d7856 100644
--- a/.cursor/rules/backend-api-routers.mdc
+++ b/.cursor/rules/backend-api-routers.mdc
@@ -21,7 +21,7 @@ async def analyze(
## Dependency Injection
-- **Auth**: `Depends(get_current_user)` — supports both Bearer JWT and `X-API-Key` header.
+- **Auth**: `Depends(get_current_user)` — Bearer JWT only.
- **Database**: `Depends(get_db_session)` — yields an `AsyncSession`.
- **Services**: Instantiated in the handler body, not injected:
```python
diff --git a/.cursor/rules/backend-infrastructure.mdc b/.cursor/rules/backend-infrastructure.mdc
index ef1910f..1efe24a 100644
--- a/.cursor/rules/backend-infrastructure.mdc
+++ b/.cursor/rules/backend-infrastructure.mdc
@@ -10,7 +10,7 @@ alwaysApply: false
- Constructor takes `AsyncSession` — instantiated inside services, not injected by FastAPI.
- All queries enforce **user-scoped access**: `WHERE user_id = :user_id` on every read/write.
-- Pattern: `UserRepository`, `JobRepository`, `ApiKeyRepository` in `db/repositories.py`.
+- Pattern: `UserRepository`, `JobRepository` in `db/repositories.py`.
## Database Access
@@ -19,7 +19,6 @@ alwaysApply: false
- Session: `async_sessionmaker` with `expire_on_commit=False`.
- `get_db_session()` is an async generator yielding from `SessionLocal()` — used as `Depends()`.
- UUID primary keys, JSONB columns for flexible data (`input_metadata`, `result`).
-- Soft delete for API keys via `revoked_at` timestamp.
## Docker Gateway (`docker/client.py`)
diff --git a/README-compose.md b/README-compose.md
index f883c16..857635c 100644
--- a/README-compose.md
+++ b/README-compose.md
@@ -11,11 +11,11 @@ Lifecycle scripts (mode-aware) are available:
- `./scripts/start.sh --dev`
- `./scripts/start.sh --prod`
- `./scripts/status.sh --dev|--prod`
-- `./scripts/stop.sh --dev|--prod [--wipe]`
+- `./scripts/stop.sh --dev|--prod [--wipe]` (`--wipe` in **dev** also clears `backend/storage/uploads` on disk)
- `.\scripts\start.ps1 --dev`
- `.\scripts\start.ps1 --prod`
- `.\scripts\status.ps1 --dev|--prod`
-- `.\scripts\stop.ps1 --dev|--prod [--wipe]`
+- `.\scripts\stop.ps1 --dev|--prod [--wipe]` (`--wipe` in **dev** also clears `backend/storage/uploads` on disk)
## 1) Prepare environment and secrets
diff --git a/README.md b/README.md
index a19cef8..e47513c 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@ A multi-tenant platform for analyzing, scoring, and deploying Docker artifacts.
- **Project Archives**: Upload full `.zip` projects. The platform automatically detects Dockerfiles and Compose stacks, analyzes them, and prepares them for deployment.
- **Live Container Execution**: Deploy runnable Compose stacks directly from the UI to the underlying Docker daemon.
- **Real-time Monitoring**: Stream live container logs and view real-time CPU/Memory usage charts via WebSockets.
-- **API Keys**: Generate API keys for programmatic access and CI/CD integration.
- **Analysis History**: Keep track of all your past analyses and deployments.
## 🏗️ Tech Stack & Architecture
@@ -17,7 +16,7 @@ A multi-tenant platform for analyzing, scoring, and deploying Docker artifacts.
- **Backend**: FastAPI (`backend/`) using modular-monolith + hexagonal architecture.
- **Frontend**: React SPA (`frontend/`) with Tailwind CSS, Recharts for monitoring, and real-time WebSocket integration.
- **Workers**: Redis event bus + arq workers for asynchronous analysis and deployment pipelines.
-- **Database**: PostgreSQL for persistence (users, jobs, projects, containers, images, API keys).
+- **Database**: PostgreSQL for persistence (users, jobs, projects, containers, images).
- **Proxy**: Nginx for serving the frontend and proxying API/WebSocket traffic.
- **Docker-in-Docker (DinD)**: We utilize a `dind` service to securely isolate the building and execution of user-submitted Dockerfiles and Compose stacks from the host system. The worker service communicates with this isolated daemon via TCP.
@@ -93,14 +92,14 @@ JWT_SECRET_KEY=choose-a-long-random-secret
./scripts/status.sh # compose + health summary
docker compose logs -f frontend api worker # tail live logs
./scripts/stop.sh # stop stack
-./scripts/stop.sh --wipe # stop + wipe volumes
+./scripts/stop.sh --wipe # stop + wipe volumes (+ dev upload dir on disk)
```
```powershell
.\scripts\status.ps1 # compose + health summary
docker compose logs -f frontend api worker # tail live logs
.\scripts\stop.ps1 # stop stack
-.\scripts\stop.ps1 --wipe # stop + wipe volumes
+.\scripts\stop.ps1 --wipe # stop + wipe volumes (+ dev upload dir on disk)
```
### Troubleshooting
diff --git a/backend/.env.example b/backend/.env.example
index 8c74cd3..17405c8 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -9,4 +9,7 @@ JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_MINUTES=10080
UPLOAD_DIR=storage/uploads
-MAX_UPLOAD_SIZE_MB=30
+MAX_UPLOAD_SIZE_MB=200
+PROJECT_JOB_TIMEOUT_SECONDS=7200
+IMAGE_BUILD_TIMEOUT_SECONDS=3600
+MAX_IMAGE_BUILDS=3
diff --git a/backend/README.md b/backend/README.md
index 1e0aade..8e3cc9f 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -11,7 +11,7 @@ Production-grade FastAPI backend using a modular-monolith + hexagonal structure:
## Features
-- Multi-user auth (`/auth/register`, `/auth/login`) with JWT + API key support
+- Multi-user auth (`/auth/register`, `/auth/login`) with JWT Bearer tokens
- User-scoped jobs/history for Dockerfile, Compose, and project archive workflows
- Redis event bus with job/container event streams
- Plugin-based analyzers: hadolint, compose validator, compose runnability, security scanner, resource estimation
diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py
index e7fba57..0294ed4 100644
--- a/backend/alembic/versions/0001_initial_schema.py
+++ b/backend/alembic/versions/0001_initial_schema.py
@@ -81,24 +81,8 @@ def upgrade() -> None:
)
op.create_index("ix_docker_images_user_id", "docker_images", ["user_id"], unique=False)
- op.create_table(
- "api_keys",
- sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
- sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
- sa.Column("key_prefix", sa.String(length=32), nullable=False),
- sa.Column("key_hash", sa.String(length=255), nullable=False),
- sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
- sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
- sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
- )
- op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"], unique=False)
- op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"], unique=False)
-
def downgrade() -> None:
- op.drop_index("ix_api_keys_key_prefix", table_name="api_keys")
- op.drop_index("ix_api_keys_user_id", table_name="api_keys")
- op.drop_table("api_keys")
op.drop_index("ix_docker_images_user_id", table_name="docker_images")
op.drop_table("docker_images")
op.drop_index("ix_containers_docker_container_id", table_name="containers")
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index f499dd7..d277bcd 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -1,13 +1,12 @@
import uuid
-from datetime import UTC, datetime
-from fastapi import Depends, Header, HTTPException
+from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token
from app.infrastructure.db.models import UserModel
-from app.infrastructure.db.repositories import ApiKeyRepository, UserRepository
+from app.infrastructure.db.repositories import UserRepository
from app.infrastructure.db.session import get_db_session
bearer_scheme = HTTPBearer(auto_error=False)
@@ -15,11 +14,9 @@
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
- x_api_key: str | None = Header(default=None),
session: AsyncSession = Depends(get_db_session),
) -> UserModel:
user_repo = UserRepository(session)
- api_key_repo = ApiKeyRepository(session)
if credentials:
try:
@@ -33,13 +30,4 @@ async def get_current_user(
except Exception as exc:
raise HTTPException(status_code=401, detail="Invalid bearer token.") from exc
- if x_api_key:
- key_record = await api_key_repo.get_by_raw_key(x_api_key)
- if key_record:
- key_record.last_used_at = datetime.now(UTC)
- user = await user_repo.get_by_id(key_record.user_id)
- await session.commit()
- if user:
- return user
-
raise HTTPException(status_code=401, detail="Authentication required.")
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 41230fe..f4b5ec3 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -1,12 +1,12 @@
from fastapi import APIRouter
-from app.api.routers import api_keys, auth, compose, dockerfile, history, project, research, ws
+from app.api.routers import auth, compose, dockerfile, history, preview_proxy, project, research, ws
api_router = APIRouter()
api_router.include_router(auth.router)
-api_router.include_router(api_keys.router)
api_router.include_router(dockerfile.router)
api_router.include_router(compose.router)
+api_router.include_router(preview_proxy.router)
api_router.include_router(project.router)
api_router.include_router(history.router)
api_router.include_router(research.router)
diff --git a/backend/app/api/routers/api_keys.py b/backend/app/api/routers/api_keys.py
deleted file mode 100644
index e2c579f..0000000
--- a/backend/app/api/routers/api_keys.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import uuid
-
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.api.deps import get_current_user
-from app.application.schemas import ApiKeyCreateResponse, ApiKeyRead
-from app.infrastructure.db.models import UserModel
-from app.infrastructure.db.repositories import ApiKeyRepository
-from app.infrastructure.db.session import get_db_session
-
-router = APIRouter(prefix="/api/v1/users/me/api-keys", tags=["api-keys"])
-
-
-@router.post("", response_model=ApiKeyCreateResponse)
-async def create_api_key(
- current_user: UserModel = Depends(get_current_user),
- session: AsyncSession = Depends(get_db_session),
-) -> ApiKeyCreateResponse:
- repo = ApiKeyRepository(session)
- record, raw_key = await repo.create_key(current_user.id)
- await session.commit()
- return ApiKeyCreateResponse(id=record.id, key=raw_key, key_prefix=record.key_prefix)
-
-
-@router.get("", response_model=list[ApiKeyRead])
-async def list_api_keys(
- current_user: UserModel = Depends(get_current_user),
- session: AsyncSession = Depends(get_db_session),
-) -> list[ApiKeyRead]:
- repo = ApiKeyRepository(session)
- keys = await repo.list_keys(current_user.id)
- return [ApiKeyRead(id=item.id, key_prefix=item.key_prefix, created_at=item.created_at) for item in keys]
-
-
-@router.delete("/{key_id}", status_code=204)
-async def revoke_api_key(
- key_id: uuid.UUID,
- current_user: UserModel = Depends(get_current_user),
- session: AsyncSession = Depends(get_db_session),
-) -> None:
- repo = ApiKeyRepository(session)
- revoked = await repo.revoke(current_user.id, key_id)
- if not revoked:
- raise HTTPException(status_code=404, detail="API key not found.")
- await session.commit()
diff --git a/backend/app/api/routers/compose.py b/backend/app/api/routers/compose.py
index b3d937a..51b2070 100644
--- a/backend/app/api/routers/compose.py
+++ b/backend/app/api/routers/compose.py
@@ -1,4 +1,5 @@
import json
+import logging
import os
import re
import socket
@@ -7,7 +8,8 @@
from typing import Literal
from urllib.parse import urlparse
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+import httpx
+from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +28,21 @@
from app.workers.queue import enqueue_job
router = APIRouter(prefix="/api/v1/compose", tags=["compose"])
+logger = logging.getLogger(__name__)
+
+DEPLOY_STATE_TTL_SECONDS = 60 * 60 * 6
+_PREVIEW_ALLOWED_HOSTNAMES = frozenset(
+ {
+ "localhost",
+ "127.0.0.1",
+ "::1",
+ "docker",
+ "dind",
+ "docker-platform-dind",
+ "docker-platform-dind-1",
+ }
+)
+_PREVIEW_API_PORTS = frozenset({8000, 8001})
class ComposeDeployRequest(BaseModel):
@@ -222,6 +239,7 @@ async def stop_compose_deploy(
raise HTTPException(status_code=404, detail="Job not found.")
if job.type not in {JobType.compose, JobType.project}:
raise HTTPException(status_code=400, detail="Stop deploy is supported only for compose or project jobs.")
+ await mark_deploy_stop_pending(current_user.id, payload.job_id)
await enqueue_job(
"run_compose_stop",
{
@@ -262,6 +280,15 @@ class DindIpResponse(BaseModel):
dind_ip: str | None = None
+class PreviewCheckResponse(BaseModel):
+ reachable: bool
+ frameable: bool
+ proxy_available: bool = False
+ status_code: int | None = None
+ reason: str | None = None
+ final_url: str | None = None
+
+
def deploy_state_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str:
return f"deploy:{user_id}:{job_id}"
@@ -270,10 +297,179 @@ def deploy_stop_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str:
return f"deploy-stop:{user_id}:{job_id}"
+async def mark_deploy_stop_pending(user_id: uuid.UUID, job_id: uuid.UUID) -> None:
+ """Persist stop intent before the worker runs so deploy/status reflects stopping immediately."""
+ await redis_client.set(
+ deploy_stop_redis_key(user_id, job_id),
+ "1",
+ ex=DEPLOY_STATE_TTL_SECONDS,
+ )
+ raw = await redis_client.get(deploy_state_redis_key(user_id, job_id))
+ if not raw:
+ return
+ try:
+ state = json.loads(raw)
+ except (json.JSONDecodeError, TypeError):
+ return
+ if not isinstance(state, dict):
+ return
+ state["stopping"] = True
+ state["stop_requested_by_user"] = True
+ state["stop_reason"] = "user_requested"
+ await redis_client.set(
+ deploy_state_redis_key(user_id, job_id),
+ json.dumps(state),
+ ex=DEPLOY_STATE_TTL_SECONDS,
+ )
+
+
def _is_ipv4(value: str) -> bool:
return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", value))
+def _is_private_or_loopback_ipv4(host: str) -> bool:
+ if not _is_ipv4(host):
+ return False
+ parts = [int(x) for x in host.split(".")]
+ if parts[0] == 10:
+ return True
+ if parts[0] == 172 and 16 <= parts[1] <= 31:
+ return True
+ if parts[0] == 192 and parts[1] == 168:
+ return True
+ return parts[0] == 127
+
+
+def _is_allowed_preview_host(host: str) -> bool:
+ normalized = (host or "").strip().lower()
+ if not normalized:
+ return False
+ if normalized in _PREVIEW_ALLOWED_HOSTNAMES:
+ return True
+ return _is_private_or_loopback_ipv4(normalized)
+
+
+def _normalize_preview_url(raw_url: str) -> str:
+ trimmed = (raw_url or "").strip()
+ if not trimmed:
+ raise HTTPException(status_code=400, detail="Preview URL is required.")
+ parsed = urlparse(trimmed)
+ if parsed.scheme not in ("http", "https"):
+ raise HTTPException(status_code=400, detail="Preview URL must use http or https.")
+ if not parsed.hostname or not _is_allowed_preview_host(parsed.hostname):
+ raise HTTPException(
+ status_code=400,
+ detail="Preview host must be a container/DinD address (private IP or DinD hostname).",
+ )
+ port = parsed.port
+ if port is not None and not (1 <= port <= 65535):
+ raise HTTPException(status_code=400, detail="Preview port is out of range.")
+ return trimmed
+
+
+def _frame_headers_block_embedding(headers: httpx.Headers) -> str | None:
+ xfo = (headers.get("x-frame-options") or "").lower()
+ if "deny" in xfo:
+ return "X-Frame-Options: deny"
+ if "sameorigin" in xfo:
+ return "X-Frame-Options: SAMEORIGIN (parent origin differs from the app)"
+ csp = (headers.get("content-security-policy") or "").lower()
+ if "frame-ancestors 'none'" in csp or "frame-ancestors 'self'" in csp:
+ return "Content-Security-Policy blocks framing"
+ return None
+
+
+def _looks_like_platform_api_url(parsed_url) -> bool:
+ host = (parsed_url.hostname or "").lower()
+ port = parsed_url.port
+ path = parsed_url.path or "/"
+ if port in _PREVIEW_API_PORTS and host in {"localhost", "127.0.0.1", "::1"}:
+ return True
+ if path.startswith(("/api", "/docs", "/redoc", "/openapi.json", "/metrics")):
+ return True
+ return False
+
+
+async def _probe_preview_url(url: str) -> PreviewCheckResponse:
+ parsed = urlparse(url)
+ if _looks_like_platform_api_url(parsed):
+ return PreviewCheckResponse(
+ reachable=False,
+ frameable=False,
+ status_code=None,
+ reason="URL points at the platform API, not a container web port.",
+ final_url=url,
+ )
+
+ timeout = httpx.Timeout(connect=5.0, read=8.0, write=8.0, pool=5.0)
+ try:
+ async with httpx.AsyncClient(
+ timeout=timeout,
+ follow_redirects=True,
+ max_redirects=5,
+ ) as client:
+ try:
+ response = await client.head(url)
+ except httpx.HTTPError:
+ response = await client.get(url, headers={"Range": "bytes=0-0"})
+
+ final_url = str(response.url)
+ final_parsed = urlparse(final_url)
+ if _looks_like_platform_api_url(final_parsed):
+ return PreviewCheckResponse(
+ reachable=True,
+ frameable=False,
+ status_code=response.status_code,
+ reason="App redirects to the platform API, which cannot be embedded.",
+ final_url=final_url,
+ )
+
+ block_reason = _frame_headers_block_embedding(response.headers)
+ if block_reason:
+ return PreviewCheckResponse(
+ reachable=True,
+ frameable=False,
+ proxy_available=True,
+ status_code=response.status_code,
+ reason=block_reason,
+ final_url=final_url,
+ )
+
+ return PreviewCheckResponse(
+ reachable=True,
+ frameable=True,
+ proxy_available=False,
+ status_code=response.status_code,
+ reason=None,
+ final_url=final_url,
+ )
+ except httpx.ConnectError:
+ return PreviewCheckResponse(
+ reachable=False,
+ frameable=False,
+ status_code=None,
+ reason="Could not connect to the preview URL from the platform network.",
+ final_url=url,
+ )
+ except httpx.TimeoutException:
+ return PreviewCheckResponse(
+ reachable=False,
+ frameable=False,
+ status_code=None,
+ reason="Preview URL timed out.",
+ final_url=url,
+ )
+ except httpx.HTTPError as exc:
+ logger.warning("preview_check_failed url=%s err=%s", url, exc)
+ return PreviewCheckResponse(
+ reachable=False,
+ frameable=False,
+ status_code=None,
+ reason="Failed to probe the preview URL.",
+ final_url=url,
+ )
+
+
def _resolve_dind_ip() -> str | None:
# Prefer direct inspect of the known DinD container names.
for candidate in ("docker-platform-dind-1", "docker-platform-dind", "dind", "docker"):
@@ -372,7 +568,7 @@ async def compute_deploy_status(user_id: uuid.UUID, job_id: uuid.UUID) -> Deploy
runtime_state_val: str = "none"
active = False
- if explicit_runtime_state in ("stopped_by_user", "cleanup_completed"):
+ if explicit_runtime_state in ("stopped_by_user", "cleanup_completed", "exited", "unhealthy"):
runtime_state_val = explicit_runtime_state
active = False
elif explicit_runtime_state == "failed":
@@ -423,7 +619,7 @@ async def compute_deploy_status(user_id: uuid.UUID, job_id: uuid.UUID) -> Deploy
running_count=running_count,
exited_count=exited_count,
unhealthy_count=unhealthy_count,
- stopped_by_user=stored_stopped_by_user,
+ stopped_by_user=stored_stopped_by_user and not stop_pending,
stop_reason=stop_reason,
exit_reason=exit_reason,
can_retry_runtime=can_retry_runtime,
@@ -442,3 +638,13 @@ async def get_deploy_status(
async def get_dind_ip(current_user: UserModel = Depends(get_current_user)) -> DindIpResponse:
_ = current_user
return DindIpResponse(dind_ip=_resolve_dind_ip())
+
+
+@router.get("/deploy/preview-check", response_model=PreviewCheckResponse)
+async def check_deploy_preview(
+ url: str = Query(..., min_length=1),
+ current_user: UserModel = Depends(get_current_user),
+) -> PreviewCheckResponse:
+ _ = current_user
+ normalized = _normalize_preview_url(url)
+ return await _probe_preview_url(normalized)
diff --git a/backend/app/api/routers/history.py b/backend/app/api/routers/history.py
index 24aa8df..12809fe 100644
--- a/backend/app/api/routers/history.py
+++ b/backend/app/api/routers/history.py
@@ -6,6 +6,7 @@
from app.api.deps import get_current_user
from app.api.routers.compose import compute_deploy_status, deploy_state_redis_key, deploy_stop_redis_key
from app.application.schemas import JobRead
+from app.application.services.job_cleanup import cleanup_job_artifacts
from app.infrastructure.db.models import JobStatus, JobType, UserModel
from app.infrastructure.db.repositories import JobRepository
from app.infrastructure.db.session import get_db_session
@@ -101,6 +102,13 @@ async def delete_job(
detail="Stop running containers for this job before deleting it.",
)
+ await cleanup_job_artifacts(
+ user_id=current_user.id,
+ job_id=job_id,
+ input_metadata=dict(job.input_metadata or {}),
+ result=dict(job.result) if isinstance(job.result, dict) else job.result,
+ )
+
deleted = await repo.delete_job(job_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Job not found.")
diff --git a/backend/app/api/routers/preview_proxy.py b/backend/app/api/routers/preview_proxy.py
new file mode 100644
index 0000000..a7b0867
--- /dev/null
+++ b/backend/app/api/routers/preview_proxy.py
@@ -0,0 +1,368 @@
+"""Authenticated reverse proxy for in-app container previews (strips X-Frame-Options)."""
+from __future__ import annotations
+
+import json
+import logging
+import re
+import uuid
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import httpx
+from fastapi import APIRouter, Cookie, Depends, HTTPException, Query, Request, Response
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_user
+from app.api.routers.compose import (
+ _looks_like_platform_api_url,
+ _normalize_preview_url,
+)
+from app.core.security import decode_token
+from app.infrastructure.db.models import UserModel
+from app.infrastructure.db.session import get_db_session
+from app.infrastructure.events.bus import redis_client
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/v1/compose/deploy/preview-proxy", tags=["compose-preview"])
+bearer_scheme = HTTPBearer(auto_error=False)
+
+PREVIEW_SESSION_TTL_SECONDS = 60 * 60
+PREVIEW_SESSION_KEY_PREFIX = "preview-proxy:"
+PREVIEW_COOKIE_NAME = "dpa_preview"
+PREVIEW_PROXY_TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
+
+HOP_BY_HOP_REQUEST = frozenset(
+ {
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailers",
+ "transfer-encoding",
+ "upgrade",
+ "host",
+ "content-length",
+ }
+)
+
+STRIP_RESPONSE_HEADERS = frozenset(
+ {
+ "x-frame-options",
+ "content-security-policy",
+ "content-security-policy-report-only",
+ "strict-transport-security",
+ "permissions-policy",
+ "cross-origin-embedder-policy",
+ "cross-origin-opener-policy",
+ "cross-origin-resource-policy",
+ }
+)
+
+
+class PreviewSessionCreate(BaseModel):
+ url: str
+ user_agent: str | None = None
+ accept_language: str | None = None
+
+
+class PreviewSessionResponse(BaseModel):
+ session_id: str
+ proxy_root: str
+
+
+def _session_redis_key(session_id: str) -> str:
+ return f"{PREVIEW_SESSION_KEY_PREFIX}{session_id}"
+
+
+async def _load_preview_session(session_id: str) -> dict[str, Any]:
+ raw = await redis_client.get(_session_redis_key(session_id))
+ if not raw:
+ raise HTTPException(status_code=404, detail="Preview session not found or expired.")
+ try:
+ state = json.loads(raw)
+ except (json.JSONDecodeError, TypeError) as exc:
+ raise HTTPException(status_code=404, detail="Preview session is invalid.") from exc
+ if not isinstance(state, dict):
+ raise HTTPException(status_code=404, detail="Preview session is invalid.")
+ return state
+
+
+async def _authenticate_preview_user(
+ token: str | None,
+ credentials: HTTPAuthorizationCredentials | None,
+ session: AsyncSession,
+) -> UserModel:
+ if credentials:
+ return await get_current_user(credentials=credentials, session=session)
+ if not token:
+ raise HTTPException(status_code=401, detail="Authentication required for preview proxy.")
+ try:
+ payload = decode_token(token)
+ if payload.get("type") != "access":
+ raise HTTPException(status_code=401, detail="Invalid token type.")
+ user_id = uuid.UUID(payload["sub"])
+ except (KeyError, TypeError, ValueError) as exc:
+ raise HTTPException(status_code=401, detail="Invalid token.") from exc
+
+ from app.infrastructure.db.repositories import UserRepository
+
+ user = await UserRepository(session).get_by_id(user_id)
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid token.")
+ return user
+
+
+async def _authorize_preview_session(
+ session_id: str,
+ user: UserModel | None,
+ preview_cookie: str | None,
+) -> dict[str, Any]:
+ state = await _load_preview_session(session_id)
+ if preview_cookie == session_id:
+ return state
+ if user is None:
+ raise HTTPException(status_code=401, detail="Authentication required for preview proxy.")
+ if str(state.get("user_id")) != str(user.id):
+ raise HTTPException(status_code=403, detail="Preview session does not belong to this user.")
+ return state
+
+
+def _build_upstream_url(base_url: str, path: str, query: str | None) -> str:
+ base_parsed = urlparse(base_url)
+ clean_path = path or ""
+ if clean_path.startswith("/"):
+ clean_path = clean_path[1:]
+ upstream = urljoin(base_url if base_url.endswith("/") else f"{base_url}/", clean_path)
+ if query:
+ upstream = f"{upstream}?{query}"
+ # Guard: upstream must stay on the same host as the session base.
+ upstream_parsed = urlparse(upstream)
+ if upstream_parsed.hostname != base_parsed.hostname:
+ raise HTTPException(status_code=400, detail="Preview path escapes the container origin.")
+ if _looks_like_platform_api_url(upstream_parsed):
+ raise HTTPException(status_code=400, detail="Preview cannot proxy the platform API.")
+ return upstream
+
+
+def _filter_request_headers(headers: Any, upstream_host: str) -> dict[str, str]:
+ forwarded: dict[str, str] = {}
+ for key, value in headers.items():
+ lowered = key.lower()
+ if lowered in HOP_BY_HOP_REQUEST:
+ continue
+ forwarded[key] = value
+ forwarded["Host"] = upstream_host
+ return forwarded
+
+
+def _strip_response_headers(headers: httpx.Headers) -> dict[str, str]:
+ out: dict[str, str] = {}
+ for key, value in headers.items():
+ if key.lower() in STRIP_RESPONSE_HEADERS:
+ continue
+ if key.lower() in {"content-encoding", "transfer-encoding", "content-length"}:
+ continue
+ out[key] = value
+ return out
+
+
+def _rewrite_location(location: str, session_id: str, base_url: str) -> str:
+ parsed = urlparse(location)
+ base_parsed = urlparse(base_url)
+ if parsed.scheme and parsed.netloc:
+ if parsed.hostname != base_parsed.hostname or parsed.port != base_parsed.port:
+ return location
+ path = parsed.path or "/"
+ query = f"?{parsed.query}" if parsed.query else ""
+ return f"/api/v1/compose/deploy/preview-proxy/{session_id}{path}{query}"
+ path = location if location.startswith("/") else f"/{location}"
+ return f"/api/v1/compose/deploy/preview-proxy/{session_id}{path}"
+
+
+def _inject_html_base(html: bytes, base_href: str) -> bytes:
+ base_tag = f' '
+ text = html.decode("utf-8", errors="ignore")
+ if re.search(r" ]*>", text, flags=re.IGNORECASE)
+ if head_match:
+ insert_at = head_match.end()
+ text = text[:insert_at] + base_tag + text[insert_at:]
+ elif re.search(r"]*>", text, flags=re.IGNORECASE):
+ text = re.sub(
+ r"(]*>)",
+ r"\1
" + base_tag + "",
+ text,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+ else:
+ text = base_tag + text
+ return text.encode("utf-8")
+
+
+def _proxy_root_path(session_id: str) -> str:
+ return f"/api/v1/compose/deploy/preview-proxy/{session_id}/"
+
+
+async def _proxy_preview(
+ session_id: str,
+ path: str,
+ request: Request,
+ user: UserModel | None,
+ preview_cookie: str | None,
+ token: str | None,
+) -> Response:
+ state = await _authorize_preview_session(session_id, user, preview_cookie)
+ base_url = str(state["base_url"])
+
+ query_parts = [
+ f"{key}={value}"
+ for key, value in request.query_params.multi_items()
+ if key != "token"
+ ]
+ query = "&".join(query_parts) if query_parts else None
+ upstream = _build_upstream_url(base_url, path, query)
+ upstream_parsed = urlparse(upstream)
+
+ body = await request.body()
+ forward_headers = _filter_request_headers(request.headers, upstream_parsed.hostname or "")
+ stored_ua = state.get("user_agent")
+ if isinstance(stored_ua, str) and stored_ua.strip():
+ forward_headers["User-Agent"] = stored_ua.strip()
+ stored_lang = state.get("accept_language")
+ if isinstance(stored_lang, str) and stored_lang.strip():
+ forward_headers["Accept-Language"] = stored_lang.strip()
+ forward_headers["Accept"] = request.headers.get(
+ "accept",
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ )
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=PREVIEW_PROXY_TIMEOUT,
+ follow_redirects=False,
+ ) as client:
+ upstream_response = await client.request(
+ request.method,
+ upstream,
+ headers=forward_headers,
+ content=body if body else None,
+ )
+ except httpx.ConnectError as exc:
+ raise HTTPException(
+ status_code=502,
+ detail="Could not reach the container from the preview proxy.",
+ ) from exc
+ except httpx.TimeoutException as exc:
+ raise HTTPException(status_code=504, detail="Container preview timed out.") from exc
+ except httpx.HTTPError as exc:
+ logger.warning("preview_proxy_upstream_failed session=%s err=%s", session_id, exc)
+ raise HTTPException(status_code=502, detail="Preview proxy upstream failed.") from exc
+
+ if upstream_response.status_code in {301, 302, 303, 307, 308}:
+ location = upstream_response.headers.get("location")
+ if location:
+ headers = _strip_response_headers(upstream_response.headers)
+ headers["location"] = _rewrite_location(location, session_id, base_url)
+ return Response(
+ status_code=upstream_response.status_code,
+ headers=headers,
+ )
+
+ content = upstream_response.content
+ content_type = (upstream_response.headers.get("content-type") or "").lower()
+ if "text/html" in content_type and request.method == "GET":
+ content = _inject_html_base(content, _proxy_root_path(session_id))
+
+ response_headers = _strip_response_headers(upstream_response.headers)
+ response = Response(
+ content=content,
+ status_code=upstream_response.status_code,
+ headers=response_headers,
+ media_type=upstream_response.headers.get("content-type"),
+ )
+ if user is not None:
+ response.set_cookie(
+ key=PREVIEW_COOKIE_NAME,
+ value=session_id,
+ httponly=True,
+ samesite="lax",
+ max_age=PREVIEW_SESSION_TTL_SECONDS,
+ path="/api/v1/compose/deploy/preview-proxy",
+ )
+ return response
+
+
+@router.post("/session", response_model=PreviewSessionResponse)
+async def create_preview_proxy_session(
+ payload: PreviewSessionCreate,
+ response: Response,
+ current_user: UserModel = Depends(get_current_user),
+) -> PreviewSessionResponse:
+ base_url = _normalize_preview_url(payload.url)
+ parsed = urlparse(base_url)
+ if _looks_like_platform_api_url(parsed):
+ raise HTTPException(
+ status_code=400,
+ detail="Cannot proxy the platform API. Use a container host:port URL.",
+ )
+
+ session_id = uuid.uuid4().hex
+ session_payload: dict[str, Any] = {
+ "user_id": str(current_user.id),
+ "base_url": base_url,
+ }
+ if payload.user_agent and payload.user_agent.strip():
+ session_payload["user_agent"] = payload.user_agent.strip()[:512]
+ if payload.accept_language and payload.accept_language.strip():
+ session_payload["accept_language"] = payload.accept_language.strip()[:256]
+ await redis_client.set(
+ _session_redis_key(session_id),
+ json.dumps(session_payload),
+ ex=PREVIEW_SESSION_TTL_SECONDS,
+ )
+ response.set_cookie(
+ key=PREVIEW_COOKIE_NAME,
+ value=session_id,
+ httponly=True,
+ samesite="lax",
+ max_age=PREVIEW_SESSION_TTL_SECONDS,
+ path="/api/v1/compose/deploy/preview-proxy",
+ )
+ return PreviewSessionResponse(
+ session_id=session_id,
+ proxy_root=f"/api/v1/compose/deploy/preview-proxy/{session_id}/",
+ )
+
+
+@router.api_route(
+ "/{session_id}",
+ methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
+)
+@router.api_route(
+ "/{session_id}/{path:path}",
+ methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
+)
+async def preview_proxy(
+ session_id: str,
+ request: Request,
+ path: str = "",
+ token: str | None = Query(None),
+ preview_cookie: str | None = Cookie(None, alias=PREVIEW_COOKIE_NAME),
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+ session: AsyncSession = Depends(get_db_session),
+) -> Response:
+ if request.method == "OPTIONS":
+ return Response(status_code=204)
+
+ user: UserModel | None = None
+ if preview_cookie == session_id:
+ user = None
+ else:
+ user = await _authenticate_preview_user(token, credentials, session)
+ return await _proxy_preview(session_id, path, request, user, preview_cookie, token)
diff --git a/backend/app/api/routers/project.py b/backend/app/api/routers/project.py
index b2f8ce4..3049803 100644
--- a/backend/app/api/routers/project.py
+++ b/backend/app/api/routers/project.py
@@ -13,6 +13,7 @@
ProjectPrimaryComposeRequest,
)
from app.application.services.analysis_service import AnalysisService
+from app.application.services.build_selection import select_dockerfiles_for_build
from app.application.services.github_import import download_repo_zipball, resolve_public_repo_target
from app.application.services.project_scanner import safe_extract_zip, scan_extracted_project
from app.core.config import get_settings
@@ -41,6 +42,19 @@ async def _enqueue_project_analysis_job(
if metadata_overrides:
metadata.update(metadata_overrides)
+ dockerfiles: list[str] = metadata.get("dockerfiles", [])
+ compose_files: list[str] = metadata.get("compose_files", [])
+ primary_compose_file = compose_files[0] if compose_files else None
+ build_dockerfiles = select_dockerfiles_for_build(
+ dockerfiles,
+ extract_dir,
+ primary_compose_file=primary_compose_file,
+ max_builds=settings.max_image_builds,
+ )
+ metadata["primary_compose_file"] = primary_compose_file
+ metadata["build_dockerfiles"] = build_dockerfiles
+ metadata["build_selected_images"] = bool(build_dockerfiles)
+
service = AnalysisService(session)
job_id = await service.enqueue_job(
current_user.id,
@@ -49,8 +63,6 @@ async def _enqueue_project_analysis_job(
initial_status=JobStatus.queued,
)
- dockerfiles: list[str] = metadata.get("dockerfiles", [])
- compose_files: list[str] = metadata.get("compose_files", [])
await enqueue_job(
"run_project_analysis",
{
@@ -59,9 +71,10 @@ async def _enqueue_project_analysis_job(
"project_path": str(extract_dir),
"dockerfiles": dockerfiles,
"compose_files": compose_files,
- "primary_compose_file": compose_files[0] if compose_files else None,
+ "primary_compose_file": primary_compose_file,
+ "build_dockerfiles": build_dockerfiles,
"analysis_mode": "auto",
- "build_selected_images": True,
+ "build_selected_images": bool(build_dockerfiles),
},
)
return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued")
@@ -115,7 +128,9 @@ async def upload_project_github(
upload_root = Path(settings.upload_dir) / str(current_user.id)
upload_root.mkdir(parents=True, exist_ok=True)
- timeout = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
+ # Scale read timeout with upload cap (~0.75s per MB, minimum 30s).
+ read_timeout_s = max(30.0, settings.max_upload_size_mb * 0.75)
+ timeout = httpx.Timeout(connect=10.0, read=read_timeout_s, write=30.0, pool=10.0)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
target = await resolve_public_repo_target(client, payload.url, payload.ref)
diff --git a/backend/app/api/routers/research.py b/backend/app/api/routers/research.py
index 8c3c921..e645f67 100644
--- a/backend/app/api/routers/research.py
+++ b/backend/app/api/routers/research.py
@@ -59,11 +59,12 @@ def _extract_score_grade(result: dict | None) -> tuple[int | None, str | None]:
return score, grade
-def _to_public_research_job(job: AnalysisJobModel) -> PublicResearchJobRead:
+def _to_public_research_job(job: AnalysisJobModel, current_user: UserModel) -> PublicResearchJobRead:
score, grade = _extract_score_grade(job.result)
return PublicResearchJobRead(
id=job.id,
anonymized_submitter=anonymize_user_id(job.user_id),
+ is_own_job=job.user_id == current_user.id,
type=job.type.value,
status=job.status.value,
created_at=job.created_at,
@@ -141,7 +142,7 @@ async def list_research_jobs(
created_to=created_before,
)
return PaginatedPublicResearchJobs(
- items=[_to_public_research_job(j) for j in rows],
+ items=[_to_public_research_job(j, current_user) for j in rows],
total=total,
limit=limit,
offset=offset,
@@ -257,4 +258,4 @@ async def get_research_job(
job = await repo.get_job_global(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found.")
- return _to_public_research_job(job)
+ return _to_public_research_job(job, current_user)
diff --git a/backend/app/api/routers/ws.py b/backend/app/api/routers/ws.py
index 753eee2..6a2e177 100644
--- a/backend/app/api/routers/ws.py
+++ b/backend/app/api/routers/ws.py
@@ -1,5 +1,6 @@
import json
import uuid
+from datetime import datetime, timezone
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
@@ -57,6 +58,45 @@ async def _user_has_container_access(user_id: uuid.UUID, container_id: str) -> b
return False
+async def _get_container_last_logs(
+ user_id: uuid.UUID,
+ container_id: str,
+) -> tuple[list[str], str | None]:
+ """Return cached ``last_logs`` and ``job_id`` from deploy state for a container."""
+ pattern = f"deploy:{user_id}:*"
+ cursor = 0
+ while True:
+ cursor, keys = await redis_client.scan(cursor=cursor, match=pattern, count=100)
+ for key in keys:
+ raw_state = await redis_client.get(key)
+ if not raw_state:
+ continue
+ try:
+ state = json.loads(raw_state)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(state, dict):
+ continue
+ job_id: str | None = None
+ key_str = key if isinstance(key, str) else key.decode("utf-8")
+ parts = key_str.split(":")
+ if len(parts) >= 3:
+ job_id = parts[2]
+ containers = state.get("containers")
+ if not isinstance(containers, list):
+ continue
+ for row in containers:
+ if not isinstance(row, dict) or row.get("id") != container_id:
+ continue
+ raw_logs = row.get("last_logs")
+ if isinstance(raw_logs, list):
+ lines = [line for line in raw_logs if isinstance(line, str) and line]
+ return lines, job_id
+ if cursor == 0:
+ break
+ return [], None
+
+
@router.websocket("/ws/jobs/{job_id}")
async def job_updates(
websocket: WebSocket,
@@ -141,6 +181,58 @@ async def user_container_metrics(
return
+@router.websocket("/ws/users/{user_id}/containers/{container_id}/logs")
+async def user_container_logs(
+ websocket: WebSocket,
+ user_id: str,
+ container_id: str,
+ token: str = Query(...),
+) -> None:
+ await websocket.accept()
+ try:
+ payload = decode_token(token)
+ if payload.get("type") != "access":
+ raise ValueError("wrong token type")
+ if payload.get("sub") != user_id:
+ raise ValueError("user mismatch")
+ user_uuid = uuid.UUID(user_id)
+ except (KeyError, TypeError, ValueError):
+ await websocket.send_json({"error": "invalid token"})
+ await websocket.close(code=4401)
+ return
+
+ if not await _user_has_container_access(user_uuid, container_id):
+ await websocket.send_json({"error": "forbidden"})
+ await websocket.close(code=4403)
+ return
+
+ cached_logs, cached_job_id = await _get_container_last_logs(user_uuid, container_id)
+ now_iso = datetime.now(timezone.utc).isoformat()
+ for line in cached_logs:
+ await websocket.send_text(
+ json.dumps(
+ {
+ "event_name": "container.log",
+ "user_id": user_id,
+ "job_id": cached_job_id,
+ "payload": {
+ "container_id": container_id,
+ "line": line,
+ "stream": "stdout",
+ "timestamp": None,
+ },
+ "timestamp": now_iso,
+ }
+ )
+ )
+
+ try:
+ async for event in subscribe(f"user:{user_id}:container:{container_id}:logs"):
+ await websocket.send_text(json.dumps(event))
+ except WebSocketDisconnect:
+ return
+
+
@router.websocket("/ws/users/{user_id}/events")
async def user_events(
websocket: WebSocket,
diff --git a/backend/app/application/schemas.py b/backend/app/application/schemas.py
index cd3ee00..14dbd7d 100644
--- a/backend/app/application/schemas.py
+++ b/backend/app/application/schemas.py
@@ -32,18 +32,6 @@ class RefreshRequest(BaseModel):
refresh_token: str
-class ApiKeyRead(BaseModel):
- id: uuid.UUID
- key_prefix: str
- created_at: datetime
-
-
-class ApiKeyCreateResponse(BaseModel):
- id: uuid.UUID
- key: str
- key_prefix: str
-
-
class AnalysisEnqueueResponse(BaseModel):
job_id: uuid.UUID
status: str
@@ -133,6 +121,7 @@ class PublicResearchJobRead(BaseModel):
id: uuid.UUID
anonymized_submitter: str
+ is_own_job: bool
type: str
status: str
created_at: datetime
@@ -245,6 +234,16 @@ class ImageBuildResult(BaseModel):
error_message: str | None = None
+class ContainerPortBinding(BaseModel):
+ host_ip: str | None = None
+ host_port: str | None = None
+
+
+class ContainerPortMapping(BaseModel):
+ container_port: str | None = None
+ host_bindings: list[ContainerPortBinding] = []
+
+
class ContainerStateInfo(BaseModel):
id: str
name: str | None = None
@@ -258,4 +257,6 @@ class ContainerStateInfo(BaseModel):
finished_at: str | None = None
restart_count: int | None = None
oom_killed: bool | None = None
+ ip_address: str | None = None
+ ports: list[ContainerPortMapping] = []
last_logs: list[str] = []
diff --git a/backend/app/application/services/build_selection.py b/backend/app/application/services/build_selection.py
new file mode 100644
index 0000000..7d65b6b
--- /dev/null
+++ b/backend/app/application/services/build_selection.py
@@ -0,0 +1,96 @@
+"""Select primary compose files and Dockerfiles to auto-build for project jobs."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from app.application.services.compose_mapper import map_compose_services
+
+# Auxiliary paths — still analyzed, but skipped for automatic image builds.
+_BUILD_SKIP_DIR_SEGMENTS: frozenset[str] = frozenset({
+ ".devcontainer",
+ "RELEASING",
+ "releasing",
+ "scripts",
+ "test",
+ "tests",
+ "docs",
+})
+
+
+def compose_file_sort_key(path: str) -> tuple[int, int, str]:
+ """Prefer root docker-compose.yml over variants like docker-compose-light.yml."""
+ normalized = path.replace("\\", "/")
+ name = Path(normalized).name.lower()
+ depth = normalized.count("/")
+ if name in {"docker-compose.yml", "compose.yml"}:
+ return (0, depth, normalized)
+ if name in {"docker-compose.yaml", "compose.yaml"}:
+ return (1, depth, normalized)
+ if name.startswith("docker-compose") and all(
+ token not in name for token in ("light", "non-dev", "image-tag", "override", "prod", "dev")
+ ):
+ return (2, depth, normalized)
+ if name.startswith("docker-compose") or name.startswith("compose"):
+ return (3, depth, normalized)
+ return (4, depth, normalized)
+
+
+def _is_auxiliary_dockerfile(path: str) -> bool:
+ normalized = path.replace("\\", "/")
+ if any(part in _BUILD_SKIP_DIR_SEGMENTS for part in Path(normalized).parts):
+ return True
+ return Path(normalized).name != "Dockerfile"
+
+
+def _root_dockerfile(dockerfiles: list[str]) -> str | None:
+ candidates = [p for p in dockerfiles if Path(p.replace("\\", "/")).name == "Dockerfile"]
+ if not candidates:
+ return None
+ candidates.sort(key=lambda p: (p.count("/"), p))
+ return candidates[0]
+
+
+def select_dockerfiles_for_build(
+ dockerfiles: list[str],
+ project_path: Path,
+ *,
+ primary_compose_file: str | None = None,
+ max_builds: int = 5,
+) -> list[str]:
+ """Return Dockerfiles to auto-build, prioritizing primary compose service builds."""
+ if not dockerfiles or max_builds <= 0:
+ return []
+
+ selected: list[str] = []
+ seen: set[str] = set()
+
+ def add(path: str | None) -> None:
+ if not path or path in seen or path not in dockerfiles:
+ return
+ seen.add(path)
+ selected.append(path)
+
+ if primary_compose_file:
+ for mapping in map_compose_services(primary_compose_file, project_path):
+ resolved = mapping.get("resolved_dockerfile")
+ if mapping.get("can_build") and isinstance(resolved, str):
+ add(resolved)
+ if len(selected) >= max_builds:
+ return selected[:max_builds]
+
+ root = _root_dockerfile(dockerfiles)
+ if root:
+ add(root)
+
+ for df in sorted(dockerfiles, key=lambda p: (p.count("/"), p)):
+ if _is_auxiliary_dockerfile(df):
+ continue
+ add(df)
+ if len(selected) >= max_builds:
+ break
+
+ if not selected and root:
+ add(root)
+
+ return selected[:max_builds]
diff --git a/backend/app/application/services/compose_mapper.py b/backend/app/application/services/compose_mapper.py
index 3da63c8..3a69a5b 100644
--- a/backend/app/application/services/compose_mapper.py
+++ b/backend/app/application/services/compose_mapper.py
@@ -10,6 +10,20 @@
from app.application.services.bind_mounts import validate_bind_mounts
+def _normalize_env_file_ref(entry: Any) -> tuple[str | None, bool]:
+ """Return project-relative env file path and whether the reference is required."""
+ if isinstance(entry, str):
+ path = entry.strip()
+ return (path or None, True)
+ if isinstance(entry, dict):
+ raw_path = entry.get("path")
+ required = entry.get("required", True)
+ if isinstance(raw_path, str):
+ path = raw_path.strip()
+ return (path or None, bool(required))
+ return (None, True)
+
+
def map_compose_services(
compose_file_rel: str,
project_root: Path,
@@ -104,9 +118,13 @@ def map_compose_services(
if isinstance(env_files, str):
env_files = [env_files]
for ef in env_files:
- ef_path = (project_root / ef).resolve()
- if not ef_path.exists():
- issues.append(f"env_file '{ef}' does not exist in project.")
+ ef_ref, required = _normalize_env_file_ref(ef)
+ if not ef_ref:
+ issues.append("env_file entry is invalid or missing a path.")
+ continue
+ ef_path = (project_root / ef_ref).resolve()
+ if not ef_path.exists() and required:
+ issues.append(f"env_file '{ef_ref}' does not exist in project.")
issues.extend(validate_bind_mounts(str(service_name), service_def, project_root))
diff --git a/backend/app/application/services/job_cleanup.py b/backend/app/application/services/job_cleanup.py
new file mode 100644
index 0000000..a9c530c
--- /dev/null
+++ b/backend/app/application/services/job_cleanup.py
@@ -0,0 +1,187 @@
+"""Remove on-disk and queued artifacts tied to an analysis job."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import shutil
+import uuid
+from pathlib import Path
+from typing import Any
+
+from app.core.config import get_settings
+from app.infrastructure.events.bus import redis_client
+from app.workers.queue import enqueue_job
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+def deploy_state_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str:
+ return f"deploy:{user_id}:{job_id}"
+
+
+def _user_upload_root(user_id: uuid.UUID) -> Path:
+ return (Path(settings.upload_dir) / str(user_id)).resolve()
+
+
+def _is_under_root(path: Path, root: Path) -> bool:
+ try:
+ path.resolve().relative_to(root.resolve())
+ return True
+ except ValueError:
+ return False
+
+
+def collect_image_tags(result: dict[str, Any] | None) -> list[str]:
+ if not isinstance(result, dict):
+ return []
+ builds = result.get("image_build_results")
+ if not isinstance(builds, list):
+ return []
+ tags: list[str] = []
+ seen: set[str] = set()
+ for row in builds:
+ if not isinstance(row, dict):
+ continue
+ if row.get("status") != "success":
+ continue
+ tag = row.get("image_tag")
+ if isinstance(tag, str) and tag.strip() and tag not in seen:
+ seen.add(tag)
+ tags.append(tag.strip())
+ return tags
+
+
+def collect_runtime_image_refs(
+ deploy_state: dict[str, Any] | None,
+ result: dict[str, Any] | None,
+) -> list[str]:
+ """Merge analysis-built tags and images recorded in deploy state."""
+ seen: set[str] = set(collect_image_tags(result))
+ refs = list(seen)
+ if not isinstance(deploy_state, dict):
+ return refs
+ for row in deploy_state.get("containers") or []:
+ if not isinstance(row, dict):
+ continue
+ image = row.get("image")
+ if isinstance(image, str) and image.strip() and image not in seen:
+ seen.add(image)
+ refs.append(image.strip())
+ return refs
+
+
+def deploy_spec_from_state(state: dict[str, Any]) -> dict[str, Any] | None:
+ project_name = state.get("project_name")
+ project_dir = state.get("project_dir")
+ compose_file = state.get("compose_file")
+ if not all(isinstance(v, str) and v.strip() for v in (project_name, project_dir, compose_file)):
+ return None
+ spec: dict[str, Any] = {
+ "project_name": project_name,
+ "project_dir": project_dir,
+ "compose_file": compose_file,
+ }
+ reused = state.get("reused_service_names")
+ if isinstance(reused, list):
+ spec["reused_service_names"] = reused
+ return spec
+
+
+async def _load_deploy_state(user_id: uuid.UUID, job_id: uuid.UUID) -> dict[str, Any] | None:
+ raw = await redis_client.get(deploy_state_redis_key(user_id, job_id))
+ if not raw:
+ return None
+ try:
+ parsed = json.loads(raw)
+ return parsed if isinstance(parsed, dict) else None
+ except json.JSONDecodeError:
+ return None
+
+
+def _safe_project_path(user_id: uuid.UUID, input_metadata: dict[str, Any]) -> Path | None:
+ raw = input_metadata.get("project_path")
+ if not isinstance(raw, str) or not raw.strip():
+ return None
+ candidate = Path(raw).resolve()
+ upload_root = _user_upload_root(user_id)
+ if not _is_under_root(candidate, upload_root):
+ logger.warning(
+ "job_cleanup_skip_project_path",
+ extra={"user_id": str(user_id), "project_path": raw},
+ )
+ return None
+ return candidate
+
+
+def _deployment_dir(user_id: uuid.UUID, job_id: uuid.UUID) -> Path:
+ return (Path(settings.upload_dir) / "deployments" / str(user_id) / str(job_id)).resolve()
+
+
+def _rmtree_sync(path: Path) -> None:
+ if path.is_dir():
+ shutil.rmtree(path, ignore_errors=True)
+ elif path.exists():
+ path.unlink(missing_ok=True)
+
+
+async def _remove_path(path: Path) -> None:
+ await asyncio.to_thread(_rmtree_sync, path)
+
+
+async def cleanup_job_artifacts(
+ *,
+ user_id: uuid.UUID,
+ job_id: uuid.UUID,
+ input_metadata: dict[str, Any],
+ result: dict[str, Any] | None,
+) -> None:
+ """Best-effort cleanup of filesystem artifacts and DinD runtime for a job."""
+ metadata = input_metadata if isinstance(input_metadata, dict) else {}
+ deploy_state = await _load_deploy_state(user_id, job_id)
+
+ project_dir = _safe_project_path(user_id, metadata)
+ if project_dir is not None:
+ try:
+ await _remove_path(project_dir)
+ except Exception:
+ logger.warning(
+ "job_cleanup_project_path_failed",
+ extra={"user_id": str(user_id), "job_id": str(job_id), "path": str(project_dir)},
+ exc_info=True,
+ )
+
+ deploy_dir = _deployment_dir(user_id, job_id)
+ upload_root = Path(settings.upload_dir).resolve()
+ if _is_under_root(deploy_dir, upload_root):
+ try:
+ await _remove_path(deploy_dir)
+ except Exception:
+ logger.warning(
+ "job_cleanup_deployment_dir_failed",
+ extra={"user_id": str(user_id), "job_id": str(job_id), "path": str(deploy_dir)},
+ exc_info=True,
+ )
+
+ image_tags = collect_runtime_image_refs(deploy_state, result)
+ deploy_spec = deploy_spec_from_state(deploy_state) if deploy_state else None
+ if deploy_spec or image_tags:
+ payload: dict[str, Any] = {
+ "user_id": str(user_id),
+ "job_id": str(job_id),
+ "remove_images": True,
+ "remove_volumes": False,
+ "image_tags": image_tags,
+ }
+ if deploy_spec:
+ payload["deploy_spec"] = deploy_spec
+ try:
+ await enqueue_job("teardown_job_runtime", payload)
+ except Exception:
+ logger.warning(
+ "job_cleanup_enqueue_runtime_failed",
+ extra={"user_id": str(user_id), "job_id": str(job_id)},
+ exc_info=True,
+ )
diff --git a/backend/app/application/services/project_scanner.py b/backend/app/application/services/project_scanner.py
index 73b75dd..80dceae 100644
--- a/backend/app/application/services/project_scanner.py
+++ b/backend/app/application/services/project_scanner.py
@@ -8,6 +8,8 @@
import yaml
+from app.application.services.build_selection import compose_file_sort_key
+
_IGNORED_DIRS: frozenset[str] = frozenset(
{
"node_modules",
@@ -40,7 +42,7 @@
)
# ZIP bomb limits
-_MAX_ENTRIES = 10_000
+_MAX_ENTRIES = 15_000
_MAX_TOTAL_BYTES = 500 * 1024 * 1024 # 500 MB
_MAX_SINGLE_BYTES = 100 * 1024 * 1024 # 100 MB per file
_MAX_COMPRESSION_RATIO = 100 # decompressed / compressed
@@ -324,9 +326,9 @@ def rel(p: Path) -> str:
elif path.name in {".env.example", ".env.sample", ".env.template"}:
env_examples.append(rel(path))
- # Sort for determinism; shortest path first = root-level files preferred
+ # Sort for determinism; prefer canonical root compose files over variants.
dockerfiles.sort(key=lambda p: (p.count("/"), p))
- compose_files.sort(key=lambda p: (p.count("/"), p))
+ compose_files.sort(key=compose_file_sort_key)
# Detect stacks from all non-ignored files
stacks = _detect_stacks(extract_root, all_paths)
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index f9cb855..84ba27e 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -23,9 +23,13 @@ class Settings(BaseSettings):
refresh_token_expire_minutes: int = 60 * 24 * 7
upload_dir: str = "storage/uploads"
- max_upload_size_mb: int = 30
+ max_upload_size_mb: int = 200
project_draft_ttl_seconds: int = 3600
+ project_job_timeout_seconds: int = 7200
+ image_build_timeout_seconds: int = 3600
+ max_image_builds: int = 3
+
@lru_cache
def get_settings() -> Settings:
diff --git a/backend/app/infrastructure/db/models.py b/backend/app/infrastructure/db/models.py
index de79b14..d94896b 100644
--- a/backend/app/infrastructure/db/models.py
+++ b/backend/app/infrastructure/db/models.py
@@ -50,13 +50,3 @@ class AnalysisJobModel(Base):
user: Mapped[UserModel] = relationship(back_populates="jobs")
-class ApiKeyModel(Base):
- __tablename__ = "api_keys"
-
- id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True)
- key_prefix: Mapped[str] = mapped_column(String(32), index=True)
- key_hash: Mapped[str] = mapped_column(String(255))
- last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
diff --git a/backend/app/infrastructure/db/repositories.py b/backend/app/infrastructure/db/repositories.py
index 0207218..ac3664f 100644
--- a/backend/app/infrastructure/db/repositories.py
+++ b/backend/app/infrastructure/db/repositories.py
@@ -1,7 +1,5 @@
-import hashlib
-import secrets
import uuid
-from datetime import UTC, datetime
+from datetime import datetime
from sqlalchemy import Date, Numeric, Select, and_, cast, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,7 +7,6 @@
from app.core.security import hash_password, verify_password
from app.infrastructure.db.models import (
AnalysisJobModel,
- ApiKeyModel,
JobStatus,
JobType,
UserModel,
@@ -40,46 +37,6 @@ async def authenticate(self, email: str, password: str) -> UserModel | None:
return user if verify_password(password, user.hashed_password) else None
-class ApiKeyRepository:
- def __init__(self, session: AsyncSession) -> None:
- self.session = session
-
- async def create_key(self, user_id: uuid.UUID) -> tuple[ApiKeyModel, str]:
- raw = f"dpa_{secrets.token_urlsafe(32)}"
- key_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
- record = ApiKeyModel(user_id=user_id, key_prefix=raw[:12], key_hash=key_hash)
- self.session.add(record)
- await self.session.flush()
- return record, raw
-
- async def list_keys(self, user_id: uuid.UUID) -> list[ApiKeyModel]:
- stmt = (
- select(ApiKeyModel)
- .where(ApiKeyModel.user_id == user_id)
- .where(ApiKeyModel.revoked_at.is_(None))
- .order_by(ApiKeyModel.created_at.desc())
- )
- rows = await self.session.scalars(stmt)
- return list(rows)
-
- async def get_by_raw_key(self, raw_key: str) -> ApiKeyModel | None:
- key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
- stmt = (
- select(ApiKeyModel)
- .where(ApiKeyModel.key_hash == key_hash)
- .where(ApiKeyModel.revoked_at.is_(None))
- )
- return await self.session.scalar(stmt)
-
- async def revoke(self, user_id: uuid.UUID, key_id: uuid.UUID) -> bool:
- key = await self.session.get(ApiKeyModel, key_id)
- if not key or key.user_id != user_id or key.revoked_at is not None:
- return False
- key.revoked_at = datetime.now(UTC)
- await self.session.flush()
- return True
-
-
class JobRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
diff --git a/backend/app/infrastructure/docker/client.py b/backend/app/infrastructure/docker/client.py
index ec23774..2f81304 100644
--- a/backend/app/infrastructure/docker/client.py
+++ b/backend/app/infrastructure/docker/client.py
@@ -1,4 +1,7 @@
import asyncio
+import threading
+from collections.abc import AsyncIterator
+from datetime import datetime, timezone
from typing import Any
import docker
@@ -52,11 +55,16 @@ async def build_image(
dockerfile: str,
tag: str,
buildargs: dict[str, str] | None = None,
+ timeout: int | None = None,
) -> tuple[Any, list[str]]:
"""Build a Docker image and return (image, log_lines)."""
- image, log_gen = await asyncio.to_thread(
+ build_coro = asyncio.to_thread(
self._build_image_sync, path, dockerfile, tag, buildargs or {}
)
+ if timeout and timeout > 0:
+ image, log_gen = await asyncio.wait_for(build_coro, timeout=timeout)
+ else:
+ image, log_gen = await build_coro
log_lines: list[str] = []
for chunk in log_gen:
if isinstance(chunk, dict):
@@ -85,6 +93,17 @@ def _build_image_sync(
async def inspect_image(self, image_id_or_tag: str) -> dict[str, Any]:
return await asyncio.to_thread(self._inspect_image_sync, image_id_or_tag)
+ async def remove_image(self, tag: str) -> bool:
+ """Remove an image by tag or id. Returns True if removed, False if not found."""
+ return await asyncio.to_thread(self._remove_image_sync, tag)
+
+ def _remove_image_sync(self, tag: str) -> bool:
+ try:
+ self.client.images.remove(tag, force=True)
+ return True
+ except docker.errors.ImageNotFound:
+ return False
+
def _inspect_image_sync(self, image_id_or_tag: str) -> dict[str, Any]:
img = self.client.images.get(image_id_or_tag)
attrs = img.attrs or {}
@@ -120,6 +139,31 @@ def _inspect_image_sync(self, image_id_or_tag: str) -> dict[str, Any]:
"workdir": cfg.get("WorkingDir") or None,
}
+ @staticmethod
+ def _decode_log_bytes(raw_logs: bytes | str) -> list[str]:
+ if isinstance(raw_logs, bytes):
+ text = raw_logs.decode("utf-8", errors="ignore")
+ else:
+ text = raw_logs
+ return [line for line in text.splitlines() if line]
+
+ async def tail_container_logs(self, container_id: str, *, tail: int = 200) -> list[str]:
+ """Return the last ``tail`` log lines (non-streaming snapshot)."""
+ return await asyncio.to_thread(self._tail_container_logs_sync, container_id, tail)
+
+ def _tail_container_logs_sync(self, container_id: str, tail: int) -> list[str]:
+ try:
+ container = self.client.containers.get(container_id)
+ except docker.errors.NotFound:
+ return []
+ try:
+ raw_logs = container.logs(tail=tail, stream=False)
+ if isinstance(raw_logs, bytes):
+ return self._decode_log_bytes(raw_logs)
+ except Exception:
+ pass
+ return []
+
# ── Container final state (for exited containers) ─────────────────────────
async def inspect_container_final_state(self, container_id: str) -> dict[str, Any]:
@@ -135,13 +179,7 @@ def _inspect_final_sync(self, container_id: str) -> dict[str, Any]:
state = attrs.get("State") or {}
cfg = attrs.get("Config") or {}
- last_logs: list[str] = []
- try:
- raw_logs = container.logs(tail=50, stream=False)
- if isinstance(raw_logs, bytes):
- last_logs = [l for l in raw_logs.decode("utf-8", errors="ignore").splitlines() if l]
- except Exception:
- pass
+ last_logs = self._tail_container_logs_sync(container_id, 50)
return {
"container_id": container_id,
@@ -171,6 +209,145 @@ def _inspect_container_state_sync(self, container_id: str) -> dict[str, Any]:
status = str(state.get("Status") or "").lower()
return {"container_id": container_id, "status": status}
+ # ── Container runtime info (no stats; for deploy-state enrichment) ─────────
+
+ async def inspect_container_runtime(self, container_id: str) -> dict[str, Any]:
+ """Lightweight inspect (no stats) for deploy-state enrichment: name, service, ports, health."""
+ return await asyncio.to_thread(self._inspect_container_runtime_sync, container_id)
+
+ def _inspect_container_runtime_sync(self, container_id: str) -> dict[str, Any]:
+ try:
+ container = self.client.containers.get(container_id)
+ except docker.errors.NotFound:
+ return {"id": container_id, "status": "not_found"}
+ attrs = container.attrs or {}
+ state = attrs.get("State") or {}
+ cfg = attrs.get("Config") or {}
+ labels = cfg.get("Labels") or {}
+ return {
+ "id": container_id,
+ "name": str(attrs.get("Name", "")).lstrip("/") or None,
+ "service": labels.get("com.docker.compose.service"),
+ "image": str(cfg.get("Image", "")) or None,
+ "status": state.get("Status"),
+ "health_status": (state.get("Health") or {}).get("Status"),
+ "restart_count": int(attrs.get("RestartCount") or 0),
+ "ip_address": self._extract_ip(attrs),
+ "ports": self._extract_ports(attrs),
+ }
+
+ # ── Container log streaming ───────────────────────────────────────────────
+
+ @staticmethod
+ def _split_log_timestamp(text: str) -> tuple[str | None, str]:
+ """Split a ``docker logs --timestamps`` line into (timestamp, message)."""
+ if " " not in text:
+ return None, text
+ candidate, _, rest = text.partition(" ")
+ # RFC3339 timestamps from the Docker daemon look like 2024-01-02T03:04:05.123456789Z
+ if "T" in candidate and (candidate.endswith("Z") or "+" in candidate or candidate.count(":") >= 2):
+ normalized = candidate.replace("Z", "+00:00")
+ try:
+ datetime.fromisoformat(normalized)
+ return candidate, rest
+ except ValueError:
+ return None, text
+ return None, text
+
+ async def follow_container_logs(
+ self,
+ container_id: str,
+ *,
+ tail: int = 200,
+ max_buffer: int = 2000,
+ ) -> AsyncIterator[dict[str, Any]]:
+ """Yield ``{stream, line, timestamp}`` dicts, tailing then following container logs.
+
+ The synchronous Docker SDK log generator is consumed in a background thread and
+ pushed onto a bounded asyncio queue so the event loop never blocks. The stream ends
+ naturally when the container stops or is removed (EOF).
+ """
+ loop = asyncio.get_running_loop()
+ queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=max_buffer)
+ sentinel = object()
+
+ def _put(item: Any) -> None:
+ try:
+ queue.put_nowait(item)
+ except asyncio.QueueFull:
+ # Drop oldest to keep memory bounded under log floods.
+ try:
+ queue.get_nowait()
+ except asyncio.QueueEmpty:
+ pass
+ try:
+ queue.put_nowait(item)
+ except asyncio.QueueFull:
+ pass
+
+ def _reader() -> None:
+ try:
+ container = self.client.containers.get(container_id)
+ # docker-py Container.logs() does not accept demux= (only attach() does).
+ # Streamed logs arrive as multiplexed payload bytes without stream ids.
+ stream = container.logs(
+ stream=True,
+ follow=True,
+ timestamps=True,
+ tail=tail,
+ stdout=True,
+ stderr=True,
+ )
+ for chunk in stream:
+ if isinstance(chunk, tuple):
+ # attach()-style demux tuples if a future caller passes them through
+ chunks = (("stdout", chunk[0]), ("stderr", chunk[1]))
+ elif isinstance(chunk, bytes):
+ chunks = (("stdout", chunk),)
+ else:
+ chunks = (("stdout", str(chunk).encode()),)
+ for source, raw in chunks:
+ if not raw:
+ continue
+ decoded = raw.decode("utf-8", errors="ignore")
+ for line in decoded.splitlines():
+ if not line:
+ continue
+ timestamp, message = DockerGateway._split_log_timestamp(line)
+ loop.call_soon_threadsafe(
+ _put,
+ {"stream": source, "line": message, "timestamp": timestamp},
+ )
+ except docker.errors.NotFound:
+ loop.call_soon_threadsafe(
+ _put,
+ {
+ "stream": "system",
+ "line": f"Container {container_id[:12]} not found.",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ },
+ )
+ except Exception as exc: # noqa: BLE001 - surface as a system log line
+ loop.call_soon_threadsafe(
+ _put,
+ {
+ "stream": "system",
+ "line": f"Log stream error: {exc}",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ },
+ )
+ finally:
+ loop.call_soon_threadsafe(_put, sentinel)
+
+ thread = threading.Thread(target=_reader, name=f"logs-{container_id[:12]}", daemon=True)
+ thread.start()
+
+ while True:
+ item = await queue.get()
+ if item is sentinel:
+ break
+ yield item
+
# ── Container metrics ─────────────────────────────────────────────────────
async def inspect_container_metrics(self, container_id: str) -> dict[str, Any]:
diff --git a/backend/app/infrastructure/events/bus.py b/backend/app/infrastructure/events/bus.py
index 8a6c63c..e358d6f 100644
--- a/backend/app/infrastructure/events/bus.py
+++ b/backend/app/infrastructure/events/bus.py
@@ -14,15 +14,28 @@
async def publish_event(event: DomainEvent) -> None:
payload = asdict(event)
- await redis_client.publish(f"user:{event.user_id}:events", json.dumps(payload))
+ data = json.dumps(payload)
+
+ # Container log lines are high-volume; keep them isolated on a dedicated
+ # user-scoped logs channel so they never flood the user/job event streams.
+ if event.event_name == "container.log":
+ container_id = event.payload.get("container_id")
+ if container_id:
+ await redis_client.publish(
+ f"user:{event.user_id}:container:{container_id}:logs",
+ data,
+ )
+ return
+
+ await redis_client.publish(f"user:{event.user_id}:events", data)
if event.job_id:
- await redis_client.publish(f"job:{event.job_id}:events", json.dumps(payload))
+ await redis_client.publish(f"job:{event.job_id}:events", data)
container_id = event.payload.get("container_id")
if container_id:
- await redis_client.publish(f"container:{container_id}:metrics", json.dumps(payload))
+ await redis_client.publish(f"container:{container_id}:metrics", data)
await redis_client.publish(
f"user:{event.user_id}:container:{container_id}:metrics",
- json.dumps(payload),
+ data,
)
diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py
index a31ab81..8d6d194 100644
--- a/backend/app/workers/tasks.py
+++ b/backend/app/workers/tasks.py
@@ -1,6 +1,7 @@
import asyncio
import hashlib
import json
+import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -12,6 +13,7 @@
from app.core.config import get_settings
from app.application.services.analysis_service import AnalysisService, _grade
+from app.application.services.build_selection import select_dockerfiles_for_build
from app.application.services.compose_mapper import map_compose_services
from app.domain.events import DomainEvent
from app.infrastructure.db.models import JobStatus
@@ -21,8 +23,10 @@
from app.infrastructure.events.bus import publish_event, redis_client
settings = get_settings()
+logger = logging.getLogger(__name__)
DEPLOY_STATE_TTL_SECONDS = 60 * 60 * 6
METRICS_INTERVAL_SECONDS = 2
+DEPLOY_LOG_TAIL_MAX = 200
SOURCE_PREVIEW_MAX_LINES = 300
SOURCE_PREVIEW_MAX_BYTES = 50 * 1024
@@ -61,6 +65,39 @@ def truncate_source_preview(content: str, max_lines: int, max_bytes: int) -> str
return blob[:max_bytes].decode("utf-8", errors="ignore")
+async def _mark_project_analysis_failed(
+ user_id: uuid.UUID,
+ job_id: uuid.UUID,
+ message: str,
+) -> dict[str, Any]:
+ fail_result: dict[str, Any] = {
+ "message": message,
+ "overall_score": 0,
+ "overall_grade": "F",
+ "per_file_results": [],
+ "project_summary": {
+ "total_files_analyzed": 0,
+ "dockerfiles_analyzed": 0,
+ "compose_files_analyzed": 0,
+ "total_errors": 0,
+ "total_warnings": 0,
+ "total_security_issues": 0,
+ "total_suggestions": 0,
+ },
+ }
+ async with SessionLocal() as fail_session:
+ fail_repo = JobRepository(fail_session)
+ await fail_repo.update_status(job_id, user_id, JobStatus.failed, result=fail_result)
+ await fail_session.commit()
+ await publish_event(
+ DomainEvent("project.analysis_failed", str(user_id), str(job_id), payload=fail_result)
+ )
+ await publish_event(
+ DomainEvent("user.analysis.failed", str(user_id), str(job_id), payload={"message": message})
+ )
+ return fail_result
+
+
async def run_dockerfile_analysis(ctx, payload: dict) -> dict:
async with SessionLocal() as session:
return await _run_with_payload(session, payload, ["hadolint", "security_scanner", "resource_estimation"], job_type="dockerfile")
@@ -123,271 +160,302 @@ async def run_project_analysis(ctx, payload: dict) -> dict:
await publish_event(DomainEvent("project.analysis_failed", str(user_id), str(job_id), payload=result))
return result
- per_file_results: list[dict[str, Any]] = []
- all_scores: list[int] = []
+ try:
+ per_file_results: list[dict[str, Any]] = []
+ all_scores: list[int] = []
- # Analyze each Dockerfile separately
- for df_rel in dockerfiles:
- df_path = project_path / df_rel
- await publish_event(
- DomainEvent("project.file_analysis_started", str(user_id), str(job_id), payload={"file": df_rel, "type": "dockerfile"})
- )
- try:
- content = df_path.read_text(encoding="utf-8", errors="ignore")
- source_preview = truncate_source_preview(
- content,
- SOURCE_PREVIEW_MAX_LINES,
- SOURCE_PREVIEW_MAX_BYTES,
- )
- file_result = await svc.analyze_content(
- content,
- content_type="dockerfile",
- context_extras={"project_path": str(project_path)},
+ # Analyze each Dockerfile separately
+ for df_rel in dockerfiles:
+ df_path = project_path / df_rel
+ await publish_event(
+ DomainEvent("project.file_analysis_started", str(user_id), str(job_id), payload={"file": df_rel, "type": "dockerfile"})
)
- file_entry: dict[str, Any] = {
- "file_path": df_rel,
- "file_type": "dockerfile",
- "source_preview": source_preview,
- **file_result,
- }
- per_file_results.append(file_entry)
- all_scores.append(file_result["score"])
- except Exception as exc:
- per_file_results.append(
- {
+ try:
+ content = df_path.read_text(encoding="utf-8", errors="ignore")
+ source_preview = truncate_source_preview(
+ content,
+ SOURCE_PREVIEW_MAX_LINES,
+ SOURCE_PREVIEW_MAX_BYTES,
+ )
+ file_result = await svc.analyze_content(
+ content,
+ content_type="dockerfile",
+ context_extras={"project_path": str(project_path)},
+ )
+ file_entry: dict[str, Any] = {
"file_path": df_rel,
"file_type": "dockerfile",
- "score": 0,
- "grade": "F",
- "errors_count": 1,
- "warnings_count": 0,
- "security_count": 0,
- "suggestions_count": 0,
- "errors": [{"code": "SCAN_ERROR", "severity": "error", "message": str(exc), "line": 1, "suggestion": ""}],
- "warnings": [],
- "securityIssues": [],
- "suggestions": [],
- "meta": {},
+ "source_preview": source_preview,
+ **file_result,
}
+ per_file_results.append(file_entry)
+ all_scores.append(file_result["score"])
+ except Exception as exc:
+ per_file_results.append(
+ {
+ "file_path": df_rel,
+ "file_type": "dockerfile",
+ "score": 0,
+ "grade": "F",
+ "errors_count": 1,
+ "warnings_count": 0,
+ "security_count": 0,
+ "suggestions_count": 0,
+ "errors": [{"code": "SCAN_ERROR", "severity": "error", "message": str(exc), "line": 1, "suggestion": ""}],
+ "warnings": [],
+ "securityIssues": [],
+ "suggestions": [],
+ "meta": {},
+ }
+ )
+ all_scores.append(0)
+ await publish_event(
+ DomainEvent("project.file_analysis_completed", str(user_id), str(job_id), payload={"file": df_rel, "type": "dockerfile"})
)
- all_scores.append(0)
- await publish_event(
- DomainEvent("project.file_analysis_completed", str(user_id), str(job_id), payload={"file": df_rel, "type": "dockerfile"})
- )
- # Analyze each Compose file separately
- for cf_rel in compose_files:
- cf_path = project_path / cf_rel
- await publish_event(
- DomainEvent("project.file_analysis_started", str(user_id), str(job_id), payload={"file": cf_rel, "type": "compose"})
- )
- try:
- content = cf_path.read_text(encoding="utf-8", errors="ignore")
- source_preview = truncate_source_preview(
- content,
- SOURCE_PREVIEW_MAX_LINES,
- SOURCE_PREVIEW_MAX_BYTES,
- )
- file_result = await svc.analyze_content(
- content,
- content_type="compose",
- context_extras={"project_path": str(project_path)},
+ # Analyze each Compose file separately
+ for cf_rel in compose_files:
+ cf_path = project_path / cf_rel
+ await publish_event(
+ DomainEvent("project.file_analysis_started", str(user_id), str(job_id), payload={"file": cf_rel, "type": "compose"})
)
- file_entry = {
- "file_path": cf_rel,
- "file_type": "compose",
- "source_preview": source_preview,
- **file_result,
- }
- per_file_results.append(file_entry)
- all_scores.append(file_result["score"])
- except Exception as exc:
- per_file_results.append(
- {
+ try:
+ content = cf_path.read_text(encoding="utf-8", errors="ignore")
+ source_preview = truncate_source_preview(
+ content,
+ SOURCE_PREVIEW_MAX_LINES,
+ SOURCE_PREVIEW_MAX_BYTES,
+ )
+ file_result = await svc.analyze_content(
+ content,
+ content_type="compose",
+ context_extras={"project_path": str(project_path)},
+ )
+ file_entry = {
"file_path": cf_rel,
"file_type": "compose",
- "score": 0,
- "grade": "F",
- "errors_count": 1,
- "warnings_count": 0,
- "security_count": 0,
- "suggestions_count": 0,
- "errors": [{"code": "SCAN_ERROR", "severity": "error", "message": str(exc), "line": 1, "suggestion": ""}],
- "warnings": [],
- "securityIssues": [],
- "suggestions": [],
- "meta": {},
+ "source_preview": source_preview,
+ **file_result,
}
- )
- all_scores.append(0)
- await publish_event(
- DomainEvent("project.file_analysis_completed", str(user_id), str(job_id), payload={"file": cf_rel, "type": "compose"})
- )
-
- await publish_event(DomainEvent("project.merge_started", str(user_id), str(job_id), payload={}))
-
- # Compose-to-Dockerfile mapping
- service_mappings: list[dict[str, Any]] = []
- primary_compose = payload.get("primary_compose_file") or (compose_files[0] if compose_files else None)
- if primary_compose:
- service_mappings = map_compose_services(primary_compose, project_path)
-
- # Aggregate project-level result
- overall_score = round(sum(all_scores) / len(all_scores)) if all_scores else 0
- overall_grade = _grade(overall_score)
-
- df_results = [r for r in per_file_results if r["file_type"] == "dockerfile"]
- cf_results = [r for r in per_file_results if r["file_type"] == "compose"]
-
- total_errors = sum(r.get("errors_count", len(r.get("errors", []))) for r in per_file_results)
- total_warnings = sum(r.get("warnings_count", len(r.get("warnings", []))) for r in per_file_results)
- total_security = sum(r.get("security_count", len(r.get("securityIssues", []))) for r in per_file_results)
- total_suggestions = sum(r.get("suggestions_count", len(r.get("suggestions", []))) for r in per_file_results)
-
- best_file = max(per_file_results, key=lambda r: r["score"])["file_path"] if per_file_results else None
- worst_file = min(per_file_results, key=lambda r: r["score"])["file_path"] if per_file_results else None
-
- project_recommendations: list[str] = []
- if not dockerfiles:
- project_recommendations.append("No Dockerfile found — add a Dockerfile to enable image builds.")
- if not compose_files:
- project_recommendations.append("No Compose file found — add a docker-compose.yml for multi-service orchestration.")
- if dockerfiles and not any("dockerignore" in p for p in payload.get("dockerfiles", [])):
- project_recommendations.append("Consider adding a .dockerignore to reduce build context size.")
- for mapping in service_mappings:
- for issue in mapping.get("issues", []):
- project_recommendations.append(f"[{mapping['service']}] {issue}")
-
- for r in per_file_results:
- r.setdefault("errors_count", len(r.get("errors", [])))
- r.setdefault("warnings_count", len(r.get("warnings", [])))
- r.setdefault("security_count", len(r.get("securityIssues", [])))
- r.setdefault("suggestions_count", len(r.get("suggestions", [])))
-
- # ── Optional image build phase ────────────────────────────────────────
- image_build_results: list[dict[str, Any]] = []
- if build_selected_images and dockerfiles:
- gateway = DockerGateway()
- job_id_hex = str(job_id).replace("-", "")
- for df_rel in dockerfiles:
- df_path = (project_path / df_rel).resolve()
- df_hash = hashlib.sha256(df_rel.encode()).hexdigest()[:8]
- image_tag = f"dqa-{job_id_hex[:12]}-{df_hash}"
- build_ctx = str(df_path.parent)
- dockerfile_name = df_path.name
- base_image: str | None = None
- try:
- base_image = extract_base_image_from_dockerfile(
- df_path.read_text(encoding="utf-8", errors="ignore")
+ per_file_results.append(file_entry)
+ all_scores.append(file_result["score"])
+ except Exception as exc:
+ per_file_results.append(
+ {
+ "file_path": cf_rel,
+ "file_type": "compose",
+ "score": 0,
+ "grade": "F",
+ "errors_count": 1,
+ "warnings_count": 0,
+ "security_count": 0,
+ "suggestions_count": 0,
+ "errors": [{"code": "SCAN_ERROR", "severity": "error", "message": str(exc), "line": 1, "suggestion": ""}],
+ "warnings": [],
+ "securityIssues": [],
+ "suggestions": [],
+ "meta": {},
+ }
)
- except Exception:
- pass
-
- build_entry: dict[str, Any] = {
- "dockerfile_path": df_rel,
- "build_context": build_ctx,
- "image_tag": image_tag,
- "base_image": base_image,
- "status": "skipped",
- "build_logs": [],
- }
- started_at = datetime.now(timezone.utc).isoformat()
- build_entry["build_started_at"] = started_at
+ all_scores.append(0)
await publish_event(
- DomainEvent(
- "project.image_build_started",
- str(user_id),
- str(job_id),
- payload={"dockerfile_path": df_rel, "image_tag": image_tag},
- )
+ DomainEvent("project.file_analysis_completed", str(user_id), str(job_id), payload={"file": cf_rel, "type": "compose"})
)
- try:
- async def _stream_build_log(line: str) -> None:
- build_entry["build_logs"].append(line)
- await publish_event(
- DomainEvent(
- "project.image_build_log",
- str(user_id),
- str(job_id),
- payload={"dockerfile_path": df_rel, "image_tag": image_tag, "line": line},
- )
- )
- _, log_lines = await gateway.build_image(
- path=build_ctx,
- dockerfile=dockerfile_name,
- tag=image_tag,
- )
- for line in log_lines:
- await _stream_build_log(line)
-
- finished_at = datetime.now(timezone.utc).isoformat()
- started_dt = datetime.fromisoformat(started_at)
- finished_dt = datetime.fromisoformat(finished_at)
- duration_ms = int((finished_dt - started_dt).total_seconds() * 1000)
-
- # Inspect the built image for metadata
- image_meta = await gateway.inspect_image(image_tag)
-
- build_entry.update({
- "status": "success",
- "build_finished_at": finished_at,
- "build_duration_ms": duration_ms,
- **image_meta,
- })
- await publish_event(
- DomainEvent(
- "project.image_build_completed",
- str(user_id),
- str(job_id),
- payload={"dockerfile_path": df_rel, "image_tag": image_tag, "image_id": image_meta.get("image_id")},
+ await publish_event(DomainEvent("project.merge_started", str(user_id), str(job_id), payload={}))
+
+ # Compose-to-Dockerfile mapping
+ service_mappings: list[dict[str, Any]] = []
+ primary_compose = payload.get("primary_compose_file") or (compose_files[0] if compose_files else None)
+ if primary_compose:
+ service_mappings = map_compose_services(primary_compose, project_path)
+
+ # Aggregate project-level result
+ overall_score = round(sum(all_scores) / len(all_scores)) if all_scores else 0
+ overall_grade = _grade(overall_score)
+
+ df_results = [r for r in per_file_results if r["file_type"] == "dockerfile"]
+ cf_results = [r for r in per_file_results if r["file_type"] == "compose"]
+
+ total_errors = sum(r.get("errors_count", len(r.get("errors", []))) for r in per_file_results)
+ total_warnings = sum(r.get("warnings_count", len(r.get("warnings", []))) for r in per_file_results)
+ total_security = sum(r.get("security_count", len(r.get("securityIssues", []))) for r in per_file_results)
+ total_suggestions = sum(r.get("suggestions_count", len(r.get("suggestions", []))) for r in per_file_results)
+
+ best_file = max(per_file_results, key=lambda r: r["score"])["file_path"] if per_file_results else None
+ worst_file = min(per_file_results, key=lambda r: r["score"])["file_path"] if per_file_results else None
+
+ project_recommendations: list[str] = []
+ if not dockerfiles:
+ project_recommendations.append("No Dockerfile found — add a Dockerfile to enable image builds.")
+ if not compose_files:
+ project_recommendations.append("No Compose file found — add a docker-compose.yml for multi-service orchestration.")
+ if dockerfiles and not any("dockerignore" in p for p in payload.get("dockerfiles", [])):
+ project_recommendations.append("Consider adding a .dockerignore to reduce build context size.")
+ for mapping in service_mappings:
+ for issue in mapping.get("issues", []):
+ project_recommendations.append(f"[{mapping['service']}] {issue}")
+
+ for r in per_file_results:
+ r.setdefault("errors_count", len(r.get("errors", [])))
+ r.setdefault("warnings_count", len(r.get("warnings", [])))
+ r.setdefault("security_count", len(r.get("securityIssues", [])))
+ r.setdefault("suggestions_count", len(r.get("suggestions", [])))
+
+ # ── Optional image build phase ────────────────────────────────────────
+ image_build_results: list[dict[str, Any]] = []
+ build_targets: list[str] = list(payload.get("build_dockerfiles") or [])
+ if build_selected_images and not build_targets:
+ primary_compose_for_build = payload.get("primary_compose_file") or (
+ compose_files[0] if compose_files else None
+ )
+ build_targets = select_dockerfiles_for_build(
+ dockerfiles,
+ project_path,
+ primary_compose_file=primary_compose_for_build,
+ max_builds=settings.max_image_builds,
+ )
+ if build_selected_images and build_targets:
+ gateway = DockerGateway()
+ job_id_hex = str(job_id).replace("-", "")
+ for df_rel in build_targets:
+ df_path = (project_path / df_rel).resolve()
+ df_hash = hashlib.sha256(df_rel.encode()).hexdigest()[:8]
+ image_tag = f"dqa-{job_id_hex[:12]}-{df_hash}"
+ build_ctx = str(df_path.parent)
+ dockerfile_name = df_path.name
+ base_image: str | None = None
+ try:
+ base_image = extract_base_image_from_dockerfile(
+ df_path.read_text(encoding="utf-8", errors="ignore")
)
- )
- except Exception as exc:
- finished_at = datetime.now(timezone.utc).isoformat()
- build_entry.update({
- "status": "failed",
- "build_finished_at": finished_at,
- "error_message": str(exc),
- })
+ except Exception:
+ pass
+
+ build_entry: dict[str, Any] = {
+ "dockerfile_path": df_rel,
+ "build_context": build_ctx,
+ "image_tag": image_tag,
+ "base_image": base_image,
+ "status": "skipped",
+ "build_logs": [],
+ }
+ started_at = datetime.now(timezone.utc).isoformat()
+ build_entry["build_started_at"] = started_at
await publish_event(
DomainEvent(
- "project.image_build_failed",
+ "project.image_build_started",
str(user_id),
str(job_id),
- payload={"dockerfile_path": df_rel, "image_tag": image_tag, "error": str(exc)},
+ payload={"dockerfile_path": df_rel, "image_tag": image_tag},
)
)
+ try:
+ async def _stream_build_log(line: str) -> None:
+ build_entry["build_logs"].append(line)
+ await publish_event(
+ DomainEvent(
+ "project.image_build_log",
+ str(user_id),
+ str(job_id),
+ payload={"dockerfile_path": df_rel, "image_tag": image_tag, "line": line},
+ )
+ )
- image_build_results.append(build_entry)
-
- result = {
- "score": overall_score,
- "grade": overall_grade,
- "overall_score": overall_score,
- "overall_grade": overall_grade,
- "per_file_results": per_file_results,
- "service_mappings": service_mappings,
- "project_summary": {
- "total_files_analyzed": len(per_file_results),
- "dockerfiles_analyzed": len(df_results),
- "compose_files_analyzed": len(cf_results),
- "total_errors": total_errors,
- "total_warnings": total_warnings,
- "total_security_issues": total_security,
- "total_suggestions": total_suggestions,
- "best_score_file": best_file,
- "worst_score_file": worst_file,
- },
- "project_recommendations": project_recommendations,
- "image_build_results": image_build_results,
- }
+ _, log_lines = await gateway.build_image(
+ path=build_ctx,
+ dockerfile=dockerfile_name,
+ tag=image_tag,
+ timeout=settings.image_build_timeout_seconds,
+ )
+ for line in log_lines:
+ await _stream_build_log(line)
+
+ finished_at = datetime.now(timezone.utc).isoformat()
+ started_dt = datetime.fromisoformat(started_at)
+ finished_dt = datetime.fromisoformat(finished_at)
+ duration_ms = int((finished_dt - started_dt).total_seconds() * 1000)
+
+ # Inspect the built image for metadata
+ image_meta = await gateway.inspect_image(image_tag)
+
+ build_entry.update({
+ "status": "success",
+ "build_finished_at": finished_at,
+ "build_duration_ms": duration_ms,
+ **image_meta,
+ })
+ await publish_event(
+ DomainEvent(
+ "project.image_build_completed",
+ str(user_id),
+ str(job_id),
+ payload={"dockerfile_path": df_rel, "image_tag": image_tag, "image_id": image_meta.get("image_id")},
+ )
+ )
+ except Exception as exc:
+ finished_at = datetime.now(timezone.utc).isoformat()
+ build_entry.update({
+ "status": "failed",
+ "build_finished_at": finished_at,
+ "error_message": str(exc),
+ })
+ await publish_event(
+ DomainEvent(
+ "project.image_build_failed",
+ str(user_id),
+ str(job_id),
+ payload={"dockerfile_path": df_rel, "image_tag": image_tag, "error": str(exc)},
+ )
+ )
- await repo.update_status(job_id, user_id, JobStatus.done, result=result)
- await session.commit()
- await publish_event(DomainEvent("project.analysis_completed", str(user_id), str(job_id), payload={"overall_score": overall_score, "overall_grade": overall_grade}))
- await publish_event(DomainEvent("user.analysis.completed", str(user_id), str(job_id), payload=result))
- return result
+ image_build_results.append(build_entry)
+
+ result = {
+ "score": overall_score,
+ "grade": overall_grade,
+ "overall_score": overall_score,
+ "overall_grade": overall_grade,
+ "per_file_results": per_file_results,
+ "service_mappings": service_mappings,
+ "project_summary": {
+ "total_files_analyzed": len(per_file_results),
+ "dockerfiles_analyzed": len(df_results),
+ "compose_files_analyzed": len(cf_results),
+ "total_errors": total_errors,
+ "total_warnings": total_warnings,
+ "total_security_issues": total_security,
+ "total_suggestions": total_suggestions,
+ "best_score_file": best_file,
+ "worst_score_file": worst_file,
+ },
+ "project_recommendations": project_recommendations,
+ "image_build_results": image_build_results,
+ }
+
+ await repo.update_status(job_id, user_id, JobStatus.done, result=result)
+ await session.commit()
+ await publish_event(DomainEvent("project.analysis_completed", str(user_id), str(job_id), payload={"overall_score": overall_score, "overall_grade": overall_grade}))
+ await publish_event(DomainEvent("user.analysis.completed", str(user_id), str(job_id), payload=result))
+ return result
+ except asyncio.CancelledError:
+ timeout_minutes = max(1, settings.project_job_timeout_seconds // 60)
+ logger.warning("run_project_analysis timed out job_id=%s", job_id)
+ await asyncio.shield(
+ _mark_project_analysis_failed(
+ user_id,
+ job_id,
+ f"Project analysis timed out after {timeout_minutes} minutes.",
+ )
+ )
+ raise
+ except Exception as exc:
+ logger.exception("run_project_analysis failed job_id=%s", job_id)
+ return await _mark_project_analysis_failed(
+ user_id,
+ job_id,
+ f"Project analysis failed: {exc}",
+ )
async def run_compose_deploy(ctx, payload: dict) -> dict:
@@ -436,8 +504,8 @@ async def run_compose_deploy(ctx, payload: dict) -> dict:
container_ids = await _compose_ps_ids(deploy_spec)
if container_ids:
primary_container_id = container_ids[0]
- # Build initial per-container state entries
- initial_containers = [{"id": cid, "status": "running"} for cid in container_ids]
+ # Build rich initial per-container state entries (name/service/ports/health)
+ initial_containers = await _build_initial_containers(container_ids)
await _set_deploy_state(
user_id,
job_id,
@@ -468,7 +536,10 @@ async def run_compose_deploy(ctx, payload: dict) -> dict:
)
if run_stack and container_ids:
- await _stream_metrics(user_id, job_id, container_ids)
+ await asyncio.gather(
+ _stream_metrics(user_id, job_id, container_ids),
+ _stream_logs(user_id, job_id, container_ids),
+ )
return {
"status": "deployment workflow acknowledged",
@@ -522,11 +593,19 @@ async def run_compose_stop(ctx, payload: dict) -> dict:
# Persist user intent immediately so concurrent metric/exit watchers
# do not classify this shutdown path as an unexpected self-exit.
state["stop_requested_by_user"] = True
- state["stopped_by_user"] = True
state["stop_reason"] = "user_requested"
await _set_deploy_state(user_id, job_id, state)
- await _compose_down(state, remove_volumes)
+ await _teardown_deploy_stack(
+ user_id,
+ job_id,
+ remove_images=False,
+ remove_volumes=remove_volumes,
+ reason="user_requested",
+ terminal_runtime_state="stopped_by_user",
+ publish_events=True,
+ )
+ state = await _get_deploy_state(user_id, job_id) or state
container_id = str(state.get("container_id") or state.get("project_name") or "")
await publish_event(
DomainEvent(
@@ -540,18 +619,6 @@ async def run_compose_stop(ctx, payload: dict) -> dict:
},
)
)
- # Persist terminal state as stopped_by_user so refresh can distinguish from self-exit
- terminal_state = {
- **state,
- "stopping": False,
- "active": False,
- "explicit_runtime_state": "stopped_by_user",
- "stopped_by_user": True,
- "stop_requested_by_user": True,
- "stop_reason": "user_requested",
- "stopped_at": datetime.now(timezone.utc).isoformat(),
- }
- await _set_deploy_state(user_id, job_id, terminal_state)
await redis_client.delete(_deploy_stop_key(user_id, job_id))
return {"status": "stopped"}
@@ -854,19 +921,176 @@ async def _compose_ps_ids(spec: dict[str, Any]) -> list[str]:
return [line.strip() for line in stdout.splitlines() if line.strip()]
-async def _compose_down(spec: dict[str, Any], remove_volumes: bool) -> None:
+async def _compose_down(
+ spec: dict[str, Any],
+ *,
+ remove_volumes: bool = False,
+ remove_orphans: bool = True,
+ remove_images: bool = False,
+) -> None:
command = _compose_base_cmd(spec) + ["down"]
+ if remove_orphans:
+ command.append("--remove-orphans")
if remove_volumes:
command.append("-v")
+ if remove_images:
+ command.extend(["--rmi", "all"])
_, stderr, returncode = await _run_subprocess(command, cwd=str(spec["project_dir"]))
if returncode != 0:
raise RuntimeError(f"docker-compose down failed: {stderr}")
+def _deploy_spec_from_state(state: dict[str, Any]) -> dict[str, Any] | None:
+ project_name = state.get("project_name")
+ project_dir = state.get("project_dir")
+ compose_file = state.get("compose_file")
+ if not all(isinstance(v, str) and v.strip() for v in (project_name, project_dir, compose_file)):
+ return None
+ spec: dict[str, Any] = {
+ "project_name": project_name,
+ "project_dir": project_dir,
+ "compose_file": compose_file,
+ }
+ reused = state.get("reused_service_names")
+ if isinstance(reused, list):
+ spec["reused_service_names"] = reused
+ return spec
+
+
+def _collect_image_refs_from_state(
+ state: dict[str, Any],
+ extra_tags: list[str] | None = None,
+) -> list[str]:
+ seen: set[str] = set()
+ refs: list[str] = []
+ for row in state.get("containers") or []:
+ if not isinstance(row, dict):
+ continue
+ image = row.get("image")
+ if isinstance(image, str) and image.strip() and image not in seen:
+ seen.add(image)
+ refs.append(image.strip())
+ for tag in extra_tags or []:
+ if isinstance(tag, str) and tag.strip() and tag not in seen:
+ seen.add(tag)
+ refs.append(tag.strip())
+ return refs
+
+
+async def _remove_images_best_effort(image_refs: list[str]) -> tuple[int, int]:
+ gateway = DockerGateway()
+ removed = 0
+ skipped = 0
+ for ref in image_refs:
+ try:
+ if await gateway.remove_image(ref):
+ removed += 1
+ else:
+ skipped += 1
+ except Exception:
+ logger.warning("remove_image_failed", extra={"image_ref": ref}, exc_info=True)
+ skipped += 1
+ return removed, skipped
+
+
+async def _teardown_deploy_stack(
+ user_id: str,
+ job_id: str,
+ *,
+ remove_images: bool,
+ remove_volumes: bool,
+ reason: str,
+ terminal_runtime_state: str,
+ extra_image_tags: list[str] | None = None,
+ publish_events: bool = True,
+) -> bool:
+ """Tear down a compose stack in DinD. Idempotent when runtime_cleanup_done is set."""
+ state = await _get_deploy_state(user_id, job_id) or {}
+ if state.get("runtime_cleanup_done"):
+ return False
+
+ spec = _deploy_spec_from_state(state)
+ cleanup_payload = {"project_name": state.get("project_name"), "reason": reason}
+ cleanup_ok = True
+
+ if publish_events:
+ await publish_event(
+ DomainEvent("deploy.cleanup_started", user_id=user_id, job_id=job_id, payload=cleanup_payload)
+ )
+
+ if spec is not None:
+ try:
+ await _compose_down(
+ spec,
+ remove_volumes=remove_volumes,
+ remove_images=remove_images,
+ )
+ except Exception as exc:
+ cleanup_ok = False
+ logger.warning(
+ "teardown_compose_down_failed",
+ extra={"user_id": user_id, "job_id": job_id, "reason": reason},
+ exc_info=True,
+ )
+ if publish_events:
+ await publish_event(
+ DomainEvent(
+ "deploy.cleanup_completed",
+ user_id=user_id,
+ job_id=job_id,
+ payload={**cleanup_payload, "ok": False, "error": str(exc)},
+ )
+ )
+ elif publish_events:
+ await publish_event(
+ DomainEvent(
+ "deploy.cleanup_completed",
+ user_id=user_id,
+ job_id=job_id,
+ payload={**cleanup_payload, "ok": False, "error": "no_deploy_spec"},
+ )
+ )
+
+ if remove_images:
+ image_refs = _collect_image_refs_from_state(state, extra_image_tags)
+ if image_refs:
+ await _remove_images_best_effort(image_refs)
+
+ if publish_events and cleanup_ok:
+ await publish_event(
+ DomainEvent(
+ "deploy.cleanup_completed",
+ user_id=user_id,
+ job_id=job_id,
+ payload={**cleanup_payload, "ok": True},
+ )
+ )
+
+ terminal_state: dict[str, Any] = {
+ **state,
+ "stopping": False,
+ "active": False,
+ "runtime_cleanup_done": True,
+ "explicit_runtime_state": terminal_runtime_state,
+ "exit_reason": reason,
+ "running_count": 0,
+ }
+ if terminal_runtime_state == "stopped_by_user":
+ terminal_state["stopped_by_user"] = True
+ terminal_state["stop_requested_by_user"] = True
+ terminal_state["stop_reason"] = reason
+ terminal_state["stopped_at"] = datetime.now(timezone.utc).isoformat()
+ else:
+ terminal_state["stopped_by_user"] = False
+
+ await _set_deploy_state(user_id, job_id, terminal_state)
+ return True
+
+
async def _compose_down_soft(spec: dict[str, Any], *, remove_volumes: bool = False) -> None:
"""Remove a partially created stack without masking the original deploy error."""
try:
- await _compose_down(spec, remove_volumes)
+ await _compose_down(spec, remove_volumes=remove_volumes)
except Exception:
pass
@@ -933,6 +1157,138 @@ def _reconcile_deploy_counts(state: dict[str, Any]) -> None:
state["unhealthy_count"] = unhealthy
+async def _append_deploy_log_line(
+ user_id: str,
+ job_id: str,
+ container_id: str,
+ line: str,
+) -> None:
+ """Append a log line to the container's ``last_logs`` ring buffer in deploy state."""
+ if not line:
+ return
+ state = await _get_deploy_state(user_id, job_id) or {}
+ containers_state = [c for c in state.get("containers", []) if isinstance(c, dict)]
+ updated = False
+ for row in containers_state:
+ if row.get("id") != container_id:
+ continue
+ logs_raw = row.get("last_logs")
+ logs: list[str] = list(logs_raw) if isinstance(logs_raw, list) else []
+ logs.append(line)
+ row["last_logs"] = logs[-DEPLOY_LOG_TAIL_MAX:]
+ updated = True
+ break
+ if not updated:
+ containers_state.append(
+ {
+ "id": container_id,
+ "status": "running",
+ "last_logs": [line][-DEPLOY_LOG_TAIL_MAX:],
+ }
+ )
+ state["containers"] = containers_state
+ await _set_deploy_state(user_id, job_id, state)
+
+
+async def _build_initial_containers(container_ids: list[str]) -> list[dict[str, Any]]:
+ """Inspect each freshly-started container for name/service/ports/health/ip and log tail."""
+ gateway = DockerGateway()
+ containers: list[dict[str, Any]] = []
+ for cid in container_ids:
+ try:
+ info = await gateway.inspect_container_runtime(cid)
+ except Exception:
+ info = {"id": cid}
+ try:
+ last_logs = await gateway.tail_container_logs(cid, tail=DEPLOY_LOG_TAIL_MAX)
+ except Exception:
+ last_logs = []
+ status = info.get("status")
+ containers.append(
+ {
+ "id": cid,
+ "name": info.get("name"),
+ "service": info.get("service"),
+ "image": info.get("image"),
+ "status": status if isinstance(status, str) and status else "running",
+ "health_status": info.get("health_status"),
+ "restart_count": info.get("restart_count"),
+ "ip_address": info.get("ip_address"),
+ "ports": info.get("ports") or [],
+ "last_logs": last_logs,
+ }
+ )
+ return containers
+
+
+def _metrics_container_runtime(container_id: str, metrics: dict[str, Any]) -> dict[str, Any]:
+ """Project a metrics payload into the deploy-state container shape (running entry)."""
+ info = metrics.get("container") or {}
+ status = info.get("status")
+ return {
+ "id": container_id,
+ "name": info.get("name"),
+ "service": info.get("service"),
+ "image": info.get("image"),
+ "status": status if isinstance(status, str) and status else "running",
+ "health_status": info.get("health_status"),
+ "restart_count": info.get("restart_count"),
+ "ip_address": info.get("ip_address"),
+ "ports": info.get("ports") or [],
+ }
+
+
+async def _stream_logs(user_id: str, job_id: str, container_ids: list[str]) -> None:
+ """Follow each container's logs concurrently, publishing ``container.log`` events."""
+ gateway = DockerGateway()
+
+ async def _follow(container_id: str) -> None:
+ try:
+ async for entry in gateway.follow_container_logs(
+ container_id, tail=DEPLOY_LOG_TAIL_MAX
+ ):
+ if await _is_stop_requested(user_id, job_id):
+ break
+ line = entry.get("line", "")
+ if not isinstance(line, str) or not line:
+ continue
+ await publish_event(
+ DomainEvent(
+ "container.log",
+ user_id=user_id,
+ job_id=job_id,
+ payload={
+ "container_id": container_id,
+ "line": line,
+ "stream": entry.get("stream", "stdout"),
+ "timestamp": entry.get("timestamp"),
+ },
+ )
+ )
+ await _append_deploy_log_line(user_id, job_id, container_id, line)
+ except Exception as exc:
+ logger.warning(
+ "container log stream failed for %s: %s",
+ container_id[:12],
+ exc,
+ )
+ await publish_event(
+ DomainEvent(
+ "container.log",
+ user_id=user_id,
+ job_id=job_id,
+ payload={
+ "container_id": container_id,
+ "line": f"Log stream error: {exc}",
+ "stream": "system",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ },
+ )
+ )
+
+ await asyncio.gather(*(_follow(cid) for cid in container_ids))
+
+
async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -> None:
docker_gateway = DockerGateway()
active_ids: set[str] = set(container_ids)
@@ -943,6 +1299,7 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -
break
newly_exited: list[str] = []
+ running_runtime: dict[str, dict[str, Any]] = {}
for container_id in list(active_ids):
try:
cstate = await docker_gateway.inspect_container_state(container_id)
@@ -962,6 +1319,7 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -
except Exception:
continue
+ running_runtime[container_id] = _metrics_container_runtime(container_id, metrics)
payload = {"container_id": container_id, **metrics}
await publish_event(
DomainEvent(
@@ -972,6 +1330,42 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -
)
)
+ # Refresh running-container runtime info (status/health/ports) in deploy state
+ # so deployStatus stays authoritative for the Monitoring overview + preview targets.
+ if running_runtime:
+ state = await _get_deploy_state(user_id, job_id) or {}
+ containers_state = [c for c in state.get("containers", []) if isinstance(c, dict)]
+ by_id = {c.get("id"): c for c in containers_state}
+ for cid, runtime in running_runtime.items():
+ existing = by_id.get(cid, {})
+ merged = {**existing, **{k: v for k, v in runtime.items() if v is not None}}
+ # Preserve last_logs/exit info already captured for this container.
+ by_id[cid] = merged
+ state["containers"] = list(by_id.values())
+ _reconcile_deploy_counts(state)
+ await _set_deploy_state(user_id, job_id, state)
+
+ unhealthy_count = int(state.get("unhealthy_count", 0) or 0)
+ running_count = int(state.get("running_count", 0) or 0)
+ if (
+ unhealthy_count > 0
+ and running_count > 0
+ and not state.get("runtime_cleanup_done")
+ and not await _is_stop_requested(user_id, job_id)
+ and not state.get("stop_requested_by_user")
+ and not state.get("stopped_by_user")
+ ):
+ await _teardown_deploy_stack(
+ user_id,
+ job_id,
+ remove_images=True,
+ remove_volumes=False,
+ reason="unhealthy",
+ terminal_runtime_state="unhealthy",
+ publish_events=True,
+ )
+ break
+
for container_id in newly_exited:
active_ids.discard(container_id)
try:
@@ -1032,6 +1426,15 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -
if was_user_stop:
break
+ await _teardown_deploy_stack(
+ user_id,
+ job_id,
+ remove_images=True,
+ remove_volumes=False,
+ reason="all_containers_exited",
+ terminal_runtime_state="exited",
+ publish_events=True,
+ )
await publish_event(
DomainEvent(
"project.runtime_stopped",
@@ -1040,14 +1443,6 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) -
payload={"reason": "all_containers_exited"},
)
)
- # Persist explicit self-exit marker so refresh can distinguish from user stop
- state["all_exited"] = True
- state["active"] = False
- if not was_user_stop:
- state["explicit_runtime_state"] = "exited"
- state["stopped_by_user"] = False
- state["exit_reason"] = "all_containers_exited"
- await _set_deploy_state(user_id, job_id, state)
break
await asyncio.sleep(METRICS_INTERVAL_SECONDS)
@@ -1080,3 +1475,49 @@ async def _is_stop_requested(user_id: str, job_id: str) -> bool:
async def _clear_deploy_state(user_id: str, job_id: str) -> None:
await redis_client.delete(_deployment_key(user_id, job_id))
await redis_client.delete(_deploy_stop_key(user_id, job_id))
+
+
+async def cleanup_job_images(ctx, payload: dict) -> dict:
+ """Best-effort removal of DinD images built for a deleted analysis job."""
+ tags_raw = payload.get("image_tags")
+ if not isinstance(tags_raw, list):
+ return {"removed": 0, "skipped": 0}
+ refs = [t.strip() for t in tags_raw if isinstance(t, str) and t.strip()]
+ removed, skipped = await _remove_images_best_effort(refs)
+ return {"removed": removed, "skipped": skipped}
+
+
+async def teardown_job_runtime(ctx, payload: dict) -> dict:
+ """Tear down leftover deploy stack and images when a job is deleted from history."""
+ user_id = str(payload.get("user_id") or "")
+ job_id = str(payload.get("job_id") or "")
+ remove_images = bool(payload.get("remove_images", True))
+ remove_volumes = bool(payload.get("remove_volumes", False))
+ deploy_spec = payload.get("deploy_spec")
+ image_tags_raw = payload.get("image_tags")
+
+ stack_removed = False
+ if isinstance(deploy_spec, dict):
+ spec = _deploy_spec_from_state(deploy_spec) or deploy_spec
+ if isinstance(spec.get("project_name"), str) and isinstance(spec.get("project_dir"), str):
+ try:
+ await _compose_down(
+ spec,
+ remove_volumes=remove_volumes,
+ remove_images=remove_images,
+ )
+ stack_removed = True
+ except Exception:
+ logger.warning(
+ "teardown_job_runtime_compose_failed",
+ extra={"user_id": user_id, "job_id": job_id},
+ exc_info=True,
+ )
+
+ image_result = {"removed": 0, "skipped": 0}
+ if remove_images and isinstance(image_tags_raw, list):
+ refs = [t.strip() for t in image_tags_raw if isinstance(t, str) and t.strip()]
+ removed, skipped = await _remove_images_best_effort(refs)
+ image_result = {"removed": removed, "skipped": skipped}
+
+ return {"stack_removed": stack_removed, **image_result}
diff --git a/backend/app/workers/worker.py b/backend/app/workers/worker.py
index df017bb..422f824 100644
--- a/backend/app/workers/worker.py
+++ b/backend/app/workers/worker.py
@@ -1,12 +1,15 @@
from arq.connections import RedisSettings
+from arq.worker import func
from app.core.config import get_settings
from app.workers.tasks import (
+ cleanup_job_images,
run_compose_analysis,
run_compose_deploy,
run_compose_stop,
run_dockerfile_analysis,
run_project_analysis,
+ teardown_job_runtime,
)
settings = get_settings()
@@ -14,4 +17,17 @@
class WorkerSettings:
redis_settings = RedisSettings.from_dsn(settings.redis_url)
- functions = [run_dockerfile_analysis, run_compose_analysis, run_project_analysis, run_compose_deploy, run_compose_stop]
+ job_timeout = settings.project_job_timeout_seconds
+ functions = [
+ run_dockerfile_analysis,
+ run_compose_analysis,
+ func(
+ run_project_analysis,
+ timeout=settings.project_job_timeout_seconds,
+ max_tries=1,
+ ),
+ run_compose_deploy,
+ run_compose_stop,
+ cleanup_job_images,
+ teardown_job_runtime,
+ ]
diff --git a/backend/tests/test_api_routes.py b/backend/tests/test_api_routes.py
index d0a1ca7..3fc237d 100644
--- a/backend/tests/test_api_routes.py
+++ b/backend/tests/test_api_routes.py
@@ -13,7 +13,7 @@
from app.application.schemas import TokenResponse, UserRead
from app.application.services.github_import import GithubRepoTarget
from app.core.security import create_token
-from app.infrastructure.db.models import AnalysisJobModel, ApiKeyModel, JobStatus, JobType
+from app.infrastructure.db.models import AnalysisJobModel, JobStatus, JobType
from app.infrastructure.db.session import get_db_session
from tests.conftest import auth_header_for, make_user
@@ -82,8 +82,8 @@ async def fake_enqueue_job(self, user_id, job_type, metadata):
queue_calls: list[dict] = []
- async def fake_enqueue(task_name: str, payload: dict):
- queue_calls.append({"task": task_name, "payload": payload})
+ async def fake_enqueue(task_name: str, payload: dict, **kwargs):
+ queue_calls.append({"task": task_name, "payload": payload, "kwargs": kwargs})
monkeypatch.setattr("app.application.services.analysis_service.AnalysisService.enqueue_job", fake_enqueue_job)
monkeypatch.setattr("app.api.routers.dockerfile.enqueue_job", fake_enqueue)
@@ -318,8 +318,14 @@ async def get_job(self, _job_id, _user_id):
async def fake_enqueue(task_name: str, payload: dict):
calls.append({"task": task_name, "payload": payload})
+ mark_calls: list[tuple[uuid.UUID, uuid.UUID]] = []
+
+ async def fake_mark_pending(user_id: uuid.UUID, job_id_arg: uuid.UUID) -> None:
+ mark_calls.append((user_id, job_id_arg))
+
monkeypatch.setattr("app.api.routers.compose.JobRepository", RunnableJobRepo)
monkeypatch.setattr("app.api.routers.compose.enqueue_job", fake_enqueue)
+ monkeypatch.setattr("app.api.routers.compose.mark_deploy_stop_pending", fake_mark_pending)
app.dependency_overrides[get_db_session] = fake_db_session_dependency
app.dependency_overrides[get_current_user] = lambda: user
@@ -331,6 +337,7 @@ async def fake_enqueue(task_name: str, payload: dict):
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["status"] == "queued"
+ assert mark_calls == [(user.id, job_id)]
assert calls and calls[0]["task"] == "run_compose_stop"
assert calls[0]["payload"]["remove_volumes"] is False
@@ -359,8 +366,12 @@ async def get_job(self, _job_id, _user_id):
async def fake_enqueue(task_name: str, payload: dict):
calls.append({"task": task_name, "payload": payload})
+ async def fake_mark_pending(_user_id: uuid.UUID, _job_id: uuid.UUID) -> None:
+ return None
+
monkeypatch.setattr("app.api.routers.compose.JobRepository", RunnableJobRepo)
monkeypatch.setattr("app.api.routers.compose.enqueue_job", fake_enqueue)
+ monkeypatch.setattr("app.api.routers.compose.mark_deploy_stop_pending", fake_mark_pending)
app.dependency_overrides[get_db_session] = fake_db_session_dependency
app.dependency_overrides[get_current_user] = lambda: user
@@ -376,6 +387,118 @@ async def fake_enqueue(task_name: str, payload: dict):
assert calls[0]["payload"]["remove_volumes"] is True
+def test_compose_preview_check_rejects_platform_api_url(client, app):
+ user = make_user()
+ app.dependency_overrides[get_current_user] = lambda: user
+
+ response = client.get(
+ "/api/v1/compose/deploy/preview-check",
+ params={"url": "http://127.0.0.1:8000/"},
+ headers=auth_header_for(user.id),
+ )
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["frameable"] is False
+ assert data["reachable"] is False
+ assert "platform API" in (data["reason"] or "")
+
+
+def test_compose_preview_check_detects_x_frame_options_deny(client, monkeypatch, app):
+ user = make_user()
+ app.dependency_overrides[get_current_user] = lambda: user
+
+ class FakeResponse:
+ status_code = 200
+ url = "http://172.18.0.9:8080/"
+ headers = httpx.Headers({"x-frame-options": "DENY"})
+
+ class FakeClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return None
+
+ async def head(self, _url: str):
+ return FakeResponse()
+
+ monkeypatch.setattr("app.api.routers.compose.httpx.AsyncClient", lambda **kwargs: FakeClient())
+
+ response = client.get(
+ "/api/v1/compose/deploy/preview-check",
+ params={"url": "http://172.18.0.9:8080/"},
+ headers=auth_header_for(user.id),
+ )
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["reachable"] is True
+ assert data["frameable"] is False
+ assert data["proxy_available"] is True
+ assert "X-Frame-Options" in (data["reason"] or "")
+
+
+def test_compose_preview_proxy_session_and_strip_xfo(client, monkeypatch, app, fake_db_session_dependency):
+ user = make_user()
+ app.dependency_overrides[get_current_user] = lambda: user
+ app.dependency_overrides[get_db_session] = fake_db_session_dependency
+
+ stored: dict[str, str] = {}
+
+ async def fake_set(key, value, ex=None):
+ stored[str(key)] = value
+
+ async def fake_get(key):
+ return stored.get(str(key))
+
+ class FakeResponse:
+ status_code = 200
+ url = "http://172.18.0.9:8080/"
+ headers = httpx.Headers(
+ {
+ "content-type": "text/html",
+ "x-frame-options": "DENY",
+ }
+ )
+ content = b"ok"
+
+ class FakeClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return None
+
+ async def request(self, _method, _url, **kwargs):
+ return FakeResponse()
+
+ monkeypatch.setattr("app.api.routers.preview_proxy.redis_client.set", fake_set)
+ monkeypatch.setattr("app.api.routers.preview_proxy.redis_client.get", fake_get)
+ monkeypatch.setattr("app.api.routers.preview_proxy.httpx.AsyncClient", lambda **kwargs: FakeClient())
+
+ create = client.post(
+ "/api/v1/compose/deploy/preview-proxy/session",
+ json={"url": "http://172.18.0.9:8080/"},
+ headers=auth_header_for(user.id),
+ )
+ assert create.status_code == 200
+ session_id = create.json()["session_id"]
+
+ proxied = client.get(
+ f"/api/v1/compose/deploy/preview-proxy/{session_id}/",
+ headers=auth_header_for(user.id),
+ )
+ app.dependency_overrides.clear()
+
+ assert proxied.status_code == 200
+ assert "x-frame-options" not in {k.lower() for k in proxied.headers.keys()}
+ assert b" None:
+ files = [
+ "proj/docker-compose-image-tag.yml",
+ "proj/docker-compose-light.yml",
+ "proj/docker-compose.yml",
+ "proj/scripts/databases/hive/docker-compose.yml",
+ ]
+ files.sort(key=compose_file_sort_key)
+ assert files[0] == "proj/docker-compose.yml"
+
+
+def test_select_dockerfiles_for_build_uses_primary_compose(tmp_path: Path) -> None:
+ project_root = tmp_path / "proj"
+ project_root.mkdir()
+ (project_root / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
+ (project_root / "docker-compose.yml").write_text(
+ """
+services:
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+""",
+ encoding="utf-8",
+ )
+ (project_root / ".devcontainer").mkdir()
+ (project_root / ".devcontainer" / "Dockerfile").write_text("FROM debian\n", encoding="utf-8")
+
+ selected = select_dockerfiles_for_build(
+ ["Dockerfile", ".devcontainer/Dockerfile"],
+ project_root,
+ primary_compose_file="docker-compose.yml",
+ max_builds=3,
+ )
+ assert selected == ["Dockerfile"]
+
+
+def test_select_dockerfiles_for_build_falls_back_to_root_dockerfile(tmp_path: Path) -> None:
+ project_root = tmp_path / "proj"
+ project_root.mkdir()
+ (project_root / "Dockerfile").write_text("FROM alpine:3.20\n", encoding="utf-8")
+ (project_root / "RELEASING").mkdir()
+ (project_root / "RELEASING" / "Dockerfile.make_docs").write_text("FROM alpine\n", encoding="utf-8")
+
+ selected = select_dockerfiles_for_build(
+ ["Dockerfile", "RELEASING/Dockerfile.make_docs"],
+ project_root,
+ primary_compose_file=None,
+ max_builds=3,
+ )
+ assert selected == ["Dockerfile"]
diff --git a/backend/tests/test_container_lifecycle.py b/backend/tests/test_container_lifecycle.py
index 004f48f..1fb4614 100644
--- a/backend/tests/test_container_lifecycle.py
+++ b/backend/tests/test_container_lifecycle.py
@@ -64,12 +64,19 @@ async def fake_get_state(uid, jid):
async def fake_set_state(uid, jid, state):
fake_state.update(state)
+ teardown_calls: list[dict] = []
+
+ async def fake_teardown(_uid, _jid, **kwargs):
+ teardown_calls.append(kwargs)
+ return True
+
with (
patch("app.workers.tasks.DockerGateway", return_value=fake_gateway),
patch("app.workers.tasks.publish_event", side_effect=capture_event),
patch("app.workers.tasks._is_stop_requested", side_effect=fake_is_stop),
patch("app.workers.tasks._get_deploy_state", side_effect=fake_get_state),
patch("app.workers.tasks._set_deploy_state", side_effect=fake_set_state),
+ patch("app.workers.tasks._teardown_deploy_stack", side_effect=fake_teardown),
):
from app.workers.tasks import _stream_metrics
# Run one iteration — all containers exit immediately
@@ -78,6 +85,9 @@ async def fake_set_state(uid, jid, state):
event_names = [e.event_name for e in published_events]
assert "container.exited" in event_names
assert "project.runtime_stopped" in event_names
+ assert len(teardown_calls) == 1
+ assert teardown_calls[0]["remove_images"] is True
+ assert teardown_calls[0]["terminal_runtime_state"] == "exited"
exited_event = next(e for e in published_events if e.event_name == "container.exited")
assert exited_event.payload["exit_code"] == 1
@@ -196,6 +206,60 @@ async def fake_get(key: str):
assert data["containers"][0]["exit_code"] == 1
+def test_deploy_status_returns_ports_and_ip_for_running_container(client, app):
+ """deployStatus exposes ports + ip_address so Monitoring can build preview targets."""
+ from app.api.deps import get_current_user
+ from app.infrastructure.db.session import get_db_session
+ from tests.conftest import auth_header_for, make_user
+
+ user = make_user()
+ job_id = uuid.uuid4()
+ state = {
+ "container_ids": ["web1"],
+ "containers": [
+ {
+ "id": "web1",
+ "name": "dqa-web-1",
+ "service": "web",
+ "status": "running",
+ "health_status": "healthy",
+ "ip_address": "172.18.0.5",
+ "ports": [
+ {
+ "container_port": "80/tcp",
+ "host_bindings": [{"host_ip": "0.0.0.0", "host_port": "8080"}],
+ }
+ ],
+ }
+ ],
+ "project_name": "demo",
+ }
+ from app.infrastructure.events.bus import redis_client as _redis
+
+ app.dependency_overrides[get_current_user] = lambda: user
+ app.dependency_overrides[get_db_session] = AsyncMock
+
+ async def fake_get(key: str):
+ if "deploy-stop:" in str(key):
+ return None
+ return json.dumps(state)
+
+ with patch.object(_redis, "get", new=AsyncMock(side_effect=fake_get)):
+ response = client.get(
+ f"/api/v1/compose/deploy/status/{job_id}",
+ headers=auth_header_for(user.id),
+ )
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ container = data["containers"][0]
+ assert container["ip_address"] == "172.18.0.5"
+ assert container["service"] == "web"
+ assert container["ports"][0]["container_port"] == "80/tcp"
+ assert container["ports"][0]["host_bindings"][0]["host_port"] == "8080"
+
+
def test_deploy_status_runtime_state_partial(client, app):
"""Mixed running + exited yields partial and active=true; counts recomputed from containers."""
from app.api.deps import get_current_user
@@ -276,6 +340,43 @@ async def fake_get(key: str):
assert data["active"] is True
+def test_deploy_status_stopping_via_redis_flag(client, app):
+ """``deploy-stop`` redis key yields runtime_state=stopping even before worker updates JSON."""
+ from app.api.deps import get_current_user
+ from app.infrastructure.db.session import get_db_session
+ from tests.conftest import auth_header_for, make_user
+
+ user = make_user()
+ job_id = uuid.uuid4()
+ state = {
+ "container_ids": ["a"],
+ "containers": [{"id": "a", "status": "running"}],
+ "project_name": "demo",
+ }
+ from app.infrastructure.events.bus import redis_client as _redis
+
+ app.dependency_overrides[get_current_user] = lambda: user
+ app.dependency_overrides[get_db_session] = AsyncMock
+
+ async def fake_get(key: str):
+ if "deploy-stop:" in str(key):
+ return "1"
+ return json.dumps(state)
+
+ with patch.object(_redis, "get", new=AsyncMock(side_effect=fake_get)):
+ response = client.get(
+ f"/api/v1/compose/deploy/status/{job_id}",
+ headers=auth_header_for(user.id),
+ )
+ app.dependency_overrides.clear()
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["runtime_state"] == "stopping"
+ assert data["active"] is True
+ assert data["stopped_by_user"] is False
+
+
def test_deploy_status_stopping_flag(client, app):
"""``stopping: true`` in deploy JSON yields runtime_state=stopping while stack exists."""
from app.api.deps import get_current_user
@@ -488,3 +589,106 @@ async def test_deploy_status_differentiates_stopped_by_user_vs_self_exit():
assert result.runtime_state == "exited"
assert result.stopped_by_user is False
assert result.can_retry_runtime is False
+
+
+@pytest.mark.asyncio
+async def test_stream_metrics_unhealthy_triggers_teardown_with_images():
+ """When a running container is unhealthy, tear down stack and remove images."""
+ user_id = _uid()
+ job_id = _uid()
+ container_id = "sick1"
+
+ fake_gateway = MagicMock()
+ fake_gateway.inspect_container_state = AsyncMock(return_value={"status": "running"})
+ fake_gateway.inspect_container_metrics = AsyncMock(
+ return_value={
+ "container": {
+ "id": container_id,
+ "name": "web",
+ "status": "running",
+ "health_status": "unhealthy",
+ "image": "nginx:1.27",
+ },
+ "cpu_percent": 1.0,
+ }
+ )
+ fake_gateway.inspect_container_final_state = AsyncMock()
+
+ fake_state: dict[str, Any] = {
+ "project_name": "dqa-proj",
+ "project_dir": "/tmp/proj",
+ "compose_file": "/tmp/proj/docker-compose.yml",
+ "containers": [],
+ "container_ids": [container_id],
+ }
+
+ async def fake_get_state(_uid, _jid):
+ return dict(fake_state)
+
+ async def fake_set_state(_uid, _jid, state):
+ fake_state.update(state)
+
+ teardown_calls: list[dict] = []
+
+ async def fake_teardown(_uid, _jid, **kwargs):
+ teardown_calls.append(kwargs)
+ return True
+
+ with (
+ patch("app.workers.tasks.DockerGateway", return_value=fake_gateway),
+ patch("app.workers.tasks.publish_event", new=AsyncMock()),
+ patch("app.workers.tasks._is_stop_requested", new=AsyncMock(return_value=False)),
+ patch("app.workers.tasks._get_deploy_state", side_effect=fake_get_state),
+ patch("app.workers.tasks._set_deploy_state", side_effect=fake_set_state),
+ patch("app.workers.tasks._teardown_deploy_stack", side_effect=fake_teardown),
+ patch("app.workers.tasks.asyncio.sleep", new=AsyncMock()),
+ ):
+ from app.workers.tasks import _stream_metrics
+
+ await _stream_metrics(user_id, job_id, [container_id])
+
+ assert len(teardown_calls) == 1
+ assert teardown_calls[0]["remove_images"] is True
+ assert teardown_calls[0]["terminal_runtime_state"] == "unhealthy"
+
+
+def test_deploy_status_explicit_unhealthy_is_inactive(client, app):
+ """After unhealthy teardown, deploy status must not report active/live."""
+ from app.api.deps import get_current_user
+ from app.infrastructure.db.session import get_db_session
+ from tests.conftest import auth_header_for, make_user
+
+ user = make_user()
+ job_id = uuid.uuid4()
+ state = {
+ "container_ids": ["c1"],
+ "containers": [{"id": "c1", "status": "exited", "health_status": "unhealthy"}],
+ "explicit_runtime_state": "unhealthy",
+ "runtime_cleanup_done": True,
+ "running_count": 0,
+ "exited_count": 1,
+ "unhealthy_count": 1,
+ "project_name": "dqa-proj",
+ }
+
+ from app.infrastructure.events.bus import redis_client as _redis
+
+ app.dependency_overrides[get_current_user] = lambda: user
+ app.dependency_overrides[get_db_session] = AsyncMock
+
+ async def fake_get(key: str):
+ if "deploy-stop:" in str(key):
+ return None
+ return json.dumps(state)
+
+ with patch.object(_redis, "get", new=AsyncMock(side_effect=fake_get)):
+ response = client.get(
+ f"/api/v1/compose/deploy/status/{job_id}",
+ headers=auth_header_for(user.id),
+ )
+
+ app.dependency_overrides.clear()
+ assert response.status_code == 200
+ data = response.json()
+ assert data["active"] is False
+ assert data["runtime_state"] == "unhealthy"
diff --git a/backend/tests/test_docker_logs.py b/backend/tests/test_docker_logs.py
new file mode 100644
index 0000000..85e6bbd
--- /dev/null
+++ b/backend/tests/test_docker_logs.py
@@ -0,0 +1,183 @@
+"""Tests for DockerGateway log streaming/parsing and runtime inspection."""
+import uuid
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from app.infrastructure.docker.client import DockerGateway
+
+
+def _make_gateway(fake_client) -> DockerGateway:
+ with patch("docker.from_env", return_value=fake_client):
+ return DockerGateway()
+
+
+def test_split_log_timestamp_parses_rfc3339():
+ ts, msg = DockerGateway._split_log_timestamp(
+ "2026-05-13T10:00:00.123456789Z hello world"
+ )
+ assert ts == "2026-05-13T10:00:00.123456789Z"
+ assert msg == "hello world"
+
+
+def test_split_log_timestamp_passthrough_without_timestamp():
+ ts, msg = DockerGateway._split_log_timestamp("just a plain log line")
+ assert ts is None
+ assert msg == "just a plain log line"
+
+
+@pytest.mark.asyncio
+async def test_follow_container_logs_parses_timestamped_lines():
+ fake_stream = [
+ b"2026-05-13T10:00:00.000000000Z hello world\n",
+ b"2026-05-13T10:00:01.000000000Z second line\n",
+ ]
+ fake_container = MagicMock()
+ fake_container.logs.return_value = iter(fake_stream)
+ fake_client = MagicMock()
+ fake_client.containers.get.return_value = fake_container
+
+ gateway = _make_gateway(fake_client)
+ entries = [entry async for entry in gateway.follow_container_logs("cid", tail=10)]
+
+ assert len(entries) == 2
+ assert entries[0]["stream"] == "stdout"
+ assert entries[0]["line"] == "hello world"
+ assert entries[0]["timestamp"] == "2026-05-13T10:00:00.000000000Z"
+ assert entries[1]["stream"] == "stdout"
+ assert entries[1]["line"] == "second line"
+
+ _, kwargs = fake_container.logs.call_args
+ assert kwargs["follow"] is True
+ assert kwargs["timestamps"] is True
+ assert kwargs["tail"] == 10
+ assert "demux" not in kwargs
+
+
+def test_tail_container_logs_returns_decoded_lines():
+ fake_container = MagicMock()
+ fake_container.logs.return_value = b"line one\nline two\n"
+ fake_client = MagicMock()
+ fake_client.containers.get.return_value = fake_container
+
+ gateway = _make_gateway(fake_client)
+ lines = gateway._tail_container_logs_sync("cid", 200)
+
+ assert lines == ["line one", "line two"]
+ fake_container.logs.assert_called_once_with(tail=200, stream=False)
+
+
+def test_inspect_container_runtime_maps_service_and_ports():
+ fake_container = MagicMock()
+ fake_container.attrs = {
+ "Name": "/dqa-proj-web-1",
+ "RestartCount": 0,
+ "Config": {
+ "Image": "nginx:1.27",
+ "Labels": {"com.docker.compose.service": "web"},
+ },
+ "State": {"Status": "running", "Health": {"Status": "healthy"}},
+ "NetworkSettings": {
+ "Networks": {"backend": {"IPAddress": "172.18.0.5"}},
+ "Ports": {
+ "80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}],
+ },
+ },
+ }
+ fake_client = MagicMock()
+ fake_client.containers.get.return_value = fake_container
+
+ gateway = _make_gateway(fake_client)
+ info = gateway._inspect_container_runtime_sync("cid")
+
+ assert info["service"] == "web"
+ assert info["name"] == "dqa-proj-web-1"
+ assert info["status"] == "running"
+ assert info["health_status"] == "healthy"
+ assert info["ip_address"] == "172.18.0.5"
+ assert info["ports"] == [
+ {"container_port": "80/tcp", "host_bindings": [{"host_ip": "0.0.0.0", "host_port": "8080"}]}
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_logs_publishes_container_log_events():
+ async def fake_follow(container_id, *, tail=200):
+ yield {"stream": "stdout", "line": "hello", "timestamp": "2026-05-13T10:00:00Z"}
+ yield {"stream": "stderr", "line": "boom", "timestamp": None}
+
+ fake_gateway = MagicMock()
+ fake_gateway.follow_container_logs = fake_follow
+
+ published = []
+
+ async def capture(event):
+ published.append(event)
+
+ with (
+ patch("app.workers.tasks.DockerGateway", return_value=fake_gateway),
+ patch("app.workers.tasks.publish_event", side_effect=capture),
+ patch("app.workers.tasks._append_deploy_log_line", new=AsyncMock()),
+ patch("app.workers.tasks._is_stop_requested", new=AsyncMock(return_value=False)),
+ ):
+ from app.workers.tasks import _stream_logs
+
+ await _stream_logs(str(uuid.uuid4()), str(uuid.uuid4()), ["c1"])
+
+ log_events = [e for e in published if e.event_name == "container.log"]
+ assert len(log_events) == 2
+ assert log_events[0].payload["container_id"] == "c1"
+ assert log_events[0].payload["line"] == "hello"
+ assert log_events[0].payload["stream"] == "stdout"
+ assert log_events[1].payload["stream"] == "stderr"
+
+
+@pytest.mark.asyncio
+async def test_build_initial_containers_includes_last_logs():
+ fake_gateway = MagicMock()
+ fake_gateway.inspect_container_runtime = AsyncMock(
+ return_value={
+ "id": "cid1",
+ "name": "web",
+ "service": "app",
+ "status": "running",
+ "ports": [],
+ }
+ )
+ fake_gateway.tail_container_logs = AsyncMock(return_value=["boot complete"])
+
+ with patch("app.workers.tasks.DockerGateway", return_value=fake_gateway):
+ from app.workers.tasks import _build_initial_containers
+
+ result = await _build_initial_containers(["cid1"])
+
+ assert result[0]["last_logs"] == ["boot complete"]
+ fake_gateway.tail_container_logs.assert_awaited_once_with("cid1", tail=200)
+
+
+@pytest.mark.asyncio
+async def test_append_deploy_log_line_caps_ring_buffer():
+ from app.workers.tasks import DEPLOY_LOG_TAIL_MAX, _append_deploy_log_line
+
+ stored: dict[str, Any] = {
+ "containers": [{"id": "c1", "status": "running", "last_logs": [f"line-{i}" for i in range(DEPLOY_LOG_TAIL_MAX)]}]
+ }
+
+ async def fake_get(_uid: str, _jid: str):
+ return dict(stored)
+
+ async def fake_set(_uid: str, _jid: str, state: dict):
+ stored.clear()
+ stored.update(state)
+
+ with (
+ patch("app.workers.tasks._get_deploy_state", side_effect=fake_get),
+ patch("app.workers.tasks._set_deploy_state", side_effect=fake_set),
+ ):
+ await _append_deploy_log_line("u1", "j1", "c1", "line-new")
+
+ logs = stored["containers"][0]["last_logs"]
+ assert len(logs) == DEPLOY_LOG_TAIL_MAX
+ assert logs[-1] == "line-new"
+ assert logs[0] == "line-1"
diff --git a/backend/tests/test_event_bus.py b/backend/tests/test_event_bus.py
new file mode 100644
index 0000000..897ff7f
--- /dev/null
+++ b/backend/tests/test_event_bus.py
@@ -0,0 +1,49 @@
+"""Tests for DomainEvent routing on the Redis event bus."""
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from app.domain.events import DomainEvent
+from app.infrastructure.events import bus
+
+
+@pytest.mark.asyncio
+async def test_container_log_routes_only_to_logs_channel():
+ """container.log events must stay on the dedicated logs channel (no user/job flooding)."""
+ event = DomainEvent(
+ "container.log",
+ user_id="u1",
+ job_id="j1",
+ payload={"container_id": "c1", "line": "hello", "stream": "stdout"},
+ )
+
+ publish = AsyncMock()
+ with patch.object(bus, "redis_client") as mock_redis:
+ mock_redis.publish = publish
+ await bus.publish_event(event)
+
+ channels = [call.args[0] for call in publish.await_args_list]
+ assert channels == ["user:u1:container:c1:logs"]
+
+
+@pytest.mark.asyncio
+async def test_metrics_event_routes_to_event_and_metrics_channels():
+ """Non-log container events keep publishing to user/job/metrics channels."""
+ event = DomainEvent(
+ "container.metrics",
+ user_id="u1",
+ job_id="j1",
+ payload={"container_id": "c1", "cpu_percent": 1.0},
+ )
+
+ publish = AsyncMock()
+ with patch.object(bus, "redis_client") as mock_redis:
+ mock_redis.publish = publish
+ await bus.publish_event(event)
+
+ channels = [call.args[0] for call in publish.await_args_list]
+ assert "user:u1:events" in channels
+ assert "job:j1:events" in channels
+ assert "container:c1:metrics" in channels
+ assert "user:u1:container:c1:metrics" in channels
+ assert "user:u1:container:c1:logs" not in channels
diff --git a/backend/tests/test_job_cleanup.py b/backend/tests/test_job_cleanup.py
new file mode 100644
index 0000000..db95e77
--- /dev/null
+++ b/backend/tests/test_job_cleanup.py
@@ -0,0 +1,168 @@
+import uuid
+from pathlib import Path
+
+import pytest
+
+from app.application.services import job_cleanup
+
+
+def test_collect_image_tags_success_only():
+ result = {
+ "image_build_results": [
+ {"image_tag": "dqa-abc-main", "status": "success"},
+ {"image_tag": "dqa-abc-side", "status": "failed"},
+ {"image_tag": "dqa-abc-main", "status": "success"},
+ ]
+ }
+ assert job_cleanup.collect_image_tags(result) == ["dqa-abc-main"]
+
+
+def test_collect_image_tags_empty_when_missing():
+ assert job_cleanup.collect_image_tags(None) == []
+ assert job_cleanup.collect_image_tags({}) == []
+
+
+def test_collect_runtime_image_refs_merges_deploy_and_build():
+ deploy_state = {
+ "containers": [{"image": "nginx:1.27"}, {"image": "dqa-abc-main"}],
+ }
+ result = {
+ "image_build_results": [
+ {"image_tag": "dqa-abc-main", "status": "success"},
+ {"image_tag": "dqa-other", "status": "success"},
+ ]
+ }
+ refs = job_cleanup.collect_runtime_image_refs(deploy_state, result)
+ assert set(refs) == {"dqa-abc-main", "dqa-other", "nginx:1.27"}
+
+
+def test_safe_project_path_rejects_escape(tmp_path, monkeypatch):
+ user_id = uuid.uuid4()
+ upload_root = tmp_path / "uploads" / str(user_id)
+ upload_root.mkdir(parents=True)
+ outside = tmp_path / "outside"
+ outside.mkdir()
+
+ monkeypatch.setattr(job_cleanup.settings, "upload_dir", str(tmp_path / "uploads"))
+
+ assert job_cleanup._safe_project_path(user_id, {"project_path": str(outside)}) is None
+
+
+@pytest.mark.asyncio
+async def test_cleanup_job_artifacts_removes_dirs(tmp_path, monkeypatch):
+ user_id = uuid.uuid4()
+ job_id = uuid.uuid4()
+ upload_root = tmp_path / "uploads"
+ user_dir = upload_root / str(user_id)
+ project_dir = user_dir / "demo-abc12345"
+ project_dir.mkdir(parents=True)
+ (project_dir / "Dockerfile").write_text("FROM alpine\n", encoding="utf-8")
+
+ deploy_dir = upload_root / "deployments" / str(user_id) / str(job_id)
+ deploy_dir.mkdir(parents=True)
+ (deploy_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
+
+ monkeypatch.setattr(job_cleanup.settings, "upload_dir", str(upload_root))
+ enqueued: list[tuple[str, dict]] = []
+
+ async def fake_enqueue(task_name: str, payload: dict) -> None:
+ enqueued.append((task_name, payload))
+
+ async def fake_load_deploy(_uid, _jid):
+ return {
+ "project_name": "dqa-deadbeef",
+ "project_dir": str(project_dir),
+ "compose_file": str(project_dir / "docker-compose.yml"),
+ "containers": [{"image": "nginx:1.27"}],
+ }
+
+ monkeypatch.setattr(job_cleanup, "enqueue_job", fake_enqueue)
+ monkeypatch.setattr(job_cleanup, "_load_deploy_state", fake_load_deploy)
+
+ await job_cleanup.cleanup_job_artifacts(
+ user_id=user_id,
+ job_id=job_id,
+ input_metadata={"project_path": str(project_dir)},
+ result={
+ "image_build_results": [
+ {"image_tag": "dqa-deadbeef0001-abcd1234", "status": "success"},
+ ]
+ },
+ )
+
+ assert not project_dir.exists()
+ assert not deploy_dir.exists()
+ assert len(enqueued) == 1
+ assert enqueued[0][0] == "teardown_job_runtime"
+ payload = enqueued[0][1]
+ assert payload["user_id"] == str(user_id)
+ assert payload["job_id"] == str(job_id)
+ assert payload["remove_images"] is True
+ assert "dqa-deadbeef0001-abcd1234" in payload["image_tags"]
+ assert "nginx:1.27" in payload["image_tags"]
+ assert payload["deploy_spec"]["project_name"] == "dqa-deadbeef"
+
+
+@pytest.mark.asyncio
+async def test_cleanup_job_images_worker(monkeypatch):
+ from app.workers import tasks
+
+ removed: list[str] = []
+
+ class FakeGateway:
+ async def remove_image(self, tag: str) -> bool:
+ removed.append(tag)
+ return tag != "missing"
+
+ monkeypatch.setattr(tasks, "DockerGateway", lambda: FakeGateway())
+
+ result = await tasks.cleanup_job_images(
+ None,
+ {
+ "user_id": str(uuid.uuid4()),
+ "job_id": str(uuid.uuid4()),
+ "image_tags": ["dqa-one", "missing", "", "dqa-two"],
+ },
+ )
+
+ assert removed == ["dqa-one", "missing", "dqa-two"]
+ assert result == {"removed": 2, "skipped": 1}
+
+
+@pytest.mark.asyncio
+async def test_teardown_job_runtime_worker(monkeypatch):
+ from app.workers import tasks
+
+ compose_calls: list[dict] = []
+ removed: list[str] = []
+
+ async def fake_compose_down(spec, **kwargs):
+ compose_calls.append({"spec": spec, **kwargs})
+
+ class FakeGateway:
+ async def remove_image(self, tag: str) -> bool:
+ removed.append(tag)
+ return True
+
+ monkeypatch.setattr(tasks, "_compose_down", fake_compose_down)
+ monkeypatch.setattr(tasks, "DockerGateway", lambda: FakeGateway())
+
+ result = await tasks.teardown_job_runtime(
+ None,
+ {
+ "user_id": str(uuid.uuid4()),
+ "job_id": str(uuid.uuid4()),
+ "remove_images": True,
+ "deploy_spec": {
+ "project_name": "dqa-test",
+ "project_dir": "/tmp/proj",
+ "compose_file": "/tmp/proj/docker-compose.yml",
+ },
+ "image_tags": ["dqa-one"],
+ },
+ )
+
+ assert compose_calls[0]["remove_images"] is True
+ assert removed == ["dqa-one"]
+ assert result["stack_removed"] is True
+ assert result["removed"] == 1
diff --git a/backend/tests/test_project_build.py b/backend/tests/test_project_build.py
index 076b1df..c260c0f 100644
--- a/backend/tests/test_project_build.py
+++ b/backend/tests/test_project_build.py
@@ -8,6 +8,8 @@
import pytest
+from app.infrastructure.db.models import JobStatus
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -16,6 +18,7 @@ def _make_payload(
build_selected_images: bool = False,
dockerfiles: list[str] | None = None,
compose_files: list[str] | None = None,
+ build_dockerfiles: list[str] | None = None,
) -> dict[str, Any]:
user_id = str(uuid.uuid4())
job_id = str(uuid.uuid4())
@@ -32,7 +35,7 @@ def _make_payload(
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("version: '3'\nservices:\n web:\n image: python:3.12\n")
- return {
+ payload: dict[str, Any] = {
"user_id": user_id,
"job_id": job_id,
"project_path": str(tmp_path),
@@ -40,6 +43,19 @@ def _make_payload(
"compose_files": compose_files,
"build_selected_images": build_selected_images,
}
+ if build_selected_images:
+ if build_dockerfiles is not None:
+ payload["build_dockerfiles"] = build_dockerfiles
+ else:
+ from app.application.services.build_selection import select_dockerfiles_for_build
+
+ primary = compose_files[0] if compose_files else None
+ payload["build_dockerfiles"] = select_dockerfiles_for_build(
+ dockerfiles,
+ tmp_path,
+ primary_compose_file=primary,
+ )
+ return payload
def _make_fake_session():
@@ -113,7 +129,7 @@ async def test_build_selected_images_true_records_results(tmp_path):
mock_image = MagicMock()
- async def fake_build_image(path, dockerfile, tag, buildargs=None):
+ async def fake_build_image(path, dockerfile, tag, buildargs=None, **kwargs):
return mock_image, ["Step 1/2 : FROM python:3.12", "Successfully built abc123def456"]
async def fake_inspect_image(image_id_or_tag):
@@ -286,7 +302,7 @@ async def test_build_image_nested_dockerfile_passes_parent_dir_and_filename(tmp_
captured: dict[str, str] = {}
- async def fake_build_image(path: str, dockerfile: str, tag: str, buildargs=None):
+ async def fake_build_image(path: str, dockerfile: str, tag: str, buildargs=None, **kwargs):
captured["path"] = path
captured["dockerfile"] = dockerfile
captured["tag"] = tag
@@ -357,7 +373,7 @@ async def test_build_image_tag_includes_sha256_prefix_of_relative_path(tmp_path)
captured: dict[str, str] = {}
- async def fake_build_image(path: str, dockerfile: str, tag: str, buildargs=None):
+ async def fake_build_image(path: str, dockerfile: str, tag: str, buildargs=None, **kwargs):
captured["tag"] = tag
return MagicMock(), []
@@ -420,7 +436,12 @@ async def stub_inspect(_tag):
@pytest.mark.asyncio
async def test_successful_project_build_records_base_image(tmp_path):
- payload = _make_payload(tmp_path, build_selected_images=True, dockerfiles=["Dockerfile.api"])
+ payload = _make_payload(
+ tmp_path,
+ build_selected_images=True,
+ dockerfiles=["Dockerfile.api"],
+ build_dockerfiles=["Dockerfile.api"],
+ )
api_df = tmp_path / "Dockerfile.api"
api_df.write_text("FROM node:22-alpine AS build\nCOPY . .\n", encoding="utf-8")
@@ -479,3 +500,57 @@ async def stub_inspect(_tag):
builds = result.get("image_build_results") or []
assert builds and builds[0].get("base_image") == "node:22-alpine"
+
+
+@pytest.mark.asyncio
+async def test_run_project_analysis_marks_job_failed_on_unhandled_error(tmp_path):
+ """Unexpected worker errors must fail the job and publish failure events."""
+ payload = _make_payload(
+ tmp_path,
+ build_selected_images=False,
+ dockerfiles=["Dockerfile"],
+ compose_files=["docker-compose.yml"],
+ )
+
+ fake_repo = _make_fake_job_repo()
+ fake_svc = AsyncMock()
+ fake_svc.analyze_content = AsyncMock(return_value={
+ "score": 70,
+ "grade": "B",
+ "errors": [],
+ "warnings": [],
+ "securityIssues": [],
+ "suggestions": [],
+ "meta": {},
+ })
+ publish_mock = AsyncMock()
+
+ def boom(_compose_file, _project_root):
+ raise TypeError("unsupported operand type(s) for /: 'PosixPath' and 'dict'")
+
+ with (
+ patch("app.workers.tasks.SessionLocal") as mock_sl,
+ patch("app.workers.tasks.JobRepository", return_value=fake_repo),
+ patch("app.workers.tasks.AnalysisService", return_value=fake_svc),
+ patch("app.workers.tasks.publish_event", new=publish_mock),
+ patch("app.workers.tasks.map_compose_services", side_effect=boom),
+ ):
+ session_ctx = AsyncMock()
+ session_ctx.__aenter__ = AsyncMock(return_value=AsyncMock())
+ session_ctx.__aexit__ = AsyncMock(return_value=False)
+ mock_sl.return_value = session_ctx
+
+ from app.workers.tasks import run_project_analysis
+
+ result = await run_project_analysis(None, payload)
+
+ assert "Project analysis failed" in result["message"]
+ failed_calls = [
+ call
+ for call in fake_repo.update_status.await_args_list
+ if len(call.args) >= 3 and call.args[2] == JobStatus.failed
+ ]
+ assert failed_calls
+ event_names = [call.args[0].event_name for call in publish_mock.await_args_list]
+ assert "project.analysis_failed" in event_names
+ assert "user.analysis.failed" in event_names
diff --git a/backend/tests/test_project_scan.py b/backend/tests/test_project_scan.py
index e35e6b0..6c2f5a5 100644
--- a/backend/tests/test_project_scan.py
+++ b/backend/tests/test_project_scan.py
@@ -371,6 +371,28 @@ def test_compose_mapper_missing_env_file(tmp_path: Path) -> None:
assert any("env_file" in issue for issue in api_mapping["issues"])
+def test_compose_mapper_env_file_object_form(tmp_path: Path) -> None:
+ project_root = tmp_path / "project"
+ project_root.mkdir()
+ (project_root / "docker").mkdir()
+ (project_root / "docker" / ".env").write_text("FOO=bar\n", encoding="utf-8")
+ compose_content = """
+services:
+ api:
+ image: myapp:latest
+ env_file:
+ - path: docker/.env
+ required: true
+ - path: docker/.env-local
+ required: false
+"""
+ (project_root / "docker-compose.yml").write_text(compose_content, encoding="utf-8")
+
+ mappings = map_compose_services("docker-compose.yml", project_root)
+ api_mapping = mappings[0]
+ assert api_mapping["issues"] == []
+
+
def test_compose_mapper_missing_compose_file(tmp_path: Path) -> None:
project_root = tmp_path / "project"
project_root.mkdir()
diff --git a/backend/tests/test_research_api.py b/backend/tests/test_research_api.py
index 55e3d3f..6c83f67 100644
--- a/backend/tests/test_research_api.py
+++ b/backend/tests/test_research_api.py
@@ -169,11 +169,39 @@ async def get_job_global(_self, job_id):
assert "user_id" not in data
assert data["anonymized_submitter"] == anonymize_user_id(owner_id)
assert data["score"] == 50
+ assert data["is_own_job"] is False
r404 = research_client.get(f"/api/v1/research/jobs/{uuid.uuid4()}", headers=auth_header_for(requester.id))
assert r404.status_code == 404
+def test_research_job_is_own_job_when_viewer_is_owner(
+ research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch
+):
+ owner = make_user()
+ jid = uuid.uuid4()
+ research_app.dependency_overrides[get_current_user] = lambda: owner
+
+ job_row = AnalysisJobModel(
+ id=jid,
+ user_id=owner.id,
+ type=JobType.dockerfile,
+ status=JobStatus.done,
+ input_metadata={},
+ result={"score": 90, "grade": "A", "errors": [], "warnings": [], "suggestions": [], "securityIssues": []},
+ created_at=datetime.now(UTC),
+ )
+
+ async def get_job_global(_self, job_id):
+ return job_row if job_id == jid else None
+
+ monkeypatch.setattr(JobRepository, "get_job_global", get_job_global)
+
+ response = research_client.get(f"/api/v1/research/jobs/{jid}", headers=auth_header_for(owner.id))
+ assert response.status_code == 200
+ assert response.json()["is_own_job"] is True
+
+
def test_research_jobs_pagination_total(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch):
user = make_user()
research_app.dependency_overrides[get_current_user] = lambda: user
@@ -245,6 +273,7 @@ async def get_job_global(_self, job_id):
# Must not leak user identity.
assert "user_id" not in data
assert "email" not in data
+ assert data["is_own_job"] is False
# Must not leak raw metadata.
assert "input_metadata" not in data
diff --git a/backend/tests/test_worker_events.py b/backend/tests/test_worker_events.py
index 2033c1d..8a7d553 100644
--- a/backend/tests/test_worker_events.py
+++ b/backend/tests/test_worker_events.py
@@ -71,9 +71,17 @@ async def fake_compose_up(_spec, **_kwargs):
async def fake_compose_ps_ids(_spec):
return ["web-123"]
+ async def fake_stream_logs(_user_id: str, _job_id: str, _container_ids: list[str]):
+ return None
+
+ async def fake_initial_containers(container_ids: list[str]):
+ return [{"id": cid, "status": "running"} for cid in container_ids]
+
monkeypatch.setattr("app.workers.tasks._compose_up", fake_compose_up)
monkeypatch.setattr("app.workers.tasks._compose_ps_ids", fake_compose_ps_ids)
monkeypatch.setattr("app.workers.tasks._stream_metrics", fake_stream)
+ monkeypatch.setattr("app.workers.tasks._stream_logs", fake_stream_logs)
+ monkeypatch.setattr("app.workers.tasks._build_initial_containers", fake_initial_containers)
monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish)
result = await run_compose_deploy(
@@ -280,6 +288,12 @@ async def fake_compose_ps_ids(_spec):
async def failing_stream_metrics(_user_id: str, _job_id: str, _container_ids: list[str]):
raise RuntimeError("metrics failed after stack start")
+ async def fake_stream_logs(_user_id: str, _job_id: str, _container_ids: list[str]):
+ return None
+
+ async def fake_initial_containers(container_ids: list[str]):
+ return [{"id": cid, "status": "running"} for cid in container_ids]
+
monkeypatch.setattr("app.workers.tasks.SessionLocal", lambda: FakeSessionContext())
monkeypatch.setattr("app.workers.tasks.JobRepository", FakeRepo)
monkeypatch.setattr(
@@ -295,6 +309,8 @@ async def failing_stream_metrics(_user_id: str, _job_id: str, _container_ids: li
monkeypatch.setattr("app.workers.tasks._compose_up", fake_compose_up)
monkeypatch.setattr("app.workers.tasks._compose_ps_ids", fake_compose_ps_ids)
monkeypatch.setattr("app.workers.tasks._stream_metrics", failing_stream_metrics)
+ monkeypatch.setattr("app.workers.tasks._stream_logs", fake_stream_logs)
+ monkeypatch.setattr("app.workers.tasks._build_initial_containers", fake_initial_containers)
monkeypatch.setattr("app.workers.tasks._compose_down", record_down)
monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish)
@@ -350,10 +366,13 @@ async def fake_set_deploy(uid: str, jid: str, state: dict):
monkeypatch.setattr("app.workers.tasks._set_deploy_state", fake_set_deploy)
monkeypatch.setattr("app.workers.tasks.redis_client.delete", fake_redis_del)
- async def fake_compose_down(_state, _remove_volumes):
- return None
+ teardown_kwargs: list[dict] = []
+
+ async def fake_teardown(_uid, _jid, **kwargs):
+ teardown_kwargs.append(kwargs)
+ return True
- monkeypatch.setattr("app.workers.tasks._compose_down", fake_compose_down)
+ monkeypatch.setattr("app.workers.tasks._teardown_deploy_stack", fake_teardown)
result = await run_compose_stop(
None,
@@ -366,18 +385,16 @@ async def fake_compose_down(_state, _remove_volumes):
assert result["status"] == "stopped"
assert stop_called["value"] is True
- # First persisted state should immediately carry user-stop intent.
- assert len(set_state_calls) >= 2
+ assert teardown_kwargs[0]["remove_images"] is False
+ assert teardown_kwargs[0]["remove_volumes"] is True
+ assert teardown_kwargs[0]["terminal_runtime_state"] == "stopped_by_user"
+ # First persisted state should immediately carry user-stop intent before teardown.
+ assert len(set_state_calls) >= 1
initial_state = set_state_calls[0]
assert initial_state["stopping"] is True
assert initial_state["stop_requested_by_user"] is True
- assert initial_state["stopped_by_user"] is True
+ assert initial_state.get("stopped_by_user") is not True
assert initial_state["stop_reason"] == "user_requested"
- # Terminal state must mark stopped_by_user=True
- terminal_states = [s for s in set_state_calls if s.get("explicit_runtime_state") == "stopped_by_user"]
- assert len(terminal_states) >= 1
- assert terminal_states[-1]["stopped_by_user"] is True
- assert terminal_states[-1]["stop_requested_by_user"] is True
event_names = [event.event_name for event in emitted_events]
assert "container.stopped" in event_names
diff --git a/backend/tests/test_ws_logs.py b/backend/tests/test_ws_logs.py
new file mode 100644
index 0000000..f46a8be
--- /dev/null
+++ b/backend/tests/test_ws_logs.py
@@ -0,0 +1,119 @@
+import json
+import uuid
+from unittest.mock import AsyncMock
+
+from app.core.security import create_token
+
+
+def test_ws_logs_streams_container_log_for_valid_user(client, monkeypatch):
+ user_id = uuid.uuid4()
+ token = create_token(str(user_id), "access", 30)
+
+ async def fake_subscribe(channel: str):
+ assert channel == f"user:{user_id}:container:web-container:logs"
+ yield {
+ "event_name": "container.log",
+ "user_id": str(user_id),
+ "job_id": "j-1",
+ "payload": {
+ "container_id": "web-container",
+ "line": "listening on :8000",
+ "stream": "stdout",
+ "timestamp": "2026-05-13T10:00:00Z",
+ },
+ "timestamp": "2026-05-13T10:00:00Z",
+ }
+
+ async def fake_get_logs(_uid, _cid):
+ return [], None
+
+ monkeypatch.setattr("app.api.routers.ws._get_container_last_logs", fake_get_logs)
+ monkeypatch.setattr("app.api.routers.ws.subscribe", fake_subscribe)
+ monkeypatch.setattr(
+ "app.api.routers.ws._user_has_container_access", AsyncMock(return_value=True)
+ )
+
+ with client.websocket_connect(
+ f"/ws/users/{user_id}/containers/web-container/logs?token={token}"
+ ) as websocket:
+ payload = json.loads(websocket.receive_text())
+ assert payload["event_name"] == "container.log"
+ assert payload["payload"]["container_id"] == "web-container"
+ assert payload["payload"]["line"] == "listening on :8000"
+ assert payload["payload"]["stream"] == "stdout"
+
+
+def test_ws_logs_rejects_non_access_token(client):
+ # A refresh token must not be accepted on the logs websocket.
+ user_id = uuid.uuid4()
+ refresh_token = create_token(str(user_id), "refresh", 30)
+ with client.websocket_connect(
+ f"/ws/users/{user_id}/containers/web-container/logs?token={refresh_token}"
+ ) as websocket:
+ payload = websocket.receive_json()
+ assert payload["error"] == "invalid token"
+
+
+def test_ws_logs_rejects_user_mismatch(client):
+ user_id = uuid.uuid4()
+ other_user_id = uuid.uuid4()
+ token = create_token(str(user_id), "access", 30)
+
+ with client.websocket_connect(
+ f"/ws/users/{other_user_id}/containers/web-container/logs?token={token}"
+ ) as websocket:
+ payload = websocket.receive_json()
+ assert payload["error"] == "invalid token"
+
+
+def test_ws_logs_hydrates_cached_tail_before_subscribe(client, monkeypatch):
+ user_id = uuid.uuid4()
+ token = create_token(str(user_id), "access", 30)
+
+ async def fake_get_logs(_uid, _cid):
+ return ["boot line 1", "boot line 2"], "job-abc"
+
+ async def fake_subscribe(channel: str):
+ assert channel == f"user:{user_id}:container:web-container:logs"
+ yield {
+ "event_name": "container.log",
+ "user_id": str(user_id),
+ "job_id": "job-abc",
+ "payload": {
+ "container_id": "web-container",
+ "line": "live line",
+ "stream": "stdout",
+ "timestamp": "2026-05-13T10:00:01Z",
+ },
+ "timestamp": "2026-05-13T10:00:01Z",
+ }
+
+ monkeypatch.setattr("app.api.routers.ws._get_container_last_logs", fake_get_logs)
+ monkeypatch.setattr("app.api.routers.ws.subscribe", fake_subscribe)
+ monkeypatch.setattr(
+ "app.api.routers.ws._user_has_container_access", AsyncMock(return_value=True)
+ )
+
+ with client.websocket_connect(
+ f"/ws/users/{user_id}/containers/web-container/logs?token={token}"
+ ) as websocket:
+ first = json.loads(websocket.receive_text())
+ second = json.loads(websocket.receive_text())
+ third = json.loads(websocket.receive_text())
+ assert first["payload"]["line"] == "boot line 1"
+ assert second["payload"]["line"] == "boot line 2"
+ assert third["payload"]["line"] == "live line"
+
+
+def test_ws_logs_rejects_container_not_owned(client, monkeypatch):
+ user_id = uuid.uuid4()
+ token = create_token(str(user_id), "access", 30)
+ monkeypatch.setattr(
+ "app.api.routers.ws._user_has_container_access", AsyncMock(return_value=False)
+ )
+
+ with client.websocket_connect(
+ f"/ws/users/{user_id}/containers/foreign-container/logs?token={token}"
+ ) as websocket:
+ payload = websocket.receive_json()
+ assert payload["error"] == "forbidden"
diff --git a/context/refactor-backlog.md b/context/refactor-backlog.md
index af87d36..a17053b 100644
--- a/context/refactor-backlog.md
+++ b/context/refactor-backlog.md
@@ -15,7 +15,7 @@
| 2 | Split project upload orchestration from router into service-level use case (`backend/app/api/routers/project.py`) | 5 | 3 | 3 | 0.5 | Router currently owns scan + queue + payload defaults; extraction improves testability and policy control. |
| 3 | Centralize deploy status derivation contract (`backend/app/api/routers/compose.py`, `backend/app/workers/tasks.py`) | 4 | 2 | 3 | 0.5 | Keeps runtime-state semantics consistent between API response and worker state writes. |
| 4 | Introduce typed project-flow metadata object in backend schemas (`backend/app/application/schemas.py`, project/compose routers) | 4 | 2 | 4 | 0.0 | Replaces ad-hoc dict metadata usage and lowers merge/regression risk. |
-| 5 | Harden TODO/FIXME debt in frontend auth and API keys pages (`frontend/src/pages/Login.tsx`, `Register.tsx`, `ApiKeys.tsx`) | 3 | 1 | 2 | 1.0 | Small cleanup wins that reduce recurring warnings and behavior ambiguity. |
+| 5 | Harden TODO/FIXME debt in frontend auth pages (`frontend/src/pages/Login.tsx`, `Register.tsx`) | 3 | 1 | 2 | 1.0 | Small cleanup wins that reduce recurring warnings and behavior ambiguity. |
| 6 | Normalize scanner/analysis decision boundaries (`backend/app/application/services/project_scanner.py`, `analysis_service.py`, `workers/tasks.py`) | 4 | 3 | 4 | -1.0 | Clarifies scan-time metadata vs analysis-time decisions for long-term maintainability. |
## Batch plan
diff --git a/context/ui-and-jobs.md b/context/ui-and-jobs.md
index bb4b15e..27a69ac 100644
--- a/context/ui-and-jobs.md
+++ b/context/ui-and-jobs.md
@@ -1,6 +1,6 @@
# UI and job lifecycle notes
-Last updated: 2026-05-17
+Last updated: 2026-06-03
This file tracks product-truth behavior for UI, job lifecycle, and deploy/runtime handling.
@@ -9,7 +9,7 @@ This file tracks product-truth behavior for UI, job lifecycle, and deploy/runtim
**History dashboard**
- Fourth stat card is **"Container jobs"**: counts `job.type` **compose** plus **project** (not compose-only).
-- Each row has **Delete** (trash): `window.confirm`, `stopPropagation` so the row click still opens results/analysis. On success, list state and `jobListRef` update; `sessionStorage` keys `analysisJobId` / `projectJobId` cleared if they matched the deleted id.
+- Each row has **Delete** (trash): `window.confirm`, `stopPropagation` so the row click still opens results/analysis. On success, list state and `jobListRef` update; client session cleanup via [`frontend/src/utils/jobSessionCleanup.ts`](frontend/src/utils/jobSessionCleanup.ts) (`analysisJobId`, `projectJobId`, monitoring/execution state, deploy-stop flags, notification replay entry for that job).
**Delete analysis API**
@@ -17,6 +17,7 @@ This file tracks product-truth behavior for UI, job lifecycle, and deploy/runtim
- **404** if job missing or not owned.
- **409** if status is **queued** or **running** (avoid races with the worker).
- **409** for **compose** / **project** when deploy is **active** (same semantics as `compute_deploy_status` in [`backend/app/api/routers/compose.py`](backend/app/api/routers/compose.py)) — user must stop containers first.
+- Before DB delete: [`cleanup_job_artifacts`](backend/app/application/services/job_cleanup.py) removes project extract dir (`input_metadata.project_path` under `upload_dir/{user_id}`), compose deployment dir (`upload_dir/deployments/{user_id}/{job_id}`), and enqueues worker `teardown_job_runtime` (leftover compose stack from Redis deploy state + analysis/runtime image refs; best-effort; failures do not block delete).
- After successful DB delete: Redis keys `deploy:{user_id}:{job_id}` and `deploy-stop:{user_id}:{job_id}` are deleted (no ghost deploy UI).
- Client: [`frontend/src/utils/api.ts`](frontend/src/utils/api.ts) `jobs.delete(jobId)`.
@@ -25,10 +26,21 @@ This file tracks product-truth behavior for UI, job lifecycle, and deploy/runtim
- Upload flow currently queues project analysis for all detected Docker/Compose assets and sets image builds enabled in enqueue payload.
- Compose runtime remains manual from results/deploy actions (`run_stack`), not auto-run on upload completion.
+**Runtime stack teardown (DinD)**
+
+| Trigger | Containers | Images |
+|---------|------------|--------|
+| User **Stop** | `compose down --remove-orphans` (no `--rmi`) | Kept (redeploy friendly) |
+| Stack **unhealthy** (worker metrics) | Torn down automatically | Removed (`--rmi all` + best-effort `docker rmi` on deploy-state refs) |
+| Stack **self-exit** (all containers exited) | Torn down automatically | Removed (same as unhealthy) |
+| **History delete** (deploy inactive) | Best-effort via `teardown_job_runtime` if Redis deploy spec exists | Build tags + container images from deploy state |
+
+Worker helper: [`_teardown_deploy_stack`](backend/app/workers/tasks.py). Terminal deploy states `exited` and `unhealthy` are **inactive** in `compute_deploy_status` (not “Live”).
+
**Job state truthfulness**
- Workflow should preserve explicit scan-to-analysis state transitions where available (including `scanned` semantics in state-machine-aware flows).
-- UI status labels should never imply execution when runtime is terminal (`exited`, `failed`, `stopped_by_user`, `cleanup_completed`).
+- UI status labels should never imply execution when runtime is terminal (`exited`, `unhealthy`, `failed`, `stopped_by_user`, `cleanup_completed`).
**Analysis progress (project jobs)**
diff --git a/examples/README.md b/examples/README.md
index bb9e867..42b5246 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -86,25 +86,7 @@ Try:
bind mount). Project jobs are not hard-blocked from deploy, but the reasons
are rendered clearly in the Results view.
-## D. API keys + direct API
-
-1. Open the top-right **API Keys** link in the nav.
-2. Click **Create new key**. Copy the displayed raw key immediately (it is only
- shown once).
-3. Use it from a terminal:
-
-```bash
-API=http://localhost:3000
-KEY=dpa_XXXXXXXXXXXXXXXXXX
-
-curl -s -H "X-Api-Key: $KEY" $API/api/v1/users/me/history | jq .
-
-curl -s -H "X-Api-Key: $KEY" \
- -F "file=@examples/dockerfiles/issues.Dockerfile" \
- $API/api/v1/dockerfile/analyze | jq .
-```
-
-## E. Live event stream (optional)
+## D. Live event stream (optional)
Once a job is running, the WebSocket feed is available at
`ws://localhost:3000/ws/jobs/?token=` through the nginx
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index c5497c6..5090024 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -12,7 +12,7 @@ server {
server_name _;
# Aggressive body size so project archive uploads can pass through.
- client_max_body_size 100m;
+ client_max_body_size 200m;
# Light proxy defaults.
proxy_http_version 1.1;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2b12c77..6ca58d9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,6 +17,7 @@
"@rive-app/react-canvas": "^4.28.6",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
+ "echarts": "^5.6.0",
"gsap": "^3.15.0",
"lucide-react": "0.487.0",
"motion": "^12.40.0",
@@ -2797,6 +2798,22 @@
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
"license": "Apache-2.0"
},
+ "node_modules/echarts": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+ "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "5.6.1"
+ }
+ },
+ "node_modules/echarts/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -5196,6 +5213,21 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
+ "node_modules/zrender": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+ "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ },
+ "node_modules/zrender/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 45b7e15..610ab5e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
"@rive-app/react-canvas": "^4.28.6",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
+ "echarts": "^5.6.0",
"gsap": "^3.15.0",
"lucide-react": "0.487.0",
"motion": "^12.40.0",
diff --git a/frontend/src/components/AppFooter.tsx b/frontend/src/components/AppFooter.tsx
new file mode 100644
index 0000000..924b9aa
--- /dev/null
+++ b/frontend/src/components/AppFooter.tsx
@@ -0,0 +1,26 @@
+import { Github } from "lucide-react";
+
+const GITHUB_PROFILE_URL = "https://github.com/cppenloglou";
+const GITHUB_USERNAME = "cppenloglou";
+
+export function AppFooter() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/DockerLoader.tsx b/frontend/src/components/DockerLoader.tsx
index dd89da8..5a88661 100644
--- a/frontend/src/components/DockerLoader.tsx
+++ b/frontend/src/components/DockerLoader.tsx
@@ -1,12 +1,80 @@
-import { useEffect, useState } from "react";
+import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
-const MIN_LOADER_MS = 1500;
+const MIN_LOADER_MS = 1800;
const FILL_INTERVAL_MS = 50;
const FILL_STEP = 100 / (MIN_LOADER_MS / FILL_INTERVAL_MS);
+/** Title fully flooded by this %; remaining % holds before the cycle ends. */
+const FILL_TITLE_COMPLETE_AT = 88;
+const VIEW_Y_MIN = -36;
+const TITLE_FILL_TOP = VIEW_Y_MIN;
+
+/** Loader fill palette (slightly darker than blue-500 / pure white). */
+const LOADER_BLUE = "#1d4ed8";
+const LOADER_LIGHT = "#cbd5e1";
+
+const APP_TITLE = "Docker Analyzer";
+
+const VIEW_W = 300;
+const VIEW_H = 118;
+const VIEW_BOX_H = VIEW_H - VIEW_Y_MIN;
+const ICON_DRAW_SIZE = 64;
+const ICON_SCALE = 1.25;
+const ICON_VISUAL_SIZE = ICON_DRAW_SIZE * ICON_SCALE;
+const ICON_X = (VIEW_W - ICON_VISUAL_SIZE) / 2;
+const ICON_Y = 30;
+const TITLE_FONT_SIZE = 18;
+
+/** Arc bows upward; strong curve with ends near the whale. */
+const TITLE_ARC_PATH = `M 58 34 Q ${VIEW_W / 2} -12 ${VIEW_W - 58} 34`;
+
+const TITLE_TEXT_STYLE = {
+ fontSize: TITLE_FONT_SIZE,
+ fontWeight: 600,
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
+};
+
+function LoaderTitleText({
+ arcId,
+ fill,
+ stroke,
+ strokeWidth,
+}: {
+ arcId: string;
+ fill?: string;
+ stroke?: string;
+ strokeWidth?: number;
+}) {
+ return (
+
+
+ {APP_TITLE}
+
+
+ );
+}
+
+function globalWaterYFromFillPercent(percent: number): number {
+ const fillRange = VIEW_H - TITLE_FILL_TOP;
+ const t = Math.min(percent, FILL_TITLE_COMPLETE_AT) / FILL_TITLE_COMPLETE_AT;
+ return VIEW_H - t * fillRange;
+}
const DOCKER_PATH =
- "M 4 30 C 4 28 5 26 8 25 C 8 22 9 19 12 18 C 14 14 18 12 23 12 L 23 12 L 41 12 L 41 12 C 46 12 50 14 52 18 C 55 19 56 22 56 25 C 59 26 60 28 60 30 C 60 34 57 37 53 38 L 11 38 C 7 37 4 34 4 30 Z M 15 20 L 19 20 L 19 24 L 15 24 Z M 21 20 L 25 20 L 25 24 L 21 24 Z M 27 20 L 31 20 L 31 24 L 27 24 Z M 33 20 L 37 20 L 37 24 L 33 24 Z M 21 14 L 25 14 L 25 18 L 21 18 Z M 27 14 L 31 14 L 31 18 L 27 18 Z M 33 14 L 37 14 L 37 18 L 33 18 Z M 39 20 L 43 20 L 43 24 L 39 24 Z M 45 20 L 49 20 L 49 24 L 45 24 Z M 10 38 L 10 48 C 10 50 12 52 14 52 L 50 52 C 52 52 54 50 54 48 L 54 38 Z";
+ "M 4 30 C 4 28 5 26 8 25 C 8 22 9 19 12 18 C 14 14 18 12 23 12 L 23 12 L 41 12 L 41 12 C 46 12 50 14 52 18 C 55 19 56 22 56 25 C 59 26 60 28 60 30 C 60 34 57 37 53 38 L 11 38 C 7 37 4 34 4 30 Z M 15 22 L 19 22 L 19 26 L 15 26 Z M 21 22 L 25 22 L 25 26 L 21 26 Z M 27 22 L 31 22 L 31 26 L 27 26 Z M 33 22 L 37 22 L 37 26 L 33 26 Z M 21 16 L 25 16 L 25 20 L 21 20 Z M 27 16 L 31 16 L 31 20 L 27 20 Z M 33 16 L 37 16 L 37 20 L 33 20 Z M 39 22 L 43 22 L 43 26 L 39 26 Z M 45 22 L 49 22 L 49 26 L 45 26 Z M 10 38 L 10 48 C 10 50 12 52 14 52 L 50 52 C 52 52 54 50 54 48 L 54 38 Z";
+
+/** Container cubes (bottom Y = when the rising fill turns them white). */
+const DOCKER_CUBES: { d: string; bottom: number }[] = [
+ { d: "M 15 22 L 19 22 L 19 26 L 15 26 Z", bottom: 26 },
+ { d: "M 21 22 L 25 22 L 25 26 L 21 26 Z", bottom: 26 },
+ { d: "M 27 22 L 31 22 L 31 26 L 27 26 Z", bottom: 26 },
+ { d: "M 33 22 L 37 22 L 37 26 L 33 26 Z", bottom: 26 },
+ { d: "M 39 22 L 43 22 L 43 26 L 39 26 Z", bottom: 26 },
+ { d: "M 45 22 L 49 22 L 49 26 L 45 26 Z", bottom: 26 },
+ { d: "M 21 16 L 25 16 L 25 20 L 21 20 Z", bottom: 20 },
+ { d: "M 27 16 L 31 16 L 31 20 L 27 20 Z", bottom: 20 },
+ { d: "M 33 16 L 37 16 L 37 20 L 33 20 Z", bottom: 20 },
+];
interface DockerLoaderProps {
message?: string;
@@ -16,6 +84,14 @@ interface DockerLoaderProps {
export function DockerLoader({ message = "Loading...", fullScreen = true }: DockerLoaderProps) {
const [fillPercent, setFillPercent] = useState(0);
const reducedMotion = useReducedMotion();
+ const uid = useId().replace(/:/g, "");
+ const shapeClip = `docker-shape-${uid}`;
+ const iconFillBandClip = `docker-icon-fill-band-${uid}`;
+ const iconUnfillBandClip = `docker-icon-unfill-band-${uid}`;
+ const globalFillBandClip = `docker-global-fill-band-${uid}`;
+ const globalUnfillBandClip = `docker-global-unfill-band-${uid}`;
+ const titleTextClip = `docker-title-text-${uid}`;
+ const titleArcId = `docker-title-arc-${uid}`;
useEffect(() => {
const interval = setInterval(() => {
@@ -28,6 +104,12 @@ export function DockerLoader({ message = "Loading...", fullScreen = true }: Dock
return () => clearInterval(interval);
}, []);
+ const globalWaterY = globalWaterYFromFillPercent(fillPercent);
+ const iconWaterY = Math.min(
+ ICON_DRAW_SIZE,
+ Math.max(0, (globalWaterY - ICON_Y) / ICON_SCALE),
+ );
+
const containerClass = fullScreen
? "min-h-screen bg-slate-950 flex flex-col items-center justify-center"
: "flex-1 flex flex-col items-center justify-center min-h-[60vh]";
@@ -40,7 +122,7 @@ export function DockerLoader({ message = "Loading...", fullScreen = true }: Dock
transition={{ duration: 0.2 }}
>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {DOCKER_CUBES.map((cube, i) =>
+ iconWaterY <= cube.bottom ? (
+
+ ) : null,
+ )}
+
+
+
+
+
+
+
{message}
diff --git a/frontend/src/components/DragDropUpload.tsx b/frontend/src/components/DragDropUpload.tsx
index bf2e5fb..8fe988f 100644
--- a/frontend/src/components/DragDropUpload.tsx
+++ b/frontend/src/components/DragDropUpload.tsx
@@ -8,13 +8,18 @@ import { dragActiveVariants, dragActiveTransition } from "./motion/variants";
interface DragDropUploadProps {
onFileSelect: (file: File, hint?: DockerFileKind) => void;
onFilesSelect?: (files: File[], hint?: DockerFileKind) => void;
+ maxBatchFiles?: number;
}
const DOCKERFILE_ACCEPT = ".dockerfile,Dockerfile,dockerfile,text/plain";
const COMPOSE_ACCEPT = ".yml,.yaml,application/x-yaml,text/yaml,text/plain";
const DROP_ACCEPT = `${DOCKERFILE_ACCEPT},${COMPOSE_ACCEPT}`;
-export function DragDropUpload({ onFileSelect, onFilesSelect }: DragDropUploadProps) {
+export function DragDropUpload({
+ onFileSelect,
+ onFilesSelect,
+ maxBatchFiles = 10,
+}: DragDropUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const reducedMotion = useReducedMotion();
const dockerfileInputRef = useRef(null);
@@ -97,8 +102,10 @@ export function DragDropUpload({ onFileSelect, onFilesSelect }: DragDropUploadPr
Drop your Docker file here
-
- Supported: Dockerfile (any name), docker-compose.yml / compose.yaml
+
+ Drop one file to preview, or select multiple (up to {maxBatchFiles}) for
+ batch analysis. Supported: Dockerfile (any name), docker-compose.yml /
+ compose.yaml
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index a7cb9cf..06da047 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
-import { FileCode, History, Home, KeyRound, LogOut, BarChart3 } from "lucide-react";
+import { FileCode, History, Home, LogOut, BarChart3 } from "lucide-react";
+import { AppFooter } from "./AppFooter";
import { useAuth } from "../auth/AuthProvider";
import { NotificationEventBridge } from "./NotificationEventBridge";
import { NotificationPanel } from "./NotificationPanel";
@@ -68,10 +69,6 @@ export function Layout({ children }: LayoutProps) {
Research
-
-
- API Keys
-
);
}
diff --git a/frontend/src/components/NotificationEventBridge.tsx b/frontend/src/components/NotificationEventBridge.tsx
index 82532ec..9c6e853 100644
--- a/frontend/src/components/NotificationEventBridge.tsx
+++ b/frontend/src/components/NotificationEventBridge.tsx
@@ -60,6 +60,13 @@ function notifyFromEvent(event: DomainEvent) {
);
return;
case "container.stopped":
+ pushNotification(
+ "success",
+ "Containers Stopped",
+ "All containers have been successfully stopped",
+ { dedupeKey: `deploy.stop.completed:${event.job_id ?? "none"}` },
+ );
+ return;
case "project.runtime_stopped":
pushNotification(
"warning",
@@ -92,6 +99,7 @@ function notifyFromEvent(event: DomainEvent) {
export function NotificationEventBridge() {
const { user } = useAuth();
const socketRef = useRef(null);
+ const connectionGenRef = useRef(0);
useEffect(() => {
if (!user?.id) return;
@@ -147,11 +155,24 @@ export function NotificationEventBridge() {
const connect = () => {
if (closedByEffect) return;
+ const connectionGen = ++connectionGenRef.current;
const socket = ws.connectUserEvents(user.id);
socketRef.current = socket;
- void reconcileMissedJobOutcomes();
+
+ socket.onopen = () => {
+ if (connectionGen !== connectionGenRef.current) {
+ try {
+ socket.close();
+ } catch {
+ /* noop */
+ }
+ return;
+ }
+ void reconcileMissedJobOutcomes();
+ };
socket.onmessage = (rawEvent) => {
+ if (connectionGen !== connectionGenRef.current) return;
try {
const event = JSON.parse(rawEvent.data as string) as DomainEvent;
notifyFromEvent(event);
@@ -161,6 +182,7 @@ export function NotificationEventBridge() {
};
socket.onclose = () => {
+ if (connectionGen !== connectionGenRef.current) return;
if (closedByEffect) return;
reconnectTimer = setTimeout(connect, 2000);
};
@@ -169,16 +191,18 @@ export function NotificationEventBridge() {
connect();
return () => {
closedByEffect = true;
+ connectionGenRef.current += 1;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
- if (socketRef.current) {
+ const socket = socketRef.current;
+ socketRef.current = null;
+ if (socket?.readyState === WebSocket.OPEN) {
try {
- socketRef.current.close();
+ socket.close();
} catch {
- // noop
+ /* noop */
}
- socketRef.current = null;
}
};
}, [user?.id]);
diff --git a/frontend/src/components/QuickDemo.tsx b/frontend/src/components/QuickDemo.tsx
index 0e1097f..158cdbe 100644
--- a/frontend/src/components/QuickDemo.tsx
+++ b/frontend/src/components/QuickDemo.tsx
@@ -8,6 +8,7 @@ export function QuickDemo() {
const navigate = useNavigate();
const loadSampleFile = (type: "dockerfile" | "docker-compose") => {
+ sessionStorage.removeItem("batchAnalysis");
const fileData = {
name: type === "dockerfile" ? "Dockerfile" : "docker-compose.yml",
content:
@@ -16,24 +17,23 @@ export function QuickDemo() {
};
sessionStorage.setItem("uploadedFile", JSON.stringify(fileData));
- navigate("/upload");
+ navigate("/analysis");
};
return (
-
+
-
+
Try a Quick Demo
-
- Don't have a Docker file handy? Load a sample file to see the
- analyzer in action.
+
+ No Docker file handy? Runs analysis immediately with a sample file.
-
+
loadSampleFile("dockerfile")}
size="sm"
@@ -46,7 +46,7 @@ export function QuickDemo() {
onClick={() => loadSampleFile("docker-compose")}
size="sm"
variant="outline"
- className="border-blue-500/50 text-blue-300 hover:bg-blue-500/10"
+ className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Demo Compose
diff --git a/frontend/src/components/auth/AuthShell.tsx b/frontend/src/components/auth/AuthShell.tsx
index 4be2ec6..bc0d712 100644
--- a/frontend/src/components/auth/AuthShell.tsx
+++ b/frontend/src/components/auth/AuthShell.tsx
@@ -11,6 +11,8 @@ import {
Zap,
} from "lucide-react";
+import { AppFooter } from "../AppFooter";
+
interface AuthShellProps {
mode: "login" | "register";
children: ReactNode;
@@ -374,7 +376,7 @@ export function AuthShell({ mode, children }: AuthShellProps) {
] as const;
return (
-
+
@@ -444,7 +446,7 @@ export function AuthShell({ mode, children }: AuthShellProps) {
`}
-
+
@@ -496,6 +498,8 @@ export function AuthShell({ mode, children }: AuthShellProps) {
{children}
+
+
);
}
\ No newline at end of file
diff --git a/frontend/src/components/monitoring/ContainerMetricsPanel.tsx b/frontend/src/components/monitoring/ContainerMetricsPanel.tsx
new file mode 100644
index 0000000..b4855e6
--- /dev/null
+++ b/frontend/src/components/monitoring/ContainerMetricsPanel.tsx
@@ -0,0 +1,536 @@
+import { useMemo, type ReactNode } from "react";
+import {
+ Activity,
+ Cpu,
+ Database,
+ HardDrive,
+ MemoryStick,
+ Network,
+ Server,
+} from "lucide-react";
+import type { EChartsOption } from "echarts";
+
+import { Card } from "../ui/card";
+import { EChartPanel } from "./EChartPanel";
+import type { ContainerMetricsPayload, RuntimeContainerState } from "../../utils/api";
+import {
+ formatBps,
+ formatBytes,
+ type MetricSample,
+} from "../../utils/metricsSeries";
+
+const SLATE_AXIS = "#94a3b8";
+const SLATE_GRID = "#1e293b";
+const TOOLTIP_BG = "#0f172a";
+const TOOLTIP_BORDER = "#334155";
+
+interface ContainerMetricsPanelProps {
+ container: RuntimeContainerState | null;
+ containerLabel: string;
+ points: MetricSample[];
+ latestPayload: ContainerMetricsPayload | null;
+ connected: boolean;
+ runtimeTerminal: boolean;
+ reducedMotion: boolean;
+ multiContainer: boolean;
+}
+
+function baseChartOption(reducedMotion: boolean): Pick {
+ return {
+ animation: !reducedMotion,
+ textStyle: { color: SLATE_AXIS, fontSize: 10 },
+ };
+}
+
+function axisDefaults(): EChartsOption {
+ return {
+ grid: { left: 48, right: 16, top: 28, bottom: 48 },
+ xAxis: {
+ type: "category",
+ boundaryGap: false,
+ axisLine: { lineStyle: { color: SLATE_GRID } },
+ axisLabel: { color: SLATE_AXIS, fontSize: 9, hideOverlap: true },
+ },
+ yAxis: {
+ type: "value",
+ axisLine: { show: false },
+ splitLine: { lineStyle: { color: SLATE_GRID, type: "dashed" } },
+ axisLabel: { color: SLATE_AXIS, fontSize: 9 },
+ },
+ tooltip: {
+ trigger: "axis",
+ backgroundColor: TOOLTIP_BG,
+ borderColor: TOOLTIP_BORDER,
+ textStyle: { color: "#e2e8f0", fontSize: 11 },
+ },
+ dataZoom: [
+ {
+ type: "inside",
+ start: 0,
+ end: 100,
+ },
+ {
+ type: "slider",
+ height: 18,
+ bottom: 4,
+ borderColor: SLATE_GRID,
+ fillerColor: "rgba(59, 130, 246, 0.15)",
+ handleStyle: { color: "#64748b" },
+ textStyle: { color: SLATE_AXIS, fontSize: 9 },
+ },
+ ],
+ };
+}
+
+function ChartPlaceholder({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
+
+export function ContainerMetricsPanel({
+ container,
+ containerLabel,
+ points,
+ latestPayload,
+ connected,
+ runtimeTerminal,
+ reducedMotion,
+ multiContainer,
+}: ContainerMetricsPanelProps) {
+ const latest = points[points.length - 1] ?? null;
+ const netTotals = latestPayload?.network?.totals;
+ const ioStats = latestPayload?.io;
+ const pids = latestPayload?.pids;
+ const meta = latestPayload?.container;
+ const throttling = latestPayload?.cpu?.throttling;
+
+ const statusMessage = useMemo(() => {
+ if (!container) return "No container selected.";
+ if (points.length === 0 && runtimeTerminal) {
+ return "Container exited before metrics were collected.";
+ }
+ if (points.length === 0 && connected) return "Waiting for metrics…";
+ if (points.length === 0) return "Waiting for metrics stream…";
+ if (runtimeTerminal) return "Monitoring ended — showing last collected samples.";
+ return null;
+ }, [container, points.length, runtimeTerminal, connected]);
+
+ const labels = useMemo(() => points.map((p) => p.label), [points]);
+
+ const cpuOption = useMemo(
+ () => ({
+ ...baseChartOption(reducedMotion),
+ ...axisDefaults(),
+ xAxis: { ...axisDefaults().xAxis, data: labels },
+ yAxis: {
+ ...axisDefaults().yAxis,
+ name: "%",
+ nameTextStyle: { color: SLATE_AXIS, fontSize: 9 },
+ min: 0,
+ },
+ series: [
+ {
+ name: "CPU",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ areaStyle: { color: "rgba(52, 211, 153, 0.12)" },
+ lineStyle: { color: "#34d399", width: 2 },
+ itemStyle: { color: "#34d399" },
+ data: points.map((p) => p.cpuPercent),
+ },
+ ],
+ tooltip: {
+ ...axisDefaults().tooltip,
+ valueFormatter: (v) => (typeof v === "number" ? `${v.toFixed(2)}%` : "-"),
+ },
+ }),
+ [labels, points, reducedMotion],
+ );
+
+ const memoryOption = useMemo(
+ () => ({
+ ...baseChartOption(reducedMotion),
+ ...axisDefaults(),
+ legend: {
+ top: 0,
+ textStyle: { color: SLATE_AXIS, fontSize: 10 },
+ },
+ xAxis: { ...axisDefaults().xAxis, data: labels },
+ yAxis: [
+ {
+ type: "value",
+ name: "MB",
+ position: "left",
+ min: 0,
+ axisLine: { show: false },
+ splitLine: { lineStyle: { color: SLATE_GRID, type: "dashed" } },
+ axisLabel: { color: SLATE_AXIS, fontSize: 9 },
+ nameTextStyle: { color: SLATE_AXIS, fontSize: 9 },
+ },
+ {
+ type: "value",
+ name: "%",
+ position: "right",
+ min: 0,
+ max: 100,
+ axisLine: { show: false },
+ splitLine: { show: false },
+ axisLabel: { color: SLATE_AXIS, fontSize: 9 },
+ nameTextStyle: { color: SLATE_AXIS, fontSize: 9 },
+ },
+ ],
+ series: [
+ {
+ name: "Memory MB",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ yAxisIndex: 0,
+ areaStyle: { color: "rgba(168, 85, 247, 0.12)" },
+ lineStyle: { color: "#a855f7", width: 2 },
+ data: points.map((p) => p.memoryMb),
+ },
+ {
+ name: "Memory %",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ yAxisIndex: 1,
+ lineStyle: { color: "#818cf8", width: 1.5, type: "dashed" },
+ data: points.map((p) => p.memoryPercent),
+ },
+ ],
+ }),
+ [labels, points, reducedMotion],
+ );
+
+ const networkOption = useMemo(
+ () => ({
+ ...baseChartOption(reducedMotion),
+ ...axisDefaults(),
+ legend: { top: 0, textStyle: { color: SLATE_AXIS, fontSize: 10 } },
+ xAxis: { ...axisDefaults().xAxis, data: labels },
+ yAxis: {
+ ...axisDefaults().yAxis,
+ axisLabel: {
+ color: SLATE_AXIS,
+ fontSize: 9,
+ formatter: (v: number) => formatBytes(v),
+ },
+ },
+ series: [
+ {
+ name: "RX",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ lineStyle: { color: "#22d3ee", width: 2 },
+ data: points.map((p) => p.rxBps),
+ },
+ {
+ name: "TX",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ lineStyle: { color: "#38bdf8", width: 2 },
+ data: points.map((p) => p.txBps),
+ },
+ ],
+ tooltip: {
+ ...axisDefaults().tooltip,
+ valueFormatter: (v) => formatBps(typeof v === "number" ? v : null),
+ },
+ }),
+ [labels, points, reducedMotion],
+ );
+
+ const ioOption = useMemo(
+ () => ({
+ ...baseChartOption(reducedMotion),
+ ...axisDefaults(),
+ legend: { top: 0, textStyle: { color: SLATE_AXIS, fontSize: 10 } },
+ xAxis: { ...axisDefaults().xAxis, data: labels },
+ yAxis: {
+ ...axisDefaults().yAxis,
+ axisLabel: {
+ color: SLATE_AXIS,
+ fontSize: 9,
+ formatter: (v: number) => formatBytes(v),
+ },
+ },
+ series: [
+ {
+ name: "Read",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ lineStyle: { color: "#fb923c", width: 2 },
+ data: points.map((p) => p.readBps),
+ },
+ {
+ name: "Write",
+ type: "line",
+ smooth: true,
+ showSymbol: false,
+ lineStyle: { color: "#fbbf24", width: 2 },
+ data: points.map((p) => p.writeBps),
+ },
+ ],
+ tooltip: {
+ ...axisDefaults().tooltip,
+ valueFormatter: (v) => formatBps(typeof v === "number" ? v : null),
+ },
+ }),
+ [labels, points, reducedMotion],
+ );
+
+ const interfaces = useMemo(() => {
+ const raw = latestPayload?.network?.interfaces;
+ if (!raw || typeof raw !== "object") return [];
+ return Object.entries(raw).map(([name, data]) => ({
+ name,
+ data: (data ?? {}) as Record,
+ }));
+ }, [latestPayload?.network?.interfaces]);
+
+ if (!container) {
+ return (
+
+ No container selected for metrics.
+
+ );
+ }
+
+ const showLiveBadge = connected && !runtimeTerminal && points.length > 0;
+
+ return (
+
+
+
+
+ Container metrics
+ {containerLabel}
+
+
+ {multiContainer && (
+ Metrics follow the selected container above.
+ )}
+ {showLiveBadge && streaming }
+ {runtimeTerminal && points.length > 0 && (
+ ended
+ )}
+ {!runtimeTerminal && !connected && points.length === 0 && (
+ connecting…
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {statusMessage && points.length === 0 ? (
+
+ ) : (
+
+
+ {points.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {points.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {points.length < 2 ? (
+
+ ) : (
+
+ )}
+
+
+ {points.length < 2 ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+ Container
+
+ Name
+ {meta?.name ?? container.name ?? "-"}
+ Image
+ {meta?.image ?? container.image ?? "-"}
+ IP
+ {meta?.ip_address ?? "-"}
+ Started
+
+ {meta?.started_at ? new Date(meta.started_at).toLocaleString() : "-"}
+
+ Restarts
+ {meta?.restart_count ?? container.restart_count ?? "-"}
+ Mounts
+
+ {Array.isArray(meta?.mounts) ? meta.mounts.length : 0}
+
+
+
+
+ Ports
+ {!meta?.ports?.length ? (
+ No port mappings.
+ ) : (
+
+ {meta.ports.map((p, i) => (
+
+ {p.container_port}
+ {p.host_bindings && p.host_bindings.length > 0 && (
+
+ {" → "}
+ {p.host_bindings.map((b, j) => (
+
+ {b.host_ip || "0.0.0.0"}:{b.host_port}
+ {j < (p.host_bindings?.length ?? 0) - 1 ? ", " : ""}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ Network interfaces
+ {interfaces.length === 0 ? (
+ No per-interface data yet.
+ ) : (
+
+ {interfaces.map((iface) => (
+
+
{iface.name}
+
+ RX: {formatBytes(iface.data.rx_bytes as number | undefined)}
+ TX: {formatBytes(iface.data.tx_bytes as number | undefined)}
+
+ Pkts: {String(iface.data.rx_packets ?? "-")}/{String(iface.data.tx_packets ?? "-")}
+
+
+ Err: {String(iface.data.rx_errors ?? "-")}/{String(iface.data.tx_errors ?? "-")}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function KpiCard({
+ icon: Icon,
+ iconClass,
+ label,
+ value,
+ sub,
+}: {
+ icon: typeof Cpu;
+ iconClass: string;
+ label: string;
+ value: string;
+ sub?: string;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+ {sub && {sub}
}
+
+ );
+}
+
+function ChartCard({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/monitoring/EChartPanel.tsx b/frontend/src/components/monitoring/EChartPanel.tsx
new file mode 100644
index 0000000..1e3ad4d
--- /dev/null
+++ b/frontend/src/components/monitoring/EChartPanel.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useRef } from "react";
+import * as echarts from "echarts/core";
+import { LineChart } from "echarts/charts";
+import {
+ DataZoomComponent,
+ GridComponent,
+ LegendComponent,
+ TooltipComponent,
+} from "echarts/components";
+import { CanvasRenderer } from "echarts/renderers";
+import type { EChartsOption } from "echarts";
+
+import { cn } from "../ui/utils";
+
+echarts.use([
+ LineChart,
+ GridComponent,
+ TooltipComponent,
+ LegendComponent,
+ DataZoomComponent,
+ CanvasRenderer,
+]);
+
+interface EChartPanelProps {
+ option: EChartsOption;
+ className?: string;
+ height?: number | string;
+ "aria-label"?: string;
+}
+
+export function EChartPanel({
+ option,
+ className,
+ height = 200,
+ "aria-label": ariaLabel,
+}: EChartPanelProps) {
+ const containerRef = useRef(null);
+ const chartRef = useRef(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const chart = echarts.init(el, undefined, { renderer: "canvas" });
+ chartRef.current = chart;
+
+ const observer = new ResizeObserver(() => {
+ chart.resize();
+ });
+ observer.observe(el);
+
+ return () => {
+ observer.disconnect();
+ chart.dispose();
+ chartRef.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ chartRef.current?.setOption(option, { notMerge: false, lazyUpdate: true });
+ }, [option]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx
index b511109..1b017c5 100644
--- a/frontend/src/components/ui/tabs.tsx
+++ b/frontend/src/components/ui/tabs.tsx
@@ -57,7 +57,10 @@ function TabsContent({
return (
);
diff --git a/frontend/src/pages/AnalysisProgress.tsx b/frontend/src/pages/AnalysisProgress.tsx
index d4fb1df..bab50cc 100644
--- a/frontend/src/pages/AnalysisProgress.tsx
+++ b/frontend/src/pages/AnalysisProgress.tsx
@@ -22,6 +22,26 @@ import {
type StepStatus = "pending" | "running" | "complete" | "error";
+interface StoredUploadedFile {
+ name: string;
+ content: string;
+ type: string;
+}
+
+/** Survives StrictMode remount so upload enqueue runs at most once per file. */
+let uploadEnqueuePromise: Promise | null = null;
+
+async function enqueueUploadedFile(uploadedFile: StoredUploadedFile): Promise {
+ const file = new File([uploadedFile.content], uploadedFile.name, {
+ type: "text/plain",
+ });
+ const response =
+ uploadedFile.type === "docker-compose"
+ ? await composeApi.analyze(file)
+ : await dockerfileApi.analyze(file);
+ return response.job_id;
+}
+
interface AnalysisStep {
id: string;
label: string;
@@ -180,7 +200,7 @@ export function AnalysisProgress() {
const [steps, setSteps] = useState(() =>
BASE_STEPS.map((step) => ({ ...step })),
);
- const [jobId, setJobId] = useState(queryJobId);
+ const jobId = queryJobId;
const [jobStatus, setJobStatus] = useState(null);
const [progressValue, setProgressValue] = useState(queryJobId ? 15 : 5);
const [errorMessage, setErrorMessage] = useState(null);
@@ -265,35 +285,29 @@ export function AnalysisProgress() {
if (!activeJobId) {
const storedFile = sessionStorage.getItem("uploadedFile");
- if (!storedFile) {
+ if (!storedFile && !uploadEnqueuePromise) {
navigate("/");
return;
}
- const uploadedFile = JSON.parse(storedFile) as {
- name: string;
- content: string;
- type: string;
- };
- pushLog({
- message: `Uploading ${uploadedFile.name} to the analysis queue...`,
- tone: "info",
- });
- const file = new File([uploadedFile.content], uploadedFile.name, {
- type: "text/plain",
- });
- const response =
- uploadedFile.type === "docker-compose"
- ? await composeApi.analyze(file)
- : await dockerfileApi.analyze(file);
- if (cancelled) return;
- activeJobId = response.job_id;
- setJobId(activeJobId);
- setJobStatus("queued");
- setProgressValue(25);
- pushLog({
- message: `Job ${activeJobId} queued (status: ${response.status})`,
- tone: "success",
- });
+
+ if (!uploadEnqueuePromise && storedFile) {
+ const uploadedFile = JSON.parse(storedFile) as StoredUploadedFile;
+ pushLog({
+ message: `Uploading ${uploadedFile.name} to the analysis queue...`,
+ tone: "info",
+ });
+ uploadEnqueuePromise = enqueueUploadedFile(uploadedFile).finally(() => {
+ uploadEnqueuePromise = null;
+ });
+ }
+
+ if (uploadEnqueuePromise) {
+ activeJobId = await uploadEnqueuePromise;
+ if (cancelled) return;
+ sessionStorage.setItem("analysisJobId", activeJobId);
+ navigate(`/analysis?jobId=${activeJobId}`, { replace: true });
+ return;
+ }
} else {
pushLog({
message: `Attaching to existing job ${activeJobId}`,
diff --git a/frontend/src/pages/ApiKeys.tsx b/frontend/src/pages/ApiKeys.tsx
deleted file mode 100644
index 3fbdea9..0000000
--- a/frontend/src/pages/ApiKeys.tsx
+++ /dev/null
@@ -1,282 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-import {
- Check,
- Copy,
- KeyRound,
- Loader2,
- Plus,
- ShieldAlert,
- Trash2,
-} from "lucide-react";
-
-import { DockerLoader, useMinLoader } from "../components/DockerLoader";
-import { Layout } from "../components/Layout";
-import { MotionPage } from "../components/motion";
-import { Card } from "../components/ui/card";
-import { Button } from "../components/ui/button";
-import { Badge } from "../components/ui/badge";
-import {
- ApiError,
- apiKeys as apiKeysApi,
- type ApiKey,
-} from "../utils/api";
-
-export function ApiKeys() {
- const [keys, setKeys] = useState([]);
- const [loading, setLoading] = useState(true);
- const ready = useMinLoader(!loading);
- const [error, setError] = useState(null);
- const [creating, setCreating] = useState(false);
- const [revokingId, setRevokingId] = useState(null);
- const [freshlyCreated, setFreshlyCreated] = useState<{
- id: string;
- key: string;
- key_prefix: string;
- } | null>(null);
- const [copied, setCopied] = useState(false);
-
- const loadKeys = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const list = await apiKeysApi.list();
- setKeys(list);
- } catch (err) {
- const message =
- err instanceof ApiError
- ? err.message
- : err instanceof Error
- ? err.message
- : "Failed to load keys.";
- setError(message);
- toast.error(message);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- loadKeys();
- }, [loadKeys]);
-
- const handleCreate = async () => {
- setCreating(true);
- try {
- const result = await apiKeysApi.create();
- setFreshlyCreated(result);
- toast.success("API key created. Copy it now - it will not be shown again.");
- await loadKeys();
- } catch (err) {
- const message =
- err instanceof ApiError
- ? err.message
- : err instanceof Error
- ? err.message
- : "Failed to create key.";
- toast.error(message);
- } finally {
- setCreating(false);
- }
- };
-
- const handleRevoke = async (id: string) => {
- const confirmed = window.confirm(
- "Revoke this API key? Any clients using it will stop working immediately.",
- );
- if (!confirmed) return;
- setRevokingId(id);
- try {
- await apiKeysApi.revoke(id);
- toast.success("API key revoked");
- setKeys((prev) => prev.filter((item) => item.id !== id));
- } catch (err) {
- const message =
- err instanceof ApiError
- ? err.message
- : err instanceof Error
- ? err.message
- : "Failed to revoke key.";
- toast.error(message);
- } finally {
- setRevokingId(null);
- }
- };
-
- const copyFreshKey = async () => {
- if (!freshlyCreated) return;
- try {
- await navigator.clipboard.writeText(freshlyCreated.key);
- setCopied(true);
- setTimeout(() => setCopied(false), 2500);
- toast.success("Key copied to clipboard");
- } catch {
- toast.error("Clipboard unavailable - copy manually");
- }
- };
-
- const prefixBlocks = useMemo(
- () =>
- keys.map((key) => ({
- ...key,
- created: new Date(key.created_at).toLocaleString(),
- })),
- [keys],
- );
-
- return (
-
-
-
-
-
-
- API Keys
-
-
- Create long-lived API keys to authenticate requests without a JWT.
-
-
-
- {creating ? (
-
- ) : (
-
- )}
- Create new key
-
-
-
-
- Usage
-
- Send the key in an X-Api-Key request
- header instead of an Authorization{" "}
- bearer token. Example:
-
-
- curl -H "X-Api-Key: dpa_XXXXXX" {" "}
- https://example.com/api/v1/users/me/history
-
-
-
- {freshlyCreated && (
-
-
-
-
-
- Copy this key now - it will not be shown again
-
-
- Stored prefix: {" "}
-
- {freshlyCreated.key_prefix}
-
-
-
-
- {freshlyCreated.key}
-
-
- {copied ? (
- <>
- Copied
- >
- ) : (
- <>
- Copy
- >
- )}
-
- setFreshlyCreated(null)}
- variant="ghost"
- className="text-slate-400 hover:text-white"
- >
- Dismiss
-
-
-
-
-
- )}
-
- {!ready && (
-
- )}
-
- {ready && error && (
-
- {error}
-
- )}
-
- {ready && !error && prefixBlocks.length === 0 && (
-
-
- No API keys yet.
-
- Create your first key
-
-
- )}
-
- {ready && prefixBlocks.length > 0 && (
-
- {prefixBlocks.map((item) => (
-
-
-
-
-
-
-
- {item.key_prefix}
- ...
-
-
- Created {item.created}
-
-
-
- id: {item.id.slice(0, 8)}
-
-
- handleRevoke(item.id)}
- disabled={revokingId === item.id}
- className="border-red-500/40 text-red-300 hover:bg-red-500/10"
- >
- {revokingId === item.id ? (
-
- ) : (
-
- )}
- Revoke
-
-
- ))}
-
- )}
-
-
-
- );
-}
diff --git a/frontend/src/pages/ContainerExecution.tsx b/frontend/src/pages/ContainerExecution.tsx
index f3544e6..004e3ff 100644
--- a/frontend/src/pages/ContainerExecution.tsx
+++ b/frontend/src/pages/ContainerExecution.tsx
@@ -30,6 +30,7 @@ import {
type Job,
type RunnabilityMeta,
} from "../utils/api";
+import { clearSessionStopping, markSessionStopping } from "../utils/deploySession";
import { clearState, loadState, saveState } from "../utils/monitoringState";
import { pushNotification } from "../utils/notifications";
@@ -232,7 +233,7 @@ export function ContainerExecution() {
setStopping(false);
setDeployPhase("failed");
clearDeployTimeout();
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
if (!options?.keepExitData) {
resetExecutionViewState();
}
@@ -383,7 +384,7 @@ export function ContainerExecution() {
clearDeployTimeout();
setContainerIds([]);
setDeployJobId(null);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
if (stateKey) clearState(stateKey);
toast.success("Compose stack stopped");
pushNotification("warning", "Containers Stopped", "All containers have been stopped", {
@@ -412,7 +413,7 @@ export function ContainerExecution() {
setStopping(false);
setContainerIds([]);
setDeployJobId(null);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
if (stateKey) clearState(stateKey);
pushLog({
message: `Cleanup completed for ${projectName}`,
@@ -522,7 +523,7 @@ export function ContainerExecution() {
setDeployJobId(null);
resetExecutionViewState();
if (stateKey) clearState(stateKey);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
return;
}
setStackRunning(false);
@@ -559,7 +560,7 @@ export function ContainerExecution() {
setDeployJobId(null);
resetExecutionViewState();
if (stateKey) clearState(stateKey);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
} else {
setStackRunning(false);
setStopping(false);
@@ -568,7 +569,7 @@ export function ContainerExecution() {
setDeployJobId(null);
resetExecutionViewState();
if (stateKey) clearState(stateKey);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
}
} catch (err) {
if (cancelled) return;
@@ -691,8 +692,8 @@ export function ContainerExecution() {
const previousContainerIds = containerIds;
setStopping(true);
setStackRunning(false);
- setContainerIds([]);
- sessionStorage.setItem("dqa:containerStatus", "stopping");
+ setDeployPhase("idle");
+ markSessionStopping(job.id);
pushLog({ message: `Stop requested for deploy ${job.id}`, tone: "warning" });
try {
const response = await composeApi.stopDeploy({ job_id: job.id });
@@ -706,17 +707,28 @@ export function ContainerExecution() {
dedupeKey: `deploy.stop.requested:${job.id}`,
});
- for (let i = 0; i < 30; i++) {
+ let confirmed = false;
+ for (let i = 0; i < 45; i++) {
if (unmountedRef.current) break;
- await new Promise((r) => setTimeout(r, 2000));
+ await new Promise((r) => setTimeout(r, 1000));
if (unmountedRef.current) break;
try {
const status = await composeApi.deployStatus(job.id);
- if (!status.active) {
+ const rs = status.runtime_state ?? "none";
+ if (rs === "stopping") {
+ setStopping(true);
+ setStackRunning(false);
+ }
+ if (
+ rs === "stopped_by_user" ||
+ (!status.active && (status.stopped_by_user || rs === "none"))
+ ) {
+ confirmed = true;
if (unmountedRef.current) break;
setDeployJobId(null);
+ setContainerIds([]);
if (stateKey) clearState(stateKey);
- sessionStorage.removeItem("dqa:containerStatus");
+ if (job?.id) clearSessionStopping(job.id);
pushLog({ message: "Stack confirmed stopped", tone: "success" });
pushNotification("success", "Containers Stopped", "All containers have been successfully stopped", {
dedupeKey: `deploy.stop.completed:${job.id}`,
@@ -727,17 +739,39 @@ export function ContainerExecution() {
break;
}
}
+ if (!confirmed && !unmountedRef.current) {
+ pushLog({
+ message: "Stop still in progress; refresh Monitoring for latest state",
+ tone: "warning",
+ });
+ }
} catch (err) {
const message = err instanceof ApiError ? err.message : "Failed to stop deployment";
pushLog({ message, tone: "error" });
toast.error(message);
+ if (job?.id) clearSessionStopping(job.id);
if (!unmountedRef.current) {
setStackRunning(true);
setContainerIds(previousContainerIds);
+ setStopping(false);
}
} finally {
if (!unmountedRef.current) {
- setStopping(false);
+ try {
+ const status = await composeApi.deployStatus(job.id);
+ const rs = status.runtime_state ?? "none";
+ if (
+ rs === "stopped_by_user" ||
+ (!status.active && (status.stopped_by_user || rs === "none"))
+ ) {
+ setStopping(false);
+ setStackRunning(false);
+ setContainerIds([]);
+ if (job?.id) clearSessionStopping(job.id);
+ }
+ } catch {
+ // Keep stopping UI until a later deployStatus poll or page reload confirms state.
+ }
}
}
};
diff --git a/frontend/src/pages/FileUpload.tsx b/frontend/src/pages/FileUpload.tsx
index 94d0c03..777359e 100644
--- a/frontend/src/pages/FileUpload.tsx
+++ b/frontend/src/pages/FileUpload.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Layout } from "../components/Layout";
import { CodePreview } from "../components/CodePreview";
@@ -8,16 +8,25 @@ import { Card } from "../components/ui/card";
import { FileCode, Play, ArrowLeft } from "lucide-react";
import { Badge } from "../components/ui/badge";
+type UploadedFileData = {
+ name: string;
+ content: string;
+ type: string;
+};
+
+function readUploadedFile(): UploadedFileData | null {
+ const stored = sessionStorage.getItem("uploadedFile");
+ if (!stored) return null;
+ try {
+ return JSON.parse(stored) as UploadedFileData;
+ } catch {
+ return null;
+ }
+}
+
export function FileUpload() {
const navigate = useNavigate();
- const [fileData] = useState<{
- name: string;
- content: string;
- type: string;
- } | null>(() => {
- const stored = sessionStorage.getItem("uploadedFile");
- return stored ? JSON.parse(stored) : null;
- });
+ const fileData = readUploadedFile();
useEffect(() => {
if (!fileData) {
diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx
index fbb9b38..9bd5036 100644
--- a/frontend/src/pages/History.tsx
+++ b/frontend/src/pages/History.tsx
@@ -16,7 +16,9 @@ import { Layout } from "../components/Layout";
import { Card } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Badge } from "../components/ui/badge";
+import { useAuth } from "../auth/AuthProvider";
import { ApiError, compose as composeApi, jobs as jobsApi, type Job } from "../utils/api";
+import { clearJobSessionState } from "../utils/jobSessionCleanup";
import { MotionPage, StaggerList, StaggerItem, MotionCard } from "../components/motion";
interface HistoryItem {
@@ -79,6 +81,7 @@ function formatTimestamp(date: Date) {
}
export function History() {
+ const { user } = useAuth();
const navigate = useNavigate();
const [jobList, setJobList] = useState([]);
const [loading, setLoading] = useState(true);
@@ -229,16 +232,7 @@ export function History() {
next.delete(item.id);
return next;
});
- try {
- if (sessionStorage.getItem("analysisJobId") === item.id) {
- sessionStorage.removeItem("analysisJobId");
- }
- if (sessionStorage.getItem("projectJobId") === item.id) {
- sessionStorage.removeItem("projectJobId");
- }
- } catch {
- // ignore
- }
+ clearJobSessionState(item.id, user?.id ?? null);
toast.success("Analysis removed from history");
} catch (err) {
const message =
diff --git a/frontend/src/pages/Landing.tsx b/frontend/src/pages/Landing.tsx
index 43c1b9f..f71d155 100644
--- a/frontend/src/pages/Landing.tsx
+++ b/frontend/src/pages/Landing.tsx
@@ -12,6 +12,7 @@ import {
CheckCircle,
Play,
Package,
+ Info,
} from "lucide-react";
import { Card } from "../components/ui/card";
import { Button } from "../components/ui/button";
@@ -146,15 +147,31 @@ export function Landing() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.08 }}
>
- Upload your Dockerfile or docker-compose.yml to analyze for errors,
- security issues, and best practices. Get instant feedback from
- industry-standard linters.
+ Upload a single Dockerfile or docker-compose.yml—or batch up to{" "}
+ {MAX_BATCH_FILES} files of the same kind—for linting, security checks,
+ and best-practice feedback from industry-standard tools.
{/* Upload Area */}
-
-
+
+
+
+
+
+
+ Batch analysis: select
+ up to {MAX_BATCH_FILES} Dockerfiles or Compose files at once (not mixed).
+ All jobs are queued together and tracked on the batch progress page (
+ /analysis/batch ).
+
+
+
+
{/* Or Upload Project */}
@@ -332,11 +349,6 @@ export function Landing() {
-
- {/* Quick Demo */}
-
-
-
diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx
index 336aee8..3b40889 100644
--- a/frontend/src/pages/Monitoring.tsx
+++ b/frontend/src/pages/Monitoring.tsx
@@ -1,111 +1,103 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { toast } from "sonner";
import {
Activity,
AlertTriangle,
ArrowLeft,
- Cpu,
- Database,
+ ArrowRight,
+ ChevronLeft,
ExternalLink,
Globe,
- HardDrive,
- MemoryStick,
- Network,
PlugZap,
RefreshCw,
- Server,
+ Terminal,
XCircle,
} from "lucide-react";
-import {
- CartesianGrid,
- Line,
- LineChart,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
import { Layout } from "../components/Layout";
+import { ContainerMetricsPanel } from "../components/monitoring/ContainerMetricsPanel";
import { Card } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { DockerLoader } from "../components/DockerLoader";
-import { TerminalLog, type TerminalLogEntry } from "../components/TerminalLog";
import { useAuth } from "../auth/AuthProvider";
-import { MotionPage, StaggerList, StaggerItem } from "../components/motion";
+import { MotionPage } from "../components/motion";
import { motion, useReducedMotion } from "motion/react";
import {
+ buildPreviewProxyEmbedUrl,
compose as composeApi,
+ safeCloseSocket,
ws,
+ type ContainerLogPayload,
type ContainerMetricsPayload,
+ type DeployRuntimeState,
+ type DeployStatusResponse,
type DomainEvent,
+ type PreviewCheckResponse,
+ type RuntimeContainerState,
} from "../utils/api";
+import {
+ appendMetricSample,
+ PERSIST_METRIC_POINTS,
+ trimPayloadForPersist,
+ type MetricSample,
+} from "../utils/metricsSeries";
+import { collectBrowserProxyHints, openPopOutPreview } from "../utils/previewBrowser";
+import { localPreviewIssue } from "../utils/previewCheck";
+import { clearSessionStopping, isSessionStopping } from "../utils/deploySession";
import { loadState, saveState } from "../utils/monitoringState";
-interface MetricPoint {
- timestamp: number;
- label: string;
- cpuPercent: number | null;
- memoryMb: number | null;
-}
+type LogStream = "stdout" | "stderr" | "system";
+type LogTone = "info" | "warning" | "error" | "system";
-interface ContainerExitedState {
- exit_code?: number | null;
- error?: string | null;
- started_at?: string | null;
- finished_at?: string | null;
- restart_count?: number | null;
- oom_killed?: boolean | null;
- last_logs?: string[] | null;
-}
-
-interface ContainerState {
- points: MetricPoint[];
- logs: TerminalLogEntry[];
- latestPayload: ContainerMetricsPayload | null;
- connected: boolean;
- exited?: ContainerExitedState | null;
-}
-
-interface MonitoringPersistedState {
- containerStates: Record
;
- selectedContainerId: string | null;
-}
-
-interface ProblematicAlertState {
- open: boolean;
- message: string;
+interface LogLine {
+ stream: LogStream;
+ line: string;
+ timestamp?: string | null;
+ tone: LogTone;
}
interface PreviewTarget {
key: string;
label: string;
url: string;
+ port: number | null;
+ service: string | null;
}
-const MAX_POINTS = 120;
-const MONITORING_STATE_TTL_MS = 1000 * 60 * 60 * 6;
+interface PersistedMetricsState {
+ points: MetricSample[];
+ latest: ContainerMetricsPayload | null;
+}
-function formatBytes(bytes: number | null | undefined): string {
- if (bytes == null) return "-";
- if (bytes <= 0) return "0 B";
- const units = ["B", "KB", "MB", "GB"];
- let value = bytes;
- let unit = 0;
- while (value > 1024 && unit < units.length - 1) {
- value /= 1024;
- unit += 1;
- }
- return `${value.toFixed(2)} ${units[unit]}`;
+interface PersistedMonitoringState {
+ selectedPreviewUrl: string;
+ previewAddress: string;
+ logsByContainer: Record;
+ metricsByContainer?: Record;
}
-function normalizeContainerPort(raw: string | null | undefined): string | null {
+const MONITORING_STATE_TTL_MS = 1000 * 60 * 60 * 6;
+const MAX_LOG_LINES = 400;
+const PERSIST_LOG_LINES = 200;
+const DEPLOY_POLL_MS = 4000;
+const DEPLOY_POLL_FAST_MS = 1000;
+const WEB_PORT_PRIORITY = [80, 3000, 5173, 8000, 8080, 5000];
+const TERMINAL_CONTAINER_STATUSES = new Set(["exited", "dead", "removing"]);
+const TERMINAL_RUNTIME_STATES = new Set([
+ "none",
+ "exited",
+ "failed",
+ "stopped_by_user",
+ "cleanup_completed",
+]);
+const ERROR_PATTERN = /(error|exception|fatal|traceback|panic|fail(ed|ure)?)/i;
+
+function normalizeContainerPort(raw: string | null | undefined): number | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const [port] = trimmed.split("/");
- return port && /^\d+$/.test(port) ? port : null;
+ return port && /^\d+$/.test(port) ? Number(port) : null;
}
function isLoopbackBinding(hostIp: string): boolean {
@@ -113,6 +105,648 @@ function isLoopbackBinding(hostIp: string): boolean {
return normalized === "0.0.0.0" || normalized === "::" || normalized === ":::" || normalized === "";
}
+function classifyLogTone(stream: LogStream, line: string): LogTone {
+ if (stream === "system") return "system";
+ if (ERROR_PATTERN.test(line)) return "error";
+ if (stream === "stderr") return "warning";
+ return "info";
+}
+
+function lastLogsToEntries(lastLogs: string[] | null | undefined): LogLine[] {
+ return (lastLogs ?? [])
+ .filter((line): line is string => typeof line === "string" && line.length > 0)
+ .map((line) => ({
+ stream: "stdout" as const,
+ line,
+ timestamp: null,
+ tone: classifyLogTone("stdout", line),
+ }));
+}
+
+function buildPreviewTargets(
+ containers: RuntimeContainerState[],
+ dindIp: string | null,
+): PreviewTarget[] {
+ const hostname =
+ typeof window !== "undefined" && window.location.hostname
+ ? window.location.hostname
+ : "localhost";
+ const seen = new Set();
+ const candidates: PreviewTarget[] = [];
+
+ for (const container of containers) {
+ const service = container.service || container.name || null;
+ for (const portInfo of container.ports ?? []) {
+ const containerPort = normalizeContainerPort(portInfo.container_port);
+ for (const binding of portInfo.host_bindings ?? []) {
+ const rawHostIp = String(binding.host_ip ?? "").trim();
+ const hostPort = String(binding.host_port ?? "").trim();
+ if (!hostPort) continue;
+ const host = isLoopbackBinding(rawHostIp) ? dindIp || hostname : rawHostIp;
+ const url = `http://${host}:${hostPort}`;
+ if (seen.has(url)) continue;
+ seen.add(url);
+ candidates.push({
+ key: `${container.id}-${host}-${hostPort}`,
+ label: `${service ? `${service} ` : ""}${host}:${hostPort}${containerPort ? ` -> ${containerPort}` : ""}`,
+ url,
+ port: containerPort,
+ service,
+ });
+ }
+ }
+ }
+
+ candidates.sort((a, b) => {
+ const ai = a.port != null ? WEB_PORT_PRIORITY.indexOf(a.port) : -1;
+ const bi = b.port != null ? WEB_PORT_PRIORITY.indexOf(b.port) : -1;
+ const aRank = ai === -1 ? WEB_PORT_PRIORITY.length : ai;
+ const bRank = bi === -1 ? WEB_PORT_PRIORITY.length : bi;
+ return aRank - bRank;
+ });
+
+ return candidates;
+}
+
+function containerStatusBadge(
+ container: RuntimeContainerState,
+ shuttingDown = false,
+): {
+ label: string;
+ className: string;
+ dotClassName: string;
+} {
+ const status = (container.status ?? "").toLowerCase();
+ const health = (container.health_status ?? "").toLowerCase();
+ if (
+ shuttingDown &&
+ !TERMINAL_CONTAINER_STATUSES.has(status) &&
+ status !== "exited"
+ ) {
+ return {
+ label: "Stopping",
+ className: "border-slate-600 bg-slate-800/80 text-slate-300",
+ dotClassName: "bg-slate-400",
+ };
+ }
+ if (TERMINAL_CONTAINER_STATUSES.has(status) || status === "exited") {
+ return {
+ label: `Exited${container.exit_code != null ? ` (${container.exit_code})` : ""}`,
+ className: "border-red-500/40 bg-red-500/10 text-red-300",
+ dotClassName: "bg-red-500",
+ };
+ }
+ if (health === "unhealthy") {
+ return {
+ label: "Unhealthy",
+ className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
+ dotClassName: "bg-amber-400",
+ };
+ }
+ if (["running", "paused", "restarting"].includes(status)) {
+ return {
+ label: status === "running" ? "Running" : status.charAt(0).toUpperCase() + status.slice(1),
+ className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
+ dotClassName: "bg-emerald-400",
+ };
+ }
+ return {
+ label: container.status ?? "Unknown",
+ className: "border-slate-700 bg-slate-900 text-slate-400",
+ dotClassName: "bg-slate-600",
+ };
+}
+
+function containerLabel(container: RuntimeContainerState): string {
+ return container.service || container.name || container.id.slice(0, 12);
+}
+
+function logToneClass(tone: LogTone): string {
+ switch (tone) {
+ case "error":
+ return "text-red-300";
+ case "warning":
+ return "text-amber-300";
+ case "system":
+ return "text-cyan-300/80";
+ default:
+ return "text-slate-300";
+ }
+}
+
+function formatLogTimestamp(timestamp?: string | null): string {
+ if (!timestamp) return "";
+ const parsed = new Date(timestamp);
+ if (Number.isNaN(parsed.getTime())) return "";
+ return parsed.toLocaleTimeString();
+}
+
+interface RuntimeStatusBannerProps {
+ runtimeState: DeployRuntimeState;
+ stoppedByUser: boolean;
+ exitReason?: string | null;
+ onAcknowledge: () => void;
+}
+
+function RuntimeStatusBanner({
+ runtimeState,
+ stoppedByUser,
+ exitReason,
+ onAcknowledge,
+}: RuntimeStatusBannerProps) {
+ if (runtimeState === "stopping") {
+ return (
+
+
+
+
+ Stop in progress. Container status may still show running until Docker finishes shutdown.
+
+
+
+ );
+ }
+
+ if (stoppedByUser || runtimeState === "stopped_by_user") {
+ return (
+
+
+
+
+ Stopped by user. Final logs remain available below if the container captured any.
+
+
+
+ );
+ }
+
+ const isProblematic =
+ runtimeState === "failed" ||
+ runtimeState === "exited" ||
+ runtimeState === "partial" ||
+ runtimeState === "unhealthy" ||
+ runtimeState === "cleanup_completed";
+
+ if (!isProblematic) return null;
+
+ return (
+
+
+
+
+
+ Stack is {runtimeState === "partial" ? "partially running" : runtimeState}
+
+
+ Inspect the container logs below to understand what went wrong.
+ {exitReason ? ` Reason: ${exitReason}.` : ""}
+
+
+ Return to Results to fix the stack and upload/deploy again. Analysis remains available there.
+
+
+
+ Go to Results
+
+
+
+ );
+}
+
+interface AppPreviewBrowserProps {
+ targets: PreviewTarget[];
+ selectedTargetUrl: string;
+ onSelectTarget: (url: string) => void;
+ address: string;
+ onAddressChange: (value: string) => void;
+ src: string;
+ reloadTick: number;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ onSubmit: () => void;
+ onReload: () => void;
+ onBack: () => void;
+ onForward: () => void;
+}
+
+type PreviewGate = "pending" | "embed" | "blocked";
+type PreviewDisplayMode = "popout" | "inline";
+type InlinePreviewKind = "direct" | "proxy";
+
+const PREVIEW_MODE_STORAGE_KEY = "dqa:previewDisplayMode";
+
+function loadPreviewDisplayMode(): PreviewDisplayMode {
+ if (typeof sessionStorage === "undefined") return "inline";
+ const stored = sessionStorage.getItem(PREVIEW_MODE_STORAGE_KEY);
+ return stored === "popout" ? "popout" : "inline";
+}
+
+function AppPreviewBrowser({
+ targets,
+ selectedTargetUrl,
+ onSelectTarget,
+ address,
+ onAddressChange,
+ src,
+ reloadTick,
+ canGoBack,
+ canGoForward,
+ onSubmit,
+ onReload,
+ onBack,
+ onForward,
+}: AppPreviewBrowserProps) {
+ const localIssue = src ? localPreviewIssue(src) : null;
+ const [displayMode, setDisplayMode] = useState(loadPreviewDisplayMode);
+ const [gate, setGate] = useState("pending");
+ const [blockedMessage, setBlockedMessage] = useState("");
+ const [embedSrc, setEmbedSrc] = useState("");
+ const [resolvedUrl, setResolvedUrl] = useState("");
+ const [inlineKind, setInlineKind] = useState("direct");
+ const popoutOpenedForRef = useRef("");
+ const previewUrl = resolvedUrl || src;
+
+ const handleDisplayModeChange = useCallback((mode: PreviewDisplayMode) => {
+ setDisplayMode(mode);
+ sessionStorage.setItem(PREVIEW_MODE_STORAGE_KEY, mode);
+ }, []);
+
+ const openRealBrowser = useCallback((url: string) => {
+ const opened = openPopOutPreview(url);
+ if (!opened) {
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!src || localIssue) return;
+
+ if (displayMode === "popout") {
+ const popoutKey = `${src}:${reloadTick}`;
+ if (popoutOpenedForRef.current !== popoutKey) {
+ popoutOpenedForRef.current = popoutKey;
+ openRealBrowser(src);
+ }
+ return;
+ }
+
+ let cancelled = false;
+ void (async () => {
+ try {
+ const result: PreviewCheckResponse = await composeApi.checkPreview(src);
+ if (cancelled) return;
+ const targetUrl = result.final_url ?? src;
+ setResolvedUrl(targetUrl);
+ if (!result.frameable && result.proxy_available) {
+ const session = await composeApi.createPreviewProxySession(
+ targetUrl,
+ collectBrowserProxyHints(),
+ );
+ setEmbedSrc(buildPreviewProxyEmbedUrl(session.session_id));
+ setInlineKind("proxy");
+ setGate("embed");
+ return;
+ }
+ if (!result.frameable) {
+ setBlockedMessage(
+ result.reason ??
+ (result.reachable
+ ? "This app cannot be shown in an embedded preview."
+ : "Could not reach this URL from the platform."),
+ );
+ setGate("blocked");
+ return;
+ }
+ setEmbedSrc(targetUrl);
+ setInlineKind("direct");
+ setGate("embed");
+ } catch {
+ if (cancelled) return;
+ setBlockedMessage(
+ "Could not verify whether this URL allows in-app preview. Open it in a new tab instead.",
+ );
+ setGate("blocked");
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [src, reloadTick, localIssue, displayMode, openRealBrowser]);
+
+ const handlePopOut = useCallback(() => {
+ const url = previewUrl;
+ if (!url) return;
+ popoutOpenedForRef.current = `${url}:${reloadTick}`;
+ openRealBrowser(url);
+ }, [previewUrl, reloadTick, openRealBrowser]);
+
+ const handleReloadPreview = useCallback(() => {
+ if (displayMode === "popout") {
+ handlePopOut();
+ return;
+ }
+ onReload();
+ }, [displayMode, handlePopOut, onReload]);
+
+ let previewBody: ReactNode;
+ if (!src) {
+ previewBody = (
+
+
+ No accessible web port detected yet. Once a container publishes a host port, the preview
+ appears here. You can also type a URL above.
+
+
+ );
+ } else if (displayMode === "popout") {
+ previewBody = (
+
+
+
+
Real browser preview
+
+ The app opens in a separate window with the real container URL — the same as pasting the
+ address into Chrome or Firefox. That avoids iframe limits and proxy quirks.
+
+
{previewUrl}
+
+
+
+
+ Open / focus preview window
+
+ handleDisplayModeChange("inline")}
+ >
+ Try inline preview
+
+
+
+ );
+ } else if (localIssue || gate === "blocked") {
+ previewBody = (
+
+
+
+
In-app preview unavailable
+
{localIssue ?? blockedMessage}
+
{src}
+
+
+
+
+ Open in browser window
+
+ handleDisplayModeChange("popout")}
+ >
+ Use real browser mode
+
+
+
+ );
+ } else if (gate === "pending") {
+ previewBody = (
+
+
Checking whether this app allows in-app preview…
+
+ );
+ } else {
+ previewBody = (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Live App Preview
+
+
+ handleDisplayModeChange("popout")}
+ className={`px-2 py-0.5 text-[10px] rounded transition-colors ${
+ displayMode === "popout"
+ ? "bg-cyan-600/30 text-cyan-200"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ >
+ Browser window
+
+ handleDisplayModeChange("inline")}
+ className={`px-2 py-0.5 text-[10px] rounded transition-colors ${
+ displayMode === "inline"
+ ? "bg-cyan-600/30 text-cyan-200"
+ : "text-slate-500 hover:text-slate-300"
+ }`}
+ >
+ Inline
+
+
+ {targets.length > 1 && (
+
onSelectTarget(e.target.value)}
+ aria-label="Preview target"
+ className="h-8 max-w-[260px] rounded border border-slate-700 bg-slate-950 px-2 text-xs text-slate-200 font-mono"
+ >
+ {targets.map((target) => (
+
+ {target.label}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {displayMode === "inline" && gate === "embed" && inlineKind === "proxy" && (
+
+ Inline proxy mode — some apps break here. Switch to{" "}
+ handleDisplayModeChange("popout")}
+ >
+ Browser window
+ {" "}
+ for the same behavior as opening the URL directly.
+
+ )}
+
+
+ {previewBody}
+
+
+ Browser window uses the real container
+ URL (recommended). Inline embeds in
+ this page via iframe or proxy. Do not use localhost:8000 .
+
+
+ );
+}
+
+interface ContainerLogPanelProps {
+ container: RuntimeContainerState;
+ liveLines: LogLine[];
+ connected: boolean;
+}
+
+function ContainerLogPanel({ container, liveLines, connected }: ContainerLogPanelProps) {
+ const scrollRef = useRef(null);
+ const pinnedToBottom = useRef(true);
+
+ const fallbackLines = useMemo(() => {
+ if (liveLines.length > 0) return [];
+ return lastLogsToEntries(container.last_logs);
+ }, [liveLines.length, container.last_logs]);
+
+ const lines = liveLines.length > 0 ? liveLines : fallbackLines;
+
+ const handleScroll = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ pinnedToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+ }, []);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el || !pinnedToBottom.current) return;
+ el.scrollTop = el.scrollHeight;
+ }, [lines]);
+
+ const status = (container.status ?? "").toLowerCase();
+ const isExited = TERMINAL_CONTAINER_STATUSES.has(status) || status === "exited";
+
+ return (
+
+
+
+
+ {containerLabel(container)}
+
+
+ {!isExited &&
+ (connected ? (
+ live
+ ) : (
+ connecting…
+ ))}
+ {isExited && lines.length > 0 && final logs }
+
+
+
+ {lines.length === 0 ? (
+
+ {isExited ? "No logs were captured for this container." : "Waiting for logs…"}
+
+ ) : (
+ lines.map((entry, index) => (
+
+ {entry.timestamp && (
+ [{formatLogTimestamp(entry.timestamp)}]
+ )}
+ {entry.stream === "stderr" && stderr }
+ {entry.line}
+
+ ))
+ )}
+
+
+ );
+}
+
export function Monitoring() {
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
@@ -124,197 +758,207 @@ export function Monitoring() {
const stateKey = jobId ? `dqa:monitoring:${jobId}` : null;
const persisted =
stateKey != null
- ? loadState(stateKey, MONITORING_STATE_TTL_MS)
+ ? loadState(stateKey, MONITORING_STATE_TTL_MS)
: null;
- const [containerIds, setContainerIds] = useState([]);
- const [selectedContainerId, setSelectedContainerId] = useState(
- routeContainerId ?? persisted?.selectedContainerId ?? null,
- );
- const [problematicAlert, setProblematicAlert] = useState({
- open: false,
- message: "",
- });
+ const [status, setStatus] = useState(null);
const [dindIp, setDindIp] = useState(null);
- const [manualPreviewUrl, setManualPreviewUrl] = useState(null);
- const [previewReloadTick, setPreviewReloadTick] = useState(0);
- const [containerStates, setContainerStates] = useState>(() => {
- const initial: Record = {};
- if (persisted?.containerStates) {
- for (const [id, s] of Object.entries(persisted.containerStates)) {
- initial[id] = { ...s, connected: false };
+ const [logsByContainer, setLogsByContainer] = useState>(
+ () => persisted?.logsByContainer ?? {},
+ );
+ const [connectionByContainer, setConnectionByContainer] = useState>({});
+ const [metricsByContainer, setMetricsByContainer] = useState<
+ Record
+ >(() => {
+ const initial: Record =
+ {};
+ if (persisted?.metricsByContainer) {
+ for (const [id, entry] of Object.entries(persisted.metricsByContainer)) {
+ initial[id] = {
+ points: entry.points ?? [],
+ latest: entry.latest ?? null,
+ };
}
}
return initial;
});
+ const [metricsConnectedByContainer, setMetricsConnectedByContainer] = useState<
+ Record
+ >({});
+ const [focusedContainerId, setFocusedContainerId] = useState(
+ routeContainerId ?? null,
+ );
+
+ const [selectedTargetUrl, setSelectedTargetUrl] = useState(
+ persisted?.selectedPreviewUrl ?? "",
+ );
+ const [previewAddress, setPreviewAddress] = useState(
+ persisted?.previewAddress ?? persisted?.selectedPreviewUrl ?? "",
+ );
+ const [previewSrc, setPreviewSrc] = useState(persisted?.selectedPreviewUrl ?? "");
+ const [previewReloadTick, setPreviewReloadTick] = useState(0);
+ const [history, setHistory] = useState(
+ persisted?.selectedPreviewUrl ? [persisted.selectedPreviewUrl] : [],
+ );
+ const [historyIndex, setHistoryIndex] = useState(
+ persisted?.selectedPreviewUrl ? 0 : -1,
+ );
+
const socketsRef = useRef>(new Map());
- const cleanupAttemptedRef = useRef(false);
+ const socketGenByContainerRef = useRef>(new Map());
+ const metricsSocketsRef = useRef>(new Map());
+ const metricsSocketGenByContainerRef = useRef>(new Map());
+ const previewInitializedRef = useRef(Boolean(persisted?.selectedPreviewUrl));
- const setResubmitRequired = useCallback(() => {
- if (!jobId) return;
- sessionStorage.setItem(`dqa:resubmitRequired:${jobId}`, "1");
- }, [jobId]);
+ const invalidateContainerSocket = useCallback((containerId: string) => {
+ socketGenByContainerRef.current.set(
+ containerId,
+ (socketGenByContainerRef.current.get(containerId) ?? 0) + 1,
+ );
+ }, []);
- const markProblematicStack = useCallback(
- (message: string, triggerCleanup: boolean) => {
- setResubmitRequired();
- setProblematicAlert({ open: true, message });
- sessionStorage.removeItem("dqa:containerStatus");
-
- if (triggerCleanup && jobId && !cleanupAttemptedRef.current) {
- cleanupAttemptedRef.current = true;
- void composeApi
- .stopDeploy({ job_id: jobId, remove_volumes: true })
- .catch(() => {
- toast.error("Failed to trigger cleanup for problematic stack.");
- });
- }
- },
- [jobId, setResubmitRequired],
+ const invalidateMetricsSocket = useCallback((containerId: string) => {
+ metricsSocketGenByContainerRef.current.set(
+ containerId,
+ (metricsSocketGenByContainerRef.current.get(containerId) ?? 0) + 1,
+ );
+ }, []);
+
+ const closeOpenSocket = useCallback((socket: WebSocket) => {
+ safeCloseSocket(socket);
+ }, []);
+
+ const containers = useMemo(
+ () => status?.containers ?? [],
+ [status?.containers],
);
+ const runtimeState: DeployRuntimeState = status?.runtime_state ?? "none";
+ const shuttingDown = runtimeState === "stopping" || isSessionStopping(jobId ?? undefined);
+ const displayContainers = useMemo(() => {
+ if (containers.length > 0) return containers;
+ const ids = status?.container_ids?.length
+ ? status.container_ids
+ : routeContainerId
+ ? [routeContainerId]
+ : [];
+ return ids.map((id) => ({ id }) as RuntimeContainerState);
+ }, [containers, status?.container_ids, routeContainerId]);
+
+ const liveContainerIds = useMemo(() => {
+ const set = new Set();
+ for (const c of containers) {
+ const st = (c.status ?? "").toLowerCase();
+ if (st && !TERMINAL_CONTAINER_STATUSES.has(st)) set.add(c.id);
+ }
+ if (set.size === 0 && status?.active) {
+ for (const id of status.container_ids ?? []) set.add(id);
+ }
+ return Array.from(set);
+ }, [containers, status?.active, status?.container_ids]);
+ const liveContainerKey = liveContainerIds.join(",");
+
+ const previewTargets = useMemo(
+ () => buildPreviewTargets(displayContainers, dindIp),
+ [displayContainers, dindIp],
+ );
+
+ // Resolve the focused container for the log tabs (route id is an optional initial hint only).
+ useEffect(() => {
+ if (displayContainers.length === 0) return;
+ setFocusedContainerId((current) => {
+ if (current && displayContainers.some((c) => c.id === current)) return current;
+ return displayContainers[0].id;
+ });
+ }, [displayContainers]);
+
+ // Poll deploy status (source of truth). Stop polling once the runtime is terminal.
useEffect(() => {
if (!jobId) return;
let cancelled = false;
- (async () => {
+ let timer: ReturnType | null = null;
+
+ const poll = async () => {
try {
- const status = await composeApi.deployStatus(jobId);
+ const next = await composeApi.deployStatus(jobId);
if (cancelled) return;
+ setStatus(next);
- const rs = status.runtime_state ?? "none";
- const problematicByRuntimeState =
- rs === "failed" ||
- rs === "cleanup_completed" ||
- rs === "exited" ||
- rs === "partial" ||
- rs === "unhealthy";
- const isProblematic = problematicByRuntimeState && !status.stopped_by_user;
- const isTerminal = ["none", "stopped_by_user", "exited", "failed", "cleanup_completed"].includes(rs);
-
- const fromIds =
- status.container_ids.length > 0
- ? status.container_ids
- : (status.containers?.map((c) => c.id).filter(Boolean) as string[]) ?? [];
-
- const ids =
- fromIds.length > 0 ? fromIds : routeContainerId ? [routeContainerId] : [];
-
- if (ids.length > 0) {
- setContainerIds(ids);
- setSelectedContainerId((sel) =>
- sel && ids.includes(sel) ? sel : ids[0],
- );
- } else if (routeContainerId) {
- setContainerIds([routeContainerId]);
- setSelectedContainerId(routeContainerId);
+ const rs = next.runtime_state ?? "none";
+ const problematic =
+ !next.stopped_by_user &&
+ (rs === "failed" || rs === "exited" || rs === "partial" || rs === "unhealthy" || rs === "cleanup_completed");
+ if (problematic) {
+ sessionStorage.setItem(`dqa:resubmitRequired:${jobId}`, "1");
}
- // For terminal states with no active containers, don't attempt to open sockets
- if (isTerminal && !status.active) {
- // Pre-populate exited state so charts show offline state
- const fromDeploy = status.containers ?? [];
- if (fromDeploy.length > 0) {
- setContainerStates((prev) => {
- const next = { ...prev };
- for (const c of fromDeploy) {
- if (!c.id) continue;
- const existing = next[c.id] ?? {
- points: [],
- logs: [],
- latestPayload: null,
- connected: false,
- };
- const lastLogs = (c.last_logs ?? []).filter(
- (l): l is string => typeof l === "string",
- );
- const tailLogs: TerminalLogEntry[] = lastLogs.map((line) => ({
- message: `[deploy] ${line}`,
- tone: "info" as const,
- }));
- next[c.id] = {
- ...existing,
- connected: false,
- exited: {
- exit_code: c.exit_code,
- error: c.error ?? null,
- started_at: c.started_at ?? null,
- finished_at: c.finished_at ?? null,
- restart_count: c.restart_count ?? null,
- oom_killed: c.oom_killed ?? null,
- last_logs: lastLogs,
- },
- logs: [...existing.logs.slice(-200), ...tailLogs.slice(-80)],
- };
- }
- return next;
- });
- }
- return;
+ if (rs === "stopped_by_user" || rs === "none" || !next.active) {
+ if (jobId) clearSessionStopping(jobId);
}
- if (isProblematic) {
- markProblematicStack(
- "Current stack is problematic. Please fix the stack and resubmit.",
- true,
- );
+ if (!TERMINAL_RUNTIME_STATES.has(rs)) {
+ const pollMs =
+ rs === "stopping" || isSessionStopping(jobId) ? DEPLOY_POLL_FAST_MS : DEPLOY_POLL_MS;
+ timer = setTimeout(poll, pollMs);
}
-
- const fromDeploy = status.containers ?? [];
- if (fromDeploy.length === 0) return;
-
- setContainerStates((prev) => {
- const next = { ...prev };
- for (const c of fromDeploy) {
- if (!c.id || c.status !== "exited") continue;
- const existing = next[c.id] ?? {
- points: [],
- logs: [],
- latestPayload: null,
- connected: false,
- };
- const lastLogs = (c.last_logs ?? []).filter(
- (l): l is string => typeof l === "string",
- );
- const tailLogs: TerminalLogEntry[] = lastLogs.map((line) => ({
- message: `[deploy] ${line}`,
- tone: "info" as const,
- }));
- next[c.id] = {
- ...existing,
- connected: false,
- exited: {
- exit_code: c.exit_code,
- error: c.error ?? null,
- started_at: c.started_at ?? null,
- finished_at: c.finished_at ?? null,
- restart_count: c.restart_count ?? null,
- oom_killed: c.oom_killed ?? null,
- last_logs: lastLogs,
- },
- logs: [...existing.logs.slice(-200), ...tailLogs.slice(-80)],
- };
- }
- return next;
- });
} catch {
- if (routeContainerId) {
- setContainerIds([routeContainerId]);
- setSelectedContainerId(routeContainerId);
+ if (!cancelled) {
+ timer = setTimeout(poll, DEPLOY_POLL_MS);
}
}
- })();
+ };
+
+ void poll();
return () => {
cancelled = true;
+ if (timer) clearTimeout(timer);
};
- }, [jobId, routeContainerId, markProblematicStack]);
+ }, [jobId]);
+
+ // Seed log panels from deployStatus.last_logs (survives refresh / late page open).
+ useEffect(() => {
+ const fromStatus = status?.containers ?? [];
+ if (fromStatus.length === 0) return;
+
+ setLogsByContainer((prev) => {
+ const next = { ...prev };
+ for (const container of fromStatus) {
+ const fromDeploy = lastLogsToEntries(container.last_logs);
+ if (fromDeploy.length === 0) continue;
+
+ const existing = next[container.id] ?? [];
+ if (existing.length === 0) {
+ next[container.id] = fromDeploy.slice(-MAX_LOG_LINES);
+ continue;
+ }
+ const seen = new Set(existing.map((entry) => entry.line));
+ const merged = [...existing];
+ for (const entry of fromDeploy) {
+ if (seen.has(entry.line)) continue;
+ seen.add(entry.line);
+ merged.push(entry);
+ }
+ if (merged.length > existing.length) {
+ next[container.id] = merged.slice(-MAX_LOG_LINES);
+ }
+ }
+ return next;
+ });
+ }, [status?.containers]);
+
+ // Resolve DinD IP once for loopback-bound preview targets.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const resolved = await composeApi.dindIp();
if (!cancelled) {
- setDindIp(typeof resolved.dind_ip === "string" && resolved.dind_ip.trim() ? resolved.dind_ip : null);
+ setDindIp(
+ typeof resolved.dind_ip === "string" && resolved.dind_ip.trim()
+ ? resolved.dind_ip
+ : null,
+ );
}
} catch {
if (!cancelled) setDindIp(null);
@@ -325,203 +969,258 @@ export function Monitoring() {
};
}, []);
+ // Initialize the preview target once when one first becomes available.
useEffect(() => {
- if (!user?.id || containerIds.length === 0 || problematicAlert.open) return;
+ if (previewInitializedRef.current) return;
+ if (previewTargets.length === 0) return;
+ const target = previewTargets[0].url;
+ previewInitializedRef.current = true;
+ setSelectedTargetUrl(target);
+ setPreviewAddress(target);
+ setPreviewSrc(target);
+ setHistory([target]);
+ setHistoryIndex(0);
+ setPreviewReloadTick((v) => v + 1);
+ }, [previewTargets]);
- const existingSockets = socketsRef.current;
- for (const cid of containerIds) {
- if (existingSockets.has(cid)) continue;
+ // Open one live-logs websocket per active container. Stable across data updates.
+ useEffect(() => {
+ if (!user?.id || liveContainerIds.length === 0) return;
+ const sockets = socketsRef.current;
- const socket = ws.connectUserContainer(user.id, cid);
- existingSockets.set(cid, socket);
+ for (const cid of liveContainerIds) {
+ if (sockets.has(cid)) continue;
+ const connectionGen = (socketGenByContainerRef.current.get(cid) ?? 0) + 1;
+ socketGenByContainerRef.current.set(cid, connectionGen);
+ const socket = ws.connectUserContainerLogs(user.id, cid);
+ sockets.set(cid, socket);
socket.onopen = () => {
- setContainerStates((prev) => ({
- ...prev,
- [cid]: {
- ...(prev[cid] ?? { points: [], logs: [], latestPayload: null, connected: false }),
- connected: true,
- logs: [...(prev[cid]?.logs ?? []), { message: `Connected to ${cid.slice(0, 12)}`, tone: "success" as const }],
- },
- }));
+ if (socketGenByContainerRef.current.get(cid) !== connectionGen) {
+ closeOpenSocket(socket);
+ return;
+ }
+ setConnectionByContainer((prev) => ({ ...prev, [cid]: true }));
};
socket.onmessage = (event) => {
+ if (socketGenByContainerRef.current.get(cid) !== connectionGen) return;
try {
const parsed = JSON.parse(event.data as string) as DomainEvent;
+ if (parsed.event_name !== "container.log") return;
+ const payload = parsed.payload as ContainerLogPayload;
+ const stream: LogStream =
+ payload.stream === "stderr" || payload.stream === "system" ? payload.stream : "stdout";
+ const line = typeof payload.line === "string" ? payload.line : "";
+ if (!line) return;
+ const entry: LogLine = {
+ stream,
+ line,
+ timestamp: payload.timestamp ?? parsed.timestamp,
+ tone: classifyLogTone(stream, line),
+ };
+ setLogsByContainer((prev) => {
+ const existing = prev[cid] ?? [];
+ return { ...prev, [cid]: [...existing, entry].slice(-MAX_LOG_LINES) };
+ });
+ } catch {
+ // ignore malformed frames
+ }
+ };
- if (parsed.event_name === "container.exited") {
- const p = parsed.payload as ContainerExitedState & { container_id?: string };
- setContainerStates((prev) => {
- const existing = prev[cid] ?? { points: [], logs: [], latestPayload: null, connected: false };
- const exitMsg = `Container exited (code ${p.exit_code ?? "??"})${p.oom_killed ? " [OOM killed]" : ""}${p.error ? ` — ${p.error}` : ""}`;
- const lastLogEntries: TerminalLogEntry[] = (p.last_logs ?? []).map(l => ({ message: l, tone: "error" as const }));
- return {
- ...prev,
- [cid]: {
- ...existing,
- connected: false,
- exited: p,
- logs: [...existing.logs.slice(-200), { message: exitMsg, tone: "error" as const }, ...lastLogEntries],
- },
- };
- });
- return;
- }
+ socket.onerror = () => {
+ if (socketGenByContainerRef.current.get(cid) !== connectionGen) return;
+ setConnectionByContainer((prev) => ({ ...prev, [cid]: false }));
+ };
+ socket.onclose = () => {
+ if (socketGenByContainerRef.current.get(cid) !== connectionGen) return;
+ setConnectionByContainer((prev) => ({ ...prev, [cid]: false }));
+ sockets.delete(cid);
+ };
+ }
+
+ // Close sockets for containers no longer live.
+ for (const [cid, socket] of sockets) {
+ if (!liveContainerIds.includes(cid)) {
+ invalidateContainerSocket(cid);
+ closeOpenSocket(socket);
+ sockets.delete(cid);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.id, liveContainerKey, closeOpenSocket, invalidateContainerSocket]);
+
+ // Open one metrics websocket per active container.
+ useEffect(() => {
+ if (!user?.id || liveContainerIds.length === 0) return;
+ const sockets = metricsSocketsRef.current;
+
+ for (const cid of liveContainerIds) {
+ if (sockets.has(cid)) continue;
+ const connectionGen = (metricsSocketGenByContainerRef.current.get(cid) ?? 0) + 1;
+ metricsSocketGenByContainerRef.current.set(cid, connectionGen);
+ const socket = ws.connectUserContainer(user.id, cid);
+ sockets.set(cid, socket);
+
+ socket.onopen = () => {
+ if (metricsSocketGenByContainerRef.current.get(cid) !== connectionGen) {
+ closeOpenSocket(socket);
+ return;
+ }
+ setMetricsConnectedByContainer((prev) => ({ ...prev, [cid]: true }));
+ };
+
+ socket.onmessage = (event) => {
+ if (metricsSocketGenByContainerRef.current.get(cid) !== connectionGen) return;
+ try {
+ const parsed = JSON.parse(event.data as string) as DomainEvent;
if (parsed.event_name !== "container.metrics") return;
const payload = parsed.payload as ContainerMetricsPayload;
- const cpu = typeof payload.cpu_percent === "number" ? payload.cpu_percent : null;
- const memBytes = typeof payload.memory_bytes === "number" ? payload.memory_bytes : null;
- const memoryMb = memBytes == null ? null : memBytes / (1024 * 1024);
- const ts = new Date(parsed.timestamp).getTime();
-
- setContainerStates((prev) => {
- const existing = prev[cid] ?? { points: [], logs: [], latestPayload: null, connected: true };
- const nextPoints = [
- ...existing.points,
- {
- timestamp: Number.isNaN(ts) ? Date.now() : ts,
- label: new Date(ts || Date.now()).toLocaleTimeString(),
- cpuPercent: cpu,
- memoryMb,
- },
- ].slice(-MAX_POINTS);
+ setMetricsByContainer((prev) => {
+ const existing = prev[cid] ?? { points: [], latest: null };
+ const nextPoints = appendMetricSample(
+ existing.points,
+ payload,
+ parsed.timestamp,
+ existing.latest,
+ );
return {
...prev,
- [cid]: {
- ...existing,
- latestPayload: payload,
- points: nextPoints,
- logs: [...existing.logs.slice(-200), { message: `cpu_percent=${cpu ?? "null"} memory_bytes=${memBytes ?? "null"} memory_percent=${payload.memory_percent ?? "null"}`, timestamp: parsed.timestamp, tone: "info" as const }],
- },
+ [cid]: { points: nextPoints, latest: payload },
};
});
} catch {
- // ignore parse errors
+ // ignore malformed frames
}
};
socket.onerror = () => {
- setContainerStates((prev) => ({
- ...prev,
- [cid]: {
- ...(prev[cid] ?? { points: [], logs: [], latestPayload: null, connected: false }),
- connected: false,
- },
- }));
- toast.error(`Stream error: ${cid.slice(0, 12)}`);
+ if (metricsSocketGenByContainerRef.current.get(cid) !== connectionGen) return;
+ setMetricsConnectedByContainer((prev) => ({ ...prev, [cid]: false }));
};
socket.onclose = () => {
- setContainerStates((prev) => ({
- ...prev,
- [cid]: {
- ...(prev[cid] ?? { points: [], logs: [], latestPayload: null, connected: false }),
- connected: false,
- },
- }));
- existingSockets.delete(cid);
+ if (metricsSocketGenByContainerRef.current.get(cid) !== connectionGen) return;
+ setMetricsConnectedByContainer((prev) => ({ ...prev, [cid]: false }));
+ sockets.delete(cid);
};
}
+ for (const [cid, socket] of sockets) {
+ if (!liveContainerIds.includes(cid)) {
+ invalidateMetricsSocket(cid);
+ closeOpenSocket(socket);
+ sockets.delete(cid);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.id, liveContainerKey, closeOpenSocket, invalidateMetricsSocket]);
+
+ // Close all sockets on unmount.
+ useEffect(() => {
+ const logSockets = socketsRef.current;
+ const metricSockets = metricsSocketsRef.current;
return () => {
- for (const [, socket] of existingSockets) {
- try { socket.close(); } catch { /* noop */ }
+ for (const [cid, socket] of logSockets) {
+ invalidateContainerSocket(cid);
+ closeOpenSocket(socket);
}
- existingSockets.clear();
+ logSockets.clear();
+ for (const [cid, socket] of metricSockets) {
+ invalidateMetricsSocket(cid);
+ closeOpenSocket(socket);
+ }
+ metricSockets.clear();
};
- }, [user?.id, containerIds, problematicAlert.open]);
+ }, [closeOpenSocket, invalidateContainerSocket, invalidateMetricsSocket]);
+ // Persist preview selection + recent logs + metrics samples.
useEffect(() => {
if (!stateKey) return;
- const persistable: MonitoringPersistedState["containerStates"] = {};
- for (const [id, s] of Object.entries(containerStates)) {
- persistable[id] = {
- points: s.points.slice(-MAX_POINTS),
- logs: s.logs.slice(-200),
- latestPayload: s.latestPayload,
+ const trimmedLogs: Record = {};
+ for (const [cid, lines] of Object.entries(logsByContainer)) {
+ trimmedLogs[cid] = lines.slice(-PERSIST_LOG_LINES);
+ }
+ const trimmedMetrics: Record = {};
+ for (const [cid, entry] of Object.entries(metricsByContainer)) {
+ trimmedMetrics[cid] = {
+ points: entry.points.slice(-PERSIST_METRIC_POINTS),
+ latest: entry.latest ? trimPayloadForPersist(entry.latest) : null,
};
}
- saveState(stateKey, { containerStates: persistable, selectedContainerId });
- }, [containerStates, selectedContainerId, stateKey]);
-
- const currentState: ContainerState | null = selectedContainerId
- ? containerStates[selectedContainerId] ?? null
- : null;
-
- const chartData = useMemo(() => currentState?.points ?? [], [currentState?.points]);
- const latestPayload = currentState?.latestPayload ?? null;
- const connected = currentState?.connected ?? false;
- const exitedState = currentState?.exited ?? null;
- const latest = chartData[chartData.length - 1] ?? null;
- const netTotals = latestPayload?.network?.totals;
- const ioStats = latestPayload?.io;
- const pids = latestPayload?.pids;
- const container = latestPayload?.container;
- const currentHealthStatus = String(container?.health_status ?? "").toLowerCase();
- const isUnhealthyLive = !exitedState && connected && currentHealthStatus === "unhealthy";
- const interfaces = useMemo(() => {
- const raw = latestPayload?.network?.interfaces;
- if (!raw || typeof raw !== "object") return [];
- return Object.entries(raw).map(([name, value]) => ({
- name,
- data: typeof value === "object" && value ? (value as Record) : {},
- }));
- }, [latestPayload]);
-
- const previewTargets = useMemo(() => {
- if (!container) return [];
- const candidates: PreviewTarget[] = [];
- const seen = new Set();
- const protocol = "http://";
- const hostname =
- typeof window !== "undefined" && window.location.hostname
- ? window.location.hostname
- : "localhost";
+ saveState(stateKey, {
+ selectedPreviewUrl: selectedTargetUrl,
+ previewAddress,
+ logsByContainer: trimmedLogs,
+ metricsByContainer: trimmedMetrics,
+ });
+ }, [stateKey, selectedTargetUrl, previewAddress, logsByContainer, metricsByContainer]);
- for (const portInfo of container.ports ?? []) {
- const containerPort = normalizeContainerPort(portInfo.container_port);
- if (!containerPort) continue;
+ const historyIndexRef = useRef(historyIndex);
+ useEffect(() => {
+ historyIndexRef.current = historyIndex;
+ }, [historyIndex]);
- for (const binding of portInfo.host_bindings ?? []) {
- const rawHostIp = String(binding.host_ip ?? "").trim();
- const hostPort = String(binding.host_port ?? "").trim();
- if (!hostPort) continue;
- const host = isLoopbackBinding(rawHostIp) ? dindIp || hostname : rawHostIp;
- const url = `${protocol}${host}:${hostPort}`;
- if (seen.has(url)) continue;
- seen.add(url);
- candidates.push({
- key: `host-${host}-${hostPort}-${containerPort}`,
- label: `${host}:${hostPort} -> ${containerPort}`,
- url,
- });
- }
+ const navigatePreview = useCallback((url: string) => {
+ const trimmed = url.trim();
+ if (!trimmed) return;
+ const newIndex = historyIndexRef.current + 1;
+ setHistory((prev) => [...prev.slice(0, historyIndexRef.current + 1), trimmed]);
+ historyIndexRef.current = newIndex;
+ setHistoryIndex(newIndex);
+ setPreviewSrc(trimmed);
+ setPreviewAddress(trimmed);
+ setPreviewReloadTick((v) => v + 1);
+ }, []);
- if (container.ip_address) {
- const ipUrl = `${protocol}${container.ip_address}:${containerPort}`;
- if (!seen.has(ipUrl)) {
- seen.add(ipUrl);
- candidates.push({
- key: `container-ip-${container.ip_address}-${containerPort}`,
- label: `container ${container.ip_address}:${containerPort}`,
- url: ipUrl,
- });
- }
- }
- }
+ const handleSelectTarget = useCallback(
+ (url: string) => {
+ setSelectedTargetUrl(url);
+ navigatePreview(url);
+ },
+ [navigatePreview],
+ );
- return candidates;
- }, [container, dindIp]);
+ const handleAddressSubmit = useCallback(() => {
+ navigatePreview(previewAddress);
+ }, [navigatePreview, previewAddress]);
- const selectedPreviewUrl = useMemo(() => {
- if (previewTargets.length === 0) return "";
- if (manualPreviewUrl && previewTargets.some((t) => t.url === manualPreviewUrl)) {
- return manualPreviewUrl;
- }
- return previewTargets[0].url;
- }, [previewTargets, manualPreviewUrl]);
+ const handleReload = useCallback(() => {
+ if (!previewSrc) return;
+ setPreviewReloadTick((v) => v + 1);
+ }, [previewSrc]);
+
+ const handleBack = useCallback(() => {
+ setHistoryIndex((index) => {
+ if (index <= 0) return index;
+ const nextIndex = index - 1;
+ const url = history[nextIndex];
+ setPreviewSrc(url);
+ setPreviewAddress(url);
+ setPreviewReloadTick((v) => v + 1);
+ historyIndexRef.current = nextIndex;
+ return nextIndex;
+ });
+ }, [history]);
+
+ const handleForward = useCallback(() => {
+ setHistoryIndex((index) => {
+ if (index >= history.length - 1) return index;
+ const nextIndex = index + 1;
+ const url = history[nextIndex];
+ setPreviewSrc(url);
+ setPreviewAddress(url);
+ setPreviewReloadTick((v) => v + 1);
+ historyIndexRef.current = nextIndex;
+ return nextIndex;
+ });
+ }, [history]);
+
+ const focusedContainer = useMemo(() => {
+ if (!focusedContainerId) return displayContainers[0] ?? null;
+ return displayContainers.find((c) => c.id === focusedContainerId) ?? displayContainers[0] ?? null;
+ }, [displayContainers, focusedContainerId]);
if (!user?.id) {
return (
@@ -531,431 +1230,202 @@ export function Monitoring() {
);
}
+ const runningCount = status?.running_count ?? 0;
+ const exitedCount = status?.exited_count ?? 0;
+ const unhealthyCount = status?.unhealthy_count ?? 0;
+ const headerBadge = (() => {
+ if (runtimeState === "stopping" || shuttingDown) {
+ return { label: "Stopping", className: "border-slate-700 bg-slate-900 text-slate-400", icon: PlugZap };
+ }
+ if (runtimeState === "stopped_by_user") {
+ return { label: "Stopped by user", className: "border-slate-700 bg-slate-900 text-slate-400", icon: PlugZap };
+ }
+ if (runtimeState === "running" && !shuttingDown) {
+ return { label: "Running", className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300", icon: PlugZap };
+ }
+ if (runtimeState === "unhealthy") {
+ return { label: "Unhealthy", className: "border-amber-500/40 bg-amber-500/10 text-amber-300", icon: AlertTriangle };
+ }
+ if (runtimeState === "partial") {
+ return { label: "Partial", className: "border-amber-500/40 bg-amber-500/10 text-amber-300", icon: AlertTriangle };
+ }
+ if (runtimeState === "exited" || runtimeState === "failed" || runtimeState === "cleanup_completed") {
+ return { label: runtimeState === "failed" ? "Failed" : "Exited", className: "border-red-500/40 bg-red-500/10 text-red-300", icon: XCircle };
+ }
+ return { label: "No active runtime", className: "border-slate-700 bg-slate-900 text-slate-400", icon: PlugZap };
+ })();
+ const HeaderIcon = headerBadge.icon;
+ const showRunningDot = runtimeState === "running" && !shuttingDown;
+
return (
-
-
-
-
navigate(jobId ? `/execution?jobId=${jobId}` : "/history")}
- className="text-slate-400 hover:text-white"
- >
-
-
-
- Monitoring
-
-
-
-
-
- DinD: {dindIp ?? "unresolved"}
-
-
- {problematicAlert.open || exitedState ? : }
- {problematicAlert.open
- ? "Problematic"
- : exitedState
- ? `Exited (${exitedState.exit_code ?? "?"})`
- : isUnhealthyLive
- ? "Unhealthy"
- : connected
- ? "Live"
- : "Disconnected"}
-
-
-
-
- {problematicAlert.open && (
-
-
-
-
-
- Problematic Stack Detected
-
-
{problematicAlert.message}
-
- Please fix your stack and resubmit. Analysis remains available in Results.
-
-
+
+
+
navigate(jobId ? `/results?jobId=${jobId}` : "/history")}
- className="bg-red-700 hover:bg-red-800"
+ onClick={() => navigate(jobId ? `/execution?jobId=${jobId}` : "/history")}
+ className="text-slate-400 hover:text-white"
>
- I Understand
+
+
+ Monitoring
+
-
- )}
-
- {containerIds.length > 1 && (
-
- {containerIds.map((cid) => {
- const isSelected = cid === selectedContainerId;
- const cState = containerStates[cid];
- const isConnected = cState?.connected ?? false;
- const isExited = !!cState?.exited;
- const cHealthStatus = String(cState?.latestPayload?.container?.health_status ?? "").toLowerCase();
- const isUnhealthy = !isExited && isConnected && cHealthStatus === "unhealthy";
- return (
- setSelectedContainerId(cid)}
- className={`px-2 py-1 rounded border text-xs font-mono transition-colors ${
- isSelected
- ? "border-blue-500 bg-blue-500/20 text-blue-200"
- : "border-slate-700 bg-slate-800 text-slate-400 hover:border-slate-500"
- }`}
- >
-
-
- {cState?.latestPayload?.container?.name || cid.slice(0, 12)}
- {isExited && ✕ }
-
-
- );
- })}
-
- )}
-
- {exitedState && (
-
-
-
-
-
- Container Exited
- {exitedState.exit_code != null && (
-
- exit code: {exitedState.exit_code}
-
- )}
- {exitedState.oom_killed && (
- OOM Killed
- )}
-
- {exitedState.error && (
-
{exitedState.error}
- )}
-
- {exitedState.started_at && (
- Started: {new Date(exitedState.started_at).toLocaleString()}
- )}
- {exitedState.finished_at && (
- Finished: {new Date(exitedState.finished_at).toLocaleString()}
- )}
- {exitedState.restart_count != null && exitedState.restart_count > 0 && (
- Restarts: {exitedState.restart_count}
- )}
-
- {Array.isArray(exitedState.last_logs) && exitedState.last_logs.length > 0 && (
-
-
- Last logs ({exitedState.last_logs.length} lines)
-
-
- {exitedState.last_logs.join("\n")}
-
-
- )}
- {chartData.length === 0 && (
-
- Container exited before metrics were collected.
-
+
+
+
+ DinD: {dindIp ?? "unresolved"}
+
+
+ {showRunningDot && !reducedMotion ? (
+
+ ) : (
+
)}
-
+ {headerBadge.label}
+
-
- )}
+
-
-
-
-
-
- CPU %
-
-
- {latest?.cpuPercent != null ? `${latest.cpuPercent}` : "-"}
-
-
-
-
-
-
-
- Mem bytes
-
-
- {latestPayload?.memory_bytes != null ? `${latestPayload.memory_bytes}` : "-"}
-
-
-
-
-
-
-
- Net RX / TX
-
-
- {netTotals?.rx_bytes ?? "-"} / {netTotals?.tx_bytes ?? "-"}
-
-
-
-
-
-
-
- I/O R / W
-
-
- {ioStats?.read_bytes ?? "-"} / {ioStats?.write_bytes ?? "-"}
-
-
-
-
-
-
-
- PIDs
-
-
- {typeof pids?.current === "number" ? pids.current : "-"}
-
-
-
-
-
-
-
- Status
-
-
- {currentState?.connected && (
-
+
navigate(jobId ? `/results?jobId=${jobId}` : "/history")}
+ />
+
+ {/* Runtime overview */}
+
+
+
+ Project: {status?.project_name ?? "-"}
+
+
+ Containers: {displayContainers.length}
+
+ {shuttingDown ? (
+ shutting down (Docker may still report running briefly)
+ ) : (
+ <>
+ running {runningCount}
+ unhealthy {unhealthyCount}
+ exited {exitedCount}
+ >
)}
- {container?.status ?? "-"} / {container?.health_status ?? "-"}
-
-
-
-
-
-
- CPU (%)
- {chartData.length === 0 ? (
-
- {exitedState
- ? "Container exited before metrics were collected."
- : "Waiting for metrics..."}
-
- ) : (
-
-
-
-
-
-
- [`${value.toFixed(4)}%`, "CPU"]}
- />
-
-
-
-
- )}
-
-
- Memory (MB)
- {chartData.length === 0 ? (
-
- {exitedState
- ? "Container exited before metrics were collected."
- : "Waiting for metrics..."}
-
- ) : (
-
-
-
-
-
-
- [`${value.toFixed(4)} MB`, "Memory"]}
- />
-
-
-
+ {displayContainers.length > 0 && (
+
+ {displayContainers.map((c) => {
+ const badge = containerStatusBadge(c, shuttingDown);
+ return (
+
+
+ {containerLabel(c)}: {badge.label}
+
+ );
+ })}
)}
-
-
-
- Container
-
- Name
- {container?.name ?? "-"}
- Image
- {container?.image ?? "-"}
- IP
- {container?.ip_address || "-"}
- Started
- {container?.started_at ? new Date(container.started_at).toLocaleString() : "-"}
- Restarts
- {container?.restart_count ?? "-"}
- Mounts
- {Array.isArray(container?.mounts) ? container?.mounts.length : 0}
- Samples
- {chartData.length}
-
-
-
- Ports
- {!container?.ports || container.ports.length === 0 ? (
- No port mappings.
- ) : (
-
- {container.ports.map((p, i) => (
-
- {p.container_port}
- {p.host_bindings && p.host_bindings.length > 0 && (
-
- {" → "}
- {p.host_bindings.map((b, j) => (
-
- {b.host_ip || "0.0.0.0"}:{b.host_port}
- {j < (p.host_bindings?.length ?? 0) - 1 ? ", " : ""}
-
- ))}
-
- )}
-
- ))}
-
- )}
-
-
- Network interfaces
- {interfaces.length === 0 ? (
- No data yet.
- ) : (
-
- {interfaces.map((iface) => (
-
-
{iface.name}
-
- RX: {formatBytes(iface.data.rx_bytes as number | null | undefined)}
- TX: {formatBytes(iface.data.tx_bytes as number | null | undefined)}
- Pkts: {String(iface.data.rx_packets ?? "-")}/{String(iface.data.tx_packets ?? "-")}
- Err: {String(iface.data.rx_errors ?? "-")}/{String(iface.data.tx_errors ?? "-")}
-
-
- ))}
-
- )}
-
-
+
+
0}
+ canGoForward={historyIndex >= 0 && historyIndex < history.length - 1}
+ onSubmit={handleAddressSubmit}
+ onReload={handleReload}
+ onBack={handleBack}
+ onForward={handleForward}
+ />
-
-
-
-
- Live App Preview
-
-
- {previewTargets.length > 0 && (
-
setManualPreviewUrl(e.target.value)}
- className="h-8 rounded border border-slate-700 bg-slate-950 px-2 text-xs text-slate-200 font-mono"
- >
- {previewTargets.map((target) => (
-
- {target.label}
-
- ))}
-
+
+
+
+ Live Logs
+
+ {displayContainers.length > 1 && (
+
+ {displayContainers.map((c) => {
+ const isFocused = c.id === focusedContainer?.id;
+ const badge = containerStatusBadge(c, shuttingDown);
+ return (
+ setFocusedContainerId(c.id)}
+ className={`px-2 py-1 rounded border text-xs font-mono transition-colors flex items-center gap-1.5 ${
+ isFocused
+ ? "border-blue-500 bg-blue-500/20 text-blue-200"
+ : "border-slate-700 bg-slate-800 text-slate-400 hover:border-slate-500"
+ }`}
+ >
+
+ {containerLabel(c)}
+
+ );
+ })}
+
)}
- setPreviewReloadTick((v) => v + 1)}
- className="border-slate-700 text-slate-300 hover:bg-slate-800"
- >
-
- Reload
-
- selectedPreviewUrl && window.open(selectedPreviewUrl, "_blank", "noopener,noreferrer")}
- className="border-slate-700 text-slate-300 hover:bg-slate-800"
- >
-
- Open
-
-
+ {focusedContainer ? (
+
+ ) : (
+
+
No containers to show logs for.
+
+ )}
+
- {selectedPreviewUrl ? (
-
-
- {selectedPreviewUrl}
-
-
-
- ) : (
-
- No accessible web port detected yet. Once a container exposes host bindings, preview appears here.
-
- )}
-
- If preview stays blank, the app may block framing (`X-Frame-Options`) or bind to an unreachable network.
-
-
-
-
-
+
+
+ 1}
+ />
+
+
);
diff --git a/frontend/src/pages/ProjectUpload.tsx b/frontend/src/pages/ProjectUpload.tsx
index feb6356..ce8d28e 100644
--- a/frontend/src/pages/ProjectUpload.tsx
+++ b/frontend/src/pages/ProjectUpload.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
@@ -8,12 +8,13 @@ import {
Check,
ChevronLeft,
Github,
- Loader2,
} from "lucide-react";
import { Layout } from "../components/Layout";
+import { ProgressStep } from "../components/ProgressStep";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
+import { Progress } from "../components/ui/progress";
import { MotionPage } from "../components/motion";
import { dragActiveVariants, dragActiveTransition } from "../components/motion/variants";
import { ApiError, project } from "../utils/api";
@@ -29,6 +30,108 @@ function formatBytes(bytes: number): string {
type Step = "upload" | "uploading";
type SourceMode = "zip" | "github";
+type ImportPhaseStatus = "pending" | "running" | "complete" | "error";
+
+interface ImportPhase {
+ id: string;
+ label: string;
+ description: string;
+ status: ImportPhaseStatus;
+}
+
+const GITHUB_IMPORT_PHASE_DEFS: Omit[] = [
+ {
+ id: "resolve",
+ label: "Resolving repository",
+ description: "Contacting GitHub and resolving the branch or tag",
+ },
+ {
+ id: "download",
+ label: "Downloading archive",
+ description: "Fetching the repository zipball — large repos can take up to a minute",
+ },
+ {
+ id: "extract",
+ label: "Extracting archive",
+ description: "Unpacking files and validating archive safety limits",
+ },
+ {
+ id: "scan",
+ label: "Scanning project",
+ description: "Detecting Dockerfiles, Compose files, and related assets",
+ },
+ {
+ id: "queue",
+ label: "Queueing analysis",
+ description: "Creating the background analysis job",
+ },
+];
+
+const ZIP_IMPORT_PHASE_DEFS: Omit[] = [
+ {
+ id: "upload",
+ label: "Uploading archive",
+ description: "Sending the ZIP file to the server",
+ },
+ {
+ id: "extract",
+ label: "Extracting archive",
+ description: "Unpacking files and validating archive safety limits",
+ },
+ {
+ id: "scan",
+ label: "Scanning project",
+ description: "Detecting Dockerfiles, Compose files, and related assets",
+ },
+ {
+ id: "queue",
+ label: "Queueing analysis",
+ description: "Creating the background analysis job",
+ },
+];
+
+/** Estimated phase transitions while the import HTTP request is in flight. */
+const GITHUB_PHASE_ADVANCE_MS = [2_000, 15_000, 24_000, 32_000] as const;
+const ZIP_PHASE_ADVANCE_MS = [800, 2_500, 5_000] as const;
+
+function buildInitialPhases(mode: SourceMode): ImportPhase[] {
+ const defs = mode === "github" ? GITHUB_IMPORT_PHASE_DEFS : ZIP_IMPORT_PHASE_DEFS;
+ return defs.map((phase, index) => ({
+ ...phase,
+ status: index === 0 ? "running" : "pending",
+ }));
+}
+
+function advanceImportPhases(phases: ImportPhase[], activeIndex: number): ImportPhase[] {
+ return phases.map((phase, index) => {
+ if (index < activeIndex) return { ...phase, status: "complete" };
+ if (index === activeIndex) return { ...phase, status: "running" };
+ return { ...phase, status: "pending" };
+ });
+}
+
+function completeImportPhases(phases: ImportPhase[]): ImportPhase[] {
+ return phases.map((phase) => ({ ...phase, status: "complete" }));
+}
+
+function failImportPhases(phases: ImportPhase[]): ImportPhase[] {
+ let failed = false;
+ return phases.map((phase) => {
+ if (failed) return phase;
+ if (phase.status === "running") {
+ failed = true;
+ return { ...phase, status: "error" };
+ }
+ return phase;
+ });
+}
+
+function importProgressValue(phases: ImportPhase[]): number {
+ if (!phases.length) return 0;
+ const completed = phases.filter((p) => p.status === "complete").length;
+ const running = phases.some((p) => p.status === "running") ? 0.35 : 0;
+ return Math.min(100, Math.round(((completed + running) / phases.length) * 100));
+}
export function ProjectUpload() {
const navigate = useNavigate();
@@ -42,6 +145,35 @@ export function ProjectUpload() {
const [githubUrl, setGithubUrl] = useState("");
const [githubRef, setGithubRef] = useState("");
const [error, setError] = useState(null);
+ const [importPhases, setImportPhases] = useState([]);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const importStartedAtRef = useRef(null);
+
+ useEffect(() => {
+ if (step !== "uploading") {
+ importStartedAtRef.current = null;
+ return;
+ }
+
+ importStartedAtRef.current = Date.now();
+ const tick = window.setInterval(() => {
+ if (importStartedAtRef.current) {
+ setElapsedSeconds(Math.floor((Date.now() - importStartedAtRef.current) / 1000));
+ }
+ }, 1000);
+
+ const advances = sourceMode === "github" ? GITHUB_PHASE_ADVANCE_MS : ZIP_PHASE_ADVANCE_MS;
+ const advanceTimers = advances.map((ms, index) =>
+ window.setTimeout(() => {
+ setImportPhases((prev) => advanceImportPhases(prev, index + 1));
+ }, ms),
+ );
+
+ return () => {
+ clearInterval(tick);
+ advanceTimers.forEach(clearTimeout);
+ };
+ }, [step, sourceMode]);
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
@@ -60,18 +192,23 @@ export function ProjectUpload() {
}
setError(null);
setSelectedFile(file);
+ setImportPhases(buildInitialPhases("zip"));
+ setElapsedSeconds(0);
setStep("uploading");
try {
const resp = await project.upload(file);
+ setImportPhases((prev) => completeImportPhases(prev));
sessionStorage.setItem("analysisJobId", resp.job_id);
sessionStorage.setItem("projectJobId", resp.job_id);
toast.success("Project queued for analysis!");
navigate(`/analysis?jobId=${resp.job_id}`);
} catch (err) {
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Upload failed.";
+ setImportPhases((prev) => failImportPhases(prev));
setError(msg);
toast.error(msg);
+ setElapsedSeconds(0);
setStep("upload");
}
};
@@ -88,9 +225,12 @@ export function ProjectUpload() {
setError(null);
setSelectedFile(null);
+ setImportPhases(buildInitialPhases("github"));
+ setElapsedSeconds(0);
setStep("uploading");
try {
const resp = await project.uploadGithub({ url, ref: ref || null });
+ setImportPhases((prev) => completeImportPhases(prev));
sessionStorage.setItem("analysisJobId", resp.job_id);
sessionStorage.setItem("projectJobId", resp.job_id);
toast.success("GitHub repository queued for analysis!");
@@ -102,8 +242,10 @@ export function ProjectUpload() {
: err instanceof Error
? err.message
: "GitHub import failed.";
+ setImportPhases((prev) => failImportPhases(prev));
setError(msg);
toast.error(msg);
+ setElapsedSeconds(0);
setStep("upload");
}
};
@@ -192,7 +334,7 @@ export function ProjectUpload() {
Drop your project archive here
- Accepts .zip archives up to 30 MB
+ Accepts .zip archives up to 200 MB
{ e.stopPropagation(); fileInputRef.current?.click(); }}
className="bg-purple-600 hover:bg-purple-700"
@@ -272,7 +414,7 @@ export function ProjectUpload() {
- Only public GitHub repositories are supported.
+ Only public GitHub repositories are supported. Large repositories may take up to a minute to download before analysis begins.
-
-
- {sourceMode === "github" ? "Importing and scanning…" : "Uploading and scanning…"}
-
-
- {sourceMode === "github"
- ? githubRef.trim()
- ? `${githubUrl.trim()} @ ${githubRef.trim()}`
- : githubUrl.trim() || "Processing repository"
- : selectedFile
- ? `${selectedFile.name} (${formatBytes(selectedFile.size)})`
- : "Processing archive"}
-
- Analysis will start automatically
+
+
+
+ {sourceMode === "github" ? "Importing from GitHub" : "Uploading project archive"}
+
+
+ {sourceMode === "github"
+ ? githubRef.trim()
+ ? `${githubUrl.trim()} @ ${githubRef.trim()}`
+ : githubUrl.trim() || "Processing repository"
+ : selectedFile
+ ? `${selectedFile.name} (${formatBytes(selectedFile.size)})`
+ : "Processing archive"}
+
+
+ Elapsed {elapsedSeconds}s — analysis will start automatically after import
+
+
+
+
+
+ Import progress
+ {importProgressValue(importPhases)}%
+
+
+
+
+
+ {importPhases.map((phase) => (
+
+ ))}
+
+
)}
diff --git a/frontend/src/pages/ResearchAnalytics.tsx b/frontend/src/pages/ResearchAnalytics.tsx
index d42bfe4..f19829c 100644
--- a/frontend/src/pages/ResearchAnalytics.tsx
+++ b/frontend/src/pages/ResearchAnalytics.tsx
@@ -119,6 +119,228 @@ function findingSeverityBadge(severity: ResearchFindingFrequency["severity"]): s
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+const METADATA_LABELS: Record
= {
+ file_extension: "File extension",
+ line_count: "Line count",
+ service_count: "Compose services",
+ has_dockerfile: "Includes Dockerfile",
+ has_compose: "Includes Compose",
+ uses_build: "Uses build",
+ uses_volumes: "Uses volumes",
+ uses_networks: "Uses networks",
+ detected_analyzer: "Analyzer",
+};
+
+function formatMetaValue(value: unknown): string {
+ if (typeof value === "boolean") return value ? "Yes" : "No";
+ if (value == null || value === "") return "—";
+ return String(value);
+}
+
+function ResearchSourcePrivacyNotice() {
+ return (
+
+
+
+
No Dockerfile or Compose source here
+
+ Research is anonymized: filenames, file contents, and paths are never shown. You only
+ see aggregate metadata and issue codes. For full file preview and highlighted findings,
+ open your own job from{" "}
+
+ History
+ {" "}
+ or{" "}
+
+ Results
+
+ .
+
+
+
+ );
+}
+
+function ResearchJobIdentity({ job }: { job: PublicResearchJob }) {
+ return (
+
+
+
+ Job ID
+
+ {job.id}
+
+
+
+ Anonymized contributor
+
+ {job.anonymized_submitter}
+
+
+
+ Type / status
+
+
+
+ {job.type}
+
+
+ {job.status}
+
+
+
+
+ );
+}
+
+function ResearchJobDetailSummary({ job }: { job: PublicResearchJob }) {
+ const result = job.public_result;
+ const severity =
+ result && typeof result.severity_distribution === "object"
+ ? (result.severity_distribution as Record)
+ : null;
+ const issueCodes =
+ result && Array.isArray(result.issue_codes)
+ ? (result.issue_codes as string[]).filter(Boolean)
+ : [];
+ const docUrls =
+ result && Array.isArray(result.doc_urls)
+ ? (result.doc_urls as string[]).filter(Boolean)
+ : [];
+
+ const metaRows = Object.entries(METADATA_LABELS)
+ .map(([key, label]) => {
+ const value = job.public_metadata[key];
+ if (value == null || value === "") return null;
+ return { label, value: formatMetaValue(value) };
+ })
+ .filter((row): row is { label: string; value: string } => row != null);
+
+ const signalRows: { label: string; value: string }[] = [];
+ const score = job.score ?? (typeof result?.score === "number" ? result.score : null);
+ const grade = job.grade ?? (typeof result?.grade === "string" ? result.grade : null);
+ if (score != null) signalRows.push({ label: "Score", value: String(score) });
+ if (grade) signalRows.push({ label: "Grade", value: grade });
+ for (const [key, label] of [
+ ["errors_count", "Errors"],
+ ["warnings_count", "Warnings"],
+ ["suggestions_count", "Suggestions"],
+ ["security_count", "Security findings"],
+ ] as const) {
+ const n = result?.[key];
+ if (typeof n === "number") signalRows.push({ label, value: String(n) });
+ }
+
+ return (
+
+
+
+
+ {metaRows.length > 0 ? (
+
+
+ Public metadata
+
+
+ {metaRows.map((row) => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
+
+ ) : null}
+
+ {signalRows.length > 0 ? (
+
+
+ Analysis signals
+
+
+ {signalRows.map((row) => (
+
+
{row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+ ) : (
+
No aggregated analysis signals for this job yet.
+ )}
+
+ {severity && Object.keys(severity).length > 0 ? (
+
+
+ Severity mix
+
+
+ {Object.entries(severity).map(([sev, count]) => (
+
+ {sev}: {count}
+
+ ))}
+
+
+ ) : null}
+
+ {issueCodes.length > 0 ? (
+
+
+ Issue codes
+
+
+ {issueCodes.map((code) => (
+
+ {code}
+
+ ))}
+
+
+ ) : null}
+
+ {docUrls.length > 0 ? (
+
+
+ Documentation links
+
+
+ {docUrls.map((url) => (
+
+
+ {url}
+
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
export function ResearchAnalytics() {
const dialogTitleId = useId();
const dialogPanelRef = useRef(null);
@@ -141,8 +363,10 @@ export function ResearchAnalytics() {
const [filterStatus, setFilterStatus] = useState("");
const [detailOpen, setDetailOpen] = useState(false);
+ const [detailTab, setDetailTab] = useState("summary");
const [detailJob, setDetailJob] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
+ const detailScrollRef = useRef(null);
const loadAll = useCallback(async () => {
const isInitialPass = !initialFetchCompletedRef.current;
@@ -230,9 +454,15 @@ export function ResearchAnalytics() {
const closeDetail = useCallback(() => {
setDetailOpen(false);
setDetailJob(null);
+ setDetailTab("summary");
window.setTimeout(() => triggerRef.current?.focus?.(), 0);
}, []);
+ const handleDetailTabChange = useCallback((value: string) => {
+ setDetailTab(value);
+ detailScrollRef.current?.scrollTo({ top: 0, behavior: "instant" });
+ }, []);
+
useEffect(() => {
if (!detailOpen) return;
@@ -289,6 +519,7 @@ export function ResearchAnalytics() {
const openDetail = async (jobId: string) => {
triggerRef.current = document.activeElement as HTMLElement;
setDetailOpen(true);
+ setDetailTab("summary");
setDetailJob(null);
setDetailLoading(true);
try {
@@ -974,7 +1205,10 @@ export function ResearchAnalytics() {
-
+
{detailLoading ? (
@@ -983,59 +1217,53 @@ export function ResearchAnalytics() {
) : null}
{!detailLoading && detailJob ? (
-
+
-
- Metadata
+
+ Summary
-
- Public signals
+
+ Raw JSON
-
-
-
-
- Job ID
-
- {detailJob.id}
-
-
-
- Anonymized contributor
-
- {detailJob.anonymized_submitter}
-
-
-
- Type / status
-
-
- {detailJob.type}
-
- {detailJob.status}
-
-
-
-
-
- {JSON.stringify(detailJob.public_metadata, null, 2)}
-
+
+
-
-
- {detailJob.public_result
- ? JSON.stringify(detailJob.public_result, null, 2)
- : "null"}
-
+
+
+ Privacy-filtered payloads only—no source file text.
+
+
+
+ public_metadata
+
+
+ {JSON.stringify(detailJob.public_metadata, null, 2)}
+
+
+
+
+ public_result
+
+
+ {detailJob.public_result
+ ? JSON.stringify(detailJob.public_result, null, 2)
+ : "null"}
+
+
) : null}
-
+
+ {detailJob?.is_own_job ? (
+
+ Open full results
+
+ ) : null}
Close
diff --git a/frontend/src/pages/ResultsDashboard.tsx b/frontend/src/pages/ResultsDashboard.tsx
index 1e9fd04..4f4b782 100644
--- a/frontend/src/pages/ResultsDashboard.tsx
+++ b/frontend/src/pages/ResultsDashboard.tsx
@@ -40,6 +40,11 @@ import {
TabsList,
TabsTrigger,
} from "../components/ui/tabs";
+import {
+ clearSessionStopping,
+ isSessionStopping,
+ markSessionStopping,
+} from "../utils/deploySession";
import {
ApiError,
compose as composeApi,
@@ -140,8 +145,12 @@ export function ResultsDashboard() {
const reducedMotion = useReducedMotion();
const [error, setError] = useState
(null);
const [containerStatus, setContainerStatus] = useState<"stopped" | "running" | "stopping" | "exited" | "partial" | "unhealthy" | "stopped_by_user">(() => {
- const stored = sessionStorage.getItem("dqa:containerStatus");
- if (stored === "stopping") return "stopping";
+ const jobId =
+ typeof window !== "undefined"
+ ? new URLSearchParams(window.location.search).get("jobId") ||
+ sessionStorage.getItem("analysisJobId")
+ : null;
+ if (isSessionStopping(jobId ?? undefined)) return "stopping";
return "stopped";
});
const [runtimeSelfExited, setRuntimeSelfExited] = useState(false);
@@ -182,12 +191,25 @@ export function ResultsDashboard() {
const metaFilename =
(fetched.input_metadata?.filename as string | undefined) ??
"uploaded file";
- if (!uploadedFile) {
- setUploadedFile({
- name: metaFilename,
- type: fetched.type === "compose" ? "docker-compose" : fetched.type,
- content: "",
- });
+ if (!uploadedFile && fetched.type !== "project") {
+ const storedUpload = sessionStorage.getItem("uploadedFile");
+ if (storedUpload) {
+ try {
+ setUploadedFile(JSON.parse(storedUpload) as UploadedFile);
+ } catch {
+ setUploadedFile({
+ name: metaFilename,
+ type: fetched.type === "compose" ? "docker-compose" : fetched.type,
+ content: "",
+ });
+ }
+ } else {
+ setUploadedFile({
+ name: metaFilename,
+ type: fetched.type === "compose" ? "docker-compose" : fetched.type,
+ content: "",
+ });
+ }
}
} catch (err) {
if (cancelled) return;
@@ -216,88 +238,114 @@ export function ResultsDashboard() {
setRequiresResubmit(sessionStorage.getItem(key) === "1");
}, [job?.id, job]);
- const reconcileDeployStatus = useCallback(async (jobId: string) => {
- try {
- const status = await composeApi.deployStatus(jobId);
-
- const rs = status.runtime_state ?? "none";
- const sessionStopping =
- typeof sessionStorage !== "undefined" &&
- sessionStorage.getItem("dqa:containerStatus") === "stopping";
+ const notifyStopCompleted = useCallback((jobId: string) => {
+ toast.success("Containers stopped");
+ pushNotification("success", "Containers Stopped", "All containers have been successfully stopped", {
+ dedupeKey: `deploy.stop.completed:${jobId}`,
+ });
+ }, []);
- const treatAsStopping =
- rs === "stopping" ||
- Boolean(status.active && sessionStopping);
+ const reconcileDeployStatus = useCallback(
+ async (jobId: string): Promise => {
+ try {
+ const status = await composeApi.deployStatus(jobId);
+ const rs = status.runtime_state ?? "none";
+ const wasStopping = isSessionStopping(jobId);
- if (treatAsStopping) {
- setContainerStatus("stopping");
- return;
- }
+ const userStopped =
+ rs === "stopped_by_user" ||
+ (!status.active &&
+ (status.stopped_by_user || status.stop_reason === "user_requested"));
- // Backend is authoritative — check explicit states first
- if (rs === "stopped_by_user") {
- setContainerStatus("stopped_by_user");
- setRuntimeSelfExited(false);
- sessionStorage.removeItem("dqa:containerStatus");
- return;
- }
- if (rs === "exited" || rs === "failed" || rs === "cleanup_completed") {
- if (status.stopped_by_user || status.stop_reason === "user_requested") {
+ if (userStopped) {
setContainerStatus("stopped_by_user");
setRuntimeSelfExited(false);
- sessionStorage.removeItem("dqa:containerStatus");
- return;
+ clearSessionStopping(jobId);
+ if (wasStopping) notifyStopCompleted(jobId);
+ return false;
}
- setContainerStatus("exited");
- setRuntimeSelfExited(!status.stopped_by_user);
- sessionStorage.removeItem("dqa:containerStatus");
- return;
- }
- if (rs === "running") {
- setContainerStatus("running");
- sessionStorage.removeItem("dqa:containerStatus");
- } else if (rs === "partial") {
- setContainerStatus("partial");
- sessionStorage.removeItem("dqa:containerStatus");
- } else if (rs === "unhealthy") {
- setContainerStatus("unhealthy");
- sessionStorage.removeItem("dqa:containerStatus");
- } else {
- const exitedCount = status.exited_count ?? 0;
- const runningCount = status.running_count ?? 0;
- const unhealthyCount = status.unhealthy_count ?? 0;
- if (!status.active) {
- if (exitedCount > 0 && runningCount === 0) {
+
+ if (rs === "stopping" || (status.active && wasStopping)) {
+ setContainerStatus("stopping");
+ return true;
+ }
+
+ if (!status.active && wasStopping) {
+ setContainerStatus("stopped");
+ setRuntimeSelfExited(false);
+ clearSessionStopping(jobId);
+ notifyStopCompleted(jobId);
+ return false;
+ }
+
+ // Backend is authoritative — check explicit states first
+ if (rs === "exited" || rs === "failed" || rs === "cleanup_completed") {
+ setContainerStatus("exited");
+ setRuntimeSelfExited(!status.stopped_by_user);
+ clearSessionStopping(jobId);
+ return false;
+ }
+ if (rs === "running") {
+ setContainerStatus("running");
+ clearSessionStopping(jobId);
+ } else if (rs === "partial") {
+ setContainerStatus("partial");
+ clearSessionStopping(jobId);
+ } else if (rs === "unhealthy") {
+ setContainerStatus("unhealthy");
+ clearSessionStopping(jobId);
+ } else {
+ const exitedCount = status.exited_count ?? 0;
+ const runningCount = status.running_count ?? 0;
+ const unhealthyCount = status.unhealthy_count ?? 0;
+ if (!status.active) {
+ if (exitedCount > 0 && runningCount === 0) {
+ setContainerStatus("exited");
+ setRuntimeSelfExited(!status.stopped_by_user);
+ } else {
+ setContainerStatus("stopped");
+ setRuntimeSelfExited(false);
+ clearSessionStopping(jobId);
+ }
+ } else if (unhealthyCount > 0) {
+ setContainerStatus("unhealthy");
+ } else if (exitedCount > 0 && runningCount > 0) {
+ setContainerStatus("partial");
+ } else if (exitedCount > 0 && runningCount === 0) {
setContainerStatus("exited");
setRuntimeSelfExited(!status.stopped_by_user);
} else {
- setContainerStatus("stopped");
- setRuntimeSelfExited(false);
- sessionStorage.removeItem("dqa:containerStatus");
+ setContainerStatus("running");
}
- } else if (unhealthyCount > 0) {
- setContainerStatus("unhealthy");
- } else if (exitedCount > 0 && runningCount > 0) {
- setContainerStatus("partial");
- } else if (exitedCount > 0 && runningCount === 0) {
- setContainerStatus("exited");
- setRuntimeSelfExited(!status.stopped_by_user);
- } else {
- setContainerStatus("running");
}
+ return false;
+ } catch {
+ return isSessionStopping(jobId);
}
- } catch {
- // ignore
- }
- }, []);
+ },
+ [notifyStopCompleted],
+ );
useEffect(() => {
if (!job || (job.type !== "compose" && job.type !== "project")) return;
- void reconcileDeployStatus(job.id);
+ let cancelled = false;
+ let timer: ReturnType | null = null;
+
+ const poll = async () => {
+ const needsPoll = await reconcileDeployStatus(job.id);
+ if (cancelled) return;
+ if (needsPoll || isSessionStopping(job.id)) {
+ timer = setTimeout(poll, 1000);
+ }
+ };
+
+ void poll();
const handleFocus = () => void reconcileDeployStatus(job.id);
window.addEventListener("focus", handleFocus);
return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
window.removeEventListener("focus", handleFocus);
};
}, [job, reconcileDeployStatus]);
@@ -429,40 +477,47 @@ export function ResultsDashboard() {
}
const previousStatus = containerStatus;
setContainerStatus("stopping");
- sessionStorage.setItem("dqa:containerStatus", "stopping");
+ markSessionStopping(job.id);
pushNotification("info", "Stopping Containers", "Stop signal sent, waiting for shutdown...", {
dedupeKey: `deploy.stop.requested:${job.id}`,
});
try {
await composeApi.stopDeploy({ job_id: job.id });
- for (let i = 0; i < 30; i++) {
+ let confirmed = false;
+ for (let i = 0; i < 45; i++) {
if (unmountedRef.current) break;
- await new Promise((r) => setTimeout(r, 2000));
+ await new Promise((r) => setTimeout(r, 1000));
if (unmountedRef.current) break;
try {
const status = await composeApi.deployStatus(job.id);
- if (!status.active) {
+ const rs = status.runtime_state ?? "none";
+ if (rs === "stopping") {
+ setContainerStatus("stopping");
+ }
+ if (
+ rs === "stopped_by_user" ||
+ (!status.active && (status.stopped_by_user || rs === "none"))
+ ) {
+ confirmed = true;
if (unmountedRef.current) break;
- setContainerStatus("stopped");
- sessionStorage.removeItem("dqa:containerStatus");
- toast.success("Containers stopped");
- pushNotification("success", "Containers Stopped", "All containers have been successfully stopped", {
- dedupeKey: `deploy.stop.completed:${job.id}`,
- });
+ setContainerStatus(
+ status.stopped_by_user || rs === "stopped_by_user" ? "stopped_by_user" : "stopped",
+ );
+ clearSessionStopping(job.id);
+ notifyStopCompleted(job.id);
return;
}
} catch {
break;
}
}
- if (!unmountedRef.current) {
- setContainerStatus("stopped");
- sessionStorage.removeItem("dqa:containerStatus");
+ if (!unmountedRef.current && !confirmed) {
+ setContainerStatus("stopping");
}
} catch {
if (!unmountedRef.current) {
setContainerStatus(previousStatus);
- sessionStorage.removeItem("dqa:containerStatus");
+ clearSessionStopping(job.id);
toast.error("Failed to stop containers");
pushNotification("error", "Stop Failed", "Failed to stop containers", {
dedupeKey: `deploy.stop.failed:${job.id}`,
@@ -597,7 +652,7 @@ export function ResultsDashboard() {
{(job.input_metadata?.filename as string | undefined) ??
- uploadedFile?.name ??
+ (!isProjectJob ? uploadedFile?.name : undefined) ??
job.id}
Job ID: {job.id}
@@ -1023,18 +1078,14 @@ export function ResultsDashboard() {
)}
- {uploadedFile?.content && (
+ {uploadedFile?.content && !isProjectJob && (
Uploaded file preview
@@ -1570,18 +1621,20 @@ function buildMarkdownReport(
}
lines.push("");
- lines.push("## Source File");
- lines.push("");
- lines.push(`File: \`${filename}\``);
- lines.push("");
- if (sourceContent) {
- lines.push("```" + language);
- lines.push(sourceContent.replace(/```/g, "``\u200b`"));
- lines.push("```");
- } else {
- lines.push("_Source file content not embedded in this report._");
+ if (!projectResult) {
+ lines.push("## Source File");
+ lines.push("");
+ lines.push(`File: \`${filename}\``);
+ lines.push("");
+ if (sourceContent) {
+ lines.push("```" + language);
+ lines.push(sourceContent.replace(/```/g, "``\u200b`"));
+ lines.push("```");
+ } else {
+ lines.push("_Source file content not embedded in this report._");
+ }
+ lines.push("");
}
- lines.push("");
if (projectResult) {
lines.push("## Per-File Results");
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index da2ac8b..0526ce8 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -180,20 +180,6 @@ export const router = createBrowserRouter([
};
},
},
- {
- path: "settings/api-keys",
- hydrateFallbackElement: routeHydrateFallbackElement,
- lazy: async () => {
- const m = await import("./pages/ApiKeys");
- return {
- Component: () => (
-
-
-
- ),
- };
- },
- },
],
},
]);
diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts
index 1f2bc4f..9ccedc5 100644
--- a/frontend/src/utils/api.ts
+++ b/frontend/src/utils/api.ts
@@ -77,6 +77,7 @@ export interface Job {
export interface PublicResearchJob {
id: string;
anonymized_submitter: string;
+ is_own_job: boolean;
type: JobType;
status: JobStatus;
public_metadata: Record
;
@@ -155,6 +156,16 @@ export interface ProjectGithubUploadRequest {
ref?: string | null;
}
+export interface ContainerPortBinding {
+ host_ip?: string | null;
+ host_port?: string | null;
+}
+
+export interface ContainerPortMapping {
+ container_port?: string | null;
+ host_bindings?: ContainerPortBinding[];
+}
+
export interface RuntimeContainerState {
id: string;
name?: string | null;
@@ -168,9 +179,18 @@ export interface RuntimeContainerState {
finished_at?: string | null;
restart_count?: number | null;
oom_killed?: boolean | null;
+ ip_address?: string | null;
+ ports?: ContainerPortMapping[];
last_logs?: string[] | null;
}
+export interface ContainerLogPayload {
+ container_id?: string;
+ line?: string;
+ stream?: "stdout" | "stderr" | "system";
+ timestamp?: string | null;
+}
+
export type DeployRuntimeState =
| "none"
| "running"
@@ -201,6 +221,20 @@ export interface DindIpResponse {
dind_ip: string | null;
}
+export interface PreviewCheckResponse {
+ reachable: boolean;
+ frameable: boolean;
+ proxy_available: boolean;
+ status_code: number | null;
+ reason: string | null;
+ final_url: string | null;
+}
+
+export interface PreviewSessionResponse {
+ session_id: string;
+ proxy_root: string;
+}
+
export interface ImageBuildResult {
dockerfile_path: string;
build_context: string;
@@ -230,18 +264,6 @@ export interface ImageBuildResult {
error_message?: string | null;
}
-export interface ApiKey {
- id: string;
- key_prefix: string;
- created_at: string;
-}
-
-export interface ApiKeyCreated {
- id: string;
- key: string;
- key_prefix: string;
-}
-
export interface DomainEvent {
event_name: string;
user_id: string;
@@ -372,6 +394,16 @@ export function getApiBaseUrl(): string {
return API_BASE_URL;
}
+/** Same-origin path for the preview iframe (uses API proxy; strips X-Frame-Options). */
+export function buildPreviewProxyEmbedUrl(sessionId: string): string {
+ const token = getAccessToken();
+ const base = API_BASE_URL.replace(/\/$/, "");
+ const prefix = base.length > 0 ? base : "";
+ const path = `/api/v1/compose/deploy/preview-proxy/${sessionId}/`;
+ if (!token) return `${prefix}${path}`;
+ return `${prefix}${path}?token=${encodeURIComponent(token)}`;
+}
+
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
@@ -539,19 +571,6 @@ export const auth = {
},
};
-// ---------- api keys ----------
-export const apiKeys = {
- async list(): Promise {
- return request("/api/v1/users/me/api-keys");
- },
- async create(): Promise {
- return request("/api/v1/users/me/api-keys", { method: "POST" });
- },
- async revoke(keyId: string): Promise {
- return request(`/api/v1/users/me/api-keys/${keyId}`, { method: "DELETE" });
- },
-};
-
// ---------- jobs / history ----------
export const jobs = {
async list(): Promise {
@@ -671,6 +690,26 @@ export const compose = {
async dindIp(): Promise {
return request("/api/v1/compose/deploy/dind-ip");
},
+ async checkPreview(url: string): Promise {
+ const params = new URLSearchParams({ url });
+ return request(
+ `/api/v1/compose/deploy/preview-check?${params.toString()}`,
+ );
+ },
+ async createPreviewProxySession(
+ url: string,
+ hints?: { user_agent?: string; accept_language?: string },
+ ): Promise {
+ return request("/api/v1/compose/deploy/preview-proxy/session", {
+ method: "POST",
+ body: JSON.stringify({
+ url,
+ user_agent: hints?.user_agent,
+ accept_language: hints?.accept_language,
+ }),
+ credentials: "include",
+ });
+ },
async stopDeploy(payload: {
job_id: string;
remove_volumes?: boolean;
@@ -781,6 +820,12 @@ export const ws = {
`${wsBase()}/ws/users/${userId}/containers/${containerId}?token=${encodeURIComponent(token)}`,
);
},
+ connectUserContainerLogs(userId: string, containerId: string): WebSocket {
+ const token = getAccessToken() ?? "";
+ return new WebSocket(
+ `${wsBase()}/ws/users/${userId}/containers/${containerId}/logs?token=${encodeURIComponent(token)}`,
+ );
+ },
connectUserEvents(userId: string): WebSocket {
const token = getAccessToken() ?? "";
return new WebSocket(
@@ -789,6 +834,45 @@ export const ws = {
},
};
+/**
+ * Close a WebSocket without triggering the browser's
+ * "WebSocket is closed before the connection is established" warning.
+ *
+ * Never call `close()` while `readyState === CONNECTING` (common under React
+ * StrictMode's dev mount/unmount/mount cycle). Stale connecting sockets are
+ * abandoned until `open`, then closed from a one-shot listener.
+ */
+export function safeCloseSocket(socket: WebSocket | null | undefined): void {
+ if (!socket) return;
+ const state = socket.readyState;
+ if (state === WebSocket.CLOSED || state === WebSocket.CLOSING) {
+ return;
+ }
+ socket.onmessage = null;
+ socket.onerror = null;
+ socket.onclose = null;
+ socket.onopen = null;
+ if (state === WebSocket.CONNECTING) {
+ socket.addEventListener(
+ "open",
+ () => {
+ try {
+ socket.close();
+ } catch {
+ /* noop */
+ }
+ },
+ { once: true },
+ );
+ return;
+ }
+ try {
+ socket.close();
+ } catch {
+ /* noop */
+ }
+}
+
// ---------- health ----------
export async function checkHealth(): Promise<{ status: string }> {
return request<{ status: string }>("/health", { skipAuth: true });
diff --git a/frontend/src/utils/deploySession.ts b/frontend/src/utils/deploySession.ts
new file mode 100644
index 0000000..b3caa8d
--- /dev/null
+++ b/frontend/src/utils/deploySession.ts
@@ -0,0 +1,25 @@
+/** Session flags for in-flight compose deploy stop (survives route changes). */
+export const SESSION_STOPPING_KEY = "dqa:containerStatus";
+
+export function markSessionStopping(jobId: string): void {
+ if (typeof sessionStorage === "undefined") return;
+ sessionStorage.setItem(SESSION_STOPPING_KEY, "stopping");
+ sessionStorage.setItem(`${SESSION_STOPPING_KEY}:${jobId}`, "stopping");
+}
+
+export function clearSessionStopping(jobId?: string): void {
+ if (typeof sessionStorage === "undefined") return;
+ sessionStorage.removeItem(SESSION_STOPPING_KEY);
+ if (jobId) {
+ sessionStorage.removeItem(`${SESSION_STOPPING_KEY}:${jobId}`);
+ }
+}
+
+export function isSessionStopping(jobId?: string): boolean {
+ if (typeof sessionStorage === "undefined") return false;
+ if (sessionStorage.getItem(SESSION_STOPPING_KEY) === "stopping") return true;
+ if (jobId && sessionStorage.getItem(`${SESSION_STOPPING_KEY}:${jobId}`) === "stopping") {
+ return true;
+ }
+ return false;
+}
diff --git a/frontend/src/utils/jobSessionCleanup.ts b/frontend/src/utils/jobSessionCleanup.ts
new file mode 100644
index 0000000..4f5a59e
--- /dev/null
+++ b/frontend/src/utils/jobSessionCleanup.ts
@@ -0,0 +1,45 @@
+import { clearSessionStopping, SESSION_STOPPING_KEY } from "./deploySession";
+import { clearState } from "./monitoringState";
+
+const REPLAY_KEY_PREFIX = "dqa:notifications:replay:";
+
+function replayStateStorageKey(userId: string): string {
+ return `${REPLAY_KEY_PREFIX}${userId}`;
+}
+
+/** Remove browser session/local state tied to a deleted analysis job. */
+export function clearJobSessionState(jobId: string, userId?: string | null): void {
+ if (typeof window === "undefined") return;
+
+ try {
+ if (sessionStorage.getItem("analysisJobId") === jobId) {
+ sessionStorage.removeItem("analysisJobId");
+ }
+ if (sessionStorage.getItem("projectJobId") === jobId) {
+ sessionStorage.removeItem("projectJobId");
+ }
+ clearState(`dqa:execution:${jobId}`);
+ clearState(`dqa:monitoring:${jobId}`);
+ sessionStorage.removeItem(`dqa:resubmitRequired:${jobId}`);
+ sessionStorage.removeItem(`${SESSION_STOPPING_KEY}:${jobId}`);
+ clearSessionStopping(jobId);
+
+ if (userId) {
+ const key = replayStateStorageKey(userId);
+ const raw = localStorage.getItem(key);
+ if (raw) {
+ try {
+ const state = JSON.parse(raw) as Record;
+ if (jobId in state) {
+ delete state[jobId];
+ localStorage.setItem(key, JSON.stringify(state));
+ }
+ } catch {
+ // ignore malformed replay state
+ }
+ }
+ }
+ } catch {
+ // ignore storage errors (private mode, quota, etc.)
+ }
+}
diff --git a/frontend/src/utils/metricsSeries.ts b/frontend/src/utils/metricsSeries.ts
new file mode 100644
index 0000000..1e421a3
--- /dev/null
+++ b/frontend/src/utils/metricsSeries.ts
@@ -0,0 +1,123 @@
+import type { ContainerMetricsPayload } from "./api";
+
+export interface MetricSample {
+ timestamp: number;
+ label: string;
+ cpuPercent: number | null;
+ memoryMb: number | null;
+ memoryPercent: number | null;
+ rxBps: number | null;
+ txBps: number | null;
+ readBps: number | null;
+ writeBps: number | null;
+}
+
+export const DEFAULT_MAX_METRIC_POINTS = 120;
+export const PERSIST_METRIC_POINTS = 60;
+
+export function formatBytes(bytes: number | null | undefined): string {
+ if (bytes == null || Number.isNaN(bytes)) return "-";
+ if (bytes <= 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ const digits = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return `${value.toFixed(digits)} ${units[unit]}`;
+}
+
+export function formatBps(bps: number | null | undefined): string {
+ if (bps == null || Number.isNaN(bps)) return "-";
+ return `${formatBytes(bps)}/s`;
+}
+
+function parseEventTimestamp(eventTimestamp: string | undefined): number {
+ if (!eventTimestamp) return Date.now();
+ const ts = new Date(eventTimestamp).getTime();
+ return Number.isNaN(ts) ? Date.now() : ts;
+}
+
+function num(value: unknown): number | null {
+ return typeof value === "number" && !Number.isNaN(value) ? value : null;
+}
+
+export function computeRates(
+ prev: ContainerMetricsPayload | null,
+ next: ContainerMetricsPayload,
+ deltaMs: number,
+): { rxBps: number | null; txBps: number | null; readBps: number | null; writeBps: number | null } {
+ if (!prev || deltaMs <= 0) {
+ return { rxBps: null, txBps: null, readBps: null, writeBps: null };
+ }
+ const deltaSec = deltaMs / 1000;
+ const prevNet = prev.network?.totals;
+ const nextNet = next.network?.totals;
+ const prevRx = num(prevNet?.rx_bytes);
+ const nextRx = num(nextNet?.rx_bytes);
+ const prevTx = num(prevNet?.tx_bytes);
+ const nextTx = num(nextNet?.tx_bytes);
+ const prevRead = num(prev.io?.read_bytes);
+ const nextRead = num(next.io?.read_bytes);
+ const prevWrite = num(prev.io?.write_bytes);
+ const nextWrite = num(next.io?.write_bytes);
+
+ const rate = (before: number | null, after: number | null): number | null => {
+ if (before == null || after == null || after < before) return null;
+ return (after - before) / deltaSec;
+ };
+
+ return {
+ rxBps: rate(prevRx, nextRx),
+ txBps: rate(prevTx, nextTx),
+ readBps: rate(prevRead, nextRead),
+ writeBps: rate(prevWrite, nextWrite),
+ };
+}
+
+export function appendMetricSample(
+ points: MetricSample[],
+ payload: ContainerMetricsPayload,
+ eventTimestamp: string | undefined,
+ prevPayload: ContainerMetricsPayload | null,
+ maxPoints = DEFAULT_MAX_METRIC_POINTS,
+): MetricSample[] {
+ const timestamp = parseEventTimestamp(eventTimestamp);
+ const prevTs = points.length > 0 ? points[points.length - 1].timestamp : null;
+ const deltaMs = prevTs != null ? timestamp - prevTs : 0;
+ const rates = computeRates(prevPayload, payload, deltaMs);
+
+ const cpu = num(payload.cpu_percent) ?? num(payload.cpu?.percent);
+ const memBytes = num(payload.memory_bytes) ?? num(payload.memory?.usage_bytes);
+ const memoryMb = memBytes == null ? null : memBytes / (1024 * 1024);
+ const memoryPercent = num(payload.memory_percent) ?? num(payload.memory?.percent);
+
+ const sample: MetricSample = {
+ timestamp,
+ label: new Date(timestamp).toLocaleTimeString(),
+ cpuPercent: cpu,
+ memoryMb,
+ memoryPercent,
+ rxBps: rates.rxBps,
+ txBps: rates.txBps,
+ readBps: rates.readBps,
+ writeBps: rates.writeBps,
+ };
+
+ return [...points, sample].slice(-maxPoints);
+}
+
+/** Trim payload for sessionStorage (drop bulky network interface maps). */
+export function trimPayloadForPersist(payload: ContainerMetricsPayload): ContainerMetricsPayload {
+ const { network, ...rest } = payload;
+ return {
+ ...rest,
+ network: network
+ ? {
+ totals: network.totals,
+ }
+ : undefined,
+ };
+}
diff --git a/frontend/src/utils/previewBrowser.ts b/frontend/src/utils/previewBrowser.ts
new file mode 100644
index 0000000..61e041f
--- /dev/null
+++ b/frontend/src/utils/previewBrowser.ts
@@ -0,0 +1,64 @@
+/** Open container preview in a real browser window (top-level navigation, not an iframe). */
+
+const POPOUT_FEATURES = [
+ "noopener",
+ "noreferrer",
+ "width=1280",
+ "height=860",
+ "menubar=no",
+ "toolbar=yes",
+ "location=yes",
+ "status=yes",
+ "resizable=yes",
+ "scrollbars=yes",
+].join(",");
+
+const popoutWindows = new Map();
+
+function popoutWindowName(url: string): string {
+ try {
+ const parsed = new URL(url);
+ return `dqa-preview-${parsed.host}-${parsed.port || "80"}`;
+ } catch {
+ return "dqa-preview";
+ }
+}
+
+/** Opens or focuses a pop-out window — same experience as opening the URL in Chrome/Firefox. */
+export function openPopOutPreview(url: string, options?: { replace?: boolean }): Window | null {
+ if (typeof window === "undefined" || !url.trim()) return null;
+
+ const name = popoutWindowName(url);
+ const existing = popoutWindows.get(name);
+ if (existing && !existing.closed) {
+ try {
+ existing.location.href = url;
+ existing.focus();
+ return existing;
+ } catch {
+ /* cross-origin focus/nav may fail; open fresh */
+ }
+ }
+
+ const opened = window.open(url, options?.replace ? name : "_blank", POPOUT_FEATURES);
+ if (opened) {
+ popoutWindows.set(name, opened);
+ opened.focus();
+ }
+ return opened;
+}
+
+export function collectBrowserProxyHints(): {
+ user_agent: string;
+ accept_language: string;
+} {
+ if (typeof navigator === "undefined") {
+ return { user_agent: "", accept_language: "" };
+ }
+ return {
+ user_agent: navigator.userAgent,
+ accept_language: navigator.languages?.length
+ ? navigator.languages.join(",")
+ : navigator.language || "en-US,en;q=0.9",
+ };
+}
diff --git a/frontend/src/utils/previewCheck.ts b/frontend/src/utils/previewCheck.ts
new file mode 100644
index 0000000..a4cc2c1
--- /dev/null
+++ b/frontend/src/utils/previewCheck.ts
@@ -0,0 +1,26 @@
+/** Client-side guards before hitting the preview probe API. */
+export function localPreviewIssue(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ const host = parsed.hostname.toLowerCase();
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
+ if (
+ (port === "8000" || port === "8001") &&
+ (host === "localhost" || host === "127.0.0.1" || host === "::1")
+ ) {
+ return "That URL is the platform API (port 8000), not your container app. Pick a published container port from the list.";
+ }
+ if (
+ parsed.pathname.startsWith("/api") ||
+ parsed.pathname.startsWith("/docs") ||
+ parsed.pathname.startsWith("/redoc") ||
+ parsed.pathname === "/openapi.json" ||
+ parsed.pathname.startsWith("/metrics")
+ ) {
+ return "That path belongs to the platform API. Use the container host:port from deploy status.";
+ }
+ } catch {
+ return "Enter a valid http://host:port URL.";
+ }
+ return null;
+}
diff --git a/scripts/common.ps1 b/scripts/common.ps1
index 9acaac0..9d4202b 100644
--- a/scripts/common.ps1
+++ b/scripts/common.ps1
@@ -74,6 +74,23 @@ function Require-Command {
}
}
+function Clear-DevUploadStorage {
+ $relPaths = @("backend/storage/uploads", "storage/uploads")
+
+ foreach ($relPath in $relPaths) {
+ $uploadDir = Join-Path $script:RootDir $relPath
+ if (-not (Test-Path -LiteralPath $uploadDir)) {
+ continue
+ }
+ $children = Get-ChildItem -LiteralPath $uploadDir -Force -ErrorAction SilentlyContinue
+ if ($children.Count -eq 0) {
+ continue
+ }
+ Write-Host "[stop] Cleaning dev upload artifacts in $relPath"
+ $children | Remove-Item -Recurse -Force
+ }
+}
+
function New-SecretValue {
param([int]$Length = 48)
diff --git a/scripts/common.sh b/scripts/common.sh
index de21d12..7a4549e 100644
--- a/scripts/common.sh
+++ b/scripts/common.sh
@@ -47,3 +47,20 @@ require_cmd() {
generate_secret() {
LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null)" ]]; then
+ echo "[stop] Cleaning dev upload artifacts in $rel_path"
+ find "$upload_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
+ fi
+ done
+}
diff --git a/scripts/start.ps1 b/scripts/start.ps1
index df93564..3b9f235 100644
--- a/scripts/start.ps1
+++ b/scripts/start.ps1
@@ -188,4 +188,9 @@ else {
}
Write-Host "Use .\scripts\status.ps1 --$script:Mode for runtime status"
-Write-Host "Use .\scripts\stop.ps1 --$script:Mode --wipe to reset everything"
+if ($script:Mode -eq "dev") {
+ Write-Host "Use .\scripts\stop.ps1 --$script:Mode --wipe to reset volumes and backend/storage/uploads"
+}
+else {
+ Write-Host "Use .\scripts\stop.ps1 --$script:Mode --wipe to reset volumes"
+}
diff --git a/scripts/start.sh b/scripts/start.sh
index abf5dc4..001798f 100755
--- a/scripts/start.sh
+++ b/scripts/start.sh
@@ -102,33 +102,37 @@ ensure_secret_file() {
}
run_migrations() {
- local migration_log
+ local migration_log alembic_subcmd
migration_log="$(mktemp)"
- if compose_cmd exec -T -w /app api sh -lc '
- export POSTGRES_PASSWORD="$(cat /run/secrets/postgres_password)"
- export DATABASE_URL="postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-docker_platform}"
- PYTHONPATH=/app alembic upgrade head
- ' >"$migration_log" 2>&1; then
+ alembic_subcmd="upgrade head"
+
+ run_alembic_once() {
+ compose_cmd run --rm --no-deps -w /app api sh -lc "set -euo pipefail
+export POSTGRES_PASSWORD=\"\$(cat /run/secrets/postgres_password)\"
+export DATABASE_URL=\"postgresql+asyncpg://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD}@postgres:5432/\${POSTGRES_DB:-docker_platform}\"
+export PYTHONPATH=/app
+cd /app
+alembic ${alembic_subcmd}"
+ }
+
+ # Run before API boot so lifespan create_all cannot race Alembic (common after --wipe).
+ if run_alembic_once >"$migration_log" 2>&1; then
rm -f "$migration_log"
log "Migrations are up to date"
return 0
fi
- if rg -i 'duplicate|already exists|DuplicateTable' "$migration_log" >/dev/null 2>&1; then
+
+ if rg -i 'duplicate|already exists|DuplicateTable|DuplicateObject' "$migration_log" >/dev/null 2>&1; then
log "Detected existing schema without Alembic state; stamping head then retrying"
- compose_cmd exec -T -w /app api sh -lc '
- export POSTGRES_PASSWORD="$(cat /run/secrets/postgres_password)"
- export DATABASE_URL="postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-docker_platform}"
- PYTHONPATH=/app alembic stamp head
- ' >>"$migration_log" 2>&1
- compose_cmd exec -T -w /app api sh -lc '
- export POSTGRES_PASSWORD="$(cat /run/secrets/postgres_password)"
- export DATABASE_URL="postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-docker_platform}"
- PYTHONPATH=/app alembic upgrade head
- ' >>"$migration_log" 2>&1
+ alembic_subcmd="stamp head"
+ run_alembic_once >>"$migration_log" 2>&1
+ alembic_subcmd="upgrade head"
+ run_alembic_once >>"$migration_log" 2>&1
rm -f "$migration_log"
log "Migration bootstrap completed"
return 0
fi
+
echo "[start][error] Migration failed. Logs:" >&2
cat "$migration_log" >&2
rm -f "$migration_log"
@@ -149,7 +153,9 @@ ensure_secret_file "secrets/postgres_password.txt" "secrets/postgres_password.tx
ensure_secret_file "secrets/jwt_secret.txt" "secrets/jwt_secret.txt.example"
log "Starting stack in mode: $MODE"
-compose_cmd up -d --build
+log "Building API image and bringing up Postgres (and Redis) before API boot"
+compose_cmd build api
+compose_cmd up -d postgres redis
log "Waiting for Postgres"
wait_postgres 180
@@ -157,6 +163,9 @@ wait_postgres 180
log "Running migrations"
run_migrations
+log "Starting remaining services"
+compose_cmd up -d
+
wait_http "http://127.0.0.1:${FRONTEND_PORT:-3000}" "Frontend" 180
if [[ "$MODE" == "dev" ]]; then
@@ -170,4 +179,8 @@ else
fi
echo "Use ./scripts/status.sh --$MODE for runtime status"
-echo "Use ./scripts/stop.sh --$MODE --wipe to reset everything"
+if [[ "$MODE" == "dev" ]]; then
+ echo "Use ./scripts/stop.sh --$MODE --wipe to reset volumes and backend/storage/uploads"
+else
+ echo "Use ./scripts/stop.sh --$MODE --wipe to reset volumes"
+fi
diff --git a/scripts/stop.ps1 b/scripts/stop.ps1
index e764587..77ae663 100644
--- a/scripts/stop.ps1
+++ b/scripts/stop.ps1
@@ -9,6 +9,9 @@ $wipe = $args -contains "--wipe"
if ($wipe) {
Write-Host "[stop] Stopping $script:Mode stack and removing volumes"
Invoke-ComposeChecked down -v --remove-orphans
+ if ($script:Mode -eq "dev") {
+ Clear-DevUploadStorage
+ }
}
else {
Write-Host "[stop] Stopping $script:Mode stack"
diff --git a/scripts/stop.sh b/scripts/stop.sh
index fecc11f..242bfd1 100755
--- a/scripts/stop.sh
+++ b/scripts/stop.sh
@@ -8,6 +8,9 @@ source "$SCRIPT_DIR/common.sh" "$@"
if [[ " $* " == *" --wipe "* ]]; then
echo "[stop] Stopping $MODE stack and removing volumes"
compose_cmd down -v --remove-orphans
+ if [[ "$MODE" == "dev" ]]; then
+ clean_dev_upload_storage
+ fi
else
echo "[stop] Stopping $MODE stack"
compose_cmd down --remove-orphans