Files
iAOP/web/admin/admin.js
T

439 lines
18 KiB
JavaScript
Raw 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.
/* 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: "#ff3b30", bg: "rgba(255,59,48,0.12)", border: "#ff3b30" },
P1: { fg: "#f5a623", bg: "rgba(245,166,35,0.12)", border: "#f5a623" },
P2: { fg: "#3aa0ff", bg: "rgba(58,160,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);
}
})();