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

This commit is contained in:
kitos
2026-06-19 09:07:39 +02:00
parent 30ca709c11
commit 6147f15238
7 changed files with 400 additions and 128 deletions
+14
View File
@@ -63,6 +63,9 @@ export interface TestListFilters {
created_by?: string;
pending_validation_side?: "red" | "blue";
not_in_any_campaign?: boolean;
assigned_to_me?: boolean;
unassigned_red?: boolean;
unassigned_blue?: boolean;
offset?: number;
limit?: number;
}
@@ -222,6 +225,17 @@ export async function reopenTest(testId: string): Promise<Test> {
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 ───────────────────────────────────────────────────────
/** Get the audit-log timeline for a test. */
+236 -128
View File
@@ -109,8 +109,22 @@ function formatElapsed(dateStr: string | null | undefined): string {
return `${days}d ${hours % 24}h`;
}
/* ── Shared sort key type ────────────────────────────────────────────── */
type SortKey =
| "name"
| "technique"
| "state"
| "team"
| "platform"
| "created_at"
| "updated_at"
| "waiting_time";
/* ── Component ──────────────────────────────────────────────────────── */
const isTechRole = (role?: string) => role === "red_tech" || role === "blue_tech";
export default function TestsPage() {
const navigate = useNavigate();
const { user } = useAuth();
@@ -118,6 +132,8 @@ export default function TestsPage() {
const canCreate =
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
const techRole = isTechRole(user?.role);
// ── Filter state ──────────────────────────────────────────────────
const [stateFilter, setStateFilter] = useState<TestState | "">("");
const [platformFilter, setPlatformFilter] = useState("");
@@ -125,15 +141,6 @@ export default function TestsPage() {
const [showMyTasks, setShowMyTasks] = useState(false);
// ── Sort state ────────────────────────────────────────────────────
type SortKey =
| "name"
| "technique"
| "state"
| "team"
| "platform"
| "created_at"
| "updated_at"
| "waiting_time";
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -290,6 +297,52 @@ export default function TestsPage() {
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 ─────────────────────────────────────────────
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return "-";
@@ -425,8 +478,8 @@ export default function TestsPage() {
{/* ── Filters Bar ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* My tasks toggle */}
{user?.role !== "admin" && user?.role !== "viewer" && (
{/* My tasks toggle — hidden for tech roles (they have the two-queue view below) */}
{user?.role !== "admin" && user?.role !== "viewer" && !techRole && (
<button
onClick={() => {
setShowMyTasks(!showMyTasks);
@@ -525,125 +578,180 @@ export default function TestsPage() {
)}
</div>
{/* ── Tests Table ───────────────────────────────────────────────── */}
<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>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
{mainTableColumns.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">
{showMyTasks
? "No pending tasks for your role."
: "No tests found matching your filters."}
{/* ── Tests Table / Two-Queue View ─────────────────────────────── */}
{techRole ? (
/* Two-section queue for red_tech and blue_tech */
<div className="space-y-6">
{/* Available 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">Available Tests</h2>
<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">
{availableTests.length}
</span>
</div>
<span className="text-xs text-gray-500">Unassigned pick one to claim it</span>
</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>
) : (
/* 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>
);
}
+4
View File
@@ -110,6 +110,10 @@ export interface Test {
remediation_status: string | null;
remediation_assignee: string | null;
// Assignment fields
red_tech_assignee: string | null;
blue_tech_assignee: string | null;
// Re-test fields
retest_of: string | null;
retest_count: number;