fix(tempo,jira,tests,ui): fix 4 pending issues
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- tempo: remove unsupported `workType` kwarg from create_worklog call;
  tempoapiclient v4 does not accept it → was causing every Tempo sync to fail
- tests: set created_at=datetime.utcnow() explicitly on test creation (both
  create_test and create_test_from_template) since the DB column has no
  server default, causing 'Created —' in the UI
- jira: remove duplicate Proof of Concept section from ticket description body;
  PoC already lives in customfield_10309, no need to repeat it in description
- ui: add TestPhaseTimeline component (read-only) showing RT execution time,
  blue queue time, blue evaluation time and lead validation timestamps derived
  from test phase timestamps; placed above WorklogTimeline in test detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-28 11:38:29 +02:00
parent 7111debd8f
commit 0955f35015
5 changed files with 261 additions and 16 deletions

View File

@@ -219,9 +219,6 @@ def _build_test_description(test: Test, technique: Optional[Technique]) -> str:
"h3. Description", "h3. Description",
test.description or "_No description provided._", test.description or "_No description provided._",
"", "",
"h3. Proof of Concept",
f"{{code}}{test.procedure_text or 'N/A'}{{code}}",
"",
f"*Tool:* {test.tool_used or 'N/A'}", f"*Tool:* {test.tool_used or 'N/A'}",
"", "",
"----", "----",

View File

@@ -69,21 +69,16 @@ def log_worklog(
date: str, date: str,
time_spent_seconds: int, time_spent_seconds: int,
description: str, description: str,
work_type: str | None = None,
) -> dict: ) -> dict:
"""Create a worklog entry in Tempo using *user*'s personal token.""" """Create a worklog entry in Tempo using *user*'s personal token."""
tempo = get_user_tempo_client(user) tempo = get_user_tempo_client(user)
kwargs: dict = { return tempo.create_worklog(
"accountId": author_account_id, accountId=author_account_id,
"issueId": jira_issue_id, issueId=jira_issue_id,
"dateFrom": date, dateFrom=date,
"timeSpentSeconds": time_spent_seconds, timeSpentSeconds=time_spent_seconds,
"description": description, description=description,
} )
wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE
if wt:
kwargs["workType"] = wt
return tempo.create_worklog(**kwargs)
def auto_log_test_worklog( def auto_log_test_worklog(

View File

@@ -5,6 +5,7 @@ The router is responsible for HTTP concerns, auth, audit logging, and commit.
""" """
import uuid import uuid
from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -78,6 +79,7 @@ def create_test(
technique_id=technique_id, technique_id=technique_id,
created_by=creator_id, created_by=creator_id,
state=TestState.draft, state=TestState.draft,
created_at=datetime.utcnow(), # explicit — DB column has no server default
**fields, **fields,
) )
db.add(test) db.add(test)
@@ -127,6 +129,7 @@ def create_test_from_template(
remediation_steps=template.suggested_remediation, remediation_steps=template.suggested_remediation,
created_by=creator_id, created_by=creator_id,
state=TestState.draft, state=TestState.draft,
created_at=datetime.utcnow(), # explicit — DB column has no server default
) )
db.add(test) db.add(test)
db.flush() db.flush()

View File

