Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/api/v1/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ async def post_failure_process(
@router.get("/inherit-batch-options", response_model=InheritBatchOptionsResponse)
async def get_inherit_batch_options_endpoint(
exclude_batch: Optional[str] = Query(None, description="排除的批次"),
q: Optional[str] = Query(None, max_length=200, description="批次子串搜索;为空时返回最近 100 个"),
db: AsyncSession = Depends(get_db),
payload: dict = Depends(get_current_user),
):
"""获取继承弹窗的批次选项,排除当前批次,按时间倒序。"""
return await get_inherit_batch_options(db, exclude_batch)
"""获取继承弹窗的批次选项,排除当前批次,按时间倒序;支持 q 全库子串搜索。"""
return await get_inherit_batch_options(db, exclude_batch, q)


@router.get("/inherit-source-options", response_model=InheritSourceOptionsResponse)
Expand Down
27 changes: 24 additions & 3 deletions backend/services/inherit_failure_reason_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,42 @@ async def _mysql_release_lock(db: AsyncSession, lock_name: str) -> None:
await db.execute(text("SELECT RELEASE_LOCK(:n)"), {"n": lock_name})


_LIKE_ESCAPE_CHAR = "\\"
_DEFAULT_BATCH_OPTIONS_LIMIT = 100


def _like_escape_literal(value: str) -> str:
return (
value.replace(_LIKE_ESCAPE_CHAR, _LIKE_ESCAPE_CHAR + _LIKE_ESCAPE_CHAR)
.replace("%", _LIKE_ESCAPE_CHAR + "%")
.replace("_", _LIKE_ESCAPE_CHAR + "_")
)


async def get_inherit_batch_options(
db: AsyncSession, exclude_batch: Optional[str] = None
db: AsyncSession,
exclude_batch: Optional[str] = None,
q: Optional[str] = None,
) -> InheritBatchOptionsResponse:
"""获取继承弹窗的批次选项,排除当前批次,按时间倒序。仅返回 20 开头的批次(与历史列表默认逻辑一致)。"""
"""获取继承弹窗的批次选项,排除当前批次,按时间倒序。仅返回 20 开头的批次。

无关键词时返回最近 100 个去重批次;有关键词时按 start_time 子串在全库检索(仍最多 100 条)。
"""
stmt = (
select(ph.start_time)
.where(ph.start_time.is_not(None))
.where(ph.start_time != "")
.where(ph.start_time.like("20%"))
.distinct()
.order_by(ph.start_time.desc())
.limit(100)
.limit(_DEFAULT_BATCH_OPTIONS_LIMIT)
)
if exclude_batch and str(exclude_batch).strip():
stmt = stmt.where(ph.start_time != exclude_batch.strip())
q_stripped = str(q).strip() if q is not None else ""
if q_stripped:
pattern = f"%{_like_escape_literal(q_stripped)}%"
stmt = stmt.where(ph.start_time.like(pattern, escape=_LIKE_ESCAPE_CHAR))
result = await db.execute(stmt)
batches = [r[0] for r in result.all() if r[0]]
return InheritBatchOptionsResponse(batches=batches)
Expand Down
65 changes: 42 additions & 23 deletions frontend/src/pages/history/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
const [inheritSubmitLoading, setInheritSubmitLoading] = useState(false);
/** 继承提交进行中时用于中止 HTTP,避免点取消后仍占服务端 GET_LOCK */
const inheritAbortRef = useRef<AbortController | null>(null);
/** 源批次远程搜索防抖 */
const inheritBatchSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Comment on lines +318 to +319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clean up pending search timers on every close path.

The timer is cleared only by the cancel handler. If the user confirms or navigates away before 300 ms, the callback still performs a request and may update state for a closed or unmounted page. Add unmount cleanup and clear the timer when the modal closes successfully as well.

Also applies to: 976-983

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/history/HistoryPage.tsx` around lines 318 - 319, Update
the inherit-batch search flow around inheritBatchSearchTimerRef to clear any
pending timer during component unmount cleanup and whenever the modal closes
after successful confirmation, not only in the cancel handler. Preserve the
existing debounce behavior while preventing callbacks from issuing requests or
updating state after closure or unmount.

const [oneClickLoading, setOneClickLoading] = useState(false);
const [bugNotifyLoading, setBugNotifyLoading] = useState(false);
const [reportModalVisible, setReportModalVisible] = useState(false);
Expand Down Expand Up @@ -955,6 +957,32 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
processForm.resetFields();
};

const fetchInheritBatchOptions = async (q?: string, showError = false) => {
setInheritBatchOptionsLoading(true);
try {
const res = await historyApi.inheritBatchOptions(currentBatch, q);
setInheritBatchOptions(res.batches ?? []);
} catch (e: unknown) {
setInheritBatchOptions([]);
if (showError) {
const err = e as { response?: { data?: { detail?: string } }; message?: string };
message.error(err?.response?.data?.detail || err?.message || "获取批次选项失败");
}
} finally {
setInheritBatchOptionsLoading(false);
}
};

const handleInheritBatchSearch = (input: string) => {
if (inheritBatchSearchTimerRef.current) {
clearTimeout(inheritBatchSearchTimerRef.current);
}
inheritBatchSearchTimerRef.current = setTimeout(() => {
const trimmed = input.trim();
void fetchInheritBatchOptions(trimmed || undefined);
}, 300);
Comment on lines +960 to +983

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ignore stale remote-search responses.

Debouncing does not cancel requests already in flight. If an older query resolves after a newer query, its results overwrite the current options; with filterOption={false}, users can see or select batches unrelated to the current search.

Track a request generation or abort the previous request, guarding both options and loading-state updates.

Proposed request-generation guard
+  const inheritBatchRequestIdRef = useRef(0);
+
   const fetchInheritBatchOptions = async (q?: string, showError = false) => {
+    const requestId = ++inheritBatchRequestIdRef.current;
     setInheritBatchOptionsLoading(true);
     try {
       const res = await historyApi.inheritBatchOptions(currentBatch, q);
+      if (requestId !== inheritBatchRequestIdRef.current) return;
       setInheritBatchOptions(res.batches ?? []);
     } catch (e: unknown) {
+      if (requestId !== inheritBatchRequestIdRef.current) return;
       setInheritBatchOptions([]);
       // existing error handling
     } finally {
-      setInheritBatchOptionsLoading(false);
+      if (requestId === inheritBatchRequestIdRef.current) {
+        setInheritBatchOptionsLoading(false);
+      }
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchInheritBatchOptions = async (q?: string, showError = false) => {
setInheritBatchOptionsLoading(true);
try {
const res = await historyApi.inheritBatchOptions(currentBatch, q);
setInheritBatchOptions(res.batches ?? []);
} catch (e: unknown) {
setInheritBatchOptions([]);
if (showError) {
const err = e as { response?: { data?: { detail?: string } }; message?: string };
message.error(err?.response?.data?.detail || err?.message || "获取批次选项失败");
}
} finally {
setInheritBatchOptionsLoading(false);
}
};
const handleInheritBatchSearch = (input: string) => {
if (inheritBatchSearchTimerRef.current) {
clearTimeout(inheritBatchSearchTimerRef.current);
}
inheritBatchSearchTimerRef.current = setTimeout(() => {
const trimmed = input.trim();
void fetchInheritBatchOptions(trimmed || undefined);
}, 300);
const inheritBatchRequestIdRef = useRef(0);
const fetchInheritBatchOptions = async (q?: string, showError = false) => {
const requestId = ++inheritBatchRequestIdRef.current;
setInheritBatchOptionsLoading(true);
try {
const res = await historyApi.inheritBatchOptions(currentBatch, q);
if (requestId !== inheritBatchRequestIdRef.current) return;
setInheritBatchOptions(res.batches ?? []);
} catch (e: unknown) {
if (requestId !== inheritBatchRequestIdRef.current) return;
setInheritBatchOptions([]);
if (showError) {
const err = e as { response?: { data?: { detail?: string } }; message?: string };
message.error(err?.response?.data?.detail || err?.message || "获取批次选项失败");
}
} finally {
if (requestId === inheritBatchRequestIdRef.current) {
setInheritBatchOptionsLoading(false);
}
}
};
const handleInheritBatchSearch = (input: string) => {
if (inheritBatchSearchTimerRef.current) {
clearTimeout(inheritBatchSearchTimerRef.current);
}
inheritBatchSearchTimerRef.current = setTimeout(() => {
const trimmed = input.trim();
void fetchInheritBatchOptions(trimmed || undefined);
}, 300);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/history/HistoryPage.tsx` around lines 960 - 983, Update
fetchInheritBatchOptions and handleInheritBatchSearch to track a request
generation (or abort the previous request) so stale remote responses cannot
update inheritBatchOptions or loading state after a newer search begins. Guard
both successful and error/finally state updates, while preserving the existing
debounce and error-display behavior for the latest request.

};

const openInheritModal = async () => {
if (!processBtnEnabled) return;
inheritAbortRef.current?.abort();
Expand All @@ -969,16 +997,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
source_pfr_id: undefined,
});
if (showBatchDimension) {
setInheritBatchOptionsLoading(true);
try {
const res = await historyApi.inheritBatchOptions(currentBatch);
setInheritBatchOptions(res.batches ?? []);
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } }; message?: string };
message.error(err?.response?.data?.detail || err?.message || "获取批次选项失败");
} finally {
setInheritBatchOptionsLoading(false);
}
await fetchInheritBatchOptions(undefined, true);
} else {
setInheritSourceOptionsLoading(true);
try {
Expand Down Expand Up @@ -1051,6 +1070,10 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
const handleInheritModalCancel = () => {
inheritAbortRef.current?.abort();
inheritAbortRef.current = null;
if (inheritBatchSearchTimerRef.current) {
clearTimeout(inheritBatchSearchTimerRef.current);
inheritBatchSearchTimerRef.current = null;
}
setInheritSubmitLoading(false);
setInheritModalVisible(false);
inheritForm.resetFields();
Expand Down Expand Up @@ -2316,15 +2339,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
inheritForm.setFieldValue("source_pfr_id", undefined);
setInheritSourceRecords([]);
if (all.inherit_mode === "batch" && showBatchDimension) {
setInheritBatchOptionsLoading(true);
try {
const res = await historyApi.inheritBatchOptions(currentBatch);
setInheritBatchOptions(res.batches ?? []);
} catch {
setInheritBatchOptions([]);
} finally {
setInheritBatchOptionsLoading(false);
}
await fetchInheritBatchOptions();
} else {
fetchInheritSourceOptions();
}
Expand Down Expand Up @@ -2367,13 +2382,17 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
rules={[{ required: true, message: "请选择源批次" }]}
>
<Select
placeholder="请选择要继承的历史批次"
placeholder="请选择或输入批次搜索"
allowClear
showSearch
loading={inheritBatchOptionsLoading}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
filterOption={false}
onSearch={handleInheritBatchSearch}
onDropdownVisibleChange={(open) => {
if (open && inheritBatchOptions.length === 0) {
void fetchInheritBatchOptions();
}
}}
options={inheritBatchOptions.map((v) => ({ label: v, value: v }))}
/>
</Form.Item>
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ export const historyApi = {
failureProcess(data: FailureProcessRequest): Promise<{ success: boolean; message: string }> {
return request.post("/history/failure-process", data) as any;
},
/** 获取继承弹窗批次选项 */
inheritBatchOptions(excludeBatch?: string): Promise<{ batches: string[] }> {
const params = excludeBatch ? { exclude_batch: excludeBatch } : {};
/** 获取继承弹窗批次选项;q 为空返回最近 100 批,有值则按子串全库搜索 */
inheritBatchOptions(excludeBatch?: string, q?: string): Promise<{ batches: string[] }> {
const params: Record<string, string> = {};
if (excludeBatch) params.exclude_batch = excludeBatch;
if (q && q.trim()) params.q = q.trim();
return request.get("/history/inherit-batch-options", { params }) as any;
},
/** 获取继承弹窗用例维度源选择三字段选项 */
Expand Down
14 changes: 12 additions & 2 deletions spec/10_inherit_failure_reason_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@

| 字段 | 控件类型 | 必填 | 选项来源 | 说明 |
|------|----------|------|----------|------|
| 源批次 | 下拉单选 Select(可搜索) | 是 | `pipeline_history.start_time` 去重,仅 20 开头批次,排除当前批次按时间倒序 | 选择要继承的历史轮次 |
| 源批次 | 下拉单选 Select(可搜索,远程) | 是 | `GET /history/inherit-batch-options`:默认最近 100 个去重 `start_time`(仅 20 开头、排除当前批次按时间倒序);输入关键词时按子串全库检索 | 选择要继承的历史轮次 |

- 当前筛选批次:勾选用例所在批次的 `start_time`(单条时即该条;多条同批次时即该批次;跨批次时无「当前批次」,不展示批次维度)
- 默认下拉仅展示最近 100 批;在输入框输入任意批次子串后,只要库中存在即可被搜出(服务端 `q` 参数)

### 4.4 用例维度:源选择(筛选 → 选择)

Expand Down Expand Up @@ -261,7 +262,16 @@
→ 返回成功 → 前端刷新表格 → 关闭弹窗 → 成功提示
```

## 10. 用例维度接口(补充)
## 10. 接口补充

### 10.0 批次选项接口 `GET /history/inherit-batch-options`

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| exclude_batch | string | 否 | 排除的目标批次(当前筛选批次) |
| q | string | 否 | 批次子串;为空或不传时返回最近 100 个去重批次;有值时按 `start_time` 子串全库检索(仍最多 100 条) |

约束:仅 `start_time` 以 `20` 开头的批次;按时间倒序。

### 10.1 选项接口 `GET /history/inherit-source-options`

Expand Down
Loading