feat(tests): remove Time Log, move Tempo sync to Phase Timeline
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -649,3 +649,78 @@ def get_retest_chain(
|
|||||||
}
|
}
|
||||||
for t in 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}
|
||||||
|
|||||||
@@ -263,3 +263,21 @@ export async function rejectTest(testId: string): Promise<Test> {
|
|||||||
const { data } = await client.post<Test>(`/tests/${testId}/reject`);
|
const { data } = await client.post<Test>(`/tests/${testId}/reject`);
|
||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export async function getWorklog(worklogId: string): Promise<Worklog> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** List worklogs for a specific test (shorthand). */
|
||||||
|
export async function listTestWorklogs(testId: string): Promise<Worklog[]> {
|
||||||
|
return listWorklogs({ entity_type: "test", entity_id: testId });
|
||||||
|
}
|
||||||
|
|
||||||
/** Verify a worklog's integrity hash. */
|
/** Verify a worklog's integrity hash. */
|
||||||
export async function verifyWorklogIntegrity(
|
export async function verifyWorklogIntegrity(
|
||||||
worklogId: string,
|
worklogId: string,
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* TestPhaseTimeline
|
* 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)
|
* 1. Red Team Execution (red_started_at → blue_started_at, minus paused)
|
||||||
* 2. Blue Queue (blue_started_at → blue_work_started_at)
|
* 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)
|
* 4. Red Lead Validation (red_validated_at + status)
|
||||||
* 5. Blue Lead Validation (blue_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 type { Test } from "../types/models";
|
||||||
|
import { listTestWorklogs, type Worklog } from "../api/worklogs";
|
||||||
|
import { syncTestToTempo } from "../api/tests";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Parse a backend datetime string (may lack 'Z') to a JS Date. */
|
|
||||||
function parseDate(s: string): Date {
|
function parseDate(s: string): Date {
|
||||||
return new Date(s.endsWith("Z") ? s : s + "Z");
|
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 {
|
function durationSeconds(start: string, end: string, pausedSecs = 0): number {
|
||||||
const ms = parseDate(end).getTime() - parseDate(start).getTime();
|
const ms = parseDate(end).getTime() - parseDate(start).getTime();
|
||||||
return Math.max(0, Math.floor(ms / 1000) - pausedSecs);
|
return Math.max(0, Math.floor(ms / 1000) - pausedSecs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable duration string. */
|
|
||||||
function fmtDuration(secs: number): string {
|
function fmtDuration(secs: number): string {
|
||||||
if (secs < 60) return `${secs}s`;
|
if (secs < 60) return `${secs}s`;
|
||||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
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`;
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short date + time label. */
|
|
||||||
function fmtTs(s: string): string {
|
function fmtTs(s: string): string {
|
||||||
return parseDate(s).toLocaleDateString("en-US", {
|
return parseDate(s).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -54,25 +56,21 @@ interface PhaseRowProps {
|
|||||||
label: string;
|
label: string;
|
||||||
badge?: React.ReactNode;
|
badge?: React.ReactNode;
|
||||||
startTs: string | null;
|
startTs: string | null;
|
||||||
duration?: number | null; // seconds; null → still running / unknown
|
duration?: number | null;
|
||||||
isLast?: boolean;
|
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 (
|
return (
|
||||||
<div className="relative flex gap-3 py-2">
|
<div className="relative flex gap-3 py-2">
|
||||||
{/* Connector line */}
|
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div className="absolute left-[15px] top-[18px] bottom-0 w-px bg-gray-700/60" />
|
<div className="absolute left-[15px] top-[18px] bottom-0 w-px bg-gray-700/60" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dot */}
|
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${dotClass}`}
|
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${dotClass}`}
|
||||||
style={{ marginLeft: "6px" }}
|
style={{ marginLeft: "6px" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0 pb-1">
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-gray-400">{icon}</span>
|
<span className="text-xs text-gray-400">{icon}</span>
|
||||||
@@ -87,6 +85,7 @@ function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: P
|
|||||||
{startTs && (
|
{startTs && (
|
||||||
<p className="mt-0.5 text-xs text-gray-500">{fmtTs(startTs)}</p>
|
<p className="mt-0.5 text-xs text-gray-500">{fmtTs(startTs)}</p>
|
||||||
)}
|
)}
|
||||||
|
{extra && <div className="mt-1.5">{extra}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-green-900/30 text-green-400"
|
||||||
|
title={`Synced to Tempo${worklog.tempo_worklog_id ? ` (ID: ${worklog.tempo_worklog_id})` : ""}`}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-3 w-3" /> Tempo
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not synced — show button
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs border border-blue-500/30 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-50 transition-colors"
|
||||||
|
title="Push this phase's time to Tempo"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Sync to Tempo
|
||||||
|
</button>
|
||||||
|
{toast && (
|
||||||
|
<span className="text-xs text-gray-400">{toast}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TestPhaseTimelineProps {
|
interface TestPhaseTimelineProps {
|
||||||
test: Test;
|
test: Test;
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
|
export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelineProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
red_started_at,
|
red_started_at,
|
||||||
blue_started_at,
|
blue_started_at,
|
||||||
@@ -134,6 +208,19 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
|
|||||||
blue_validation_status,
|
blue_validation_status,
|
||||||
} = test;
|
} = 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
|
// Compute per-phase durations
|
||||||
const redExecSecs =
|
const redExecSecs =
|
||||||
red_started_at && blue_started_at
|
red_started_at && blue_started_at
|
||||||
@@ -145,14 +232,12 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
|
|||||||
? durationSeconds(blue_started_at, blue_work_started_at)
|
? durationSeconds(blue_started_at, blue_work_started_at)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Blue evaluation: blue_work_started_at → first validation timestamp
|
|
||||||
const blueEvalEnd = red_validated_at || blue_validated_at || null;
|
const blueEvalEnd = red_validated_at || blue_validated_at || null;
|
||||||
const blueEvalSecs =
|
const blueEvalSecs =
|
||||||
blue_work_started_at && blueEvalEnd
|
blue_work_started_at && blueEvalEnd
|
||||||
? durationSeconds(blue_work_started_at, blueEvalEnd, blue_paused_seconds)
|
? durationSeconds(blue_work_started_at, blueEvalEnd, blue_paused_seconds)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Determine which phases to show (need at least a start timestamp)
|
|
||||||
const hasRedExec = !!red_started_at;
|
const hasRedExec = !!red_started_at;
|
||||||
const hasBlueQueue = !!blue_started_at;
|
const hasBlueQueue = !!blue_started_at;
|
||||||
const hasBlueEval = !!blue_work_started_at;
|
const hasBlueEval = !!blue_work_started_at;
|
||||||
@@ -164,16 +249,8 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
|
|||||||
|
|
||||||
if (!anyPhase) return null;
|
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);
|
const lastIdx = phases.lastIndexOf(true);
|
||||||
|
|
||||||
let phaseIdx = -1;
|
let phaseIdx = -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,6 +269,15 @@ export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
|
|||||||
startTs={red_started_at}
|
startTs={red_started_at}
|
||||||
duration={redExecSecs}
|
duration={redExecSecs}
|
||||||
isLast={++phaseIdx === lastIdx}
|
isLast={++phaseIdx === lastIdx}
|
||||||
|
extra={
|
||||||
|
testId ? (
|
||||||
|
<TempoSyncBadge
|
||||||
|
worklog={redWorklog}
|
||||||
|
testId={testId}
|
||||||
|
onSynced={refreshWorklogs}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import TeamTabs from "../components/test-detail/TeamTabs";
|
|||||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import WorklogTimeline from "../components/WorklogTimeline";
|
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
|
|
||||||
// ── Page Component ─────────────────────────────────────────────────
|
// ── Page Component ─────────────────────────────────────────────────
|
||||||
@@ -541,11 +540,8 @@ export default function TestDetailPage() {
|
|||||||
{/* Jira Integration */}
|
{/* Jira Integration */}
|
||||||
<JiraLinkPanel entityType="test" entityId={testId!} />
|
<JiraLinkPanel entityType="test" entityId={testId!} />
|
||||||
|
|
||||||
{/* Phase Timeline (read-only, derived from test timestamps) */}
|
{/* Phase Timeline (read-only, with Tempo sync) */}
|
||||||
<TestPhaseTimeline test={test} />
|
<TestPhaseTimeline test={test} testId={testId} />
|
||||||
|
|
||||||
{/* Time Tracking (manual worklogs) */}
|
|
||||||
<WorklogTimeline entityType="test" entityId={testId!} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user