feat(tests): operator assignment — two queues for techs, assign endpoint for leads
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add red_tech_assignee and blue_tech_assignee to tests.
|
||||||
|
|
||||||
|
Revision ID: b050
|
||||||
|
Revises: b049
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "b050"
|
||||||
|
down_revision = "b049"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("tests", sa.Column("red_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tests", "blue_tech_assignee")
|
||||||
|
op.drop_column("tests", "red_tech_assignee")
|
||||||
@@ -129,6 +129,14 @@ class Test(Base):
|
|||||||
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
|
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
|
||||||
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
||||||
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
||||||
|
|
||||||
|
# ── Assignment fields ──────────────────────────────────────────
|
||||||
|
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
||||||
|
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
||||||
|
|
||||||
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from app.models.user import User
|
|||||||
|
|
||||||
# Import from app.schemas.test
|
# Import from app.schemas.test
|
||||||
from app.schemas.test import (
|
from app.schemas.test import (
|
||||||
|
TestAssign,
|
||||||
TestBlueUpdate,
|
TestBlueUpdate,
|
||||||
TestBlueValidate,
|
TestBlueValidate,
|
||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
@@ -563,6 +564,15 @@ def update_test_red(
|
|||||||
Returns:
|
Returns:
|
||||||
TestOut: The updated test with refreshed red-team field values.
|
TestOut: The updated test with refreshed red-team field values.
|
||||||
"""
|
"""
|
||||||
|
# Assignee lock: red_tech cannot work a test assigned to someone else
|
||||||
|
_pre_test = crud_get_test_or_raise(db, test_id)
|
||||||
|
if (
|
||||||
|
_pre_test.red_tech_assignee is not None
|
||||||
|
and current_user.role == "red_tech"
|
||||||
|
and _pre_test.red_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
# Assign update_data = payload.model_dump(exclude_unset=True)
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
# Open context manager
|
# Open context manager
|
||||||
@@ -620,6 +630,15 @@ def update_test_blue(
|
|||||||
Returns:
|
Returns:
|
||||||
TestOut: The updated test with refreshed blue-team field values.
|
TestOut: The updated test with refreshed blue-team field values.
|
||||||
"""
|
"""
|
||||||
|
# Assignee lock: blue_tech cannot work a test assigned to someone else
|
||||||
|
_pre_test = crud_get_test_or_raise(db, test_id)
|
||||||
|
if (
|
||||||
|
_pre_test.blue_tech_assignee is not None
|
||||||
|
and current_user.role == "blue_tech"
|
||||||
|
and _pre_test.blue_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
# Assign update_data = payload.model_dump(exclude_unset=True)
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
# Open context manager
|
# Open context manager
|
||||||
@@ -676,10 +695,22 @@ def start_execution(
|
|||||||
"""
|
"""
|
||||||
# Assign test = crud_get_test_or_raise(db, test_id)
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
# Assignee lock: red_tech cannot start a test assigned to someone else
|
||||||
|
if (
|
||||||
|
test.red_tech_assignee is not None
|
||||||
|
and current_user.role == "red_tech"
|
||||||
|
and test.red_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign test = wf_start_execution(db, test, current_user)
|
# Assign test = wf_start_execution(db, test, current_user)
|
||||||
test = wf_start_execution(db, test, current_user)
|
test = wf_start_execution(db, test, current_user)
|
||||||
|
# Auto-assign: if no assignee set, claim this test for the current operator
|
||||||
|
if test.red_tech_assignee is None:
|
||||||
|
test.red_tech_assignee = current_user.id
|
||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
# Reload ORM object attributes from the database
|
# Reload ORM object attributes from the database
|
||||||
@@ -715,6 +746,15 @@ def submit_red(
|
|||||||
"""
|
"""
|
||||||
# Assign test = crud_get_test_or_raise(db, test_id)
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
# Assignee lock: red_tech cannot submit a test assigned to someone else
|
||||||
|
if (
|
||||||
|
test.red_tech_assignee is not None
|
||||||
|
and current_user.role == "red_tech"
|
||||||
|
and test.red_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign test = wf_submit_red(db, test, current_user)
|
# Assign test = wf_submit_red(db, test, current_user)
|
||||||
@@ -754,6 +794,15 @@ def submit_blue(
|
|||||||
"""
|
"""
|
||||||
# Assign test = crud_get_test_or_raise(db, test_id)
|
# Assign test = crud_get_test_or_raise(db, test_id)
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
# Assignee lock: blue_tech cannot submit a test assigned to someone else
|
||||||
|
if (
|
||||||
|
test.blue_tech_assignee is not None
|
||||||
|
and current_user.role == "blue_tech"
|
||||||
|
and test.blue_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign test = wf_submit_blue(db, test, current_user)
|
# Assign test = wf_submit_blue(db, test, current_user)
|
||||||
@@ -779,8 +828,20 @@ def start_blue_work(
|
|||||||
):
|
):
|
||||||
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
|
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
# Assignee lock: blue_tech cannot pick up a test assigned to someone else
|
||||||
|
if (
|
||||||
|
test.blue_tech_assignee is not None
|
||||||
|
and current_user.role == "blue_tech"
|
||||||
|
and test.blue_tech_assignee != current_user.id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
|
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
test = wf_start_blue_work(db, test, current_user)
|
test = wf_start_blue_work(db, test, current_user)
|
||||||
|
# Auto-assign: if no assignee set, claim this test for the current operator
|
||||||
|
if test.blue_tech_assignee is None:
|
||||||
|
test.blue_tech_assignee = current_user.id
|
||||||
uow.commit()
|
uow.commit()
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
return test
|
return test
|
||||||
@@ -1017,6 +1078,48 @@ def reopen(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + admin)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/assign", response_model=TestOut)
|
||||||
|
def assign_test_operators(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestAssign,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||||
|
):
|
||||||
|
"""Assign red_tech and/or blue_tech operators to a test. Admin/leads only."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if payload.red_tech_assignee is not None:
|
||||||
|
u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
|
||||||
|
if not u or u.role not in ("red_tech", "red_lead", "admin"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid red tech assignee")
|
||||||
|
test.red_tech_assignee = payload.red_tech_assignee
|
||||||
|
|
||||||
|
if payload.blue_tech_assignee is not None:
|
||||||
|
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
|
||||||
|
if not u or u.role not in ("blue_tech", "blue_lead", "admin"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
|
||||||
|
test.blue_tech_assignee = payload.blue_tech_assignee
|
||||||
|
|
||||||
|
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent
|
||||||
|
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
|
||||||
|
test.red_tech_assignee = None
|
||||||
|
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is None:
|
||||||
|
test.blue_tech_assignee = None
|
||||||
|
|
||||||
|
log_action(db, current_user.id, "assign_test", str(test_id), {
|
||||||
|
"red_tech_assignee": str(payload.red_tech_assignee) if payload.red_tech_assignee else None,
|
||||||
|
"blue_tech_assignee": str(payload.blue_tech_assignee) if payload.blue_tech_assignee else None,
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PATCH /tests/{id}/remediation — update remediation fields
|
# PATCH /tests/{id}/remediation — update remediation fields
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ class TestRemediationUpdate(BaseModel):
|
|||||||
remediation_assignee: uuid.UUID | None = None
|
remediation_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssign(BaseModel):
|
||||||
|
"""Payload for assigning operators to a test."""
|
||||||
|
|
||||||
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── Legacy validate (kept for backwards compat) ────────────────────
|
# ── Legacy validate (kept for backwards compat) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +228,10 @@ class TestOut(BaseModel):
|
|||||||
# Assign remediation_assignee = None
|
# Assign remediation_assignee = None
|
||||||
remediation_assignee: uuid.UUID | None = None
|
remediation_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
# Assignment fields
|
||||||
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
# Re-test fields
|
# Re-test fields
|
||||||
retest_of: uuid.UUID | None = None
|
retest_of: uuid.UUID | None = None
|
||||||
# Assign retest_count = 0
|
# Assign retest_count = 0
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export interface TestListFilters {
|
|||||||
created_by?: string;
|
created_by?: string;
|
||||||
pending_validation_side?: "red" | "blue";
|
pending_validation_side?: "red" | "blue";
|
||||||
not_in_any_campaign?: boolean;
|
not_in_any_campaign?: boolean;
|
||||||
|
assigned_to_me?: boolean;
|
||||||
|
unassigned_red?: boolean;
|
||||||
|
unassigned_blue?: boolean;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
@@ -222,6 +225,17 @@ export async function reopenTest(testId: string): Promise<Test> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Assignment ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Assign red_tech and/or blue_tech operators to a test. Admin/leads only. */
|
||||||
|
export async function assignTest(
|
||||||
|
testId: string,
|
||||||
|
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
||||||
|
): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Timeline ───────────────────────────────────────────────────────
|
// ── Timeline ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Get the audit-log timeline for a test. */
|
/** Get the audit-log timeline for a test. */
|
||||||
|
|||||||
+236
-128
@@ -109,8 +109,22 @@ function formatElapsed(dateStr: string | null | undefined): string {
|
|||||||
return `${days}d ${hours % 24}h`;
|
return `${days}d ${hours % 24}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared sort key type ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type SortKey =
|
||||||
|
| "name"
|
||||||
|
| "technique"
|
||||||
|
| "state"
|
||||||
|
| "team"
|
||||||
|
| "platform"
|
||||||
|
| "created_at"
|
||||||
|
| "updated_at"
|
||||||
|
| "waiting_time";
|
||||||
|
|
||||||
/* ── Component ──────────────────────────────────────────────────────── */
|
/* ── Component ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const isTechRole = (role?: string) => role === "red_tech" || role === "blue_tech";
|
||||||
|
|
||||||
export default function TestsPage() {
|
export default function TestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -118,6 +132,8 @@ export default function TestsPage() {
|
|||||||
const canCreate =
|
const canCreate =
|
||||||
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
|
||||||
|
|
||||||
|
const techRole = isTechRole(user?.role);
|
||||||
|
|
||||||
// ── Filter state ──────────────────────────────────────────────────
|
// ── Filter state ──────────────────────────────────────────────────
|
||||||
const [stateFilter, setStateFilter] = useState<TestState | "">("");
|
const [stateFilter, setStateFilter] = useState<TestState | "">("");
|
||||||
const [platformFilter, setPlatformFilter] = useState("");
|
const [platformFilter, setPlatformFilter] = useState("");
|
||||||
@@ -125,15 +141,6 @@ export default function TestsPage() {
|
|||||||
const [showMyTasks, setShowMyTasks] = useState(false);
|
const [showMyTasks, setShowMyTasks] = useState(false);
|
||||||
|
|
||||||
// ── Sort state ────────────────────────────────────────────────────
|
// ── Sort state ────────────────────────────────────────────────────
|
||||||
type SortKey =
|
|
||||||
| "name"
|
|
||||||
| "technique"
|
|
||||||
| "state"
|
|
||||||
| "team"
|
|
||||||
| "platform"
|
|
||||||
| "created_at"
|
|
||||||
| "updated_at"
|
|
||||||
| "waiting_time";
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
@@ -290,6 +297,52 @@ export default function TestsPage() {
|
|||||||
|
|
||||||
const totalTests = allTestsUnfiltered?.length || 0;
|
const totalTests = allTestsUnfiltered?.length || 0;
|
||||||
|
|
||||||
|
// ── Two-queue split for tech roles ────────────────────────────────
|
||||||
|
const { availableTests, myAssignedTests } = useMemo(() => {
|
||||||
|
if (!techRole || !user || !allTests) {
|
||||||
|
return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests };
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchFiltered = allTests.filter((t) => t.state !== "validated");
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const q = searchText.toLowerCase();
|
||||||
|
searchFiltered = searchFiltered.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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (platformFilter) {
|
||||||
|
searchFiltered = searchFiltered.filter((t) =>
|
||||||
|
t.platform?.toLowerCase().includes(platformFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "red_tech") {
|
||||||
|
const available = searchFiltered.filter(
|
||||||
|
(t) =>
|
||||||
|
(t.state === "draft" || t.state === "red_executing") &&
|
||||||
|
t.red_tech_assignee === null
|
||||||
|
);
|
||||||
|
const mine = searchFiltered.filter((t) => t.red_tech_assignee === user.id);
|
||||||
|
return { availableTests: available, myAssignedTests: mine };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === "blue_tech") {
|
||||||
|
const available = searchFiltered.filter(
|
||||||
|
(t) =>
|
||||||
|
t.state === "blue_evaluating" &&
|
||||||
|
t.blue_tech_assignee === null &&
|
||||||
|
t.blue_work_started_at === null
|
||||||
|
);
|
||||||
|
const mine = searchFiltered.filter((t) => t.blue_tech_assignee === user.id);
|
||||||
|
return { availableTests: available, myAssignedTests: mine };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests };
|
||||||
|
}, [techRole, user, allTests, searchText, platformFilter]);
|
||||||
|
|
||||||
// ── Formatting helpers ─────────────────────────────────────────────
|
// ── Formatting helpers ─────────────────────────────────────────────
|
||||||
const formatDate = (dateStr: string | null | undefined) => {
|
const formatDate = (dateStr: string | null | undefined) => {
|
||||||
if (!dateStr) return "-";
|
if (!dateStr) return "-";
|
||||||
@@ -425,8 +478,8 @@ export default function TestsPage() {
|
|||||||
{/* ── Filters Bar ───────────────────────────────────────────────── */}
|
{/* ── Filters Bar ───────────────────────────────────────────────── */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* My tasks toggle */}
|
{/* My tasks toggle — hidden for tech roles (they have the two-queue view below) */}
|
||||||
{user?.role !== "admin" && user?.role !== "viewer" && (
|
{user?.role !== "admin" && user?.role !== "viewer" && !techRole && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowMyTasks(!showMyTasks);
|
setShowMyTasks(!showMyTasks);
|
||||||
@@ -525,125 +578,180 @@ export default function TestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tests Table ───────────────────────────────────────────────── */}
|
{/* ── Tests Table / Two-Queue View ─────────────────────────────── */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
{techRole ? (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
/* Two-section queue for red_tech and blue_tech */
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<div className="space-y-6">
|
||||||
{showMyTasks ? myTasksLabel : "All Tests"}
|
{/* Available Tests */}
|
||||||
</h2>
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
<div className="mb-4 flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Available Tests</h2>
|
||||||
<div className="overflow-x-auto">
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-xs font-medium text-cyan-400">
|
||||||
<table className="w-full text-left text-sm">
|
{availableTests.length}
|
||||||
<thead>
|
</span>
|
||||||
<tr className="border-b border-gray-800">
|
</div>
|
||||||
{mainTableColumns.map(({ key, label, cls }) => (
|
<span className="text-xs text-gray-500">Unassigned — pick one to claim it</span>
|
||||||
<th
|
|
||||||
key={key}
|
|
||||||
className={`pb-3 ${cls} font-medium text-gray-400 cursor-pointer select-none hover:text-white transition-colors`}
|
|
||||||
onClick={() => handleSort(key)}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
{key === "waiting_time" && (
|
|
||||||
<Timer className="h-3.5 w-3.5 text-indigo-400" />
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
{sortKey === key ? (
|
|
||||||
sortDir === "asc" ? (
|
|
||||||
<ChevronUp className="h-3.5 w-3.5 text-cyan-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-cyan-400" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tests.map((test: Test) => (
|
|
||||||
<tr
|
|
||||||
key={test.id}
|
|
||||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
|
|
||||||
onClick={() => navigate(`/tests/${test.id}`)}
|
|
||||||
>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<span className="font-medium text-gray-200">{test.name}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
{test.technique_mitre_id ? (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-mono text-xs text-cyan-400">
|
|
||||||
{test.technique_mitre_id}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500 truncate max-w-[160px]">
|
|
||||||
{test.technique_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
||||||
testStateBadgeColors[test.state]
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testStateLabels[test.state]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
|
||||||
{currentTeamForState(test.state)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
|
||||||
{test.platform || "-"}
|
|
||||||
</td>
|
|
||||||
{/* Waiting time — how long since Red submitted to Blue */}
|
|
||||||
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
|
||||||
{test.state === "blue_evaluating" ? (
|
|
||||||
<span className="font-mono text-indigo-400">
|
|
||||||
{formatElapsed(test.blue_started_at)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-700">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
|
||||||
{formatDate(test.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
|
||||||
{formatDate(lastActivityDate(test))}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pl-4">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/tests/${test.id}`);
|
|
||||||
}}
|
|
||||||
className="text-sm text-cyan-400 hover:underline"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{tests.length === 0 && (
|
|
||||||
<div className="py-12 text-center text-gray-400">
|
|
||||||
{showMyTasks
|
|
||||||
? "No pending tasks for your role."
|
|
||||||
: "No tests found matching your filters."}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<TestTable tests={availableTests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No available tests right now." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Assigned Tests */}
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-white">My Assigned Tests</h2>
|
||||||
|
<span className="rounded-full border border-orange-500/30 bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-400">
|
||||||
|
{myAssignedTests.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">Tests assigned to you</span>
|
||||||
|
</div>
|
||||||
|
<TestTable tests={myAssignedTests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage="No tests currently assigned to you." />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
/* Normal single-table view for leads, admin, viewer */
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{showMyTasks ? myTasksLabel : "All Tests"}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
||||||
|
</div>
|
||||||
|
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared test table component ────────────────────────────────────── */
|
||||||
|
|
||||||
|
function TestTable({
|
||||||
|
tests,
|
||||||
|
columns,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
handleSort,
|
||||||
|
navigate,
|
||||||
|
formatDate,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
tests: Test[];
|
||||||
|
columns: { key: SortKey; label: string; cls: string }[];
|
||||||
|
sortKey: SortKey;
|
||||||
|
sortDir: "asc" | "desc";
|
||||||
|
handleSort: (key: SortKey) => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
formatDate: (d: string | null | undefined) => string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800">
|
||||||
|
{columns.map(({ key, label, cls }) => (
|
||||||
|
<th
|
||||||
|
key={key}
|
||||||
|
className={`pb-3 ${cls} font-medium text-gray-400 cursor-pointer select-none hover:text-white transition-colors`}
|
||||||
|
onClick={() => handleSort(key)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{key === "waiting_time" && (
|
||||||
|
<Timer className="h-3.5 w-3.5 text-indigo-400" />
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
{sortKey === key ? (
|
||||||
|
sortDir === "asc" ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5 text-cyan-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-cyan-400" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tests.map((test: Test) => (
|
||||||
|
<tr
|
||||||
|
key={test.id}
|
||||||
|
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() => navigate(`/tests/${test.id}`)}
|
||||||
|
>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<span className="font-medium text-gray-200">{test.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{test.technique_mitre_id ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-mono text-xs text-cyan-400">
|
||||||
|
{test.technique_mitre_id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate max-w-[160px]">
|
||||||
|
{test.technique_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||||
|
testStateBadgeColors[test.state]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testStateLabels[test.state]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||||
|
{currentTeamForState(test.state)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||||
|
{test.platform || "-"}
|
||||||
|
</td>
|
||||||
|
{/* Waiting time — how long since Red submitted to Blue */}
|
||||||
|
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
||||||
|
{test.state === "blue_evaluating" ? (
|
||||||
|
<span className="font-mono text-indigo-400">
|
||||||
|
{formatElapsed(test.blue_started_at)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
||||||
|
{formatDate(test.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-400 text-xs whitespace-nowrap">
|
||||||
|
{formatDate(lastActivityDate(test))}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pl-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/tests/${test.id}`);
|
||||||
|
}}
|
||||||
|
className="text-sm text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{tests.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-gray-400">{emptyMessage}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export interface Test {
|
|||||||
remediation_status: string | null;
|
remediation_status: string | null;
|
||||||
remediation_assignee: string | null;
|
remediation_assignee: string | null;
|
||||||
|
|
||||||
|
// Assignment fields
|
||||||
|
red_tech_assignee: string | null;
|
||||||
|
blue_tech_assignee: string | null;
|
||||||
|
|
||||||
// Re-test fields
|
// Re-test fields
|
||||||
retest_of: string | null;
|
retest_of: string | null;
|
||||||
retest_count: number;
|
retest_count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user