fix(tempo,jira,tests,ui): fix 4 pending issues
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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'}",
|
||||||
"",
|
"",
|
||||||
"----",
|
"----",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
246
frontend/src/components/TestPhaseTimeline.tsx
Normal file
246
frontend/src/components/TestPhaseTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user