Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .assistant/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
---

## Recent Progress
- **P-033 UI:** Added Backfill tab to run one-off backfill via `/api/backfill/run` (ephemeral container, run-once).
- **P-033 kickoff:** Added one-off backfill flow backend — new `/api/backfill/run` endpoint launches ephemeral backfill container (no main config mutation); loader supports `BACKFILL_RUN_ONCE` and idles after run to avoid restart loops.
- **Startup Control:** Added `AUTO_START`/`START_SIGNAL_FILE` gating so webui deployments keep the generator idle until Start is triggered; Control UI start/restart now writes the start signal; loader waits for the flag; tests added.
- **P-032 Bug Fix:** Added `format_cdt()` to send Matomo `cdt` in UTC; tests confirm CET→UTC conversion.
- **P-032 Complete:** End-to-end backfill (validation, UI, loader caps/seed/RPS, docs, pytest coverage); merged `develop`→`main` via PR #11.
Expand Down
35 changes: 35 additions & 0 deletions .assistant/task_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,38 @@
- args: none
- result: Tests passed (20 passed).
- artifacts: none

- tool: apply_patch (control-ui/app.py; control-ui/container_manager.py; control-ui/models.py; matomo-load-baked/loader.py)
- args: Added one-off backfill support: `/api/backfill/run` launches an ephemeral backfill container using current env, validates input, and sets `BACKFILL_RUN_ONCE`; loader now idles after a one-shot run to avoid restart loops.
- result: Backfill can be triggered as a separate flow without mutating the main container config; loader respects `BACKFILL_RUN_ONCE`.
- artifacts: none

- tool: shell (python3 -m pytest matomo-load-baked/tests/test_backfill.py)
- args: none
- result: Tests passed (5 passed).
- artifacts: none

- tool: apply_patch (control-ui/static/index.html; control-ui/static/js/api.js; control-ui/static/js/app.js; control-ui/static/js/backfill.js)
- args: Added Backfill tab with one-off backfill form, wired to new `/api/backfill/run` endpoint via API helper and App controller; displays run results (container/id/message).
- result: Users can trigger one-off backfill runs from the UI without altering primary config.
- artifacts: control-ui/static/js/backfill.js

- tool: apply_patch (control-ui/app.py; control-ui/container_manager.py; control-ui/models.py; control-ui/static/js/api.js; control-ui/static/js/backfill.js; control-ui/static/index.html)
- args: Added backfill status/cleanup endpoints and UI: list labeled backfill runs, cleanup exited jobs, render runs table, and enforce frontend date window validation (<=180d, no future end, mode exclusivity).
- result: Backfill tab now shows run history and supports cleanup; backend lists/cleans backfill containers; client blocks invalid windows before calling the API.
- artifacts: control-ui/static/js/backfill.js

- tool: apply_patch (control-ui/app.py; control-ui/static/js/api.js; control-ui/static/js/backfill.js; control-ui/static/index.html; control-ui/models.py)
- args: Persist last backfill payload/result to disk, expose `/api/backfill/last`, and surface last-run info in the Backfill tab alongside status/history.
- result: UI now loads the most recent backfill record on tab activation; backend saves/serves last run metadata.
- artifacts: control-ui/static/js/backfill.js

- tool: apply_patch (control-ui/app.py; control-ui/container_manager.py; control-ui/models.py; control-ui/static/js/api.js; control-ui/static/js/backfill.js; control-ui/static/index.html)
- args: Added backfill cancel endpoint and UI action to stop running backfill containers; runs table now shows cancel buttons for running jobs.
- result: Users can stop a running backfill from the UI; backend stops labeled backfill containers safely.
- artifacts: control-ui/static/js/backfill.js

