@noob-stupid/dsh-plugin-console 0.5.95 → 0.5.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +397 -0
- package/lib/server/domain/patch-yaml-check.js +10 -0
- package/lib/server/routes/compat.js +14 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -387,6 +387,51 @@
|
|
|
387
387
|
gateRowFromUpgrade: "来源:框架升级预扫",
|
|
388
388
|
gateRowAt: " · {t}",
|
|
389
389
|
gateRowAdoptable: " · 已通过源码扫描,可解锁",
|
|
390
|
+
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」在界面上的入口 —— 门控面板里的一个分区。
|
|
391
|
+
// 用户原话「现在这能力藏得太深,只有命令行能用」;这里只做展示 + 两段式上膛,
|
|
392
|
+
// 判据与动作白名单全在服务端 POST /plugin-console/safe-boot。
|
|
393
|
+
safeBootSectionTitle: "安全启动",
|
|
394
|
+
safeBootToggleTitle: "展开/收起安全启动面板(只有展开那一下才读一次快照列表,收起时零请求)",
|
|
395
|
+
safeBootSectionHint: "服务还能应答时的出路:回到上次好的补丁状态、或只禁可疑行。服务起不来时用下面的命令行兜底。",
|
|
396
|
+
safeBootLoading: "正在读取快照列表…",
|
|
397
|
+
safeBootReload: "重新读取",
|
|
398
|
+
safeBootListFailed: "读取快照失败:",
|
|
399
|
+
safeBootEmpty: "还没有快照:",
|
|
400
|
+
safeBootNotWritten: "本次没有写新快照:",
|
|
401
|
+
safeBootReasonPatchInvalid: "补丁结构校验没过,快照闸门没放行",
|
|
402
|
+
safeBootReasonNoPatch: "没有补丁文件,没有可写的材料",
|
|
403
|
+
safeBootReasonEnvChanged: "环境指纹刚变过,还没确认服务正常",
|
|
404
|
+
safeBootReasonUnchanged: "内容与最新快照逐字节相同,无需再写一份",
|
|
405
|
+
safeBootReasonNotYet: "还没到该写的时机(快照只在服务确认正常、且环境指纹未变时才写)",
|
|
406
|
+
safeBootCount: "快照 {n} 份",
|
|
407
|
+
safeBootLastGoodId: "最后标记为良好:",
|
|
408
|
+
safeBootLastGood: "良好(last-good)",
|
|
409
|
+
safeBootRowAt: "时间",
|
|
410
|
+
safeBootRowSha: "sha256",
|
|
411
|
+
safeBootRestore: "恢复上次好的状态",
|
|
412
|
+
safeBootRestoreTitle: "两段式:第一下只是上膛并写明要恢复到哪份快照,确认后才真发请求",
|
|
413
|
+
safeBootDisable: "只禁用可疑行",
|
|
414
|
+
safeBootDisableTitle: "两段式:只给自动预检点名的行追加 disabled: true,不做整树回滚",
|
|
415
|
+
safeBootMarkGood: "标记当前为好的状态",
|
|
416
|
+
safeBootMarkGoodTitle: "把最新这份快照标记为良好(永不随保留策略删除);成功后自动刷新列表",
|
|
417
|
+
safeBootArmRestore: "要恢复到的快照:",
|
|
418
|
+
safeBootArmRestoreOverwrite: "会把这个补丁文件整份覆盖成快照内容(不做合并),改前先另存备份:",
|
|
419
|
+
safeBootArmDisable: "只给这些行追加 disabled: true:",
|
|
420
|
+
safeBootArmDisableNote: "不删任何行、不做整树回滚,改前先另存备份。",
|
|
421
|
+
safeBootConfirm: "确认执行",
|
|
422
|
+
safeBootCancel: "取消(不发任何请求)",
|
|
423
|
+
safeBootDoneRestore: "已按快照逐字节恢复(改前状态已备份)",
|
|
424
|
+
safeBootDoneDisable: "已只给点名行追加 disabled: true(其余逐字节未动):",
|
|
425
|
+
safeBootDoneMarkGood: "已标记为良好:这份快照不再随保留策略删除:",
|
|
426
|
+
safeBootDisabledSkipped: "跳过",
|
|
427
|
+
safeBootNoopRestore: "当前补丁与快照逐字节一致,无需恢复(已备份当前状态)",
|
|
428
|
+
safeBootNoSuspects: "服务端没点名任何可疑行,一个字节都没改",
|
|
429
|
+
safeBootSuspectsTitle: "自动预检点名的可疑行:",
|
|
430
|
+
safeBootSuspectsNone: "自动预检还没点名任何可疑行 —— 所以这里不显示「只禁用可疑行」按钮(点了也没东西可禁)",
|
|
431
|
+
safeBootNoSnapshotActions: "没有快照可操作:「恢复」与「标记为良好」都要先有快照,所以这两个按钮不显示",
|
|
432
|
+
safeBootFailed: "操作失败:",
|
|
433
|
+
safeBootCmdHint: "服务起不来时(命令行兜底,无需服务在跑):",
|
|
434
|
+
safeBootBusy: "进行中…",
|
|
390
435
|
starHint: "喜欢这个插件?去 GitHub 点个 Star",
|
|
391
436
|
starHintTitle: "在新标签页打开 GitHub 项目",
|
|
392
437
|
fwFailedButUpgraded: "框架本体其实已经升到 {v} 并已生效——失败的只是脚本最后「重启服务」那一步。可点「重启服务」确认,或必要时回滚。",
|
|
@@ -1120,6 +1165,48 @@
|
|
|
1120
1165
|
gateRowFromUpgrade: "Source: framework upgrade pre-scan",
|
|
1121
1166
|
gateRowAt: " · {t}",
|
|
1122
1167
|
gateRowAdoptable: " · passed the source scan, can be unlocked",
|
|
1168
|
+
safeBootSectionTitle: "Safe boot",
|
|
1169
|
+
safeBootToggleTitle: "Expand/collapse the safe-boot panel (the snapshot list is read once per expand — zero requests while collapsed)",
|
|
1170
|
+
safeBootSectionHint: "Ways out while the service still answers: return to the last good patch state, or disable suspect rows only. When the service cannot start, use the command below.",
|
|
1171
|
+
safeBootLoading: "Reading the snapshot list…",
|
|
1172
|
+
safeBootReload: "Reload",
|
|
1173
|
+
safeBootListFailed: "Could not read snapshots: ",
|
|
1174
|
+
safeBootEmpty: "No snapshot yet: ",
|
|
1175
|
+
safeBootNotWritten: "No new snapshot was written this time: ",
|
|
1176
|
+
safeBootReasonPatchInvalid: "the patch failed the structural check, so the snapshot gate refused to write",
|
|
1177
|
+
safeBootReasonNoPatch: "there is no patch file, so there is nothing to snapshot",
|
|
1178
|
+
safeBootReasonEnvChanged: "the environment fingerprint just changed, the service is not confirmed healthy yet",
|
|
1179
|
+
safeBootReasonUnchanged: "byte-identical to the newest snapshot, no need to write another one",
|
|
1180
|
+
safeBootReasonNotYet: "the moment has not arrived yet (snapshots are written only once the service is confirmed healthy and the environment fingerprint has not changed)",
|
|
1181
|
+
safeBootCount: "Snapshots: {n}",
|
|
1182
|
+
safeBootLastGoodId: "Last marked good: ",
|
|
1183
|
+
safeBootLastGood: "good (last-good)",
|
|
1184
|
+
safeBootRowAt: "at",
|
|
1185
|
+
safeBootRowSha: "sha256",
|
|
1186
|
+
safeBootRestore: "Restore last-good state",
|
|
1187
|
+
safeBootRestoreTitle: "Two steps: the first click only arms it and names the snapshot, the request goes out after you confirm",
|
|
1188
|
+
safeBootDisable: "Disable suspect rows only",
|
|
1189
|
+
safeBootDisableTitle: "Two steps: append disabled: true to the rows named by auto-preflight only, never a whole-tree rollback",
|
|
1190
|
+
safeBootMarkGood: "Mark the current state as good",
|
|
1191
|
+
safeBootMarkGoodTitle: "Mark the newest snapshot as good (never removed by retention); the list refreshes afterwards",
|
|
1192
|
+
safeBootArmRestore: "Snapshot to restore:",
|
|
1193
|
+
safeBootArmRestoreOverwrite: "It overwrites this whole patch file with the snapshot content (no merge); the current state is backed up first:",
|
|
1194
|
+
safeBootArmDisable: "Append disabled: true to these rows only:",
|
|
1195
|
+
safeBootArmDisableNote: "No row is deleted and there is no whole-tree rollback; the current state is backed up first.",
|
|
1196
|
+
safeBootConfirm: "Confirm",
|
|
1197
|
+
safeBootCancel: "Cancel (sends nothing)",
|
|
1198
|
+
safeBootDoneRestore: "Restored byte-for-byte from the snapshot (previous state backed up)",
|
|
1199
|
+
safeBootDoneDisable: "Appended disabled: true to the named rows only (everything else untouched):",
|
|
1200
|
+
safeBootDoneMarkGood: "Marked as good: this snapshot is never removed by retention:",
|
|
1201
|
+
safeBootDisabledSkipped: "skipped",
|
|
1202
|
+
safeBootNoopRestore: "The patch is already byte-identical to the snapshot — nothing to restore (current state was backed up)",
|
|
1203
|
+
safeBootNoSuspects: "The server named no suspect rows, so not a single byte changed",
|
|
1204
|
+
safeBootSuspectsTitle: "Rows named by auto-preflight:",
|
|
1205
|
+
safeBootSuspectsNone: "Auto-preflight has not named any suspect row yet — so the \"disable suspect rows only\" button is not shown (there would be nothing to disable)",
|
|
1206
|
+
safeBootNoSnapshotActions: "Nothing to act on: restore and mark-as-good both need an existing snapshot, so those two buttons are not shown",
|
|
1207
|
+
safeBootFailed: "Action failed: ",
|
|
1208
|
+
safeBootCmdHint: "When the service cannot start (offline fallback, no running server needed):",
|
|
1209
|
+
safeBootBusy: "working…",
|
|
1123
1210
|
starHint: "Enjoying this plugin? Star it on GitHub",
|
|
1124
1211
|
starHintTitle: "Open the GitHub project in a new tab",
|
|
1125
1212
|
fwFailedButUpgraded: "The framework itself is already on {v} and active — only the final \"restart service\" step failed. Use Restart service to confirm, or roll back if needed.",
|
|
@@ -5695,6 +5782,312 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
5695
5782
|
const [compatNoticeClosed, setCompatNoticeClosed] = react.useState(false);
|
|
5696
5783
|
const gateRows = gateReady ? (state.data.gating ?? null) : null;
|
|
5697
5784
|
const gateTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
5785
|
+
// ══ 安全启动(门控面板里的一个分区)══════════════════════════════════════════════
|
|
5786
|
+
// 用户 2026-10-18 原话:「现在这能力藏得太深,只有命令行能用」。后端 POST /safe-boot 早就有了
|
|
5787
|
+
// (lib/server/routes/safe-boot.js;动作白名单 list / restore-last-good / disable-suspects /
|
|
5788
|
+
// mark-good),界面上却一个入口都没有 —— 这里补的就是这四个动作的手套。
|
|
5789
|
+
//
|
|
5790
|
+
// 为什么放**门控面板**里(不是 fwPanel、更不是新工具栏按钮):
|
|
5791
|
+
// safe-boot 救的正是「门控/兼容判断没拦住、框架被别的通道换坏之后打不开」那种局面 ——
|
|
5792
|
+
// 它和门控是**同一条链**(先拦、拦不住再回退),放在一起,用户出事时只有一个地方要想起。
|
|
5793
|
+
// 用户也明确不许再加工具栏按钮(已经挤),所以它挂在已有门控面板里,排在门控正文之后。
|
|
5794
|
+
//
|
|
5795
|
+
// 三条硬约束(用户定):① 不许"点了没反应" —— 每个动作要么有成功/失败/进行中反馈,要么这个
|
|
5796
|
+
// 按钮根本不显示;② 危险写动作**两段式** —— 第一下只上膛并写明影响面(改哪份文件、会覆盖什么),
|
|
5797
|
+
// 确认后才真发请求;③ 拿不到的数据如实显示「—」,不编造。
|
|
5798
|
+
// 判据一条都不搬过来:动作白名单、快照挑选、写盘闸门全在服务端 —— 客户端不重算、不预测结果。
|
|
5799
|
+
const [safeBootOpen, setSafeBootOpen] = react.useState(false);
|
|
5800
|
+
const [safeBootData, setSafeBootData] = react.useState(null); // list 的响应(null = 还没读过 / 读取失败)
|
|
5801
|
+
const [safeBootError, setSafeBootError] = react.useState(null); // 读取失败原文(不静默、不留空白)
|
|
5802
|
+
const [safeBootBusy, setSafeBootBusy] = react.useState(null); // 'list' | 动作名 | null(进行中反馈)
|
|
5803
|
+
const [safeBootArmed, setSafeBootArmed] = react.useState(null); // 两段式的"上膛":{ action, id?, rowIds? }
|
|
5804
|
+
const [safeBootNote, setSafeBootNote] = react.useState(null); // 写动作的结果/失败原文:{ text, level }
|
|
5805
|
+
const safeBootErrText = (error) => {
|
|
5806
|
+
const msg = error !== null && error !== undefined && typeof error.message === "string" ? error.message : String(error ?? "");
|
|
5807
|
+
return msg === "" ? t("safetySwitchFailed") : msg;
|
|
5808
|
+
};
|
|
5809
|
+
const safeBootTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
|
|
5810
|
+
/** 快照 sha256 只显示前几位(列表里够辨认;完整值在快照文件里)。拿不到就是「—」。 */
|
|
5811
|
+
const safeBootSha = (sha) => (typeof sha === "string" && sha !== "" ? sha.slice(0, 10) : "—");
|
|
5812
|
+
/** 「为什么没写快照」的原因词表 —— 与 lib/server/routes/compat.js#maybeWriteSnapshot 是**同一套字符串**。
|
|
5813
|
+
* 不认识的 reason 原样显示(不翻译成瞎话,也不吞掉)。 */
|
|
5814
|
+
const SAFE_BOOT_REASON_KEYS = {
|
|
5815
|
+
"patch-not-yaml-valid": "safeBootReasonPatchInvalid", // 结构校验没过(有东西可写,但闸门不放行)
|
|
5816
|
+
"no-patch-file": "safeBootReasonNoPatch",
|
|
5817
|
+
"environment-changed": "safeBootReasonEnvChanged", // 时机未到
|
|
5818
|
+
"unchanged": "safeBootReasonUnchanged", // 时机未到(已有一份一样的)
|
|
5819
|
+
"not-yet": "safeBootReasonNotYet", // 时机未到(这个字符串由本面板推导出来,见下)
|
|
5820
|
+
};
|
|
5821
|
+
const safeBootReasonText = (reason) => {
|
|
5822
|
+
const key = typeof reason === "string" ? SAFE_BOOT_REASON_KEYS[reason] : undefined;
|
|
5823
|
+
if (key !== undefined) return t(key);
|
|
5824
|
+
return typeof reason === "string" && reason !== "" ? reason : "—";
|
|
5825
|
+
};
|
|
5826
|
+
/**
|
|
5827
|
+
* 「这一次该写快照却没写,是因为什么」——**只在能说出原因时**返回 `{ written: false, reason, problems }`,
|
|
5828
|
+
* 说不出来就返回 null(面板就不显示这一行,不编一句话凑数)。
|
|
5829
|
+
*
|
|
5830
|
+
* 数据来源两条,都不猜:
|
|
5831
|
+
* ① 响应里真带 `snapshot.written`(与 compat-status 的 safeBoot.snapshot 同形)→ 原样用它的
|
|
5832
|
+
* written/reason;written === true 时返回 null(没东西可抱怨)。
|
|
5833
|
+
* ② 没带时,只用 list **确实下发**的事实推导:patch.exists / patch.check(服务端当场跑
|
|
5834
|
+
* validatePatchYaml 的结论)/ snapshots。于是两类原因分得清清楚楚:
|
|
5835
|
+
* · 结构校验没过 → `patch-not-yaml-valid`(有补丁、但校验结论 ok:false)
|
|
5836
|
+
* · 还没到该写的时机 → `not-yet`(补丁没问题,只是根本还没有一份快照;
|
|
5837
|
+
* 服务端只在「服务确认正常 + 环境指纹未变」时才写 —— 见 compat.js 的那段判据)
|
|
5838
|
+
* 两个文案完全不同,绝不糊成一句"没有快照"。
|
|
5839
|
+
*/
|
|
5840
|
+
const safeBootGateOf = (data) => {
|
|
5841
|
+
if (data === null || typeof data !== "object") return null;
|
|
5842
|
+
if (data.snapshot !== null && typeof data.snapshot === "object" && typeof data.snapshot.written === "boolean") {
|
|
5843
|
+
if (data.snapshot.written === true) return null;
|
|
5844
|
+
return { written: false, reason: data.snapshot.reason ?? null, problems: Array.isArray(data.snapshot.problems) ? data.snapshot.problems : [] };
|
|
5845
|
+
}
|
|
5846
|
+
const snaps = Array.isArray(data.snapshots) ? data.snapshots : [];
|
|
5847
|
+
const patch = data.patch !== null && typeof data.patch === "object" ? data.patch : null;
|
|
5848
|
+
if (patch === null || patch.exists !== true) return { written: false, reason: "no-patch-file", problems: [] };
|
|
5849
|
+
const check = patch.check !== null && typeof patch.check === "object" ? patch.check : null;
|
|
5850
|
+
if (check !== null && check.ok !== true) return { written: false, reason: "patch-not-yaml-valid", problems: Array.isArray(check.problems) ? check.problems : [] };
|
|
5851
|
+
if (snaps.length === 0) return { written: false, reason: "not-yet", problems: [] };
|
|
5852
|
+
return null;
|
|
5853
|
+
};
|
|
5854
|
+
/** 快照清单(新→旧;只认形状对的条目,坏数据不会把整块渲染拖崩)。 */
|
|
5855
|
+
const safeBootSnapsOf = (data) => (data !== null && typeof data === "object" && Array.isArray(data.snapshots) ? data.snapshots : [])
|
|
5856
|
+
.filter((s) => s !== null && typeof s === "object" && typeof s.id === "string")
|
|
5857
|
+
.sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0));
|
|
5858
|
+
/** 自动预检点名的可疑行(服务端 lastPlan.suspects);没有就是空数组 —— 那就**不显示**这个按钮。 */
|
|
5859
|
+
const safeBootSuspectsOf = (data) => (data !== null && typeof data === "object" && data.lastPlan !== null && typeof data.lastPlan === "object" && Array.isArray(data.lastPlan.suspects)
|
|
5860
|
+
? data.lastPlan.suspects.filter((x) => typeof x === "string" && x !== "")
|
|
5861
|
+
: []);
|
|
5862
|
+
/** 读一次快照列表(**只**在"展开那一下"和写动作之后调用;收起状态零请求)。
|
|
5863
|
+
* 失败时保留上一次的数据但把错误原文摆出来 —— 一次读取失手不该把已有信息抹掉。 */
|
|
5864
|
+
const safeBootRead = async () => {
|
|
5865
|
+
try {
|
|
5866
|
+
const data = await call("/plugin-console/safe-boot", { action: "list" });
|
|
5867
|
+
if (data === null || typeof data !== "object") { setSafeBootError(t("safeBootListFailed") + "—"); return; }
|
|
5868
|
+
setSafeBootData(data);
|
|
5869
|
+
setSafeBootError(null);
|
|
5870
|
+
} catch (error) {
|
|
5871
|
+
setSafeBootError(t("safeBootListFailed") + safeBootErrText(error));
|
|
5872
|
+
}
|
|
5873
|
+
};
|
|
5874
|
+
/** 写动作结束后的一句结果(服务端说什么就说什么:changed:false / no-suspects / 具体行)。 */
|
|
5875
|
+
const safeBootDoneText = (action, data) => {
|
|
5876
|
+
if (data === null || typeof data !== "object") return t("safeBootFailed") + "—";
|
|
5877
|
+
if (action === "restore-last-good") return data.changed === false ? t("safeBootNoopRestore") : t("safeBootDoneRestore");
|
|
5878
|
+
if (action === "disable-suspects") {
|
|
5879
|
+
if (data.reason === "no-suspects") return t("safeBootNoSuspects");
|
|
5880
|
+
const added = Array.isArray(data.added) ? data.added.filter((x) => typeof x === "string") : [];
|
|
5881
|
+
const skipped = Array.isArray(data.skipped) ? data.skipped : [];
|
|
5882
|
+
if (added.length === 0) return t("safeBootNoSuspects");
|
|
5883
|
+
return t("safeBootDoneDisable") + " " + added.join("、") + (skipped.length === 0 ? "" : " · " + t("safeBootDisabledSkipped") + " " + String(skipped.length));
|
|
5884
|
+
}
|
|
5885
|
+
if (action === "mark-good") {
|
|
5886
|
+
const id = data.snapshot !== null && typeof data.snapshot === "object" && typeof data.snapshot.id === "string" ? data.snapshot.id : null;
|
|
5887
|
+
return t("safeBootDoneMarkGood") + " " + (id ?? "—");
|
|
5888
|
+
}
|
|
5889
|
+
return "—";
|
|
5890
|
+
};
|
|
5891
|
+
/** 真发请求(唯一出口):list / restore-last-good / disable-suspects / mark-good */
|
|
5892
|
+
const safeBootSend = async (action, extra) => {
|
|
5893
|
+
if (safeBootBusy !== null) return; // 进行中不叠加请求(按钮此刻也是 disabled)
|
|
5894
|
+
setSafeBootBusy(action);
|
|
5895
|
+
setSafeBootNote(null);
|
|
5896
|
+
setSafeBootArmed(null); // 一旦真发请求,上膛状态作废(绝不留一个"看着像待确认"的残影)
|
|
5897
|
+
if (action === "list") setSafeBootError(null);
|
|
5898
|
+
try {
|
|
5899
|
+
const body = { action };
|
|
5900
|
+
// 只放服务端白名单认得的两个载荷字段(id / rowIds);其余一概不传。
|
|
5901
|
+
if (extra !== null && extra !== undefined) {
|
|
5902
|
+
if (typeof extra.id === "string" && extra.id !== "") body.id = extra.id;
|
|
5903
|
+
if (Array.isArray(extra.rowIds) && extra.rowIds.length > 0) body.rowIds = extra.rowIds.slice();
|
|
5904
|
+
}
|
|
5905
|
+
const data = await call("/plugin-console/safe-boot", body);
|
|
5906
|
+
if (action === "list") {
|
|
5907
|
+
if (data === null || typeof data !== "object") setSafeBootError(t("safeBootListFailed") + "—");
|
|
5908
|
+
else { setSafeBootData(data); setSafeBootError(null); }
|
|
5909
|
+
} else {
|
|
5910
|
+
setSafeBootNote({ text: safeBootDoneText(action, data), level: null });
|
|
5911
|
+
// 写动作之后**重新读一次列表**(mark-good 尤其必须:last-good 标记要随之变化)
|
|
5912
|
+
await safeBootRead();
|
|
5913
|
+
}
|
|
5914
|
+
} catch (error) {
|
|
5915
|
+
// 失败一律把服务端给的原文摆出来(call() 已经把 {ok:false,error} 抛成 Error.message)
|
|
5916
|
+
if (action === "list") setSafeBootError(t("safeBootListFailed") + safeBootErrText(error));
|
|
5917
|
+
else setSafeBootNote({ text: t("safeBootFailed") + safeBootErrText(error), level: "error" });
|
|
5918
|
+
} finally { setSafeBootBusy(null); }
|
|
5919
|
+
};
|
|
5920
|
+
/** 展开/收起:**展开那一下**才读一次 list(收起状态零请求);收起时把上膛清掉。 */
|
|
5921
|
+
const safeBootToggle = () => {
|
|
5922
|
+
const next = !safeBootOpen;
|
|
5923
|
+
setSafeBootOpen(next);
|
|
5924
|
+
setSafeBootArmed(null);
|
|
5925
|
+
setSafeBootNote(null);
|
|
5926
|
+
if (next) void safeBootSend("list");
|
|
5927
|
+
};
|
|
5928
|
+
/** 两段式的第一段:只上膛,一个请求都不发(换动作 = 换载荷,上一个动作的载荷不会跟着发出去)。 */
|
|
5929
|
+
const safeBootArm = (next) => {
|
|
5930
|
+
setSafeBootNote(null);
|
|
5931
|
+
setSafeBootArmed(next);
|
|
5932
|
+
};
|
|
5933
|
+
const safeBootPanel = () => {
|
|
5934
|
+
const data = safeBootData;
|
|
5935
|
+
const gate = safeBootGateOf(data);
|
|
5936
|
+
const snaps = safeBootSnapsOf(data);
|
|
5937
|
+
const suspects = safeBootSuspectsOf(data);
|
|
5938
|
+
// 恢复目标的选择口径与服务端 domain/safe-boot.js#pickSnapshot 一致(kind=good 优先,其次最新);
|
|
5939
|
+
// 选好后把 id **显式传下去** —— 面板上写的是哪份,恢复的就是哪份,不可能"说 A 做 B"。
|
|
5940
|
+
const goodOne = snaps.filter((s) => s.kind === "good")[0] ?? null;
|
|
5941
|
+
const restoreTarget = goodOne ?? snaps[0] ?? null;
|
|
5942
|
+
const newest = snaps[0] ?? null;
|
|
5943
|
+
const lastGoodId = data !== null && typeof data === "object" && typeof data.lastGoodId === "string" && data.lastGoodId !== "" ? data.lastGoodId : null;
|
|
5944
|
+
const patchPath = data !== null && typeof data === "object" && typeof data.patchPath === "string" && data.patchPath !== "" ? data.patchPath : null;
|
|
5945
|
+
const commands = data !== null && typeof data === "object" && data.commands !== null && typeof data.commands === "object" ? data.commands : null;
|
|
5946
|
+
// 上膛的那份快照对象(按 id 回查 —— 显示的时间/sha256 与"会恢复的那一份"必然是同一份)
|
|
5947
|
+
const armedSnap = safeBootArmed === null || typeof safeBootArmed.id !== "string" ? null : (snaps.find((s) => s.id === safeBootArmed.id) ?? null);
|
|
5948
|
+
const busy = safeBootBusy !== null;
|
|
5949
|
+
const reasonOf = (x) => (x === null || typeof x !== "object" ? null : x.reason ?? null);
|
|
5950
|
+
return el("div", {
|
|
5951
|
+
"data-safeboot": "true",
|
|
5952
|
+
style: { flexDirection: "column", gap: 6, display: "flex", marginTop: 4, paddingTop: 8, borderTop: "1px solid var(--dsw-alias-border-l2)" },
|
|
5953
|
+
},
|
|
5954
|
+
el("button", {
|
|
5955
|
+
type: "button",
|
|
5956
|
+
className: styles.toggle,
|
|
5957
|
+
"data-safeboot-toggle": "true",
|
|
5958
|
+
"data-open": safeBootOpen ? "true" : "false",
|
|
5959
|
+
"aria-label": "safeBootToggle",
|
|
5960
|
+
"aria-expanded": safeBootOpen ? "true" : "false",
|
|
5961
|
+
title: t("safeBootToggleTitle"),
|
|
5962
|
+
onClick: safeBootToggle,
|
|
5963
|
+
}, (safeBootOpen ? "▾ " : "▸ ") + t("safeBootSectionTitle") + (busy ? " · " + t("safeBootBusy") : "")),
|
|
5964
|
+
safeBootOpen ? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
5965
|
+
el("p", { className: styles.status }, t("safeBootSectionHint")),
|
|
5966
|
+
// 进行中:明确说在做什么("点了没反应"就是这个面板最不能犯的错)
|
|
5967
|
+
busy ? el("p", { className: styles.status, "data-safeboot-busy": "true" }, t("safeBootLoading")) : null,
|
|
5968
|
+
// 读取失败:错误原文 + 一个「重新读取」(有反馈的出口,不是一个哑按钮)
|
|
5969
|
+
safeBootError !== null
|
|
5970
|
+
? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
5971
|
+
el("p", { className: styles.message, "data-error": "true", "data-safeboot-error": "true" }, safeBootError),
|
|
5972
|
+
el("button", {
|
|
5973
|
+
type: "button",
|
|
5974
|
+
className: styles.toggle,
|
|
5975
|
+
"aria-label": "safeBootReload",
|
|
5976
|
+
disabled: busy,
|
|
5977
|
+
onClick: () => { void safeBootSend("list"); },
|
|
5978
|
+
}, t("safeBootReload")))
|
|
5979
|
+
: null,
|
|
5980
|
+
data === null ? null : el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
|
|
5981
|
+
// ① 「这次没写快照」+ 原因:结构校验没过 / 时机未到 —— 两句话不一样,原因原文也照抄
|
|
5982
|
+
gate === null
|
|
5983
|
+
? null
|
|
5984
|
+
: el("p", { className: styles.status, "data-safeboot-reason": "true", "data-reason": gate.reason ?? "—" },
|
|
5985
|
+
t("safeBootNotWritten") + " " + safeBootReasonText(reasonOf(gate)) + "(" + (reasonOf(gate) ?? "—") + ")"
|
|
5986
|
+
+ (gate.problems.length === 0 ? "" : " · " + gate.problems.slice(0, 3).join(";"))),
|
|
5987
|
+
// ② 计数 + last-good(拿不到就「—」);data-count 是这份份数本身(界面上不以它为唯一来源)
|
|
5988
|
+
el("p", { className: styles.status, "data-safeboot-count": "true", "data-count": String(snaps.length) },
|
|
5989
|
+
t("safeBootCount").replace("{n}", String(snaps.length)) + " · " + t("safeBootLastGoodId") + " " + (lastGoodId ?? "—")),
|
|
5990
|
+
// ③ 清单 / 空态(空态必须说原因,不能只说"没有快照"就完了)
|
|
5991
|
+
snaps.length === 0
|
|
5992
|
+
? el("p", { className: styles.status, "data-safeboot-empty": "true" },
|
|
5993
|
+
t("safeBootEmpty") + " " + safeBootReasonText(reasonOf(gate)) + "(" + (reasonOf(gate) ?? "—") + ")")
|
|
5994
|
+
: el("div", { style: { flexDirection: "column", gap: 4, display: "flex" }, "data-safeboot-list": "true" },
|
|
5995
|
+
snaps.map((s) => el("div", {
|
|
5996
|
+
key: "sb-" + String(s.id),
|
|
5997
|
+
className: styles.row,
|
|
5998
|
+
"data-safeboot-row": "true",
|
|
5999
|
+
"data-snapshot-id": String(s.id),
|
|
6000
|
+
},
|
|
6001
|
+
el("div", { className: styles.rowTop },
|
|
6002
|
+
el("code", { className: styles.name, style: { fontSize: 11, whiteSpace: "normal", overflow: "visible", textOverflow: "clip", wordBreak: "break-all" }, title: String(s.id) }, String(s.id)),
|
|
6003
|
+
s.kind === "good" || s.id === lastGoodId
|
|
6004
|
+
? el("span", { className: styles.tag, "data-lastgood": "true" }, t("safeBootLastGood"))
|
|
6005
|
+
: null),
|
|
6006
|
+
el("div", { className: styles.rowTop, style: { gap: 6 } },
|
|
6007
|
+
el("span", { className: styles.status }, t("safeBootRowAt") + " " + safeBootTime(s.at)),
|
|
6008
|
+
" · ",
|
|
6009
|
+
el("span", { className: styles.status, "data-snapshot-sha": "true" }, t("safeBootRowSha") + " " + safeBootSha(s.patchSha256)))))),
|
|
6010
|
+
// ④ 动作:没有可操作对象时**一个按钮都不显示**,并说明为什么(不许出现点了必失败的按钮)
|
|
6011
|
+
snaps.length === 0
|
|
6012
|
+
? el("p", { className: styles.status, "data-safeboot-noaction": "true" }, t("safeBootNoSnapshotActions"))
|
|
6013
|
+
: el("div", { style: { flexDirection: "row", gap: 8, display: "flex", flexWrap: "wrap" } },
|
|
6014
|
+
el("button", {
|
|
6015
|
+
type: "button",
|
|
6016
|
+
className: styles.toggle,
|
|
6017
|
+
"aria-label": "safeBootRestore",
|
|
6018
|
+
"data-armed": safeBootArmed !== null && safeBootArmed.action === "restore-last-good" ? "true" : undefined,
|
|
6019
|
+
disabled: busy || restoreTarget === null,
|
|
6020
|
+
title: t("safeBootRestoreTitle"),
|
|
6021
|
+
onClick: () => safeBootArm({ action: "restore-last-good", id: restoreTarget === null ? null : String(restoreTarget.id) }),
|
|
6022
|
+
}, t("safeBootRestore")),
|
|
6023
|
+
suspects.length === 0
|
|
6024
|
+
? null // 没有点名的行 ⇒ 不显示这个按钮(点了只会得到 no-suspects,属于"点了没反应")
|
|
6025
|
+
: el("button", {
|
|
6026
|
+
type: "button",
|
|
6027
|
+
className: styles.toggle,
|
|
6028
|
+
"aria-label": "safeBootDisable",
|
|
6029
|
+
"data-armed": safeBootArmed !== null && safeBootArmed.action === "disable-suspects" ? "true" : undefined,
|
|
6030
|
+
disabled: busy,
|
|
6031
|
+
title: t("safeBootDisableTitle"),
|
|
6032
|
+
onClick: () => safeBootArm({ action: "disable-suspects", rowIds: suspects.slice() }),
|
|
6033
|
+
}, t("safeBootDisable")),
|
|
6034
|
+
el("button", {
|
|
6035
|
+
type: "button",
|
|
6036
|
+
className: styles.toggle,
|
|
6037
|
+
"aria-label": "safeBootMarkGood",
|
|
6038
|
+
disabled: busy,
|
|
6039
|
+
title: t("safeBootMarkGoodTitle"),
|
|
6040
|
+
onClick: () => { void safeBootSend("mark-good", { id: newest === null ? null : String(newest.id) }); },
|
|
6041
|
+
}, t("safeBootMarkGood"))),
|
|
6042
|
+
snaps.length === 0
|
|
6043
|
+
? null
|
|
6044
|
+
: (suspects.length === 0
|
|
6045
|
+
? el("p", { className: styles.status, "data-safeboot-suspects": "none" }, t("safeBootSuspectsNone"))
|
|
6046
|
+
: el("p", { className: styles.status, "data-safeboot-suspects": "true" }, t("safeBootSuspectsTitle") + " " + suspects.join("、"))),
|
|
6047
|
+
// ⑤ 两段式:上膛后把"要改什么、改哪份文件"写清楚,取消 = 零请求
|
|
6048
|
+
safeBootArmed === null
|
|
6049
|
+
? null
|
|
6050
|
+
: el("div", { "data-safeboot-armed": "true", style: { flexDirection: "column", gap: 6, display: "flex", borderLeft: "2px solid var(--dsw-alias-state-warning-primary)", paddingLeft: 8 } },
|
|
6051
|
+
el("p", { className: styles.message, "data-warn": "true", "data-safeboot-armedtext": "true" },
|
|
6052
|
+
safeBootArmed.action === "restore-last-good"
|
|
6053
|
+
? t("safeBootArmRestore") + " " + String(safeBootArmed.id ?? "—") + " · " + t("safeBootRowAt") + " "
|
|
6054
|
+
+ safeBootTime(armedSnap === null ? null : armedSnap.at) + " · sha256 "
|
|
6055
|
+
+ safeBootSha(armedSnap === null ? null : armedSnap.patchSha256)
|
|
6056
|
+
: t("safeBootArmDisable") + " " + (Array.isArray(safeBootArmed.rowIds) ? safeBootArmed.rowIds.join("、") : "—")),
|
|
6057
|
+
safeBootArmed.action === "restore-last-good"
|
|
6058
|
+
? el("p", { className: styles.status }, t("safeBootArmRestoreOverwrite") + " " + (patchPath ?? "—"))
|
|
6059
|
+
: el("p", { className: styles.status }, t("safeBootArmDisableNote")),
|
|
6060
|
+
el("div", { style: { flexDirection: "row", gap: 8, display: "flex" } },
|
|
6061
|
+
el("button", {
|
|
6062
|
+
type: "button",
|
|
6063
|
+
className: styles.toggle,
|
|
6064
|
+
"aria-label": "safeBootConfirm",
|
|
6065
|
+
disabled: busy,
|
|
6066
|
+
onClick: () => { void safeBootSend(safeBootArmed.action, safeBootArmed); },
|
|
6067
|
+
}, t("safeBootConfirm")),
|
|
6068
|
+
el("button", {
|
|
6069
|
+
type: "button",
|
|
6070
|
+
className: styles.toggle,
|
|
6071
|
+
"aria-label": "safeBootCancel",
|
|
6072
|
+
onClick: () => { setSafeBootArmed(null); setSafeBootNote(null); },
|
|
6073
|
+
}, t("safeBootCancel")))),
|
|
6074
|
+
// ⑥ 结果反馈(成功/无操作/失败原文,都留在面板上)——
|
|
6075
|
+
// 失败走既有 error 样式;成功用既有 success 色(没有新的 CSS 类,只借颜色令牌)。
|
|
6076
|
+
safeBootNote === null
|
|
6077
|
+
? null
|
|
6078
|
+
: el("p", {
|
|
6079
|
+
className: styles.message,
|
|
6080
|
+
"data-safeboot-note": "true",
|
|
6081
|
+
"data-error": safeBootNote.level === "error" ? "true" : undefined,
|
|
6082
|
+
style: safeBootNote.level === "error" ? undefined : { color: "var(--dsw-alias-state-success-primary)" },
|
|
6083
|
+
}, String(safeBootNote.text ?? "—")),
|
|
6084
|
+
// ⑦ 服务起不来时的命令行兜底(服务端下发的原始命令,不自己拼)
|
|
6085
|
+
commands !== null && typeof commands.list === "string"
|
|
6086
|
+
? el("p", { className: styles.status, "data-safeboot-cmd": "true" }, t("safeBootCmdHint") + " " + commands.list)
|
|
6087
|
+
: null))
|
|
6088
|
+
: null,
|
|
6089
|
+
);
|
|
6090
|
+
};
|
|
5698
6091
|
// 「功能包 → 框架」按钮角标:升级进行中 ⟳ / 上次失败 ✕ / 有可用更新 ↑(优先级从高到低)
|
|
5699
6092
|
const fwRunning = frameworkStatus !== null
|
|
5700
6093
|
&& frameworkStatus.status !== "idle" && frameworkStatus.status !== "done" && frameworkStatus.status !== "failed";
|
|
@@ -6356,6 +6749,10 @@ const RESTART_FAILED_NOTE = "重启请求失败";
|
|
|
6356
6749
|
: null)
|
|
6357
6750
|
: null)
|
|
6358
6751
|
: null),
|
|
6752
|
+
// ★ 0.5.x 加法(用户 2026-10-18):「安全启动」分区 —— 挂在门控面板里(不新增工具栏按钮)。
|
|
6753
|
+
// 默认收起,展开那一下才读一次快照列表;四个动作见 safeBootPanel(list / restore-last-good /
|
|
6754
|
+
// disable-suspects / mark-good)。它排在门控正文**之后**,Star 行仍在最后(贴底那条不变)。
|
|
6755
|
+
safeBootPanel(),
|
|
6359
6756
|
// 0.5.42 加法(用户点名):右侧贴底的 Star 提示 —— 只加这一行 UI,
|
|
6360
6757
|
// 不动任何开关逻辑/路由/门控语义。它是卡片的最后一个子项(整行,不占左侧内容宽度),
|
|
6361
6758
|
// margin-top:auto 让它在卡片有富余高度时贴底;position:sticky;bottom:0 让正文
|
|
@@ -63,6 +63,16 @@ function blockScalarIndicator(value) {
|
|
|
63
63
|
function scanValueQuotes(value, initialQuote = null) {
|
|
64
64
|
const text = String(value)
|
|
65
65
|
let quote = initialQuote
|
|
66
|
+
// 函数头注释 ⑤ 的前提「值不以引号开头 ⇒ 不跨行扫描」原先只写在注释里、代码没实现。
|
|
67
|
+
// 代价(issue #26):`- 'D:/x/y.js'` 这类带盘符的序列项里,盘符的冒号被 key:value 正则
|
|
68
|
+
// 当成分隔符吃掉,value 变成 `/x/y.js'`(首字符不是引号),于是行尾那个 `'` 被当成
|
|
69
|
+
// 「引号开口」并一直挂到文件末尾 ⇒ 误报「文件末尾仍有未闭合的引号」,
|
|
70
|
+
// 而 safeBoot 的写盘前闸门正是它 ⇒ 快照/恢复/只禁可疑行全部退化成空转。
|
|
71
|
+
// 值不以引号开头时,本行的引号不可能跨行开口。
|
|
72
|
+
if (quote === null) {
|
|
73
|
+
const first = text.trimStart()[0]
|
|
74
|
+
if (first !== "'" && first !== '"') return { quote: null }
|
|
75
|
+
}
|
|
66
76
|
for (let i = 0; i < text.length; i += 1) {
|
|
67
77
|
const ch = text[i]
|
|
68
78
|
if (quote === null) {
|
|
@@ -39,6 +39,17 @@ import { preflightRoots } from './framework-preflight.js'
|
|
|
39
39
|
|
|
40
40
|
const PREFLIGHT_TIMEOUT_ENV = 'DSH_AUTO_PREFLIGHT_TIMEOUT_MS'
|
|
41
41
|
/** 自动预检的预算:到点降级成「未完成,可手动重跑」,状态查询照常返回(env 可调)。 */
|
|
42
|
+
/**
|
|
43
|
+
* 安全启动的命令行兜底:拼成**可直接复制运行**的绝对路径命令。
|
|
44
|
+
* 现场问题(2026-10-29):原先给的是相对路径 `node scripts/safe-boot.mjs --list`,
|
|
45
|
+
* 只有先 cd 到插件安装目录才成立 —— 而那是 node_modules 里的深层哈希目录,
|
|
46
|
+
* 服务起不来、界面打不开的时候,用户根本无从找到它。救火命令必须是能直接粘的。
|
|
47
|
+
*/
|
|
48
|
+
function safeBootCommand(args = []) {
|
|
49
|
+
const script = join(pluginRoot(), 'scripts', 'safe-boot.mjs')
|
|
50
|
+
return ['node', JSON.stringify(script), ...args].join(' ')
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
function preflightTimeoutMs() {
|
|
43
54
|
const raw = Number(process.env[PREFLIGHT_TIMEOUT_ENV])
|
|
44
55
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS
|
|
@@ -218,7 +229,7 @@ function autoDisableNotice(entry = lastAutoDisable, { enabled = null } = {}) {
|
|
|
218
229
|
if (entry === null || entry === undefined) return null
|
|
219
230
|
if (on !== true && entry.code === 'auto-disabled') return null
|
|
220
231
|
const added = Array.isArray(entry.disabled) ? entry.disabled : []
|
|
221
|
-
const restore = '
|
|
232
|
+
const restore = safeBootCommand(['--restore-last-good'])
|
|
222
233
|
if (added.length > 0) {
|
|
223
234
|
return {
|
|
224
235
|
at: entry.at ?? null,
|
|
@@ -318,7 +329,7 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
318
329
|
onEvidence: readCompatGate().autoDisableOnEvidence === true,
|
|
319
330
|
switch: { file: 'compat-gate.json', key: 'autoDisableOnEvidence', route: '/plugin-console/compat-ack', body: { autoDisableOnEvidence: false } },
|
|
320
331
|
notice: autoDisableNotice(),
|
|
321
|
-
restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: '
|
|
332
|
+
restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: safeBootCommand(['--restore-last-good']) },
|
|
322
333
|
logHint: '记录落在 dshHome()/plugin-console/auto-disable.log(追加式 JSONL)',
|
|
323
334
|
},
|
|
324
335
|
// D2 加法字段:安全启动材料(last-known-good 快照 + 恢复入口提示)
|
|
@@ -326,7 +337,7 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
326
337
|
snapshot: { written: snapshot.written === true, reason: snapshot.reason ?? null, id: snapshot.snapshot?.id ?? null, at: snapshot.snapshot?.at ?? null, sha256: snapshot.snapshot?.patchSha256 ?? null },
|
|
327
338
|
snapshots: listSnapshots().slice(0, 5),
|
|
328
339
|
lastGoodId: readSafeBootState().lastGoodId,
|
|
329
|
-
command: { list: '
|
|
340
|
+
command: { list: safeBootCommand(['--list']), restore: safeBootCommand(['--restore-last-good']), disable: safeBootCommand(['--disable-suspects']) },
|
|
330
341
|
route: { action: 'restore-last-good | disable-suspects | list | mark-good', path: '/plugin-console/safe-boot' },
|
|
331
342
|
},
|
|
332
343
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.97",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|