/* iAOP 管理控制台(issue #135 / PRD 8.1/8.2)。 * * 纯静态单页(无构建链),语义对齐后端内核: * - 模型管理:core/model-framework/template_registry.py —— semver 校验、 * dev→staging→prod 逐级 promote、rollback 回退、全程 _log 审计; * - 知识库管理:文档列表来自 web/chat/citations.json(模板 rag-kb 资产编译产物), * 上传在浏览器端索引(Demo;rag-kb HTTP 服务就绪后替换为真实索引管线); * - 告警确认:PRD 8.1 状态机 pending→acked→closed(P0 必须先确认再闭环, * 高利害人工确认),severity 配色取自 cockpit 的 alarm_panel.ti.json; * - 审计查询:统一读取 UserStore 审计日志(#134),支持筛选与导出。 * * 数据持久化在 localStorage(Demo 级);后端 HTTP 服务就绪后替换 Store 读写即可。 */ "use strict"; var INFER_BASE = "http://39.101.182.167:30800"; var MODELS_KEY = "iaop.admin.models.v1"; var ALERTS_KEY = "iaop.admin.alerts.v1"; var KB_KEY = "iaop.admin.kb.v1"; var currentUserName = "anonymous"; var severityStyles = { P0: { fg: "#ff4d4f", bg: "rgba(255,77,79,0.12)", border: "#ff4d4f" }, P1: { fg: "#faad14", bg: "rgba(250,173,20,0.12)", border: "#faad14" }, P2: { fg: "#1677ff", bg: "rgba(22,119,255,0.10)" } }; /* ---------------- 工具 ---------------- */ function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; } function load(key, fallback) { try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (e) { return fallback; } } function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); } function audit(action, resource, reason) { if (typeof UserStore !== "undefined") UserStore.audit(action, resource, reason, currentUserName); } function download(filename, text) { var a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], { type: "application/json" })); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } /* ---------------- 模型管理(template_registry 语义) ---------------- */ var STAGES = ["dev", "staging", "prod"]; var SEMVER_RE = /^\d+\.\d+\.\d+$/; function semverKey(v) { var p = v.split(".").map(Number); return p[0] * 1e6 + p[1] * 1e3 + p[2]; } function seedModels() { return [ { name: "quality_forecast", version: "1.2.0", stage: "prod", author: "bot_dev1", updated_at: "2026-08-01T09:00:00Z" }, { name: "anomaly_detection", version: "1.0.3", stage: "staging", author: "bot_dev1", updated_at: "2026-08-02T09:00:00Z" }, { name: "process_optimizer", version: "0.9.1", stage: "dev", author: "engineer", updated_at: "2026-08-03T09:00:00Z" }, { name: "cross_process_optimizer", version: "0.5.0", stage: "dev", author: "engineer", updated_at: "2026-08-04T09:00:00Z" } ]; } function getModels() { var m = load(MODELS_KEY, null); if (!m) { m = seedModels(); save(MODELS_KEY, m); } return m; } function renderModels() { var tbody = document.getElementById("model-tbody"); tbody.innerHTML = ""; getModels().forEach(function (m, i) { var tr = el("tr"); tr.appendChild(el("td", null, m.name)); tr.appendChild(el("td", null, m.version)); var tdStage = el("td"); tdStage.appendChild(el("span", "stage stage-" + m.stage, m.stage)); tr.appendChild(tdStage); tr.appendChild(el("td", null, m.author)); tr.appendChild(el("td", null, (m.updated_at || "").slice(0, 10))); var ops = el("td"); var idx = STAGES.indexOf(m.stage); if (idx < STAGES.length - 1) { var btn = el("button", null, "提升到 " + STAGES[idx + 1]); btn.onclick = function () { promoteModel(i); }; ops.appendChild(btn); } else { ops.appendChild(el("span", "hint", "已达 prod")); } if (idx > 0) { var rb = el("button", null, "回滚到 " + STAGES[idx - 1]); rb.style.marginLeft = "6px"; rb.onclick = function () { rollbackModel(i); }; ops.appendChild(rb); } // issue #153:该模型的审计历史(promote/rollback/register 可追溯) var hist = el("button", null, "历史"); hist.style.marginLeft = "6px"; hist.onclick = function () { renderModelHistory(m.name); }; ops.appendChild(hist); tr.appendChild(ops); tbody.appendChild(tr); }); } function registerModel() { var name = document.getElementById("model-name").value.trim(); var version = document.getElementById("model-version").value.trim(); if (!name || !SEMVER_RE.test(version)) { alert("模型名必填,版本须为 semver(如 1.0.0)"); return; } var models = getModels(); var existing = models.filter(function (m) { return m.name === name; }); if (existing.length) { var latest = existing.reduce(function (a, b) { return semverKey(a.version) > semverKey(b.version) ? a : b; }); if (semverKey(version) <= semverKey(latest.version)) { alert("版本必须大于当前最新 " + latest.version); return; } } models.push({ name: name, version: version, stage: "dev", author: currentUserName, updated_at: new Date().toISOString() }); save(MODELS_KEY, models); audit("create", "model", name + "@" + version + " 注册(dev)"); renderModels(); } function promoteModel(i) { var models = getModels(); var m = models[i]; var next = STAGES[STAGES.indexOf(m.stage) + 1]; if (!next) return; m.stage = next; m.updated_at = new Date().toISOString(); save(MODELS_KEY, models); audit("promote", "model", m.name + "@" + m.version + " → " + next); renderModels(); } function rollbackModel(i) { var models = getModels(); var m = models[i]; var prev = STAGES[STAGES.indexOf(m.stage) - 1]; if (!prev) return; m.stage = prev; m.updated_at = new Date().toISOString(); save(MODELS_KEY, models); audit("rollback", "model", m.name + "@" + m.version + " → " + prev); renderModels(); } /* issue #153:按模型名过滤审计日志,展示版本/阶段变迁历史 */ function renderModelHistory(name) { var box = document.getElementById("model-history"); box.hidden = false; box.innerHTML = ""; box.appendChild(el("div", null, "模型「" + name + "」审计历史:")); var log = (typeof UserStore !== "undefined") ? UserStore.auditLog() : []; var hits = log.filter(function (e) { return e.resource === "model" && e.reason.indexOf(name) >= 0; }); if (!hits.length) { box.appendChild(el("div", "hint", "(暂无该模型的审计记录)")); return; } hits.slice().reverse().forEach(function (e) { box.appendChild(el("div", null, e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " + e.action + " | " + e.reason)); }); } /* ---------------- 知识库管理 ---------------- */ function getKbDocs() { return load(KB_KEY, []); } function renderKb() { var tbody = document.getElementById("kb-tbody"); tbody.innerHTML = ""; getKbDocs().forEach(function (d, i) { var tr = el("tr"); tr.appendChild(el("td", null, d.title)); tr.appendChild(el("td", null, d.source)); tr.appendChild(el("td", null, String(d.sections.length))); tr.appendChild(el("td", "idx-ok", d.status || "已索引")); var ops = el("td"); var view = el("button", null, "查看引用"); view.onclick = function () { var box = document.getElementById("kb-detail"); box.hidden = false; box.innerHTML = ""; box.appendChild(el("div", null, "《" + d.title + "》 " + d.source)); d.sections.forEach(function (s) { box.appendChild(el("div", null, "§ " + s.heading + " — " + s.snippet)); }); }; ops.appendChild(view); // issue #154:仅本地上传的文档可移除(模板资产只读,引用溯源不可破坏) if ((d.source || "").indexOf("upload/") === 0) { var del = el("button", null, "移除"); del.style.marginLeft = "6px"; del.onclick = function () { if (!confirm("移除文档「" + d.title + "」的本地索引?")) return; save(KB_KEY, getKbDocs().filter(function (x) { return x.source !== d.source; })); audit("delete", "kb_doc", d.source + " 移除本地索引"); renderKb(); }; ops.appendChild(del); } tr.appendChild(ops); tbody.appendChild(tr); }); } function loadKbFromCitations() { // 首选:web/chat/citations.json(模板知识库编译产物,含真实来源路径) fetch("../chat/citations.json").then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (d && d.documents && !getKbDocs().length) { save(KB_KEY, d.documents.map(function (doc) { return { title: doc.title, source: doc.source, sections: doc.sections, status: "已索引" }; })); } renderKb(); }).catch(function () { renderKb(); }); } function uploadKb() { var f = document.getElementById("kb-file").files[0]; if (!f) { alert("请选择 .md/.txt 文档"); return; } var reader = new FileReader(); reader.onload = function () { var text = String(reader.result || ""); var titleM = text.match(/^#\s+(.+)$/m); var docs = getKbDocs(); docs.push({ title: titleM ? titleM[1].trim() : f.name.replace(/\.\w+$/, ""), source: "upload/" + f.name, sections: [{ heading: "全文", snippet: text.replace(/\s+/g, " ").slice(0, 160) }], status: "已索引(本地)" }); save(KB_KEY, docs); audit("create", "kb_doc", f.name + " 上传并索引"); document.getElementById("kb-hint").textContent = f.name + " 已索引"; renderKb(); }; reader.readAsText(f, "utf-8"); } /* ---------------- 告警确认(PRD 8.1 状态机) ---------------- */ var ALERT_STATUS = { pending: "待处理", acked: "已确认", closed: "已闭环" }; function seedAlerts() { var now = Date.now(); return [ { id: "ALM-2001", severity: "P0", point: "CLF-01.TEMP", message: "氯化炉温度骤升越上限(920℃ > 900℃)", time: new Date(now - 12 * 60000).toLocaleString("zh-CN"), ts: now - 12 * 60000, status: "pending", sop: "1) 现场确认热电偶;2) 降氯气进料 10%;3) 通知值班长;4) 必要时切旁路。" }, { id: "ALM-2002", severity: "P1", point: "RF-01.VAC", message: "还原炉真空度波动超阈值", time: new Date(now - 45 * 60000).toLocaleString("zh-CN"), ts: now - 45 * 60000, status: "pending", sop: "1) 检查真空泵组;2) 核查泄漏点;3) 加强监控 30 分钟。" }, { id: "ALM-2003", severity: "P2", point: "ST-01.STEAM", message: "蒸汽流量低于经验区间", time: new Date(now - 90 * 60000).toLocaleString("zh-CN"), ts: now - 90 * 60000, status: "acked", sop: "记录趋势,纳入班报跟踪。" } ]; } function getAlerts() { var a = load(ALERTS_KEY, null); if (!a) { a = seedAlerts(); save(ALERTS_KEY, a); } return a; } /* issue #155:确认超时自动升级(alarm_panel.ti.yaml ackTimeoutS=300, * 对齐 alarm_config.py 的 ack_timeout_s 语义:待处理超过阈值 severity 升一级, * 每次渲染前检查一次,升级落审计)。 */ var SEV_UP = { P2: "P1", P1: "P0" }; var ackTimeoutS = 300; // alarm plan 加载后被 severityColors 同源配置覆盖 function escalateOverdueAlerts() { var alerts = getAlerts(); var changed = false; alerts.forEach(function (a) { if (a.status !== "pending" || !a.ts || !SEV_UP[a.severity]) return; if (Date.now() - a.ts > ackTimeoutS * 1000 && !a.escalated) { var from = a.severity; a.severity = SEV_UP[a.severity]; a.escalated = true; changed = true; audit("update", "alert", a.id + " 超时未确认(>" + ackTimeoutS + "s),severity " + from + " → " + a.severity); } }); if (changed) save(ALERTS_KEY, alerts); } function renderAlerts() { escalateOverdueAlerts(); var filter = document.getElementById("alert-filter").value; var box = document.getElementById("alert-list"); box.innerHTML = ""; var rank = { P0: 3, P1: 2, P2: 1 }; getAlerts() .filter(function (a) { return !filter || a.status === filter; }) .sort(function (a, b) { return rank[b.severity] - rank[a.severity]; }) .forEach(function (a) { var st = severityStyles[a.severity] || {}; var item = el("div", "alert-item"); item.style.color = st.fg || "inherit"; item.style.background = st.bg || "transparent"; if (st.border) item.style.borderLeftColor = st.border; item.appendChild(el("span", "sev", a.severity)); item.appendChild(el("span", null, a.id + " · " + a.message)); item.appendChild(el("span", "st", ALERT_STATUS[a.status])); item.onclick = function () { renderAlertDetail(a.id); }; box.appendChild(item); }); } function renderAlertDetail(id) { var a = getAlerts().find(function (x) { return x.id === id; }); var box = document.getElementById("alert-detail"); if (!a) return; box.innerHTML = ""; box.appendChild(el("div", null, a.id + " · " + a.severity + " · " + a.point)); box.appendChild(el("div", "hint", a.message + " · " + a.time + " · 状态:" + ALERT_STATUS[a.status])); var llm = el("div", "llm", "点击「生成 LLM 解释」获取可能原因与处置建议(PRD 场景A)"); box.appendChild(llm); var btnLlm = el("button", "primary", "生成 LLM 解释"); btnLlm.onclick = function () { btnLlm.disabled = true; llm.textContent = "生成中…"; fetch(INFER_BASE + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "iaop-ti-cl4-v1", max_tokens: 512, messages: [ { role: "system", content: "你是工业报警解释助手,输出【可能原因】与【处置建议】。" }, { role: "user", content: "告警 " + a.severity + " 点位 " + a.point + ":" + a.message } ] }) }).then(function (r) { return r.json(); }) .then(function (d) { llm.textContent = (d.choices && d.choices[0] && d.choices[0].message.content) || "(空响应)"; }) .catch(function (e) { llm.textContent = "解释生成失败:" + e.message; }) .finally(function () { btnLlm.disabled = false; }); }; box.appendChild(btnLlm); box.appendChild(el("div", "sop", "SOP:" + a.sop)); // 状态流转按钮(P0 高利害:必须先确认再闭环,PRD 8.1) var row = el("div", "row"); if (a.status === "pending") { var ack = el("button", "primary", "确认告警"); ack.onclick = function () { transitionAlert(a.id, "acked"); }; row.appendChild(ack); if (a.severity === "P0") row.appendChild(el("span", "hint", "P0 高利害:确认后仍需人工复核闭环")); } if (a.status === "acked") { var close = el("button", "primary", "闭环"); close.onclick = function () { transitionAlert(a.id, "closed"); }; row.appendChild(close); } box.appendChild(row); } function transitionAlert(id, to) { var alerts = getAlerts(); var a = alerts.find(function (x) { return x.id === id; }); if (!a) return; a.status = to; save(ALERTS_KEY, alerts); audit(to === "acked" ? "ack" : "close", "alert", a.id + " " + a.severity + " → " + ALERT_STATUS[to]); renderAlerts(); renderAlertDetail(id); } /* ---------------- 审计查询 ---------------- */ function renderAudit() { var q = document.getElementById("audit-q").value.trim(); var action = document.getElementById("audit-action").value; // issue #156:时间范围筛选(起止日期,含当日全天) var from = document.getElementById("audit-from").value; var to = document.getElementById("audit-to").value; var tbody = document.getElementById("audit-tbody"); tbody.innerHTML = ""; var log = (typeof UserStore !== "undefined") ? UserStore.auditLog() : []; log.slice().reverse() .filter(function (e) { if (action && e.action !== action) return false; if (q && (e.actor + e.resource + e.reason).indexOf(q) < 0) return false; var day = (e.time || "").slice(0, 10); if (from && day < from) return false; if (to && day > to) return false; return true; }) .forEach(function (e) { var tr = el("tr"); tr.appendChild(el("td", null, e.time.slice(0, 19).replace("T", " "))); tr.appendChild(el("td", null, e.actor)); tr.appendChild(el("td", null, e.action)); tr.appendChild(el("td", null, e.resource)); tr.appendChild(el("td", null, e.reason)); tbody.appendChild(tr); }); } /* ---------------- 启动 ---------------- */ (function init() { function boot(user) { if (user) { currentUserName = user.username; document.getElementById("who").textContent = user.username + " · " + user.role; } document.querySelectorAll(".tab").forEach(function (tab) { tab.onclick = function () { document.querySelectorAll(".tab").forEach(function (t) { t.classList.remove("active"); }); document.querySelectorAll(".page").forEach(function (p) { p.classList.remove("active"); }); tab.classList.add("active"); document.getElementById("page-" + tab.dataset.page).classList.add("active"); }; }); document.getElementById("btn-register").onclick = registerModel; document.getElementById("btn-kb-upload").onclick = uploadKb; document.getElementById("alert-filter").onchange = renderAlerts; document.getElementById("btn-audit-filter").onclick = renderAudit; document.getElementById("audit-from").onchange = renderAudit; document.getElementById("audit-to").onchange = renderAudit; document.getElementById("btn-audit-export").onclick = function () { download("audit-export-" + Date.now() + ".json", JSON.stringify(typeof UserStore !== "undefined" ? UserStore.auditLog() : [], null, 2)); }; // severity 配色 + ackTimeoutS 资产(与驾驶舱同源;取不到用内置兜底) fetch("../cockpit/plans/alarm_panel.ti.json").then(function (r) { return r.ok ? r.json() : null; }).then(function (p) { if (p) { if (p.severityStyles) severityStyles = p.severityStyles; if (p.ackTimeoutS) ackTimeoutS = p.ackTimeoutS; // #155 超时升级阈值同源 renderAlerts(); } }).catch(function () {}); renderModels(); loadKbFromCitations(); renderAlerts(); renderAudit(); } // 认证守卫(#150 IAOP_AUTH,在则强制登录;纯静态独立运行时降级免登录演示) if (typeof IAOP_AUTH !== "undefined") { IAOP_AUTH.requireLoginElseRedirect("../auth/login.html").then(function (user) { if (user) boot(user); }); } else { boot(null); } })();