- tool: apply_patch (control-ui/static/index.html; control-ui/static/js/backfill.js)
- args: Added “Load last payload” button and auto-fill support using the last saved backfill payload from `/api/backfill/last`.
- result: Users can quickly rerun or tweak the previous backfill configuration without retyping.
- artifacts: control-ui/static/js/backfill.js
18 changes: 18 additions & 0 deletions WEB_UI_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ The Web UI consists of 5 main tabs:
```
Guardrails: window must end on/before today; start <= end; max 180 days; caps must be consistent (total ≥ per-day); warnings on very high per-day caps and RPS.

### Backfill Tab (One-off runs)

**Purpose:** Run a historical replay as an ephemeral job without changing the main config.

**Features:**
- Separate form from the main Config tab; calls `/api/backfill/run` to spawn a one-off container.
- Mode guardrails: absolute (start/end) or relative (days back + duration), not both; frontend enforces no future end and ≤180-day windows.
- Run controls: caps per day/total, RPS limit, seed, run name, “run once and idle” toggle.
- History & control: table of backfill runs with cancel for running jobs and cleanup for exited jobs.
- Last payload/result: loads the most recent backfill payload; “Load last payload” button pre-fills the form.

**Usage:**
1) Open Backfill tab. Pick absolute or relative window; fill caps/throttle/seed as needed.
- Matomo credentials: the backfill uses the current loadgen environment (MATOMO_URL/SITE_ID/MATOMO_TOKEN_AUTH) from the main container. Set the token in the Config tab (Matomo Token Auth) before running backfill; the Backfill tab does not ask for it separately.
2) Click **Run Backfill** (validation runs client-side; server validates too).
3) Monitor the runs table; use **Cancel** to stop a running job; use **Cleanup exited** to remove finished jobs.
4) Use **Load last payload** to quickly rerun or tweak the previous backfill.

**Field Reference:**

| Field | Description | Default | Range |
Expand Down
154 changes: 154 additions & 0 deletions control-ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
RestartResponse,
LogsResponse,
ApplyConfigResponse,
BackfillRunRequest,
BackfillRunResponse,
BackfillStatusResponse,
BackfillCleanupResponse,
BackfillLastResponse,
BackfillCancelResponse,
URLContentRequest,
PresetListResponse,
PresetDetail,
Expand Down Expand Up @@ -61,6 +67,7 @@
container_manager = ContainerManager(docker_client)
config_database = Database(os.getenv("CONFIG_DB_PATH"))
FUNNEL_CONFIG_PATH = Path(os.getenv("FUNNEL_CONFIG_PATH", "/app/data/funnels.json"))
BACKFILL_HISTORY_PATH = Path(os.getenv("BACKFILL_HISTORY_PATH", "/app/data/backfill_history.json"))


@asynccontextmanager
Expand Down Expand Up @@ -224,6 +231,153 @@ async def get_status(request: Request, authenticated: bool = Depends(verify_api_
)


@app.post("/api/backfill/run", response_model=BackfillRunResponse)
@limiter.limit("10/minute")
async def run_backfill(
request: Request,
backfill_request: BackfillRunRequest,
authenticated: bool = Depends(verify_api_key),
):
"""
Launch a one-off backfill run in an ephemeral container without mutating the main loadgen config.
"""
if not docker_client.is_connected():
raise HTTPException(status_code=503, detail="Docker daemon not connected")

current_env = container_manager.get_current_env()
if current_env is None:
return BackfillRunResponse(success=False, message="Primary container not found", error="container_not_found")

env = current_env.copy()
env.update({
"BACKFILL_ENABLED": "true",
"BACKFILL_RUN_ONCE": "true" if backfill_request.BACKFILL_RUN_ONCE else "false",
"AUTO_START": "true",
})

for field in [
"BACKFILL_START_DATE",
"BACKFILL_END_DATE",
"BACKFILL_DAYS_BACK",
"BACKFILL_DURATION_DAYS",
"BACKFILL_MAX_VISITS_PER_DAY",
"BACKFILL_MAX_VISITS_TOTAL",
"BACKFILL_RPS_LIMIT",
"BACKFILL_SEED",
]:
value = getattr(backfill_request, field)
if value is not None:
env[field] = str(value)

validator = ConfigValidator()
try:
validator.validate_config(env)
except Exception as e:
return BackfillRunResponse(success=False, message="Validation failed", error=str(e))

result = container_manager.spawn_backfill_job(env_vars=env, name=backfill_request.name)
if result.get("success"):
# Persist last run payload/result for UI reference
try:
BACKFILL_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
history = {
"payload": env,
"result": result,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
BACKFILL_HISTORY_PATH.write_text(json.dumps(history, indent=2), encoding="utf-8")
except Exception as e:
logger.warning(f"Failed to write backfill history: {e}")
return BackfillRunResponse(
success=True,
message="Backfill job started",
container_name=result.get("container_name"),
container_id=result.get("container_id"),
)
return BackfillRunResponse(
success=False,
message="Failed to start backfill job",
error=result.get("error"),
)


@app.get("/api/backfill/status", response_model=BackfillStatusResponse)
@limiter.limit("30/minute")
async def backfill_status(request: Request, authenticated: bool = Depends(verify_api_key)):
"""Return list of backfill runs (ephemeral containers)."""
if not docker_client.is_connected():
raise HTTPException(status_code=503, detail="Docker daemon not connected")

runs = container_manager.list_backfill_runs()
formatted = []
for r in runs:
formatted.append({
"container_name": r.get("name"),
"container_id": r.get("id"),
"state": r.get("status"),
"started_at": r.get("started_at"),
"finished_at": r.get("finished_at"),
"exit_code": r.get("exit_code"),
"error": None,
})

return BackfillStatusResponse(success=True, message="ok", runs=formatted)


@app.post("/api/backfill/cleanup", response_model=BackfillCleanupResponse)
@limiter.limit("10/minute")
async def backfill_cleanup(request: Request, authenticated: bool = Depends(verify_api_key)):
"""Remove exited backfill containers."""
if not docker_client.is_connected():
raise HTTPException(status_code=503, detail="Docker daemon not connected")

result = container_manager.cleanup_backfill_runs()
success = len(result.get("errors", [])) == 0
message = "Cleanup complete" if success else "Cleanup completed with errors"
return BackfillCleanupResponse(
success=success,
message=message,
removed=result.get("removed", []),
errors=result.get("errors", []),
)


@app.get("/api/backfill/last", response_model=BackfillLastResponse)
@limiter.limit("30/minute")
async def backfill_last(request: Request, authenticated: bool = Depends(verify_api_key)):
"""Return last backfill payload/result if available."""
if not BACKFILL_HISTORY_PATH.exists():
return BackfillLastResponse(success=True, message="No backfill history", payload=None, result=None, timestamp=None)
try:
data = json.loads(BACKFILL_HISTORY_PATH.read_text(encoding="utf-8"))
return BackfillLastResponse(
success=True,
message="ok",
payload=data.get("payload"),
result=data.get("result"),
timestamp=data.get("timestamp"),
)
except Exception as e:
return BackfillLastResponse(success=False, message="Failed to read backfill history", payload=None, result=None, timestamp=None)


@app.post("/api/backfill/cancel", response_model=BackfillCancelResponse)
@limiter.limit("10/minute")
async def backfill_cancel(
request: Request,
container_name: str = Body(..., embed=True),
authenticated: bool = Depends(verify_api_key),
):
"""Stop a running backfill container."""
if not docker_client.is_connected():
raise HTTPException(status_code=503, detail="Docker daemon not connected")

result = container_manager.cancel_backfill(container_name)
if result.get("success"):
return BackfillCancelResponse(success=True, message="Backfill container stopped")
return BackfillCancelResponse(success=False, message="Failed to stop backfill container", error=result.get("error"))


@app.post("/api/start", response_model=StartResponse)
@limiter.limit("10/minute")
async def start_container(
Expand Down
Loading