Merge PR #115 (feat #52 报警看板配置化:阈值/规则外置 + 布局/渲染整合)

This commit is contained in:
2026-08-05 08:26:40 +08:00
parent 8a407fc0ad
commit 52d986e6f0
6 changed files with 1219 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# 配置化驾驶舱内核模块(iAOP-Core / cockpit)
对齐 PRD 5.5「⑤ 配置化驾驶舱」:把驾驶舱抽象为模板化配置资产,按"布局/配置"渲染,
切换行业模板零改前端代码(PRD 5.5 验收口径)。
## 模块状态
| 能力 | Issue | 状态 |
| --- | --- | --- |
| 布局 JSON Schema 定义 + 校验/解析 | #50 | feature/issue-50(推进中) |
| 图表组件配置化渲染引擎(RenderPlan) | #51 | feature/issue-51(推进中) |
| **报警看板配置化(阈值/规则外置)** | **#52** | **本分支 feature/issue-52** |
#52「报警看板配置化」把告警面板(`alarm_panel`)的展示语义外置为模板配置资产,
让"换行业只换配置、前端代码零改动"也覆盖到告警面板。配置点:
- **严重度→颜色映射**(`severityColors`):P0/P1/P2 三级各自的 `fg`/`bg`/可选 `icon`/`border`,
覆盖主题默认告警色;驾驶舱红色告警(PRD 5.3 ③ 场景A)即由 `P0` 的 `fg` 决定。
- **告警规则 / 阈值源绑定**(`rulesSource` / `thresholdsSource`):告警面板订阅哪份
规则资产(`kind: asset` + `ref`)或内联规则(`kind: inline` + `data`)——把"看哪条规则"
也变成配置项,避免把规则 id 写死在前端。
- **SOP 联动**(`showSop`):PRD 场景A「LLM 生成原因+处置建议 → 值班长确认」是否在面板展开。
- **确认 / 静默行为**(`requireAck` / `ackTimeoutS` / `muteLower`):关键告警是否强制人工确认、
超时升级、是否静默低于某 severity 的提示。
- **分组 / 排序 / 最大条数**(`groupBy` / `sortBy` / `maxItems`):大屏展示策略外置。
## 用法
```python
from cockpit import (
validate_alarm_config, load_alarm_config, render_alarm_panel_props,
)
# 1) 配置资产(通常来自模板 YAML;这里用 dict 示意)
config = {
"$schema": "iAOP-cockpit-alarm-panel-v1",
"severityColors": [
{"severity": "P0", "fg": "#ff3b30", "bg": "rgba(255,59,48,0.12)", "icon": "alert-octagon"},
{"severity": "P1", "fg": "#f5a623", "bg": "rgba(245,166,35,0.12)"},
{"severity": "P2", "fg": "#3aa0ff", "bg": "rgba(58,160,255,0.10)"},
],
"rulesSource": {"kind": "asset", "ref": "ti-cl4/impurity-forecast/config/alert_rules.template.yaml"},
"showSop": True, "requireAck": True, "ackTimeoutS": 300, "muteLower": "P2",
"groupBy": "severity", "sortBy": "severity", "maxItems": 50,
}
# 2) 校验(聚合全部字段级错误,便于配置台「错误列表」展示)
result = validate_alarm_config(config)
assert result.ok, result.errors
# 3) 加载为内存模型
cfg = load_alarm_config(config)
# 4) 编译为 alarm_panel 组件的 props(#51 渲染层在 _build_props 里合并即可)
props = render_alarm_panel_props(cfg)
# -> {'subscribe': 'alarm_stream', 'severityStyles': {...}, 'rulesSource': {...},
# 'showSop': True, 'requireAck': True, 'ackTimeoutS': 300, ...}
```
切换行业模板(如树脂)= 换一份配置资产(不同配色 / 规则源),`render_alarm_panel_props`
随配置变化产出不同 props,**前端代码零改动**。
## 设计要点
- **零运行时依赖**:只用标准库,配置资产是可 `json.dumps` 的纯 dict,便于配置台发布与审计。
- **声明式 + 强校验**:`validate_alarm_config` 收集全部字段级错误(沿用 #50
`LayoutValidationResult` 风格);`load_alarm_config` 校验失败抛 `AlarmConfigError`
并携带错误清单。
- **与 #50/#51 解耦**:本模块不 import `layout` / `renderer`(它们在各自 feature 分支上推进),
避免对未合并分支形成硬依赖;`render_alarm_panel_props` 仅产出 `alarm_panel` 组件的 props dict,
由 #51 渲染层在 `_build_props` 里合并即可。#50/#51 合入 main 后,本模块的 `__init__`
会合并它们的导出(`CockpitLayout` / `validate_layout` / `RenderPlan` 等)。
## 测试 / 验收
```bash
# 在 core/cockpit 目录下
python -m unittest discover -s tests -p "test_alarm_config.py" -v # 28 用例
python scripts/verify_alarm_config.py # 验收脚本
```
参考资产:`templates/ti-cl4/dashboard/alarm_panel.ti.yaml`(氯化车间/海绵钛告警面板配置)。
+43
View File
@@ -46,6 +46,28 @@ from .renderer import ( # noqa: F401
render_layout, render_layout,
render_widget, render_widget,
) )
from .alarm_config import ( # noqa: F401
ALARM_CONFIG_SCHEMA_ID,
ALARM_CONFIG_SCHEMA_VERSION,
DEFAULT_ACK_TIMEOUT_S,
DEFAULT_MAX_ITEMS,
DEFAULT_MUTE_LOWER,
MAX_ALLOWED_ITEMS,
SEVERITY_RANK,
VALID_GROUP_BY,
VALID_SEVERITIES,
VALID_SORT_BY,
VALID_SOURCE_KINDS,
AlarmConfigError,
AlarmConfigValidationResult,
AlarmPanelConfig,
AlarmRulesSource,
SeverityColor,
filter_alarms_by_mute,
load_alarm_config,
render_alarm_panel_props,
validate_alarm_config,
)
__all__ = [ __all__ = [
"LAYOUT_SCHEMA_ID", "LAYOUT_SCHEMA_ID",
@@ -73,4 +95,25 @@ __all__ = [
"compute_grid_placement", "compute_grid_placement",
"plan_to_dict", "plan_to_dict",
"plan_to_json", "plan_to_json",
# 报警看板配置化(#52 / PRD 5.5 告警面板配置化)
"ALARM_CONFIG_SCHEMA_ID",
"ALARM_CONFIG_SCHEMA_VERSION",
"DEFAULT_ACK_TIMEOUT_S",
"DEFAULT_MAX_ITEMS",
"DEFAULT_MUTE_LOWER",
"MAX_ALLOWED_ITEMS",
"SEVERITY_RANK",
"VALID_GROUP_BY",
"VALID_SEVERITIES",
"VALID_SORT_BY",
"VALID_SOURCE_KINDS",
"AlarmConfigError",
"AlarmConfigValidationResult",
"AlarmPanelConfig",
"AlarmRulesSource",
"SeverityColor",
"filter_alarms_by_mute",
"load_alarm_config",
"render_alarm_panel_props",
"validate_alarm_config",
] ]
+519
View File
@@ -0,0 +1,519 @@
# -*- coding: utf-8 -*-
"""报警看板配置化(issue #52 / PRD 5.5「⑤ 配置化驾驶舱」)。
PRD 5.5 的告警面板(``alarm_panel``)在 #50/#51 里被实现为「固定订阅
``alarm_stream``、套用主题默认告警色」的硬编码组件。本模块把告警面板的
**展示语义外置为模板配置**(PRD line 152/171:阈值/规则外置 JSON,行业工程师
在配置台维护),让"换行业只换配置资产、前端代码零改动"这条验收口径也覆盖到
告警面板。
外置的配置点(均由行业模板在配置台维护):
1. **严重度→颜色映射**(``severity_colors``):P0/P1/P2 三级各自的前景色 /
背景色 / 图标,覆盖主题默认告警色;驾驶舱红色告警(PRD 5.3 ③ 场景A)
即由 ``P0`` 的 ``fg`` 决定,**换行业只改这张映射表**。
2. **告警规则 / 阈值源绑定**(``rules_source`` / ``thresholds_source``):
告警面板订阅哪份规则资产(如 ``templates/ti-cl4/impurity-forecast/
config/alert_rules.template.yaml``)与哪份阈值包——把"看哪条规则"
也变成配置项,避免把规则 id 写死在前端。
3. **SOP 联动开关**(``show_sop``):PRD 场景A「LLM 生成原因+处置建议 →
值班长确认」,是否在面板里展开处置 SOP(高利害人工确认,PRD line 333)。
4. **确认 / 静默行为**(``require_ack`` / ``ack_timeout_s`` / ``mute_lower``):
关键告警是否强制人工确认、超时升级、是否静默低于某 severity 的提示。
5. **分组 / 排序 / 最大条数**(``group_by`` / ``sort_by`` / ``max_items``):
大屏展示策略外置(与 #51 的 perf 策略互补:这里是"展示多少/怎么排",
#51 是"怎么渲染得快")。
设计要点
--------
- **零运行时依赖**:与 #50/#51、data-bus、rag-kb 一致,只用标准库;
配置资产是可 ``json.dumps`` 的纯 dict,便于配置台发布与审计。
- **声明式 + 强校验**:``validate_alarm_config`` 收集全部字段级错误
(沿用 #50 ``LayoutValidationResult`` 风格),``load_alarm_config`` 在校验
不通过时抛 ``AlarmConfigError`` 并携带错误清单,便于配置台「错误列表」展示。
- **与 #50/#51 解耦**:本模块不 import ``layout`` / ``renderer``(它们尚未合入
main),避免对未合并分支形成硬依赖;``render_alarm_panel_props`` 仅产出一份
``alarm_panel`` 组件的 props dict,由 #51 渲染层在 ``_build_props`` 里合并即可。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# 版本标识(被告警面板配置资产的 $schema 引用)
# ---------------------------------------------------------------------------
ALARM_CONFIG_SCHEMA_ID: str = "iAOP-cockpit-alarm-panel-v1"
ALARM_CONFIG_SCHEMA_VERSION: int = 1
# ---------------------------------------------------------------------------
# 合法性集合
# ---------------------------------------------------------------------------
# 严重度三级(与 templates/ti-cl4/impurity-forecast/alert_rules.py 的 AlertSeverity 对齐)。
VALID_SEVERITIES: Tuple[str, ...] = ("P0", "P1", "P2")
# severity 排序权重(越大越严重;用于 mute_lower / sort_by=severity 的排序基准)。
SEVERITY_RANK: Dict[str, int] = {"P0": 3, "P1": 2, "P2": 1}
# severity_colors 里每个 severity 必须声明的颜色键。
_REQUIRED_COLOR_KEYS: Tuple[str, ...] = ("fg", "bg")
# severity_colors 里允许额外声明的可选颜色键(图标 / 边框)。
_OPTIONAL_COLOR_KEYS: Tuple[str, ...] = ("icon", "border")
# rules_source / thresholds_source 允许的 ``kind`` 取值:
# - asset : 指向模板仓库内的一份配置资产路径(如 alert_rules.template.yaml)
# - inline : 内联在配置里(rules_source.data 直接给出规则声明列表)
VALID_SOURCE_KINDS: Tuple[str, ...] = ("asset", "inline")
# 告警面板允许的分组维度(PRD 5.5 能力:可按 severity / 工序 / 规则分组)。
VALID_GROUP_BY: Tuple[str, ...] = ("severity", "rule", "process")
# 告警面板允许的排序键(severity 按严重度;time 按触发时间倒序)。
VALID_SORT_BY: Tuple[str, ...] = ("severity", "time")
# 静默下限:低于该 severity 的告警不展示(默认 P2 = 提示级也展示,即不静默)。
DEFAULT_MUTE_LOWER: str = "P2"
# 单面板最大展示条数(PRD 5.5 大屏策略:超过则折叠 + 计数角标)。
DEFAULT_MAX_ITEMS: int = 50
MAX_ALLOWED_ITEMS: int = 500
# 确认超时下限(秒):require_ack=True 时,超时未确认自动升级 severity。
DEFAULT_ACK_TIMEOUT_S: int = 300
# ---------------------------------------------------------------------------
# 异常 / 结果
# ---------------------------------------------------------------------------
class AlarmConfigError(ValueError):
"""告警面板配置校验失败。``load_alarm_config`` 在校验不通过时抛出。
``errors`` 收集全部字段级错误,便于配置台「错误列表(行号+原因)」展示,
沿用 #50 ``LayoutValidationError`` 的多错误聚合风格。
"""
def __init__(self, errors: List[str]):
super().__init__("; ".join(errors) if errors else "alarm panel config validation failed")
self.errors: List[str] = list(errors)
@dataclass
class AlarmConfigValidationResult:
"""``validate_alarm_config`` 的返回值,区分「是否合法」与「全部错误清单」。"""
ok: bool
errors: List[str] = field(default_factory=list)
# 校验通过后回填的规范化配置(补默认值后的 dict),便于直接发布/渲染。
normalized: Optional[Dict[str, Any]] = None
# ---------------------------------------------------------------------------
# 内存模型(dataclass)
# ---------------------------------------------------------------------------
@dataclass
class SeverityColor:
"""单个严重度的展示配色(覆盖主题默认告警色)。
Attributes:
severity: P0 / P1 / P2。
fg: 前景色(告警文本 / 图标颜色;P0 的 fg 即驾驶舱「红色告警」)。
bg: 背景色(告警条底色)。
icon: 可选图标名(如 ``"alert-triangle"``)。
border: 可选左边框色(用于告警条强调)。
"""
severity: str
fg: str
bg: str
icon: Optional[str] = None
border: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"severity": self.severity, "fg": self.fg, "bg": self.bg}
if self.icon is not None:
d["icon"] = self.icon
if self.border is not None:
d["border"] = self.border
return d
@dataclass
class AlarmRulesSource:
"""告警规则 / 阈值的来源绑定(PRD「阈值外置 JSON」)。
Attributes:
kind: ``asset``(模板仓库内资产路径)或 ``inline``(内联声明)。
ref: ``asset`` 时的资产路径(相对模板根,如
``ti-cl4/impurity-forecast/config/alert_rules.template.yaml``)。
data: ``inline`` 时的规则声明列表(每条是 {id, severity, ...} dict)。
"""
kind: str
ref: Optional[str] = None
data: Optional[List[Dict[str, Any]]] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"kind": self.kind}
if self.ref is not None:
d["ref"] = self.ref
if self.data is not None:
d["data"] = list(self.data)
return d
@dataclass
class AlarmPanelConfig:
"""一份告警面板配置的内存模型(对应一份模板级配置资产)。
渲染层(Vue3 / 配置台)消费本对象即可驱动 ``AlarmPanel`` 组件的全部展示
行为:切换行业模板 = 加载另一份 ``AlarmPanelConfig``,**前端代码零改动**。
"""
severity_colors: List[SeverityColor]
rules_source: AlarmRulesSource
thresholds_source: Optional[AlarmRulesSource] = None
show_sop: bool = True
require_ack: bool = False
ack_timeout_s: int = DEFAULT_ACK_TIMEOUT_S
mute_lower: str = DEFAULT_MUTE_LOWER
group_by: str = "severity"
sort_by: str = "severity"
max_items: int = DEFAULT_MAX_ITEMS
schema: str = ALARM_CONFIG_SCHEMA_ID
def to_dict(self) -> Dict[str, Any]:
"""序列化为可发布的配置资产 dict(结构对齐校验器输入)。"""
d: Dict[str, Any] = {
"$schema": self.schema,
"severityColors": [c.to_dict() for c in self.severity_colors],
"rulesSource": self.rules_source.to_dict(),
"showSop": self.show_sop,
"requireAck": self.require_ack,
"muteLower": self.mute_lower,
"groupBy": self.group_by,
"sortBy": self.sort_by,
"maxItems": self.max_items,
}
if self.thresholds_source is not None:
d["thresholdsSource"] = self.thresholds_source.to_dict()
if self.require_ack:
d["ackTimeoutS"] = self.ack_timeout_s
return d
# ---------------------------------------------------------------------------
# 校验器
# ---------------------------------------------------------------------------
def _is_str_nonempty(value: Any) -> bool:
return isinstance(value, str) and value.strip() != ""
def _validate_source(
source: Any, field_name: str, errors: List[str], required: bool
) -> Optional[Dict[str, Any]]:
"""校验一个 rules_source / thresholds_source dict。
返回规范化后的 dict(校验通过时),或 None(非法 / 缺失)。
"""
ctx = field_name
if source is None:
if required:
errors.append(f"{ctx}: 缺失(告警面板必须绑定 rulesSource)")
return None
if not isinstance(source, dict):
errors.append(f"{ctx}: 必须是对象(dict),实际为 {type(source).__name__}")
return None
kind = source.get("kind")
if kind not in VALID_SOURCE_KINDS:
errors.append(
f"{ctx}.kind: 非法 {kind!r},合法值 {list(VALID_SOURCE_KINDS)}"
)
return None
normalized: Dict[str, Any] = {"kind": kind}
if kind == "asset":
ref = source.get("ref")
if not _is_str_nonempty(ref):
errors.append(f"{ctx}.ref: kind=asset 时必须给出非空资产路径")
else:
normalized["ref"] = ref
else: # inline
data = source.get("data")
if not isinstance(data, list) or not data:
errors.append(f"{ctx}.data: kind=inline 时必须给出非空规则声明列表")
else:
# 每条内联规则至少要有 id(便于驾驶舱引用 / 审计)
bad = [
str(i)
for i, item in enumerate(data)
if not isinstance(item, dict) or not _is_str_nonempty(item.get("id"))
]
if bad:
errors.append(
f"{ctx}.data: 内联规则项 {','.join(bad)} 缺失 id 或非对象"
)
else:
normalized["data"] = list(data)
return normalized
def validate_alarm_config(config: Any) -> AlarmConfigValidationResult:
"""对一份告警面板配置资产做结构 + 语义校验,返回校验结果。
非法资产不会提前返回:尽量收集全部字段级错误,便于配置台一次性展示
「错误列表(字段 + 原因)」,与 #50 ``validate_layout`` 行为一致。
"""
errors: List[str] = []
if not isinstance(config, dict):
return AlarmConfigValidationResult(
ok=False, errors=[f"配置必须是对象(dict),实际为 {type(config).__name__}"]
)
# $schema(选填,但若给出必须对齐版本标识)
schema = config.get("$schema")
if schema is not None and schema != ALARM_CONFIG_SCHEMA_ID:
errors.append(
f"$schema: 应为 {ALARM_CONFIG_SCHEMA_ID!r},实际为 {schema!r}"
)
# severityColors:必填、至少覆盖 P0/P1/P2 三级、每级颜色键齐全
raw_colors = config.get("severityColors", config.get("severity_colors"))
color_by_sev: Dict[str, Dict[str, Any]] = {}
if not isinstance(raw_colors, list) or not raw_colors:
errors.append("severityColors: 缺失或非列表(至少需要 P0/P1/P2 三级配色)")
else:
for i, item in enumerate(raw_colors):
ctx = f"severityColors[{i}]"
if not isinstance(item, dict):
errors.append(f"{ctx}: 必须是对象(dict)")
continue
sev = item.get("severity")
if sev not in VALID_SEVERITIES:
errors.append(
f"{ctx}.severity: 非法 {sev!r},合法值 {list(VALID_SEVERITIES)}"
)
continue
if sev in color_by_sev:
errors.append(f"{ctx}.severity: {sev!r} 重复声明")
continue
norm_color: Dict[str, Any] = {"severity": sev}
color_ok = True
for key in _REQUIRED_COLOR_KEYS:
v = item.get(key)
if not _is_str_nonempty(v):
errors.append(f"{ctx}.{key}: 缺失或非非空字符串")
color_ok = False
else:
norm_color[key] = v
for key in _OPTIONAL_COLOR_KEYS:
v = item.get(key)
if v is None:
continue
if not _is_str_nonempty(v):
errors.append(f"{ctx}.{key}: 给出则必须是非空字符串")
else:
norm_color[key] = v
if color_ok:
color_by_sev[sev] = norm_color
for sev in VALID_SEVERITIES:
if sev not in color_by_sev:
errors.append(f"severityColors: 缺少 {sev} 级配色(必须覆盖 P0/P1/P2)")
# rulesSource:必填
raw_rules = config.get("rulesSource", config.get("rules_source"))
norm_rules = _validate_source(raw_rules, "rulesSource", errors, required=True)
# thresholdsSource:选填(阈值可与规则同源,也可独立)
raw_thr = config.get("thresholdsSource", config.get("thresholds_source"))
norm_thr = _validate_source(raw_thr, "thresholdsSource", errors, required=False)
# showSop:布尔
show_sop = config.get("showSop", config.get("show_sop", True))
if not isinstance(show_sop, bool):
errors.append(f"showSop: 必须是布尔,实际为 {type(show_sop).__name__}")
# requireAck:布尔
require_ack = config.get("requireAck", config.get("require_ack", False))
if not isinstance(require_ack, bool):
errors.append(f"requireAck: 必须是布尔,实际为 {type(require_ack).__name__}")
# ackTimeoutS:require_ack 时才生效,给出则必须是正整数
ack_timeout = config.get("ackTimeoutS", config.get("ack_timeout_s", DEFAULT_ACK_TIMEOUT_S))
if isinstance(ack_timeout, bool) or not isinstance(ack_timeout, int) or ack_timeout <= 0:
errors.append(f"ackTimeoutS: 必须是正整数(秒),实际为 {ack_timeout!r}")
# muteLower:必须是合法 severity
mute_lower = config.get("muteLower", config.get("mute_lower", DEFAULT_MUTE_LOWER))
if mute_lower not in VALID_SEVERITIES:
errors.append(
f"muteLower: 非法 {mute_lower!r},合法值 {list(VALID_SEVERITIES)}"
)
# groupBy / sortBy:枚举
group_by = config.get("groupBy", config.get("group_by", "severity"))
if group_by not in VALID_GROUP_BY:
errors.append(f"groupBy: 非法 {group_by!r},合法值 {list(VALID_GROUP_BY)}")
sort_by = config.get("sortBy", config.get("sort_by", "severity"))
if sort_by not in VALID_SORT_BY:
errors.append(f"sortBy: 非法 {sort_by!r},合法值 {list(VALID_SORT_BY)}")
# maxItems:1..MAX_ALLOWED_ITEMS
max_items = config.get("maxItems", config.get("max_items", DEFAULT_MAX_ITEMS))
if (
isinstance(max_items, bool)
or not isinstance(max_items, int)
or not (1 <= max_items <= MAX_ALLOWED_ITEMS)
):
errors.append(
f"maxItems: 必须是 1..{MAX_ALLOWED_ITEMS} 的整数,实际为 {max_items!r}"
)
if errors:
return AlarmConfigValidationResult(ok=False, errors=errors)
# 规范化输出(统一字段名 + 补默认值),便于直接发布 / 渲染。
normalized: Dict[str, Any] = {
"$schema": ALARM_CONFIG_SCHEMA_ID,
"severityColors": [color_by_sev[sev] for sev in VALID_SEVERITIES],
"rulesSource": norm_rules,
"showSop": show_sop,
"requireAck": require_ack,
"ackTimeoutS": ack_timeout if require_ack else DEFAULT_ACK_TIMEOUT_S,
"muteLower": mute_lower,
"groupBy": group_by,
"sortBy": sort_by,
"maxItems": max_items,
}
if norm_thr is not None:
normalized["thresholdsSource"] = norm_thr
return AlarmConfigValidationResult(ok=True, errors=[], normalized=normalized)
def load_alarm_config(config: Any) -> AlarmPanelConfig:
"""从已解析的 dict 构造 ``AlarmPanelConfig``(校验失败抛 ``AlarmConfigError``)。
与 #50 ``load_layout`` 对称:先 ``validate_alarm_config``,再按规范化结果
构造内存模型;校验不通过时把全部错误聚合到异常里。
"""
result = validate_alarm_config(config)
if not result.ok:
raise AlarmConfigError(result.errors)
assert result.normalized is not None # 校验通过一定回填 normalized
norm = result.normalized
severity_colors = [
SeverityColor(
severity=c["severity"],
fg=c["fg"],
bg=c["bg"],
icon=c.get("icon"),
border=c.get("border"),
)
for c in norm["severityColors"]
]
def _to_source(d: Dict[str, Any]) -> AlarmRulesSource:
return AlarmRulesSource(
kind=d["kind"],
ref=d.get("ref"),
data=d.get("data"),
)
thresholds_source = None
if norm.get("thresholdsSource") is not None:
thresholds_source = _to_source(norm["thresholdsSource"])
return AlarmPanelConfig(
severity_colors=severity_colors,
rules_source=_to_source(norm["rulesSource"]),
thresholds_source=thresholds_source,
show_sop=norm["showSop"],
require_ack=norm["requireAck"],
ack_timeout_s=norm["ackTimeoutS"],
mute_lower=norm["muteLower"],
group_by=norm["groupBy"],
sort_by=norm["sortBy"],
max_items=norm["maxItems"],
schema=ALARM_CONFIG_SCHEMA_ID,
)
# ---------------------------------------------------------------------------
# 渲染辅助:把告警面板配置编译成 alarm_panel 组件的 props
# ---------------------------------------------------------------------------
def render_alarm_panel_props(config: AlarmPanelConfig) -> Dict[str, Any]:
"""把一份 ``AlarmPanelConfig`` 编译成 ``alarm_panel`` 组件的 props dict。
#51 渲染层在 ``_build_props`` 里对 ``alarm_panel`` 原本只写死
``subscribe="alarm_stream"``;合入本模块后改为合并本函数的输出即可,
前端 ``AlarmPanel`` 组件据此驱动「颜色 / 规则源 / SOP / 确认 / 分组排序」,
**切换行业模板只换配置资产,前端代码零改动**(PRD 5.5 验收口径)。
返回的 props 与 #51 的 props 风格一致:扁平、可直接 ``json.dumps``、
键名用前端友好的 camelCase。
"""
# severity → 展示描述(颜色 + 可选图标 / 边框),驱动告警条着色
severity_styles: Dict[str, Dict[str, Any]] = {}
for c in config.severity_colors:
style: Dict[str, Any] = {"fg": c.fg, "bg": c.bg}
if c.icon is not None:
style["icon"] = c.icon
if c.border is not None:
style["border"] = c.border
severity_styles[c.severity] = style
# 规则 / 阈值源:asset 透传 ref,inline 透传 data
def _source_props(src: AlarmRulesSource) -> Dict[str, Any]:
if src.kind == "asset":
return {"kind": "asset", "ref": src.ref or ""}
return {"kind": "inline", "data": list(src.data or [])}
props: Dict[str, Any] = {
# 保留 #51 原有的订阅语义(向后兼容)
"subscribe": "alarm_stream",
"severityStyles": severity_styles,
"rulesSource": _source_props(config.rules_source),
"showSop": config.show_sop,
"requireAck": config.require_ack,
"muteLower": config.mute_lower,
"groupBy": config.group_by,
"sortBy": config.sort_by,
"maxItems": config.max_items,
}
if config.thresholds_source is not None:
props["thresholdsSource"] = _source_props(config.thresholds_source)
if config.require_ack:
props["ackTimeoutS"] = config.ack_timeout_s
return props
def filter_alarms_by_mute(
alarms: List[Dict[str, Any]],
mute_lower: str = DEFAULT_MUTE_LOWER,
) -> List[Dict[str, Any]]:
"""按 ``mute_lower`` 过滤告警列表(演示配置如何驱动展示策略)。
给定一批告警 dict(每条含 ``severity``),返回严重度 ≥ ``mute_lower`` 的子集。
用于验证「静默下限」配置点真正生效——配置台改 ``muteLower`` 即可改变面板
展示内容,无需改前端代码。``sort_by=severity`` 时同时按严重度降序排序。
"""
if mute_lower not in SEVERITY_RANK:
raise AlarmConfigError([f"mute_lower 非法: {mute_lower!r}"])
floor = SEVERITY_RANK[mute_lower]
kept = [
a for a in alarms
if isinstance(a, dict) and SEVERITY_RANK.get(a.get("severity"), 0) >= floor
]
return sorted(
kept,
key=lambda a: SEVERITY_RANK.get(a.get("severity"), 0),
reverse=True,
)
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
"""报警看板配置化验收脚本(issue #52,PRD 5.5 验收口径)。
验证四个能力点(覆盖 PRD 5.5「切换模板零改码」对告警面板的覆盖):
1. **Ti 行业模板配置资产合法**:``templates/ti-cl4/dashboard/alarm_panel.ti.yaml``
所描述的配置(等价 dict)通过 ``iAOP-cockpit-alarm-panel-v1`` 校验,
并能渲染成 ``alarm_panel`` 组件 props(证明告警面板已配置化);
2. **树脂模板同样可配置**:换一份配置资产(不同配色 / 规则源)即得到不同 props,
前端代码零改动(PRD 5.5 验收口径「切换模板零改码」);
3. **配置点真实生效**:``mute_lower`` 改变 → ``filter_alarms_by_mute`` 过滤结果
改变;``require_ack`` 关闭 → props 里不再出现 ``ackTimeoutS``;
4. **负例**:一份故意破坏的配置(缺 P0 配色 / 非法 severity / 缺 rulesSource)
被正确拒绝并聚合多条错误(对齐 #50 校验器风格)。
用法(在 core/cockpit 目录下):
python scripts/verify_alarm_config.py
退出码:0 = 全部通过;1 = 存在未达标项。
说明:Ti 模板资产本身是 YAML;本脚本不依赖 PyYAML(避免引入运行时依赖),
而是用与该 YAML 文件内容等价的 dict 进行校验——字段语义完全一致,
若安装了 PyYAML,可直接解析原文件复现(见文末注释)。
"""
from __future__ import annotations
import os
import sys
# 本脚本位于 core/cockpit/scripts/,需要把 core/ 加入 sys.path,
# 才能 `from cockpit import ...`(cockpit 包位于 core/cockpit)
sys.path.insert(
0,
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
)
from cockpit import ( # noqa: E402
ALARM_CONFIG_SCHEMA_ID,
filter_alarms_by_mute,
load_alarm_config,
render_alarm_panel_props,
validate_alarm_config,
)
def _ti_config() -> dict:
"""与 templates/ti-cl4/dashboard/alarm_panel.ti.yaml 等价的 dict。"""
return {
"$schema": ALARM_CONFIG_SCHEMA_ID,
"severityColors": [
{"severity": "P0", "fg": "#ff3b30", "bg": "rgba(255,59,48,0.12)",
"icon": "alert-octagon", "border": "#ff3b30"},
{"severity": "P1", "fg": "#f5a623", "bg": "rgba(245,166,35,0.12)",
"icon": "alert-triangle", "border": "#f5a623"},
{"severity": "P2", "fg": "#3aa0ff", "bg": "rgba(58,160,255,0.10)",
"icon": "info"},
],
"rulesSource": {
"kind": "asset",
"ref": "ti-cl4/impurity-forecast/config/alert_rules.template.yaml",
},
"thresholdsSource": {
"kind": "asset",
"ref": "ti-cl4/impurity-forecast/config/alert_rules.template.yaml",
},
"showSop": True,
"requireAck": True,
"ackTimeoutS": 300,
"muteLower": "P2",
"groupBy": "severity",
"sortBy": "severity",
"maxItems": 50,
}
def _resin_config() -> dict:
"""树脂行业模板的等价配置(不同配色 + 内联规则,证明换模板零改码)。"""
return {
"severityColors": [
{"severity": "P0", "fg": "#e63329", "bg": "rgba(230,51,41,0.12)"},
{"severity": "P1", "fg": "#ffb000", "bg": "rgba(255,176,0,0.12)"},
{"severity": "P2", "fg": "#2bb673", "bg": "rgba(43,182,115,0.10)"},
],
"rulesSource": {
"kind": "inline",
"data": [
{"id": "resin-temp-high", "severity": "P0"},
{"id": "resin-crosslink-low", "severity": "P1"},
],
},
"showSop": False,
"requireAck": False,
"muteLower": "P1", # 树脂模板只看 P0/P1,静默 P2 提示
}
def _check(name: str, fn) -> bool:
try:
ok = fn()
except Exception as exc: # noqa: BLE001
print(f"[FAIL] {name}: 抛异常 {exc!r}")
return False
status = "PASS" if ok else "FAIL"
print(f"[{status}] {name}")
return bool(ok)
def main() -> int:
print("== 报警看板配置化验收(issue #52 / PRD 5.5)==")
all_ok = True
# 1) Ti 模板资产合法 + 可渲染为 props
def check_ti_valid() -> bool:
res = validate_alarm_config(_ti_config())
if not res.ok:
for e in res.errors:
print(f" - {e}")
return False
cfg = load_alarm_config(_ti_config())
props = render_alarm_panel_props(cfg)
# P0 红色告警配色进入 props;requireAck=True 携带 ackTimeoutS
return (
props["severityStyles"]["P0"]["fg"] == "#ff3b30"
and props["requireAck"] is True
and props.get("ackTimeoutS") == 300
and props["rulesSource"]["ref"].endswith("alert_rules.template.yaml")
)
all_ok &= _check("Ti 模板配置合法 + 渲染 props 正确", check_ti_valid)
# 2) 树脂模板换配置 → props 不同,前端代码零改动
def check_resin_differs() -> bool:
cfg = load_alarm_config(_resin_config())
props = render_alarm_panel_props(cfg)
# 树脂 P0 用不同红色;内联规则透传 data;requireAck 关闭不带 ackTimeoutS
return (
props["severityStyles"]["P0"]["fg"] == "#e63329"
and props["rulesSource"]["kind"] == "inline"
and len(props["rulesSource"]["data"]) == 2
and "ackTimeoutS" not in props
and props["showSop"] is False
)
all_ok &= _check("树脂模板换配置 → props 随之变化(切换模板零改码)", check_resin_differs)
# 3) mute_lower 配置点真实驱动展示策略
def check_mute_lower() -> bool:
alarms = [
{"severity": "P2", "id": "a"},
{"severity": "P0", "id": "b"},
{"severity": "P1", "id": "c"},
]
keep_all = filter_alarms_by_mute(alarms, "P2")
only_p0_p1 = filter_alarms_by_mute(alarms, "P1")
# mute=P2 全保留且按严重度降序;mute=P1 过滤掉 P2
return (
[a["id"] for a in keep_all] == ["b", "c", "a"]
and [a["id"] for a in only_p0_p1] == ["b", "c"]
)
all_ok &= _check("muteLower 配置点真实过滤 + 排序告警", check_mute_lower)
# 4) 负例:破坏的配置被拒绝并聚合多条错误
def check_negative() -> bool:
bad = {
"severityColors": [
{"severity": "P0", "fg": "#f00", "bg": "#000"},
# 缺 P1 / P2
{"severity": "P0", "fg": "#f00", "bg": "#000"}, # 重复 P0
{"severity": "P9", "fg": "#f00", "bg": "#000"}, # 非法 severity
],
"rulesSource": {"kind": "asset"}, # 缺 ref
"ackTimeoutS": 0, # 非正整数
"sortBy": "color", # 非法排序键
"maxItems": 0, # 越界
}
res = validate_alarm_config(bad)
# 应收集到多条错误(聚合而非首条即返)
return (not res.ok) and len(res.errors) >= 5
all_ok &= _check("负例:破坏的配置被拒绝并聚合多条错误", check_negative)
print("=" * 48)
print("结果: " + ("全部通过 ✅" if all_ok else "存在未达标项 ❌"))
return 0 if all_ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+338
View File
@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""报警看板配置化单元测试(issue #52 / PRD 5.5「⑤ 配置化驾驶舱」)。
覆盖:
1. 合法配置(Ti 模板)→ 校验通过、可解析为 ``AlarmPanelConfig``、序列化往返一致;
2. 默认值与字段别名(snake_case / camelCase 都接受)、thresholds_source 选填;
3. ``render_alarm_panel_props`` 正确编译 severity 样式 / 规则源 / 确认策略;
4. ``filter_alarms_by_mute`` 静默下限 + severity 降序排序;
5. 各类非法情况:非对象、错误 $schema、severityColors 缺级/重复/非法、
rulesSource 缺失/asset 缺 ref/inline 缺 data、布尔/整数/枚举/区间非法;
6. ``load_alarm_config`` 校验失败抛 ``AlarmConfigError`` 并携带全部错误。
"""
from __future__ import annotations
import copy
import unittest
from cockpit import ( # type: ignore[import-not-found]
ALARM_CONFIG_SCHEMA_ID,
DEFAULT_ACK_TIMEOUT_S,
DEFAULT_MAX_ITEMS,
AlarmConfigError,
AlarmPanelConfig,
filter_alarms_by_mute,
load_alarm_config,
render_alarm_panel_props,
validate_alarm_config,
)
def _valid_config() -> dict:
"""返回一份合法的告警面板配置(Ti 模板风格)。"""
return {
"$schema": ALARM_CONFIG_SCHEMA_ID,
"severityColors": [
{"severity": "P0", "fg": "#ff3b30", "bg": "rgba(255,59,48,0.12)",
"icon": "alert-octagon", "border": "#ff3b30"},
{"severity": "P1", "fg": "#f5a623", "bg": "rgba(245,166,35,0.12)"},
{"severity": "P2", "fg": "#3aa0ff", "bg": "rgba(58,160,255,0.10)"},
],
"rulesSource": {"kind": "asset", "ref": "ti-cl4/cfg/alert_rules.yaml"},
"showSop": True,
"requireAck": True,
"ackTimeoutS": 300,
"muteLower": "P2",
"groupBy": "severity",
"sortBy": "severity",
"maxItems": 50,
}
class ValidateTest(unittest.TestCase):
def test_valid_config_passes(self):
res = validate_alarm_config(_valid_config())
self.assertTrue(res.ok, msg=res.errors)
self.assertEqual(res.errors, [])
norm = res.normalized
assert norm is not None
# 规范化:severityColors 按 P0/P1/P2 顺序回填
self.assertEqual([c["severity"] for c in norm["severityColors"]], ["P0", "P1", "P2"])
# P0 的可选 icon/border 保留
self.assertEqual(norm["severityColors"][0]["icon"], "alert-octagon")
def test_non_dict_rejected(self):
res = validate_alarm_config(["not", "a", "dict"])
self.assertFalse(res.ok)
self.assertIn("对象(dict)", res.errors[0])
def test_wrong_schema_rejected(self):
cfg = _valid_config()
cfg["$schema"] = "something-else"
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("$schema" in e for e in res.errors))
def test_snake_case_aliases_accepted(self):
# snake_case 字段名同样接受(向后兼容手写 YAML)
cfg = {
"severity_colors": [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P1", "fg": "#f", "bg": "#0"},
{"severity": "P2", "fg": "#f", "bg": "#0"},
],
"rules_source": {"kind": "asset", "ref": "a.yaml"},
"show_sop": False,
"require_ack": False,
"mute_lower": "P2",
"group_by": "severity",
"sort_by": "severity",
"max_items": 10,
}
res = validate_alarm_config(cfg)
self.assertTrue(res.ok, msg=res.errors)
def test_missing_severity_level_rejected(self):
cfg = _valid_config()
cfg["severityColors"] = cfg["severityColors"][:2] # 只留 P0/P1
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("缺少 P2" in e for e in res.errors))
def test_duplicate_and_invalid_severity_rejected(self):
cfg = _valid_config()
cfg["severityColors"] = [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P0", "fg": "#f", "bg": "#0"}, # 重复
{"severity": "P9", "fg": "#f", "bg": "#0"}, # 非法
]
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
joined = "; ".join(res.errors)
self.assertIn("重复", joined)
self.assertIn("P9", joined)
def test_missing_required_color_key_rejected(self):
cfg = _valid_config()
cfg["severityColors"][0] = {"severity": "P0", "fg": "#f"} # 缺 bg
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("bg" in e for e in res.errors))
def test_missing_rules_source_rejected(self):
cfg = _valid_config()
del cfg["rulesSource"]
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("rulesSource" in e for e in res.errors))
def test_asset_source_without_ref_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "asset"} # 缺 ref
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("ref" in e for e in res.errors))
def test_inline_source_without_data_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "inline"} # 缺 data
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("data" in e for e in res.errors))
def test_inline_source_item_without_id_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "inline", "data": [{"severity": "P0"}]} # 缺 id
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("id" in e for e in res.errors))
def test_invalid_source_kind_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "graphql", "ref": "x"}
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("kind" in e for e in res.errors))
def test_bool_fields_type_checked(self):
for field in ("showSop", "requireAck"):
cfg = _valid_config()
cfg[field] = "yes"
res = validate_alarm_config(cfg)
self.assertFalse(res.ok, msg=field)
self.assertTrue(any(field in e for e in res.errors), msg=field)
def test_ack_timeout_must_be_positive_int(self):
cfg = _valid_config()
cfg["ackTimeoutS"] = 0
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("ackTimeoutS" in e for e in res.errors))
# bool 不应被当成 int 接受
cfg["ackTimeoutS"] = True
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
def test_enum_fields_validated(self):
for field, valid, invalid in [
("muteLower", ["P0", "P1", "P2"], "P9"),
("groupBy", ["severity", "rule", "process"], "color"),
("sortBy", ["severity", "time"], "color"),
]:
for v in valid:
cfg = _valid_config()
cfg[field] = v
# muteLower=P0 合法但会过滤掉 P1/P2,不影响校验合法性
self.assertTrue(validate_alarm_config(cfg).ok, msg=f"{field}={v}")
cfg = _valid_config()
cfg[field] = invalid
res = validate_alarm_config(cfg)
self.assertFalse(res.ok, msg=f"{field}={invalid}")
self.assertTrue(any(field in e for e in res.errors), msg=field)
def test_max_items_range(self):
cfg = _valid_config()
cfg["maxItems"] = 0
self.assertFalse(validate_alarm_config(cfg).ok)
cfg["maxItems"] = 501
self.assertFalse(validate_alarm_config(cfg).ok)
cfg["maxItems"] = 500
self.assertTrue(validate_alarm_config(cfg).ok)
cfg["maxItems"] = True # bool 不被当成 int
self.assertFalse(validate_alarm_config(cfg).ok)
def test_defaults_applied_when_optional_omitted(self):
cfg = {
"severityColors": [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P1", "fg": "#f", "bg": "#0"},
{"severity": "P2", "fg": "#f", "bg": "#0"},
],
"rulesSource": {"kind": "asset", "ref": "a.yaml"},
}
res = validate_alarm_config(cfg)
self.assertTrue(res.ok, msg=res.errors)
assert res.normalized is not None
self.assertEqual(res.normalized["showSop"], True)
self.assertEqual(res.normalized["requireAck"], False)
self.assertEqual(res.normalized["ackTimeoutS"], DEFAULT_ACK_TIMEOUT_S)
self.assertEqual(res.normalized["muteLower"], "P2")
self.assertEqual(res.normalized["groupBy"], "severity")
self.assertEqual(res.normalized["sortBy"], "severity")
self.assertEqual(res.normalized["maxItems"], DEFAULT_MAX_ITEMS)
# thresholds_source 选填,未给出则 normalized 不含它
self.assertNotIn("thresholdsSource", res.normalized)
def test_multiple_errors_aggregated(self):
# 一次性破坏多个字段,校验器应聚合多条错误而非首条即返
bad = {
"severityColors": [{"severity": "P0", "fg": "#f", "bg": "#0"}], # 缺 P1/P2
"rulesSource": {"kind": "asset"}, # 缺 ref
"ackTimeoutS": -1,
"sortBy": "color",
}
res = validate_alarm_config(bad)
self.assertFalse(res.ok)
self.assertGreaterEqual(len(res.errors), 4)
class LoadTest(unittest.TestCase):
def test_load_valid_returns_model(self):
cfg = load_alarm_config(_valid_config())
self.assertIsInstance(cfg, AlarmPanelConfig)
self.assertEqual(cfg.schema, ALARM_CONFIG_SCHEMA_ID)
self.assertEqual(len(cfg.severity_colors), 3)
self.assertEqual(cfg.rules_source.kind, "asset")
self.assertEqual(cfg.rules_source.ref, "ti-cl4/cfg/alert_rules.yaml")
self.assertTrue(cfg.require_ack)
def test_load_roundtrip(self):
original = _valid_config()
cfg = load_alarm_config(original)
# 内存模型序列化后再次校验仍合法(往返一致)
res = validate_alarm_config(cfg.to_dict())
self.assertTrue(res.ok, msg=res.errors)
def test_load_invalid_raises_with_errors(self):
bad = copy.deepcopy(_valid_config())
del bad["rulesSource"]
with self.assertRaises(AlarmConfigError) as cm:
load_alarm_config(bad)
# 异常携带全部错误清单
self.assertGreaterEqual(len(cm.exception.errors), 1)
self.assertTrue(any("rulesSource" in e for e in cm.exception.errors))
class RenderPropsTest(unittest.TestCase):
def test_props_contain_severity_styles_and_sources(self):
cfg = load_alarm_config(_valid_config())
props = render_alarm_panel_props(cfg)
# 保留 #51 的订阅语义(向后兼容)
self.assertEqual(props["subscribe"], "alarm_stream")
# severity 样式:P0 红色 + 可选 border
self.assertEqual(props["severityStyles"]["P0"]["fg"], "#ff3b30")
self.assertEqual(props["severityStyles"]["P0"]["border"], "#ff3b30")
# asset 规则源透传 ref
self.assertEqual(props["rulesSource"]["kind"], "asset")
self.assertTrue(props["rulesSource"]["ref"].endswith("alert_rules.yaml"))
# require_ack=True 携带 ackTimeoutS
self.assertEqual(props["ackTimeoutS"], 300)
self.assertTrue(props["showSop"])
def test_props_ack_omitted_when_not_required(self):
cfg = _valid_config()
cfg["requireAck"] = False
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertNotIn("ackTimeoutS", props)
self.assertFalse(props["requireAck"])
def test_props_inline_source_data_passed_through(self):
cfg = _valid_config()
cfg["rulesSource"] = {
"kind": "inline",
"data": [{"id": "r1", "severity": "P0"}, {"id": "r2", "severity": "P1"}],
}
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertEqual(props["rulesSource"]["kind"], "inline")
self.assertEqual(len(props["rulesSource"]["data"]), 2)
def test_props_thresholds_source_optional(self):
# 未声明 thresholds_source → props 不含该键
props = render_alarm_panel_props(load_alarm_config(_valid_config()))
self.assertNotIn("thresholdsSource", props)
# 声明后 → 透传
cfg = _valid_config()
cfg["thresholdsSource"] = {"kind": "asset", "ref": "thr.yaml"}
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertEqual(props["thresholdsSource"]["ref"], "thr.yaml")
class FilterMuteTest(unittest.TestCase):
def test_filter_keeps_above_floor_and_sorts_desc(self):
alarms = [
{"severity": "P2", "id": "a"},
{"severity": "P0", "id": "b"},
{"severity": "P1", "id": "c"},
]
# mute=P2:全保留,按严重度降序
kept = filter_alarms_by_mute(alarms, "P2")
self.assertEqual([a["id"] for a in kept], ["b", "c", "a"])
# mute=P1:滤掉 P2
kept = filter_alarms_by_mute(alarms, "P1")
self.assertEqual([a["id"] for a in kept], ["b", "c"])
# mute=P0:只留 P0
kept = filter_alarms_by_mute(alarms, "P0")
self.assertEqual([a["id"] for a in kept], ["b"])
def test_filter_ignores_unknown_severity(self):
alarms = [{"severity": "P9", "id": "x"}, {"severity": "P0", "id": "y"}]
kept = filter_alarms_by_mute(alarms, "P2")
self.assertEqual([a["id"] for a in kept], ["y"])
def test_filter_invalid_mute_raises(self):
with self.assertRaises(AlarmConfigError):
filter_alarms_by_mute([], "P9")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# 氯化车间/海绵钛驾驶舱 · 告警面板配置资产(iAOP-Template-Ti 一期)。
#
# 对齐 PRD 5.5「⑤ 配置化驾驶舱」+ 5.3 ③ 场景A(异常检测 → 驾驶舱红色告警 +
# LLM 生成"原因+处置建议" → 值班长确认):把告警面板的「严重度配色 / 规则源 /
# SOP 联动 / 确认与静默 / 分组排序」外置为模板配置(issue #52)。
#
# 切换到其他行业模板(如树脂)= 换一份本文件,告警面板前端代码零改动
# (PRD 5.5 验收口径「切换模板零改码」覆盖到告警面板)。
#
# $schema 对应 core/cockpit/alarm_config.py 的 ALARM_CONFIG_SCHEMA_ID。
$schema: iAOP-cockpit-alarm-panel-v1
# severityColors:P0/P1/P2 三级展示配色(覆盖主题默认告警色)。
# P0=红色(驾驶舱红色告警,PRD 场景A)、P1=黄色(加强监控)、P2=蓝色(提示)。
severityColors:
- severity: P0
fg: "#ff3b30" # 红色前景(告警文本 / 图标)
bg: "rgba(255,59,48,0.12)"
icon: alert-octagon
border: "#ff3b30"
- severity: P1
fg: "#f5a623" # 黄色
bg: "rgba(245,166,35,0.12)"
icon: alert-triangle
border: "#f5a623"
- severity: P2
fg: "#3aa0ff" # 蓝色(提示)
bg: "rgba(58,160,255,0.10)"
icon: info
# rulesSource:告警面板订阅哪份规则资产(阈值/规则外置)。
# 指向 issue #72 落地的炉层杂质预警规则模板(templates/ti-cl4/impurity-forecast/
# config/alert_rules.template.yaml,在其 feature 分支上推进;此处先按规划路径绑定)。
rulesSource:
kind: asset
ref: ti-cl4/impurity-forecast/config/alert_rules.template.yaml
# thresholdsSource:阈值可与规则同源,这里显式独立声明,便于配置台单独维护。
thresholdsSource:
kind: asset
ref: ti-cl4/impurity-forecast/config/alert_rules.template.yaml
# SOP 联动:在面板里展开处置 SOP(PRD 场景A:LLM 报警解释 + 值班长确认)。
showSop: true
# 关键告警强制人工确认(PRD line 333:高利害人工确认,不直接联动执行机构)。
requireAck: true
ackTimeoutS: 300 # 5 分钟未确认自动升级 severity
# 静默下限:低于 P2 的不展示(Ti 一期 P0/P1/P2 全展示)。
muteLower: P2
# 分组 / 排序 / 最大条数:大屏展示策略外置。
groupBy: severity
sortBy: severity
maxItems: 50