Files

520 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,
)