diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index b1fa66c..85472da 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -3052,6 +3052,52 @@ def _kind_allows(kind_n: str, diff_kind: str) -> bool: return dk == kind_n +def _parse_qf(raw: Any) -> dict[str, str]: + """Normalize field query map: {field: value} with safe names only.""" + from .compare_sql import _FIELD_RE + + if raw is None or raw == "": + return {} + obj: Any = raw + if isinstance(raw, str): + s = raw.strip() + if not s: + return {} + try: + obj = json.loads(s) + except Exception: + return {} + if not isinstance(obj, dict): + return {} + out: dict[str, str] = {} + for k, v in obj.items(): + name = str(k or "").strip() + val = str(v or "").strip() + if not name or not val or not _FIELD_RE.match(name): + continue + out[name] = val + return out + + +def _split_kw_and_field_tokens(kw: str) -> tuple[str, dict[str, str]]: + """Parse ``direction:out network:1.1.1.1 foo`` → free kw + field map.""" + from .compare_sql import _FIELD_RE + + free: list[str] = [] + fields: dict[str, str] = {} + for tok in str(kw or "").split(): + if ":" in tok: + name, _, val = tok.partition(":") + name = name.strip() + val = val.strip() + if name and val and _FIELD_RE.match(name): + fields[name] = val + continue + if tok.strip(): + free.append(tok.strip()) + return " ".join(free), fields + + def _kw_match_sql(key_fields: list[str], *, param: str = "kw") -> str: """OR of ILIKE on key fields (and data_json::text fallback).""" from .compare_sql import _FIELD_RE, _safe_field @@ -3068,6 +3114,24 @@ def _kw_match_sql(key_fields: list[str], *, param: str = "kw") -> str: return "(" + " OR ".join(parts) + ")" if parts else f"(lower(data_json::text) LIKE :{param})" +def _field_qf_sql(field_q: dict[str, str], *, prefix: str = "qf") -> tuple[str, dict[str, Any]]: + """AND of ILIKE contains on each field.""" + from .compare_sql import _safe_field + + if not field_q: + return "TRUE", {} + parts: list[str] = [] + params: dict[str, Any] = {} + for i, (name, val) in enumerate(field_q.items()): + sf = _safe_field(name) + key = f"{prefix}_{i}" + params[key] = f"%{val.lower()}%" + parts.append( + f"lower(trim(both from coalesce(data_json->>'{sf}', ''))) LIKE :{key}" + ) + return "(" + " AND ".join(parts) + ")", params + + def _load_metric_rows_for_search( db: Session, *, @@ -3075,10 +3139,11 @@ def _load_metric_rows_for_search( metric_id: str, row_filters: list[dict[str, Any]] | None, key_fields: list[str], - kw: str, + kw: str = "", + field_q: dict[str, str] | None = None, cap: int = _LIVE_SEARCH_LOAD_CAP, ) -> tuple[list[dict[str, Any]], bool]: - """Load rows matching sheet filters + kw. Returns (rows, truncated).""" + """Load rows matching sheet filters + optional free kw / field_q. Returns (rows, truncated).""" from .compare_sql import ( _dialect_is_postgres, _filters_sql_compatible, @@ -3090,25 +3155,31 @@ def _load_metric_rows_for_search( bid = str(batch_id or "").strip() mid = str(metric_id or "").strip() needle = str(kw or "").strip() - if not bid or not mid or not needle: + fq = {k: v for k, v in (field_q or {}).items() if str(v or "").strip()} + if not bid or not mid or (not needle and not fq): return [], False lim = max(1, min(int(cap), _LIVE_SEARCH_LOAD_CAP)) filters = [f for f in (row_filters or []) if isinstance(f, dict)] - like = f"%{needle.lower()}%" if _dialect_is_postgres(db): filter_sql, filter_params = ("TRUE", {}) if filters and _filters_sql_compatible(filters): filter_sql, filter_params = compile_row_filters_sql(filters) - kw_sql = _kw_match_sql(key_fields) - # Fetch lim+1 to detect truncation - params = { + qf_sql, qf_params = _field_qf_sql(fq) + search_parts = [qf_sql] + params: dict[str, Any] = { "bid": bid, "mid": mid, - "kw": like, "lim": lim + 1, **filter_params, + **qf_params, } + if needle: + params["kw"] = f"%{needle.lower()}%" + search_parts.append(_kw_match_sql(key_fields)) + search_sql = " AND ".join(f"({p})" for p in search_parts if p and p != "TRUE") + if not search_sql: + search_sql = "TRUE" rows = db.execute( sql_text( f""" @@ -3117,7 +3188,7 @@ def _load_metric_rows_for_search( WHERE batch_id = :bid AND metric_id = :mid AND ({filter_sql}) - AND ({kw_sql}) + AND ({search_sql}) ORDER BY seq ASC, id ASC LIMIT :lim """ @@ -3178,21 +3249,30 @@ def _load_metric_rows_for_search( if filters and not all(row_matches_filter(row, f) for f in filters): db.expunge(r) continue - hit = False - for kf in key_set: - if needle_l in str(row.get(kf) or "").lower(): - hit = True - break - if not hit: - blob = json.dumps(data, ensure_ascii=False, default=str).lower() - hit = needle_l in blob - if not hit: - db.expunge(r) - continue + if fq: + ok_f = True + for fname, fval in fq.items(): + if fval.lower() not in str(row.get(fname) or "").lower(): + ok_f = False + break + if not ok_f: + db.expunge(r) + continue + if needle_l: + hit = False + for kf in key_set: + if needle_l in str(row.get(kf) or "").lower(): + hit = True + break + if not hit: + blob = json.dumps(data, ensure_ascii=False, default=str).lower() + hit = needle_l in blob + if not hit: + db.expunge(r) + continue out.append(row) db.expunge(r) if len(out) >= lim: - # peek one more? truncated = True break return out, truncated @@ -3204,7 +3284,8 @@ def _live_search_sheet_diffs( sheet: dict[str, Any], *, kind: str, - kw: str, + kw: str = "", + field_q: dict[str, str] | None = None, page: int, page_size: int, ) -> dict[str, Any]: @@ -3236,6 +3317,11 @@ def _live_search_sheet_diffs( ) break + free_kw, tok_fields = _split_kw_and_field_tokens(kw) + merged_q = {**tok_fields, **(field_q or {})} + # Drop empty + merged_q = {k: v for k, v in merged_q.items() if str(v or "").strip()} + if not key_fields or not mid_src: return { "total": 0, @@ -3260,7 +3346,8 @@ def _live_search_sheet_diffs( metric_id=mid_src, row_filters=row_filters, key_fields=key_fields, - kw=kw, + kw=free_kw, + field_q=merged_q, ) after_rows, trunc_a = _load_metric_rows_for_search( db, @@ -3268,7 +3355,8 @@ def _live_search_sheet_diffs( metric_id=mid_src, row_filters=row_filters, key_fields=key_fields, - kw=kw, + kw=free_kw, + field_q=merged_q, ) result = compare_rows( before_rows=before_rows, @@ -3314,6 +3402,7 @@ def list_run_diffs( metric_id: str = "", kind: str = "diff", kw: str = "", + qf: Any = None, page: int = 1, page_size: int = 100, ) -> dict[str, Any]: @@ -3324,6 +3413,12 @@ def list_run_diffs( size_n = max(1, min(500, int(page_size or 100))) kind_n = (kind or "diff").strip().lower() kw_n = (kw or "").strip() + field_q = _parse_qf(qf) + # Also accept field:value tokens inside kw + free_from_kw, tok_fields = _split_kw_and_field_tokens(kw_n) + if tok_fields: + field_q = {**tok_fields, **field_q} + kw_n = free_from_kw summary = dict(r.summary_json or {}) tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None @@ -3332,14 +3427,15 @@ def list_run_diffs( sheet = _lookup_sheet(sheets, asked) if asked else (sheets[0] if sheets else None) mid = sheet_key(sheet) if sheet else (asked or str(r.metric_id or "")) - # Unified search: any kw → live source lookup; kind tab only filters. - if kw_n and sheet: + # Unified search: kw and/or field filters → live source lookup; kind tab only filters. + if (kw_n or field_q) and sheet: return _live_search_sheet_diffs( db, r, sheet, kind=kind_n, kw=kw_n, + field_q=field_q, page=page_n, page_size=size_n, ) diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 48af725..20a0319 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -763,6 +763,7 @@ def api_list_run_diffs( metric_id: str = "", kind: str = "diff", kw: str = "", + qf: str = "", page: int = 1, page_size: int = 100, db: Session = Depends(get_db), @@ -773,6 +774,7 @@ def api_list_run_diffs( metric_id=metric_id, kind=kind, kw=kw, + qf=qf, page=page, page_size=page_size, ) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1367d79..a72a130 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -510,8 +510,9 @@ const en = { ranWithDuration: "Compare finished ({{s}}s)", unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.", unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.", - liveSearchHint: "Search: live verdict from source batches (tabs only filter kind). Works for fail, success, and added.", - liveSearchTruncatedHint: "Search results truncated — narrow the query (full prefix / neighbor).", + liveSearchHint: "Field filters look up source batches live; tabs only filter kind. E.g. direction=out, network=1.1.1.1.", + liveSearchTruncatedHint: "Search results truncated — narrow the field filters.", + clearKeyFilters: "Clear fields", storeUnchanged: "Store success rows", storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)", storeUnchangedSample: "Sample only (up to 5k keys + hydrate)", @@ -545,7 +546,7 @@ const en = { pickRun: "Select run…", runCount: "{{n}} runs", resultEmpty: "No matching diff rows", - resultFilterPh: "Search prefix / neighbor / RD… (live source lookup)", + resultFilterPh: "Free text, or direction:out network:1.1.1.1", kindAll: "All", kindDiff: "Fail", kindAdded: "Added", @@ -577,7 +578,7 @@ const en = { filterFailField: "Failed compare field", filterFailFieldAll: "Any failed field", resultEmpty: "No matching rows", - resultFilterPh: "Search prefix / neighbor / RD… (live source lookup)", + resultFilterPh: "Free text, or direction:out network:1.1.1.1", diffCount: "{{n}} failed", passRate: "Pass rate", passOk: "All passed", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index bd7c3e9..5247a88 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -509,8 +509,9 @@ const zh = { ranWithDuration: "比对完成(耗时 {{s}} 秒)", unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。", unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。", - liveSearchHint: "搜索:原表即时判定(页签只过滤种类)。失败/成功/新增均可查到。", - liveSearchTruncatedHint: "搜索结果已截断,请收窄条件(如完整前缀 / neighbor)。", + liveSearchHint: "按字段回查原表即时判定;页签只过滤种类。例:direction=out,network=1.1.1.1。", + liveSearchTruncatedHint: "搜索结果已截断,请再收窄字段条件。", + clearKeyFilters: "清空字段", storeUnchanged: "成功行保存", storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)", storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)", @@ -544,7 +545,7 @@ const zh = { pickRun: "选择比对记录…", runCount: "{{n}} 次", resultEmpty: "无匹配失败/结果行", - resultFilterPh: "搜索前缀 / neighbor / RD…(回查原表)", + resultFilterPh: "自由词,或 direction:out network:1.1.1.1", kindAll: "全部", kindDiff: "失败", kindAdded: "新增", diff --git a/web/src/index.css b/web/src/index.css index ffbdbfa..d70fc9d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -12504,6 +12504,36 @@ body:has(.bs-cmp-board:fullscreen) { white-space: nowrap; } +.bs-cmp-key-filters { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + align-items: flex-end; + width: 100%; +} + +.bs-cmp-key-filter { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 110px; + max-width: 160px; + flex: 0 1 140px; +} + +.bs-cmp-key-filter > span { + font-size: 11px; + line-height: 1.2; +} + +.bs-cmp-key-filter .input, +.bs-cmp-key-filter [data-slot="input"], +.bs-cmp-key-filter input { + min-width: 0; + width: 100%; + font-size: 12px; +} + .bs-cmp-kind-pills { display: flex; flex-wrap: wrap; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index af63c82..4ebbf6d 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -843,6 +843,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); const debouncedResultKw = useDebouncedValue(resultKw, 300); + const [resultKeyFilters, setResultKeyFilters] = useState>({}); + const debouncedKeyFiltersJson = useDebouncedValue(JSON.stringify(resultKeyFilters), 300); + const debouncedKeyFilters = useMemo(() => { + try { + const o = JSON.parse(debouncedKeyFiltersJson || "{}") as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(o || {})) { + if (String(k || "").trim() && String(v || "").trim()) out[String(k)] = String(v).trim(); + } + return out; + } catch { + return {} as Record; + } + }, [debouncedKeyFiltersJson]); + const hasResultSearch = + Boolean(debouncedResultKw.trim()) || Object.keys(debouncedKeyFilters).length > 0; const [resultPage, setResultPage] = useState(1); const [resultPageSize, setResultPageSize] = useState(100); const [resultTotal, setResultTotal] = useState(0); @@ -1058,10 +1074,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage [runSheets, resultSheetId], ); - // Reset page when sheet / filter / page size changes + // Clear field filters when switching sheet + useEffect(() => { + setResultKeyFilters({}); + }, [resultSheetId]); + + // Reset page when sheet / filter / page size changes useEffect(() => { setResultPage(1); - }, [resultSheetId, kindFilter, debouncedResultKw, resultPageSize, runDetail?.id]); + }, [ + resultSheetId, + kindFilter, + debouncedResultKw, + debouncedKeyFiltersJson, + resultPageSize, + runDetail?.id, + ]); useEffect(() => { const runId = String(runDetail?.id || ""); @@ -1085,6 +1113,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage metricId: mid, kind: kindFilter, kw: debouncedResultKw.trim(), + qf: debouncedKeyFilters, page: resultPage, pageSize: resultPageSize, }); @@ -1120,6 +1149,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage activeRunSheet?.status, kindFilter, debouncedResultKw, + debouncedKeyFiltersJson, resultPage, resultPageSize, jobDetailTab, @@ -1274,7 +1304,17 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage kindFilter === "added" || kindFilter === "removed" || kindFilter === "changed"; - const isLiveSearch = diffsSource === "live" && Boolean(debouncedResultKw.trim()); + const isLiveSearch = diffsSource === "live" && hasResultSearch; + const resultSearchKeyFields = useMemo(() => { + const keys = (activeRunSheet?.key_fields || []).map((f) => String(f || "").trim()).filter(Boolean); + // Prefer BGP-ish fields first for compact UI + const prefer = ["direction", "neighbor", "network", "rd", "afi", "vrf", "local_as"]; + const ranked = [ + ...prefer.filter((p) => keys.includes(p)), + ...keys.filter((k) => !prefer.includes(k)), + ]; + return ranked.slice(0, 8); + }, [activeRunSheet?.key_fields]); const resultEmptyColSpan = 1 + (showFailCol ? 1 : 0) + @@ -3565,6 +3605,45 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage {diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`} + {resultSearchKeyFields.length ? ( +
+ {resultSearchKeyFields.map((f) => ( + + ))} + {Object.keys(resultKeyFilters).some((k) => + String(resultKeyFilters[k] || "").trim(), + ) ? ( + + ) : null} +
+ ) : null} {isLiveSearch ? (

{diffsTruncated diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 6facb74..c4aec6b 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -2170,6 +2170,8 @@ export const bizCompareListRunDiffs = (params: { metricId?: string; kind?: string; kw?: string; + /** Field filters e.g. { direction: "out", network: "1.1.1.1" } */ + qf?: Record; page?: number; pageSize?: number; }) => { @@ -2177,6 +2179,15 @@ export const bizCompareListRunDiffs = (params: { if (params.metricId) p.set("metric_id", params.metricId); if (params.kind) p.set("kind", params.kind); if (params.kw) p.set("kw", params.kw); + if (params.qf && Object.keys(params.qf).length) { + const cleaned: Record = {}; + for (const [k, v] of Object.entries(params.qf)) { + const key = String(k || "").trim(); + const val = String(v || "").trim(); + if (key && val) cleaned[key] = val; + } + if (Object.keys(cleaned).length) p.set("qf", JSON.stringify(cleaned)); + } p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100))))); return apiGet<{