From d3baa9c032a6cd4941d485887c247be317bec0e5 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 14:09:16 +0200 Subject: [PATCH] feat(tests): remove Time Log, move Tempo sync to Phase Timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove WorklogTimeline (manual time log) from test detail page - TestPhaseTimeline now accepts testId, fetches its own worklogs, and shows Tempo sync status on the Red Team Execution row: • green badge if already synced (with worklog ID tooltip) • 'Sync to Tempo' button (blue) if not yet synced - Add POST /tests/{id}/sync-tempo backend endpoint for manual sync: finds unsynced red_team_execution worklogs and pushes them to Tempo Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/tests.py | 75 +++++++++ frontend/src/api/tests.ts | 18 +++ frontend/src/api/worklogs.ts | 5 + frontend/src/components/TestPhaseTimeline.tsx | 142 ++++++++++++++---- frontend/src/pages/TestDetailPage.tsx | 8 +- 5 files changed, 214 insertions(+), 34 deletions(-) diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 41f67f1..98c1f63 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -649,3 +649,78 @@ def get_retest_chain( } for t in chain ] + + +# --------------------------------------------------------------------------- +# POST /tests/{id}/sync-tempo — manual Tempo sync for red execution worklog +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/sync-tempo") +def sync_tempo( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Manually sync this test's red team execution worklog(s) to Tempo. + + Useful when the automatic sync failed at phase completion (e.g. Tempo + was not yet configured). Only red_team_execution worklogs are eligible. + Already-synced worklogs are skipped. Returns a summary of what happened. + """ + from datetime import datetime as _dt + from app.models.worklog import Worklog + from app.services.tempo_service import auto_log_test_worklog + from app.services.test_crud_service import get_test_or_raise as _get + + test = _get(db, test_id) + + worklogs = ( + db.query(Worklog) + .filter( + Worklog.entity_type == "test", + Worklog.entity_id == test_id, + Worklog.activity_type == "red_team_execution", + ) + .all() + ) + + if not worklogs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No red team execution worklog found for this test.", + ) + + results = [] + for wl in worklogs: + if wl.tempo_synced: + results.append({"worklog_id": str(wl.id), "status": "already_synced"}) + continue + + try: + result = auto_log_test_worklog( + db=db, + test=test, + user=current_user, + activity_type=wl.activity_type, + duration_seconds=wl.duration_seconds, + ) + if result and isinstance(result, dict): + wl.tempo_synced = _dt.utcnow() + wl.tempo_worklog_id = str(result.get("tempoWorklogId", "")) + db.commit() + results.append({"worklog_id": str(wl.id), "status": "synced"}) + else: + results.append({ + "worklog_id": str(wl.id), + "status": "skipped", + "detail": "Tempo not configured or conditions not met.", + }) + except Exception as exc: + results.append({ + "worklog_id": str(wl.id), + "status": "error", + "detail": str(exc), + }) + + return {"results": results} diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 39c0710..0463cd3 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -263,3 +263,21 @@ export async function rejectTest(testId: string): Promise { const { data } = await client.post(`/tests/${testId}/reject`); return data; } + +// ── Tempo sync ───────────────────────────────────────────────────── + +export interface TempoSyncResult { + worklog_id: string; + status: "synced" | "already_synced" | "skipped" | "error"; + detail?: string; +} + +/** Manually push this test's red team execution worklog to Tempo. */ +export async function syncTestToTempo( + testId: string, +): Promise<{ results: TempoSyncResult[] }> { + const { data } = await client.post<{ results: TempoSyncResult[] }>( + `/tests/${testId}/sync-tempo`, + ); + return data; +} diff --git a/frontend/src/api/worklogs.ts b/frontend/src/api/worklogs.ts index 50c5337..164dbd3 100644 --- a/frontend/src/api/worklogs.ts +++ b/frontend/src/api/worklogs.ts @@ -53,6 +53,11 @@ export async function getWorklog(worklogId: string): Promise { return data; } +/** List worklogs for a specific test (shorthand). */ +export async function listTestWorklogs(testId: string): Promise { + return listWorklogs({ entity_type: "test", entity_id: testId }); +} + /** Verify a worklog's integrity hash. */ export async function verifyWorklogIntegrity( worklogId: string, diff --git a/frontend/src/components/TestPhaseTimeline.tsx b/frontend/src/components/TestPhaseTimeline.tsx index 983f1ea..561a039 100644 --- a/frontend/src/components/TestPhaseTimeline.tsx +++ b/frontend/src/components/TestPhaseTimeline.tsx @@ -1,33 +1,36 @@ /** * TestPhaseTimeline * - * Read-only timeline showing the automated phase durations for a test: + * Read-only timeline of automated phase durations, with Tempo sync status and + * a manual "Sync to Tempo" button for the Red Team Execution phase. + * * 1. Red Team Execution (red_started_at → blue_started_at, minus paused) * 2. Blue Queue (blue_started_at → blue_work_started_at) - * 3. Blue Evaluation (blue_work_started_at → …, open-ended until validated) + * 3. Blue Evaluation (blue_work_started_at → …) * 4. Red Lead Validation (red_validated_at + status) * 5. Blue Lead Validation (blue_validated_at + status) - * - * Only phases with at least a start timestamp are rendered. */ -import { Clock, Sword, Shield, CheckCircle, XCircle, Timer } from "lucide-react"; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Clock, Sword, Shield, CheckCircle, XCircle, Timer, RefreshCw, Loader2, +} from "lucide-react"; import type { Test } from "../types/models"; +import { listTestWorklogs, type Worklog } from "../api/worklogs"; +import { syncTestToTempo } from "../api/tests"; // ── Helpers ────────────────────────────────────────────────────────── -/** Parse a backend datetime string (may lack 'Z') to a JS Date. */ function parseDate(s: string): Date { return new Date(s.endsWith("Z") ? s : s + "Z"); } -/** Compute duration in seconds between two timestamps, minus any paused seconds. */ function durationSeconds(start: string, end: string, pausedSecs = 0): number { const ms = parseDate(end).getTime() - parseDate(start).getTime(); return Math.max(0, Math.floor(ms / 1000) - pausedSecs); } -/** Human-readable duration string. */ function fmtDuration(secs: number): string { if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m`; @@ -36,7 +39,6 @@ function fmtDuration(secs: number): string { return m > 0 ? `${h}h ${m}m` : `${h}h`; } -/** Short date + time label. */ function fmtTs(s: string): string { return parseDate(s).toLocaleDateString("en-US", { month: "short", @@ -54,25 +56,21 @@ interface PhaseRowProps { label: string; badge?: React.ReactNode; startTs: string | null; - duration?: number | null; // seconds; null → still running / unknown + duration?: number | null; isLast?: boolean; + extra?: React.ReactNode; } -function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: PhaseRowProps) { +function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast, extra }: PhaseRowProps) { return (
- {/* Connector line */} {!isLast && (
)} - - {/* Dot */}
- - {/* Content */}
{icon} @@ -87,6 +85,7 @@ function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: P {startTs && (

{fmtTs(startTs)}

)} + {extra &&
{extra}
}
); @@ -115,13 +114,88 @@ function ValidationBadge({ status }: { status: string | null }) { ); } +// ── Tempo sync badge / button ───────────────────────────────────────── + +function TempoSyncBadge({ + worklog, + testId, + onSynced, +}: { + worklog: Worklog | undefined; + testId: string; + onSynced: () => void; +}) { + const [toast, setToast] = useState(null); + + const mutation = useMutation({ + mutationFn: () => syncTestToTempo(testId), + onSuccess: (data) => { + const r = data.results[0]; + if (r?.status === "synced") { + setToast("Synced to Tempo ✓"); + onSynced(); + } else if (r?.status === "already_synced") { + setToast("Already synced"); + } else { + setToast(r?.detail ?? "Skipped — check Tempo settings"); + } + setTimeout(() => setToast(null), 4000); + }, + onError: (e: unknown) => { + const msg = + e && typeof e === "object" && "response" in e + ? ((e as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Sync failed") + : "Sync failed"; + setToast(msg); + setTimeout(() => setToast(null), 5000); + }, + }); + + // Already synced + if (worklog?.tempo_synced) { + return ( + + Tempo + + ); + } + + // Not synced — show button + return ( +
+ + {toast && ( + {toast} + )} +
+ ); +} + // ── Main component ──────────────────────────────────────────────────── interface TestPhaseTimelineProps { test: Test; + testId?: string; } -export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) { +export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelineProps) { + const queryClient = useQueryClient(); + const { red_started_at, blue_started_at, @@ -134,6 +208,19 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) { blue_validation_status, } = test; + // Fetch worklogs when testId is provided + const { data: worklogs = [] } = useQuery({ + queryKey: ["worklogs", "test", testId], + queryFn: () => listTestWorklogs(testId!), + enabled: !!testId, + }); + + const redWorklog = worklogs.find((w) => w.activity_type === "red_team_execution"); + + const refreshWorklogs = () => { + if (testId) queryClient.invalidateQueries({ queryKey: ["worklogs", "test", testId] }); + }; + // Compute per-phase durations const redExecSecs = red_started_at && blue_started_at @@ -145,14 +232,12 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) { ? durationSeconds(blue_started_at, blue_work_started_at) : null; - // Blue evaluation: blue_work_started_at → first validation timestamp const blueEvalEnd = red_validated_at || blue_validated_at || null; const blueEvalSecs = blue_work_started_at && blueEvalEnd ? durationSeconds(blue_work_started_at, blueEvalEnd, blue_paused_seconds) : null; - // Determine which phases to show (need at least a start timestamp) const hasRedExec = !!red_started_at; const hasBlueQueue = !!blue_started_at; const hasBlueEval = !!blue_work_started_at; @@ -164,16 +249,8 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) { if (!anyPhase) return null; - // Count rendered phases for isLast detection - const phases = [ - hasRedExec, - hasBlueQueue, - hasBlueEval, - hasRedValidation, - hasBlueValidation, - ]; + const phases = [hasRedExec, hasBlueQueue, hasBlueEval, hasRedValidation, hasBlueValidation]; const lastIdx = phases.lastIndexOf(true); - let phaseIdx = -1; return ( @@ -192,6 +269,15 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) { startTs={red_started_at} duration={redExecSecs} isLast={++phaseIdx === lastIdx} + extra={ + testId ? ( + + ) : undefined + } /> )} diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index 1876d94..5be6620 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -28,7 +28,6 @@ import TeamTabs from "../components/test-detail/TeamTabs"; import ValidationModal from "../components/test-detail/ValidationModal"; import ConfirmDialog from "../components/ConfirmDialog"; import JiraLinkPanel from "../components/JiraLinkPanel"; -import WorklogTimeline from "../components/WorklogTimeline"; import TestPhaseTimeline from "../components/TestPhaseTimeline"; // ── Page Component ───────────────────────────────────────────────── @@ -541,11 +540,8 @@ export default function TestDetailPage() { {/* Jira Integration */} - {/* Phase Timeline (read-only, derived from test timestamps) */} - - - {/* Time Tracking (manual worklogs) */} - + {/* Phase Timeline (read-only, with Tempo sync) */} +