feat(phase-36): automatic Tempo time tracking via workflow buttons + fix campaign test management
- Add red_started_at/blue_started_at timing fields to Test model with Alembic migration - Modify workflow transitions to auto-create integrity-hashed worklogs: Start Execution records red_started_at, Submit to Blue Team stops Red timer and creates worklog then starts Blue timer, Submit for Review stops Blue timer and creates worklog - Auto-sync worklogs to Tempo when test has a Jira link - Add LiveTimer component showing real-time elapsed counter during active phases - Clear timing fields on test reopen - Fix campaign test management: replace broken navigate-to-tests flow with AddTestToCampaignModal that lets users search and add existing tests directly from the campaign detail page
This commit is contained in:
32
backend/alembic/versions/b021_add_phase_timing_fields.py
Normal file
32
backend/alembic/versions/b021_add_phase_timing_fields.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""add_phase_timing_fields
|
||||||
|
|
||||||
|
Revision ID: b021phasetiming
|
||||||
|
Revises: b020jiraworklogs
|
||||||
|
Create Date: 2026-02-17 18:00:00.000000
|
||||||
|
|
||||||
|
Add red_started_at and blue_started_at columns to the tests table
|
||||||
|
so that automatic worklogs can record real elapsed time per phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b021phasetiming"
|
||||||
|
down_revision = "b020jiraworklogs"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
ALTER TABLE tests
|
||||||
|
ADD COLUMN IF NOT EXISTS red_started_at TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS blue_started_at TIMESTAMP;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
ALTER TABLE tests
|
||||||
|
DROP COLUMN IF EXISTS red_started_at,
|
||||||
|
DROP COLUMN IF EXISTS blue_started_at;
|
||||||
|
""")
|
||||||
@@ -49,6 +49,10 @@ class Test(Base):
|
|||||||
blue_validation_status = Column(String, nullable=True) # pending / approved / rejected
|
blue_validation_status = Column(String, nullable=True) # pending / approved / rejected
|
||||||
blue_validation_notes = Column(Text, nullable=True)
|
blue_validation_notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
|
||||||
|
red_started_at = Column(DateTime, nullable=True)
|
||||||
|
blue_started_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# ── Remediation fields ───────────────────────────────────────────
|
# ── Remediation fields ───────────────────────────────────────────
|
||||||
remediation_steps = Column(Text, nullable=True)
|
remediation_steps = Column(Text, nullable=True)
|
||||||
remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable
|
remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ class TestOut(BaseModel):
|
|||||||
blue_validation_status: str | None = None
|
blue_validation_status: str | None = None
|
||||||
blue_validation_notes: str | None = None
|
blue_validation_notes: str | None = None
|
||||||
|
|
||||||
|
# Phase timing fields (for Tempo worklogs)
|
||||||
|
red_started_at: datetime | None = None
|
||||||
|
blue_started_at: datetime | None = None
|
||||||
|
|
||||||
# Remediation fields
|
# Remediation fields
|
||||||
remediation_steps: str | None = None
|
remediation_steps: str | None = None
|
||||||
remediation_status: str | None = None
|
remediation_status: str | None = None
|
||||||
|
|||||||
@@ -89,8 +89,26 @@ def auto_log_test_worklog(
|
|||||||
|
|
||||||
|
|
||||||
def _calculate_duration(test, activity_type: str) -> int:
|
def _calculate_duration(test, activity_type: str) -> int:
|
||||||
"""Estimate duration in seconds based on test timestamps and activity type."""
|
"""Calculate real duration in seconds from the phase timing fields.
|
||||||
|
|
||||||
|
Uses the actual start/end timestamps recorded by the workflow buttons,
|
||||||
|
so the data cannot be falsified.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
if activity_type == "red_team_execution" and test.red_started_at:
|
||||||
|
delta = now - test.red_started_at
|
||||||
|
return max(int(delta.total_seconds()), 1)
|
||||||
|
|
||||||
|
if activity_type == "blue_team_evaluation" and test.blue_started_at:
|
||||||
|
delta = now - test.blue_started_at
|
||||||
|
return max(int(delta.total_seconds()), 1)
|
||||||
|
|
||||||
|
# Fallback for legacy activity types
|
||||||
if activity_type == "execution" and test.execution_date and test.created_at:
|
if activity_type == "execution" and test.execution_date and test.created_at:
|
||||||
delta = test.execution_date - test.created_at
|
delta = test.execution_date - test.created_at
|
||||||
return max(int(delta.total_seconds()), 0)
|
return max(int(delta.total_seconds()), 0)
|
||||||
return 3600 # default 1 hour if no timestamps available
|
|
||||||
|
return 0
|
||||||
|
|||||||
@@ -113,12 +113,15 @@ def start_execution(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""Move from ``draft`` → ``red_executing``.
|
"""Move from ``draft`` → ``red_executing``.
|
||||||
|
|
||||||
Typically called by a **red_tech** when they begin the attack.
|
Typically called by a **red_tech** when they begin the attack.
|
||||||
|
Starts the Red Team timer by recording ``red_started_at``.
|
||||||
"""
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.red_executing, user,
|
db, test, TestState.red_executing, user,
|
||||||
action_name="start_execution",
|
action_name="start_execution",
|
||||||
)
|
)
|
||||||
test.execution_date = datetime.utcnow()
|
test.execution_date = now
|
||||||
|
test.red_started_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -127,11 +130,29 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""Move from ``red_executing`` → ``blue_evaluating``.
|
"""Move from ``red_executing`` → ``blue_evaluating``.
|
||||||
|
|
||||||
Called by **red_tech** once they have finished documenting the attack.
|
Called by **red_tech** once they have finished documenting the attack.
|
||||||
|
Stops the Red Team timer and creates an automatic worklog.
|
||||||
|
Starts the Blue Team timer by recording ``blue_started_at``.
|
||||||
"""
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.blue_evaluating, user,
|
db, test, TestState.blue_evaluating, user,
|
||||||
action_name="submit_red_evidence",
|
action_name="submit_red_evidence",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create automatic worklog for Red Team phase
|
||||||
|
_create_phase_worklog(
|
||||||
|
db,
|
||||||
|
test=test,
|
||||||
|
user=user,
|
||||||
|
phase_started_at=test.red_started_at,
|
||||||
|
phase_ended_at=now,
|
||||||
|
activity_type="red_team_execution",
|
||||||
|
description=f"Red Team execution: {test.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start Blue Team timer
|
||||||
|
test.blue_started_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -140,15 +161,83 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""Move from ``blue_evaluating`` → ``in_review``.
|
"""Move from ``blue_evaluating`` → ``in_review``.
|
||||||
|
|
||||||
Called by **blue_tech** once they have finished documenting detection.
|
Called by **blue_tech** once they have finished documenting detection.
|
||||||
|
Stops the Blue Team timer and creates an automatic worklog.
|
||||||
"""
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.in_review, user,
|
db, test, TestState.in_review, user,
|
||||||
action_name="submit_blue_evidence",
|
action_name="submit_blue_evidence",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create automatic worklog for Blue Team phase
|
||||||
|
_create_phase_worklog(
|
||||||
|
db,
|
||||||
|
test=test,
|
||||||
|
user=user,
|
||||||
|
phase_started_at=test.blue_started_at,
|
||||||
|
phase_ended_at=now,
|
||||||
|
activity_type="blue_team_evaluation",
|
||||||
|
description=f"Blue Team evaluation: {test.name}",
|
||||||
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def _create_phase_worklog(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
test: Test,
|
||||||
|
user: User,
|
||||||
|
phase_started_at: datetime | None,
|
||||||
|
phase_ended_at: datetime,
|
||||||
|
activity_type: str,
|
||||||
|
description: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create an automatic, integrity-hashed worklog for a completed phase.
|
||||||
|
|
||||||
|
Also triggers Tempo sync if the test has a Jira link.
|
||||||
|
"""
|
||||||
|
if not phase_started_at:
|
||||||
|
logger.warning(
|
||||||
|
"No phase start timestamp for test %s (%s), skipping worklog",
|
||||||
|
test.id, activity_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_seconds = max(int((phase_ended_at - phase_started_at).total_seconds()), 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.worklog_service import create_worklog
|
||||||
|
|
||||||
|
wl = create_worklog(
|
||||||
|
db,
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
user_id=user.id,
|
||||||
|
activity_type=activity_type,
|
||||||
|
started_at=phase_started_at,
|
||||||
|
ended_at=phase_ended_at,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Auto-worklog created for test %s: %s, %ds (worklog %s)",
|
||||||
|
test.id, activity_type, duration_seconds, wl.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync to Tempo if enabled
|
||||||
|
try:
|
||||||
|
from app.services.tempo_service import auto_log_test_worklog
|
||||||
|
auto_log_test_worklog(db, test, user, activity_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create auto-worklog for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def validate_as_red_lead(
|
def validate_as_red_lead(
|
||||||
db: Session,
|
db: Session,
|
||||||
test: Test,
|
test: Test,
|
||||||
@@ -428,5 +517,9 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
|||||||
test.blue_validated_at = None
|
test.blue_validated_at = None
|
||||||
test.blue_validation_notes = None
|
test.blue_validation_notes = None
|
||||||
|
|
||||||
|
# Clear phase timing fields
|
||||||
|
test.red_started_at = None
|
||||||
|
test.blue_started_at = None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|||||||
197
frontend/src/components/AddTestToCampaignModal.tsx
Normal file
197
frontend/src/components/AddTestToCampaignModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
FlaskConical,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTests } from "../api/tests";
|
||||||
|
import { addTestToCampaign } from "../api/campaigns";
|
||||||
|
import type { Test, TestState } from "../types/models";
|
||||||
|
|
||||||
|
const stateBadge: Record<TestState, string> = {
|
||||||
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddTestToCampaignModalProps {
|
||||||
|
campaignId: string;
|
||||||
|
existingTestIds: string[];
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddTestToCampaignModal({
|
||||||
|
campaignId,
|
||||||
|
existingTestIds,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: AddTestToCampaignModalProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { data: allTests, isLoading } = useQuery({
|
||||||
|
queryKey: ["tests", "for-campaign-picker"],
|
||||||
|
queryFn: () => getTests({ limit: 200 }),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTests = useMemo(() => {
|
||||||
|
if (!allTests) return [];
|
||||||
|
const alreadyIn = new Set([...existingTestIds, ...addedIds]);
|
||||||
|
let results = allTests.filter((t) => !alreadyIn.has(t.id));
|
||||||
|
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const q = searchText.toLowerCase();
|
||||||
|
results = results.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
(t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) ||
|
||||||
|
(t.technique_name && t.technique_name.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [allTests, searchText, existingTestIds, addedIds]);
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (testId: string) =>
|
||||||
|
addTestToCampaign(campaignId, { test_id: testId }),
|
||||||
|
onSuccess: (_data, testId) => {
|
||||||
|
setAddedIds((prev) => new Set(prev).add(testId));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
Add Tests to Campaign
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="border-b border-gray-800 px-6 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
placeholder="Search tests by name or technique..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2.5 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test list */}
|
||||||
|
<div className="max-h-[400px] overflow-y-auto px-6 py-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
) : filteredTests.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
|
<FlaskConical className="mb-2 h-8 w-8 text-gray-600" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{searchText
|
||||||
|
? "No tests match your search."
|
||||||
|
: "All available tests are already in this campaign."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredTests.map((test: Test) => (
|
||||||
|
<div
|
||||||
|
key={test.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3 hover:border-gray-700 hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-200 truncate">
|
||||||
|
{test.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
stateBadge[test.state]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{test.state.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
{test.technique_mitre_id && (
|
||||||
|
<span className="font-mono text-cyan-400/70">
|
||||||
|
{test.technique_mitre_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{test.technique_name && (
|
||||||
|
<span className="truncate">{test.technique_name}</span>
|
||||||
|
)}
|
||||||
|
{test.platform && (
|
||||||
|
<span className="capitalize">{test.platform}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addMutation.mutate(test.id)}
|
||||||
|
disabled={addMutation.isPending && addMutation.variables === test.id}
|
||||||
|
className="ml-3 flex shrink-0 items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{addMutation.isPending && addMutation.variables === test.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{addedIds.size > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-green-400">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
{addedIds.size} test{addedIds.size !== 1 ? "s" : ""} added
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/components/test-detail/LiveTimer.tsx
Normal file
65
frontend/src/components/test-detail/LiveTimer.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Timer } from "lucide-react";
|
||||||
|
|
||||||
|
interface LiveTimerProps {
|
||||||
|
startedAt: string;
|
||||||
|
label: string;
|
||||||
|
variant: "red" | "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time elapsed timer that counts up from a given start timestamp.
|
||||||
|
* Shown while a Red/Blue Team phase is active so users can see
|
||||||
|
* exactly how long they've been working. This time is recorded
|
||||||
|
* as an automatic worklog when the phase ends.
|
||||||
|
*/
|
||||||
|
export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = new Date(startedAt).getTime();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
setElapsed(Math.max(0, Math.floor((now - start) / 1000)));
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const interval = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
const hours = Math.floor(elapsed / 3600);
|
||||||
|
const minutes = Math.floor((elapsed % 3600) / 60);
|
||||||
|
const seconds = elapsed % 60;
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
const colors =
|
||||||
|
variant === "red"
|
||||||
|
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
|
||||||
|
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
|
||||||
|
|
||||||
|
const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
<span
|
||||||
|
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} animate-pulse`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm font-bold tabular-nums">
|
||||||
|
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Test, TestState, User } from "../../types/models";
|
import type { Test, TestState, User } from "../../types/models";
|
||||||
|
import LiveTimer from "./LiveTimer";
|
||||||
|
|
||||||
// ── Progress steps ─────────────────────────────────────────────────
|
// ── Progress steps ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -235,6 +236,30 @@ export default function TestDetailHeader({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Live timer ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const renderLiveTimer = () => {
|
||||||
|
if (test.state === "red_executing" && test.red_started_at) {
|
||||||
|
return (
|
||||||
|
<LiveTimer
|
||||||
|
startedAt={test.red_started_at}
|
||||||
|
label="Red Team Timer"
|
||||||
|
variant="red"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (test.state === "blue_evaluating" && test.blue_started_at) {
|
||||||
|
return (
|
||||||
|
<LiveTimer
|
||||||
|
startedAt={test.blue_started_at}
|
||||||
|
label="Blue Team Timer"
|
||||||
|
variant="blue"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -263,7 +288,10 @@ export default function TestDetailHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderActions()}
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{renderLiveTimer()}
|
||||||
|
{renderActions()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import CampaignTimeline from "../components/CampaignTimeline";
|
import CampaignTimeline from "../components/CampaignTimeline";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import WorklogTimeline from "../components/WorklogTimeline";
|
import WorklogTimeline from "../components/WorklogTimeline";
|
||||||
|
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
@@ -61,6 +62,7 @@ export default function CampaignDetailPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
|
const [showAddTestModal, setShowAddTestModal] = useState(false);
|
||||||
|
|
||||||
const showToast = (message: string, type: "success" | "error") => {
|
const showToast = (message: string, type: "success" | "error") => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -500,7 +502,7 @@ export default function CampaignDetailPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
{canManage && campaign.status === "draft" && (
|
{canManage && campaign.status === "draft" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/tests?campaign=${campaignId}`)}
|
onClick={() => setShowAddTestModal(true)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -606,6 +608,15 @@ export default function CampaignDetailPage() {
|
|||||||
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
|
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Test to Campaign Modal */}
|
||||||
|
<AddTestToCampaignModal
|
||||||
|
campaignId={campaignId!}
|
||||||
|
existingTestIds={campaign.tests.map((ct) => ct.test_id)}
|
||||||
|
open={showAddTestModal}
|
||||||
|
onClose={() => setShowAddTestModal(false)}
|
||||||
|
onSuccess={() => showToast("Test added to campaign", "success")}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Toast notification */}
|
{/* Toast notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export interface Test {
|
|||||||
blue_validation_status: ValidationStatus | null;
|
blue_validation_status: ValidationStatus | null;
|
||||||
blue_validation_notes: string | null;
|
blue_validation_notes: string | null;
|
||||||
|
|
||||||
|
// Phase timing fields (for automatic Tempo worklogs)
|
||||||
|
red_started_at: string | null;
|
||||||
|
blue_started_at: string | null;
|
||||||
|
|
||||||
// Remediation fields
|
// Remediation fields
|
||||||
remediation_steps: string | null;
|
remediation_steps: string | null;
|
||||||
remediation_status: string | null;
|
remediation_status: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user