Tighten cutover verdicts and scope iface metrics by port mapping.

Mapped interfaces are the expect set and must appear after cutover; unmapped same-name down/up is anomaly. Success presets no longer treat bare unchanged as green.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-19 01:10:13 +08:00
parent 27c6b7c61d
commit 88b7558a48
11 changed files with 4663 additions and 1497 deletions

View file

@ -288,8 +288,16 @@ const en = {
stepPickMetrics: "① Pick metrics",
stepConfigRules: "② Configure compare rules",
pickMetricsHint: "Check metrics to add. Then configure each sheet below.",
noMetricsYet: "Select at least one metric above",
pickMetricsHintShort: "Click to add/remove; switch sheets in the left list below.",
showMetrics: "Show metric picker",
hideMetrics: "Hide metric picker",
noMetricsYet: "Select at least one metric first",
selectedCount: "{{n}} selected",
tabFields: "Field rules",
tabFilters: "Row filters",
fieldsHintShort:
"Key aligns rows; Compare fields take part in value diff; empty compare = presence-only.",
tagCompareN: "{{n}} cmp",
colFields: "Fields",
keyFields: "Key",
ifaceFields: "Interface",
@ -301,7 +309,7 @@ const en = {
modeEq: "Exact equal",
modeNumeric: "Abs delta",
modePercent: "Relative %",
normalizeField: "Normalize",
normalizeField: "Norm",
ignoreFields: "Ignore",
field: "Field",
note: "Note",
@ -312,12 +320,17 @@ const en = {
rowFilters: "Row filter rules",
rowFiltersHint:
"Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.",
rowFiltersEmpty: "No filters → compare all rows. Add a condition group to start.",
filterClear: "Clear rules",
rowFiltersHintShort: "AND in group · OR across groups · empty = all rows",
rowFiltersEmpty: "No filters → compare all rows.",
filterClear: "Clear",
filterAddOr: "Add group (OR)",
filterAddAnd: "Add condition (AND)",
filterAddOrShort: "+ OR group",
filterAddAndShort: "+ AND",
filterRemoveGroup: "Remove group",
filterGroupN: "Group {{n}}",
filterOr: "OR",
filterAnd: "AND",
filterField: "Field",
filterOp: "Op",
filterValue: "Value",
@ -455,8 +468,13 @@ const en = {
hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.",
hintPort:
"Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.",
hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.",
create: "Create task",
createHint: "Name the task and bind old/new business-monitor collect tasks. Mapping and baselines can be set later.",
createHintShort: "Name + old/new collect tasks; template, mapping, baselines can come later.",
showCreateMore: "More: template / mapping / baselines",
hideCreateMore: "Hide more",
setupHintShort: "Collect tasks are fixed at create; set monitor template, mapping and dual baselines here.",
createProject: "Create task",
projectName: "Task name",
projectNamePh: "e.g. CN1/CN2 night-1 cutover",
@ -469,23 +487,27 @@ const en = {
sectionPair: "Bound collect tasks",
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
sectionBaseline: "Baseline batches",
sectionBaselineHint: "Old baseline feeds the expected-object picker; new baseline is optional.",
sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.",
optionalNone: "None",
monitorTemplate: "Monitor template",
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to port cutover.",
compareTemplate: "Compare template",
compareTemplateBound: "Bound compare template: {{name}}",
portMappingHint: "From Business compare port mappings; leave empty if none yet.",
portMappingHint:
"With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.",
baselineNeedTask: "Select the matching collect task first",
baselinePickHint: "Choose from that task’s collect batches",
oldTask: "Old-device collect task",
newTask: "New-device collect task",
portMapping: "Port mapping",
oldBaseline: "Old baseline batch",
newBaseline: "New baseline batch (optional)",
newBaseline: "New baseline batch",
needProjectFields: "Enter a name and pick old/new collect tasks",
needExpectPorts: "Select at least one expected object for this batch",
needBaselineFirst: "Save an old baseline batch first to list expect objects",
needBothBaselines: "Save both old and new baseline batches before evaluating",
errOldBaselineRequired: "Old baseline batch is required",
errNewBaselineRequired: "New baseline batch is required",
needBizStateTasks: "No business-monitor tasks yet. Create one first:",
projectCreated: "Task created",
projects: "Tasks",
@ -534,6 +556,15 @@ const en = {
progress: "Progress",
anomaly: "Anomaly",
onlyExpect: "Expected objects only",
onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set",
pinCurrentBatches: "Current collect batches",
pinOldBatch: "Old current batch",
pinNewBatch: "New current batch",
pinLatest: "Auto latest success",
runHistory: "Run history",
runLatest: "Latest run",
colRuleHit: "Rule hit",
mappingBound: "Mapping saved and bound to this project",
allMetrics: "All metrics",
colMetric: "Metric",
colKey: "Object",
@ -548,6 +579,7 @@ const en = {
colNew: "New",
colVerdict: "Verdict",
emptyDiffs: "No rows yet — run Evaluate",
emptyRed: "No red tickets",
verdictMigrated: "Migrated",
verdictMigrating: "Migrating",
verdictLost: "Lost",
@ -568,6 +600,7 @@ const en = {
acceptFailed: "Batch acceptance failed ({{n}} red tickets; you may continue)",
acceptPassedShort: "Passed",
acceptFailedShort: "Failed",
newBaselineMissingBanner: "These sheets have no usable new baseline rows; verdicts are unreliable: {{metrics}}",
acceptCarryHint: "Failure does not block the next batch; red tickets stay until resolved.",
openRedHint: "{{n}} open red ticket(s) on this task (carry allowed)",
redTitle: "Red tickets",
@ -602,7 +635,7 @@ const en = {
needName: "Name is required",
defaultsInvalid: "Invalid defaults JSON",
overridesInvalid: "Invalid sheet_overrides JSON",
formHint: "Pick a compare template → same sheet tabs appear → configure status fields and success patterns per sheet.",
formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.",
compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.",
pickCompare: "Pick compare template…",
openCompare: "Manage compare templates",
@ -621,26 +654,85 @@ const en = {
ooeIgnore: "Ignore (always not involved)",
sheetRules: "Per-sheet migrate rules",
applyAllPresets: "Apply recommended presets to all",
applyAllPresetsShort: "All presets",
sheetsUnit: " sheets",
sheetHowReadonly: "Compare template (read-only)",
iface: "Interface",
compare: "Compare",
presenceOnly: "presence only",
editInCompare: "Edit HOW in compare templates",
presetAuto: "Apply recommended preset",
presetSkip: "Skip dual verdict",
editInCompare: "Edit HOW",
presetAuto: "Recommended",
presetSkip: "Skip sheet",
skipDual: "Skip dual for this sheet",
skipDualHint: "This sheet is skipped in cutover evaluate (use a compare job for pure value checks).",
statusFields: "Status fields",
statusFieldsHint: "Classify current rows as up/down; ports use admin/phy/prot, BGP uses state.",
skipDualHint: "Skipped in cutover evaluate (use a compare job for counters).",
statusFields: "Fields used for up/down",
statusFieldsHint: "Ports: admin/phy/prot; BGP: state; ARP: leave empty (presence only).",
statusOptionalHint: "With no status fields, only presence (gone/added) is used.",
downValues: "Down values",
upValues: "Up values",
noFields: "No fields (pick metrics on the compare template first)",
successPatterns: "Success patterns (old any-of × new any-of)",
successPatternsHint: "e.g. port: old removed/down + new added/up; ARP: old removed + new added; BGP: old down(idle) + new up(established).",
oldSideAny: "Old side (any match)",
newSideAny: "New side (any match)",
addSuccessRow: "Add success pattern",
successPatterns: "Success rules (old OR-groups × new OR-groups)",
successPatternsShort: "Success",
successPatternsHint:
"Presence is whether the row exists. Value change binds a field and a strategy. AND inside a group, OR between groups.",
successPatternsHintShort: "AND in group · OR across groups · old+new both match ⇒ success",
successPatternsHintFree:
"Each side has two condition kinds: presence (no field) or value change (must bind a field). Inside a group everything must match (AND). Between groups, any group is enough (OR).",
anomalyPatternsShort: "Anomaly",
anomalyPatternsHint:
"Same shape as success. Match ⇒ red. Check “don’t care” on one side to watch only the other (e.g. new-side gone/down). Not-yet-success is still migrating, not anomaly.",
anomalyPatternsHintShort: "Success first; then anomaly. Don’t-care watches one side. Mid-state ≠ anomaly.",
successRow: "Success {{n}}",
anomalyRow: "Anomaly {{n}}",
removeRow: "Remove",
oldSideAny: "Old",
newSideAny: "New",
groupLogicHint: "AND inside a group, OR between groups",
sideDontCare: "Don’t care this side",
sideDontCareHint: "Judged by the other side only.",
groupN: "Group {{n}}",
addAnd: "Add to this group (AND)",
addOr: "Add another group (OR)",
addAndShort: "+ AND",
addOrShort: "+ OR group",
addSuccessRow: "+ Success rule",
addAnomalyRow: "+ Anomaly rule",
modeAny: "Match any",
modeAll: "Match all",
addCond: "Add condition",
pickField: "Pick field…",
condValue: "Value",
opEmpty: "empty",
opNotEmpty: "not empty",
opChanged: "changed vs baseline",
opUnchanged: "same as baseline",
op_eq: "equals",
op_ne: "not equals",
op_in: "in",
op_not_in: "not in",
op_empty: "empty",
op_not_empty: "not empty",
op_changed: "changed vs baseline",
op_unchanged: "same as baseline",
and: "and",
groupPresence: "Presence",
groupValue: "Value change",
groupStatus: "Status class",
groupField: "Field condition",
tokRemoved: "Gone",
tokAdded: "Added",
tokUnchanged: "Unchanged",
tokChanged: "Changed",
tokUp: "up",
tokDown: "down",
tokOther: "other",
or: "or",
tagSkip: "skip",
tagRules: "{{n}}",
tagEmpty: "empty",
needCompareFirst: "Pick a compare template that has sheets first.",
showMore: "More: note / HF collect / JSON",
hideMore: "Hide more",
showJson: "Advanced: edit JSON",
hideJson: "Back to visual editor",
},

View file

@ -288,20 +288,27 @@ const zh = {
stepPickMetrics: "① 选择监控项",
stepConfigRules: "② 配置比对规则",
pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。",
noMetricsYet: "请先在上方勾选至少一个监控项",
pickMetricsHintShort: "点选加入 / 取消;配置在下方左侧表列表中切换。",
showMetrics: "展开选表",
hideMetrics: "收起选表",
noMetricsYet: "请先勾选至少一个监控项",
selectedCount: "已选 {{n}} 项",
tabFields: "字段规则",
tabFilters: "行过滤",
fieldsHintShort: "Key 对齐行;比对勾选参与值 diff;不勾比对则只看存在性。",
tagCompareN: "{{n}} 比对",
colFields: "字段数",
keyFields: "Key",
ifaceFields: "接口",
compareFields: "比对",
compareMode: "比对方式",
compareMode: "方式",
tolerance: "容差",
displayField: "展示",
keyAlwaysDisplay: "Key 列始终展示",
modeEq: "完全相等",
modeNumeric: "绝对偏差",
modePercent: "相对偏差 %",
normalizeField: "归一化",
normalizeField: "归一",
ignoreFields: "忽略",
field: "字段",
note: "备注",
@ -312,12 +319,17 @@ const zh = {
rowFilters: "行过滤规则",
rowFiltersHint:
"比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。",
rowFiltersEmpty: "无过滤则比对全部行。可添加条件组。",
filterClear: "清空规则",
rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行",
rowFiltersEmpty: "无过滤则比对全部行。",
filterClear: "清空",
filterAddOr: "添加条件组(或)",
filterAddAnd: "组内加条件(且)",
filterAddOrShort: "+ 或组",
filterAddAndShort: "+ 且",
filterRemoveGroup: "删除本组",
filterGroupN: "组 {{n}}",
filterOr: "或",
filterAnd: "且",
filterField: "字段",
filterOp: "运算",
filterValue: "值",
@ -452,8 +464,13 @@ const zh = {
title: "割接监控",
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
hintShort: "监控模板定判定规则;绑老/新采集与双基线 → 勾预期 → 看板评估。",
create: "创建任务",
createHint: "先填任务名并绑定老/新业务监控采集任务;映射与基线可稍后在详情里补。",
createHintShort: "任务名 + 老/新采集任务即可创建;模板、映射、基线可稍后补。",
showCreateMore: "更多:模板 / 映射 / 基线",
hideCreateMore: "收起更多",
setupHintShort: "采集任务创建后固定;此处配置监控模板、映射与双基线。",
createProject: "创建任务",
projectName: "任务名称",
projectNamePh: "例如:CN1/CN2 第1夜割接",
@ -465,23 +482,27 @@ const zh = {
sectionPair: "已绑定采集任务",
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
sectionBaseline: "基线批次",
sectionBaselineHint: "老侧基线用于列出本批预期对象;新侧基线可选。",
sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。",
optionalNone: "不选",
monitorTemplate: "监控模板",
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。",
compareTemplate: "对比模板",
compareTemplateBound: "绑定对比模板:{{name}}",
portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。",
portMappingHint:
"有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。",
baselineNeedTask: "请先选择对应采集任务",
baselinePickHint: "从该任务的历史采集批次中选择",
oldTask: "老设备采集任务",
newTask: "新设备采集任务",
portMapping: "端口映射",
oldBaseline: "老设备基线批次",
newBaseline: "新设备基线批次(可选)",
newBaseline: "新设备基线批次",
needProjectFields: "请填写任务名并选择老/新采集任务",
needExpectPorts: "请先勾选本批预期迁移的对象",
needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象",
needBothBaselines: "请先保存老、新两侧基线批次后再评估",
errOldBaselineRequired: "缺少老设备基线批次",
errNewBaselineRequired: "缺少新设备基线批次",
needBizStateTasks: "尚无业务监控任务,请先创建:",
projectCreated: "任务已创建",
projects: "任务",
@ -529,6 +550,15 @@ const zh = {
progress: "进度",
anomaly: "异常",
onlyExpect: "仅看预期对象",
onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期",
pinCurrentBatches: "当前采集批次",
pinOldBatch: "老侧当前批",
pinNewBatch: "新侧当前批",
pinLatest: "自动最新成功批",
runHistory: "判定历史",
runLatest: "最新一次",
colRuleHit: "命中规则",
mappingBound: "映射已保存并绑定到本任务",
allMetrics: "全部监控项",
colMetric: "监控项",
colKey: "对象",
@ -543,6 +573,7 @@ const zh = {
colNew: "新侧",
colVerdict: "判定",
emptyDiffs: "暂无明细,请先「立即判定」",
emptyRed: "暂无红单",
verdictMigrated: "已迁移",
verdictMigrating: "迁移中",
verdictLost: "丢失",
@ -563,6 +594,7 @@ const zh = {
acceptFailed: "本批验收未通过(红单 {{n}},可带红开下一批)",
acceptPassedShort: "通过",
acceptFailedShort: "未通过",
newBaselineMissingBanner: "这些表没有可用的新侧基线内容,判定不可信:{{metrics}}",
acceptCarryHint: "验收不过不拦截:可新建下一批继续;红单保留留痕,可手动关闭。",
openRedHint: "任务仍有 {{n}} 条未关闭红单(带红继续)",
redTitle: "红单留痕",
@ -597,7 +629,7 @@ const zh = {
needName: "请填写名称",
defaultsInvalid: "defaults JSON 无效",
overridesInvalid: "sheet_overrides JSON 无效",
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置状态字段与成功模式。",
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。",
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
pickCompare: "选择对比模板…",
openCompare: "管理对比模板",
@ -616,26 +648,84 @@ const zh = {
ooeIgnore: "忽略(一律未涉及)",
sheetRules: "各表搬迁判定",
applyAllPresets: "全部套用推荐预设",
applyAllPresetsShort: "全部预设",
sheetsUnit: "张表",
sheetHowReadonly: "对比模板(只读)",
iface: "接口",
compare: "比对",
presenceOnly: "仅存在性",
editInCompare: "去对比模板改 HOW",
presetAuto: "套用本表推荐预设",
presetSkip: "跳过双端判定",
skipDual: "本表不参与 dual 判定",
skipDualHint: "该 sheet 将在割接判定中跳过(适合纯计数器等值校验类,改用业务对比 Job)。",
statusFields: "状态字段",
statusFieldsHint: "用于把当前行归类为 up/down;端口勾选 admin/phy/prot,BGP 勾选 state。",
downValues: "Down 取值",
upValues: "Up 取值",
editInCompare: "改 HOW",
presetAuto: "推荐预设",
presetSkip: "跳过本表",
skipDual: "本表不参与双端判定",
skipDualHint: "该表在割接判定中跳过(计数器等请用业务对比 Job)。",
statusFields: "用哪些字段判断 up/down",
statusFieldsHint: "端口勾选 admin/phy/prot;BGP 勾选 state;ARP 可不勾(只看条目有无)。",
statusOptionalHint: "未勾选状态字段时,只按「消失 / 新增」等存在性判定。",
downValues: "算 down 的取值",
upValues: "算 up 的取值",
noFields: "无可用字段(先在对比模板勾选 metric)",
successPatterns: "成功模式(老侧任意命中 × 新侧任意命中)",
successPatternsHint: "例如端口:老 removed/down + 新 added/up;ARP:老 removed + 新 added;BGP:老 down(idle) + 新 up(established)。",
oldSideAny: "老侧(任意命中)",
newSideAny: "新侧(任意命中)",
addSuccessRow: "添加成功模式",
successPatterns: "成功规则(老侧组间或 × 新侧组间或)",
successPatternsShort: "成功规则",
successPatternsHint: "存在性看条目有无;值变更绑定字段并选择怎么变。组内是且,组之间是或。",
successPatternsHintShort: "组内且 · 组间或 · 老侧命中一组且新侧命中一组 → 成功",
successPatternsHintFree:
"每侧只有两种条件:存在性(不绑字段)或值变更(必须绑字段)。同一组内全部满足(且),组与组之间满足一组即可(或)。",
anomalyPatternsShort: "异常规则",
anomalyPatternsHint:
"与成功同结构。命中后标红。某一侧可勾「不限制」表示只看另一侧(如新侧现网消失/down)。未成功≠异常,中间态仍是迁移动态。",
anomalyPatternsHintShort: "先匹配成功;未命中再看异常。可勾「不限制」只盯一侧。中间态≠异常。",
successRow: "成功 {{n}}",
anomalyRow: "异常 {{n}}",
removeRow: "删除",
oldSideAny: "老设备",
newSideAny: "新设备",
groupLogicHint: "组内且,组之间或",
sideDontCare: "不限制此侧",
sideDontCareHint: "只按另一侧条件判定。",
groupN: "组 {{n}}",
addAnd: "组内再加一条(且)",
addOr: "再加一组(或)",
addAndShort: "+ 且",
addOrShort: "+ 或组",
addSuccessRow: "+ 成功规则",
addAnomalyRow: "+ 异常规则",
modeAny: "满足任一即可",
modeAll: "全部同时满足",
addCond: "添加条件",
pickField: "选择字段…",
condValue: "取值",
opEmpty: "为空",
opNotEmpty: "非空",
opChanged: "相对基线变了",
opUnchanged: "相对基线没变",
op_eq: "等于",
op_ne: "不等于",
op_in: "属于",
op_not_in: "不属于",
op_empty: "为空",
op_not_empty: "非空",
op_changed: "相对基线变了",
op_unchanged: "相对基线没变",
and: "且",
groupPresence: "存在性",
groupValue: "值变更",
groupStatus: "状态类",
groupField: "字段条件",
tokRemoved: "消失",
tokAdded: "新增",
tokUnchanged: "未变",
tokChanged: "有变化",
tokUp: "up",
tokDown: "down",
tokOther: "其它状态",
or: "或",
tagSkip: "跳过",
tagRules: "{{n}} 条",
tagEmpty: "未配",
needCompareFirst: "请先选择带 sheets 的对比模板。",
showMore: "更多:备注 / 高频采集 / JSON",
hideMore: "收起更多",
showJson: "高级:编辑 JSON",
hideJson: "回到可视化编辑",
},

View file

@ -10030,6 +10030,764 @@ html.login-page--paused .login-page__flare {
font-variant-numeric: tabular-nums;
}
/* Monitor template visual rule editor */
.mt-editor__basics {
display: grid;
grid-template-columns: 1.2fr 1.2fr 1fr;
gap: 12px;
align-items: start;
}
@media (max-width: 900px) {
.mt-editor__basics {
grid-template-columns: 1fr;
}
}
.mt-editor__main {
display: grid;
grid-template-columns: 200px 1fr;
gap: 12px;
min-height: 360px;
align-items: stretch;
}
@media (max-width: 900px) {
.mt-editor__main {
grid-template-columns: 1fr;
}
}
.mt-editor__nav {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 8px;
background: rgba(15, 23, 42, 0.35);
display: flex;
flex-direction: column;
gap: 6px;
max-height: 70vh;
overflow: auto;
}
.mt-editor__nav-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 2px 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.mt-editor__nav-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mt-editor__nav-item {
appearance: none;
text-align: left;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: inherit;
padding: 8px 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.mt-editor__nav-item:hover {
background: rgba(30, 41, 59, 0.65);
}
.mt-editor__nav-item.is-active {
border-color: rgba(59, 130, 246, 0.55);
background: rgba(37, 99, 235, 0.18);
}
.mt-editor__nav-name {
font-size: 13px;
font-weight: 600;
}
.mt-editor__nav-tag {
font-size: 11px;
opacity: 0.7;
}
.mt-editor__pane {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.mt-editor__pane-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.mt-editor__metric {
font-size: 15px;
font-weight: 700;
}
.mt-editor__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
font-size: 12px;
margin-top: 4px;
}
.mt-editor__uv {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
}
.mt-block__title {
font-size: 13px;
font-weight: 650;
margin-bottom: 6px;
}
.mt-block__hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.4;
}
.mt-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mt-chip {
appearance: none;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 999px;
background: transparent;
color: inherit;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
line-height: 1.3;
}
.mt-chip:hover {
border-color: rgba(59, 130, 246, 0.55);
}
.mt-chip.is-on {
border-color: rgba(37, 99, 235, 0.75);
background: rgba(37, 99, 235, 0.22);
font-weight: 600;
}
.mt-skip-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin: 0;
cursor: pointer;
}
.mt-rule-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
padding-bottom: 0;
}
.mt-rule-tab {
appearance: none;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: transparent;
color: inherit;
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
opacity: 0.72;
}
.mt-rule-tab:hover {
opacity: 1;
background: rgba(30, 41, 59, 0.45);
}
.mt-rule-tab.is-active {
opacity: 1;
border-color: rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.55);
color: rgba(74, 222, 128, 0.95);
}
.mt-rule-tab--anomaly.is-active {
color: rgba(248, 113, 113, 0.95);
}
.mt-rule-tab__n {
font-size: 11px;
font-weight: 600;
opacity: 0.7;
min-width: 1.2em;
text-align: center;
padding: 0 5px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
}
.mt-rule-card {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: rgba(15, 23, 42, 0.28);
}
.mt-rule-card--anomaly {
border-color: rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.05);
}
.mt-rule-card__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 12px;
font-weight: 650;
}
.mt-rule-card__grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 10px;
align-items: start;
}
@media (max-width: 900px) {
.mt-rule-card__grid {
grid-template-columns: 1fr;
}
.mt-rule-arrow {
display: none;
}
}
.mt-rule-arrow {
align-self: center;
font-size: 18px;
font-weight: 700;
opacity: 0.55;
padding-top: 22px;
}
.mt-side-col__label {
font-size: 12px;
font-weight: 650;
margin-bottom: 6px;
}
.mt-side {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.mt-side-empty {
margin: 0;
font-size: 12px;
}
.mt-or-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.mt-and-box {
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
padding: 8px;
background: rgba(2, 6, 23, 0.25);
}
.mt-and-box__head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
margin-bottom: 4px;
}
.mt-logic-badge {
align-self: center;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 1px 8px;
border-radius: 999px;
opacity: 0.85;
}
.mt-logic-badge--or {
background: rgba(59, 130, 246, 0.2);
color: rgba(147, 197, 253, 0.95);
}
.mt-logic-badge--and {
background: rgba(148, 163, 184, 0.18);
color: rgba(203, 213, 225, 0.95);
margin: 2px auto;
width: fit-content;
}
.mt-select {
appearance: auto;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 6px;
background: rgba(15, 23, 42, 0.45);
color: inherit;
padding: 4px 8px;
font-size: 12px;
min-width: 0;
}
.mt-select--grow {
flex: 1 1 120px;
}
.mt-cond-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
margin: 4px 0;
}
.mt-cond-row.is-value {
grid-template-columns: 88px minmax(72px, 1fr) minmax(88px, 1.1fr) minmax(64px, 1fr) auto;
}
.mt-cond-row .mt-cond-value,
.mt-cond-row input {
min-width: 0;
width: 100%;
}
.mt-icon-btn {
appearance: none;
border: none;
background: transparent;
color: inherit;
opacity: 0.55;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
}
.mt-icon-btn:hover {
opacity: 1;
background: rgba(148, 163, 184, 0.15);
}
.mt-link-btn {
appearance: none;
border: none;
background: transparent;
color: rgba(147, 197, 253, 0.95);
font-size: 12px;
cursor: pointer;
padding: 4px 0 0;
text-align: left;
width: fit-content;
}
.mt-link-btn:hover {
text-decoration: underline;
}
.mt-dontcare {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin: 0 0 4px;
cursor: pointer;
}
/* legacy aliases kept for safety */
.mt-success-card {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
}
.mt-anomaly-card {
border-color: rgba(239, 68, 68, 0.45);
background: rgba(239, 68, 68, 0.06);
}
.mt-block--success .mt-block__title {
color: rgba(74, 222, 128, 0.95);
}
.mt-block--anomaly .mt-block__title {
color: rgba(248, 113, 113, 0.95);
}
.mt-rule-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.mt-success-card__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
}
.mt-success-card__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.mt-success-card__grid {
grid-template-columns: 1fr;
}
}
.mt-success-side__label {
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
}
.mt-group {
border: 1px dashed rgba(148, 163, 184, 0.35);
border-radius: 8px;
padding: 8px;
margin-bottom: 6px;
}
.mt-group__head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
opacity: 0.75;
}
.mt-group__or,
.mt-group__and {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
opacity: 0.7;
margin: 2px 0;
}
.mt-side-hint {
margin: 0 0 6px;
font-size: 11px;
}
.mt-chip-group {
margin-bottom: 8px;
}
.mt-chip-group__label {
display: block;
font-size: 11px;
opacity: 0.65;
margin-bottom: 4px;
}
.mt-success-card__formula {
margin-top: 8px;
font-size: 12px;
padding-top: 8px;
border-top: 1px dashed rgba(148, 163, 184, 0.35);
}
.mt-arrow {
font-weight: 700;
opacity: 0.85;
}
.mt-editor__more {
border-top: 1px solid rgba(148, 163, 184, 0.22);
padding-top: 8px;
}
/* Compare template editor (aligned with monitor template editor) */
.ct-editor__basics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 720px) {
.ct-editor__basics {
grid-template-columns: 1fr;
}
}
.ct-editor__pick {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 8px 10px;
background: rgba(15, 23, 42, 0.28);
}
.ct-editor__pick-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ct-editor__pick-title {
font-size: 13px;
font-weight: 650;
}
.ct-editor__pick-hint {
margin: 6px 0 8px;
font-size: 12px;
}
.ct-metric-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 140px;
overflow: auto;
}
.ct-metric-chip__n {
margin-left: 6px;
opacity: 0.65;
font-size: 11px;
}
.ct-editor__main {
display: grid;
grid-template-columns: 200px 1fr;
gap: 12px;
min-height: 360px;
align-items: stretch;
}
@media (max-width: 900px) {
.ct-editor__main {
grid-template-columns: 1fr;
}
}
.ct-editor__nav {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 8px;
background: rgba(15, 23, 42, 0.35);
display: flex;
flex-direction: column;
gap: 6px;
max-height: 70vh;
overflow: auto;
}
.ct-editor__nav-head {
font-size: 12px;
font-weight: 600;
padding: 2px 4px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.ct-editor__nav-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.ct-editor__nav-item {
appearance: none;
text-align: left;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: inherit;
padding: 8px 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.ct-editor__nav-item:hover {
background: rgba(30, 41, 59, 0.65);
}
.ct-editor__nav-item.is-active {
border-color: rgba(59, 130, 246, 0.55);
background: rgba(37, 99, 235, 0.18);
}
.ct-editor__nav-name {
font-size: 13px;
font-weight: 600;
}
.ct-editor__nav-tag {
font-size: 11px;
opacity: 0.7;
}
.ct-editor__pane {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.ct-editor__pane-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.ct-editor__metric {
font-size: 15px;
font-weight: 700;
}
.ct-editor__meta {
font-size: 12px;
margin-top: 4px;
}
.ct-filters__hint,
.ct-filters__empty,
.ct-field-hint {
margin: 0 0 8px;
font-size: 12px;
}
.ct-filters__actions {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.ct-filter-row {
display: grid;
grid-template-columns: minmax(90px, 1.1fr) minmax(72px, 0.8fr) minmax(80px, 1.2fr) auto;
gap: 6px;
align-items: center;
margin: 4px 0;
}
.ct-filter-row input {
min-width: 0;
width: 100%;
}
.ct-field-table-wrap {
max-height: min(55vh, 520px);
overflow: auto;
}
.ct-field-table th,
.ct-field-table td {
vertical-align: middle;
}
.ct-field-table th:nth-child(n + 2),
.ct-field-table td:nth-child(n + 2) {
text-align: center;
white-space: nowrap;
}
.ct-field-table td:first-child {
text-align: left;
}
.ct-field-name {
font-size: 12px;
}
/* Cutover monitor (biz migration) */
.bm-hint {
margin: 0;
font-size: 12px;
}
.bm-pair-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
}
@media (max-width: 720px) {
.bm-pair-grid {
grid-template-columns: 1fr;
}
}
.bm-pair-card {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.28);
}
.bm-pair-card__label {
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
}
.bm-mapping {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.22);
}
.bm-mapping__label {
font-size: 13px;
font-weight: 650;
}
.bm-mapping__row {
display: grid;
grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 1fr) auto;
gap: 8px;
align-items: end;
}
@media (max-width: 720px) {
.bm-mapping__row {
grid-template-columns: 1fr;
}
}
.bm-mapping__text {
width: 100%;
min-height: 110px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 6px;
background: rgba(2, 6, 23, 0.35);
color: inherit;
padding: 8px 10px;
resize: vertical;
}
.bm-detail__chrome {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: center;
justify-content: space-between;
}
.bm-detail__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: center;
font-size: 12px;
}
.bm-detail__arrow {
opacity: 0.55;
font-weight: 700;
}
.bm-detail__red {
margin-left: 4px;
font-size: 12px;
}
.bm-detail__actions {
flex-wrap: wrap;
gap: 8px;
}
.bm-detail__link {
font-size: 12px;
align-self: center;
}
.bm-batches {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
flex: 1;
}
.bm-batches__toolbar {
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
.bm-batches__main {
min-height: 320px;
flex: 1;
}
.bm-expect-table {
max-height: min(48vh, 420px);
overflow: auto;
}
.bm-expect-desc {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bm-board {
flex: 1;
min-height: 0;
}
.bm-board__accept {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
font-size: 12px;
font-variant-numeric: tabular-nums;
align-items: center;
}
.bm-board__table {
max-height: min(58vh, 560px);
overflow: auto;
}
.bs-bind-panel {
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.25);

View file

@ -525,6 +525,152 @@ function emptyLeaf(field = ""): RowFilter {
return { field, op: "eq", value: "" };
}
const FILTER_OPS = [
"eq",
"ne",
"in",
"not_in",
"empty",
"not_empty",
"regex",
"age_timer",
] as const;
function TplRowFiltersEditor({
groups,
fieldOpts,
t,
onChange,
}: {
groups: RowFilter[][];
fieldOpts: string[];
t: (k: string, vars?: Record<string, string | number>) => string;
onChange: (next: RowFilter[][]) => void;
}) {
const setGroups = onChange;
return (
<div className="ct-filters">
<p className="muted ct-filters__hint">{t("bizCompare.rowFiltersHintShort")}</p>
<div className="ct-filters__actions">
<button
type="button"
className="mt-link-btn"
onClick={() => setGroups([...(groups.length ? groups : []), [emptyLeaf(fieldOpts[0] || "")]])}
>
{t("bizCompare.filterAddOrShort")}
</button>
{groups.length ? (
<button type="button" className="mt-link-btn" onClick={() => setGroups([])}>
{t("bizCompare.filterClear")}
</button>
) : null}
</div>
{!groups.length ? <p className="muted ct-filters__empty">{t("bizCompare.rowFiltersEmpty")}</p> : null}
{groups.map((group, gi) => (
<div key={gi} className="mt-or-wrap">
{gi > 0 ? <div className="mt-logic-badge mt-logic-badge--or">{t("bizCompare.filterOr")}</div> : null}
<div className="mt-and-box">
<div className="mt-and-box__head">
<span className="muted">{t("bizCompare.filterGroupN", { n: String(gi + 1) })}</span>
<button
type="button"
className="mt-icon-btn"
aria-label={t("bizCompare.filterRemoveGroup")}
onClick={() => setGroups(groups.filter((_, i) => i !== gi))}
>
×
</button>
</div>
{group.map((filt, fi) => (
<div key={fi}>
{fi > 0 ? (
<div className="mt-logic-badge mt-logic-badge--and">{t("bizCompare.filterAnd")}</div>
) : null}
<div className="ct-filter-row">
<select
className="mt-select"
value={filt.field || ""}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi][fi] = { ...next[gi][fi], field: e.target.value };
setGroups(next);
}}
>
<option value="">{t("bizCompare.filterField")}</option>
{fieldOpts.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
className="mt-select"
value={filt.op || "eq"}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi][fi] = { ...next[gi][fi], op: e.target.value };
setGroups(next);
}}
>
{FILTER_OPS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
value={
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
}
placeholder={t("bizCompare.filterValue")}
isDisabled={["empty", "not_empty", "age_timer"].includes(filt.op || "")}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
const op = next[gi][fi]?.op || "eq";
let value: string | string[] = e.target.value;
if (op === "in" || op === "not_in") {
value = e.target.value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
next[gi][fi] = { ...next[gi][fi], value };
setGroups(next);
}}
/>
<button
type="button"
className="mt-icon-btn"
aria-label={t("bizCompare.filterRemoveGroup")}
onClick={() => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi].splice(fi, 1);
setGroups(next.filter((g) => g.length));
}}
>
×
</button>
</div>
</div>
))}
<button
type="button"
className="mt-link-btn"
onClick={() => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi].push(emptyLeaf(fieldOpts[0] || ""));
setGroups(next);
}}
>
{t("bizCompare.filterAddAndShort")}
</button>
</div>
</div>
))}
</div>
);
}
function metricLabel(id: string) {
return id;
}
@ -554,6 +700,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [tplNote, setTplNote] = useState("");
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
const [tplSheetIdx, setTplSheetIdx] = useState(0);
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
const [showMetricPicker, setShowMetricPicker] = useState(true);
// job create / detail
const [jobCreateOpen, setJobCreateOpen] = useState(false);
@ -570,7 +718,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [afterBatchId, setAfterBatchId] = useState("");
const [mode, setMode] = useState<"manual" | "auto">("manual");
const [mapName, setMapName] = useState("端口映射");
const [mapText, setMapText] = useState("before_if,after_if\n");
const [mapText, setMapText] = useState("");
const [validateOut, setValidateOut] = useState<any>(null);
const [runs, setRuns] = useState<any[]>([]);
const [runDetail, setRunDetail] = useState<any>(null);
@ -956,6 +1104,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplNote("");
setTplSheets([]);
setTplSheetIdx(0);
setTplPaneTab("fields");
setShowMetricPicker(true);
setTplOpen(true);
};
@ -966,6 +1116,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplNote(tpl.note || "");
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
setTplSheetIdx(0);
setTplPaneTab("fields");
setShowMetricPicker(!sheets.length);
setTplOpen(true);
};
@ -976,6 +1128,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (prev.some((s) => s.metric_id === metricId)) return prev;
const next = [...prev, defaultSheetForMetric(schema, metricId)];
setTplSheetIdx(next.length - 1);
if (!prev.length) setShowMetricPicker(false);
return next;
});
return;
@ -1141,7 +1294,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (!m) return;
setMappingId(id);
setMapName(m.name);
setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n"));
setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n"));
};
const saveMapping = async () => {
@ -1196,7 +1349,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
else {
setMapName(t("bizCompare.mapping"));
setMapText("before_if,after_if\n");
setMapText("");
}
};
@ -1417,7 +1570,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (id) loadMappingText(id);
else {
setMappingId("");
setMapText("before_if,after_if\n");
setMapText("");
}
}}
>
@ -1657,473 +1810,343 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</div>
{/* Template editor */}
<AppModalShell open={tplOpen} onClose={() => setTplOpen(false)} size="lg" className="app-heroui-modal--xl">
<AppModalShell open={tplOpen} onClose={() => setTplOpen(false)} size="cover">
<Modal.Header>
<Modal.Heading>
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.colName")}</span>
<Input value={tplName} onChange={(e) => setTplName(e.target.value)} />
</label>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.note")}</span>
<Input value={tplNote} onChange={(e) => setTplNote(e.target.value)} />
</label>
<Modal.Body className="flex flex-col gap-3 ct-editor">
<div className="ct-editor__basics">
<Input
value={tplName}
onChange={(e) => setTplName(e.target.value)}
placeholder={t("bizCompare.colName")}
aria-label={t("bizCompare.colName")}
/>
<Input
value={tplNote}
onChange={(e) => setTplNote(e.target.value)}
placeholder={t("bizCompare.note")}
aria-label={t("bizCompare.note")}
/>
</div>
<div className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.stepPickMetrics")}</span>
<p className="muted" style={{ marginTop: 0 }}>
{t("bizCompare.pickMetricsHint")}
</p>
<div
className="pt-list-table-wrap"
style={{ maxHeight: 220, overflow: "auto", border: "1px solid var(--border, #ddd)", borderRadius: 6 }}
>
<table className="data-table pt-list-table">
<thead>
<tr>
<th style={{ width: 48 }} />
<th>{t("bizCompare.addMetric")}</th>
<th>{t("bizCompare.colFields")}</th>
</tr>
</thead>
<tbody>
<div className="ct-editor__pick">
<div className="ct-editor__pick-head">
<span className="ct-editor__pick-title">
{t("bizCompare.stepPickMetrics")}
<span className="muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{t("bizCompare.selectedCount", { n: String(tplSheets.length) })}
</span>
</span>
<Button size="sm" variant="ghost" onPress={() => setShowMetricPicker((v) => !v)}>
{showMetricPicker ? t("bizCompare.hideMetrics") : t("bizCompare.showMetrics")}
</Button>
</div>
{showMetricPicker ? (
<>
<p className="muted ct-editor__pick-hint">{t("bizCompare.pickMetricsHintShort")}</p>
<div className="ct-metric-chips">
{metrics.map((m) => {
const checked = tplSheets.some((s) => s.metric_id === m.metric_id);
return (
<tr key={m.metric_id}>
<td>
<input
type="checkbox"
checked={checked}
onChange={(e) => toggleTplMetric(m.metric_id, e.target.checked)}
/>
</td>
<td>
<code>{m.metric_id}</code>
</td>
<td className="muted">{m.fields?.length || 0}</td>
</tr>
<button
key={m.metric_id}
type="button"
className={`mt-chip${checked ? " is-on" : ""}`}
aria-pressed={checked}
onClick={() => toggleTplMetric(m.metric_id, !checked)}
>
{m.metric_id}
<span className="ct-metric-chip__n">{m.fields?.length || 0}</span>
</button>
);
})}
{!metrics.length ? (
<tr>
<td colSpan={3}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
<span className="muted">{t("bizCompare.noMetricFields")}</span>
) : null}
</tbody>
</table>
</div>
<p className="muted">
{t("bizCompare.selectedCount", { n: String(tplSheets.length) })}
</p>
</div>
<div className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.stepConfigRules")}</span>
<p className="muted" style={{ marginTop: 0 }}>
{t("bizCompare.templateHint")}
</p>
</div>
</>
) : null}
</div>
{!tplSheets.length ? (
<div className="pt-list-empty">{t("bizCompare.noMetricsYet")}</div>
) : (
<>
<div className="bs-sheet-tabs" role="tablist">
{tplSheets.map((s, i) => (
<button
key={s.metric_id}
type="button"
className={`bs-sheet-tab${i === tplSheetIdx ? " is-active" : ""}`}
onClick={() => setTplSheetIdx(i)}
>
{s.metric_id}
{!s.compare_fields.length ? (
<span className="bs-sheet-tab__count">{t("bizCompare.presenceShort")}</span>
) : (
<span className="bs-sheet-tab__count">{s.compare_fields.length}</span>
)}
{(s.row_filters || []).length ? (
<span className="bs-sheet-tab__count">F{(s.row_filters || []).length}</span>
) : null}
</button>
))}
</div>
<div className="ct-editor__main">
<aside className="ct-editor__nav" aria-label={t("bizCompare.colSheets")}>
<div className="ct-editor__nav-head">
<span>{t("bizCompare.colSheets")}</span>
</div>
<div className="ct-editor__nav-list" role="tablist">
{tplSheets.map((s, i) => {
const filterN = toOrGroups(s.row_filters || []).length;
return (
<button
key={s.metric_id}
type="button"
role="tab"
aria-selected={tplSheetIdx === i}
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
onClick={() => {
setTplSheetIdx(i);
setTplPaneTab("fields");
}}
>
<span className="ct-editor__nav-name">{s.metric_id}</span>
<span className="ct-editor__nav-tag">
{!s.compare_fields.length
? t("bizCompare.presenceShort")
: t("bizCompare.tagCompareN", { n: String(s.compare_fields.length) })}
{filterN ? ` · F${filterN}` : ""}
</span>
</button>
);
})}
</div>
</aside>
{activeTplSheet ? (
<>
<div className="filter-inline" style={{ justifyContent: "space-between" }}>
<span className="muted">
{activeTplSheet.compare_fields.length
? t("bizCompare.modeFields")
: t("bizCompare.modePresence")}
{(activeTplSheet.row_filters || []).length
? ` · ${(activeTplSheet.row_filters || []).length} filter(s)`
: ""}
</span>
<Button
size="sm"
variant="ghost"
onPress={() => removeTplMetric(tplSheetIdx)}
>
<div className="ct-editor__pane">
<div className="ct-editor__pane-head">
<div>
<div className="ct-editor__metric">{activeTplSheet.metric_id}</div>
<div className="ct-editor__meta muted">
{activeTplSheet.compare_fields.length
? t("bizCompare.modeFieldsShort")
: t("bizCompare.presenceShort")}
{" · "}
Key {(activeTplSheet.key_fields || []).join(" · ") || "—"}
</div>
</div>
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
{t("bizCompare.removeSheet")}
</Button>
</div>
<div className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.rowFilters")}</span>
<p className="muted" style={{ marginTop: 0 }}>
{t("bizCompare.rowFiltersHint")}
</p>
{(() => {
const groups = toOrGroups(activeTplSheet.row_filters || []);
const setGroups = (next: RowFilter[][]) =>
updateActiveSheet({ row_filters: fromOrGroups(next) });
const fieldOpts = activeTplFields.map((f) => f.name);
const opOpts = [
"eq",
"ne",
"in",
"not_in",
"empty",
"not_empty",
"regex",
"age_timer",
];
return (
<>
<div className="filter-inline" style={{ flexWrap: "wrap", gap: 8 }}>
<Button
size="sm"
variant="secondary"
onPress={() => {
const g = groups.length ? groups : [];
setGroups([
...g,
[emptyLeaf(fieldOpts[0] || "")],
]);
}}
>
{t("bizCompare.filterAddOr")}
</Button>
<Button
size="sm"
variant="ghost"
onPress={() => setGroups([])}
>
{t("bizCompare.filterClear")}
</Button>
</div>
{!groups.length ? (
<p className="muted">{t("bizCompare.rowFiltersEmpty")}</p>
) : null}
{groups.map((group, gi) => (
<div
key={gi}
style={{
marginTop: 10,
padding: "8px 10px",
border: "1px solid var(--border, #ddd)",
borderRadius: 6,
}}
>
{gi > 0 ? (
<div className="muted" style={{ marginBottom: 6 }}>
{t("bizCompare.filterOr")}
</div>
) : null}
{group.map((filt, fi) => (
<div key={fi} className="filter-inline" style={{ marginTop: fi ? 6 : 0 }}>
<select
value={filt.field || ""}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi][fi] = { ...next[gi][fi], field: e.target.value };
setGroups(next);
}}
>
<option value="">{t("bizCompare.filterField")}</option>
{fieldOpts.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
value={filt.op || "eq"}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi][fi] = { ...next[gi][fi], op: e.target.value };
setGroups(next);
}}
>
{opOpts.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
value={
Array.isArray(filt.value)
? filt.value.join(",")
: String(filt.value ?? "")
}
placeholder={t("bizCompare.filterValue")}
isDisabled={["empty", "not_empty", "age_timer"].includes(
filt.op || "",
)}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
const op = next[gi][fi]?.op || "eq";
let value: string | string[] = e.target.value;
if (op === "in" || op === "not_in") {
value = e.target.value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
next[gi][fi] = { ...next[gi][fi], value };
setGroups(next);
}}
/>
<Button
size="sm"
variant="ghost"
onPress={() => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi].splice(fi, 1);
setGroups(next.filter((g) => g.length));
}}
>
×
</Button>
</div>
))}
<div className="filter-inline" style={{ marginTop: 8, gap: 8 }}>
<Button
size="sm"
variant="ghost"
onPress={() => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
next[gi].push(emptyLeaf(fieldOpts[0] || ""));
setGroups(next);
}}
>
{t("bizCompare.filterAddAnd")}
</Button>
<Button
size="sm"
variant="ghost"
onPress={() => {
const next = groups.filter((_, i) => i !== gi);
setGroups(next);
}}
>
{t("bizCompare.filterRemoveGroup")}
</Button>
</div>
</div>
))}
</>
);
})()}
<div className="mt-rule-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tplPaneTab === "fields"}
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
onClick={() => setTplPaneTab("fields")}
>
{t("bizCompare.tabFields")}
<span className="mt-rule-tab__n">{activeTplFields.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={tplPaneTab === "filters"}
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
onClick={() => setTplPaneTab("filters")}
>
{t("bizCompare.tabFilters")}
<span className="mt-rule-tab__n">
{toOrGroups(activeTplSheet.row_filters || []).length}
</span>
</button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizCompare.field")}</th>
<th>{t("bizCompare.keyFields")}</th>
<th>{t("bizCompare.ifaceFields")}</th>
<th>{t("bizCompare.compareFields")}</th>
<th>{t("bizCompare.compareMode")}</th>
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
<th>{t("bizCompare.normalizeField")}</th>
</tr>
</thead>
<tbody>
{activeTplFields.map((f) => {
const isKey = activeTplSheet.key_fields.includes(f.name);
const isCompare =
!isKey && activeTplSheet.compare_fields.includes(f.name);
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
const rule = ruleForField(activeTplSheet, f.name);
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
const norm = rule.normalize || "none";
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
return (
<tr key={f.name}>
<td>
<div className="pt-list-task-name">{f.display_name || f.name}</div>
<div className="muted">
<code>{f.name}</code> · {f.role}
</div>
</td>
<td>
<input
type="checkbox"
checked={isKey}
onChange={(e) =>
updateActiveSheet({
key_fields: toggleInList(
activeTplSheet.key_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<input
type="checkbox"
checked={activeTplSheet.iface_fields.includes(f.name)}
onChange={(e) =>
updateActiveSheet({
iface_fields: toggleInList(
activeTplSheet.iface_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<input
type="checkbox"
disabled={isKey}
title={isKey ? t("bizCompare.keyIsIdentity") : undefined}
checked={isCompare}
onChange={(e) =>
updateActiveSheet({
compare_fields: toggleInList(
activeTplSheet.compare_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<select
value={cmpMode === "ignore" ? "eq" : cmpMode}
disabled={!isCompare}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
compare: e.target.value,
tolerance:
e.target.value === "eq"
? undefined
: rule.tolerance ?? 0,
}),
})
}
>
<option value="eq">{t("bizCompare.modeEq")}</option>
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
<option value="percent">{t("bizCompare.modePercent")}</option>
</select>
</td>
<td>
<Input
type="number"
value={
needsTol && rule.tolerance !== undefined && rule.tolerance !== null
? String(rule.tolerance)
: ""
}
placeholder={cmpMode === "percent" ? "%" : ""}
isDisabled={!isCompare || !needsTol}
onChange={(e) => {
const raw = e.target.value.trim();
const tol = raw === "" ? undefined : Number(raw);
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
compare: cmpMode,
tolerance: Number.isFinite(tol as number)
? (tol as number)
: undefined,
}),
});
}}
/>
</td>
<td>
<input
type="checkbox"
disabled={isKey}
title={isKey ? t("bizCompare.keyAlwaysDisplay") : undefined}
checked={isKey || isDisplay}
onChange={(e) =>
updateActiveSheet({
display_fields: toggleInList(
activeTplSheet.display_fields || [],
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<select
value={norm}
disabled={!isCompare}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
normalize: e.target.value,
}),
})
}
>
<option value="none">{t("bizCompare.normalizeNone")}</option>
<option value="lower">{t("bizCompare.normalizeLower")}</option>
<option value="upper">{t("bizCompare.normalizeUpper")}</option>
<option value="mac">{t("bizCompare.normalizeMac")}</option>
<option value="empty_as_blank">
{t("bizCompare.normalizeEmpty")}
</option>
</select>
{tplPaneTab === "filters" ? (
<TplRowFiltersEditor
groups={toOrGroups(activeTplSheet.row_filters || [])}
fieldOpts={activeTplFields.map((f) => f.name)}
t={t}
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
/>
) : (
<div className="pt-list-table-wrap ct-field-table-wrap">
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
<table className="data-table pt-list-table ct-field-table">
<thead>
<tr>
<th>{t("bizCompare.field")}</th>
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
<th>{t("bizCompare.compareMode")}</th>
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
<th>{t("bizCompare.normalizeField")}</th>
</tr>
</thead>
<tbody>
{activeTplFields.map((f) => {
const isKey = activeTplSheet.key_fields.includes(f.name);
const isCompare =
!isKey && activeTplSheet.compare_fields.includes(f.name);
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
const rule = ruleForField(activeTplSheet, f.name);
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
const norm = rule.normalize || "none";
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
return (
<tr key={f.name}>
<td>
<code className="ct-field-name">{f.name}</code>
</td>
<td>
<input
type="checkbox"
checked={isKey}
onChange={(e) =>
updateActiveSheet({
key_fields: toggleInList(
activeTplSheet.key_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<input
type="checkbox"
checked={activeTplSheet.iface_fields.includes(f.name)}
onChange={(e) =>
updateActiveSheet({
iface_fields: toggleInList(
activeTplSheet.iface_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<input
type="checkbox"
disabled={isKey}
title={isKey ? t("bizCompare.keyIsIdentity") : undefined}
checked={isCompare}
onChange={(e) =>
updateActiveSheet({
compare_fields: toggleInList(
activeTplSheet.compare_fields,
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<select
className="mt-select"
value={cmpMode === "ignore" ? "eq" : cmpMode}
disabled={!isCompare}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
compare: e.target.value,
tolerance:
e.target.value === "eq" ? undefined : rule.tolerance ?? 0,
}),
})
}
>
<option value="eq">{t("bizCompare.modeEq")}</option>
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
<option value="percent">{t("bizCompare.modePercent")}</option>
</select>
</td>
<td>
<Input
type="number"
value={
needsTol &&
rule.tolerance !== undefined &&
rule.tolerance !== null
? String(rule.tolerance)
: ""
}
placeholder={cmpMode === "percent" ? "%" : ""}
isDisabled={!isCompare || !needsTol}
onChange={(e) => {
const raw = e.target.value.trim();
const tol = raw === "" ? undefined : Number(raw);
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
compare: cmpMode,
tolerance: Number.isFinite(tol as number)
? (tol as number)
: undefined,
}),
});
}}
/>
</td>
<td>
<input
type="checkbox"
disabled={isKey}
title={isKey ? t("bizCompare.keyAlwaysDisplay") : undefined}
checked={isKey || isDisplay}
onChange={(e) =>
updateActiveSheet({
display_fields: toggleInList(
activeTplSheet.display_fields || [],
f.name,
e.target.checked,
),
})
}
/>
</td>
<td>
<select
className="mt-select"
value={norm}
disabled={!isCompare}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
normalize: e.target.value,
}),
})
}
>
<option value="none">{t("bizCompare.normalizeNone")}</option>
<option value="lower">{t("bizCompare.normalizeLower")}</option>
<option value="upper">{t("bizCompare.normalizeUpper")}</option>
<option value="mac">{t("bizCompare.normalizeMac")}</option>
<option value="empty_as_blank">
{t("bizCompare.normalizeEmpty")}
</option>
</select>
</td>
</tr>
);
})}
{!activeTplFields.length ? (
<tr>
<td colSpan={8}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
);
})}
{!activeTplFields.length ? (
<tr>
<td colSpan={8}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
) : null}
</tbody>
</table>
</div>
)}
</div>
) : null}
</>
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={() => setTplOpen(false)}>
{t("bizState.cancel")}
</Button>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
{t("bizCompare.saveTemplate")}
</Button>
<Button size="sm" variant="ghost" onPress={() => setTplOpen(false)}>
{t("bizState.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2090,6 +2090,11 @@ export const bizMigrationEvaluate = (batchId: string, body: Record<string, unkno
body,
);
export const bizMigrationListRuns = (batchId: string, limit = 20) =>
apiGet<{ items?: Array<Record<string, unknown>> }>(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`,
);
export const bizMigrationGetBoard = (batchId: string, runId = "") => {
const q = runId ? `?run_id=${encodeURIComponent(runId)}` : "";
return apiGet<Record<string, unknown>>(