feat(jira+tests): 5 improvements from review
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:
kitos
2026-05-27 13:07:46 +02:00
parent 43c8b241dc
commit eeee17d260
6 changed files with 169 additions and 19 deletions

View File

@@ -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>

View File

@@ -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) => {