@@ -0,0 +1,246 @@
/**
* TestPhaseTimeline
*
* Read-only timeline showing the automated phase durations for a test:
* 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)
* 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 type { Test } from "../types/models";
// ── 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`;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
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",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ── Phase row ─────────────────────────────────────────────────────────
interface PhaseRowProps {
dotClass: string;
icon: React.ReactNode;
label: string;
badge?: React.ReactNode;
startTs: string | null;
duration?: number | null; // seconds; null → still running / unknown
isLast?: boolean;
}
function PhaseRow({ dotClass, icon, label, badge, startTs, duration, isLast }: 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>
<span className="text-xs font-medium text-gray-200">{label}</span>
{badge}
{duration != null && (
<span className="ml-auto text-xs font-semibold text-cyan-400">
{fmtDuration(duration)}
</span>
)}
</div>
{startTs && (
<p className="mt-0.5 text-xs text-gray-500">{fmtTs(startTs)}</p>
)}
</div>
</div>
);
}
// ── Validation badge ──────────────────────────────────────────────────
function ValidationBadge({ status }: { status: string | null }) {
if (!status) return null;
if (status === "approved")
return (
<span className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-green-900/40 text-green-400">
<CheckCircle className="h-3 w-3" /> Approved
</span>
);
if (status === "rejected")
return (
<span className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs bg-red-900/40 text-red-400">
<XCircle className="h-3 w-3" /> Rejected
</span>
);
return (
<span className="rounded px-1.5 py-0.5 text-xs bg-yellow-900/30 text-yellow-400">
{status}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────
interface TestPhaseTimelineProps {
test: Test;
}
export default function TestPhaseTimeline({ test }: TestPhaseTimelineProps) {
const {
red_started_at,
blue_started_at,
blue_work_started_at,
red_paused_seconds,
blue_paused_seconds,
red_validated_at,
blue_validated_at,
red_validation_status,
blue_validation_status,
} = test;
// Compute per-phase durations
const redExecSecs =
red_started_at && blue_started_at
? durationSeconds(red_started_at, blue_started_at, red_paused_seconds)
: null;
const blueQueueSecs =
blue_started_at && blue_work_started_at
? 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;
const hasRedValidation = !!red_validated_at;
const hasBlueValidation = !!blue_validated_at;
const anyPhase =
hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation;
if (!anyPhase) return null;
// Count rendered phases for isLast detection
const phases = [
hasRedExec,
hasBlueQueue,
hasBlueEval,
hasRedValidation,
hasBlueValidation,
];
const lastIdx = phases.lastIndexOf(true);
let phaseIdx = -1;
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Timer className="h-5 w-5 text-cyan-400" />
Phase Timeline
</h2>
<div className="relative space-y-0">
{hasRedExec && (
<PhaseRow
dotClass="border-orange-500/60"
icon={<Sword className="h-3 w-3 text-orange-400 inline" />}
label="Red Team Execution"
startTs={red_started_at}
duration={redExecSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueQueue && (
<PhaseRow
dotClass="border-yellow-500/60"
icon={<Clock className="h-3 w-3 text-yellow-400 inline" />}
label="Blue Queue"
startTs={blue_started_at}
duration={blueQueueSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueEval && (
<PhaseRow
dotClass="border-indigo-500/60"
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
label="Blue Evaluation"
startTs={blue_work_started_at}
duration={blueEvalSecs}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasRedValidation && (
<PhaseRow
dotClass="border-orange-400/60"
icon={<CheckCircle className="h-3 w-3 text-orange-400 inline" />}
label="Red Lead Validation"
badge={<ValidationBadge status={red_validation_status} />}
startTs={red_validated_at}
duration={null}
isLast={++phaseIdx === lastIdx}
/>
)}
{hasBlueValidation && (
<PhaseRow
dotClass="border-indigo-400/60"
icon={<CheckCircle className="h-3 w-3 text-indigo-400 inline" />}
label="Blue Lead Validation"
badge={<ValidationBadge status={blue_validation_status} />}
startTs={blue_validated_at}
duration={null}
isLast={++phaseIdx === lastIdx}
/>
)}
</div>
</div>
);
}

View File

@@ -29,6 +29,7 @@ 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 WorklogTimeline from "../components/WorklogTimeline";
import TestPhaseTimeline from "../components/TestPhaseTimeline";
// ── Page Component ───────────────────────────────────────────────── // ── Page Component ─────────────────────────────────────────────────
@@ -540,7 +541,10 @@ export default function TestDetailPage() {
{/* Jira Integration */} {/* Jira Integration */}
<JiraLinkPanel entityType="test" entityId={testId!} /> <JiraLinkPanel entityType="test" entityId={testId!} />
{/* Time Tracking */} {/* Phase Timeline (read-only, derived from test timestamps) */}
<TestPhaseTimeline test={test} />
{/* Time Tracking (manual worklogs) */}
<WorklogTimeline entityType="test" entityId={testId!} /> <WorklogTimeline entityType="test" entityId={testId!} />
</div> </div>
</div> </div>