Merge PR #130 (feat #62-67 模板配置台全链路:RBAC/点位导入/配置/预览/发布推送 + #55 Ti 布局模板改进)

This commit is contained in:
2026-08-05 08:27:01 +08:00
parent 52d986e6f0
commit 6036e5e151
17 changed files with 3243 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
"""⑤.7 配置预览渲染引擎 —— issue #65 / PRD ⑤.7。
配置台让实施工程师"边配边看":改完布局/告警/查询配置后,立即在预览区看到
驾驶舱会变成什么样、告警会怎么触发、NL 查询会怎么响应——**不必发布到生产
就能确认效果**。本模块是预览区的渲染后端,把 ``ConfigStore`` 里的配置渲染
为**结构化的预览片段**(dict/JSON),对齐 ``iAOP-cockpit-layout-v1`` 的
widget 类型与既有驾驶舱资产(resin cockpit)。
三类预览:
- **布局预览**(``render_layout_preview``):把 layout widget 列表渲染为
带占位网格坐标的 widget 描述(type/bind/metric/description + x/y/w/h),
计算网格占用率(发现越界/重叠);
- **告警预览**(``render_alarm_preview``):把告警规则(point + 阈值 + 级别)
渲染为"当 X 超过 Y 时,触发 级别 告警"的可读条目 + 模拟评估(给定当前值
是否触发);
- **NL 查询预览**(``render_nl_query_preview``):把 NL 查询模板渲染为示例
问答对(模板 × 示例槽位 → 渲染后的问句 + 预期数据来源)。
预览是**只读、无副作用**的——只读配置、产出结构化输出,不改任何状态,
对齐 PRD「预演不污染生产」。
零运行时依赖:仅用 dataclass / Enum / 标准库。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
from .config_store import ALLOWED_WIDGET_TYPES, ConfigKind, ConfigStore
# ---------------------------------------------------------------------------
# 预览模型
# ---------------------------------------------------------------------------
class PreviewKind(str, Enum):
"""三类预览。"""
LAYOUT = "layout"
ALARM = "alarm"
NL_QUERY = "nl_query"
@dataclass
class PreviewResult:
"""一次预览渲染的结果(结构化片段 + 说明 + 问题提示)。"""
kind: PreviewKind
title: str
items: List[Dict[str, Any]] = field(default_factory=list) # 渲染后的条目
notes: List[str] = field(default_factory=list) # 说明 / 渲染提示
warnings: List[str] = field(default_factory=list) # 布局越界/重叠等
reason: str = "" # 本次预览的来源说明
@property
def ok(self) -> bool:
return not self.warnings
def to_dict(self) -> dict:
return {
"kind": self.kind.value,
"title": self.title,
"items": self.items,
"notes": self.notes,
"warnings": self.warnings,
"reason": self.reason,
"ok": self.ok,
}
# ---------------------------------------------------------------------------
# 布局预览
# ---------------------------------------------------------------------------
#: 驾驶舱网格规格(对齐 resin cockpit:12 列 × 若干行,w/h 以网格单元计)
GRID_COLUMNS = 12
def render_layout_preview(
widgets: List[Dict[str, Any]],
title: str = "驾驶舱布局预览",
grid_columns: int = GRID_COLUMNS,
) -> PreviewResult:
"""渲染布局 widget 列表为预览片段。
每个 widget 渲染为带 type/描述/坐标的卡片;同时做**布局体检**:
- 越界(x+w 超出列数 / y+h 超出合理行数);
- 重叠(两个 widget 矩形相交);
- 非法类型(不在 ``ALLOWED_WIDGET_TYPES``)。
"""
result = PreviewResult(kind=PreviewKind.LAYOUT, title=title,
reason=f"渲染 {len(widgets)} 个 widget")
seen_rects: List[Dict[str, int]] = []
for i, w in enumerate(widgets):
wt = w.get("type")
x, y = w.get("x", 0), w.get("y", 0)
ww, hh = w.get("w", 0), w.get("h", 0)
card: Dict[str, Any] = {
"index": i,
"type": wt,
"x": x, "y": y, "w": ww, "h": hh,
"description": w.get("description", ""),
}
# 携带业务绑定(trend.bind / kpi_card.metric)
if wt == "trend":
card["bind"] = w.get("bind", "")
elif wt == "kpi_card":
card["metric"] = w.get("metric", "")
card["label"] = w.get("label", "")
elif wt == "process_view":
card["src"] = w.get("src", "")
result.items.append(card)
# 体检:类型合法
if wt not in ALLOWED_WIDGET_TYPES:
result.warnings.append(f"widget[{i}] 非法类型 '{wt}'")
# 越界
if x < 0 or y < 0 or ww <= 0 or hh <= 0:
result.warnings.append(f"widget[{i}] 坐标/尺寸非法 ({x},{y},{ww},{hh})")
elif x + ww > grid_columns:
result.warnings.append(
f"widget[{i}] 越界:x+w={x + ww} > {grid_columns} 列")
else:
# 重叠检测(矩形相交)
rect = {"x": x, "y": y, "w": ww, "h": hh}
for j, prev in enumerate(seen_rects):
if _rects_overlap(rect, prev):
result.warnings.append(f"widget[{i}] 与 widget[{j}] 重叠")
seen_rects.append(rect)
# 网格占用率
total_area = sum(r["w"] * r["h"] for r in seen_rects)
max_row = max((r["y"] + r["h"] for r in seen_rects), default=0)
grid_area = grid_columns * max(max_row, 1)
usage = round(total_area / grid_area * 100, 1) if grid_area else 0.0
result.notes.append(f"网格占用率 {usage}%({grid_columns} 列,最大 {max_row} 行)")
return result
def _rects_overlap(a: Dict[str, int], b: Dict[str, int]) -> bool:
"""两个网格矩形是否相交(不含边界共享视为不重叠)。"""
ax2, ay2 = a["x"] + a["w"], a["y"] + a["h"]
bx2, by2 = b["x"] + b["w"], b["y"] + b["h"]
return not (ax2 <= b["x"] or bx2 <= a["x"] or ay2 <= b["y"] or by2 <= a["y"])
# ---------------------------------------------------------------------------
# 告警预览
# ---------------------------------------------------------------------------
#: 合法的告警级别(对齐 cockpit alarm_panel)
ALARM_LEVELS = {"info", "warn", "critical"}
@dataclass
class AlarmRule:
"""一条告警规则(供告警预览渲染与模拟评估)。"""
point_id: str # 关联测点
metric: str # 指标名(展示用)
operator: str # 比较运算符 > / >= / < / <= / ==
threshold: float # 阈值
level: str = "warn" # 告警级别 info/warn/critical
message: str = "" # 告警文案模板(可含 {value})
def evaluate(self, value: float) -> bool:
"""给定当前值,判断是否触发告警。"""
ops = {
">": value > self.threshold,
">=": value >= self.threshold,
"<": value < self.threshold,
"<=": value <= self.threshold,
"==": value == self.threshold,
}
return ops.get(self.operator, False)
def render_alarm_preview(
rules: List[AlarmRule],
current_values: Optional[Dict[str, float]] = None,
title: str = "告警规则预览",
) -> PreviewResult:
"""渲染告警规则为可读条目,并用当前值模拟触发评估。
Args:
rules: 告警规则列表;
current_values: 当前测点值(point_id → value),用于模拟评估;
不提供则只渲染规则、不做触发评估。
"""
result = PreviewResult(kind=PreviewKind.ALARM, title=title,
reason=f"渲染 {len(rules)} 条告警规则")
for r in rules:
if r.level not in ALARM_LEVELS:
result.warnings.append(f"告警 '{r.point_id}' 非法级别 '{r.level}'")
if r.operator not in (">", ">=", "<", "<=", "=="):
result.warnings.append(f"告警 '{r.point_id}' 非法运算符 '{r.operator}'")
text = (f"当 {r.metric}({r.point_id}) {r.operator} {r.threshold} 时,"
f"触发 [{r.level}] 告警")
entry: Dict[str, Any] = {
"point_id": r.point_id, "metric": r.metric,
"operator": r.operator, "threshold": r.threshold,
"level": r.level, "text": text,
}
if current_values is not None and r.point_id in current_values:
val = current_values[r.point_id]
triggered = r.evaluate(val)
entry["current_value"] = val
entry["triggered"] = triggered
entry["state"] = "触发" if triggered else "正常"
result.items.append(entry)
if current_values is not None:
triggered_count = sum(1 for e in result.items if e.get("triggered"))
result.notes.append(f"模拟评估:{triggered_count}/{len(rules)} 条触发")
return result
# ---------------------------------------------------------------------------
# NL 查询预览
# ---------------------------------------------------------------------------
@dataclass
class NLQueryTemplate:
"""一条 NL 查询模板(供 NL 查询预览渲染)。"""
name: str # 模板名
question_template: str # 问句模板(含 {slot} 占位)
slots: Dict[str, List[str]] # 槽位 → 候选取值(用于生成示例问句)
data_source: str = "" # 预期数据来源(如 tdengine/rag)
answer_hint: str = "" # 预期答案提示
def render_examples(self, max_per_slot: int = 2) -> List[str]:
"""用槽位候选值生成示例问句(笛卡尔积,限量)。"""
if not self.slots:
return [self.question_template]
examples: List[str] = []
# 取每个槽位前 N 个候选,做限量笛卡尔积
first_slot = next(iter(self.slots))
for val in self.slots[first_slot][:max_per_slot]:
examples.append(self.question_template.replace("{" + first_slot + "}", val))
if not examples:
examples.append(self.question_template)
return examples
def render_nl_query_preview(
templates: List[NLQueryTemplate],
title: str = "NL 查询模板预览",
) -> PreviewResult:
"""渲染 NL 查询模板为示例问答对。"""
result = PreviewResult(kind=PreviewKind.NL_QUERY, title=title,
reason=f"渲染 {len(templates)} 个查询模板")
for t in templates:
examples = t.render_examples()
entry: Dict[str, Any] = {
"name": t.name,
"data_source": t.data_source,
"answer_hint": t.answer_hint,
"examples": examples,
}
result.items.append(entry)
if not t.question_template:
result.warnings.append(f"模板 '{t.name}' 问句模板为空")
result.notes.append(f"共生成 {sum(len(e['examples']) for e in result.items)} 条示例问句")
return result
# ---------------------------------------------------------------------------
# 从 ConfigStore 一键预览
# ---------------------------------------------------------------------------
def preview_from_store(
store: ConfigStore,
kind: PreviewKind = PreviewKind.LAYOUT,
current_values: Optional[Dict[str, float]] = None,
) -> PreviewResult:
"""从 ConfigStore 读取配置并渲染对应预览(配置台预览区入口)。
- LAYOUT:读 ``layout`` 类目下 key 含 'dashboard' 的 widget 列表;
- ALARM:读 ``model_param`` 类目下 key 以 'alarm_' 开头的规则;
- NL_QUERY:读 ``rag_config`` 类目下 key 以 'nl_' 开头的模板。
配置缺失时返回空结果(含提示),不报错——预览是只读的、宽容的。
"""
if kind == PreviewKind.LAYOUT:
widgets: List[Dict[str, Any]] = []
for it in store.list(ConfigKind.LAYOUT):
if isinstance(it.value, list):
widgets.extend(it.value)
if not widgets:
return PreviewResult(kind=kind, title="布局预览(空)",
notes=["未配置布局 widget,请在布局编辑页添加"])
return render_layout_preview(widgets)
if kind == PreviewKind.ALARM:
rules: List[AlarmRule] = []
for it in store.list(ConfigKind.MODEL_PARAM):
if it.key.startswith("alarm_") and isinstance(it.value, dict):
rules.append(AlarmRule(
point_id=it.value.get("point_id", ""),
metric=it.value.get("metric", ""),
operator=it.value.get("operator", ">"),
threshold=float(it.value.get("threshold", 0)),
level=it.value.get("level", "warn"),
message=it.value.get("message", ""),
))
if not rules:
return PreviewResult(kind=kind, title="告警预览(空)",
notes=["未配置告警规则,请在告警编辑页添加"])
return render_alarm_preview(rules, current_values=current_values)
# NL_QUERY
templates: List[NLQueryTemplate] = []
for it in store.list(ConfigKind.RAG_CONFIG):
if it.key.startswith("nl_") and isinstance(it.value, dict):
templates.append(NLQueryTemplate(
name=it.value.get("name", it.key),
question_template=it.value.get("question_template", ""),
slots=it.value.get("slots", {}),
data_source=it.value.get("data_source", ""),
answer_hint=it.value.get("answer_hint", ""),
))
if not templates:
return PreviewResult(kind=kind, title="NL 查询预览(空)",
notes=["未配置 NL 查询模板,请在查询编辑页添加"])
return render_nl_query_preview(templates)