From 44e34c30998b5d5bdaaa4b51623d5e02e03bcbc3 Mon Sep 17 00:00:00 2001 From: cppenloglou Date: Tue, 2 Jun 2026 19:27:20 +0300 Subject: [PATCH 1/3] Refactor authentication and remove API key functionality - Removed API key generation and management features from the backend and frontend, including the deletion of related routes and components. - Updated authentication to support only Bearer JWT tokens, enhancing security and simplifying the codebase. - Revised documentation to reflect the removal of API key features and updated user authentication methods. - Adjusted database schema and migration scripts to drop the API keys table, ensuring a clean transition. - Enhanced user experience by streamlining the authentication process and removing unnecessary complexity. --- .cursor/rules/backend-api-routers.mdc | 2 +- .cursor/rules/backend-infrastructure.mdc | 3 +- README.md | 3 +- backend/README.md | 2 +- .../versions/0003_drop_api_keys_table.py | 36 ++ backend/app/api/deps.py | 16 +- backend/app/api/router.py | 3 +- backend/app/api/routers/api_keys.py | 46 --- backend/app/api/routers/research.py | 7 +- backend/app/application/schemas.py | 13 +- backend/app/infrastructure/db/models.py | 10 - backend/app/infrastructure/db/repositories.py | 45 +-- backend/tests/test_api_routes.py | 91 +---- backend/tests/test_research_api.py | 29 ++ context/refactor-backlog.md | 2 +- examples/README.md | 20 +- frontend/src/components/DragDropUpload.tsx | 13 +- frontend/src/components/Layout.tsx | 6 +- frontend/src/components/QuickDemo.tsx | 16 +- frontend/src/components/ui/tabs.tsx | 5 +- frontend/src/pages/AnalysisProgress.tsx | 68 ++-- frontend/src/pages/ApiKeys.tsx | 282 ---------------- frontend/src/pages/FileUpload.tsx | 33 +- frontend/src/pages/Landing.tsx | 32 +- frontend/src/pages/ResearchAnalytics.tsx | 312 +++++++++++++++--- frontend/src/pages/ResultsDashboard.tsx | 23 +- frontend/src/routes.tsx | 14 - frontend/src/utils/api.ts | 26 +- 28 files changed, 479 insertions(+), 679 deletions(-) create mode 100644 backend/alembic/versions/0003_drop_api_keys_table.py delete mode 100644 backend/app/api/routers/api_keys.py delete mode 100644 frontend/src/pages/ApiKeys.tsx 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.md b/README.md index a19cef8..2538a8a 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. 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/0003_drop_api_keys_table.py b/backend/alembic/versions/0003_drop_api_keys_table.py new file mode 100644 index 0000000..90c07c7 --- /dev/null +++ b/backend/alembic/versions/0003_drop_api_keys_table.py @@ -0,0 +1,36 @@ +"""drop api_keys table + +Revision ID: 0003_drop_api_keys_table +Revises: 0002_add_scanned_job_status +Create Date: 2026-06-02 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0003_drop_api_keys_table" +down_revision = "0002_add_scanned_job_status" +branch_labels = None +depends_on = None + + +def upgrade() -> 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") + + +def downgrade() -> None: + 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) 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..8cbb064 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,10 +1,9 @@ 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, 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(project.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/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/application/schemas.py b/backend/app/application/schemas.py index cd3ee00..3694029 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 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/tests/test_api_routes.py b/backend/tests/test_api_routes.py index d0a1ca7..6065497 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 @@ -668,95 +668,6 @@ async def list_jobs(self, requested_user_id): assert payload[0]["type"] == "project" -def test_api_key_create_returns_new_key(client, monkeypatch, app, fake_db_session_dependency): - user = make_user(email="apikey-owner@example.com") - key_id = uuid.uuid4() - - class ApiKeyRepo: - def __init__(self, _session): - pass - - async def create_key(self, requested_user_id): - assert requested_user_id == user.id - record = ApiKeyModel(id=key_id, user_id=user.id, key_prefix="dpa_prefix123") - return record, "dpa_secret_value" - - monkeypatch.setattr("app.api.routers.api_keys.ApiKeyRepository", ApiKeyRepo) - app.dependency_overrides[get_db_session] = fake_db_session_dependency - app.dependency_overrides[get_current_user] = lambda: user - - response = client.post("/api/v1/users/me/api-keys", headers=auth_header_for(user.id)) - - app.dependency_overrides.clear() - assert response.status_code == 200 - payload = response.json() - assert payload["id"] == str(key_id) - assert payload["key"] == "dpa_secret_value" - assert payload["key_prefix"] == "dpa_prefix123" - - -def test_api_key_list_returns_active_keys(client, monkeypatch, app, fake_db_session_dependency): - user = make_user(email="apikey-owner@example.com") - key_id = uuid.uuid4() - created_at = datetime.now(timezone.utc) - - class ApiKeyRepo: - def __init__(self, _session): - pass - - async def list_keys(self, requested_user_id): - assert requested_user_id == user.id - return [ - ApiKeyModel( - id=key_id, - user_id=user.id, - key_prefix="dpa_prefix123", - key_hash="hash", - created_at=created_at, - ) - ] - - monkeypatch.setattr("app.api.routers.api_keys.ApiKeyRepository", ApiKeyRepo) - app.dependency_overrides[get_db_session] = fake_db_session_dependency - app.dependency_overrides[get_current_user] = lambda: user - - response = client.get("/api/v1/users/me/api-keys", headers=auth_header_for(user.id)) - - app.dependency_overrides.clear() - assert response.status_code == 200 - payload = response.json() - assert len(payload) == 1 - assert payload[0]["id"] == str(key_id) - assert payload[0]["key_prefix"] == "dpa_prefix123" - - -def test_api_key_revoke_returns_404_when_missing(client, monkeypatch, app, fake_db_session_dependency): - user = make_user(email="apikey-owner@example.com") - key_id = uuid.uuid4() - - class ApiKeyRepo: - def __init__(self, _session): - pass - - async def revoke(self, requested_user_id, requested_key_id): - assert requested_user_id == user.id - assert requested_key_id == key_id - return False - - monkeypatch.setattr("app.api.routers.api_keys.ApiKeyRepository", ApiKeyRepo) - app.dependency_overrides[get_db_session] = fake_db_session_dependency - app.dependency_overrides[get_current_user] = lambda: user - - response = client.delete( - f"/api/v1/users/me/api-keys/{key_id}", - headers=auth_header_for(user.id), - ) - - app.dependency_overrides.clear() - assert response.status_code == 404 - assert response.json()["detail"] == "API key not found." - - def test_project_upload_rejects_oversized_archive(client, monkeypatch, app, fake_db_session_dependency): user = make_user(email="project-owner@example.com") app.dependency_overrides[get_db_session] = fake_db_session_dependency 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/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/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/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..57a2d30 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ 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 { useAuth } from "../auth/AuthProvider"; import { NotificationEventBridge } from "./NotificationEventBridge"; import { NotificationPanel } from "./NotificationPanel"; @@ -68,10 +68,6 @@ export function Layout({ children }: LayoutProps) { Research - - - API Keys -
{ + 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.

-
+
-
- - -

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} - - - -
-
-
-
- )} - - {!ready && ( - - )} - - {ready && error && ( - - {error} - - )} - - {ready && !error && prefixBlocks.length === 0 && ( - - -

No API keys yet.

- -
- )} - - {ready && prefixBlocks.length > 0 && ( -
- {prefixBlocks.map((item) => ( - -
-
- -
-
-
- {item.key_prefix} - ... -
-
- Created {item.created} -
-
- - id: {item.id.slice(0, 8)} - -
- -
- ))} -
- )} -
- - - ); -} diff --git a/frontend/src/pages/FileUpload.tsx b/frontend/src/pages/FileUpload.tsx index 94d0c03..bc56b51 100644 --- a/frontend/src/pages/FileUpload.tsx +++ b/frontend/src/pages/FileUpload.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useEffect, useMemo } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { Layout } from "../components/Layout"; import { CodePreview } from "../components/CodePreview"; import { MotionPage } from "../components/motion"; @@ -8,16 +8,29 @@ 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 location = useLocation(); + const fileData = useMemo( + () => readUploadedFile(), + [location.key, location.pathname], + ); useEffect(() => { if (!fileData) { 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/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 +

+ +
+ ) : 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 ? ( + + ) : null} diff --git a/frontend/src/pages/ResultsDashboard.tsx b/frontend/src/pages/ResultsDashboard.tsx index 1e9fd04..6bab7a7 100644 --- a/frontend/src/pages/ResultsDashboard.tsx +++ b/frontend/src/pages/ResultsDashboard.tsx @@ -183,11 +183,24 @@ export function ResultsDashboard() { (fetched.input_metadata?.filename as string | undefined) ?? "uploaded file"; if (!uploadedFile) { - setUploadedFile({ - name: metaFilename, - type: fetched.type === "compose" ? "docker-compose" : fetched.type, - content: "", - }); + 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; 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..de88ee6 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; @@ -230,18 +231,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; @@ -539,19 +528,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 { From e89f885664122f44f408c15656fec6e16082b211 Mon Sep 17 00:00:00 2001 From: cppenloglou Date: Sun, 7 Jun 2026 13:33:18 +0300 Subject: [PATCH 2/3] Enhance project upload and analysis functionality - Updated `README.md` and `README-compose.md` to clarify the behavior of the `stop.sh` script, including the effect of the `--wipe` option on the development upload directory. - Increased the maximum upload size in the environment configuration from 30 MB to 200 MB and added new timeout settings for project jobs and image builds. - Removed the `api_keys` table and related migration scripts to streamline the database schema. - Introduced a new `preview_proxy` router for authenticated reverse proxying of in-app container previews, enhancing user experience and security. - Implemented job artifact cleanup functionality to remove on-disk and queued artifacts tied to analysis jobs, improving resource management. - Enhanced the project analysis job to automatically select Dockerfiles for builds based on the primary compose file, optimizing the build process. --- README-compose.md | 4 +- README.md | 4 +- backend/.env.example | 5 +- .../alembic/versions/0001_initial_schema.py | 16 - .../versions/0003_drop_api_keys_table.py | 36 - backend/app/api/router.py | 3 +- backend/app/api/routers/compose.py | 212 +- backend/app/api/routers/history.py | 8 + backend/app/api/routers/preview_proxy.py | 368 +++ backend/app/api/routers/project.py | 25 +- backend/app/api/routers/ws.py | 92 + backend/app/application/schemas.py | 12 + .../application/services/build_selection.py | 96 + .../application/services/compose_mapper.py | 24 +- .../app/application/services/job_cleanup.py | 187 ++ .../application/services/project_scanner.py | 8 +- backend/app/core/config.py | 6 +- backend/app/infrastructure/docker/client.py | 193 +- backend/app/infrastructure/events/bus.py | 21 +- backend/app/workers/tasks.py | 977 +++++--- backend/app/workers/worker.py | 18 +- backend/tests/test_api_routes.py | 160 +- backend/tests/test_build_selection.py | 63 + backend/tests/test_container_lifecycle.py | 204 ++ backend/tests/test_docker_logs.py | 183 ++ backend/tests/test_event_bus.py | 49 + backend/tests/test_job_cleanup.py | 168 ++ backend/tests/test_project_build.py | 56 + backend/tests/test_project_scan.py | 22 + backend/tests/test_worker_events.py | 39 +- backend/tests/test_ws_logs.py | 119 + context/ui-and-jobs.md | 18 +- frontend/nginx.conf | 2 +- frontend/package-lock.json | 32 + frontend/package.json | 1 + frontend/src/components/AppFooter.tsx | 26 + frontend/src/components/DockerLoader.tsx | 227 +- frontend/src/components/Layout.tsx | 7 +- .../components/NotificationEventBridge.tsx | 34 +- frontend/src/components/auth/AuthShell.tsx | 8 +- .../monitoring/ContainerMetricsPanel.tsx | 536 +++++ .../src/components/monitoring/EChartPanel.tsx | 72 + frontend/src/pages/ContainerExecution.tsx | 60 +- frontend/src/pages/History.tsx | 14 +- frontend/src/pages/Monitoring.tsx | 2032 ++++++++++------- frontend/src/pages/ProjectUpload.tsx | 201 +- frontend/src/pages/ResultsDashboard.tsx | 236 +- frontend/src/utils/api.ts | 108 + frontend/src/utils/deploySession.ts | 25 + frontend/src/utils/jobSessionCleanup.ts | 45 + frontend/src/utils/metricsSeries.ts | 123 + frontend/src/utils/previewBrowser.ts | 64 + frontend/src/utils/previewCheck.ts | 26 + scripts/common.ps1 | 17 + scripts/common.sh | 17 + scripts/start.ps1 | 7 +- scripts/start.sh | 51 +- scripts/stop.ps1 | 3 + scripts/stop.sh | 3 + 59 files changed, 6013 insertions(+), 1360 deletions(-) delete mode 100644 backend/alembic/versions/0003_drop_api_keys_table.py create mode 100644 backend/app/api/routers/preview_proxy.py create mode 100644 backend/app/application/services/build_selection.py create mode 100644 backend/app/application/services/job_cleanup.py create mode 100644 backend/tests/test_build_selection.py create mode 100644 backend/tests/test_docker_logs.py create mode 100644 backend/tests/test_event_bus.py create mode 100644 backend/tests/test_job_cleanup.py create mode 100644 backend/tests/test_ws_logs.py create mode 100644 frontend/src/components/AppFooter.tsx create mode 100644 frontend/src/components/monitoring/ContainerMetricsPanel.tsx create mode 100644 frontend/src/components/monitoring/EChartPanel.tsx create mode 100644 frontend/src/utils/deploySession.ts create mode 100644 frontend/src/utils/jobSessionCleanup.ts create mode 100644 frontend/src/utils/metricsSeries.ts create mode 100644 frontend/src/utils/previewBrowser.ts create mode 100644 frontend/src/utils/previewCheck.ts 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 2538a8a..e47513c 100644 --- a/README.md +++ b/README.md @@ -92,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/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/alembic/versions/0003_drop_api_keys_table.py b/backend/alembic/versions/0003_drop_api_keys_table.py deleted file mode 100644 index 90c07c7..0000000 --- a/backend/alembic/versions/0003_drop_api_keys_table.py +++ /dev/null @@ -1,36 +0,0 @@ -"""drop api_keys table - -Revision ID: 0003_drop_api_keys_table -Revises: 0002_add_scanned_job_status -Create Date: 2026-06-02 -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision = "0003_drop_api_keys_table" -down_revision = "0002_add_scanned_job_status" -branch_labels = None -depends_on = None - - -def upgrade() -> 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") - - -def downgrade() -> None: - 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) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 8cbb064..f4b5ec3 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,11 +1,12 @@ from fastapi import APIRouter -from app.api.routers import 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(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/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/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 3694029..14dbd7d 100644 --- a/backend/app/application/schemas.py +++ b/backend/app/application/schemas.py @@ -234,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 @@ -247,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/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 6065497..3fc237d 100644 --- a/backend/tests/test_api_routes.py +++ b/backend/tests/test_api_routes.py @@ -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..3fd76a6 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 ────────────────────────────────────────────────────────────────── @@ -479,3 +481,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_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/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/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 ( +
+
+

Docker Analyzer — Lint and analyze your Docker configurations

+

+ © {new Date().getFullYear()} + + + {GITHUB_USERNAME} + +

+
+
+ ); +} 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/Layout.tsx b/frontend/src/components/Layout.tsx index 57a2d30..06da047 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; 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"; @@ -106,11 +107,7 @@ export function Layout({ children }: LayoutProps) {
{children}
-
-
- Docker Analyzer - Lint and analyze your Docker configurations -
-
+
); } 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/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/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/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/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. +

+
+ +
+
+ ); +} + +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}

+
+
+ + +
+
+ ); + } else if (localIssue || gate === "blocked") { + previewBody = ( +
+ +
+

In-app preview unavailable

+

{localIssue ?? blockedMessage}

+

{src}

+
+
+ + +
+
+ ); + } else if (gate === "pending") { + previewBody = ( +
+

Checking whether this app allows in-app preview…

+
+ ); + } else { + previewBody = ( +