mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
Add per-field compare result filters for live source lookup.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b8c0f96982
commit
7b5dd3eada
7 changed files with 256 additions and 36 deletions
|
|
@ -3052,6 +3052,52 @@ def _kind_allows(kind_n: str, diff_kind: str) -> bool:
|
||||||
return dk == kind_n
|
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:
|
def _kw_match_sql(key_fields: list[str], *, param: str = "kw") -> str:
|
||||||
"""OR of ILIKE on key fields (and data_json::text fallback)."""
|
"""OR of ILIKE on key fields (and data_json::text fallback)."""
|
||||||
from .compare_sql import _FIELD_RE, _safe_field
|
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})"
|
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(
|
def _load_metric_rows_for_search(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -3075,10 +3139,11 @@ def _load_metric_rows_for_search(
|
||||||
metric_id: str,
|
metric_id: str,
|
||||||
row_filters: list[dict[str, Any]] | None,
|
row_filters: list[dict[str, Any]] | None,
|
||||||
key_fields: list[str],
|
key_fields: list[str],
|
||||||
kw: str,
|
kw: str = "",
|
||||||
|
field_q: dict[str, str] | None = None,
|
||||||
cap: int = _LIVE_SEARCH_LOAD_CAP,
|
cap: int = _LIVE_SEARCH_LOAD_CAP,
|
||||||
) -> tuple[list[dict[str, Any]], bool]:
|
) -> 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 (
|
from .compare_sql import (
|
||||||
_dialect_is_postgres,
|
_dialect_is_postgres,
|
||||||
_filters_sql_compatible,
|
_filters_sql_compatible,
|
||||||
|
|
@ -3090,25 +3155,31 @@ def _load_metric_rows_for_search(
|
||||||
bid = str(batch_id or "").strip()
|
bid = str(batch_id or "").strip()
|
||||||
mid = str(metric_id or "").strip()
|
mid = str(metric_id or "").strip()
|
||||||
needle = str(kw 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
|
return [], False
|
||||||
lim = max(1, min(int(cap), _LIVE_SEARCH_LOAD_CAP))
|
lim = max(1, min(int(cap), _LIVE_SEARCH_LOAD_CAP))
|
||||||
filters = [f for f in (row_filters or []) if isinstance(f, dict)]
|
filters = [f for f in (row_filters or []) if isinstance(f, dict)]
|
||||||
like = f"%{needle.lower()}%"
|
|
||||||
|
|
||||||
if _dialect_is_postgres(db):
|
if _dialect_is_postgres(db):
|
||||||
filter_sql, filter_params = ("TRUE", {})
|
filter_sql, filter_params = ("TRUE", {})
|
||||||
if filters and _filters_sql_compatible(filters):
|
if filters and _filters_sql_compatible(filters):
|
||||||
filter_sql, filter_params = compile_row_filters_sql(filters)
|
filter_sql, filter_params = compile_row_filters_sql(filters)
|
||||||
kw_sql = _kw_match_sql(key_fields)
|
qf_sql, qf_params = _field_qf_sql(fq)
|
||||||
# Fetch lim+1 to detect truncation
|
search_parts = [qf_sql]
|
||||||
params = {
|
params: dict[str, Any] = {
|
||||||
"bid": bid,
|
"bid": bid,
|
||||||
"mid": mid,
|
"mid": mid,
|
||||||
"kw": like,
|
|
||||||
"lim": lim + 1,
|
"lim": lim + 1,
|
||||||
**filter_params,
|
**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(
|
rows = db.execute(
|
||||||
sql_text(
|
sql_text(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -3117,7 +3188,7 @@ def _load_metric_rows_for_search(
|
||||||
WHERE batch_id = :bid
|
WHERE batch_id = :bid
|
||||||
AND metric_id = :mid
|
AND metric_id = :mid
|
||||||
AND ({filter_sql})
|
AND ({filter_sql})
|
||||||
AND ({kw_sql})
|
AND ({search_sql})
|
||||||
ORDER BY seq ASC, id ASC
|
ORDER BY seq ASC, id ASC
|
||||||
LIMIT :lim
|
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):
|
if filters and not all(row_matches_filter(row, f) for f in filters):
|
||||||
db.expunge(r)
|
db.expunge(r)
|
||||||
continue
|
continue
|
||||||
hit = False
|
if fq:
|
||||||
for kf in key_set:
|
ok_f = True
|
||||||
if needle_l in str(row.get(kf) or "").lower():
|
for fname, fval in fq.items():
|
||||||
hit = True
|
if fval.lower() not in str(row.get(fname) or "").lower():
|
||||||
break
|
ok_f = False
|
||||||
if not hit:
|
break
|
||||||
blob = json.dumps(data, ensure_ascii=False, default=str).lower()
|
if not ok_f:
|
||||||
hit = needle_l in blob
|
db.expunge(r)
|
||||||
if not hit:
|
continue
|
||||||
db.expunge(r)
|
if needle_l:
|
||||||
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
|
||||||
out.append(row)
|
out.append(row)
|
||||||
db.expunge(r)
|
db.expunge(r)
|
||||||
if len(out) >= lim:
|
if len(out) >= lim:
|
||||||
# peek one more?
|
|
||||||
truncated = True
|
truncated = True
|
||||||
break
|
break
|
||||||
return out, truncated
|
return out, truncated
|
||||||
|
|
@ -3204,7 +3284,8 @@ def _live_search_sheet_diffs(
|
||||||
sheet: dict[str, Any],
|
sheet: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
kind: str,
|
kind: str,
|
||||||
kw: str,
|
kw: str = "",
|
||||||
|
field_q: dict[str, str] | None = None,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -3236,6 +3317,11 @@ def _live_search_sheet_diffs(
|
||||||
)
|
)
|
||||||
break
|
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:
|
if not key_fields or not mid_src:
|
||||||
return {
|
return {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
|
|
@ -3260,7 +3346,8 @@ def _live_search_sheet_diffs(
|
||||||
metric_id=mid_src,
|
metric_id=mid_src,
|
||||||
row_filters=row_filters,
|
row_filters=row_filters,
|
||||||
key_fields=key_fields,
|
key_fields=key_fields,
|
||||||
kw=kw,
|
kw=free_kw,
|
||||||
|
field_q=merged_q,
|
||||||
)
|
)
|
||||||
after_rows, trunc_a = _load_metric_rows_for_search(
|
after_rows, trunc_a = _load_metric_rows_for_search(
|
||||||
db,
|
db,
|
||||||
|
|
@ -3268,7 +3355,8 @@ def _live_search_sheet_diffs(
|
||||||
metric_id=mid_src,
|
metric_id=mid_src,
|
||||||
row_filters=row_filters,
|
row_filters=row_filters,
|
||||||
key_fields=key_fields,
|
key_fields=key_fields,
|
||||||
kw=kw,
|
kw=free_kw,
|
||||||
|
field_q=merged_q,
|
||||||
)
|
)
|
||||||
result = compare_rows(
|
result = compare_rows(
|
||||||
before_rows=before_rows,
|
before_rows=before_rows,
|
||||||
|
|
@ -3314,6 +3402,7 @@ def list_run_diffs(
|
||||||
metric_id: str = "",
|
metric_id: str = "",
|
||||||
kind: str = "diff",
|
kind: str = "diff",
|
||||||
kw: str = "",
|
kw: str = "",
|
||||||
|
qf: Any = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 100,
|
page_size: int = 100,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -3324,6 +3413,12 @@ def list_run_diffs(
|
||||||
size_n = max(1, min(500, int(page_size or 100)))
|
size_n = max(1, min(500, int(page_size or 100)))
|
||||||
kind_n = (kind or "diff").strip().lower()
|
kind_n = (kind or "diff").strip().lower()
|
||||||
kw_n = (kw or "").strip()
|
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 {})
|
summary = dict(r.summary_json or {})
|
||||||
tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None
|
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)
|
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 ""))
|
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.
|
# Unified search: kw and/or field filters → live source lookup; kind tab only filters.
|
||||||
if kw_n and sheet:
|
if (kw_n or field_q) and sheet:
|
||||||
return _live_search_sheet_diffs(
|
return _live_search_sheet_diffs(
|
||||||
db,
|
db,
|
||||||
r,
|
r,
|
||||||
sheet,
|
sheet,
|
||||||
kind=kind_n,
|
kind=kind_n,
|
||||||
kw=kw_n,
|
kw=kw_n,
|
||||||
|
field_q=field_q,
|
||||||
page=page_n,
|
page=page_n,
|
||||||
page_size=size_n,
|
page_size=size_n,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -763,6 +763,7 @@ def api_list_run_diffs(
|
||||||
metric_id: str = "",
|
metric_id: str = "",
|
||||||
kind: str = "diff",
|
kind: str = "diff",
|
||||||
kw: str = "",
|
kw: str = "",
|
||||||
|
qf: str = "",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 100,
|
page_size: int = 100,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -773,6 +774,7 @@ def api_list_run_diffs(
|
||||||
metric_id=metric_id,
|
metric_id=metric_id,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
kw=kw,
|
kw=kw,
|
||||||
|
qf=qf,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -510,8 +510,9 @@ const en = {
|
||||||
ranWithDuration: "Compare finished ({{s}}s)",
|
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.",
|
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}}.",
|
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.",
|
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 query (full prefix / neighbor).",
|
liveSearchTruncatedHint: "Search results truncated — narrow the field filters.",
|
||||||
|
clearKeyFilters: "Clear fields",
|
||||||
storeUnchanged: "Store success rows",
|
storeUnchanged: "Store success rows",
|
||||||
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
|
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
|
||||||
storeUnchangedSample: "Sample only (up to 5k keys + hydrate)",
|
storeUnchangedSample: "Sample only (up to 5k keys + hydrate)",
|
||||||
|
|
@ -545,7 +546,7 @@ const en = {
|
||||||
pickRun: "Select run…",
|
pickRun: "Select run…",
|
||||||
runCount: "{{n}} runs",
|
runCount: "{{n}} runs",
|
||||||
resultEmpty: "No matching diff rows",
|
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",
|
kindAll: "All",
|
||||||
kindDiff: "Fail",
|
kindDiff: "Fail",
|
||||||
kindAdded: "Added",
|
kindAdded: "Added",
|
||||||
|
|
@ -577,7 +578,7 @@ const en = {
|
||||||
filterFailField: "Failed compare field",
|
filterFailField: "Failed compare field",
|
||||||
filterFailFieldAll: "Any failed field",
|
filterFailFieldAll: "Any failed field",
|
||||||
resultEmpty: "No matching rows",
|
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",
|
diffCount: "{{n}} failed",
|
||||||
passRate: "Pass rate",
|
passRate: "Pass rate",
|
||||||
passOk: "All passed",
|
passOk: "All passed",
|
||||||
|
|
|
||||||
|
|
@ -509,8 +509,9 @@ const zh = {
|
||||||
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
||||||
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
||||||
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
|
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
|
||||||
liveSearchHint: "搜索:原表即时判定(页签只过滤种类)。失败/成功/新增均可查到。",
|
liveSearchHint: "按字段回查原表即时判定;页签只过滤种类。例:direction=out,network=1.1.1.1。",
|
||||||
liveSearchTruncatedHint: "搜索结果已截断,请收窄条件(如完整前缀 / neighbor)。",
|
liveSearchTruncatedHint: "搜索结果已截断,请再收窄字段条件。",
|
||||||
|
clearKeyFilters: "清空字段",
|
||||||
storeUnchanged: "成功行保存",
|
storeUnchanged: "成功行保存",
|
||||||
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
|
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
|
||||||
storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)",
|
storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)",
|
||||||
|
|
@ -544,7 +545,7 @@ const zh = {
|
||||||
pickRun: "选择比对记录…",
|
pickRun: "选择比对记录…",
|
||||||
runCount: "{{n}} 次",
|
runCount: "{{n}} 次",
|
||||||
resultEmpty: "无匹配失败/结果行",
|
resultEmpty: "无匹配失败/结果行",
|
||||||
resultFilterPh: "搜索前缀 / neighbor / RD…(回查原表)",
|
resultFilterPh: "自由词,或 direction:out network:1.1.1.1",
|
||||||
kindAll: "全部",
|
kindAll: "全部",
|
||||||
kindDiff: "失败",
|
kindDiff: "失败",
|
||||||
kindAdded: "新增",
|
kindAdded: "新增",
|
||||||
|
|
|
||||||
|
|
@ -12504,6 +12504,36 @@ body:has(.bs-cmp-board:fullscreen) {
|
||||||
white-space: nowrap;
|
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 {
|
.bs-cmp-kind-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -843,6 +843,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
|
const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
|
||||||
const [resultKw, setResultKw] = useState("");
|
const [resultKw, setResultKw] = useState("");
|
||||||
const debouncedResultKw = useDebouncedValue(resultKw, 300);
|
const debouncedResultKw = useDebouncedValue(resultKw, 300);
|
||||||
|
const [resultKeyFilters, setResultKeyFilters] = useState<Record<string, string>>({});
|
||||||
|
const debouncedKeyFiltersJson = useDebouncedValue(JSON.stringify(resultKeyFilters), 300);
|
||||||
|
const debouncedKeyFilters = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(debouncedKeyFiltersJson || "{}") as Record<string, string>;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
}, [debouncedKeyFiltersJson]);
|
||||||
|
const hasResultSearch =
|
||||||
|
Boolean(debouncedResultKw.trim()) || Object.keys(debouncedKeyFilters).length > 0;
|
||||||
const [resultPage, setResultPage] = useState(1);
|
const [resultPage, setResultPage] = useState(1);
|
||||||
const [resultPageSize, setResultPageSize] = useState(100);
|
const [resultPageSize, setResultPageSize] = useState(100);
|
||||||
const [resultTotal, setResultTotal] = useState(0);
|
const [resultTotal, setResultTotal] = useState(0);
|
||||||
|
|
@ -1058,10 +1074,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
[runSheets, resultSheetId],
|
[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(() => {
|
useEffect(() => {
|
||||||
setResultPage(1);
|
setResultPage(1);
|
||||||
}, [resultSheetId, kindFilter, debouncedResultKw, resultPageSize, runDetail?.id]);
|
}, [
|
||||||
|
resultSheetId,
|
||||||
|
kindFilter,
|
||||||
|
debouncedResultKw,
|
||||||
|
debouncedKeyFiltersJson,
|
||||||
|
resultPageSize,
|
||||||
|
runDetail?.id,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runId = String(runDetail?.id || "");
|
const runId = String(runDetail?.id || "");
|
||||||
|
|
@ -1085,6 +1113,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
metricId: mid,
|
metricId: mid,
|
||||||
kind: kindFilter,
|
kind: kindFilter,
|
||||||
kw: debouncedResultKw.trim(),
|
kw: debouncedResultKw.trim(),
|
||||||
|
qf: debouncedKeyFilters,
|
||||||
page: resultPage,
|
page: resultPage,
|
||||||
pageSize: resultPageSize,
|
pageSize: resultPageSize,
|
||||||
});
|
});
|
||||||
|
|
@ -1120,6 +1149,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
activeRunSheet?.status,
|
activeRunSheet?.status,
|
||||||
kindFilter,
|
kindFilter,
|
||||||
debouncedResultKw,
|
debouncedResultKw,
|
||||||
|
debouncedKeyFiltersJson,
|
||||||
resultPage,
|
resultPage,
|
||||||
resultPageSize,
|
resultPageSize,
|
||||||
jobDetailTab,
|
jobDetailTab,
|
||||||
|
|
@ -1274,7 +1304,17 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
kindFilter === "added" ||
|
kindFilter === "added" ||
|
||||||
kindFilter === "removed" ||
|
kindFilter === "removed" ||
|
||||||
kindFilter === "changed";
|
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 =
|
const resultEmptyColSpan =
|
||||||
1 +
|
1 +
|
||||||
(showFailCol ? 1 : 0) +
|
(showFailCol ? 1 : 0) +
|
||||||
|
|
@ -3565,6 +3605,45 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{resultSearchKeyFields.length ? (
|
||||||
|
<div className="bs-cmp-key-filters" style={{ marginTop: 6 }}>
|
||||||
|
{resultSearchKeyFields.map((f) => (
|
||||||
|
<label key={f} className="bs-cmp-key-filter">
|
||||||
|
<span className="muted">{f}</span>
|
||||||
|
<Input
|
||||||
|
value={resultKeyFilters[f] || ""}
|
||||||
|
placeholder={
|
||||||
|
f === "direction"
|
||||||
|
? "in / out"
|
||||||
|
: f === "network"
|
||||||
|
? "1.1.1.1"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setResultKeyFilters((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v.trim()) delete next[f];
|
||||||
|
else next[f] = v;
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{Object.keys(resultKeyFilters).some((k) =>
|
||||||
|
String(resultKeyFilters[k] || "").trim(),
|
||||||
|
) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
onClick={() => setResultKeyFilters({})}
|
||||||
|
>
|
||||||
|
{t("bizCompare.clearKeyFilters")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{isLiveSearch ? (
|
{isLiveSearch ? (
|
||||||
<p className="muted bm-hint" style={{ margin: "4px 0 0" }}>
|
<p className="muted bm-hint" style={{ margin: "4px 0 0" }}>
|
||||||
{diffsTruncated
|
{diffsTruncated
|
||||||
|
|
|
||||||
|
|
@ -2170,6 +2170,8 @@ export const bizCompareListRunDiffs = (params: {
|
||||||
metricId?: string;
|
metricId?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
kw?: string;
|
kw?: string;
|
||||||
|
/** Field filters e.g. { direction: "out", network: "1.1.1.1" } */
|
||||||
|
qf?: Record<string, string>;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -2177,6 +2179,15 @@ export const bizCompareListRunDiffs = (params: {
|
||||||
if (params.metricId) p.set("metric_id", params.metricId);
|
if (params.metricId) p.set("metric_id", params.metricId);
|
||||||
if (params.kind) p.set("kind", params.kind);
|
if (params.kind) p.set("kind", params.kind);
|
||||||
if (params.kw) p.set("kw", params.kw);
|
if (params.kw) p.set("kw", params.kw);
|
||||||
|
if (params.qf && Object.keys(params.qf).length) {
|
||||||
|
const cleaned: Record<string, string> = {};
|
||||||
|
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", String(Math.max(1, Number(params.page || 1))));
|
||||||
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100)))));
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100)))));
|
||||||
return apiGet<{
|
return apiGet<{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue