Files
iAOP/web/admin/admin.js
T

390 lines
16 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);
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"), 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"), 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"), status: "acked",
sop: "记录趋势,纳入班报跟踪。" }
];
}
function getAlerts() {
var a = load(ALERTS_KEY, null);
if (!a) { a = seedAlerts(); save(ALERTS_KEY, a); }
return a;
}
function renderAlerts() {
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;
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;
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("btn-audit-export").onclick = function () {
download("audit-export-" + Date.now() + ".json",
JSON.stringify(typeof UserStore !== "undefined" ? UserStore.auditLog() : [], null, 2));
};
// severity 配色资产(与驾驶舱同源;取不到用内置兜底)
fetch("../cockpit/plans/alarm_panel.ti.json").then(function (r) {
return r.ok ? r.json() : null;
}).then(function (p) {
if (p && p.severityStyles) { severityStyles = p.severityStyles; 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);
}
})();