mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
test(webui): 内核按钮必须真的出现在**渲染后**的页面里
## 为什么需要这组判据
dashboard.html 与 dashboard.js 都是 `//go:embed` 进二进制的,再由 init
组装成 dashboardHTML(替换 {{DASHBOARD_JS}} 等占位符)。这里有两个
**各自都可能坏掉**的环节:
1. 按钮写进了 dashboard.html,但占位符没被替换 / 替换错位
⇒ 页面里看不到按钮
2. JS 函数写进了 dashboard.js,但被包在 IIFE 里(不在全局)
⇒ onclick="kernelRestart()" 点了报 "kernelRestart is not defined"
只查源码里有没有那个字符串,这两种情况都照不出来。
## 判据(3 条,都查组装后的 dashboardHTML / embed 源)
· TestKernelButtonsRenderedInPage:渲染后的页面含两个按钮的 id 与 onclick
· TestKernelFunctionsAreGlobal:4 个函数必须是**顶层**定义
(浏览器 onclick 走全局查找,jsdom 的行为测试做不到这点)
· TestKernelButtonsInNavFooter:两个按钮必须落在 nav-footer 内
—— 它们作用于整个服务,切到任何标签页都该能点到
实测:渲染页面 408057 字节,六项断言全在。
顺带把 dashboard.js 的格式化差异一并提交(-w 对比为空的纯格式调整)。
This commit is contained in:
@ -535,7 +535,10 @@ var TABS = {
|
|||||||
// 教训:依赖分析必须覆盖**整个调用链**(render → 内部调用的 update*),
|
// 教训:依赖分析必须覆盖**整个调用链**(render → 内部调用的 update*),
|
||||||
// 只看入口函数的直接引用会漏。kernel 拉过一次后不再重拉
|
// 只看入口函数的直接引用会漏。kernel 拉过一次后不再重拉
|
||||||
// (见 starmapFetchBlock 的节流),152KB 只在首屏付一次。
|
// (见 starmapFetchBlock 的节流),152KB 只在首屏付一次。
|
||||||
overview: { fetch: ["status", "runtime", "kernel"], render: ["renderOverview"] },
|
overview: {
|
||||||
|
fetch: ["status", "runtime", "kernel"],
|
||||||
|
render: ["renderOverview"],
|
||||||
|
},
|
||||||
chat: {
|
chat: {
|
||||||
fetch: ["status", "proxyServices", "terminals", "cmdHistory"],
|
fetch: ["status", "proxyServices", "terminals", "cmdHistory"],
|
||||||
render: ["renderChat"],
|
render: ["renderChat"],
|
||||||
@ -2123,7 +2126,7 @@ function buildChatLayout() {
|
|||||||
__("对话", "Chat") +
|
__("对话", "Chat") +
|
||||||
' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none">' +
|
' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none">' +
|
||||||
escHtml(state.chatStage || "") +
|
escHtml(state.chatStage || "") +
|
||||||
'</span>' +
|
"</span>" +
|
||||||
// 用量徐标:本会话累计 token 与缓存命中率。
|
// 用量徐标:本会话累计 token 与缓存命中率。
|
||||||
// 内核一直在推 agent_llm_chain(带 usage/usage_session),
|
// 内核一直在推 agent_llm_chain(带 usage/usage_session),
|
||||||
// 前端此前从未订阅 ⇒ 「发了但没人收到」,页面上看不到成本。
|
// 前端此前从未订阅 ⇒ 「发了但没人收到」,页面上看不到成本。
|
||||||
@ -2930,7 +2933,9 @@ function smAdaptGraph(d) {
|
|||||||
|
|
||||||
// 1) 节点:块 → 星图节点
|
// 1) 节点:块 → 星图节点
|
||||||
var nodes = blocks.map((b) => {
|
var nodes = blocks.map((b) => {
|
||||||
var text = String(b.text || "").replace(/\s+/g, " ").trim();
|
var text = String(b.text || "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
// 媒体块没有正文(只有 digest/描述)——用工具名与模态拼一个可读名,
|
// 媒体块没有正文(只有 digest/描述)——用工具名与模态拼一个可读名,
|
||||||
// 否则它们全叫「」在图上无法区分。
|
// 否则它们全叫「」在图上无法区分。
|
||||||
var name = text;
|
var name = text;
|
||||||
@ -3733,7 +3738,6 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
|||||||
// 本块由 tools/starmap-sync.mjs 从 shared/starmap/layout.js 生成 —— 请改共享源,不要直接改这里。
|
// 本块由 tools/starmap-sync.mjs 从 shared/starmap/layout.js 生成 —— 请改共享源,不要直接改这里。
|
||||||
// ===== END shared:starmap/layout =====
|
// ===== END shared:starmap/layout =====
|
||||||
|
|
||||||
|
|
||||||
// ===== 布局 Worker 调度 =====
|
// ===== 布局 Worker 调度 =====
|
||||||
//
|
//
|
||||||
// starmapLayoutWorkerAvailable 只在浏览器支持且脚本可加载时为真;
|
// starmapLayoutWorkerAvailable 只在浏览器支持且脚本可加载时为真;
|
||||||
@ -3772,7 +3776,10 @@ function starmapRequestLayout(flat, maxR, nodeDegs, onDone) {
|
|||||||
var seq = ++starmapLayoutSeq;
|
var seq = ++starmapLayoutSeq;
|
||||||
// 图指纹做种子:同一张图每次打开得到同一布局(可复现)。
|
// 图指纹做种子:同一张图每次打开得到同一布局(可复现)。
|
||||||
var seed = starmapGraphSeed();
|
var seed = starmapGraphSeed();
|
||||||
var nodes = starmapNodes.map((nd) => ({ id: nd.id, mention_count: nd.mention_count || 0 }));
|
var nodes = starmapNodes.map((nd) => ({
|
||||||
|
id: nd.id,
|
||||||
|
mention_count: nd.mention_count || 0,
|
||||||
|
}));
|
||||||
var idx = {};
|
var idx = {};
|
||||||
for (var i = 0; i < starmapNodes.length; i++) idx[starmapNodes[i].id] = i;
|
for (var i = 0; i < starmapNodes.length; i++) idx[starmapNodes[i].id] = i;
|
||||||
var edges = [];
|
var edges = [];
|
||||||
@ -3878,12 +3885,14 @@ function renderChatUsage(u, single, source) {
|
|||||||
(hasRate
|
(hasRate
|
||||||
? __("缓存 ", "cache ") + Math.round(u.cache_hit_rate * 100) + "%"
|
? __("缓存 ", "cache ") + Math.round(u.cache_hit_rate * 100) + "%"
|
||||||
: __("缓存 —", "cache —"));
|
: __("缓存 —", "cache —"));
|
||||||
var tip = [source || "",
|
var tip = [
|
||||||
|
source || "",
|
||||||
__("调用 ", "calls ") + (u.calls || 0),
|
__("调用 ", "calls ") + (u.calls || 0),
|
||||||
"prompt " + (u.prompt || 0),
|
"prompt " + (u.prompt || 0),
|
||||||
"completion " + (u.completion || 0),
|
"completion " + (u.completion || 0),
|
||||||
"cache_read " + (u.cache_read || 0),
|
"cache_read " + (u.cache_read || 0),
|
||||||
"cache_miss " + (u.cache_miss || 0)];
|
"cache_miss " + (u.cache_miss || 0),
|
||||||
|
];
|
||||||
tip.push(
|
tip.push(
|
||||||
hasRate
|
hasRate
|
||||||
? __("命中率 ", "hit rate ") + (u.cache_hit_rate * 100).toFixed(1) + "%"
|
? __("命中率 ", "hit rate ") + (u.cache_hit_rate * 100).toFixed(1) + "%"
|
||||||
@ -3893,8 +3902,13 @@ function renderChatUsage(u, single, source) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (single && single.total) {
|
if (single && single.total) {
|
||||||
tip.push(__("本次 ", "last call ") + "prompt " + (single.prompt || 0) +
|
tip.push(
|
||||||
" / completion " + (single.completion || 0));
|
__("本次 ", "last call ") +
|
||||||
|
"prompt " +
|
||||||
|
(single.prompt || 0) +
|
||||||
|
" / completion " +
|
||||||
|
(single.completion || 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
el.title = tip.filter(Boolean).join("\n");
|
el.title = tip.filter(Boolean).join("\n");
|
||||||
el.style.display = "";
|
el.style.display = "";
|
||||||
@ -5448,7 +5462,10 @@ function connectSSE() {
|
|||||||
// toast(m, isError):第二参是**布尔**,传字符串会被当 falsy。
|
// toast(m, isError):第二参是**布尔**,传字符串会被当 falsy。
|
||||||
// 这里用 true:插件崩溃是需要用户知道的问题,不是普通提示。
|
// 这里用 true:插件崩溃是需要用户知道的问题,不是普通提示。
|
||||||
toast(
|
toast(
|
||||||
__("插件 " + nm + " 异常退出,内核正在重启它", "Plugin " + nm + " crashed; kernel is restarting it"),
|
__(
|
||||||
|
"插件 " + nm + " 异常退出,内核正在重启它",
|
||||||
|
"Plugin " + nm + " crashed; kernel is restarting it",
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@ -6479,14 +6496,14 @@ function starmapStartMerge(fromID, toID) {
|
|||||||
starmapStartDissolve([fromID], 0);
|
starmapStartDissolve([fromID], 0);
|
||||||
// ② 目标块在停顿之后重生(不是立即)
|
// ② 目标块在停顿之后重生(不是立即)
|
||||||
var dst = idx[toID];
|
var dst = idx[toID];
|
||||||
if (dst) {
|
if (dst) {
|
||||||
var delayed = Date.now() + SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY;
|
var delayed = Date.now() + SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY;
|
||||||
var timer = setTimeout(() => {
|
var timer = setTimeout(() => {
|
||||||
if (dst && dst.parent) {
|
if (dst && dst.parent) {
|
||||||
dst.userData.growAt = Date.now();
|
dst.userData.growAt = Date.now();
|
||||||
starmapMorphAdd(dst); // 重生也要注册
|
starmapMorphAdd(dst); // 重生也要注册
|
||||||
}
|
}
|
||||||
}, SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY);
|
}, SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY);
|
||||||
if (state.starmapMergeTimers) state.starmapMergeTimers.push(timer);
|
if (state.starmapMergeTimers) state.starmapMergeTimers.push(timer);
|
||||||
else state.starmapMergeTimers = [timer];
|
else state.starmapMergeTimers = [timer];
|
||||||
// 记录重生时刻,供 tick 区分“合入重生”与普通生长(前者更短)
|
// 记录重生时刻,供 tick 区分“合入重生”与普通生长(前者更短)
|
||||||
@ -6518,7 +6535,9 @@ function starmapSpawnParticles(mesh) {
|
|||||||
),
|
),
|
||||||
born: Date.now(),
|
born: Date.now(),
|
||||||
life: 700 + Math.random() * 500,
|
life: 700 + Math.random() * 500,
|
||||||
color: mesh.material.color ? mesh.material.color.clone() : new THREE.Color(0x8b9dc4),
|
color: mesh.material.color
|
||||||
|
? mesh.material.color.clone()
|
||||||
|
: new THREE.Color(0x8b9dc4),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
starmapEnsureParticleSystem();
|
starmapEnsureParticleSystem();
|
||||||
@ -6528,8 +6547,14 @@ function starmapEnsureParticleSystem() {
|
|||||||
if (starmapParticles) return;
|
if (starmapParticles) return;
|
||||||
var geo = new THREE.BufferGeometry();
|
var geo = new THREE.BufferGeometry();
|
||||||
var cap = SM_PARTICLE_MAX;
|
var cap = SM_PARTICLE_MAX;
|
||||||
geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(cap * 3), 3));
|
geo.setAttribute(
|
||||||
geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(cap * 3), 3));
|
"position",
|
||||||
|
new THREE.BufferAttribute(new Float32Array(cap * 3), 3),
|
||||||
|
);
|
||||||
|
geo.setAttribute(
|
||||||
|
"color",
|
||||||
|
new THREE.BufferAttribute(new Float32Array(cap * 3), 3),
|
||||||
|
);
|
||||||
var mat = new THREE.PointsMaterial({
|
var mat = new THREE.PointsMaterial({
|
||||||
size: 0.6,
|
size: 0.6,
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
@ -6572,7 +6597,8 @@ function starmapTickParticles(now) {
|
|||||||
colAttr.setXYZ(j, q.color.r * f, q.color.g * f, q.color.b * f);
|
colAttr.setXYZ(j, q.color.r * f, q.color.g * f, q.color.b * f);
|
||||||
}
|
}
|
||||||
// 未用到的槽位置到远处,避免上一帧的残留粒子“卡”在原地。
|
// 未用到的槽位置到远处,避免上一帧的残留粒子“卡”在原地。
|
||||||
for (var k = live.length; k < SM_PARTICLE_MAX; k++) posAttr.setXYZ(k, 1e6, 1e6, 1e6);
|
for (var k = live.length; k < SM_PARTICLE_MAX; k++)
|
||||||
|
posAttr.setXYZ(k, 1e6, 1e6, 1e6);
|
||||||
posAttr.needsUpdate = true;
|
posAttr.needsUpdate = true;
|
||||||
colAttr.needsUpdate = true;
|
colAttr.needsUpdate = true;
|
||||||
starmapParticles.geometry.setDrawRange(0, live.length);
|
starmapParticles.geometry.setDrawRange(0, live.length);
|
||||||
@ -6746,10 +6772,13 @@ function starmapPickPulseTargets(hint) {
|
|||||||
// 排序结果按 baseVer 缓存复用(图不变则排序不变)。
|
// 排序结果按 baseVer 缓存复用(图不变则排序不变)。
|
||||||
var sorted = starmapMeshByWeight;
|
var sorted = starmapMeshByWeight;
|
||||||
if (sorted === null || starmapMeshByWeightVer !== starmapLabelBaseVer) {
|
if (sorted === null || starmapMeshByWeightVer !== starmapLabelBaseVer) {
|
||||||
sorted = starmapNodeMeshes.slice().sort((a, b) => (
|
sorted = starmapNodeMeshes
|
||||||
((b.userData.nodeData || {}).mention_count || 0) -
|
.slice()
|
||||||
((a.userData.nodeData || {}).mention_count || 0)
|
.sort(
|
||||||
));
|
(a, b) =>
|
||||||
|
((b.userData.nodeData || {}).mention_count || 0) -
|
||||||
|
((a.userData.nodeData || {}).mention_count || 0),
|
||||||
|
);
|
||||||
starmapMeshByWeight = sorted;
|
starmapMeshByWeight = sorted;
|
||||||
starmapMeshByWeightVer = starmapLabelBaseVer;
|
starmapMeshByWeightVer = starmapLabelBaseVer;
|
||||||
}
|
}
|
||||||
@ -6901,7 +6930,10 @@ function starmapTickMorph(now) {
|
|||||||
|
|
||||||
// —— 生长(插入 / 合入后重生)——
|
// —— 生长(插入 / 合入后重生)——
|
||||||
if (ud.growAt) {
|
if (ud.growAt) {
|
||||||
var dur = ud.mergeGrowAt && ud.mergeGrowAt === ud.growAt ? SM_MERGE_GROW_MS : SM_GROW_MS;
|
var dur =
|
||||||
|
ud.mergeGrowAt && ud.mergeGrowAt === ud.growAt
|
||||||
|
? SM_MERGE_GROW_MS
|
||||||
|
: SM_GROW_MS;
|
||||||
var gp = (now - ud.growAt) / dur;
|
var gp = (now - ud.growAt) / dur;
|
||||||
if (gp >= 1) {
|
if (gp >= 1) {
|
||||||
ud.growAt = null;
|
ud.growAt = null;
|
||||||
@ -6919,7 +6951,8 @@ function starmapTickMorph(now) {
|
|||||||
// 视觉上像是“凭空出现”而非“长出来”。
|
// 视觉上像是“凭空出现”而非“长出来”。
|
||||||
var scale = 0.05 + 0.95 * Math.max(0, eased);
|
var scale = 0.05 + 0.95 * Math.max(0, eased);
|
||||||
m.scale.setScalar(ud.baseScale * scale);
|
m.scale.setScalar(ud.baseScale * scale);
|
||||||
if (m.material) m.material.emissiveIntensity = ud.baseEmissive + 0.9 * (1 - gp);
|
if (m.material)
|
||||||
|
m.material.emissiveIntensity = ud.baseEmissive + 0.9 * (1 - gp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 已完成的从活跃集移除(本次遍历后,不做边遍历边删)。
|
// 已完成的从活跃集移除(本次遍历后,不做边遍历边删)。
|
||||||
@ -7248,7 +7281,7 @@ function smLegend() {
|
|||||||
var label = lbl ? __(lbl[0], lbl[1]) : k;
|
var label = lbl ? __(lbl[0], lbl[1]) : k;
|
||||||
return (
|
return (
|
||||||
escHtml(label) +
|
escHtml(label) +
|
||||||
" <span style=\"color:var(--text-muted)\">" +
|
' <span style="color:var(--text-muted)">' +
|
||||||
Math.round((smPresentTypes[k] * 100) / total) +
|
Math.round((smPresentTypes[k] * 100) / total) +
|
||||||
"%</span>"
|
"%</span>"
|
||||||
);
|
);
|
||||||
@ -7344,34 +7377,34 @@ function starmapShowDetail(mesh) {
|
|||||||
|
|
||||||
var rows = [];
|
var rows = [];
|
||||||
rows.push(
|
rows.push(
|
||||||
'<div class="sm-detail-title">' +
|
'<div class="sm-detail-title">' + escHtml(String(nd.name || id)) + "</div>",
|
||||||
escHtml(String(nd.name || id)) +
|
|
||||||
"</div>",
|
|
||||||
);
|
);
|
||||||
rows.push(
|
rows.push(
|
||||||
'<div class="sm-detail-meta">' +
|
'<div class="sm-detail-meta">' +
|
||||||
escHtml(String(nd.type || "block")) +
|
escHtml(String(nd.type || "block")) +
|
||||||
" · " +
|
" · " +
|
||||||
escHtml(String(nd.source || "-")) +
|
escHtml(String(nd.source || "-")) +
|
||||||
(nd.scene ? " · " + escHtml(String(nd.scene)) : "") +
|
(nd.scene ? " · " + escHtml(String(nd.scene)) : "") +
|
||||||
"</div>",
|
"</div>",
|
||||||
);
|
);
|
||||||
if (nb.length) {
|
if (nb.length) {
|
||||||
rows.push('<div class="sm-detail-rel">');
|
rows.push('<div class="sm-detail-rel">');
|
||||||
for (var j = 0; j < nb.length; j++) {
|
for (var j = 0; j < nb.length; j++) {
|
||||||
rows.push(
|
rows.push(
|
||||||
'<div class="sm-detail-relrow"><span class="sm-detail-arrow">' +
|
'<div class="sm-detail-relrow"><span class="sm-detail-arrow">' +
|
||||||
nb[j].dir +
|
nb[j].dir +
|
||||||
"</span>" +
|
"</span>" +
|
||||||
escHtml(String(nb[j].rel || "关联")) +
|
escHtml(String(nb[j].rel || "关联")) +
|
||||||
' <span class="sm-detail-nb">' +
|
' <span class="sm-detail-nb">' +
|
||||||
escHtml(String(nb[j].name || "").slice(0, 20)) +
|
escHtml(String(nb[j].name || "").slice(0, 20)) +
|
||||||
"</span></div>",
|
"</span></div>",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
rows.push("</div>");
|
rows.push("</div>");
|
||||||
} else {
|
} else {
|
||||||
rows.push('<div class="sm-detail-rel">' + __("无关联", "no relations") + "</div>");
|
rows.push(
|
||||||
|
'<div class="sm-detail-rel">' + __("无关联", "no relations") + "</div>",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
rows.push(
|
rows.push(
|
||||||
'<div class="sm-detail-id">' + escHtml(String(id).slice(0, 28)) + "</div>",
|
'<div class="sm-detail-id">' + escHtml(String(id).slice(0, 28)) + "</div>",
|
||||||
@ -7498,7 +7531,9 @@ function starmapMakeLabelPlan() {
|
|||||||
var nm = mesh.userData._labelText;
|
var nm = mesh.userData._labelText;
|
||||||
if (nm === undefined) {
|
if (nm === undefined) {
|
||||||
var nd = mesh.userData.nodeData || {};
|
var nd = mesh.userData.nodeData || {};
|
||||||
nm = String(nd.name || nd.id || "").replace(/\s+/g, " ").trim();
|
nm = String(nd.name || nd.id || "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
// 名字过长时截断:星图是“索引”,长句应该点开看而不是在图上读完。
|
// 名字过长时截断:星图是“索引”,长句应该点开看而不是在图上读完。
|
||||||
if (nm.length > 18) nm = nm.slice(0, 17) + "…";
|
if (nm.length > 18) nm = nm.slice(0, 17) + "…";
|
||||||
mesh.userData._labelText = nm;
|
mesh.userData._labelText = nm;
|
||||||
@ -7558,7 +7593,12 @@ var starmapMeshByWeight = null,
|
|||||||
function starmapDrawLabels() {
|
function starmapDrawLabels() {
|
||||||
if (!starmapRen || !starmapCam || !starmapNodeMeshes.length) {
|
if (!starmapRen || !starmapCam || !starmapNodeMeshes.length) {
|
||||||
if (starmapLabelCtx && starmapLabelCanvas) {
|
if (starmapLabelCtx && starmapLabelCanvas) {
|
||||||
starmapLabelCtx.clearRect(0, 0, starmapLabelCanvas.width, starmapLabelCanvas.height);
|
starmapLabelCtx.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
starmapLabelCanvas.width,
|
||||||
|
starmapLabelCanvas.height,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -7591,11 +7631,15 @@ function starmapDrawLabels() {
|
|||||||
cw +
|
cw +
|
||||||
"x" +
|
"x" +
|
||||||
ch;
|
ch;
|
||||||
// ★ 节流:相机拖动(OrbitControls 阻尼)会让 camKey 每帧都变,
|
// ★ 节流:相机拖动(OrbitControls 阻尼)会让 camKey 每帧都变,
|
||||||
// 若每帧重建 plan(3190 项)+ measureText,拖动时就是持续掉帧。
|
// 若每帧重建 plan(3190 项)+ measureText,拖动时就是持续掉帧。
|
||||||
// 标签是文字层,10fps 的刷新肉眼已看不出滞后(图形仍是 60fps)。
|
// 标签是文字层,10fps 的刷新肉眼已看不出滞后(图形仍是 60fps)。
|
||||||
var nowMs = Date.now();
|
var nowMs = Date.now();
|
||||||
if (nowMs - starmapLabelLastDraw < SM_LABEL_MIN_INTERVAL || camKey === starmapLabelLastCamKey) return;
|
if (
|
||||||
|
nowMs - starmapLabelLastDraw < SM_LABEL_MIN_INTERVAL ||
|
||||||
|
camKey === starmapLabelLastCamKey
|
||||||
|
)
|
||||||
|
return;
|
||||||
starmapLabelLastCamKey = camKey;
|
starmapLabelLastCamKey = camKey;
|
||||||
starmapLabelLastDraw = nowMs;
|
starmapLabelLastDraw = nowMs;
|
||||||
|
|
||||||
@ -7832,7 +7876,8 @@ function toggleSecretVisibility(inpId) {
|
|||||||
var btn = el.parentElement ? el.parentElement.querySelector("button") : null;
|
var btn = el.parentElement ? el.parentElement.querySelector("button") : null;
|
||||||
if (btn) btn.textContent = showing ? __("显示", "Show") : __("隐藏", "Hide");
|
if (btn) btn.textContent = showing ? __("显示", "Show") : __("隐藏", "Hide");
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") window.toggleSecretVisibility = toggleSecretVisibility;
|
if (typeof window !== "undefined")
|
||||||
|
window.toggleSecretVisibility = toggleSecretVisibility;
|
||||||
|
|
||||||
function renderSettingsSidebar() {
|
function renderSettingsSidebar() {
|
||||||
var el = document.querySelector(".settings-tabs");
|
var el = document.querySelector(".settings-tabs");
|
||||||
@ -8071,7 +8116,7 @@ function renderOneSettings() {
|
|||||||
escHtml(ph) +
|
escHtml(ph) +
|
||||||
'" onchange="markDirty(\'' +
|
'" onchange="markDirty(\'' +
|
||||||
k +
|
k +
|
||||||
"')\" style=\"flex:1\" autocomplete=\"new-password\">" +
|
'\')" style="flex:1" autocomplete="new-password">' +
|
||||||
'<button type="button" class="btn-sm" style="flex:0 0 auto" onclick="toggleSecretVisibility(\'' +
|
'<button type="button" class="btn-sm" style="flex:0 0 auto" onclick="toggleSecretVisibility(\'' +
|
||||||
inpId +
|
inpId +
|
||||||
"')\">" +
|
"')\">" +
|
||||||
@ -8424,7 +8469,10 @@ async function saveSetting(k) {
|
|||||||
// 用**全等**而不是 includes:真 key 里恰好含这两个字符的概率极低,
|
// 用**全等**而不是 includes:真 key 里恰好含这两个字符的概率极低,
|
||||||
// 但反过来说,只要不是"整个值就是掩码",就一律放行 —— 宁可放行也不误杀真值。
|
// 但反过来说,只要不是"整个值就是掩码",就一律放行 —— 宁可放行也不误杀真值。
|
||||||
function isSecretPlaceholderValue(v) {
|
function isSecretPlaceholderValue(v) {
|
||||||
return typeof v === "string" && v.trim() === "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
return (
|
||||||
|
typeof v === "string" &&
|
||||||
|
v.trim() === "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConfigDisabled() {
|
function renderConfigDisabled() {
|
||||||
@ -8767,7 +8815,12 @@ setInterval(() => {
|
|||||||
// ★ 关机必须**二次确认且说清后果**:它不像重启那样能自己回来,
|
// ★ 关机必须**二次确认且说清后果**:它不像重启那样能自己回来,
|
||||||
// 误点一次就得 ssh 上去恢复。用原生 confirm(与其它危险操作一致)。
|
// 误点一次就得 ssh 上去恢复。用原生 confirm(与其它危险操作一致)。
|
||||||
async function kernelRestart() {
|
async function kernelRestart() {
|
||||||
if (!confirm("重启内核?\n\n服务会断线约数秒,之后自动恢复。\n正在进行中的对话可能中断。")) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
"重启内核?\n\n服务会断线约数秒,之后自动恢复。\n正在进行中的对话可能中断。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
await doKernelControl("restart", "内核正在重启,页面将在数秒后自动重连…");
|
await doKernelControl("restart", "内核正在重启,页面将在数秒后自动重连…");
|
||||||
// 重启:轮询等它回来
|
// 重启:轮询等它回来
|
||||||
waitForKernelBack();
|
waitForKernelBack();
|
||||||
@ -8781,7 +8834,10 @@ async function kernelShutdown() {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await doKernelControl("shutdown", "内核正在关闭。需要人工执行 systemctl start 才能再启动。");
|
await doKernelControl(
|
||||||
|
"shutdown",
|
||||||
|
"内核正在关闭。需要人工执行 systemctl start 才能再启动。",
|
||||||
|
);
|
||||||
// 关机:不再轮询(它不会自己回来),把页面标记为已停
|
// 关机:不再轮询(它不会自己回来),把页面标记为已停
|
||||||
document.body.innerHTML =
|
document.body.innerHTML =
|
||||||
'<div style="display:flex;height:100vh;align-items:center;justify-content:center;' +
|
'<div style="display:flex;height:100vh;align-items:center;justify-content:center;' +
|
||||||
|
|||||||
12
internal/plugins/webui/embed_read_test.go
Normal file
12
internal/plugins/webui/embed_read_test.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func mustReadEmbedded(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := dashboardFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读 embed %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
83
internal/plugins/webui/kernel_buttons_render_test.go
Normal file
83
internal/plugins/webui/kernel_buttons_render_test.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
// 内核按钮必须**真的出现在渲染后的页面里**(2026-10-17)。
|
||||||
|
//
|
||||||
|
// ## 为什么这条判据必须存在
|
||||||
|
//
|
||||||
|
// dashboard.html 与 dashboard.js 都是 `//go:embed` 进二进制的,然后由
|
||||||
|
// init 组装成 dashboardHTML(把 {{DASHBOARD_JS}} 等占位符替换成实际内容)。
|
||||||
|
// 这里有两个**各自都可能坏掉**的环节:
|
||||||
|
//
|
||||||
|
// 1. 按钮写进了 dashboard.html,但占位符没被替换/替换错位
|
||||||
|
// ⇒ 页面里看不到按钮
|
||||||
|
// 2. JS 函数写进了 dashboard.js,但它被包在 IIFE 里(不在全局)
|
||||||
|
// ⇒ onclick="kernelRestart()" 点了报 "kernelRestart is not defined"
|
||||||
|
//
|
||||||
|
// 只查源码有没有那个字符串,这两种情况都照不出来。
|
||||||
|
// 所以判据查的是**组装后的 dashboardHTML**(就是实际发给浏览器的字节)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ★★★ 判据:渲染后的页面含两个按钮的 id 与 onclick。
|
||||||
|
func TestKernelButtonsRenderedInPage(t *testing.T) {
|
||||||
|
h := dashboardHTML
|
||||||
|
if h == "" {
|
||||||
|
t.Fatal("dashboardHTML 为空 —— init 没跑或 embed 失败")
|
||||||
|
}
|
||||||
|
for _, want := range []struct {
|
||||||
|
what string
|
||||||
|
s string
|
||||||
|
}{
|
||||||
|
{"重启按钮 id", `id="kernel-restart-btn"`},
|
||||||
|
{"关机按钮 id", `id="kernel-shutdown-btn"`},
|
||||||
|
{"重启按钮 onclick", `onclick="kernelRestart()"`},
|
||||||
|
{"关机按钮 onclick", `onclick="kernelShutdown()"`},
|
||||||
|
} {
|
||||||
|
if !strings.Contains(h, want.s) {
|
||||||
|
t.Fatalf("★ 渲染页面缺少%s(%q)—— 用户在界面上看不到它", want.what, want.s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:JS 函数必须在**全局作用域**(onclick 才找得到)。
|
||||||
|
//
|
||||||
|
// 检查方式:函数定义前不能有包裹它的 IIFE 缩进层级 —— 用"行首即 function"
|
||||||
|
// 判定它处于顶层。jsdom 里的行为测试做不到这点(node 不强求),
|
||||||
|
// 而浏览器里 onclick 走的是全局查找。
|
||||||
|
func TestKernelFunctionsAreGlobal(t *testing.T) {
|
||||||
|
src := string(mustReadEmbedded(t, "dashboard.js"))
|
||||||
|
|
||||||
|
for _, fn := range []string{"kernelRestart", "kernelShutdown", "doKernelControl", "waitForKernelBack"} {
|
||||||
|
// 顶层定义:行首(可有 async 前缀),不带缩进
|
||||||
|
if !strings.Contains(src, "\nasync function "+fn+"(") &&
|
||||||
|
!strings.Contains(src, "\nfunction "+fn+"(") {
|
||||||
|
t.Fatalf("★ %s 不是顶层函数定义 —— "+
|
||||||
|
"若它被包在 IIFE/模块里,按钮 onclick 会报 is not defined", fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 判据:两个按钮都必须在 nav-footer 里(不是被误放到某个标签页内)。
|
||||||
|
//
|
||||||
|
// 它们作用于整个服务,任何标签页都该能点到。
|
||||||
|
func TestKernelButtonsInNavFooter(t *testing.T) {
|
||||||
|
h := dashboardHTML
|
||||||
|
navIdx := strings.Index(h, `class="nav-footer"`)
|
||||||
|
if navIdx < 0 {
|
||||||
|
t.Fatal("找不到 nav-footer")
|
||||||
|
}
|
||||||
|
navEnd := strings.Index(h[navIdx:], "</nav>")
|
||||||
|
if navEnd < 0 {
|
||||||
|
t.Fatal("找不到 nav 结束标签")
|
||||||
|
}
|
||||||
|
footer := h[navIdx : navIdx+navEnd]
|
||||||
|
|
||||||
|
for _, id := range []string{"kernel-restart-btn", "kernel-shutdown-btn"} {
|
||||||
|
if !strings.Contains(footer, id) {
|
||||||
|
t.Fatalf("★ %s 不在 nav-footer 内 —— 用户切到别的标签页就找不到它了", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user