feat(tests): remove Time Log, move Tempo sync to Phase Timeline
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:
kitos
2026-05-28 14:09:16 +02:00
parent 986e91a88a
commit d3baa9c032
5 changed files with 214 additions and 34 deletions

View File

@@ -263,3 +263,21 @@ export async function rejectTest(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/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;
}

View File

@@ -53,6 +53,11 @@ export async function getWorklog(worklogId: string): Promise<Worklog> {
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. */
export async function verifyWorklogIntegrity(
worklogId: string,

View File

@@ -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 (
<div className="relative flex gap-3 py-2">
{/* Connector line */}
{!isLast && (
<div className="absolute left-[15px] top-[18px] bottom-0 w-px bg-gray-700/60" />
)}
{/* Dot */}
<div
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${dotClass}`}
style={{ marginLeft: "6px" }}
/>
{/* Content */}
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400">{icon}</span>
@@ -87,6 +85,7 @@ function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: P
{startTs && (
<p className="mt-0.5 text-xs text-gray-500">{fmtTs(startTs)}</p>
)}
{extra && <div className="mt-1.5">{extra}</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 ────────────────────────────────────────────────────
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 ? (
<TempoSyncBadge
worklog={redWorklog}
testId={testId}
onSynced={refreshWorklogs}
/>
) : undefined
}
/>
)}

View File

@@ -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 */}
<JiraLinkPanel entityType="test" entityId={testId!} />
{/* Phase Timeline (read-only, derived from test timestamps) */}
<TestPhaseTimeline test={test} />
{/* Time Tracking (manual worklogs) */}
<WorklogTimeline entityType="test" entityId={testId!} />
{/* Phase Timeline (read-only, with Tempo sync) */}
<TestPhaseTimeline test={test} testId={testId} />
</div>
</div>