From bd526e7aef707f669796923316c847362af2f57a Mon Sep 17 00:00:00 2001 From: cppenloglou Date: Thu, 14 May 2026 21:08:05 +0300 Subject: [PATCH 1/8] Add job deletion functionality and enhance deploy status handling - Implemented a new endpoint to delete jobs, including checks for job status and active deployments. - Refactored deploy status retrieval to use dedicated Redis key functions for improved clarity and maintainability. - Updated frontend components to support job deletion and provide user feedback. - Enhanced project analysis steps to ensure clarity in the workflow and user interactions. --- backend/app/api/routers/compose.py | 24 ++- backend/app/api/routers/history.py | 42 +++- backend/app/api/routers/project.py | 2 + backend/app/infrastructure/db/repositories.py | 10 +- backend/tests/test_api_routes.py | 153 ++++++++++++++ backend/uv.lock | 8 + frontend/src/pages/AnalysisProgress.tsx | 199 ++++++++++++------ frontend/src/pages/History.tsx | 54 ++++- frontend/src/pages/ProjectUpload.tsx | 6 +- frontend/src/utils/api.ts | 3 + 10 files changed, 421 insertions(+), 80 deletions(-) create mode 100644 backend/uv.lock diff --git a/backend/app/api/routers/compose.py b/backend/app/api/routers/compose.py index 32ff4f4..bb943ea 100644 --- a/backend/app/api/routers/compose.py +++ b/backend/app/api/routers/compose.py @@ -130,7 +130,11 @@ class DeployStatusResponse(BaseModel): unhealthy_count: int = 0 -def _deploy_stop_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: +def deploy_state_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"deploy:{user_id}:{job_id}" + + +def deploy_stop_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"deploy-stop:{user_id}:{job_id}" @@ -147,12 +151,8 @@ def _recompute_container_counts(containers: list[ContainerStateInfo]) -> tuple[i return running, exited, unhealthy -@router.get("/deploy/status/{job_id}", response_model=DeployStatusResponse) -async def get_deploy_status( - job_id: uuid.UUID, - current_user: UserModel = Depends(get_current_user), -) -> DeployStatusResponse: - key = f"deploy:{current_user.id}:{job_id}" +async def compute_deploy_status(user_id: uuid.UUID, job_id: uuid.UUID) -> DeployStatusResponse: + key = deploy_state_redis_key(user_id, job_id) raw = await redis_client.get(key) if not raw: return DeployStatusResponse(active=False, runtime_state="none") @@ -174,7 +174,7 @@ async def get_deploy_status( unhealthy_count = int(state.get("unhealthy_count", 0) or 0) has_tracked = bool(container_ids) or bool(containers) - stop_raw = await redis_client.get(_deploy_stop_key(current_user.id, job_id)) + stop_raw = await redis_client.get(deploy_stop_redis_key(user_id, job_id)) stop_pending = str(stop_raw or "") == "1" or bool(state.get("stopping")) runtime_state: Literal["none", "running", "partial", "exited", "unhealthy", "stopping"] = "none" @@ -212,3 +212,11 @@ async def get_deploy_status( exited_count=exited_count, unhealthy_count=unhealthy_count, ) + + +@router.get("/deploy/status/{job_id}", response_model=DeployStatusResponse) +async def get_deploy_status( + job_id: uuid.UUID, + current_user: UserModel = Depends(get_current_user), +) -> DeployStatusResponse: + return await compute_deploy_status(current_user.id, job_id) diff --git a/backend/app/api/routers/history.py b/backend/app/api/routers/history.py index f5e1cb0..24aa8df 100644 --- a/backend/app/api/routers/history.py +++ b/backend/app/api/routers/history.py @@ -1,13 +1,15 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.ext.asyncio import AsyncSession 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.infrastructure.db.models import UserModel +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 +from app.infrastructure.events.bus import redis_client router = APIRouter(prefix="/api/v1/users/me", tags=["history"]) @@ -74,6 +76,42 @@ async def get_job_events( ) +@router.delete("/jobs/{job_id}", status_code=204) +async def delete_job( + job_id: uuid.UUID, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> Response: + repo = JobRepository(session) + job = await repo.get_job(job_id, current_user.id) + if not job: + raise HTTPException(status_code=404, detail="Job not found.") + + if job.status in (JobStatus.queued, JobStatus.running): + raise HTTPException( + status_code=409, + detail="Cannot delete a job that is still queued or running. Wait for it to finish.", + ) + + if job.type in (JobType.compose, JobType.project): + deploy_status = await compute_deploy_status(current_user.id, job_id) + if deploy_status.active: + raise HTTPException( + status_code=409, + detail="Stop running containers for this job before deleting it.", + ) + + deleted = await repo.delete_job(job_id, current_user.id) + if not deleted: + raise HTTPException(status_code=404, detail="Job not found.") + await session.commit() + + await redis_client.delete(deploy_state_redis_key(current_user.id, job_id)) + await redis_client.delete(deploy_stop_redis_key(current_user.id, job_id)) + + return Response(status_code=204) + + @router.get("/history", response_model=list[JobRead]) async def get_job_history( current_user: UserModel = Depends(get_current_user), diff --git a/backend/app/api/routers/project.py b/backend/app/api/routers/project.py index 89ca545..a0ee399 100644 --- a/backend/app/api/routers/project.py +++ b/backend/app/api/routers/project.py @@ -126,6 +126,8 @@ async def analyze_project( raise HTTPException(status_code=400, detail=f"Compose file '{cf}' was not found in scan results.") # Update the job metadata with the user's selections + # run_after_analysis records intent to enable a post-analysis manual Compose run from the + # results UI; it does not auto-run or auto-deploy Compose (worker payload omits it on purpose). updated_meta = { **job.input_metadata, "selected_dockerfiles": payload.selected_dockerfiles, diff --git a/backend/app/infrastructure/db/repositories.py b/backend/app/infrastructure/db/repositories.py index d5d7a4e..c0de647 100644 --- a/backend/app/infrastructure/db/repositories.py +++ b/backend/app/infrastructure/db/repositories.py @@ -3,7 +3,7 @@ import uuid from datetime import UTC, datetime -from sqlalchemy import Date, Numeric, Select, and_, cast, func, select +from sqlalchemy import Date, Numeric, Select, and_, cast, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import hash_password, verify_password @@ -106,6 +106,14 @@ async def get_job(self, job_id: uuid.UUID, user_id: uuid.UUID) -> AnalysisJobMod return None return job + async def delete_job(self, job_id: uuid.UUID, user_id: uuid.UUID) -> bool: + stmt = delete(AnalysisJobModel).where( + AnalysisJobModel.id == job_id, + AnalysisJobModel.user_id == user_id, + ) + result = await self.session.execute(stmt) + return (result.rowcount or 0) > 0 + async def list_jobs(self, user_id: uuid.UUID) -> list[AnalysisJobModel]: stmt = select(AnalysisJobModel).where(AnalysisJobModel.user_id == user_id).order_by(AnalysisJobModel.created_at.desc()) rows = await self.session.scalars(stmt) diff --git a/backend/tests/test_api_routes.py b/backend/tests/test_api_routes.py index 379027b..7a3433a 100644 --- a/backend/tests/test_api_routes.py +++ b/backend/tests/test_api_routes.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone import io +from types import SimpleNamespace from unittest.mock import AsyncMock import uuid import zipfile @@ -655,3 +656,155 @@ async def get_job(self, _job_id, _user_id): app.dependency_overrides.clear() assert response.status_code == 404 + + +def test_delete_job_success_returns_204(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-owner@example.com") + job_id = uuid.uuid4() + calls = {"deleted": False} + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.dockerfile, + status=JobStatus.done, + input_metadata={"filename": "Dockerfile"}, + result={"score": 90}, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, jid, uid): + assert jid == job_id and uid == user.id + calls["deleted"] = True + return True + + redis_del = AsyncMock() + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.redis_client.delete", redis_del) + 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/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 204 + assert response.content == b"" + assert calls["deleted"] is True + assert redis_del.await_count == 2 + + +def test_delete_job_returns_404_when_missing(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-missing@example.com") + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, _jid, _uid): + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + 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/jobs/{uuid.uuid4()}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 404 + + +@pytest.mark.parametrize("in_progress_status", [JobStatus.running, JobStatus.queued]) +def test_delete_job_returns_409_when_in_progress( + client, monkeypatch, app, fake_db_session_dependency, in_progress_status +): + user = make_user(email="delete-busy@example.com") + job_id = uuid.uuid4() + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.dockerfile, + status=in_progress_status, + input_metadata={"filename": "Dockerfile"}, + result=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + 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/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 409 + + +def test_delete_job_returns_409_when_compose_deploy_active(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-active-deploy@example.com") + job_id = uuid.uuid4() + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.compose, + status=JobStatus.done, + input_metadata={"filename": "docker-compose.yml"}, + result={"score": 88}, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + async def fake_deploy_status(*_a, **_k): + return SimpleNamespace(active=True) + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.compute_deploy_status", fake_deploy_status) + 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/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 409 + assert "Stop running containers" in response.json()["detail"] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..b28dd60 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "docker-quality-analyzer-backend" +version = "0.1.0" +source = { editable = "." } diff --git a/frontend/src/pages/AnalysisProgress.tsx b/frontend/src/pages/AnalysisProgress.tsx index 8817707..eeb2d62 100644 --- a/frontend/src/pages/AnalysisProgress.tsx +++ b/frontend/src/pages/AnalysisProgress.tsx @@ -50,7 +50,7 @@ const BASE_STEPS: AnalysisStep[] = [ }, ]; -const PROJECT_STEPS: AnalysisStep[] = [ +const PROJECT_CORE_STEPS: AnalysisStep[] = [ { id: "queued", label: "Job Queued", @@ -75,18 +75,6 @@ const PROJECT_STEPS: AnalysisStep[] = [ description: "Aggregating per-file results and computing project score", status: "pending", }, - { - id: "project.image_build", - label: "Building Images", - description: "Building Docker images from selected Dockerfiles", - status: "pending", - }, - { - id: "project.compose_run", - label: "Running Stack", - description: "Starting compose stack for runtime analysis", - status: "pending", - }, { id: "finalize", label: "Complete", @@ -95,6 +83,71 @@ const PROJECT_STEPS: AnalysisStep[] = [ }, ]; +const PROJECT_IMAGE_BUILD_STEP_TEMPLATE: AnalysisStep = { + id: "project.image_build", + label: "Building Images", + description: "Building Docker images from selected Dockerfiles", + status: "pending", +}; + +const PROJECT_COMPOSE_RUN_STEP_TEMPLATE: AnalysisStep = { + id: "project.compose_run", + label: "Running Stack", + description: "Starting compose stack for runtime analysis", + status: "pending", +}; + +const PROJECT_IMAGE_BUILD_EVENTS = new Set([ + "project.image_build_started", + "project.image_build_log", + "project.image_build_completed", + "project.image_build_failed", +]); + +const PROJECT_RUNTIME_STEP_EVENTS = new Set([ + "container.started", + "container.exited", + "project.runtime_stopped", +]); + +function cloneSteps(steps: AnalysisStep[]): AnalysisStep[] { + return steps.map((s) => ({ ...s })); +} + +function ensureProjectCoreSteps(prev: AnalysisStep[]): AnalysisStep[] { + if (prev.some((s) => s.id === "project.analysis_started")) { + return cloneSteps(prev); + } + return PROJECT_CORE_STEPS.map((s) => ({ ...s })); +} + +/** Inserts a clone of template before `finalize` if that step id is not already present. */ +function insertOptionalStepBeforeFinalize( + steps: AnalysisStep[], + template: AnalysisStep, +): AnalysisStep[] { + if (steps.some((s) => s.id === template.id)) { + return steps; + } + const fi = steps.findIndex((s) => s.id === "finalize"); + if (fi === -1) { + return steps; + } + const row: AnalysisStep = { ...template, status: "pending" }; + return [...steps.slice(0, fi), row, ...steps.slice(fi)]; +} + +function augmentProjectStepsForEvent(prev: AnalysisStep[], eventName: string): AnalysisStep[] { + let next = ensureProjectCoreSteps(prev); + if (PROJECT_IMAGE_BUILD_EVENTS.has(eventName)) { + next = insertOptionalStepBeforeFinalize(next, PROJECT_IMAGE_BUILD_STEP_TEMPLATE); + } + if (PROJECT_RUNTIME_STEP_EVENTS.has(eventName)) { + next = insertOptionalStepBeforeFinalize(next, PROJECT_COMPOSE_RUN_STEP_TEMPLATE); + } + return next; +} + // Map project event names to step IDs const PROJECT_EVENT_TO_STEP: Record = { "project.analysis_started": "project.analysis_started", @@ -167,9 +220,11 @@ export function AnalysisProgress() { }; const handleCompleted = (latestJob: Job) => { - setStep("queued", "complete"); - setStep("running", "complete"); - setStep("finalize", "complete"); + setSteps((prev) => + prev.map((step) => + step.status === "complete" ? step : { ...step, status: "complete" as StepStatus }, + ), + ); setProgressValue(100); setJobStatus("done"); try { @@ -251,7 +306,7 @@ export function AnalysisProgress() { // Switch to project step list if needed if (reconciled.type === "project") { setIsProjectJob(true); - setSteps(PROJECT_STEPS.map(s => ({ ...s }))); + setSteps(PROJECT_CORE_STEPS.map((s) => ({ ...s }))); } if (reconciled.status === "done") { handleCompleted(reconciled); @@ -267,8 +322,12 @@ export function AnalysisProgress() { } if (reconciled.status === "running") { setStep("queued", "complete"); - setStep("running", "running"); - setProgressValue(50); + if (reconciled.type === "project") { + setProgressValue(50); + } else { + setStep("running", "running"); + setProgressValue(50); + } } } catch (error) { if (error instanceof ApiError && error.status !== 404) { @@ -330,52 +389,62 @@ export function AnalysisProgress() { // Update steps for project events if (parsed.event_name.startsWith("project.") || parsed.event_name === "container.started" || parsed.event_name === "container.exited" || parsed.event_name === "project.runtime_stopped") { setIsProjectJob(true); - setSteps(prev => { - if (!prev.some(s => s.id === "project.file_analysis")) { - return PROJECT_STEPS.map(s => ({ ...s })); - } - return prev; - }); - const targetStep = PROJECT_EVENT_TO_STEP[parsed.event_name]; - if (targetStep) { - if (parsed.event_name === "project.analysis_started") { - setStep("queued", "complete"); - setStep("project.analysis_started", "running"); - setJobStatus("running"); - setProgressValue(20); - } else if (parsed.event_name === "project.file_analysis_started") { - setStep("project.analysis_started", "complete"); - setStep("project.file_analysis", "running"); - setProgressValue(45); - } else if (parsed.event_name === "project.merge_started") { - setStep("project.file_analysis", "complete"); - setStep("project.merge_started", "running"); - setProgressValue(80); - } else if (parsed.event_name === "project.analysis_completed") { - setStep("project.merge_started", "complete"); - setStep("finalize", "running"); - setProgressValue(95); - } else if (parsed.event_name === "project.analysis_failed") { - const message = (parsed.payload as { message?: string })?.message ?? "Project analysis failed."; - handleFailed(message); - } else if (parsed.event_name === "project.image_build_started") { - setStep("project.merge_started", "complete"); - setStep("project.image_build", "running"); - setProgressValue(85); - } else if (parsed.event_name === "project.image_build_completed") { - // Keep running until all builds done — will advance on analysis_completed - } else if (parsed.event_name === "project.image_build_failed") { - // Mark as error but don't fail job - setStep("project.image_build", "error"); - } else if (parsed.event_name === "container.started") { - setStep("project.image_build", "complete"); - setStep("project.compose_run", "running"); - setProgressValue(90); - } else if (parsed.event_name === "container.exited") { - setStep("project.compose_run", "error"); - } else if (parsed.event_name === "project.runtime_stopped") { - setStep("project.compose_run", "complete"); - setProgressValue(95); + setSteps((prev) => augmentProjectStepsForEvent(prev, parsed.event_name)); + if (parsed.event_name === "project.analysis_failed") { + const message = (parsed.payload as { message?: string })?.message ?? "Project analysis failed."; + handleFailed(message); + } else { + const targetStep = PROJECT_EVENT_TO_STEP[parsed.event_name]; + if (targetStep) { + if (parsed.event_name === "project.analysis_started") { + setStep("queued", "complete"); + setStep("project.analysis_started", "running"); + setJobStatus("running"); + setProgressValue(20); + } else if (parsed.event_name === "project.file_analysis_started") { + setStep("project.analysis_started", "complete"); + setStep("project.file_analysis", "running"); + setProgressValue(45); + } else if (parsed.event_name === "project.merge_started") { + setStep("project.file_analysis", "complete"); + setStep("project.merge_started", "running"); + setProgressValue(80); + } else if (parsed.event_name === "project.analysis_completed") { + setStep("project.merge_started", "complete"); + setSteps((prev) => + prev.map((s) => + s.id === "project.image_build" && s.status !== "error" + ? { ...s, status: "complete" as StepStatus } + : s, + ), + ); + setStep("finalize", "running"); + setProgressValue(95); + } else if (parsed.event_name === "project.image_build_started") { + setStep("project.merge_started", "complete"); + setStep("project.image_build", "running"); + setProgressValue(85); + } else if (parsed.event_name === "project.image_build_log" || parsed.event_name === "project.image_build_completed") { + setStep("project.image_build", "running"); + } else if (parsed.event_name === "project.image_build_failed") { + // Mark as error but don't fail job + setStep("project.image_build", "error"); + } else if (parsed.event_name === "container.started") { + setSteps((prev) => + prev.map((s) => + s.id === "project.image_build" && s.status !== "error" + ? { ...s, status: "complete" as StepStatus } + : s, + ), + ); + setStep("project.compose_run", "running"); + setProgressValue(90); + } else if (parsed.event_name === "container.exited") { + setStep("project.compose_run", "error"); + } else if (parsed.event_name === "project.runtime_stopped") { + setStep("project.compose_run", "complete"); + setProgressValue(95); + } } } } else if (parsed.event_name === "user.analysis.started") { diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 2a48d39..940f076 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -8,6 +8,7 @@ import { FileCode, FileText, FolderArchive, + Trash2, } from "lucide-react"; import { DockerLoader, useMinLoader } from "../components/DockerLoader"; @@ -211,6 +212,47 @@ export function History() { } }; + const deleteJob = async (e: React.MouseEvent, item: HistoryItem) => { + e.preventDefault(); + e.stopPropagation(); + const label = item.fileName || item.id.slice(0, 8); + if (!window.confirm(`Delete this analysis from history?\n\n${label}`)) { + return; + } + try { + await jobsApi.delete(item.id); + setJobList((prev) => { + const next = prev.filter((j) => j.id !== item.id); + jobListRef.current = next; + return next; + }); + setRunningJobs((prev) => { + const next = new Set(prev); + 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 + } + toast.success("Analysis removed from history"); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Failed to delete job."; + toast.error(message); + } + }; + return ( @@ -385,7 +427,17 @@ export function History() { )} )} - + + diff --git a/frontend/src/pages/ProjectUpload.tsx b/frontend/src/pages/ProjectUpload.tsx index 38cb62b..dc872c9 100644 --- a/frontend/src/pages/ProjectUpload.tsx +++ b/frontend/src/pages/ProjectUpload.tsx @@ -572,9 +572,9 @@ export function ProjectUpload() {
-

Run Compose stack after analysis

+

Enable Compose run after results

-

Deploy the selected Compose stack after a successful analysis

+

After analysis, the results page will let you run the selected Compose stack manually.

{selectedCompose.length === 0 &&

Requires at least one Compose file

}
@@ -590,7 +590,7 @@ export function ProjectUpload() {
Compose:{selectedCompose.length || "none selected"}
Mode:{analysisMode}
{buildImages &&
Build images:yes
} - {runAfter &&
Run compose:yes (after analysis)
} + {runAfter &&
Compose run:enabled from results page
} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index eece12e..036d976 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -513,6 +513,9 @@ export const jobs = { async getEvents(jobId: string): Promise { return request(`/api/v1/users/me/jobs/${jobId}/events`); }, + async delete(jobId: string): Promise { + return request(`/api/v1/users/me/jobs/${jobId}`, { method: "DELETE" }); + }, }; export const research = { From 7ad199315c7cf217a94bfca94b59f0b3d6feeb75 Mon Sep 17 00:00:00 2001 From: cppenloglou Date: Fri, 15 May 2026 00:31:14 +0300 Subject: [PATCH 2/8] Add UI and job lifecycle documentation, enhance container exit handling, and update monitoring status display - Introduced a new documentation file detailing UI and job lifecycle changes, including job deletion and project progress tracking. - Enhanced the ContainerExecution component to summarize exited containers, displaying relevant exit information and logs. - Updated the Monitoring component to reflect container health status, distinguishing between healthy, unhealthy, and exited states. - Modified the ResultsDashboard navigation to improve user experience when accessing monitoring metrics. --- context/ui-and-jobs.md | 25 ++++++ frontend/src/pages/ContainerExecution.tsx | 100 +++++++++++++++++++++- frontend/src/pages/History.tsx | 4 +- frontend/src/pages/Monitoring.tsx | 22 +++-- frontend/src/pages/ResultsDashboard.tsx | 2 +- 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 context/ui-and-jobs.md diff --git a/context/ui-and-jobs.md b/context/ui-and-jobs.md new file mode 100644 index 0000000..c7fa6b5 --- /dev/null +++ b/context/ui-and-jobs.md @@ -0,0 +1,25 @@ +# UI and job lifecycle notes + +## 2026-05-14 — History, delete, project progress truthfulness + +**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. + +**Delete analysis API** + +- `DELETE /api/v1/users/me/jobs/{job_id}` → **204** when allowed. +- **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. +- 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)`. + +**Project upload / `run_after_analysis`** + +- Checkbox copy reflects **manual** Compose from the results page, not auto-deploy. `run_after_analysis` stays in `job.input_metadata` only; worker enqueue payload does **not** pass it (no auto-run). Comment in [`backend/app/api/routers/project.py`](backend/app/api/routers/project.py) documents this. + +**Analysis progress (project jobs)** + +- Core steps only by default; **Building Images** and **Running Stack** rows are **inserted when** matching domain events arrive (`project.image_build_*`, `container.started` / `container.exited` / `project.runtime_stopped`). Reconcile uses core steps only (no event replay from `GET .../jobs/{id}/events`). diff --git a/frontend/src/pages/ContainerExecution.tsx b/frontend/src/pages/ContainerExecution.tsx index 7a774b2..36ebf20 100644 --- a/frontend/src/pages/ContainerExecution.tsx +++ b/frontend/src/pages/ContainerExecution.tsx @@ -64,6 +64,18 @@ interface ExecutionPersistedState { deployPhase?: DeployPhase; } +interface ExitedContainerSummary { + container_id?: string; + container_name?: string; + 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; +} + const DEPLOY_TIMEOUT_MS = 120_000; function ensureAnalysis(job: Job | null): AnalysisResult | null { @@ -113,6 +125,8 @@ export function ContainerExecution() { ); const [logs, setLogs] = useState(persisted?.logs ?? []); const [composeProgress, setComposeProgress] = useState(null); + const [exitedContainers, setExitedContainers] = useState([]); + const [metricsSeenContainerIds, setMetricsSeenContainerIds] = useState([]); const socketRef = useRef(null); const deployTimeoutRef = useRef | null>(null); @@ -227,6 +241,8 @@ export function ContainerExecution() { setDeployPhase("deploying"); clearDeployTimeout(); setComposeProgress(null); + setExitedContainers([]); + setMetricsSeenContainerIds([]); setTimeline(BASE_TIMELINE.map((entry) => ({ ...entry }))); setTimelineStatus("enqueue", "running", "Submitting deploy request..."); try { @@ -311,13 +327,29 @@ export function ContainerExecution() { ); pushNotification("success", "Containers Running", `${ids.length || 1} container(s) are now running`); } else if (parsed.event_name === "container.metrics") { + const metricContainerId = String( + (parsed.payload as { container_id?: string })?.container_id ?? "", + ); + if (metricContainerId) { + setMetricsSeenContainerIds((prev) => + prev.includes(metricContainerId) ? prev : [...prev, metricContainerId], + ); + } setTimelineStatus("metrics", "running", "Metrics streaming"); } else if (parsed.event_name === "container.exited") { - const p = parsed.payload as { container_id?: string; container_name?: string; exit_code?: number; error?: string }; + const p = parsed.payload as ExitedContainerSummary; const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; const exitMsg = `Container exited: ${name} (code ${p.exit_code ?? "??"})${p.error ? ` — ${p.error}` : ""}`; setTimelineStatus("start", "error", exitMsg); pushLog({ message: exitMsg, timestamp: parsed.timestamp, tone: "error" }); + setExitedContainers((prev) => { + const key = p.container_id ?? `${p.container_name ?? "container"}-${p.finished_at ?? ""}`; + const next = prev.filter((item) => { + const itemKey = item.container_id ?? `${item.container_name ?? "container"}-${item.finished_at ?? ""}`; + return itemKey !== key; + }); + return [...next, p]; + }); } else if (parsed.event_name === "project.runtime_stopped") { setTimelineStatus("metrics", "done", "All containers exited"); setStackRunning(false); @@ -661,6 +693,72 @@ export function ContainerExecution() { + {deployPhase === "exited" && exitedContainers.length > 0 && ( + +

Container Exit Summary

+
+ {exitedContainers.map((containerExit, index) => { + const containerKey = + containerExit.container_id ?? + `${containerExit.container_name ?? "container"}-${containerExit.finished_at ?? index}`; + const name = + containerExit.container_name ?? + containerExit.container_id?.slice(0, 12) ?? + `container-${index + 1}`; + const hasMetrics = + !!containerExit.container_id && + metricsSeenContainerIds.includes(containerExit.container_id); + return ( +
+
+ {name} + {containerExit.exit_code != null && ( + + exit code: {containerExit.exit_code} + + )} + {containerExit.oom_killed && ( + + OOM killed + + )} +
+ {containerExit.error && ( +

{containerExit.error}

+ )} +
+ {containerExit.started_at && ( + Started: {new Date(containerExit.started_at).toLocaleString()} + )} + {containerExit.finished_at && ( + Finished: {new Date(containerExit.finished_at).toLocaleString()} + )} + {containerExit.restart_count != null && ( + Restarts: {containerExit.restart_count} + )} +
+ {!hasMetrics && ( +

+ Container exited before metrics were collected. +

+ )} + {Array.isArray(containerExit.last_logs) && containerExit.last_logs.length > 0 && ( +
+ + Last logs ({containerExit.last_logs.length} lines) + +
+                          {containerExit.last_logs.join("\n")}
+                        
+
+ )} +
+ ); + })} +
+
+ )} +
- {historyItems.filter((item) => item.jobType === "compose").length} + {historyItems.filter((item) => item.jobType === "compose" || item.jobType === "project").length}
-
Compose jobs
+
Container jobs
diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index 1a96297..def87c1 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -323,6 +323,8 @@ export function Monitoring() { const ioStats = latestPayload?.io; const pids = latestPayload?.pids; const container = latestPayload?.container; + const currentHealthStatus = String(container?.health_status ?? "").toLowerCase(); + const isUnhealthyLive = !exitedState && connected && currentHealthStatus === "unhealthy"; const interfaces = useMemo(() => { const raw = latestPayload?.network?.interfaces; if (!raw || typeof raw !== "object") return []; @@ -362,13 +364,21 @@ export function Monitoring() { className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-xs ${ exitedState ? "border-red-500/40 bg-red-500/10 text-red-300" - : connected + : isUnhealthyLive + ? "border-amber-500/40 bg-amber-500/10 text-amber-300" + : connected ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" : "border-slate-700 bg-slate-900 text-slate-400" }`} > {exitedState ? : } - {exitedState ? `Exited (${exitedState.exit_code ?? "?"})` : connected ? "Live" : "Disconnected"} + {exitedState + ? `Exited (${exitedState.exit_code ?? "?"})` + : isUnhealthyLive + ? "Unhealthy" + : connected + ? "Live" + : "Disconnected"} @@ -379,6 +389,8 @@ export function Monitoring() { const cState = containerStates[cid]; const isConnected = cState?.connected ?? false; const isExited = !!cState?.exited; + const cHealthStatus = String(cState?.latestPayload?.container?.health_status ?? "").toLowerCase(); + const isUnhealthy = !isExited && isConnected && cHealthStatus === "unhealthy"; return ( + + +
    + {% for task in tasks %} +
  • +
    + +
    +
    + {{ task.title }} + {{ task.priority }} priority · {{ task.created_at }} +
    +
    + +
    +
  • + {% else %} +
  • No tasks yet. Add one above.
  • + {% endfor %} +
+ + +
+ GET /health + GET /api/tasks + POST /api/tasks + PATCH /api/tasks/<id> + DELETE /api/tasks/<id> + GET /api/stats +
+ + + diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml new file mode 100644 index 0000000..5830f9e --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml @@ -0,0 +1,34 @@ +# Image-only compose file. It does NOT build anything. +# Build the images first, then run this file: +# docker build -t local/python-mini-task-app:prod -f Dockerfile . +# docker build -t local/python-mini-task-app:smoke -f Dockerfile.smoke . +# docker compose -f docker-compose.images.yml up +services: + web: + image: local/python-mini-task-app:prod + ports: + - "5000:5000" + environment: + APP_ENV: production + DATABASE_PATH: /data/tasks.db + volumes: + - task-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health').read()"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + smoke: + image: local/python-mini-task-app:smoke + environment: + TARGET_URL: http://web:5000 + depends_on: + web: + condition: service_healthy + profiles: ["test"] + +volumes: + task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml new file mode 100644 index 0000000..a97815d --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml @@ -0,0 +1,18 @@ +# Build-based production-like compose file using the production Dockerfile. +services: + web: + build: + context: . + dockerfile: Dockerfile + image: local/python-mini-task-app:prod + ports: + - "5000:5000" + environment: + APP_ENV: production + DATABASE_PATH: /data/tasks.db + volumes: + - task-data:/data + restart: unless-stopped + +volumes: + task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml new file mode 100644 index 0000000..5fd9b1b --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml @@ -0,0 +1,46 @@ +# Build-based compose file for local development and testing. +services: + web: + build: + context: . + dockerfile: Dockerfile.dev + image: local/python-mini-task-app:dev + ports: + - "5000:5000" + environment: + APP_ENV: development + DATABASE_PATH: /data/tasks.db + volumes: + - .:/usr/src/app + - task-data:/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health').read()"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + tests: + build: + context: . + dockerfile: Dockerfile.test + image: local/python-mini-task-app:test + environment: + APP_ENV: test + DATABASE_PATH: /tmp/test-tasks.db + profiles: ["test"] + + smoke: + build: + context: . + dockerfile: Dockerfile.smoke + image: local/python-mini-task-app:smoke + environment: + TARGET_URL: http://web:5000 + depends_on: + web: + condition: service_healthy + profiles: ["test"] + +volumes: + task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py b/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py new file mode 100644 index 0000000..82ffd9f --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py @@ -0,0 +1,44 @@ +import json +import os +import sys +import time +import urllib.error +import urllib.request + +TARGET_URL = os.getenv("TARGET_URL", "http://web:5000").rstrip("/") + + +def request(path: str, method: str = "GET", payload: dict | None = None): + body = None + headers = {} + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(f"{TARGET_URL}{path}", data=body, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=5) as response: + raw = response.read().decode("utf-8") + return response.status, json.loads(raw) if raw else None + + +last_error = None +for _ in range(30): + try: + status, health = request("/health") + if status == 200 and health["status"] == "ok": + break + except (urllib.error.URLError, TimeoutError, KeyError) as exc: + last_error = exc + time.sleep(1) +else: + print(f"Service did not become healthy: {last_error}", file=sys.stderr) + sys.exit(1) + +status, created = request("/api/tasks", "POST", {"title": "Smoke test task", "priority": "high"}) +assert status == 201, created +assert created["title"] == "Smoke test task" + +status, stats = request("/api/stats") +assert status == 200, stats +assert stats["total"] >= 1, stats + +print("Smoke test passed") diff --git a/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml b/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml new file mode 100644 index 0000000..758f215 --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-mini-task-app" +version = "0.1.0" +description = "Small Flask task app used to demonstrate Dockerfiles and Docker Compose." +requires-python = ">=3.12" +dependencies = [ + "flask==3.0.3", + "gunicorn==23.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest==8.3.3", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/examples/projects/python-mini-task-app-docker-fixed/pytest.ini b/examples/projects/python-mini-task-app-docker-fixed/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/examples/projects/python-mini-task-app-docker-fixed/requirements.txt b/examples/projects/python-mini-task-app-docker-fixed/requirements.txt new file mode 100644 index 0000000..827de94 --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +gunicorn==23.0.0 +pytest==8.3.3 diff --git a/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py b/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py new file mode 100644 index 0000000..19ac002 --- /dev/null +++ b/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py @@ -0,0 +1,57 @@ +import pytest + +from app.main import create_app + + +@pytest.fixture() +def client(tmp_path): + app = create_app(database_path=str(tmp_path / "test.db")) + app.config.update(TESTING=True) + return app.test_client() + + +def test_homepage_loads(client): + response = client.get("/") + assert response.status_code == 200 + assert b"Python Mini Task App" in response.data + assert b"Flask + SQLite + Docker" in response.data + + +def test_health_endpoint(client): + response = client.get("/health") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "ok" + assert "tasks" in data + + +def test_create_task_api(client): + response = client.post("/api/tasks", json={"title": "Test task", "priority": "high"}) + assert response.status_code == 201 + data = response.get_json() + assert data["title"] == "Test task" + assert data["priority"] == "high" + assert data["done"] is False + + +def test_update_task_api(client): + created = client.post("/api/tasks", json={"title": "Patch me"}).get_json() + response = client.patch(f"/api/tasks/{created['id']}", json={"done": True, "priority": "low"}) + assert response.status_code == 200 + data = response.get_json() + assert data["done"] is True + assert data["priority"] == "low" + + +def test_delete_task_api(client): + created = client.post("/api/tasks", json={"title": "Delete me"}).get_json() + response = client.delete(f"/api/tasks/{created['id']}") + assert response.status_code == 204 + + +def test_stats_endpoint(client): + response = client.get("/api/stats") + assert response.status_code == 200 + data = response.get_json() + assert data["total"] >= 3 + assert "remaining" in data diff --git a/frontend/src/components/FileAnalysisDetail.tsx b/frontend/src/components/FileAnalysisDetail.tsx index 131aa4f..5cdbe08 100644 --- a/frontend/src/components/FileAnalysisDetail.tsx +++ b/frontend/src/components/FileAnalysisDetail.tsx @@ -34,7 +34,11 @@ export function FileAnalysisDetail({ file, defaultOpen = false }: FileAnalysisDe const meta = (file.meta ?? {}) as Record; const estimate = meta.estimate as Record | undefined; - const runnability = meta.runnability as { runnable?: boolean; reasons?: string[] } | undefined; + const runnability = meta.runnability as { + runnable?: boolean; + reasons?: string[]; + rules?: Record; + } | undefined; const highlightedLines = [ ...errors.map(e => e.line), @@ -150,19 +154,49 @@ export function FileAnalysisDetail({ file, defaultOpen = false }: FileAnalysisDe {file.file_type === "compose" && (
{runnability && ( -
- - {runnability.runnable ? "Runnable" : "Not runnable"} - - {!runnability.runnable && runnability.reasons && ( -
    - {runnability.reasons.map((r, i) =>
  • • {r}
  • )} +
    +
    +
    +

    Deploy Runnability

    +

    Strict precheck rules decide whether this compose file can be deployed.

    +
    + + {runnability.runnable ? "Runnable" : "Blocked"} + +
    + {!runnability.runnable && ( +
      + {(runnability.reasons ?? ["No runnability metadata found."]).map((r, i) =>
    • {r}
    • )}
    )} + {runnability.rules && ( +
    + + Inspect runnability rules + +
    + {Object.entries(runnability.rules).map(([rule, passed]) => ( +
    + {rule} + {passed ? "pass" : "fail"} +
    + ))} +
    +
    + )}
    )}
diff --git a/frontend/src/pages/AnalysisProgress.tsx b/frontend/src/pages/AnalysisProgress.tsx index eeb2d62..d66b3f9 100644 --- a/frontend/src/pages/AnalysisProgress.tsx +++ b/frontend/src/pages/AnalysisProgress.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; +import { FolderSearch, ArrowLeft } from "lucide-react"; import { Layout } from "../components/Layout"; import { ProgressStep } from "../components/ProgressStep"; @@ -177,6 +178,7 @@ export function AnalysisProgress() { const queryJobId = searchParams.get("jobId"); const [isProjectJob, setIsProjectJob] = useState(false); + const [isScannedOnly, setIsScannedOnly] = useState(false); const [steps, setSteps] = useState(() => BASE_STEPS.map((step) => ({ ...step })), ); @@ -308,6 +310,17 @@ export function AnalysisProgress() { setIsProjectJob(true); setSteps(PROJECT_CORE_STEPS.map((s) => ({ ...s }))); } + // Guard: scanned jobs have no worker — show CTA view instead of progress + if (reconciled.status === "scanned") { + setIsScannedOnly(true); + return; + } + // Also guard on analysis_confirmed=false in metadata + const meta = reconciled.input_metadata as Record; + if (reconciled.type === "project" && meta?.analysis_confirmed === false) { + setIsScannedOnly(true); + return; + } if (reconciled.status === "done") { handleCompleted(reconciled); return; @@ -516,6 +529,45 @@ export function AnalysisProgress() { return progressValue; }, [steps, jobStatus, progressValue]); + if (isScannedOnly) { + return ( + + +
+
+
+ +
+
+

Project Scanned

+

+ This project has been scanned but analysis has not started yet. +

+

+ Return to the project upload page to select files and confirm analysis. +

+
+ + +
+
+
+
+ ); + } + return ( diff --git a/frontend/src/pages/ContainerExecution.tsx b/frontend/src/pages/ContainerExecution.tsx index 36ebf20..27becec 100644 --- a/frontend/src/pages/ContainerExecution.tsx +++ b/frontend/src/pages/ContainerExecution.tsx @@ -184,11 +184,43 @@ export function ContainerExecution() { } const status = await composeApi.deployStatus(analysisJobId); + const rs = status.runtime_state ?? "none"; if (status.active) { setStackRunning(true); - setDeployPhase("running"); + if (rs === "stopping") { + setDeployPhase("deploying"); + } else { + setDeployPhase("running"); + } setContainerIds(status.container_ids); if (!deployJobId) setDeployJobId(analysisJobId); + } else if (rs === "exited" || rs === "failed" || rs === "cleanup_completed") { + setStackRunning(false); + setDeployPhase("exited"); + setContainerIds(status.container_ids); + // Populate exited container summaries from persisted container info + if (status.containers && status.containers.length > 0) { + setExitedContainers( + status.containers + .filter((c) => c.status === "exited" || c.exit_code != null) + .map((c) => ({ + container_id: c.id, + container_name: c.name ?? undefined, + exit_code: c.exit_code ?? undefined, + error: c.error ?? undefined, + started_at: c.started_at ?? undefined, + finished_at: c.finished_at ?? undefined, + restart_count: c.restart_count ?? undefined, + oom_killed: c.oom_killed ?? undefined, + last_logs: c.last_logs ?? undefined, + })), + ); + } + } else if (rs === "stopped_by_user") { + setStackRunning(false); + setDeployPhase("idle"); + setContainerIds([]); + setDeployJobId(null); } else { setStackRunning(false); setDeployPhase((prev) => (prev === "running" ? "idle" : prev)); diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index b74c426..fc8caae 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -60,6 +60,8 @@ function statusColor(status: Job["status"]) { return "bg-slate-700/40 text-slate-300 border-slate-600"; case "failed": return "bg-red-500/20 text-red-300 border-red-500/30"; + case "scanned": + return "bg-purple-500/20 text-purple-300 border-purple-500/30"; } } @@ -203,9 +205,12 @@ export function History() { }, [historyItems]); const openJob = (item: HistoryItem) => { - if (item.status === "done") { - navigate(`/results?jobId=${item.id}`); - } else if (item.status === "failed") { + if (item.status === "scanned") { + // Scanned-only project jobs — route back to project upload to resume review + navigate(`/project-upload?resume=${item.id}`); + return; + } + if (item.status === "done" || item.status === "failed") { navigate(`/results?jobId=${item.id}`); } else { navigate(`/analysis?jobId=${item.id}`); @@ -362,7 +367,7 @@ export function History() { {item.jobType} - {item.status} + {item.status === "scanned" ? "Scanned — not analyzed" : item.status} {runningJobs.has(item.id) && ( diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index def87c1..0e3a227 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -122,6 +122,9 @@ export function Monitoring() { const status = await composeApi.deployStatus(jobId); if (cancelled) return; + const rs = status.runtime_state ?? "none"; + const isTerminal = ["none", "stopped_by_user", "exited", "failed", "cleanup_completed"].includes(rs); + const fromIds = status.container_ids.length > 0 ? status.container_ids @@ -140,6 +143,49 @@ export function Monitoring() { setSelectedContainerId(routeContainerId); } + // For terminal states with no active containers, don't attempt to open sockets + if (isTerminal && !status.active) { + // Pre-populate exited state so charts show offline state + const fromDeploy = status.containers ?? []; + if (fromDeploy.length > 0) { + setContainerStates((prev) => { + const next = { ...prev }; + for (const c of fromDeploy) { + if (!c.id) continue; + const existing = next[c.id] ?? { + points: [], + logs: [], + latestPayload: null, + connected: false, + }; + const lastLogs = (c.last_logs ?? []).filter( + (l): l is string => typeof l === "string", + ); + const tailLogs: TerminalLogEntry[] = lastLogs.map((line) => ({ + message: `[deploy] ${line}`, + tone: "info" as const, + })); + next[c.id] = { + ...existing, + connected: false, + exited: { + exit_code: c.exit_code, + error: c.error ?? null, + started_at: c.started_at ?? null, + finished_at: c.finished_at ?? null, + restart_count: c.restart_count ?? null, + oom_killed: c.oom_killed ?? null, + last_logs: lastLogs, + }, + logs: [...existing.logs.slice(-200), ...tailLogs.slice(-80)], + }; + } + return next; + }); + } + return; + } + const fromDeploy = status.containers ?? []; if (fromDeploy.length === 0) return; diff --git a/frontend/src/pages/ProjectUpload.tsx b/frontend/src/pages/ProjectUpload.tsx index dc872c9..bb55e54 100644 --- a/frontend/src/pages/ProjectUpload.tsx +++ b/frontend/src/pages/ProjectUpload.tsx @@ -1,5 +1,5 @@ -import { useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { @@ -9,11 +9,13 @@ import { Check, ChevronLeft, ChevronRight, + Clock, FileCode, FileText, Loader2, Package, Play, + Timer, Wrench, X, } from "lucide-react"; @@ -24,7 +26,7 @@ import { Card } from "../components/ui/card"; import { Badge } from "../components/ui/badge"; import { MotionPage, StaggerList, StaggerItem } from "../components/motion"; import { dragActiveVariants, dragActiveTransition } from "../components/motion/variants"; -import { ApiError, project, type ProjectScanResponse } from "../utils/api"; +import { ApiError, project, type ProjectDraft, type ProjectScanResponse } from "../utils/api"; // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -39,6 +41,25 @@ function formatBytes(bytes: number): string { return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`; } +function formatRelativeTime(isoString: string): string { + const diffMs = Date.now() - new Date(isoString).getTime(); + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + if (hrs < 48) return "Yesterday"; + return new Date(isoString).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function formatExpiresIn(seconds: number): string { + if (seconds <= 0) return "Expired"; + const mins = Math.ceil(seconds / 60); + if (mins < 60) return `${mins}m left`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hrs}h ${remMins}m left` : `${hrs}h left`; +} + type Step = "upload" | "scanning" | "review" | "plan" | "confirming"; const STEP_LABELS: Record = { @@ -57,6 +78,7 @@ const STEPS: Step[] = ["upload", "review", "plan"]; export function ProjectUpload() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const reducedMotion = useReducedMotion(); const fileInputRef = useRef(null); @@ -74,6 +96,126 @@ export function ProjectUpload() { const [buildImages, setBuildImages] = useState(false); const [runAfter, setRunAfter] = useState(false); + // Draft projects (scanned but not yet analyzed) + const [drafts, setDrafts] = useState([]); + const [draftsLoading, setDraftsLoading] = useState(true); + // Expiry countdown for the active draft (seconds) + const [expiresInSeconds, setExpiresInSeconds] = useState(null); + // Debounce timer ref for auto-saving draft progress + const saveDraftTimerRef = useRef | null>(null); + + // ── Fetch drafts on mount + handle ?resume= ────────────────────────── + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const data = await project.drafts(); + if (!cancelled) setDrafts(data); + } catch { + // non-fatal — drafts section just won't show + } finally { + if (!cancelled) setDraftsLoading(false); + } + })(); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const resumeId = searchParams.get("resume"); + if (!resumeId) return; + // Only auto-resume once the drafts fetch has settled (or if not needed) + resumeDraft(resumeId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── Resume a draft by fetching its stored scan result ──────────────────── + + const resumeDraft = async (projectId: string) => { + setError(null); + setStep("scanning"); + try { + const result = await project.getScan(projectId); + applyScanResult(result); + } catch (err) { + const isExpired = err instanceof ApiError && err.status === 410; + const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Failed to load draft."; + if (isExpired) { + toast.error("This draft expired. Upload the archive again to continue."); + } else { + toast.error(msg); + } + setError(isExpired ? null : msg); + setStep("upload"); + setDrafts(prev => prev.filter(d => d.project_id !== projectId)); + } + }; + + const applyScanResult = (result: ProjectScanResponse) => { + setScanResult(result); + setExpiresInSeconds(result.expires_in_seconds ?? null); + + const sel = result.saved_selections; + if (sel) { + // Restore previously-saved selections + setSelectedDockerfiles(sel.selected_dockerfiles); + setSelectedCompose(sel.selected_compose_files); + setPrimaryCompose(sel.primary_compose_file ?? null); + const validModes = ["auto", "dockerfile-only", "compose-only", "full-project"] as const; + setAnalysisMode((validModes as readonly string[]).includes(sel.analysis_mode) ? sel.analysis_mode : "auto"); + setBuildImages(sel.build_selected_images); + setRunAfter(sel.run_after_analysis); + setStep(sel.workflow_step); + } else { + // Fresh scan defaults + const rec = result.recommendation; + setSelectedDockerfiles(rec.primary_dockerfile ? [rec.primary_dockerfile] : result.detected.dockerfiles.slice(0, 1)); + setSelectedCompose(rec.primary_compose_file ? [rec.primary_compose_file] : result.detected.compose_files.slice(0, 1)); + setPrimaryCompose(rec.primary_compose_file ?? result.detected.compose_files[0] ?? null); + const validModes = ["auto", "dockerfile-only", "compose-only", "full-project"] as const; + const safeMode = (validModes as readonly string[]).includes(rec.analysis_mode) + ? (rec.analysis_mode as typeof analysisMode) + : "auto"; + setAnalysisMode(safeMode); + setStep(result.workflow_step ?? "review"); + } + }; + + // ── Persist draft progress to the server ───────────────────────────────── + + const saveDraftProgress = useCallback(async ( + overrideStep?: "review" | "plan", + overrideSelections?: { + dockerfiles?: string[]; + compose?: string[]; + primaryCompose?: string | null; + mode?: typeof analysisMode; + buildImages?: boolean; + runAfter?: boolean; + }, + ) => { + if (!scanResult) return; + const targetStep = overrideStep ?? (step === "review" || step === "plan" ? step : "review") as "review" | "plan"; + try { + await project.saveDraft(scanResult.project_id, { + workflow_step: targetStep, + selected_dockerfiles: overrideSelections?.dockerfiles ?? selectedDockerfiles, + selected_compose_files: overrideSelections?.compose ?? selectedCompose, + primary_compose_file: overrideSelections?.primaryCompose !== undefined ? overrideSelections.primaryCompose : primaryCompose, + analysis_mode: overrideSelections?.mode ?? analysisMode, + build_selected_images: overrideSelections?.buildImages ?? buildImages, + run_after_analysis: overrideSelections?.runAfter ?? runAfter, + }); + } catch { + // Non-fatal: draft save failure shouldn't interrupt the user + } + }, [scanResult, step, selectedDockerfiles, selectedCompose, primaryCompose, analysisMode, buildImages, runAfter]); + + const scheduleDraftSave = useCallback(() => { + if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current); + saveDraftTimerRef.current = setTimeout(() => { void saveDraftProgress(); }, 800); + }, [saveDraftProgress]); + // ── Drag & drop ────────────────────────────────────────────────────────── const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; @@ -99,20 +241,9 @@ export function ProjectUpload() { try { const result = await project.scan(file); - setScanResult(result); - // Pre-select per recommendation - const rec = result.recommendation; - setSelectedDockerfiles(rec.primary_dockerfile ? [rec.primary_dockerfile] : result.detected.dockerfiles.slice(0, 1)); - setSelectedCompose(rec.primary_compose_file ? [rec.primary_compose_file] : result.detected.compose_files.slice(0, 1)); - setPrimaryCompose(rec.primary_compose_file ?? result.detected.compose_files[0] ?? null); - // Set mode - const mode = rec.analysis_mode; - const validModes = ["auto", "dockerfile-only", "compose-only", "full-project"] as const; - const safeMode = (validModes as readonly string[]).includes(mode) - ? (mode as typeof analysisMode) - : "auto"; - setAnalysisMode(safeMode); - setStep("review"); + applyScanResult(result); + // Remove any draft with the same archive name now that a fresh scan was done + setDrafts(prev => prev.filter(d => d.archive_name !== result.archive_name)); } catch (err) { const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Scan failed."; setError(msg); @@ -156,9 +287,11 @@ export function ProjectUpload() { // ── Toggle helpers ──────────────────────────────────────────────────────── const toggleDockerfile = (df: string) => { - setSelectedDockerfiles(prev => - prev.includes(df) ? prev.filter(x => x !== df) : [...prev, df] - ); + setSelectedDockerfiles(prev => { + const next = prev.includes(df) ? prev.filter(x => x !== df) : [...prev, df]; + scheduleDraftSave(); + return next; + }); }; const toggleCompose = (cf: string) => { @@ -167,6 +300,7 @@ export function ProjectUpload() { if (!next.includes(primaryCompose ?? "")) { setPrimaryCompose(next[0] ?? null); } + scheduleDraftSave(); return next; }); }; @@ -281,6 +415,71 @@ export function ProjectUpload() { ))}
+ + {/* ── Continue where you left off ─────────────────────────── */} + {!draftsLoading && drafts.length > 0 && ( + +
+ +

Continue where you left off

+
+
+ {drafts.map(draft => { + const secs = draft.expires_in_seconds ?? 0; + const isLow = secs > 0 && secs < 600; + const stepLabel = draft.workflow_step === "plan" ? "at plan step" : "at review step"; + return ( + + ); + })} +
+

+ + Drafts are kept for 1 hour from upload and then removed automatically. +

+
+ )} )} @@ -307,7 +506,7 @@ export function ProjectUpload() { transition={{ duration: 0.22 }} > {/* Archive info */} - +

{scanResult.archive_name}

@@ -318,6 +517,20 @@ export function ProjectUpload() { + {/* Expiry notice */} + {expiresInSeconds !== null && ( +
+ + {expiresInSeconds < 600 + ? `Draft expires in ${formatExpiresIn(expiresInSeconds)} — start analysis soon to avoid re-uploading.` + : `Progress saved for ${formatExpiresIn(expiresInSeconds)} from upload. After that you'll need to upload again.`} +
+ )} + {/* Warnings */} {scanResult.warnings.length > 0 && ( @@ -380,7 +593,10 @@ export function ProjectUpload() { {scanResult.detected.compose_files.length > 0 && (

- Compose files ({scanResult.detected.compose_files.length} found) + Compose files for deploy/run ({scanResult.detected.compose_files.length} found) +

+

+ All detected compose files are always analyzed. Your selection here controls which compose file(s) are available for deploy.

{scanResult.detected.compose_files.map(cf => ( @@ -459,7 +675,7 @@ export function ProjectUpload() { Back - {(isComposeJob || isProjectJob) && (containerStatus === "stopped") && ( + {(isComposeJob || isProjectJob) && (containerStatus === "stopped" || containerStatus === "stopped_by_user") && ( )} - {(isComposeJob || isProjectJob) && (containerStatus === "exited") && ( + {(isComposeJob || isProjectJob) && containerStatus === "exited" && runtimeSelfExited && ( + <> + + + + )} + {(isComposeJob || isProjectJob) && containerStatus === "exited" && !runtimeSelfExited && ( <> - - -
    - {% for task in tasks %} -
  • -
    - -
    -
    - {{ task.title }} - {{ task.priority }} priority · {{ task.created_at }} -
    -
    - -
    -
  • - {% else %} -
  • No tasks yet. Add one above.
  • - {% endfor %} -
- - -
- GET /health - GET /api/tasks - POST /api/tasks - PATCH /api/tasks/<id> - DELETE /api/tasks/<id> - GET /api/stats -
- - - diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml deleted file mode 100644 index 5830f9e..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.images.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Image-only compose file. It does NOT build anything. -# Build the images first, then run this file: -# docker build -t local/python-mini-task-app:prod -f Dockerfile . -# docker build -t local/python-mini-task-app:smoke -f Dockerfile.smoke . -# docker compose -f docker-compose.images.yml up -services: - web: - image: local/python-mini-task-app:prod - ports: - - "5000:5000" - environment: - APP_ENV: production - DATABASE_PATH: /data/tasks.db - volumes: - - task-data:/data - restart: unless-stopped - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health').read()"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - - smoke: - image: local/python-mini-task-app:smoke - environment: - TARGET_URL: http://web:5000 - depends_on: - web: - condition: service_healthy - profiles: ["test"] - -volumes: - task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml deleted file mode 100644 index a97815d..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.prod.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Build-based production-like compose file using the production Dockerfile. -services: - web: - build: - context: . - dockerfile: Dockerfile - image: local/python-mini-task-app:prod - ports: - - "5000:5000" - environment: - APP_ENV: production - DATABASE_PATH: /data/tasks.db - volumes: - - task-data:/data - restart: unless-stopped - -volumes: - task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml b/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml deleted file mode 100644 index 5fd9b1b..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/docker-compose.yml +++ /dev/null @@ -1,46 +0,0 @@ -# Build-based compose file for local development and testing. -services: - web: - build: - context: . - dockerfile: Dockerfile.dev - image: local/python-mini-task-app:dev - ports: - - "5000:5000" - environment: - APP_ENV: development - DATABASE_PATH: /data/tasks.db - volumes: - - .:/usr/src/app - - task-data:/data - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health').read()"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - - tests: - build: - context: . - dockerfile: Dockerfile.test - image: local/python-mini-task-app:test - environment: - APP_ENV: test - DATABASE_PATH: /tmp/test-tasks.db - profiles: ["test"] - - smoke: - build: - context: . - dockerfile: Dockerfile.smoke - image: local/python-mini-task-app:smoke - environment: - TARGET_URL: http://web:5000 - depends_on: - web: - condition: service_healthy - profiles: ["test"] - -volumes: - task-data: diff --git a/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py b/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py deleted file mode 100644 index 82ffd9f..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/docker/smoke_test.py +++ /dev/null @@ -1,44 +0,0 @@ -import json -import os -import sys -import time -import urllib.error -import urllib.request - -TARGET_URL = os.getenv("TARGET_URL", "http://web:5000").rstrip("/") - - -def request(path: str, method: str = "GET", payload: dict | None = None): - body = None - headers = {} - if payload is not None: - body = json.dumps(payload).encode("utf-8") - headers["Content-Type"] = "application/json" - req = urllib.request.Request(f"{TARGET_URL}{path}", data=body, headers=headers, method=method) - with urllib.request.urlopen(req, timeout=5) as response: - raw = response.read().decode("utf-8") - return response.status, json.loads(raw) if raw else None - - -last_error = None -for _ in range(30): - try: - status, health = request("/health") - if status == 200 and health["status"] == "ok": - break - except (urllib.error.URLError, TimeoutError, KeyError) as exc: - last_error = exc - time.sleep(1) -else: - print(f"Service did not become healthy: {last_error}", file=sys.stderr) - sys.exit(1) - -status, created = request("/api/tasks", "POST", {"title": "Smoke test task", "priority": "high"}) -assert status == 201, created -assert created["title"] == "Smoke test task" - -status, stats = request("/api/stats") -assert status == 200, stats -assert stats["total"] >= 1, stats - -print("Smoke test passed") diff --git a/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml b/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml deleted file mode 100644 index 758f215..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "python-mini-task-app" -version = "0.1.0" -description = "Small Flask task app used to demonstrate Dockerfiles and Docker Compose." -requires-python = ">=3.12" -dependencies = [ - "flask==3.0.3", - "gunicorn==23.0.0", -] - -[project.optional-dependencies] -test = [ - "pytest==8.3.3", -] - -[tool.setuptools.packages.find] -where = ["."] -include = ["app*"] diff --git a/examples/projects/python-mini-task-app-docker-fixed/pytest.ini b/examples/projects/python-mini-task-app-docker-fixed/pytest.ini deleted file mode 100644 index c7b23ec..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -pythonpath = . -testpaths = tests diff --git a/examples/projects/python-mini-task-app-docker-fixed/requirements.txt b/examples/projects/python-mini-task-app-docker-fixed/requirements.txt deleted file mode 100644 index 827de94..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask==3.0.3 -gunicorn==23.0.0 -pytest==8.3.3 diff --git a/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py b/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py deleted file mode 100644 index 19ac002..0000000 --- a/examples/projects/python-mini-task-app-docker-fixed/tests/test_app.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from app.main import create_app - - -@pytest.fixture() -def client(tmp_path): - app = create_app(database_path=str(tmp_path / "test.db")) - app.config.update(TESTING=True) - return app.test_client() - - -def test_homepage_loads(client): - response = client.get("/") - assert response.status_code == 200 - assert b"Python Mini Task App" in response.data - assert b"Flask + SQLite + Docker" in response.data - - -def test_health_endpoint(client): - response = client.get("/health") - assert response.status_code == 200 - data = response.get_json() - assert data["status"] == "ok" - assert "tasks" in data - - -def test_create_task_api(client): - response = client.post("/api/tasks", json={"title": "Test task", "priority": "high"}) - assert response.status_code == 201 - data = response.get_json() - assert data["title"] == "Test task" - assert data["priority"] == "high" - assert data["done"] is False - - -def test_update_task_api(client): - created = client.post("/api/tasks", json={"title": "Patch me"}).get_json() - response = client.patch(f"/api/tasks/{created['id']}", json={"done": True, "priority": "low"}) - assert response.status_code == 200 - data = response.get_json() - assert data["done"] is True - assert data["priority"] == "low" - - -def test_delete_task_api(client): - created = client.post("/api/tasks", json={"title": "Delete me"}).get_json() - response = client.delete(f"/api/tasks/{created['id']}") - assert response.status_code == 204 - - -def test_stats_endpoint(client): - response = client.get("/api/stats") - assert response.status_code == 200 - data = response.get_json() - assert data["total"] >= 3 - assert "remaining" in data diff --git a/examples/projects/python-mini-task-app.zip b/examples/projects/python-mini-task-app.zip deleted file mode 100644 index 94483648bd6f276f8ed1551c0626aa9e9f6cf7f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6426 zcmb7I2{@E*_a6JcWn|A1vW{J{@7vhP7RfThG?u~G$xb3}=rGw)p2y!U&~dCs|?b8c-g1||>y0N?@&B}z><;C*mU)UO28ItQQw zSUXz5ANW&=a!2 z=*L@d-z0A!xzPwPiJ_x#R<1gZMz6C42Y@lxVG^OyF?CO0QwLpjU;bcj$ER*xlQMV% zZ@<8vUP>F~1pbj3-<{a9M-c6sMxPW>97a9P7k9vs0_eeouc95U&e~1ty zMi%^xk2GXsiz_r;V}SE|aFboiEc1B0-HORS@-;+4{dvNjfnU8Jmw7l5V?=vS&++F1 zy-i!%>qf%FA55N;PNw!Z>@#xj$u7Xx5mv5;@72IPK={p_n?!uQ+fxSPHQe>{&)G0U z@%QytSt=j9cRxulbj5n9IGH)fU^Dlr#C}uA2Rnz*`r6x@213I)!Wt^K%~Z-i>%_H* zvaB;~(< zc`+LVJ{o1^Dh_;1TM%Wgw1kRIaUYDck@7Do0yCqdatf6rc&JF}D#|KoD1z**{}!k; z{WgbjKEkFIuG{l5U3R;bU)ugMZh0m1h%^Iu3dM$d~>R*i~=O%rWPKR1}z8{^f`6r-yP49w17>QBY%JRepZ5zUvJjy^UZ zZqMO{piq+^>UddmF+VYKI#5D`gnC>tc*J8v?#W_WqzRgZHISdKU8wGwKIT2S>;gRY zXWQGtZzaP`C8WOG^4aS&AJ=W`G6(e2F1kR27q%wK&G&8=uEBRyx+9h=2KF9(C?i_n zRI1ouvQA`qb4Z6T?qCmY?BgoxF?em3^qfok-ok`7=Z2??<*E|xgg{~%zg~EUPZI{i z=Cy2$;g^?U$;pTF>m<_Y(ij$9M`K4n_>3CyX;$1ArhiI6&Gk0!Q6iRMpaeq6m_qCfwFqjH{^oO|YM|7nze! zyT$uYijfZ6I#xVV^7g8rI&T@&FDQ@~ol!jCb>`z|VX-~K7ha5)%R14f(dYWI zMmCeE#I?0~AVLL3J_QoqiWK?OJs><8OF%2O!EjRZPf^5$s>Gi!OjMFtAP~IY^UDlX z<45ZksyexwB|hoQ-6TKmHaCYkz>wzVAcW_4pgG7gOyJKzC#(=?eXYg|0|3Cr0{{qq z0NGo>9R3A4BF;a6lp1I_j*F5utr$KM)A2WOqFj4kaEGU6sUj?^wv~}8uChK;C5bN6 z_3{2j8q>CgcyA5XFdF6uE9g|?u1YRJvyB`QgHV>BP2F!3?LM+M2y@Pi`%csjJDxKf z5-FycvQYTIRq2|r<3BgNd@H%`!j<|qrrtO9`4i7Hr48C2&M=m@eYi2Kfe7e+T& zH*ey3D~yE4PL+78l?p%N#G5xV(t2Qlv$_IkZ7&kd&H-HjbH7EspMvnXoufVMg%?WifJSMV0%yA2N_orF70kOv zCkv&t&QmG~w+X(I-WytSPrQq*#Msz?=)oABN2uyVPTb{`OWCkT>~f3WN^QLiD2W!u zgQ6*)jyM^c*}q#LQhRcN)zcf}R;~Q~`WA`0+29I8<|g0e#N6^6HS3%=v-dfKir-WU z3V*9rim#!`Q~EeFs^m62tt$TUI`_H$(Jxrwmis(S`;vR3A3ombH4U+n-IWJbr3SFXOlOu!7$#n>z9Na%&PsQejcNdu_D#@Q@DMjK5@O(*x>JC6wQP3F?+ApTLlNQgbc&H~j49P`Oh z1^*X5IiUgGZ3GAtMFYz?007l*I4}omhzH0Pd8(Px{he4!ul7U@@{u-g=n*CwV7(R@ zlXOyHzG@=FSn=dsm*=~V!Hyz$$_T8`z!$$vf?lhAM0Gr8{Ep&me76>K;h?!9uK8Tv zCT)cF=iFDk1Dk;7+Y(j<#<$R75??BMWWHhbB3A6=3X`&`&bT|puhJmflXSl#;aRnC zf{ixSnvUK_6MSoOsnR=tkEhI9AE5-55Fc0OUc=3cPL^O8iQdm9sm^2OoLA%phX>JSc_*frxd@CU1i zJiUt|;_9Y1*@5mLtHi#0!nf{-ayJSc+z3w?#o@4XN1K?b5`ZWAF?yppzU=@Klngv` ztL!SiFcKC+O{O=apkb(Wks>V=K#l;H0ZTNhW4wsrspz%mhX;~c-;U=q?P({ysGlU2 z@@sk$(kZ!Le_m#Zf;H>>u*%wtu$nN{!5qaj^VH1DnS`YQ#`z*z8my>_Kdd>v^CZ{TWbnSq6T=ZY=8^ zvHX%SKJoCt$aHm<-1-N8>*``fn=GL~w-=(*$~@JjzTT!Z{7k9x0-HEIl~)^E2iPsS ztjL*EWbdxvx=CEnI~Yr8=Ps4gn!Z08N!GeiU823JsWWbC;;n@){2b*Eo)>)f#Eaq5`&_Lz!h+F+zjyT zRQF;LYoqaoGDoG6{+$-~{(PI-9MZ)!% z{djF~eHQzWs{I3=?q;F!+dX|9o^~4dS9z+X*Ke&jaVd}Ot^1~)SGw`_!yyJsPtOCk z*Djm^Qd&MP(`m2jTMoRW3I$EALcwMG8Nx064f1mtha@Mb^wDr~x;p2UXpKzWH{E#3<(vP3pSi=CQxoEpWzX%~g`lAkC^w zz>ltaOV3Msad{t5oUkffrgCY_C7^lt0T+n|dUOuMhnEQj5<=<9J>n90Bo+dMJTDxc z%lp-2`F&04XW045tMV?BSK5Q;%IC!cN)MMF%NkBR-?D^MFN25WvJ~sg&PL0D6zL!7 zc}cWd($kwWGYaA_N!_%Jw$eM zU}6o=9hajW6Xs-J*7$~&6KH>hnFe;{VWOeG%n<8njyFk&(@Z9Nrvz|Mr+GWZ0AS0A zKc(PQxyAyigpO>EPZKaZ%!J%nDcN7)DA)G& zHR^x#a3U+Hj)s|349UJo-ZQ&VZCj6h$wEe9%^`q6sZmf%NE&AM+CaArG{}cg?0jj@ zp|NLTn8J_>$jjwgI{) zu5XBQSizniwKhvQ_0HAP^CX99V6AXVk!lwX2{fqkD89%_+WbS;^Rz>8#+wIjh=7@J z-1gX)3Tq%8^-eUytRXhDZUGz@mB20183eA_~K-YmS%=oK2O?5%4TZ6>eb!K7-YS5#H+KC!;&2n;1eKc=aPJ+m_UY)bjpnb@ z4SbQS;KGr`z;;=-2Ttm@7dbq7G5P$bV)aZiYhzys;imjT3E3X3p{d3$!7wK)0z$yz zWed&h2~4fxw+rwZ-6Dw1ibc|_`Bw|`Bv<<9P&jS5qT_$2)xu$rBclfH!-3}6LJ>W7 z<^<9rGpS(^X_`w&-E2>IO#8kaPo?dAgcG~T_|vGd^ys+`{>R?3A*%c#UK4TLYNYnhQeOSXt?S1Z-qzkA*JK!L>QYb4gw z7w3H#<9w%6Jyn-7ku78PXzv9XyM&N_-oU!hYoX$E`kYx*ASy*=VD{fP6>v*pWg-ZohdL+g=(9=R$G)=|#BAuAaaV`v1)RR7}Tkw$hYIOKix_4>MJEKEVIO{rWy!t>n}3<)1%_kG~&Z5tVo zOD|Pf=Q70pfzG&t#y57|Z!U0saliV?p`M$|z`!Bq@U_!kTZqV;Fi&(uP0jpD;YPn; zrl@NaQo|ORhQYLuCXyCJNm+-W#AYD1kDtDvbEDj8C6+~+GYK6Z;7f{oU0MEARem3=j z6na8Fo097CN z7!ReLbfA<0vY+=Kp_rp)x6|{I{O=^38^f=K!A}^ndq9zxUmRQ(QZYcpZI?jRzywT= z7$;}~XFaASoo$ITRe)J!#_;ScF77s_wBrJ;1Y@^mI;hxsz7-X5Z^#(`^T}df#al$4 zk!#!1OxA01W`wJ1>r;1vTGRb;axmd_QMBBtRb#lRB`gj%Un4{RqS|NXJOUAoD#T-x zG)payZ$-YFkDR9366XQ6{LCy}%2;4Eo3do=H6m}?pst#)kS7IZP$_XWF=cL!WE;F@ zDZS2rFwW!obZ=1+d#F9kYZb^pyil2PKVAcyP;qxLhF?W9nI#c2N@1P9Mq4x(;6zlX zArO$x>WQ^ymAccY zoG7DD>vsPe-dR2CG@d>xmrto%e}g-A`qx6jL`@6FFH*pvY(1(?Io2^yNPnC;j<)3( z>30?5U!?{&omDhWdnFaOkCu#X(OTK>*oUu5K@EHAP7y38A6LZh@ zpr_59JqviMBmLiy&mIn^k+aDFzaABTgZy)TVxRGz^BmwO?W3^%YlfaUewOr4n|Jdk t)|n&VZzdkY{?2=+VVlVTXIK!0cf^9f&2 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + err "Required command not found: $1" + exit 1 + fi +} + +generate_secret() { + LC_ALL=C tr -dc 'A-Za-z0-9' > .env + log "Added missing $key to .env" +} + +wait_http() { + local url="$1" + local name="$2" + local timeout_s="${3:-120}" + local start_ts now + start_ts="$(date +%s)" + + while true; do + if curl -fsS "$url" >/dev/null 2>&1; then + log "$name is healthy at $url" + return 0 + fi + now="$(date +%s)" + if (( now - start_ts >= timeout_s )); then + err "Timed out waiting for $name at $url" + return 1 + fi + sleep 2 + done +} + +run_migrations() { + local migration_log + migration_log="$(mktemp)" + + if docker compose exec -T -w /app api sh -lc 'PYTHONPATH=/app alembic upgrade head' >"$migration_log" 2>&1; then + rm -f "$migration_log" + log "Migrations are up to date" + return 0 + fi + + if rg -i 'duplicate|already exists|DuplicateTable' "$migration_log" >/dev/null 2>&1; then + log "Detected existing schema without Alembic state; stamping head then retrying" + docker compose exec -T -w /app api sh -lc 'PYTHONPATH=/app alembic stamp head' >>"$migration_log" 2>&1 + docker compose exec -T -w /app api sh -lc 'PYTHONPATH=/app alembic upgrade head' >>"$migration_log" 2>&1 + rm -f "$migration_log" + log "Migration bootstrap completed" + return 0 + fi + + err "Migration failed. Logs:" + cat "$migration_log" >&2 + rm -f "$migration_log" + return 1 +} + +require_cmd docker +require_cmd curl +require_cmd rg + +if ! docker compose version >/dev/null 2>&1; then + err "Docker Compose v2 is required (docker compose ...)" + exit 1 +fi + +if [[ ! -f ".env" ]]; then + log "No .env file found. Creating one with generated secrets." + cat > .env </dev/null 2>&1; then + curl -fsS "http://127.0.0.1:8000/health" + echo +else + echo "API not healthy" +fi +echo +echo "[status] frontend" +if curl -fsS "http://127.0.0.1:3000" >/dev/null 2>&1; then + echo "Frontend reachable at http://127.0.0.1:3000" +else + echo "Frontend not reachable" +fi diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..2327a54 --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ "${1-}" == "--wipe" ]]; then + echo "[stop] Stopping stack and removing volumes" + docker compose down -v --remove-orphans +else + echo "[stop] Stopping stack" + docker compose down --remove-orphans +fi From 88097b60dbcae2d17d12983bcf2e48f535fc6a75 Mon Sep 17 00:00:00 2001 From: cppenloglou Date: Sat, 16 May 2026 21:50:54 +0300 Subject: [PATCH 5/8] Enhance Docker Compose IP resolution and project upload workflow - Added a new endpoint to retrieve the Docker-in-Docker (DinD) IP address, improving container networking capabilities. - Implemented a fallback mechanism for resolving DinD IP using Docker DNS and environment variables. - Updated project upload functionality to confirm analysis and build selected images immediately after scanning. - Removed outdated project draft management logic to streamline the upload process. - Enhanced frontend components to reflect changes in project upload and analysis states, improving user experience. --- backend/app/api/routers/compose.py | 69 ++ backend/app/api/routers/project.py | 382 ++------- backend/app/application/schemas.py | 94 +-- .../application/services/analysis_service.py | 11 +- .../app/application/services/project_draft.py | 56 -- backend/app/infrastructure/db/repositories.py | 14 - backend/app/workers/tasks.py | 27 +- backend/tests/test_analysis_service.py | 29 + backend/tests/test_api_routes.py | 568 ++++---------- backend/tests/test_container_lifecycle.py | 44 ++ backend/tests/test_worker_events.py | 8 + examples/README.md | 4 + .../projects/compose-exits-after-start.zip | Bin 0 -> 917 bytes .../compose-exits-after-start/README.md | 25 + .../docker-compose.yml | 22 + frontend/src/pages/AnalysisProgress.tsx | 52 -- frontend/src/pages/ContainerExecution.tsx | 559 +++++++++---- frontend/src/pages/History.tsx | 9 +- frontend/src/pages/Monitoring.tsx | 285 ++++++- frontend/src/pages/ProjectUpload.tsx | 738 +----------------- frontend/src/pages/ResultsDashboard.tsx | 250 ++++-- frontend/src/utils/api.ts | 121 +-- 22 files changed, 1340 insertions(+), 2027 deletions(-) delete mode 100644 backend/app/application/services/project_draft.py create mode 100644 examples/projects/compose-exits-after-start.zip create mode 100644 examples/projects/compose-exits-after-start/README.md create mode 100644 examples/projects/compose-exits-after-start/docker-compose.yml diff --git a/backend/app/api/routers/compose.py b/backend/app/api/routers/compose.py index 26cea3c..83ae1a9 100644 --- a/backend/app/api/routers/compose.py +++ b/backend/app/api/routers/compose.py @@ -1,6 +1,11 @@ import json +import os +import re +import socket +import subprocess import uuid from typing import Literal +from urllib.parse import urlparse from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel @@ -200,6 +205,10 @@ class DeployStatusResponse(BaseModel): can_retry_runtime: bool = True +class DindIpResponse(BaseModel): + dind_ip: str | None = None + + def deploy_state_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"deploy:{user_id}:{job_id}" @@ -208,6 +217,60 @@ def deploy_stop_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"deploy-stop:{user_id}:{job_id}" +def _is_ipv4(value: str) -> bool: + return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", value)) + + +def _resolve_dind_ip() -> str | None: + # Prefer direct inspect of the known DinD container names. + for candidate in ("docker-platform-dind", "dind"): + try: + proc = subprocess.run( + [ + "docker", + "inspect", + candidate, + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + ], + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if proc.returncode == 0: + ip = proc.stdout.strip() + if ip and _is_ipv4(ip): + return ip + except Exception: + continue + + # Fallback to DOCKER_HOST host resolution. + docker_host = os.getenv("DOCKER_HOST", "") + if docker_host.startswith("tcp://"): + parsed = urlparse(docker_host) + host = (parsed.hostname or "").strip() + if host: + if _is_ipv4(host): + return host + try: + resolved = socket.gethostbyname(host) + if resolved and _is_ipv4(resolved): + return resolved + except Exception: + pass + + # Final fallback for API container runtime: resolve DinD service names by Docker DNS. + for candidate in ("dind", "docker-platform-dind"): + try: + resolved = socket.gethostbyname(candidate) + if resolved and _is_ipv4(resolved): + return resolved + except Exception: + continue + return None + + def _recompute_container_counts(containers: list[ContainerStateInfo]) -> tuple[int, int, int]: running = exited = unhealthy = 0 for c in containers: @@ -320,3 +383,9 @@ async def get_deploy_status( current_user: UserModel = Depends(get_current_user), ) -> DeployStatusResponse: return await compute_deploy_status(current_user.id, job_id) + + +@router.get("/deploy/dind-ip", response_model=DindIpResponse) +async def get_dind_ip(current_user: UserModel = Depends(get_current_user)) -> DindIpResponse: + _ = current_user + return DindIpResponse(dind_ip=_resolve_dind_ip()) diff --git a/backend/app/api/routers/project.py b/backend/app/api/routers/project.py index e0b7435..c31acbc 100644 --- a/backend/app/api/routers/project.py +++ b/backend/app/api/routers/project.py @@ -1,6 +1,4 @@ import uuid -import zipfile -from datetime import UTC, datetime, timedelta from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile @@ -9,22 +7,9 @@ from app.api.deps import get_current_user from app.application.schemas import ( AnalysisEnqueueResponse, - ProjectAnalyzeRequest, - ProjectDraftRead, - ProjectDraftSaveRequest, - ProjectSavedSelections, - ProjectScanResponse, - ProjectDetectedAssets, - DetectedService, - ProjectRecommendation, + ProjectPrimaryComposeRequest, ) from app.application.services.analysis_service import AnalysisService -from app.application.services.project_draft import ( - _EXPIRED_DETAIL, - expire_draft, - is_draft_expired, - seconds_until_expiry, -) from app.application.services.project_scanner import safe_extract_zip, scan_extracted_project from app.core.config import get_settings from app.infrastructure.db.models import JobStatus, JobType, UserModel @@ -36,83 +21,13 @@ settings = get_settings() -def _draft_expires_at() -> str: - """ISO8601 UTC timestamp for when a freshly-scanned draft expires.""" - return (datetime.now(UTC) + timedelta(seconds=settings.project_draft_ttl_seconds)).isoformat() - - -def _build_scan_response( - project_id: uuid.UUID, - archive_name: str, - scan_result: dict, - meta: dict | None = None, -) -> ProjectScanResponse: - """Construct a ProjectScanResponse from scanner output and optional stored metadata.""" - detected = scan_result["detected"] - rec = scan_result["recommendation"] - - workflow_step = "review" - saved_selections = None - expires_at = None - expires_in_secs = None - - if meta: - workflow_step = meta.get("workflow_step", "review") - expires_at_str = meta.get("draft_expires_at") - if expires_at_str: - try: - expires_at = datetime.fromisoformat(expires_at_str) - expires_in_secs = seconds_until_expiry(meta) - except ValueError: - pass - - # Reconstruct saved selections only if user has already saved something - if meta.get("saved_selections_set"): - saved_selections = ProjectSavedSelections( - workflow_step=meta.get("workflow_step", "review"), - selected_dockerfiles=meta.get("selected_dockerfiles", []), - selected_compose_files=meta.get("selected_compose_files", []), - primary_compose_file=meta.get("primary_compose_file"), - analysis_mode=meta.get("analysis_mode", "auto"), - build_selected_images=meta.get("build_selected_images", False), - run_after_analysis=meta.get("run_after_analysis", False), - ) - - return ProjectScanResponse( - project_id=project_id, - archive_name=archive_name, - detected=ProjectDetectedAssets( - dockerfiles=detected["dockerfiles"], - compose_files=detected["compose_files"], - dockerignore_files=detected["dockerignore_files"], - env_examples=detected["env_examples"], - stacks=detected["stacks"], - package_managers=detected["package_managers"], - services=[DetectedService(**s) for s in detected["services"]], - ), - recommendation=ProjectRecommendation( - analysis_mode=rec["analysis_mode"], - primary_dockerfile=rec.get("primary_dockerfile"), - primary_compose_file=rec.get("primary_compose_file"), - can_build=rec["can_build"], - can_run=rec["can_run"], - reasons=rec["reasons"], - ), - warnings=scan_result["warnings"], - workflow_step=workflow_step, - saved_selections=saved_selections, - expires_at=expires_at, - expires_in_seconds=expires_in_secs, - ) - - -@router.post("/scan", response_model=ProjectScanResponse) -async def scan_project( +@router.post("/upload", response_model=AnalysisEnqueueResponse) +async def upload_project( file: UploadFile = File(...), current_user: UserModel = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), -) -> ProjectScanResponse: - """Upload a ZIP archive, scan it safely, and return detected assets without starting analysis.""" +) -> AnalysisEnqueueResponse: + """Upload a ZIP archive, scan it, and immediately queue analysis of all detected files.""" if not file.filename or not file.filename.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Project upload expects a .zip archive.") @@ -126,7 +41,6 @@ async def scan_project( archive_stem = Path(file.filename).stem extract_dir = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}" extract_dir.mkdir(parents=True, exist_ok=True) - archive_path = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}.zip" archive_path.write_bytes(content) @@ -142,287 +56,83 @@ async def scan_project( except Exception as exc: raise HTTPException(status_code=500, detail=f"Project scan failed: {exc}") from exc - scan_meta = { + meta = { **scan_result["db_safe_summary"], - "scan_only": True, - "analysis_confirmed": False, - "workflow_step": "review", - "draft_expires_at": _draft_expires_at(), + "analysis_confirmed": True, + "build_selected_images": True, } + service = AnalysisService(session) job_id = await service.enqueue_job( current_user.id, JobType.project, - scan_meta, - initial_status=JobStatus.scanned, + meta, + initial_status=JobStatus.queued, ) - return _build_scan_response(job_id, file.filename, scan_result, scan_meta) - - -@router.post("/analyze", response_model=AnalysisEnqueueResponse) -async def analyze_project( - payload: ProjectAnalyzeRequest, - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> AnalysisEnqueueResponse: - """Confirm analysis of a previously scanned project with selected files.""" - repo = JobRepository(session) - job = await repo.get_job(payload.project_id, current_user.id) - if not job: - raise HTTPException(status_code=404, detail="Project scan not found.") - - if is_draft_expired(job.input_metadata): - await expire_draft(job, session) - raise HTTPException(status_code=410, detail=_EXPIRED_DETAIL) - - if job.status not in {JobStatus.scanned, JobStatus.queued}: - raise HTTPException( - status_code=409, - detail=f"Project is not in a confirmable state (current status: {job.status.value}).", - ) - - scanned_dockerfiles: list[str] = job.input_metadata.get("dockerfiles", []) - scanned_compose: list[str] = job.input_metadata.get("compose_files", []) - project_path = job.input_metadata.get("project_path") - - if not project_path: - raise HTTPException(status_code=400, detail="Project path missing from scan metadata.") - - for df in payload.selected_dockerfiles: - if df not in scanned_dockerfiles: - raise HTTPException(status_code=400, detail=f"Dockerfile '{df}' was not found in scan results.") - - for cf in payload.selected_compose_files: - if cf not in scanned_compose: - raise HTTPException(status_code=400, detail=f"Compose file '{cf}' was not found in scan results.") - - updated_meta = { - **job.input_metadata, - "selected_dockerfiles": payload.selected_dockerfiles, - "selected_compose_files": payload.selected_compose_files, - "primary_compose_file": payload.primary_compose_file or (payload.selected_compose_files[0] if payload.selected_compose_files else None), - "analysis_mode": payload.analysis_mode, - "build_selected_images": payload.build_selected_images, - "run_after_analysis": payload.run_after_analysis, - "scan_only": False, - "analysis_confirmed": True, - } - - job.input_metadata = updated_meta - job.status = JobStatus.queued - await session.flush() - await session.commit() + dockerfiles: list[str] = meta.get("dockerfiles", []) + compose_files: list[str] = meta.get("compose_files", []) await enqueue_job( "run_project_analysis", { "user_id": str(current_user.id), - "job_id": str(payload.project_id), - "project_path": project_path, - "dockerfiles": payload.selected_dockerfiles or scanned_dockerfiles, - # Always analyze all detected compose files; selection controls deploy choice. - "compose_files": scanned_compose, - "primary_compose_file": updated_meta["primary_compose_file"], - "analysis_mode": payload.analysis_mode, - "build_selected_images": payload.build_selected_images, + "job_id": str(job_id), + "project_path": str(extract_dir), + "dockerfiles": dockerfiles, + "compose_files": compose_files, + "primary_compose_file": compose_files[0] if compose_files else None, + "analysis_mode": "auto", + "build_selected_images": True, }, ) - return AnalysisEnqueueResponse(job_id=payload.project_id, status="queued") - - -@router.get("/drafts", response_model=list[ProjectDraftRead]) -async def list_project_drafts( - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> list[ProjectDraftRead]: - """List project jobs that were scanned but never had analysis confirmed.""" - jobs = await JobRepository(session).list_scanned_project_drafts(current_user.id) - result = [] - for job in jobs: - meta = job.input_metadata - if is_draft_expired(meta): - await expire_draft(job, session) - continue - result.append( - ProjectDraftRead( - project_id=job.id, - archive_name=meta.get("filename", "unknown.zip"), - created_at=job.created_at, - dockerfiles=meta.get("dockerfiles", []), - compose_files=meta.get("compose_files", []), - stacks=meta.get("stacks", []), - service_count=meta.get("service_count", 0), - workflow_step=meta.get("workflow_step", "review"), - expires_at=datetime.fromisoformat(meta["draft_expires_at"]) if meta.get("draft_expires_at") else None, - expires_in_seconds=seconds_until_expiry(meta), - ) - ) - return result + return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") -@router.get("/{project_id}/scan", response_model=ProjectScanResponse) -async def get_project_scan( +@router.patch("/{project_id}/primary-compose", response_model=AnalysisEnqueueResponse) +async def set_primary_compose( project_id: uuid.UUID, + payload: ProjectPrimaryComposeRequest, current_user: UserModel = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), -) -> ProjectScanResponse: - """Re-fetch detected assets for a previously scanned project so the user can resume review.""" +) -> AnalysisEnqueueResponse: + """Set the primary compose file for deploy after analysis results are available.""" repo = JobRepository(session) job = await repo.get_job(project_id, current_user.id) if not job: raise HTTPException(status_code=404, detail="Project not found.") - if is_draft_expired(job.input_metadata): - await expire_draft(job, session) - raise HTTPException(status_code=410, detail=_EXPIRED_DETAIL) + if job.type != JobType.project: + raise HTTPException(status_code=400, detail="This endpoint is only for project jobs.") - if job.status != JobStatus.scanned: + if job.status != JobStatus.done: raise HTTPException( status_code=409, - detail=f"Project is not in scanned state (current status: {job.status.value}).", + detail="Primary compose can only be set after analysis is complete.", ) - project_path = job.input_metadata.get("project_path") - archive_name = job.input_metadata.get("filename", "unknown.zip") + result = job.result if isinstance(job.result, dict) else {} + per_file_results = result.get("per_file_results") or [] + analyzed_compose_paths = [ + row["file_path"] + for row in per_file_results + if isinstance(row, dict) and row.get("file_type") == "compose" + ] - if not project_path or not Path(project_path).is_dir(): + if payload.primary_compose_file not in analyzed_compose_paths: raise HTTPException( - status_code=410, - detail="Extracted project directory no longer exists. Please re-upload the archive.", + status_code=400, + detail=f"Compose file '{payload.primary_compose_file}' was not analyzed in this project.", ) - try: - scan_result = scan_extracted_project(Path(project_path), archive_name) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Project re-scan failed: {exc}") from exc - - return _build_scan_response(project_id, archive_name, scan_result, job.input_metadata) - - -@router.patch("/{project_id}/draft", response_model=ProjectDraftRead) -async def save_project_draft( - project_id: uuid.UUID, - payload: ProjectDraftSaveRequest, - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> ProjectDraftRead: - """Persist review/plan step and selections for an in-progress draft.""" - repo = JobRepository(session) - job = await repo.get_job(project_id, current_user.id) - if not job: - raise HTTPException(status_code=404, detail="Project not found.") - - if is_draft_expired(job.input_metadata): - await expire_draft(job, session) - raise HTTPException(status_code=410, detail=_EXPIRED_DETAIL) - - if job.status != JobStatus.scanned: - raise HTTPException( - status_code=409, - detail=f"Cannot save draft for a job in state '{job.status.value}'.", - ) - - # Validate selections against scanned lists - scanned_dockerfiles: list[str] = job.input_metadata.get("dockerfiles", []) - scanned_compose: list[str] = job.input_metadata.get("compose_files", []) - - for df in payload.selected_dockerfiles: - if df not in scanned_dockerfiles: - raise HTTPException(status_code=400, detail=f"Dockerfile '{df}' was not found in scan results.") - - for cf in payload.selected_compose_files: - if cf not in scanned_compose: - raise HTTPException(status_code=400, detail=f"Compose file '{cf}' was not found in scan results.") - - patch: dict = { - "workflow_step": payload.workflow_step, - "selected_dockerfiles": payload.selected_dockerfiles, - "selected_compose_files": payload.selected_compose_files, - "primary_compose_file": payload.primary_compose_file, - "analysis_mode": payload.analysis_mode, - "build_selected_images": payload.build_selected_images, - "run_after_analysis": payload.run_after_analysis, - "saved_selections_set": True, - } - - updated = await repo.update_job_metadata(project_id, current_user.id, patch) + updated = await repo.update_job_metadata( + project_id, + current_user.id, + {"primary_compose_file": payload.primary_compose_file}, + ) if not updated: raise HTTPException(status_code=404, detail="Project not found.") await session.commit() - meta = updated.input_metadata - return ProjectDraftRead( - project_id=updated.id, - archive_name=meta.get("filename", "unknown.zip"), - created_at=updated.created_at, - dockerfiles=meta.get("dockerfiles", []), - compose_files=meta.get("compose_files", []), - stacks=meta.get("stacks", []), - service_count=meta.get("service_count", 0), - workflow_step=meta.get("workflow_step", "review"), - expires_at=datetime.fromisoformat(meta["draft_expires_at"]) if meta.get("draft_expires_at") else None, - expires_in_seconds=seconds_until_expiry(meta), - ) - - -@router.post("/upload", response_model=AnalysisEnqueueResponse) -async def upload_project( - file: UploadFile = File(...), - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> AnalysisEnqueueResponse: - """Legacy single-step upload endpoint — kept for backwards compatibility. - - New callers should use POST /scan then POST /analyze instead. - """ - if not file.filename or not file.filename.lower().endswith(".zip"): - raise HTTPException(status_code=400, detail="Project upload expects a .zip archive.") - - content = await file.read() - if len(content) > settings.max_upload_size_mb * 1024 * 1024: - raise HTTPException(status_code=413, detail="Uploaded archive is too large.") - - upload_root = Path(settings.upload_dir) / str(current_user.id) - upload_root.mkdir(parents=True, exist_ok=True) - - archive_stem = Path(file.filename).stem - extract_dir = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}" - extract_dir.mkdir(parents=True, exist_ok=True) - archive_path = upload_root / f"{archive_stem}-tmp.zip" - archive_path.write_bytes(content) - - try: - safe_extract_zip(archive_path, extract_dir) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - finally: - archive_path.unlink(missing_ok=True) - - try: - scan_result = scan_extracted_project(extract_dir, file.filename) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Project scan failed: {exc}") from exc - - meta = scan_result["db_safe_summary"] - service = AnalysisService(session) - job_id = await service.enqueue_job(current_user.id, JobType.project, meta) - - dockerfiles = meta["dockerfiles"] - compose_files = meta["compose_files"] - - await enqueue_job( - "run_project_analysis", - { - "user_id": str(current_user.id), - "job_id": str(job_id), - "project_path": str(extract_dir), - "dockerfiles": dockerfiles, - "compose_files": compose_files, - "primary_compose_file": compose_files[0] if compose_files else None, - "analysis_mode": "auto", - "build_selected_images": False, - }, - ) - return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") + return AnalysisEnqueueResponse(job_id=project_id, status="updated") diff --git a/backend/app/application/schemas.py b/backend/app/application/schemas.py index cbec090..546cfcf 100644 --- a/backend/app/application/schemas.py +++ b/backend/app/application/schemas.py @@ -113,100 +113,14 @@ class PaginatedPublicResearchJobs(BaseModel): # --------------------------------------------------------------------------- -# Project scan / analyze schemas +# Project schemas # --------------------------------------------------------------------------- -class DetectedService(BaseModel): - name: str - compose_file: str - image: str | None = None - build_context: str | None = None - build_dockerfile: str | None = None - ports: list[Any] = [] - depends_on: list[str] = [] - db_hints: list[str] = [] - - -class ProjectDetectedAssets(BaseModel): - dockerfiles: list[str] = [] - compose_files: list[str] = [] - dockerignore_files: list[str] = [] - env_examples: list[str] = [] - stacks: list[str] = [] - package_managers: list[str] = [] - services: list[DetectedService] = [] - - -class ProjectRecommendation(BaseModel): - analysis_mode: str - primary_dockerfile: str | None = None - primary_compose_file: str | None = None - can_build: bool = False - can_run: bool = False - reasons: list[str] = [] - - -class ProjectSavedSelections(BaseModel): - """Persisted review/plan choices that can be restored on resume.""" - - workflow_step: Literal["review", "plan"] = "review" - selected_dockerfiles: list[str] = [] - selected_compose_files: list[str] = [] - primary_compose_file: str | None = None - analysis_mode: Literal["auto", "dockerfile-only", "compose-only", "full-project"] = "auto" - build_selected_images: bool = False - run_after_analysis: bool = False - +class ProjectPrimaryComposeRequest(BaseModel): + """Body for PATCH /project/{id}/primary-compose — sets the compose file used for deploy.""" -class ProjectScanResponse(BaseModel): - project_id: uuid.UUID - archive_name: str - detected: ProjectDetectedAssets - recommendation: ProjectRecommendation - warnings: list[str] = [] - # Resume fields (only set when fetching an existing scanned draft) - workflow_step: Literal["review", "plan"] = "review" - saved_selections: ProjectSavedSelections | None = None - expires_at: datetime | None = None - expires_in_seconds: int | None = None - - -class ProjectAnalyzeRequest(BaseModel): - project_id: uuid.UUID - selected_dockerfiles: list[str] = [] - selected_compose_files: list[str] = [] - primary_compose_file: str | None = None - analysis_mode: Literal["auto", "dockerfile-only", "compose-only", "full-project"] = "auto" - build_selected_images: bool = False - run_after_analysis: bool = False - - -class ProjectDraftSaveRequest(BaseModel): - """Body for PATCH /project/{id}/draft — persists step + selections.""" - - workflow_step: Literal["review", "plan"] - selected_dockerfiles: list[str] = [] - selected_compose_files: list[str] = [] - primary_compose_file: str | None = None - analysis_mode: Literal["auto", "dockerfile-only", "compose-only", "full-project"] = "auto" - build_selected_images: bool = False - run_after_analysis: bool = False - - -class ProjectDraftRead(BaseModel): - """Lightweight summary of a scanned-but-not-analyzed project job.""" - - project_id: uuid.UUID - archive_name: str - created_at: datetime - dockerfiles: list[str] = [] - compose_files: list[str] = [] - stacks: list[str] = [] - service_count: int = 0 - workflow_step: Literal["review", "plan"] = "review" - expires_at: datetime | None = None - expires_in_seconds: int | None = None + primary_compose_file: str class PerFileAnalysisResult(BaseModel): diff --git a/backend/app/application/services/analysis_service.py b/backend/app/application/services/analysis_service.py index bf86541..19a4378 100644 --- a/backend/app/application/services/analysis_service.py +++ b/backend/app/application/services/analysis_service.py @@ -14,6 +14,14 @@ from app.plugins.registry import load_plugins +def _hadolint_doc_url(code: str) -> str | None: + if code.startswith("DL"): + return f"https://github.com/hadolint/hadolint/wiki/{code}" + if code.startswith("SC"): + return f"https://github.com/koalaman/shellcheck/wiki/{code}" + return None + + def _rule_code_and_doc_url(raw: dict[str, Any]) -> tuple[str, str | None]: field = raw.get("code", raw.get("rule")) if isinstance(field, dict): @@ -21,7 +29,8 @@ def _rule_code_and_doc_url(raw: dict[str, Any]) -> tuple[str, str | None]: url = field.get("url") return code, str(url) if url else None if field is not None and field != "": - return str(field), None + code = str(field) + return code, _hadolint_doc_url(code) return "GEN000", None diff --git a/backend/app/application/services/project_draft.py b/backend/app/application/services/project_draft.py deleted file mode 100644 index 761865f..0000000 --- a/backend/app/application/services/project_draft.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Shared helpers for project draft expiry and cleanup.""" - -from __future__ import annotations - -import shutil -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from app.infrastructure.db.models import AnalysisJobModel - - -_EXPIRED_DETAIL = ( - "This project draft expired after 1 hour. " - "Please upload the archive again to continue." -) - - -def is_draft_expired(meta: dict[str, Any]) -> bool: - """Return True if the draft's wall-clock TTL has elapsed.""" - expires_at_str: str | None = meta.get("draft_expires_at") - if not expires_at_str: - return False - try: - expires_at = datetime.fromisoformat(expires_at_str) - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - return datetime.now(UTC) > expires_at - except ValueError: - return False - - -def seconds_until_expiry(meta: dict[str, Any]) -> int: - """Return remaining seconds until expiry (0 if already expired or no TTL set).""" - expires_at_str: str | None = meta.get("draft_expires_at") - if not expires_at_str: - return 0 - try: - expires_at = datetime.fromisoformat(expires_at_str) - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - remaining = (expires_at - datetime.now(UTC)).total_seconds() - return max(0, int(remaining)) - except ValueError: - return 0 - - -async def expire_draft(job: AnalysisJobModel, session: Any) -> None: - """Delete the DB job and remove the extracted project directory from disk.""" - project_path = job.input_metadata.get("project_path") - if project_path: - path = Path(project_path) - if path.is_dir(): - shutil.rmtree(path, ignore_errors=True) - await session.delete(job) - await session.commit() diff --git a/backend/app/infrastructure/db/repositories.py b/backend/app/infrastructure/db/repositories.py index 31330ca..0207218 100644 --- a/backend/app/infrastructure/db/repositories.py +++ b/backend/app/infrastructure/db/repositories.py @@ -128,20 +128,6 @@ async def update_job_metadata(self, job_id: uuid.UUID, user_id: uuid.UUID, patch await self.session.flush() return job - async def list_scanned_project_drafts(self, user_id: uuid.UUID) -> list[AnalysisJobModel]: - """Return project jobs with status=scanned (uploaded but not yet analyzed) for a user.""" - stmt = ( - select(AnalysisJobModel) - .where( - AnalysisJobModel.user_id == user_id, - AnalysisJobModel.type == JobType.project, - AnalysisJobModel.status == JobStatus.scanned, - ) - .order_by(AnalysisJobModel.created_at.desc()) - ) - rows = await self.session.scalars(stmt) - return list(rows) - async def count_jobs_global(self) -> int: stmt = select(func.count()).select_from(AnalysisJobModel) result = await self.session.scalar(stmt) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 4b807fd..066693b 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -487,6 +487,11 @@ async def run_compose_stop(ctx, payload: dict) -> dict: await _set_stop_requested(user_id, job_id) state["stopping"] = True + # 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) @@ -510,6 +515,7 @@ async def run_compose_stop(ctx, payload: dict) -> dict: "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(), } @@ -849,16 +855,22 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - except Exception as exc: final_state = {"container_id": container_id, "error": str(exc), "exit_code": -1} + state = await _get_deploy_state(user_id, job_id) or {} + stop_requested_by_user = bool( + state.get("stop_requested_by_user", False) + or state.get("stopped_by_user", False) + or state.get("stopping", False) + ) + await publish_event( DomainEvent( "container.exited", user_id=user_id, job_id=job_id, - payload=final_state, + payload={**final_state, "stop_requested_by_user": stop_requested_by_user}, ) ) - state = await _get_deploy_state(user_id, job_id) or {} containers_state = [c for c in state.get("containers", []) if isinstance(c, dict)] old = next((c for c in containers_state if c.get("id") == container_id), None) containers_state = [c for c in containers_state if c.get("id") != container_id] @@ -887,6 +899,15 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - # If all containers exited, publish runtime stopped if not active_ids: + state = await _get_deploy_state(user_id, job_id) or {} + was_user_stop = bool( + state.get("stopped_by_user", False) or state.get("stop_requested_by_user", False) + ) + # User stop is authoritative. run_compose_stop publishes container.stopped + # and persists terminal stopped_by_user state after compose down. + if was_user_stop: + break + await publish_event( DomainEvent( "project.runtime_stopped", @@ -896,8 +917,6 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - ) ) # Persist explicit self-exit marker so refresh can distinguish from user stop - state = await _get_deploy_state(user_id, job_id) or {} - was_user_stop = bool(state.get("stopped_by_user", False)) state["all_exited"] = True state["active"] = False if not was_user_stop: diff --git a/backend/tests/test_analysis_service.py b/backend/tests/test_analysis_service.py index 0a3e4aa..dce5b46 100644 --- a/backend/tests/test_analysis_service.py +++ b/backend/tests/test_analysis_service.py @@ -16,6 +16,35 @@ def test_normalize_issue_uses_level_when_severity_missing(): assert issue.severity == "warning" assert issue.code == "DL3002" assert issue.line == 4 + assert issue.doc_url == "https://github.com/hadolint/hadolint/wiki/DL3002" + + +def test_normalize_issue_shellcheck_code_gets_wiki_url(): + service = AnalysisService(AsyncMock()) + issue = service._normalize_issue( + { + "line": 10, + "code": "SC2086", + "level": "warning", + "message": "Double quote to prevent globbing and word splitting.", + } + ) + assert issue.code == "SC2086" + assert issue.doc_url == "https://github.com/koalaman/shellcheck/wiki/SC2086" + + +def test_normalize_issue_custom_code_has_no_wiki_url(): + service = AnalysisService(AsyncMock()) + issue = service._normalize_issue( + { + "line": 2, + "code": "SEC001", + "severity": "warning", + "message": "Potential security smell", + } + ) + assert issue.code == "SEC001" + assert issue.doc_url is None def test_normalize_issue_dclint_rdjson_code_object(): diff --git a/backend/tests/test_api_routes.py b/backend/tests/test_api_routes.py index af0cadf..18a03c2 100644 --- a/backend/tests/test_api_routes.py +++ b/backend/tests/test_api_routes.py @@ -237,6 +237,76 @@ async def fake_enqueue(task_name: str, payload: dict): assert response.status_code == 200 assert response.json()["status"] == "queued" assert calls and calls[0]["task"] == "run_compose_stop" + assert calls[0]["payload"]["remove_volumes"] is False + + +def test_compose_stop_enqueue_forwards_remove_volumes_true(client, monkeypatch, app, fake_db_session_dependency): + user = make_user() + job_id = uuid.uuid4() + runnable_job = AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.compose, + status=JobStatus.done, + input_metadata={}, + result={"meta": {"runnability": {"runnable": True, "reasons": []}}}, + ) + + class RunnableJobRepo: + def __init__(self, _session): + pass + + async def get_job(self, _job_id, _user_id): + return runnable_job + + calls: list[dict] = [] + + async def fake_enqueue(task_name: str, payload: dict): + calls.append({"task": task_name, "payload": payload}) + + monkeypatch.setattr("app.api.routers.compose.JobRepository", RunnableJobRepo) + monkeypatch.setattr("app.api.routers.compose.enqueue_job", fake_enqueue) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/compose/deploy/stop", + json={"job_id": str(job_id), "remove_volumes": True}, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 200 + assert response.json()["status"] == "queued" + assert calls and calls[0]["task"] == "run_compose_stop" + assert calls[0]["payload"]["remove_volumes"] is True + + +def test_compose_dind_ip_endpoint_returns_resolved_ip(client, monkeypatch, app): + user = make_user() + app.dependency_overrides[get_current_user] = lambda: user + monkeypatch.setattr("app.api.routers.compose._resolve_dind_ip", lambda: "172.18.0.7") + + response = client.get("/api/v1/compose/deploy/dind-ip", headers=auth_header_for(user.id)) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"dind_ip": "172.18.0.7"} + + +def test_resolve_dind_ip_falls_back_to_service_dns(monkeypatch): + from app.api.routers import compose as compose_router + + monkeypatch.setattr( + "app.api.routers.compose.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout=""), + ) + monkeypatch.setattr("app.api.routers.compose.os.getenv", lambda *args, **kwargs: "") + monkeypatch.setattr( + "app.api.routers.compose.socket.gethostbyname", + lambda host: "172.18.0.4" if host in ("dind", "docker-platform-dind") else "127.0.0.1", + ) + + assert compose_router._resolve_dind_ip() == "172.18.0.4" def test_get_job_enforces_user_scope_and_returns_404_for_other_user(client, monkeypatch, app, fake_db_session_dependency): @@ -276,13 +346,19 @@ async def get_job(self, requested_job_id, requested_user_id): def test_project_upload_enqueues_project_analysis(client, monkeypatch, app, fake_db_session_dependency): + """POST /project/upload must scan all files, create queued job, and enqueue worker with build=True.""" user = make_user(email="project-owner@example.com") job_id = uuid.uuid4() - async def fake_enqueue_job(self, user_id, job_type, metadata): + captured_meta: dict = {} + + async def fake_enqueue_job(self, user_id, job_type, metadata, initial_status=None): assert user_id == user.id assert job_type == JobType.project assert metadata["filename"] == "project.zip" + assert metadata.get("analysis_confirmed") is True + assert metadata.get("build_selected_images") is True + captured_meta.update(metadata) return job_id queue_calls: list[dict] = [] @@ -312,6 +388,11 @@ async def fake_enqueue(task_name: str, payload: dict): assert response.json()["job_id"] == str(job_id) assert response.json()["status"] == "queued" assert queue_calls and queue_calls[0]["task"] == "run_project_analysis" + # All detected files should be passed to worker + worker_payload = queue_calls[0]["payload"] + assert "Dockerfile" in worker_payload["dockerfiles"] + assert "docker-compose.yml" in worker_payload["compose_files"] + assert worker_payload["build_selected_images"] is True def test_project_upload_rejects_non_zip_file(client, app, fake_db_session_dependency): @@ -815,99 +896,74 @@ async def fake_deploy_status(*_a, **_k): # ───────────────────────────────────────────────────────────────────────────── -def test_project_analyze_transitions_scanned_to_queued(client, monkeypatch, app, fake_db_session_dependency): - """POST /project/analyze on a scanned job must set status=queued and enqueue worker.""" - user = make_user(email="analyze-scanned@example.com") +def test_project_set_primary_compose_updates_metadata(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must update primary_compose_file in job metadata.""" + user = make_user(email="primary-compose@example.com") project_id = uuid.uuid4() - scanned_job = AnalysisJobModel( + done_job = AnalysisJobModel( id=project_id, user_id=user.id, type=JobType.project, - status=JobStatus.scanned, - input_metadata={ - "project_path": "/tmp/fake", - "dockerfiles": ["Dockerfile"], - "compose_files": ["docker-compose.yml", "docker-compose.prod.yml"], - "scan_only": True, - "analysis_confirmed": False, + status=JobStatus.done, + input_metadata={"compose_files": ["docker-compose.yml", "docker-compose.prod.yml"]}, + result={ + "per_file_results": [ + {"file_path": "docker-compose.yml", "file_type": "compose", "score": 80, "grade": "B"}, + {"file_path": "docker-compose.prod.yml", "file_type": "compose", "score": 75, "grade": "B"}, + ] }, - result=None, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - - flushed_status: list[JobStatus] = [] - committed: list[bool] = [] - queue_calls: list[dict] = [] - - class FakeSession: - async def flush(self): - flushed_status.append(scanned_job.status) - - async def commit(self): - committed.append(True) - - async def get(self, model_class, pk): - if pk == project_id: - return scanned_job - return None + updated_meta: dict = {} class JobRepo: def __init__(self, _session): pass async def get_job(self, jid, uid): - if jid == project_id and uid == user.id: - return scanned_job - return None + return done_job if jid == project_id and uid == user.id else None - async def fake_enqueue(task_name: str, payload: dict): - queue_calls.append({"task": task_name, "payload": payload}) + async def update_job_metadata(self, jid, uid, patch): + done_job.input_metadata = {**done_job.input_metadata, **patch} + updated_meta.update(patch) + return done_job - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - monkeypatch.setattr("app.api.routers.project.enqueue_job", fake_enqueue) + class FakeSession: + async def commit(self): + pass - fake_session = FakeSession() - app.dependency_overrides[get_db_session] = lambda: fake_session + monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = lambda: FakeSession() app.dependency_overrides[get_current_user] = lambda: user - response = client.post( - "/api/v1/project/analyze", - json={ - "project_id": str(project_id), - "selected_dockerfiles": ["Dockerfile"], - "selected_compose_files": ["docker-compose.yml"], - "analysis_mode": "auto", - "build_selected_images": False, - "run_after_analysis": False, - }, + response = client.patch( + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "docker-compose.prod.yml"}, headers=auth_header_for(user.id), ) app.dependency_overrides.clear() + assert response.status_code == 200 - assert response.json()["status"] == "queued" - assert scanned_job.status == JobStatus.queued - assert scanned_job.input_metadata["analysis_confirmed"] is True - assert scanned_job.input_metadata["scan_only"] is False - assert len(queue_calls) == 1 - assert queue_calls[0]["task"] == "run_project_analysis" - assert queue_calls[0]["payload"]["compose_files"] == [ - "docker-compose.yml", - "docker-compose.prod.yml", - ] - - -def test_project_analyze_rejects_non_scanned_job(client, monkeypatch, app, fake_db_session_dependency): - """POST /project/analyze on a non-scanned/non-queued job must return 409.""" - user = make_user(email="analyze-done@example.com") + assert response.json()["status"] == "updated" + assert updated_meta.get("primary_compose_file") == "docker-compose.prod.yml" + + +def test_project_set_primary_compose_rejects_unanalyzed_file(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must reject compose file not in per_file_results.""" + user = make_user(email="primary-compose-bad@example.com") project_id = uuid.uuid4() done_job = AnalysisJobModel( id=project_id, user_id=user.id, type=JobType.project, status=JobStatus.done, - input_metadata={"project_path": "/tmp/fake", "dockerfiles": [], "compose_files": []}, - result={"overall_score": 80}, + input_metadata={}, + result={ + "per_file_results": [ + {"file_path": "docker-compose.yml", "file_type": "compose", "score": 80, "grade": "B"}, + ] + }, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) @@ -917,384 +973,98 @@ def __init__(self, _session): pass async def get_job(self, jid, uid): - if jid == project_id and uid == user.id: - return done_job - return None + return done_job if jid == project_id and uid == user.id else None monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) app.dependency_overrides[get_db_session] = fake_db_session_dependency app.dependency_overrides[get_current_user] = lambda: user - response = client.post( - "/api/v1/project/analyze", - json={ - "project_id": str(project_id), - "selected_dockerfiles": [], - "selected_compose_files": [], - "analysis_mode": "auto", - "build_selected_images": False, - "run_after_analysis": False, - }, - headers=auth_header_for(user.id), - ) - app.dependency_overrides.clear() - assert response.status_code == 409 - assert "not in a confirmable state" in response.json()["detail"] - - -def test_delete_job_allows_scanned_project(client, monkeypatch, app, fake_db_session_dependency): - """Scanned (not-yet-analyzed) project jobs must be deletable.""" - user = make_user(email="delete-scanned@example.com") - job_id = uuid.uuid4() - calls: dict = {} - - class JobRepo: - def __init__(self, _session): - pass - - async def get_job(self, jid, uid): - if jid == job_id and uid == user.id: - return AnalysisJobModel( - id=job_id, - user_id=user.id, - type=JobType.project, - status=JobStatus.scanned, - input_metadata={"scan_only": True, "analysis_confirmed": False}, - result=None, - created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc), - ) - return None - - async def delete_job(self, jid, uid): - calls["deleted"] = True - return True - - async def fake_deploy_status(*_a, **_k): - return SimpleNamespace(active=False) - - async def fake_redis_del(*_a): - pass - - monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) - monkeypatch.setattr("app.api.routers.history.compute_deploy_status", fake_deploy_status) - monkeypatch.setattr("app.api.routers.history.redis_client.delete", AsyncMock()) - 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/jobs/{job_id}", + response = client.patch( + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "nonexistent-compose.yml"}, headers=auth_header_for(user.id), ) app.dependency_overrides.clear() - assert response.status_code == 204 - assert calls.get("deleted") is True - -# ───────────────────────────────────────────────────────────────────────────── -# Project draft TTL and plan-step persistence tests -# ───────────────────────────────────────────────────────────────────────────── + assert response.status_code == 400 + assert "was not analyzed" in response.json()["detail"] -def _make_scanned_job(user_id, project_id, *, expires_at_str: str, extra_meta: dict | None = None): - """Build an AnalysisJobModel for a scanned draft with given expiry.""" - meta = { - "project_path": "/tmp/fake-project", - "filename": "myproject.zip", - "dockerfiles": ["Dockerfile"], - "compose_files": ["docker-compose.yml"], - "stacks": [], - "service_count": 0, - "scan_only": True, - "analysis_confirmed": False, - "workflow_step": "review", - "draft_expires_at": expires_at_str, - } - if extra_meta: - meta.update(extra_meta) - return AnalysisJobModel( +def test_project_set_primary_compose_rejects_non_done_job(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must return 409 if job is not done.""" + user = make_user(email="primary-compose-running@example.com") + project_id = uuid.uuid4() + running_job = AnalysisJobModel( id=project_id, - user_id=user_id, + user_id=user.id, type=JobType.project, - status=JobStatus.scanned, - input_metadata=meta, + status=JobStatus.running, + input_metadata={}, result=None, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) - -def _future_expiry() -> str: - from datetime import timedelta - return (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() - - -def _past_expiry() -> str: - from datetime import timedelta - return (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() - - -def test_patch_draft_saves_plan_step(client, monkeypatch, app, fake_db_session_dependency): - """PATCH /project/{id}/draft persists workflow_step=plan and selections.""" - user = make_user(email="patch-draft@example.com") - project_id = uuid.uuid4() - job = _make_scanned_job(user.id, project_id, expires_at_str=_future_expiry()) - class JobRepo: def __init__(self, _session): pass async def get_job(self, jid, uid): - return job if jid == project_id and uid == user.id else None - - async def update_job_metadata(self, jid, uid, patch): - job.input_metadata = {**job.input_metadata, **patch} - return job - - class FakeSession: - async def get(self, _cls, pk): - return job if pk == project_id else None - - async def flush(self): - pass - - async def commit(self): - pass + return running_job if jid == project_id and uid == user.id else None monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - app.dependency_overrides[get_db_session] = lambda: FakeSession() - app.dependency_overrides[get_current_user] = lambda: user - - response = client.patch( - f"/api/v1/project/{project_id}/draft", - json={ - "workflow_step": "plan", - "selected_dockerfiles": ["Dockerfile"], - "selected_compose_files": ["docker-compose.yml"], - "primary_compose_file": "docker-compose.yml", - "analysis_mode": "full-project", - "build_selected_images": True, - "run_after_analysis": False, - }, - headers=auth_header_for(user.id), - ) - app.dependency_overrides.clear() - - assert response.status_code == 200 - data = response.json() - assert data["workflow_step"] == "plan" - assert job.input_metadata["workflow_step"] == "plan" - assert job.input_metadata["analysis_mode"] == "full-project" - assert job.input_metadata["saved_selections_set"] is True - - -def test_patch_draft_expired_returns_410(client, monkeypatch, app, fake_db_session_dependency): - """PATCH /project/{id}/draft on an expired draft must return 410.""" - user = make_user(email="patch-expired@example.com") - project_id = uuid.uuid4() - job = _make_scanned_job(user.id, project_id, expires_at_str=_past_expiry()) - - deleted: list[bool] = [] - - class JobRepo: - def __init__(self, _session): - pass - - async def get_job(self, jid, uid): - return job if jid == project_id and uid == user.id else None - - class FakeSession: - async def delete(self, obj): - deleted.append(True) - - async def commit(self): - pass - - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - app.dependency_overrides[get_db_session] = lambda: FakeSession() + app.dependency_overrides[get_db_session] = fake_db_session_dependency app.dependency_overrides[get_current_user] = lambda: user response = client.patch( - f"/api/v1/project/{project_id}/draft", - json={ - "workflow_step": "plan", - "selected_dockerfiles": [], - "selected_compose_files": [], - "analysis_mode": "auto", - "build_selected_images": False, - "run_after_analysis": False, - }, + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "docker-compose.yml"}, headers=auth_header_for(user.id), ) app.dependency_overrides.clear() - assert response.status_code == 410 - assert "expired" in response.json()["detail"].lower() - assert len(deleted) == 1 - - -def test_get_project_scan_expired_returns_410(client, monkeypatch, app, fake_db_session_dependency): - """GET /project/{id}/scan on an expired draft must return 410.""" - user = make_user(email="get-scan-expired@example.com") - project_id = uuid.uuid4() - job = _make_scanned_job(user.id, project_id, expires_at_str=_past_expiry()) - - deleted: list[bool] = [] - - class JobRepo: - def __init__(self, _session): - pass - - async def get_job(self, jid, uid): - return job if jid == project_id and uid == user.id else None - - class FakeSession: - async def delete(self, obj): - deleted.append(True) - - async def commit(self): - pass - - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - app.dependency_overrides[get_db_session] = lambda: FakeSession() - app.dependency_overrides[get_current_user] = lambda: user - - response = client.get( - f"/api/v1/project/{project_id}/scan", - headers=auth_header_for(user.id), - ) - app.dependency_overrides.clear() - - assert response.status_code == 410 - assert len(deleted) == 1 - - -def test_analyze_expired_draft_returns_410(client, monkeypatch, app, fake_db_session_dependency): - """POST /project/analyze on an expired draft must return 410.""" - user = make_user(email="analyze-expired@example.com") - project_id = uuid.uuid4() - job = _make_scanned_job(user.id, project_id, expires_at_str=_past_expiry()) - - deleted: list[bool] = [] - - class JobRepo: - def __init__(self, _session): - pass - - async def get_job(self, jid, uid): - return job if jid == project_id and uid == user.id else None - - class FakeSession: - async def delete(self, obj): - deleted.append(True) - - async def commit(self): - pass - - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - monkeypatch.setattr("app.api.routers.project.enqueue_job", AsyncMock()) - app.dependency_overrides[get_db_session] = lambda: FakeSession() - app.dependency_overrides[get_current_user] = lambda: user - - response = client.post( - "/api/v1/project/analyze", - json={ - "project_id": str(project_id), - "selected_dockerfiles": ["Dockerfile"], - "selected_compose_files": [], - "analysis_mode": "auto", - "build_selected_images": False, - "run_after_analysis": False, - }, - headers=auth_header_for(user.id), - ) - app.dependency_overrides.clear() - - assert response.status_code == 410 - assert len(deleted) == 1 - - -def test_list_drafts_excludes_expired(client, monkeypatch, app, fake_db_session_dependency): - """GET /project/drafts must omit expired drafts and clean them up.""" - user = make_user(email="list-drafts@example.com") - valid_id = uuid.uuid4() - expired_id = uuid.uuid4() - - valid_job = _make_scanned_job(user.id, valid_id, expires_at_str=_future_expiry()) - expired_job = _make_scanned_job(user.id, expired_id, expires_at_str=_past_expiry()) - - deleted: list[uuid.UUID] = [] - - class JobRepo: - def __init__(self, _session): - pass - - async def list_scanned_project_drafts(self, uid): - return [valid_job, expired_job] - - class FakeSession: - async def delete(self, obj): - deleted.append(obj.id) - - async def commit(self): - pass - - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - app.dependency_overrides[get_db_session] = lambda: FakeSession() - app.dependency_overrides[get_current_user] = lambda: user - - response = client.get("/api/v1/project/drafts", headers=auth_header_for(user.id)) - app.dependency_overrides.clear() - - assert response.status_code == 200 - data = response.json() - ids = [d["project_id"] for d in data] - assert str(valid_id) in ids - assert str(expired_id) not in ids - assert expired_id in deleted + assert response.status_code == 409 + assert "after analysis is complete" in response.json()["detail"] -def test_patch_draft_returns_expiry_info(client, monkeypatch, app, fake_db_session_dependency): - """PATCH /project/{id}/draft response must include expires_at and expires_in_seconds.""" - user = make_user(email="patch-expiry@example.com") - project_id = uuid.uuid4() - job = _make_scanned_job(user.id, project_id, expires_at_str=_future_expiry()) +def test_delete_job_allows_project_job(client, monkeypatch, app, fake_db_session_dependency): + """Project jobs (done or otherwise) must be deletable.""" + user = make_user(email="delete-project@example.com") + job_id = uuid.uuid4() + calls: dict = {} class JobRepo: def __init__(self, _session): pass async def get_job(self, jid, uid): - return job if jid == project_id and uid == user.id else None - - async def update_job_metadata(self, jid, uid, patch): - job.input_metadata = {**job.input_metadata, **patch} - return job + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.project, + status=JobStatus.done, + input_metadata={"analysis_confirmed": True, "build_selected_images": True}, + result=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None - class FakeSession: - async def commit(self): - pass + async def delete_job(self, jid, uid): + calls["deleted"] = True + return True - monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) - app.dependency_overrides[get_db_session] = lambda: FakeSession() + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.compute_deploy_status", AsyncMock(return_value=SimpleNamespace(active=False))) + monkeypatch.setattr("app.api.routers.history.redis_client.delete", AsyncMock()) + app.dependency_overrides[get_db_session] = fake_db_session_dependency app.dependency_overrides[get_current_user] = lambda: user - response = client.patch( - f"/api/v1/project/{project_id}/draft", - json={ - "workflow_step": "review", - "selected_dockerfiles": ["Dockerfile"], - "selected_compose_files": [], - "analysis_mode": "auto", - "build_selected_images": False, - "run_after_analysis": False, - }, + response = client.delete( + f"/api/v1/users/me/jobs/{job_id}", headers=auth_header_for(user.id), ) app.dependency_overrides.clear() - - assert response.status_code == 200 - data = response.json() - assert data["expires_at"] is not None - assert isinstance(data["expires_in_seconds"], int) - assert data["expires_in_seconds"] > 0 + assert response.status_code == 204 + assert calls.get("deleted") is True diff --git a/backend/tests/test_container_lifecycle.py b/backend/tests/test_container_lifecycle.py index 08d3cf5..004f48f 100644 --- a/backend/tests/test_container_lifecycle.py +++ b/backend/tests/test_container_lifecycle.py @@ -402,6 +402,50 @@ async def fake_set_state(uid, jid, state): assert redis_boxes.get("exit_reason") == "all_containers_exited" +@pytest.mark.asyncio +async def test_stream_metrics_user_stop_keeps_user_stop_semantics(): + """When user stop is requested, _stream_metrics should not emit runtime_stopped or set self-exit markers.""" + user_id = _uid() + job_id = _uid() + + fake_gateway = MagicMock() + fake_gateway.inspect_container_state = AsyncMock(return_value={"status": "running"}) + fake_gateway.inspect_container_metrics = AsyncMock(side_effect=docker.errors.NotFound("gone")) + fake_gateway.inspect_container_final_state = AsyncMock( + return_value={"container_id": "c1", "exit_code": 0, "oom_killed": False, "last_logs": []} + ) + + published_events: list[Any] = [] + redis_boxes: dict[str, Any] = {"stop_requested_by_user": True, "stopped_by_user": True} + + async def capture(event): + published_events.append(event) + + async def fake_get_state(uid, jid): + return dict(redis_boxes) + + async def fake_set_state(uid, jid, state): + redis_boxes.update(state) + + with ( + patch("app.workers.tasks.DockerGateway", return_value=fake_gateway), + patch("app.workers.tasks.publish_event", side_effect=capture), + 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), + ): + from app.workers.tasks import _stream_metrics + await _stream_metrics(user_id, job_id, ["c1"]) + + event_names = [e.event_name for e in published_events] + assert "container.exited" in event_names + assert "project.runtime_stopped" not in event_names + exited_event = next(e for e in published_events if e.event_name == "container.exited") + assert exited_event.payload.get("stop_requested_by_user") is True + assert redis_boxes.get("explicit_runtime_state") != "exited" + assert redis_boxes.get("exit_reason") != "all_containers_exited" + + @pytest.mark.asyncio async def test_deploy_status_differentiates_stopped_by_user_vs_self_exit(): """compute_deploy_status must return stopped_by_user for user stops and can_retry_runtime accordingly.""" diff --git a/backend/tests/test_worker_events.py b/backend/tests/test_worker_events.py index daa76fe..ef832d5 100644 --- a/backend/tests/test_worker_events.py +++ b/backend/tests/test_worker_events.py @@ -297,10 +297,18 @@ 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 + 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["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/examples/README.md b/examples/README.md index 92a2f5f..bb9e867 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ examples/ `-- projects/ |-- node-hello-dockerfile.zip # project with a single Dockerfile |-- compose-stack.zip # project with a runnable compose stack +|-- compose-exits-after-start.zip # runnable compose that crashes after startup |-- python-webapp.zip # project with a blocked compose (build + bind) `-- src/ # un-zipped sources for reference ``` @@ -78,6 +79,9 @@ Try: `Detected files in archive` block. - `compose-stack.zip` -> both Dockerfile and compose are detected. Runnability passes, so **Run Containers** is enabled on the Results page. +- `compose-exits-after-start.zip` -> compose is runnable and deploy starts + successfully, then containers exit with code `1` after a short delay. Use this + to validate "started -> exited" runtime behavior and retry UX. - `python-webapp.zip` -> compose is flagged as not runnable (build context + bind mount). Project jobs are not hard-blocked from deploy, but the reasons are rendered clearly in the Results view. diff --git a/examples/projects/compose-exits-after-start.zip b/examples/projects/compose-exits-after-start.zip new file mode 100644 index 0000000000000000000000000000000000000000..8214deeecee81a0c40f5eeadb7536acb9735f235 GIT binary patch literal 917 zcmWIWW@Zs#U|`^2*gJQ9%;n7Vn+t)wHH-`lLJTqtDf!9SsYSZU`MCx8#i@FgxjCUB zoD9rIec7`>xU__i zST%3|m1dya*w|V@le2GoHb5&T~@?A^;aL*nBdbaCVX2%MkM_({8FmM7r z8|3Qf;_IrHn*#RZX`uf=7|oBhLALpK40!H-)#kW!iOH6o^MYz}gP6vJh0eR9r>)pr zuqC4-NJjtf+oexVD+V+28+q96xQF7&g)hlVP!0SrqgV= zTzJoeoOtE*Z9Wh=COERz=0UbuE~-Rm0*pB^ZCBY5n%;4Yyb zzkh^Q-9PrT>*xNp{{DNvewShWvbt#%1DCU-=7;CU52xG}oqBvtipDbTf(kQ+uDb0B zFLEyS3(V*+|6=e~{&nvDX}=>nPF}jptGVaiiik!1>sA?a98Z}4Inc{zor|AA;?01& zq1$w~vn}MAtZN!wC9veeJ2fzXY}W!-`5{|%vF9I$xhCAx!Lfsg&2; + exit 1 + + worker: + image: busybox:1.36.1 + command: + - sh + - -c + - | + echo "[worker] started"; + sleep 22; + echo "[worker] fatal: simulated crash after startup" >&2; + exit 1 diff --git a/frontend/src/pages/AnalysisProgress.tsx b/frontend/src/pages/AnalysisProgress.tsx index d66b3f9..eeb2d62 100644 --- a/frontend/src/pages/AnalysisProgress.tsx +++ b/frontend/src/pages/AnalysisProgress.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; -import { FolderSearch, ArrowLeft } from "lucide-react"; import { Layout } from "../components/Layout"; import { ProgressStep } from "../components/ProgressStep"; @@ -178,7 +177,6 @@ export function AnalysisProgress() { const queryJobId = searchParams.get("jobId"); const [isProjectJob, setIsProjectJob] = useState(false); - const [isScannedOnly, setIsScannedOnly] = useState(false); const [steps, setSteps] = useState(() => BASE_STEPS.map((step) => ({ ...step })), ); @@ -310,17 +308,6 @@ export function AnalysisProgress() { setIsProjectJob(true); setSteps(PROJECT_CORE_STEPS.map((s) => ({ ...s }))); } - // Guard: scanned jobs have no worker — show CTA view instead of progress - if (reconciled.status === "scanned") { - setIsScannedOnly(true); - return; - } - // Also guard on analysis_confirmed=false in metadata - const meta = reconciled.input_metadata as Record; - if (reconciled.type === "project" && meta?.analysis_confirmed === false) { - setIsScannedOnly(true); - return; - } if (reconciled.status === "done") { handleCompleted(reconciled); return; @@ -529,45 +516,6 @@ export function AnalysisProgress() { return progressValue; }, [steps, jobStatus, progressValue]); - if (isScannedOnly) { - return ( - - -
-
-
- -
-
-

Project Scanned

-

- This project has been scanned but analysis has not started yet. -

-

- Return to the project upload page to select files and confirm analysis. -

-
- - -
-
-
-
- ); - } - return ( diff --git a/frontend/src/pages/ContainerExecution.tsx b/frontend/src/pages/ContainerExecution.tsx index 27becec..564ad5a 100644 --- a/frontend/src/pages/ContainerExecution.tsx +++ b/frontend/src/pages/ContainerExecution.tsx @@ -74,6 +74,12 @@ interface ExitedContainerSummary { restart_count?: number | null; oom_killed?: boolean | null; last_logs?: string[] | null; + stop_requested_by_user?: boolean; +} + +interface ProblematicAlertState { + open: boolean; + message: string; } const DEPLOY_TIMEOUT_MS = 120_000; @@ -108,6 +114,10 @@ export function ContainerExecution() { const ready = useMinLoader(!loading); const [stopping, setStopping] = useState(false); + const [problematicAlert, setProblematicAlert] = useState({ + open: false, + message: "", + }); const [stackRunning, setStackRunning] = useState(persisted?.stackRunning ?? false); const [deployPhase, setDeployPhase] = useState( persisted?.deployPhase ?? (persisted?.stackRunning ? "running" : "idle"), @@ -129,6 +139,13 @@ export function ContainerExecution() { const [metricsSeenContainerIds, setMetricsSeenContainerIds] = useState([]); const socketRef = useRef(null); const deployTimeoutRef = useRef | null>(null); + const unmountedRef = useRef(false); + const stoppingRef = useRef(stopping); + const cleanupAttemptedRef = useRef(false); + + useEffect(() => { + stoppingRef.current = stopping; + }, [stopping]); const clearDeployTimeout = () => { if (deployTimeoutRef.current !== null) { @@ -140,6 +157,35 @@ export function ContainerExecution() { const analysis = useMemo(() => ensureAnalysis(job), [job]); const runnability: RunnabilityMeta | undefined = analysis?.meta?.runnability; const runnable = runnability?.runnable === true; + const deployComposeFile = useMemo(() => { + if (!job) return null; + const metadata = job.input_metadata ?? {}; + const explicitPrimary = metadata.primary_compose_file; + if (typeof explicitPrimary === "string" && explicitPrimary.trim()) { + return explicitPrimary; + } + const selectedCompose = metadata.selected_compose_files; + if ( + Array.isArray(selectedCompose) && + selectedCompose.length > 0 && + typeof selectedCompose[0] === "string" + ) { + return selectedCompose[0]; + } + const composeFiles = metadata.compose_files; + if ( + Array.isArray(composeFiles) && + composeFiles.length > 0 && + typeof composeFiles[0] === "string" + ) { + return composeFiles[0]; + } + const filename = metadata.filename; + return typeof filename === "string" && filename.trim() ? filename : null; + }, [job]); + const resubmitRequired = + !!analysisJobId && + sessionStorage.getItem(`dqa:resubmitRequired:${analysisJobId}`) === "1"; const pushLog = (entry: TerminalLogEntry) => setLogs((prev) => [...prev, entry]); @@ -164,6 +210,251 @@ export function ContainerExecution() { } }; + const resetExecutionViewState = () => { + setTimeline(BASE_TIMELINE.map((entry) => ({ ...entry }))); + setComposeProgress(null); + setExitedContainers([]); + setMetricsSeenContainerIds([]); + }; + + const setResubmitRequired = () => { + if (!analysisJobId) return; + sessionStorage.setItem(`dqa:resubmitRequired:${analysisJobId}`, "1"); + }; + + const markProblematicStack = ( + message: string, + options?: { cleanupRuntime?: boolean; keepExitData?: boolean }, + ) => { + setResubmitRequired(); + setProblematicAlert({ open: true, message }); + setStackRunning(false); + setStopping(false); + setDeployPhase("failed"); + clearDeployTimeout(); + sessionStorage.removeItem("dqa:containerStatus"); + if (!options?.keepExitData) { + resetExecutionViewState(); + } + if (stateKey) clearState(stateKey); + + if ( + options?.cleanupRuntime && + !cleanupAttemptedRef.current && + job && + !unmountedRef.current + ) { + cleanupAttemptedRef.current = true; + void composeApi + .stopDeploy({ job_id: job.id, remove_volumes: true }) + .then(() => { + pushLog({ + message: "Cleanup requested for problematic stack (volumes included).", + tone: "warning", + }); + }) + .catch((err) => { + const msg = err instanceof ApiError ? err.message : "Cleanup request failed"; + pushLog({ message: msg, tone: "error" }); + }); + } + }; + + const handleDomainEvent = (parsed: DomainEvent) => { + if ( + parsed.event_name !== "deploy.compose_up_log" && + parsed.event_name !== "deploy.compose_up_progress" + ) { + pushLog({ + message: `${parsed.event_name} ${ + parsed.payload ? JSON.stringify(parsed.payload) : "" + }`, + timestamp: parsed.timestamp, + tone: + parsed.event_name === "user.analysis.failed" + ? "error" + : parsed.event_name === "container.metrics" + || parsed.event_name === "deploy.cleanup_started" + ? "info" + : "success", + }); + } + if (parsed.event_name === "deploy.compose_up_log") { + const line = String((parsed.payload as { line?: string })?.line ?? ""); + if (line) { + pushLog({ message: line, timestamp: parsed.timestamp, tone: "info" }); + } + return; + } else if (parsed.event_name === "deploy.compose_up_progress") { + const p = parsed.payload as + | { total_services?: number; created?: number; started?: number } + | undefined; + const total = Number(p?.total_services ?? 0); + const created = Number(p?.created ?? 0); + const started = Number(p?.started ?? 0); + if (total > 0) { + setComposeProgress({ total, created, started }); + } + return; + } + + if (parsed.event_name === "container.started") { + const ids = (parsed.payload as { container_ids?: string[] })?.container_ids ?? []; + const primaryId = String( + (parsed.payload as { container_id?: string })?.container_id ?? "", + ); + setContainerIds(ids.length > 0 ? ids : primaryId ? [primaryId] : []); + setStackRunning(true); + setStopping(false); + setComposeProgress(null); + setDeployPhase("running"); + clearDeployTimeout(); + setTimelineStatus( + "start", + "done", + ids.length > 0 ? `${ids.length} container(s) started` : "Container started", + ); + pushNotification("success", "Containers Running", `${ids.length || 1} container(s) are now running`); + } else if (parsed.event_name === "container.metrics") { + const metricContainerId = String( + (parsed.payload as { container_id?: string })?.container_id ?? "", + ); + if (metricContainerId) { + setMetricsSeenContainerIds((prev) => + prev.includes(metricContainerId) ? prev : [...prev, metricContainerId], + ); + } + // Mark as done to avoid a perpetual "loading" spinner while runtime is healthy. + setTimelineStatus("metrics", "done", "Metrics streaming"); + } else if (parsed.event_name === "container.exited") { + const p = parsed.payload as ExitedContainerSummary; + const stopRequestedByUser = p.stop_requested_by_user === true || stoppingRef.current; + if (stopRequestedByUser) { + const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; + pushLog({ + message: `Container stopped during user-requested shutdown: ${name}`, + timestamp: parsed.timestamp, + tone: "info", + }); + return; + } + const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; + const exitMsg = `Container exited: ${name} (code ${p.exit_code ?? "??"})${p.error ? ` — ${p.error}` : ""}`; + setTimelineStatus("start", "error", exitMsg); + pushLog({ message: exitMsg, timestamp: parsed.timestamp, tone: "error" }); + setExitedContainers((prev) => { + const key = p.container_id ?? `${p.container_name ?? "container"}-${p.finished_at ?? ""}`; + const next = prev.filter((item) => { + const itemKey = item.container_id ?? `${item.container_name ?? "container"}-${item.finished_at ?? ""}`; + return itemKey !== key; + }); + return [...next, p]; + }); + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + } else if (parsed.event_name === "project.runtime_stopped") { + if (stoppingRef.current) { + setTimelineStatus("metrics", "done", "Stop completed"); + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + clearDeployTimeout(); + resetExecutionViewState(); + return; + } + setTimelineStatus("metrics", "error", "All containers exited unexpectedly"); + pushLog({ message: "All containers have exited.", timestamp: parsed.timestamp, tone: "error" }); + pushNotification("warning", "Runtime Stopped", "All containers have exited"); + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + } else if (parsed.event_name === "container.stopped") { + setTimelineStatus("metrics", "done", "Stack stopped"); + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + clearDeployTimeout(); + setContainerIds([]); + setDeployJobId(null); + sessionStorage.removeItem("dqa:containerStatus"); + if (stateKey) clearState(stateKey); + toast.success("Compose stack stopped"); + pushNotification("warning", "Containers Stopped", "All containers have been stopped"); + } else if (parsed.event_name === "deploy.cleanup_started") { + const projectName = String( + (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", + ); + setStackRunning(false); + setContainerIds([]); + pushLog({ + message: `Cleanup started for ${projectName}`, + timestamp: parsed.timestamp, + tone: "info", + }); + toast.message("Stopping and removing stack resources..."); + pushNotification("info", "Cleanup Started", "Removing containers created by the failed deploy"); + } else if (parsed.event_name === "deploy.cleanup_completed") { + const projectName = String( + (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", + ); + setStackRunning(false); + setStopping(false); + setContainerIds([]); + setDeployJobId(null); + sessionStorage.removeItem("dqa:containerStatus"); + if (stateKey) clearState(stateKey); + pushLog({ + message: `Cleanup completed for ${projectName}`, + timestamp: parsed.timestamp, + tone: "success", + }); + toast.success("Failed deploy containers stopped and removed"); + pushNotification("success", "Cleanup Completed", "Failed deploy containers were removed from the sandbox"); + } else if (parsed.event_name === "user.analysis.failed") { + setTimelineStatus( + "start", + "error", + String( + (parsed.payload as { message?: string })?.message ?? "failed", + ), + ); + setStackRunning(false); + setStopping(false); + setDeployPhase("failed"); + clearDeployTimeout(); + toast.error("Deploy workflow reported a failure"); + } + }; + + const connectJobSocket = (streamJobId: string, announce = false) => { + closeSocket(); + const socket = ws.connectJob(streamJobId); + socketRef.current = socket; + socket.onopen = () => { + if (announce) { + pushLog({ + message: `Streaming events for job ${streamJobId}`, + tone: "info", + }); + } + }; + socket.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data as string) as DomainEvent; + handleDomainEvent(parsed); + } catch { + pushLog({ message: String(event.data), tone: "info" }); + } + }; + socket.onerror = () => + pushLog({ message: "Event stream error", tone: "error" }); + socket.onclose = () => + pushLog({ message: "Event stream closed", tone: "warning" }); + }; + useEffect(() => { if (!analysisJobId) { setLoadError("No analysis job available. Upload a compose file first."); @@ -185,17 +476,47 @@ export function ContainerExecution() { const status = await composeApi.deployStatus(analysisJobId); const rs = status.runtime_state ?? "none"; + const problematicByRuntimeState = + rs === "failed" || + rs === "cleanup_completed" || + rs === "exited" || + rs === "partial" || + rs === "unhealthy"; + const resubmitRequired = + sessionStorage.getItem(`dqa:resubmitRequired:${analysisJobId}`) === "1"; if (status.active) { + if (rs === "partial" || rs === "unhealthy") { + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + return; + } setStackRunning(true); if (rs === "stopping") { - setDeployPhase("deploying"); + setStopping(true); + setDeployPhase("idle"); } else { + setStopping(false); setDeployPhase("running"); + connectJobSocket(analysisJobId); } setContainerIds(status.container_ids); if (!deployJobId) setDeployJobId(analysisJobId); - } else if (rs === "exited" || rs === "failed" || rs === "cleanup_completed") { + } else if (problematicByRuntimeState || resubmitRequired) { + if (!resubmitRequired && (status.stopped_by_user || status.stop_reason === "user_requested")) { + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + setContainerIds([]); + setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); + return; + } setStackRunning(false); + setStopping(false); setDeployPhase("exited"); setContainerIds(status.container_ids); // Populate exited container summaries from persisted container info @@ -216,16 +537,28 @@ export function ContainerExecution() { })), ); } + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); } else if (rs === "stopped_by_user") { setStackRunning(false); + setStopping(false); setDeployPhase("idle"); setContainerIds([]); setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); } else { setStackRunning(false); + setStopping(false); setDeployPhase((prev) => (prev === "running" ? "idle" : prev)); setContainerIds([]); setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); } } catch (err) { if (cancelled) return; @@ -243,10 +576,14 @@ export function ContainerExecution() { return () => { cancelled = true; }; + // This effect intentionally rehydrates only when the target analysis job changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [analysisJobId]); useEffect(() => { + unmountedRef.current = false; return () => { + unmountedRef.current = true; closeSocket(); clearDeployTimeout(); }; @@ -266,11 +603,16 @@ export function ContainerExecution() { const handleDeploy = async () => { if (!job) return; + if (resubmitRequired) { + toast.error("This stack is marked problematic. Please fix and resubmit."); + return; + } if (!runnable && job.type === "compose") { toast.error("Compose stack is not runnable from a standalone file."); return; } setDeployPhase("deploying"); + setStopping(false); clearDeployTimeout(); setComposeProgress(null); setExitedContainers([]); @@ -295,159 +637,7 @@ export function ContainerExecution() { toast.success("Deploy request accepted"); pushNotification("info", "Deploy Started", `Compose stack deploy queued for job ${job.id.slice(0, 8)}`); - const socket = ws.connectJob(job.id); - socketRef.current = socket; - socket.onopen = () => { - pushLog({ - message: `Streaming events for job ${job.id}`, - tone: "info", - }); - }; - socket.onmessage = (event) => { - try { - const parsed = JSON.parse(event.data as string) as DomainEvent; - if ( - parsed.event_name !== "deploy.compose_up_log" && - parsed.event_name !== "deploy.compose_up_progress" - ) { - pushLog({ - message: `${parsed.event_name} ${ - parsed.payload ? JSON.stringify(parsed.payload) : "" - }`, - timestamp: parsed.timestamp, - tone: - parsed.event_name === "user.analysis.failed" - ? "error" - : parsed.event_name === "container.metrics" - || parsed.event_name === "deploy.cleanup_started" - ? "info" - : "success", - }); - } - if (parsed.event_name === "deploy.compose_up_log") { - const line = String((parsed.payload as { line?: string })?.line ?? ""); - if (line) { - pushLog({ message: line, timestamp: parsed.timestamp, tone: "info" }); - } - return; - } else if (parsed.event_name === "deploy.compose_up_progress") { - const p = parsed.payload as - | { total_services?: number; created?: number; started?: number } - | undefined; - const total = Number(p?.total_services ?? 0); - const created = Number(p?.created ?? 0); - const started = Number(p?.started ?? 0); - if (total > 0) { - setComposeProgress({ total, created, started }); - } - return; - } - - if (parsed.event_name === "container.started") { - const ids = (parsed.payload as { container_ids?: string[] })?.container_ids ?? []; - const primaryId = String( - (parsed.payload as { container_id?: string })?.container_id ?? "", - ); - setContainerIds(ids.length > 0 ? ids : primaryId ? [primaryId] : []); - setStackRunning(true); - setDeployPhase("running"); - clearDeployTimeout(); - setTimelineStatus( - "start", - "done", - ids.length > 0 ? `${ids.length} container(s) started` : "Container started", - ); - pushNotification("success", "Containers Running", `${ids.length || 1} container(s) are now running`); - } else if (parsed.event_name === "container.metrics") { - const metricContainerId = String( - (parsed.payload as { container_id?: string })?.container_id ?? "", - ); - if (metricContainerId) { - setMetricsSeenContainerIds((prev) => - prev.includes(metricContainerId) ? prev : [...prev, metricContainerId], - ); - } - setTimelineStatus("metrics", "running", "Metrics streaming"); - } else if (parsed.event_name === "container.exited") { - const p = parsed.payload as ExitedContainerSummary; - const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; - const exitMsg = `Container exited: ${name} (code ${p.exit_code ?? "??"})${p.error ? ` — ${p.error}` : ""}`; - setTimelineStatus("start", "error", exitMsg); - pushLog({ message: exitMsg, timestamp: parsed.timestamp, tone: "error" }); - setExitedContainers((prev) => { - const key = p.container_id ?? `${p.container_name ?? "container"}-${p.finished_at ?? ""}`; - const next = prev.filter((item) => { - const itemKey = item.container_id ?? `${item.container_name ?? "container"}-${item.finished_at ?? ""}`; - return itemKey !== key; - }); - return [...next, p]; - }); - } else if (parsed.event_name === "project.runtime_stopped") { - setTimelineStatus("metrics", "done", "All containers exited"); - setStackRunning(false); - setDeployPhase("exited"); - clearDeployTimeout(); - pushLog({ message: "All containers have exited.", timestamp: parsed.timestamp, tone: "info" }); - pushNotification("warning", "Runtime Stopped", "All containers have exited"); - } else if (parsed.event_name === "container.stopped") { - setTimelineStatus("metrics", "done", "Stack stopped"); - setStackRunning(false); - setDeployPhase("idle"); - clearDeployTimeout(); - setContainerIds([]); - setDeployJobId(null); - if (stateKey) clearState(stateKey); - toast.success("Compose stack stopped"); - pushNotification("warning", "Containers Stopped", "All containers have been stopped"); - } else if (parsed.event_name === "deploy.cleanup_started") { - const projectName = String( - (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", - ); - setStackRunning(false); - setContainerIds([]); - pushLog({ - message: `Cleanup started for ${projectName}`, - timestamp: parsed.timestamp, - tone: "info", - }); - toast.message("Stopping and removing stack resources..."); - pushNotification("info", "Cleanup Started", "Removing containers created by the failed deploy"); - } else if (parsed.event_name === "deploy.cleanup_completed") { - const projectName = String( - (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", - ); - setStackRunning(false); - setContainerIds([]); - setDeployJobId(null); - if (stateKey) clearState(stateKey); - pushLog({ - message: `Cleanup completed for ${projectName}`, - timestamp: parsed.timestamp, - tone: "success", - }); - toast.success("Failed deploy containers stopped and removed"); - pushNotification("success", "Cleanup Completed", "Failed deploy containers were removed from the sandbox"); - } else if (parsed.event_name === "user.analysis.failed") { - setTimelineStatus( - "start", - "error", - String( - (parsed.payload as { message?: string })?.message ?? "failed", - ), - ); - setStackRunning(false); - setDeployPhase("failed"); - clearDeployTimeout(); - toast.error("Deploy workflow reported a failure"); - } - } catch { - pushLog({ message: String(event.data), tone: "info" }); - } - }; - socket.onerror = () => - pushLog({ message: "Event stream error", tone: "error" }); - socket.onclose = () => - pushLog({ message: "Event stream closed", tone: "warning" }); + connectJobSocket(job.id, true); deployTimeoutRef.current = setTimeout(() => { setDeployPhase((current) => { @@ -486,6 +676,7 @@ export function ContainerExecution() { const handleStop = async () => { if (!job || stopping) return; + const previousContainerIds = containerIds; setStopping(true); setStackRunning(false); setContainerIds([]); @@ -502,10 +693,13 @@ export function ContainerExecution() { pushNotification("info", "Stopping Containers", "Stop signal sent, waiting for containers to shut down..."); for (let i = 0; i < 30; i++) { + if (unmountedRef.current) break; await new Promise((r) => setTimeout(r, 2000)); + if (unmountedRef.current) break; try { const status = await composeApi.deployStatus(job.id); if (!status.active) { + if (unmountedRef.current) break; setDeployJobId(null); if (stateKey) clearState(stateKey); sessionStorage.removeItem("dqa:containerStatus"); @@ -521,9 +715,14 @@ export function ContainerExecution() { const message = err instanceof ApiError ? err.message : "Failed to stop deployment"; pushLog({ message, tone: "error" }); toast.error(message); - setStackRunning(true); + if (!unmountedRef.current) { + setStackRunning(true); + setContainerIds(previousContainerIds); + } } finally { - setStopping(false); + if (!unmountedRef.current) { + setStopping(false); + } } }; @@ -573,9 +772,17 @@ export function ContainerExecution() { Deploy Compose Stack {job && ( -

- Job: {job.id} ({job.type}) -

+ <> +

+ Job: {job.id} ({job.type}) +

+

+ Compose used for deploy:{" "} + + {deployComposeFile ?? "-"} + +

+ )}
@@ -583,6 +790,7 @@ export function ContainerExecution() { onClick={handleDeploy} disabled={ !job || + resubmitRequired || deployPhase === "deploying" || deployPhase === "running" || stopping || @@ -594,6 +802,8 @@ export function ContainerExecution() { ? "Stack is already running. Stop it first." : deployPhase === "deploying" ? "Deploy in progress..." + : resubmitRequired + ? "Problematic stack: fix and resubmit from Results." : deployPhase === "failed" ? "Previous deploy failed. Click to retry." : deployPhase === "exited" @@ -610,6 +820,8 @@ export function ContainerExecution() { )} {stopping ? "Stopping..." + : resubmitRequired + ? "Resubmit Required" : deployPhase === "running" ? "Running" : deployPhase === "deploying" @@ -644,6 +856,31 @@ export function ContainerExecution() {
+ {problematicAlert.open && ( + +
+ +
+

+ Problematic Stack Detected +

+

+ {problematicAlert.message} +

+

+ You need to fix the stack and resubmit. Analysis results remain available. +

+
+ +
+
+ )} +

Timeline @@ -652,7 +889,7 @@ export function ContainerExecution() { {timeline.map((entry) => ( {entry.id === "start" && - (deployPhase === "deploying" || (composeProgress && composeProgress.started < composeProgress.total)) && ( + deployPhase === "deploying" && (
docker-compose up progress diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index fc8caae..fbb9b38 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -60,8 +60,6 @@ function statusColor(status: Job["status"]) { return "bg-slate-700/40 text-slate-300 border-slate-600"; case "failed": return "bg-red-500/20 text-red-300 border-red-500/30"; - case "scanned": - return "bg-purple-500/20 text-purple-300 border-purple-500/30"; } } @@ -205,11 +203,6 @@ export function History() { }, [historyItems]); const openJob = (item: HistoryItem) => { - if (item.status === "scanned") { - // Scanned-only project jobs — route back to project upload to resume review - navigate(`/project-upload?resume=${item.id}`); - return; - } if (item.status === "done" || item.status === "failed") { navigate(`/results?jobId=${item.id}`); } else { @@ -367,7 +360,7 @@ export function History() { {item.jobType} - {item.status === "scanned" ? "Scanned — not analyzed" : item.status} + {item.status} {runningJobs.has(item.id) && ( diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index 0e3a227..336aee8 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -1,15 +1,19 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Activity, + AlertTriangle, ArrowLeft, Cpu, Database, + ExternalLink, + Globe, HardDrive, MemoryStick, Network, PlugZap, + RefreshCw, Server, XCircle, } from "lucide-react"; @@ -69,6 +73,17 @@ interface MonitoringPersistedState { selectedContainerId: string | null; } +interface ProblematicAlertState { + open: boolean; + message: string; +} + +interface PreviewTarget { + key: string; + label: string; + url: string; +} + const MAX_POINTS = 120; const MONITORING_STATE_TTL_MS = 1000 * 60 * 60 * 6; @@ -85,6 +100,19 @@ function formatBytes(bytes: number | null | undefined): string { return `${value.toFixed(2)} ${units[unit]}`; } +function normalizeContainerPort(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const [port] = trimmed.split("/"); + return port && /^\d+$/.test(port) ? port : null; +} + +function isLoopbackBinding(hostIp: string): boolean { + const normalized = hostIp.trim(); + return normalized === "0.0.0.0" || normalized === "::" || normalized === ":::" || normalized === ""; +} + export function Monitoring() { const navigate = useNavigate(); const reducedMotion = useReducedMotion(); @@ -103,6 +131,13 @@ export function Monitoring() { const [selectedContainerId, setSelectedContainerId] = useState( routeContainerId ?? persisted?.selectedContainerId ?? null, ); + const [problematicAlert, setProblematicAlert] = useState({ + open: false, + message: "", + }); + const [dindIp, setDindIp] = useState(null); + const [manualPreviewUrl, setManualPreviewUrl] = useState(null); + const [previewReloadTick, setPreviewReloadTick] = useState(0); const [containerStates, setContainerStates] = useState>(() => { const initial: Record = {}; if (persisted?.containerStates) { @@ -113,6 +148,30 @@ export function Monitoring() { return initial; }); const socketsRef = useRef>(new Map()); + const cleanupAttemptedRef = useRef(false); + + const setResubmitRequired = useCallback(() => { + if (!jobId) return; + sessionStorage.setItem(`dqa:resubmitRequired:${jobId}`, "1"); + }, [jobId]); + + const markProblematicStack = useCallback( + (message: string, triggerCleanup: boolean) => { + setResubmitRequired(); + setProblematicAlert({ open: true, message }); + sessionStorage.removeItem("dqa:containerStatus"); + + if (triggerCleanup && jobId && !cleanupAttemptedRef.current) { + cleanupAttemptedRef.current = true; + void composeApi + .stopDeploy({ job_id: jobId, remove_volumes: true }) + .catch(() => { + toast.error("Failed to trigger cleanup for problematic stack."); + }); + } + }, + [jobId, setResubmitRequired], + ); useEffect(() => { if (!jobId) return; @@ -123,6 +182,13 @@ export function Monitoring() { if (cancelled) return; const rs = status.runtime_state ?? "none"; + const problematicByRuntimeState = + rs === "failed" || + rs === "cleanup_completed" || + rs === "exited" || + rs === "partial" || + rs === "unhealthy"; + const isProblematic = problematicByRuntimeState && !status.stopped_by_user; const isTerminal = ["none", "stopped_by_user", "exited", "failed", "cleanup_completed"].includes(rs); const fromIds = @@ -186,6 +252,13 @@ export function Monitoring() { return; } + if (isProblematic) { + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit.", + true, + ); + } + const fromDeploy = status.containers ?? []; if (fromDeploy.length === 0) return; @@ -233,10 +306,27 @@ export function Monitoring() { return () => { cancelled = true; }; - }, [jobId, routeContainerId]); + }, [jobId, routeContainerId, markProblematicStack]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const resolved = await composeApi.dindIp(); + if (!cancelled) { + setDindIp(typeof resolved.dind_ip === "string" && resolved.dind_ip.trim() ? resolved.dind_ip : null); + } + } catch { + if (!cancelled) setDindIp(null); + } + })(); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { - if (!user?.id || containerIds.length === 0) return; + if (!user?.id || containerIds.length === 0 || problematicAlert.open) return; const existingSockets = socketsRef.current; for (const cid of containerIds) { @@ -341,7 +431,7 @@ export function Monitoring() { } existingSockets.clear(); }; - }, [user?.id, containerIds]); + }, [user?.id, containerIds, problematicAlert.open]); useEffect(() => { if (!stateKey) return; @@ -380,6 +470,59 @@ export function Monitoring() { })); }, [latestPayload]); + const previewTargets = useMemo(() => { + if (!container) return []; + const candidates: PreviewTarget[] = []; + const seen = new Set(); + const protocol = "http://"; + const hostname = + typeof window !== "undefined" && window.location.hostname + ? window.location.hostname + : "localhost"; + + for (const portInfo of container.ports ?? []) { + const containerPort = normalizeContainerPort(portInfo.container_port); + if (!containerPort) continue; + + for (const binding of portInfo.host_bindings ?? []) { + const rawHostIp = String(binding.host_ip ?? "").trim(); + const hostPort = String(binding.host_port ?? "").trim(); + if (!hostPort) continue; + const host = isLoopbackBinding(rawHostIp) ? dindIp || hostname : rawHostIp; + const url = `${protocol}${host}:${hostPort}`; + if (seen.has(url)) continue; + seen.add(url); + candidates.push({ + key: `host-${host}-${hostPort}-${containerPort}`, + label: `${host}:${hostPort} -> ${containerPort}`, + url, + }); + } + + if (container.ip_address) { + const ipUrl = `${protocol}${container.ip_address}:${containerPort}`; + if (!seen.has(ipUrl)) { + seen.add(ipUrl); + candidates.push({ + key: `container-ip-${container.ip_address}-${containerPort}`, + label: `container ${container.ip_address}:${containerPort}`, + url: ipUrl, + }); + } + } + } + + return candidates; + }, [container, dindIp]); + + const selectedPreviewUrl = useMemo(() => { + if (previewTargets.length === 0) return ""; + if (manualPreviewUrl && previewTargets.some((t) => t.url === manualPreviewUrl)) { + return manualPreviewUrl; + } + return previewTargets[0].url; + }, [previewTargets, manualPreviewUrl]); + if (!user?.id) { return ( @@ -406,28 +549,62 @@ export function Monitoring() { Monitoring

- + + + DinD: {dindIp ?? "unresolved"} + + + {problematicAlert.open || exitedState ? : } + {problematicAlert.open + ? "Problematic" + : exitedState + ? `Exited (${exitedState.exit_code ?? "?"})` : isUnhealthyLive - ? "border-amber-500/40 bg-amber-500/10 text-amber-300" + ? "Unhealthy" : connected - ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" - : "border-slate-700 bg-slate-900 text-slate-400" - }`} - > - {exitedState ? : } - {exitedState - ? `Exited (${exitedState.exit_code ?? "?"})` - : isUnhealthyLive - ? "Unhealthy" - : connected - ? "Live" - : "Disconnected"} - + ? "Live" + : "Disconnected"} + + + {problematicAlert.open && ( + +
+ +
+

+ Problematic Stack Detected +

+

{problematicAlert.message}

+

+ Please fix your stack and resubmit. Analysis remains available in Results. +

+
+ +
+
+ )} + {containerIds.length > 1 && (
{containerIds.map((cid) => { @@ -708,6 +885,70 @@ export function Monitoring() {
+ +
+

+ + Live App Preview +

+
+ {previewTargets.length > 0 && ( + + )} + + +
+
+ {selectedPreviewUrl ? ( +
+
+ {selectedPreviewUrl} +
+