feat(jira+tests): 5 improvements from review
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
1. Jira status → In Progress on Start Execution
- push_test_event calls set_issue_status("In Progress") when
new_state == "red_executing" (non-fatal, separate try/except)
2. Jira assignee set on Start Execution
- assign_issue() called with actor.jira_account_id when operator
clicks Start (non-fatal)
3. Standalone tests parent ticket (OFS-20798)
- New jira.parent_ticket_standalone config key
- get_jira_parent_ticket_standalone() falls back to parent_ticket
- auto_create_test_issue uses standalone parent for non-campaign tests
- Exposed in /system/jira-config GET+PATCH and SettingsPage UI
4. Tests table: Created + Updated columns
- Add Created column (created_at), fix Updated to show updated_at
- Both use UTC-aware date parsing (append Z if no tz suffix)
- updated_at added to Test TypeScript interface
5. Sortable columns in tests table
- All 7 columns sortable: Name, Technique, State, Current Team,
Platform, Created, Updated
- Click to sort asc, click again to reverse; ChevronUp/Down indicator
- Default sort: Created desc (newest first)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,7 @@ export interface JiraConfigOut {
|
||||
url: string;
|
||||
project_key: string;
|
||||
parent_ticket: string;
|
||||
parent_ticket_standalone: string;
|
||||
}
|
||||
|
||||
export interface JiraConfigUpdate {
|
||||
@@ -169,6 +170,7 @@ export interface JiraConfigUpdate {
|
||||
url?: string;
|
||||
project_key?: string;
|
||||
parent_ticket?: string;
|
||||
parent_ticket_standalone?: string;
|
||||
}
|
||||
|
||||
export async function getJiraConfig(): Promise<JiraConfigOut> {
|
||||
|
||||
@@ -1218,16 +1218,30 @@ function JiraConfigSection() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">Parent Ticket (optional)</label>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">Campaign Parent Ticket (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(form.parent_ticket !== undefined ? form.parent_ticket : (cfg?.parent_ticket ?? ""))}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, parent_ticket: e.target.value }))}
|
||||
placeholder="SEC-100"
|
||||
placeholder="OFS-9107"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
If set, all test tickets will be created as subtasks of this issue
|
||||
Campaign tickets are nested under this issue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-cyan-400">Standalone Tests Parent Ticket (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(form.parent_ticket_standalone !== undefined ? form.parent_ticket_standalone : (cfg?.parent_ticket_standalone ?? ""))}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, parent_ticket_standalone: e.target.value }))}
|
||||
placeholder="OFS-20798"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
Play,
|
||||
Shield,
|
||||
Search,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { getTests, type TestListFilters } from "../api/tests";
|
||||
import type { Test, TestState } from "../types/models";
|
||||
@@ -83,6 +86,20 @@ export default function TestsPage() {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [showMyTasks, setShowMyTasks] = useState(false);
|
||||
|
||||
// ── Sort state ────────────────────────────────────────────────────
|
||||
type SortKey = "name" | "technique" | "state" | "team" | "platform" | "created_at" | "updated_at";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// Build API filters
|
||||
const filters = useMemo<TestListFilters>(() => {
|
||||
const f: TestListFilters = { limit: 200 };
|
||||
@@ -153,8 +170,46 @@ export default function TestsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
let av: string = "";
|
||||
let bv: string = "";
|
||||
switch (sortKey) {
|
||||
case "name":
|
||||
av = a.name.toLowerCase();
|
||||
bv = b.name.toLowerCase();
|
||||
break;
|
||||
case "technique":
|
||||
av = (a.technique_mitre_id || "").toLowerCase();
|
||||
bv = (b.technique_mitre_id || "").toLowerCase();
|
||||
break;
|
||||
case "state":
|
||||
av = a.state;
|
||||
bv = b.state;
|
||||
break;
|
||||
case "team":
|
||||
av = currentTeamForState(a.state);
|
||||
bv = currentTeamForState(b.state);
|
||||
break;
|
||||
case "platform":
|
||||
av = (a.platform || "").toLowerCase();
|
||||
bv = (b.platform || "").toLowerCase();
|
||||
break;
|
||||
case "created_at":
|
||||
av = a.created_at || "";
|
||||
bv = b.created_at || "";
|
||||
break;
|
||||
case "updated_at":
|
||||
av = a.updated_at || "";
|
||||
bv = b.updated_at || "";
|
||||
break;
|
||||
}
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [allTests, searchText, showMyTasks, user, stateFilter]);
|
||||
}, [allTests, searchText, showMyTasks, user, stateFilter, sortKey, sortDir]);
|
||||
|
||||
// ── State counters ────────────────────────────────────────────────
|
||||
// Count from allTests (before client search filter) to show accurate pipeline
|
||||
@@ -191,9 +246,11 @@ export default function TestsPage() {
|
||||
const totalTests = allTestsUnfiltered?.length || 0;
|
||||
|
||||
// ── Formatting helpers ─────────────────────────────────────────────
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return "-";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
// Backend returns naive UTC; append Z so JS treats it as UTC
|
||||
const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
||||
return new Date(utc).toLocaleDateString("es-ES", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -412,12 +469,36 @@ export default function TestsPage() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Current Team</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
|
||||
<th className="pb-3 px-4 font-medium text-gray-400">Updated</th>
|
||||
{(
|
||||
[
|
||||
{ key: "name", label: "Name", cls: "pr-4" },
|
||||
{ key: "technique", label: "Technique", cls: "px-4" },
|
||||
{ key: "state", label: "State", cls: "px-4" },
|
||||
{ key: "team", label: "Current Team", cls: "px-4" },
|
||||
{ key: "platform", label: "Platform", cls: "px-4" },
|
||||
{ key: "created_at", label: "Created", cls: "px-4" },
|
||||
{ key: "updated_at", label: "Updated", cls: "px-4" },
|
||||
] as { key: SortKey; label: string; cls: string }[]
|
||||
).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">
|
||||
{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>
|
||||
@@ -460,9 +541,12 @@ export default function TestsPage() {
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
{test.platform || "-"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
<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(test.updated_at)}
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Test {
|
||||
result: TestResult | null;
|
||||
state: TestState;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// Red Team fields
|
||||
red_summary: string | null;
|
||||
|
||||
Reference in New Issue
Block a user