diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 2e1fb2e..3e1634c 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -219,9 +219,6 @@ def _build_test_description(test: Test, technique: Optional[Technique]) -> str: "h3. Description", 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'}", "", "----", diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index 7c36b69..a7e9568 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -69,21 +69,16 @@ def log_worklog( date: str, time_spent_seconds: int, description: str, - work_type: str | None = None, ) -> dict: """Create a worklog entry in Tempo using *user*'s personal token.""" tempo = get_user_tempo_client(user) - kwargs: dict = { - "accountId": author_account_id, - "issueId": jira_issue_id, - "dateFrom": date, - "timeSpentSeconds": time_spent_seconds, - "description": description, - } - wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE - if wt: - kwargs["workType"] = wt - return tempo.create_worklog(**kwargs) + return tempo.create_worklog( + accountId=author_account_id, + issueId=jira_issue_id, + dateFrom=date, + timeSpentSeconds=time_spent_seconds, + description=description, + ) def auto_log_test_worklog( diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index a5be075..3e8e65b 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -5,6 +5,7 @@ The router is responsible for HTTP concerns, auth, audit logging, and commit. """ import uuid +from datetime import datetime from typing import Any from sqlalchemy.orm import Session, joinedload @@ -78,6 +79,7 @@ def create_test( technique_id=technique_id, created_by=creator_id, state=TestState.draft, + created_at=datetime.utcnow(), # explicit — DB column has no server default **fields, ) db.add(test) @@ -127,6 +129,7 @@ def create_test_from_template( remediation_steps=template.suggested_remediation, created_by=creator_id, state=TestState.draft, + created_at=datetime.utcnow(), # explicit — DB column has no server default ) db.add(test) db.flush() diff --git a/frontend/src/components/TestPhaseTimeline.tsx b/frontend/src/components/TestPhaseTimeline.tsx new file mode 100644 index 0000000..983f1ea --- /dev/null +++ b/frontend/src/components/TestPhaseTimeline.tsx @@ -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 ( +
+ {/* Connector line */} + {!isLast && ( +
+ )} + + {/* Dot */} +
+ + {/* Content */} +
+
+ {icon} + {label} + {badge} + {duration != null && ( + + {fmtDuration(duration)} + + )} +
+ {startTs && ( +

{fmtTs(startTs)}

+ )} +
+
+ ); +} + +// ── Validation badge ────────────────────────────────────────────────── + +function ValidationBadge({ status }: { status: string | null }) { + if (!status) return null; + if (status === "approved") + return ( + + Approved + + ); + if (status === "rejected") + return ( + + Rejected + + ); + return ( + + {status} + + ); +} + +// ── 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 ( +
+

+ + Phase Timeline +

+ +
+ {hasRedExec && ( + } + label="Red Team Execution" + startTs={red_started_at} + duration={redExecSecs} + isLast={++phaseIdx === lastIdx} + /> + )} + + {hasBlueQueue && ( + } + label="Blue Queue" + startTs={blue_started_at} + duration={blueQueueSecs} + isLast={++phaseIdx === lastIdx} + /> + )} + + {hasBlueEval && ( + } + label="Blue Evaluation" + startTs={blue_work_started_at} + duration={blueEvalSecs} + isLast={++phaseIdx === lastIdx} + /> + )} + + {hasRedValidation && ( + } + label="Red Lead Validation" + badge={} + startTs={red_validated_at} + duration={null} + isLast={++phaseIdx === lastIdx} + /> + )} + + {hasBlueValidation && ( + } + label="Blue Lead Validation" + badge={} + startTs={blue_validated_at} + duration={null} + isLast={++phaseIdx === lastIdx} + /> + )} +
+
+ ); +} diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index dd141be..1876d94 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -29,6 +29,7 @@ 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 ───────────────────────────────────────────────── @@ -540,7 +541,10 @@ export default function TestDetailPage() { {/* Jira Integration */} - {/* Time Tracking */} + {/* Phase Timeline (read-only, derived from test timestamps) */} + + + {/* Time Tracking (manual worklogs) */}