@huaqiu/dsh-tool-plugin-update 0.6.5 → 0.6.7

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 CHANGED
@@ -375,6 +375,108 @@ window.__ModuleLoader__.load({
375
375
  }
376
376
  return null;
377
377
  }
378
+ /** Phases after which a job never runs again — polling stops and a result view
379
+ * replaces the active-job view. */
380
+ const TERMINAL_PHASES = /* @__PURE__ */ new Set([
381
+ "done",
382
+ "failed",
383
+ "rolled-back",
384
+ "abandoned"
385
+ ]);
386
+ const PHASE_VALUES = /* @__PURE__ */ new Set([
387
+ "queued",
388
+ "resolving",
389
+ "downloading",
390
+ "verifying",
391
+ "staging",
392
+ "activating",
393
+ "awaiting-host",
394
+ "restarting",
395
+ "verifying-after-restart",
396
+ "done",
397
+ "failed",
398
+ "rolled-back",
399
+ "abandoned"
400
+ ]);
401
+ function asFiniteNumber(value) {
402
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
403
+ }
404
+ /** Defensively normalize one job payload from the wire into {@link UpdateJob}.
405
+ * Unknown/older servers may omit fields; a malformed `phase` falls back to
406
+ * `queued` rather than crashing the view. */
407
+ function normalizeJob(raw) {
408
+ if (raw === null || typeof raw !== "object") return null;
409
+ const r = raw;
410
+ if (typeof r.id !== "string" || r.id.length === 0) return null;
411
+ const phase = typeof r.phase === "string" && PHASE_VALUES.has(r.phase) ? r.phase : "queued";
412
+ const progressRaw = r.progress ?? {};
413
+ const percentRaw = asFiniteNumber(progressRaw.percent);
414
+ const progress = { percent: percentRaw === void 0 ? null : Math.min(100, Math.max(0, percentRaw)) };
415
+ const bytesDone = asFiniteNumber(progressRaw.bytesDone);
416
+ const bytesTotal = asFiniteNumber(progressRaw.bytesTotal);
417
+ const bytesPerSecond = asFiniteNumber(progressRaw.bytesPerSecond);
418
+ const etaSeconds = asFiniteNumber(progressRaw.etaSeconds);
419
+ if (bytesDone !== void 0) progress.bytesDone = bytesDone;
420
+ if (bytesTotal !== void 0) progress.bytesTotal = bytesTotal;
421
+ if (bytesPerSecond !== void 0) progress.bytesPerSecond = bytesPerSecond;
422
+ if (etaSeconds !== void 0) progress.etaSeconds = etaSeconds;
423
+ const job = {
424
+ id: r.id,
425
+ kind: r.kind === "runtime" ? "runtime" : "plugin-set",
426
+ targetVersion: typeof r.targetVersion === "string" ? r.targetVersion : "",
427
+ previousVersion: typeof r.previousVersion === "string" ? r.previousVersion : null,
428
+ startedAt: typeof r.startedAt === "string" ? r.startedAt : "",
429
+ updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : "",
430
+ phase,
431
+ progress
432
+ };
433
+ if (typeof r.message === "string" && r.message.length > 0) job.message = r.message;
434
+ if (typeof r.logHint === "string" && r.logHint.length > 0) job.logHint = r.logHint;
435
+ if (Array.isArray(r.steps)) {
436
+ const steps = [];
437
+ for (const entry of r.steps) {
438
+ if (entry === null || typeof entry !== "object") continue;
439
+ const s = entry;
440
+ if (typeof s.name !== "string") continue;
441
+ const state = s.state === "running" || s.state === "installed" || s.state === "skipped" || s.state === "failed" ? s.state : "pending";
442
+ const step = {
443
+ name: s.name,
444
+ state
445
+ };
446
+ const pct = asFiniteNumber(s.percent);
447
+ if (pct !== void 0) step.percent = pct;
448
+ if (typeof s.message === "string" && s.message.length > 0) step.message = s.message;
449
+ steps.push(step);
450
+ }
451
+ if (steps.length > 0) job.steps = steps;
452
+ }
453
+ if (r.result !== null && typeof r.result === "object") {
454
+ const res = r.result;
455
+ const result = {
456
+ ok: res.ok === true,
457
+ installedVersion: typeof res.installedVersion === "string" ? res.installedVersion : null,
458
+ rolledBack: res.rolledBack === true,
459
+ finishedAt: typeof res.finishedAt === "string" ? res.finishedAt : ""
460
+ };
461
+ if (typeof res.errorCode === "string" && res.errorCode.length > 0) result.errorCode = res.errorCode;
462
+ if (typeof res.message === "string" && res.message.length > 0) result.message = res.message;
463
+ job.result = result;
464
+ }
465
+ return job;
466
+ }
467
+ /** Normalize a `GET /status` payload; an empty/absent body degrades to "no
468
+ * active job" rather than throwing. */
469
+ function normalizeJobStatus(raw) {
470
+ if (raw === null || typeof raw !== "object") return {
471
+ activeJob: null,
472
+ recentJobs: []
473
+ };
474
+ const r = raw;
475
+ return {
476
+ activeJob: normalizeJob(r.activeJob),
477
+ recentJobs: Array.isArray(r.recentJobs) ? r.recentJobs.map(normalizeJob).filter((j) => j !== null) : []
478
+ };
479
+ }
378
480
  async function request(path, init) {
379
481
  const res = await fetch(`${UPDATE_BASE}${path}`, {
380
482
  cache: "no-store",
@@ -459,87 +561,174 @@ window.__ModuleLoader__.load({
459
561
  body: JSON.stringify({ url })
460
562
  });
461
563
  }
462
- const COPY = {
463
- zh: {
464
- entryLabel: "插件更新",
465
- entryTooltip: "查看可用的插件更新",
466
- viewTitle: "插件更新",
467
- viewIntro: "检查并安装可用的插件更新",
468
- viewInfo: "在这里查看插件集的更新。标注为「被阻止」的插件因主机或 HQ Edge 版本不兼容而无法安装;已是最新的插件无需操作。",
469
- refresh: "刷新",
470
- close: "关闭",
471
- loading: "正在读取插件更新…",
472
- empty: "没有可用的插件更新",
473
- checkFailed: "检查更新失败,请重试",
474
- installAll: (count) => `安装 ${count} 项更新`,
475
- installOne: "安装",
476
- installing: "正在安装…(DSH 将自动重启)",
477
- nothingToInstall: "暂无可安装的更新",
478
- ignore: "忽略此版本",
479
- unignore: "恢复提醒",
480
- ignoredOffer: "已忽略当前插件集更新",
481
- groupCount: (count) => `${count} 个插件`,
482
- group: {
483
- actionable: "待处理",
484
- blocked: "无法安装",
485
- ignored: "已忽略",
486
- current: "已是最新"
487
- },
488
- state: {
489
- current: "当前",
490
- update: "可更新",
491
- install: "待安装",
492
- ignored: "已忽略",
493
- blocked: "被阻止"
494
- },
495
- blockedReason: {
496
- host: "不适用于当前 EDA 主机",
497
- "hq-edge-version": "当前 HQ Edge 版本不兼容",
498
- "runtime-unknown": "HQ Edge 版本未知,无法校验"
499
- },
500
- runtime: {
501
- title: "HQ Edge 运行时",
502
- current: "当前版本",
503
- latest: "最新版本",
504
- set: "发布插件集",
505
- checkedAt: "上次检查",
506
- source: "更新源",
507
- unknown: "版本未知",
508
- notConfigured: "未配置更新源",
509
- note: "运行时更新由 EDA 安装器处理",
510
- update: "安装运行时更新",
511
- updateConfirm: (version) => `安装 HQ Edge 运行时 ${version}?HQ Edge 会先关闭并完成替换,然后重新启动。`,
512
- updating: "正在下载并校验运行时包,可能需要一分钟…",
513
- updateAccepted: "运行时更新已受理,HQ Edge 正在重启…",
514
- sourceHint: "运行时更新清单的下载地址,保存后立即生效",
515
- sourceEdit: "修改",
516
- sourceSave: "保存",
517
- sourceReset: "恢复默认",
518
- saved: "更新源已保存,正在重新检查更新…",
519
- saveFailed: "保存更新源失败",
520
- sourceInputLabel: "运行时更新清单地址",
521
- sourcePlaceholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json"
564
+ /**
565
+ * GET the durable job status: the currently active job (if any) plus a bounded
566
+ * list of recent jobs. This is what the panel reads BEFORE running discovery
567
+ * on open, so an in-flight install/runtime update is resumed instead of
568
+ * forgotten. An older hq-edge without the status route 404s — callers catch
569
+ * that and degrade to "no active job".
570
+ */
571
+ function fetchJobStatus() {
572
+ return request("/status").then(normalizeJobStatus);
573
+ }
574
+ //#endregion
575
+ //#region src/client/copy.ts
576
+ /**
577
+ * Localized copy for the plugin-update UI (browser half).
578
+ *
579
+ * zh is the fallback, matching the rest of the Huaqiu plugin family
580
+ * (`<html lang>` drives the resolution via the shared sidebar-entry locale
581
+ * helpers). The wording follows the shell's own plugin manager
582
+ * (`ui-plugin-manager/src/client/locales.ts`) so both read as one product.
583
+ *
584
+ * @module
585
+ */
586
+ const zh = {
587
+ entryLabel: "插件更新",
588
+ entryTooltip: "查看可用的插件更新",
589
+ viewTitle: "插件更新",
590
+ viewIntro: "检查并安装可用的插件更新",
591
+ viewInfo: "在这里查看插件集的更新。标注为「被阻止」的插件因主机或 HQ Edge 版本不兼容而无法安装;已是最新的插件无需操作。",
592
+ refresh: "刷新",
593
+ close: "关闭",
594
+ loading: "正在读取插件更新…",
595
+ empty: "没有可用的插件更新",
596
+ checkFailed: "检查更新失败,请重试",
597
+ installAll: (count) => `安装整套更新(${count} 项)`,
598
+ installOne: "安装整套",
599
+ installOneTip: "将安装整套 N 项更新并自动重启 DSH,而不仅是此插件",
600
+ installing: "正在安装…(DSH 将自动重启)",
601
+ retry: "重试",
602
+ checkedAt: (when) => `上次检查:${when}`,
603
+ checkFailedBanner: (message) => `检查更新失败:${message}`,
604
+ staleNote: "以下为上次成功检查的结果,可能已过期",
605
+ nothingToInstall: "暂无可安装的更新",
606
+ ignore: "忽略此版本",
607
+ unignore: "恢复提醒",
608
+ ignoredOffer: "已忽略当前插件集更新",
609
+ groupCount: (count) => `${count} 个插件`,
610
+ group: {
611
+ actionable: "待处理",
612
+ blocked: "无法安装",
613
+ ignored: "已忽略",
614
+ current: "已是最新"
615
+ },
616
+ state: {
617
+ current: "当前",
618
+ update: "可更新",
619
+ install: "待安装",
620
+ ignored: "已忽略",
621
+ blocked: "被阻止"
622
+ },
623
+ blockedReason: {
624
+ host: "不适用于当前 EDA 主机",
625
+ "hq-edge-version": "当前 HQ Edge 版本不兼容",
626
+ "runtime-unknown": "HQ Edge 版本未知,无法校验"
627
+ },
628
+ runtime: {
629
+ title: "HQ Edge 运行时",
630
+ current: "当前版本",
631
+ latest: "最新版本",
632
+ set: "发布插件集",
633
+ checkedAt: "上次检查",
634
+ source: "更新源",
635
+ unknown: "版本未知",
636
+ notConfigured: "未配置更新源",
637
+ note: "运行时更新由 EDA 安装器处理",
638
+ sizeLabel: "包大小",
639
+ update: "安装运行时更新",
640
+ updateConfirm: (version) => `安装 HQ Edge 运行时 ${version}?HQ Edge 会先关闭并完成替换,然后重新启动。`,
641
+ updating: "正在下载并校验运行时包…",
642
+ durationNote: "耗时取决于下载速度与包体大小;运行时包较大,替换后 HQ Edge 会自动重启。",
643
+ updateAccepted: "运行时更新已受理,HQ Edge 正在重启…",
644
+ size: (bytes) => `约 ${formatMb(bytes)}`,
645
+ lastResult: {
646
+ title: "上次运行时更新结果",
647
+ succeeded: "运行时更新成功",
648
+ failed: "运行时更新失败",
649
+ rolledBack: "运行时更新已回滚(已恢复旧版本)",
650
+ elevation: "系统级安装可能弹出 UAC 提权窗口;拒绝提权会导致替换失败并自动回滚到旧版本。"
522
651
  },
523
- pluginSource: {
524
- title: "插件更新源",
525
- hint: "插件更新清单的下载地址,保存后立即生效",
526
- edit: "修改",
527
- save: "保存",
528
- reset: "恢复默认",
529
- saved: "更新源已保存,正在重新检查更新…",
530
- saveFailed: "保存更新源失败",
531
- inputLabel: "插件更新清单地址",
532
- placeholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json"
652
+ sourceHint: "运行时更新清单的下载地址,保存后立即生效",
653
+ sourceEdit: "修改",
654
+ sourceSave: "保存",
655
+ sourceReset: "恢复默认",
656
+ saved: "更新源已保存,正在重新检查更新…",
657
+ saveFailed: "保存更新源失败",
658
+ sourceInputLabel: "运行时更新清单地址",
659
+ sourcePlaceholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/edge-headless-release-manifest.json"
660
+ },
661
+ pluginSource: {
662
+ title: "插件更新源",
663
+ hint: "插件更新清单的下载地址,保存后立即生效",
664
+ edit: "修改",
665
+ save: "保存",
666
+ reset: "恢复默认",
667
+ saved: "更新源已保存,正在重新检查更新…",
668
+ saveFailed: "保存更新源失败",
669
+ inputLabel: "插件更新清单地址",
670
+ placeholder: "https://gitee.com/huaqiu-electronics/dsh-pcb-eda/raw/main/plugins-release-manifest.json"
671
+ },
672
+ originOverride: "自定义",
673
+ originConfig: "默认配置",
674
+ originUnset: "未配置",
675
+ lastError: "上次检查失败",
676
+ installError: "安装失败",
677
+ installSuccess: "更新已安装,正在重启 DSH…",
678
+ confirmInstall: "确认安装",
679
+ cancel: "取消",
680
+ job: {
681
+ kindPluginSet: "插件集更新",
682
+ kindRuntime: "HQ Edge 运行时更新",
683
+ phase: {
684
+ queued: "排队中",
685
+ resolving: "解析版本",
686
+ downloading: "下载中",
687
+ verifying: "校验完整性",
688
+ staging: "暂存文件",
689
+ activating: "启用插件集",
690
+ "awaiting-host": "等待主机替换运行时",
691
+ restarting: "正在重启",
692
+ "verifying-after-restart": "重启后校验",
693
+ done: "已完成",
694
+ failed: "失败",
695
+ "rolled-back": "已回滚",
696
+ abandoned: "已中断"
533
697
  },
534
- originOverride: "自定义",
535
- originConfig: "默认配置",
536
- originUnset: "未配置",
537
- lastError: "上次检查失败",
538
- installError: "安装失败",
539
- installSuccess: "更新已安装,正在重启 DSH…",
540
- confirmInstall: "确认安装",
541
- cancel: "取消"
698
+ willRestartDsh: "完成后 DSH 将自动重启,更新本身会在后台继续。",
699
+ willSwapRuntime: "HQ Edge 会下载完整运行时包并退出,由 EDA 主机替换后重启;系统级安装可能弹出 UAC 提权窗口。",
700
+ closeNote: "关闭此面板不会取消更新。",
701
+ bytes: (done, total) => `${done} / ${total}`,
702
+ speed: (v) => `${v}/s`,
703
+ eta: (s) => `剩余约 ${s}`,
704
+ nextDone: "正在完成,请稍候…",
705
+ resultDone: "更新已完成",
706
+ resultFailed: "更新失败",
707
+ resultRolledBack: "更新已回滚",
708
+ resultAbandoned: "更新未完成",
709
+ retry: "重新尝试(发起新的更新)",
710
+ recentTitle: "最近更新",
711
+ stepsTitle: "各组件进度",
712
+ logHint: (hint) => `日志标识:${hint}`
542
713
  },
714
+ release: {
715
+ summaryTitle: "版本说明",
716
+ notesLink: "查看发布说明",
717
+ breakingTitle: "破坏性变更",
718
+ changesTitle: "更新内容",
719
+ advanced: "高级:更新源",
720
+ publishedAt: (when) => `发布于 ${when}`
721
+ }
722
+ };
723
+ /** Compact a byte count for display: "约 12.3 MB" / "约 480 MB". */
724
+ function formatMb(bytes) {
725
+ if (!Number.isFinite(bytes) || bytes <= 0) return "";
726
+ const mb = bytes / 1048576;
727
+ if (mb >= 100) return `${Math.round(mb)} MB`;
728
+ return `${mb.toFixed(1)} MB`;
729
+ }
730
+ const COPY = {
731
+ zh,
543
732
  en: {
544
733
  entryLabel: "Plugin Updates",
545
734
  entryTooltip: "Review available plugin updates",
@@ -551,9 +740,14 @@ window.__ModuleLoader__.load({
551
740
  loading: "Reading plugin updates…",
552
741
  empty: "No plugin updates available",
553
742
  checkFailed: "Failed to check for updates — please retry",
554
- installAll: (count) => `Install ${count} update${count === 1 ? "" : "s"}`,
555
- installOne: "Install",
743
+ installAll: (count) => `Install whole set (${count})`,
744
+ installOne: "Install set",
745
+ installOneTip: "Installs the whole set of N updates and restarts DSH — not just this plugin",
556
746
  installing: "Installing… (DSH will restart)",
747
+ retry: "Retry",
748
+ checkedAt: (when) => `Last checked: ${when}`,
749
+ checkFailedBanner: (message) => `Update check failed: ${message}`,
750
+ staleNote: "Showing the last successful check — it may be stale",
557
751
  nothingToInstall: "Nothing to install right now",
558
752
  ignore: "Ignore this release",
559
753
  unignore: "Show again",
@@ -587,10 +781,20 @@ window.__ModuleLoader__.load({
587
781
  unknown: "Version unknown",
588
782
  notConfigured: "No update source configured",
589
783
  note: "Runtime updates are handled by the EDA installer",
784
+ sizeLabel: "Package size",
590
785
  update: "Install runtime update",
591
786
  updateConfirm: (version) => `Install HQ Edge runtime ${version}? HQ Edge shuts down to swap the runtime, then restarts.`,
592
- updating: "Downloading and verifying the runtime bundle — this can take a minute…",
787
+ updating: "Downloading and verifying the runtime bundle…",
788
+ durationNote: "Duration depends on download speed and package size; the runtime bundle is large, and HQ Edge restarts after the swap.",
593
789
  updateAccepted: "Runtime update accepted — HQ Edge is restarting…",
790
+ size: (bytes) => `~ ${formatMb(bytes)}`,
791
+ lastResult: {
792
+ title: "Last runtime update result",
793
+ succeeded: "Runtime update succeeded",
794
+ failed: "Runtime update failed",
795
+ rolledBack: "Runtime update rolled back (previous version restored)",
796
+ elevation: "A system-wide install may show a UAC elevation prompt; declining it fails the swap and rolls back to the previous version."
797
+ },
594
798
  sourceHint: "Where the runtime update manifest is fetched from; saved changes take effect immediately",
595
799
  sourceEdit: "Edit",
596
800
  sourceSave: "Save",
@@ -618,7 +822,49 @@ window.__ModuleLoader__.load({
618
822
  installError: "Install failed",
619
823
  installSuccess: "Update installed — restarting DSH…",
620
824
  confirmInstall: "Confirm install",
621
- cancel: "Cancel"
825
+ cancel: "Cancel",
826
+ job: {
827
+ kindPluginSet: "Plugin-set update",
828
+ kindRuntime: "HQ Edge runtime update",
829
+ phase: {
830
+ queued: "Queued",
831
+ resolving: "Resolving version",
832
+ downloading: "Downloading",
833
+ verifying: "Verifying integrity",
834
+ staging: "Staging files",
835
+ activating: "Activating plugin set",
836
+ "awaiting-host": "Waiting for host to swap runtime",
837
+ restarting: "Restarting",
838
+ "verifying-after-restart": "Verifying after restart",
839
+ done: "Done",
840
+ failed: "Failed",
841
+ "rolled-back": "Rolled back",
842
+ abandoned: "Abandoned"
843
+ },
844
+ willRestartDsh: "DSH restarts automatically once this finishes; the update continues in the background.",
845
+ willSwapRuntime: "HQ Edge downloads the full runtime bundle and exits; the EDA host swaps and relaunches it. A system-wide install may show a UAC prompt.",
846
+ closeNote: "Closing this panel does not cancel the update.",
847
+ bytes: (done, total) => `${done} / ${total}`,
848
+ speed: (v) => `${v}/s`,
849
+ eta: (s) => `~${s} left`,
850
+ nextDone: "Finishing — please wait…",
851
+ resultDone: "Update completed",
852
+ resultFailed: "Update failed",
853
+ resultRolledBack: "Update rolled back",
854
+ resultAbandoned: "Update did not complete",
855
+ retry: "Retry (start a new update)",
856
+ recentTitle: "Recent updates",
857
+ stepsTitle: "Per-component progress",
858
+ logHint: (hint) => `Log id: ${hint}`
859
+ },
860
+ release: {
861
+ summaryTitle: "Release notes",
862
+ notesLink: "View release notes",
863
+ breakingTitle: "Breaking changes",
864
+ changesTitle: "What changed",
865
+ advanced: "Advanced: update sources",
866
+ publishedAt: (when) => `Published ${when}`
867
+ }
622
868
  }
623
869
  };
624
870
  //#endregion
@@ -783,13 +1029,15 @@ window.__ModuleLoader__.load({
783
1029
  glyph.innerHTML = PLUGIN_ICON(24);
784
1030
  const main = el("div", "hqpu-card-main");
785
1031
  const titleRow = el("div", "hqpu-title-row");
786
- const title = el("span", "hqpu-card-title", shortName(item.name));
1032
+ const title = el("span", "hqpu-card-title", typeof item.displayName === "string" && item.displayName.trim().length > 0 ? item.displayName.trim() : shortName(item.name));
787
1033
  const tag = el("span", "hqpu-tag");
788
1034
  tag.dataset.tone = stateTone(item);
789
1035
  tag.textContent = stateLabel(item, copy);
790
1036
  titleRow.append(title, tag);
791
- const desc = el("span", "hqpu-card-desc", versionLine(item));
1037
+ const size = formatMb(item.sizeBytes ?? 0);
1038
+ const desc = el("span", "hqpu-card-desc", size.length > 0 ? `${versionLine(item)} · ${size}` : versionLine(item));
792
1039
  main.append(titleRow, desc);
1040
+ if (typeof item.description === "string" && item.description.trim().length > 0) main.append(el("span", "hqpu-card-desc", item.description.trim()));
793
1041
  head.append(glyph, main);
794
1042
  const end = el("div", "hqpu-card-end");
795
1043
  if (item.state === "update" || item.state === "install") {
@@ -797,6 +1045,7 @@ window.__ModuleLoader__.load({
797
1045
  button.type = "button";
798
1046
  button.disabled = installing;
799
1047
  button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`;
1048
+ button.title = copy.installOneTip;
800
1049
  button.addEventListener("click", () => {
801
1050
  runInstall(store, copy);
802
1051
  });
@@ -804,9 +1053,23 @@ window.__ModuleLoader__.load({
804
1053
  }
805
1054
  head.append(end);
806
1055
  card.append(head);
1056
+ if (Array.isArray(item.changes) && item.changes.length > 0) {
1057
+ const expander = el("div", "hqpu-changes");
1058
+ const toggle = el("button", "hqpu-btn hqpu-btn--ghost hqpu-btn--sm", copy.release.changesTitle);
1059
+ toggle.type = "button";
1060
+ const list = el("ul", "hqpu-changes-list");
1061
+ list.style.display = "none";
1062
+ for (const change of item.changes) if (typeof change === "string" && change.length > 0) list.append(el("li", "", change));
1063
+ toggle.addEventListener("click", () => {
1064
+ list.style.display = list.style.display === "none" ? "" : "none";
1065
+ });
1066
+ expander.append(toggle, list);
1067
+ card.append(expander);
1068
+ }
807
1069
  if (item.state === "blocked") {
808
1070
  const reason = blockedReason(item, copy);
809
1071
  if (reason !== void 0) card.append(el("p", "hqpu-card-reason", reason));
1072
+ for (const entry of [...item.reasons ?? [], ...item.notes ?? []]) if (typeof entry === "string" && entry.length > 0) card.append(el("p", "hqpu-card-reason", entry));
810
1073
  }
811
1074
  return card;
812
1075
  }
@@ -859,6 +1122,220 @@ window.__ModuleLoader__.load({
859
1122
  section.setAttribute("aria-label", copy.loading);
860
1123
  return section;
861
1124
  }
1125
+ /** Format a byte count for the progress line, or null when unknown. */
1126
+ function formatBytes(n) {
1127
+ if (n === void 0 || !Number.isFinite(n) || n < 0) return null;
1128
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
1129
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
1130
+ if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`;
1131
+ return `${n} B`;
1132
+ }
1133
+ /** Format an ETA in seconds to a short human string, or null when unknown. */
1134
+ function formatEta(seconds) {
1135
+ if (seconds === void 0 || !Number.isFinite(seconds) || seconds <= 0) return null;
1136
+ if (seconds < 60) return `${Math.round(seconds)} s`;
1137
+ const minutes = Math.round(seconds / 60);
1138
+ if (minutes < 60) return `${minutes} min`;
1139
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
1140
+ }
1141
+ function jobKindLabel(job, copy) {
1142
+ return job.kind === "runtime" ? copy.job.kindRuntime : copy.job.kindPluginSet;
1143
+ }
1144
+ /** The phase in human words, falling back to the raw code. */
1145
+ function phaseLabel(job, copy) {
1146
+ return copy.job.phase[job.phase] ?? job.phase;
1147
+ }
1148
+ /** One per-step line for plugin-set installs (and any job that reports steps). */
1149
+ function stepsList(job, copy) {
1150
+ if (!Array.isArray(job.steps) || job.steps.length === 0) return null;
1151
+ const wrap = el("div", "hqpu-job-steps");
1152
+ wrap.append(el("p", "hqpu-group-count", copy.job.stepsTitle));
1153
+ const ol = el("ol", "hqpu-steps");
1154
+ for (const step of job.steps) {
1155
+ const li = el("li", `hqpu-step hqpu-step--${step.state}`);
1156
+ const dot = el("span", "hqpu-dot");
1157
+ dot.dataset.state = step.state === "installed" ? "done" : step.state === "failed" ? "error" : step.state === "running" ? "warning" : "idle";
1158
+ li.append(dot);
1159
+ const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name;
1160
+ li.append(el("span", "", text));
1161
+ ol.append(li);
1162
+ }
1163
+ wrap.append(ol);
1164
+ return wrap;
1165
+ }
1166
+ /**
1167
+ * The ACTIVE-JOB view: type + target, previous→target, current phase, a
1168
+ * determinate bar only when a real percentage exists, bytes/speed/ETA when the
1169
+ * server publishes them, and what happens next. Closing the panel never
1170
+ * cancels the job — say so.
1171
+ */
1172
+ function activeJobView(job, copy) {
1173
+ const section = el("section", "hqpu-group");
1174
+ section.dataset.group = "active-job";
1175
+ const head = el("div", "hqpu-group-head");
1176
+ const tag = el("span", "hqpu-tag");
1177
+ tag.dataset.tone = "info";
1178
+ tag.textContent = jobKindLabel(job, copy);
1179
+ head.append(el("h3", "hqpu-group-title", `${jobKindLabel(job, copy)} → ${job.targetVersion}`), tag);
1180
+ section.append(head);
1181
+ const card = el("div", "hqpu-job");
1182
+ const phaseLine = el("p", "hqpu-status hqpu-status--dot");
1183
+ phaseLine.append(el("span", "hqpu-spinner"));
1184
+ const transition = job.previousVersion !== null && job.previousVersion.length > 0 ? `${job.previousVersion} → ${job.targetVersion} · ${phaseLabel(job, copy)}` : `${job.targetVersion} · ${phaseLabel(job, copy)}`;
1185
+ phaseLine.append(el("span", "", transition));
1186
+ card.append(phaseLine);
1187
+ if (typeof job.message === "string" && job.message.length > 0) card.append(el("p", "hqpu-status", job.message));
1188
+ const pct = job.progress.percent;
1189
+ if (pct !== null) {
1190
+ const bar = el("div", "hqpu-progress");
1191
+ const fill = el("div", "hqpu-progress-fill");
1192
+ fill.style.width = `${Math.min(100, Math.max(0, pct))}%`;
1193
+ bar.append(fill);
1194
+ card.append(bar);
1195
+ }
1196
+ const bits = [];
1197
+ if (pct !== null) bits.push(`${Math.round(pct)}%`);
1198
+ const done = formatBytes(job.progress.bytesDone);
1199
+ const total = formatBytes(job.progress.bytesTotal);
1200
+ if (done !== null && total !== null) bits.push(copy.job.bytes(done, total));
1201
+ const speed = formatBytes(job.progress.bytesPerSecond);
1202
+ if (speed !== null) bits.push(copy.job.speed(speed));
1203
+ const eta = formatEta(job.progress.etaSeconds);
1204
+ if (eta !== null) bits.push(copy.job.eta(eta));
1205
+ if (bits.length > 0) card.append(el("p", "hqpu-status hqpu-job-meta", bits.join(" · ")));
1206
+ const steps = stepsList(job, copy);
1207
+ if (steps !== null) card.append(steps);
1208
+ card.append(el("p", "hqpu-status", job.kind === "runtime" ? copy.job.willSwapRuntime : copy.job.willRestartDsh));
1209
+ card.append(el("p", "hqpu-status", copy.job.closeNote));
1210
+ section.append(card);
1211
+ return section;
1212
+ }
1213
+ /**
1214
+ * The RESULT view for a terminal job: success / failed / rolled-back /
1215
+ * abandoned, previous→installed, per-package steps, errorCode + message, a log
1216
+ * correlation hint, and retry guidance (a retry starts a NEW job).
1217
+ */
1218
+ function resultView(job, copy, onRetry) {
1219
+ const section = el("section", "hqpu-group");
1220
+ section.dataset.group = "job-result";
1221
+ const tone = job.phase === "done" ? "success" : job.phase === "failed" ? "danger" : "warning";
1222
+ const titleText = job.phase === "done" ? copy.job.resultDone : job.phase === "failed" ? copy.job.resultFailed : job.phase === "rolled-back" ? copy.job.resultRolledBack : copy.job.resultAbandoned;
1223
+ const head = el("div", "hqpu-group-head");
1224
+ const tag = el("span", "hqpu-tag");
1225
+ tag.dataset.tone = tone;
1226
+ tag.textContent = titleText;
1227
+ head.append(el("h3", "hqpu-group-title", `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag);
1228
+ section.append(head);
1229
+ const card = el("div", "hqpu-job");
1230
+ const res = job.result;
1231
+ const from = job.previousVersion ?? res?.installedVersion ?? "—";
1232
+ const to = res?.installedVersion ?? job.targetVersion;
1233
+ const finished = res !== void 0 && res.finishedAt.length > 0 ? ` · ${new Date(res.finishedAt).toLocaleString()}` : "";
1234
+ card.append(el("p", "hqpu-status", `${from} → ${to}${finished}`));
1235
+ if (res !== void 0 && res.message !== void 0 && res.message.length > 0) card.append(el("p", "hqpu-status", res.message));
1236
+ if (res !== void 0 && res.errorCode !== void 0 && res.errorCode.length > 0) card.append(el("p", "hqpu-status", res.errorCode));
1237
+ if (typeof job.logHint === "string" && job.logHint.length > 0) card.append(el("p", "hqpu-status hqpu-job-meta", copy.job.logHint(job.logHint)));
1238
+ const steps = stepsList(job, copy);
1239
+ if (steps !== null) card.append(steps);
1240
+ if (job.phase === "failed" || job.phase === "abandoned") {
1241
+ const retry = el("button", "hqpu-btn hqpu-btn--outline hqpu-btn--sm", copy.job.retry);
1242
+ retry.type = "button";
1243
+ retry.addEventListener("click", onRetry);
1244
+ card.append(retry);
1245
+ }
1246
+ section.append(card);
1247
+ return section;
1248
+ }
1249
+ /** A one-line row for older recent jobs (newest already shown as the card). */
1250
+ function recentJobsList(jobs, copy) {
1251
+ const older = jobs.slice(1, 5);
1252
+ if (older.length === 0) return null;
1253
+ const wrap = el("div", "hqpu-group");
1254
+ wrap.dataset.group = "recent-jobs";
1255
+ wrap.append(el("h3", "hqpu-group-count", copy.job.recentTitle));
1256
+ const ul = el("ul", "hqpu-cards");
1257
+ for (const job of older) {
1258
+ const li = el("li", "hqpu-status");
1259
+ const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : "";
1260
+ li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`;
1261
+ ul.append(li);
1262
+ }
1263
+ wrap.append(ul);
1264
+ return wrap;
1265
+ }
1266
+ /**
1267
+ * Check-status banner: a FAILED check shows a clear error row + Retry (never
1268
+ * the silent "no updates" empty state); a successful check shows the time;
1269
+ * when a check failed but a prior report exists it is labelled stale.
1270
+ */
1271
+ function checkBanner(report, copy, onRetry) {
1272
+ const failed = typeof report.lastError === "string" && report.lastError.length > 0;
1273
+ const checked = typeof report.checkedAt === "string" && report.checkedAt.length > 0;
1274
+ if (!failed && !checked) return null;
1275
+ const wrap = el("section", "hqpu-group");
1276
+ wrap.dataset.group = "check-status";
1277
+ if (failed) {
1278
+ const banner = el("div", "hqpu-banner");
1279
+ banner.dataset.tone = "danger";
1280
+ banner.innerHTML = WARNING_ICON(14);
1281
+ banner.append(el("span", "", copy.checkFailedBanner(report.lastError)));
1282
+ wrap.append(banner);
1283
+ if (report.items.length > 0) wrap.append(el("p", "hqpu-status", copy.staleNote));
1284
+ const row = el("div", "hqpu-actions");
1285
+ const retry = el("button", "hqpu-btn hqpu-btn--primary hqpu-btn--sm", copy.retry);
1286
+ retry.type = "button";
1287
+ retry.addEventListener("click", onRetry);
1288
+ row.append(retry);
1289
+ wrap.append(row);
1290
+ return wrap;
1291
+ }
1292
+ wrap.append(el("p", "hqpu-status", copy.checkedAt(new Date(report.checkedAt).toLocaleString())));
1293
+ return wrap;
1294
+ }
1295
+ /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
1296
+ function releaseHeader(release, copy) {
1297
+ if (release === null || release === void 0) return null;
1298
+ const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === "string" && n.length > 0) : [];
1299
+ const breaking = Array.isArray(release.breaking) ? release.breaking.filter((n) => typeof n === "string" && n.length > 0) : [];
1300
+ if (!(typeof release.summary === "string" && release.summary.length > 0 || notes.length > 0 || breaking.length > 0 || typeof release.notesUrl === "string" || typeof release.publishedAt === "string")) return null;
1301
+ const section = el("section", "hqpu-group");
1302
+ section.dataset.group = "release";
1303
+ section.append(el("h3", "hqpu-group-title", copy.release.summaryTitle));
1304
+ if (typeof release.summary === "string" && release.summary.length > 0) section.append(el("p", "hqpu-status", release.summary));
1305
+ if (typeof release.publishedAt === "string" && release.publishedAt.length > 0) section.append(el("p", "hqpu-status", copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())));
1306
+ if (release.requiresRestart === true) section.append(el("p", "hqpu-status", copy.job.willRestartDsh));
1307
+ if (notes.length > 0) {
1308
+ const ul = el("ul", "hqpu-release-notes");
1309
+ for (const note of notes) ul.append(el("li", "", note));
1310
+ section.append(ul);
1311
+ }
1312
+ if (breaking.length > 0) {
1313
+ const banner = el("div", "hqpu-banner");
1314
+ banner.dataset.tone = "danger";
1315
+ banner.innerHTML = WARNING_ICON(14);
1316
+ const inner = el("span");
1317
+ inner.append(el("strong", "", `${copy.release.breakingTitle}: `));
1318
+ inner.append(document.createTextNode(breaking.join(" · ")));
1319
+ banner.append(inner);
1320
+ section.append(banner);
1321
+ }
1322
+ if (typeof release.notesUrl === "string" && release.notesUrl.length > 0) {
1323
+ const link = el("a", "hqpu-status hqpu-release-link", copy.release.notesLink);
1324
+ link.href = release.notesUrl;
1325
+ link.target = "_blank";
1326
+ link.rel = "noreferrer";
1327
+ section.append(link);
1328
+ }
1329
+ return section;
1330
+ }
1331
+ /** Native disclosure that hides the developer-facing source editors by
1332
+ * default (end users should never need to type a manifest URL). */
1333
+ function advancedBlock(copy, nodes) {
1334
+ const details = el("details", "hqpu-advanced");
1335
+ details.append(el("summary", "", copy.release.advanced));
1336
+ for (const node of nodes) if (node !== null) details.append(node);
1337
+ return details;
1338
+ }
862
1339
  /**
863
1340
  * Normalize the report's `runtime.source` to the full shape.
864
1341
  *
@@ -1032,13 +1509,18 @@ window.__ModuleLoader__.load({
1032
1509
  else row(copy.runtime.source, copy.runtime.notConfigured);
1033
1510
  } else {
1034
1511
  row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown);
1035
- if (runtime.updateAvailable && latestVersion !== null) row(copy.runtime.latest, latestVersion);
1512
+ if (runtime.updateAvailable && latestVersion !== null) {
1513
+ row(copy.runtime.latest, latestVersion);
1514
+ const latestEntry = runtime.latest;
1515
+ const size = typeof latestEntry?.sizeBytes === "number" ? formatMb(latestEntry.sizeBytes) : "";
1516
+ if (size.length > 0) row(copy.runtime.sizeLabel, size);
1517
+ }
1036
1518
  if (report.manifestVersion !== null) row(copy.runtime.set, report.manifestVersion, true);
1037
1519
  if (typeof runtime.checkedAt === "string" && runtime.checkedAt.length > 0) row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString());
1038
1520
  if (source !== null) sourceFactRow(facts, source, copy);
1039
1521
  }
1040
1522
  section.append(facts);
1041
- if (source !== null) section.append(sourceEditor("runtime", source, copy, actions));
1523
+ if (source !== null) section.append(advancedBlock(copy, [sourceEditor("runtime", source, copy, actions)]));
1042
1524
  if (runtime.updateAvailable && latestVersion !== null) {
1043
1525
  const row = el("div", "hqpu-actions hqpu-runtime-actions");
1044
1526
  if (actions.accepted) {
@@ -1070,7 +1552,25 @@ window.__ModuleLoader__.load({
1070
1552
  row.append(start);
1071
1553
  }
1072
1554
  section.append(row);
1555
+ section.append(el("p", "hqpu-status hqpu-job-meta", copy.runtime.durationNote));
1073
1556
  } else section.append(el("p", "hqpu-status", copy.runtime.note));
1557
+ const lastResult = runtime.lastResult;
1558
+ if (lastResult !== null && lastResult !== void 0) {
1559
+ const state = lastResult.state;
1560
+ const line = el("p", "hqpu-status hqpu-status--dot");
1561
+ line.setAttribute("role", "status");
1562
+ const dot = el("span", "hqpu-dot");
1563
+ dot.dataset.state = state === "succeeded" ? "done" : state === "failed" ? "error" : "warning";
1564
+ const parts = [state === "succeeded" ? copy.runtime.lastResult.succeeded : state === "failed" ? copy.runtime.lastResult.failed : copy.runtime.lastResult.rolledBack];
1565
+ if (lastResult.targetVersion) parts.push(`→ ${lastResult.targetVersion}`);
1566
+ if (lastResult.installedVersion) parts.push(`(${lastResult.installedVersion})`);
1567
+ if (lastResult.at) parts.push(new Date(lastResult.at).toLocaleString());
1568
+ line.append(dot, el("span", "", parts.join(" ")));
1569
+ section.append(line);
1570
+ if (typeof lastResult.message === "string" && lastResult.message.length > 0) section.append(el("p", "hqpu-status", lastResult.message));
1571
+ if (typeof lastResult.errorCode === "string" && lastResult.errorCode.length > 0) section.append(el("p", "hqpu-status hqpu-job-meta", lastResult.errorCode));
1572
+ if (state !== "succeeded") section.append(el("p", "hqpu-status hqpu-job-meta", copy.runtime.lastResult.elevation));
1573
+ }
1074
1574
  if (typeof runtime.lastError === "string" && runtime.lastError.length > 0) {
1075
1575
  const line = el("p", "hqpu-status hqpu-status--dot");
1076
1576
  line.setAttribute("role", "status");
@@ -1081,14 +1581,18 @@ window.__ModuleLoader__.load({
1081
1581
  }
1082
1582
  return section;
1083
1583
  }
1084
- function renderList(section, report, copy, installing, store) {
1584
+ function renderList(section, report, copy, installing, store, onRetryCheck) {
1085
1585
  section.textContent = "";
1086
1586
  if (report === null) {
1087
1587
  section.append(skeleton(copy));
1088
1588
  return;
1089
1589
  }
1590
+ const banner = checkBanner(report, copy, onRetryCheck);
1591
+ if (banner !== null) section.append(banner);
1592
+ const release = releaseHeader(report.release, copy);
1593
+ if (release !== null) section.append(release);
1090
1594
  if (report.items.length === 0) {
1091
- section.append(el("div", "hqpu-empty", copy.empty));
1595
+ if (typeof report.lastError !== "string" || report.lastError.length === 0) section.append(el("div", "hqpu-empty", copy.empty));
1092
1596
  return;
1093
1597
  }
1094
1598
  const byState = (state) => report.items.filter((item) => item.state === state);
@@ -1108,14 +1612,16 @@ window.__ModuleLoader__.load({
1108
1612
  if (report === null) return;
1109
1613
  section.append(runtimeGroup(report, copy, actions));
1110
1614
  }
1111
- /** The plugin-source group, above the plugin groups. No-op without the field. */
1615
+ /** The plugin-source group, above the plugin groups, hidden behind an advanced
1616
+ * disclosure (manifest-URL editing is a developer affordance). No-op without
1617
+ * the field. */
1112
1618
  function renderPluginSource(section, report, copy, actions) {
1113
1619
  section.textContent = "";
1114
1620
  if (report === null) return;
1115
1621
  const node = pluginSourceGroup(report, copy, actions);
1116
- if (node !== null) section.append(node);
1622
+ if (node !== null) section.append(advancedBlock(copy, [node]));
1117
1623
  }
1118
- /** Run the install transaction, then keep polling until the state settles. */
1624
+ /** Run the install transaction (a new durable job), then let the store poll it. */
1119
1625
  async function runInstall(store, copy) {
1120
1626
  const report = store.report();
1121
1627
  if (report === null) return;
@@ -1123,15 +1629,18 @@ window.__ModuleLoader__.load({
1123
1629
  showToast(copy.checkFailed, "error");
1124
1630
  return;
1125
1631
  }
1632
+ store.setInstalling(true);
1126
1633
  try {
1127
1634
  const result = await installSet(report.manifestVersion);
1128
1635
  if (result.error !== void 0) {
1636
+ store.setInstalling(false);
1129
1637
  showToast(`${copy.installError}: ${result.error}`, "error");
1130
1638
  return;
1131
1639
  }
1132
1640
  showToast(copy.installSuccess, "success");
1133
- pollUntilSettled(store);
1641
+ store.fetchStatus();
1134
1642
  } catch (err) {
1643
+ store.setInstalling(false);
1135
1644
  showToast(`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`, "error");
1136
1645
  }
1137
1646
  }
@@ -1194,22 +1703,6 @@ window.__ModuleLoader__.load({
1194
1703
  bar.append(ignore);
1195
1704
  }
1196
1705
  }
1197
- /** After an install, poll the backend until the update report settles
1198
- * (installing flag clears or DSH restarts under us — the next load re-reads
1199
- * the fresh state). */
1200
- function pollUntilSettled(store) {
1201
- let attempts = 0;
1202
- const tick = () => {
1203
- store.refresh().then((report) => {
1204
- if (store.installing()) return;
1205
- if (report !== null && report.ignoredSet === false && attempts >= 2) return;
1206
- }).catch(() => void 0).finally(() => {
1207
- attempts += 1;
1208
- if (attempts < 10) window.setTimeout(tick, 2e3);
1209
- });
1210
- };
1211
- tick();
1212
- }
1213
1706
  /**
1214
1707
  * Open (or focus) the update panel. Re-renders from the store; when the store
1215
1708
  * is stale the panel first refreshes the report.
@@ -1256,11 +1749,12 @@ window.__ModuleLoader__.load({
1256
1749
  panel.append(head);
1257
1750
  const scroll = el("div", "hqpu-scroll");
1258
1751
  const column = el("div", "hqpu-column");
1752
+ const jobSection = el("div", "hqpu-stack");
1259
1753
  const pluginSourceSection = el("div", "hqpu-stack");
1260
1754
  const listSection = el("div", "hqpu-stack");
1261
1755
  const runtimeSection = el("div", "hqpu-stack");
1262
1756
  const toastSection = el("div", "hqpu-toast-host");
1263
- column.append(pluginSourceSection, listSection, runtimeSection, toastSection);
1757
+ column.append(jobSection, pluginSourceSection, listSection, runtimeSection, toastSection);
1264
1758
  scroll.append(column);
1265
1759
  panel.append(scroll);
1266
1760
  const footer = el("div", "hqpu-footer");
@@ -1403,6 +1897,7 @@ window.__ModuleLoader__.load({
1403
1897
  }
1404
1898
  runtimeAccepted = true;
1405
1899
  showToast(copy.runtime.updateAccepted, "success");
1900
+ store.fetchStatus();
1406
1901
  } catch (err) {
1407
1902
  showToast(`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`, "error");
1408
1903
  } finally {
@@ -1410,15 +1905,43 @@ window.__ModuleLoader__.load({
1410
1905
  render();
1411
1906
  }
1412
1907
  }
1908
+ /** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
1909
+ * re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
1910
+ * settled job id. */
1911
+ const retryJob = (kind) => {
1912
+ if (kind === "runtime") {
1913
+ runtimeArmed = true;
1914
+ render();
1915
+ } else runInstall(store, copy);
1916
+ };
1917
+ const renderJobSection = () => {
1918
+ jobSection.textContent = "";
1919
+ const active = store.activeJob();
1920
+ if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1921
+ jobSection.append(activeJobView(active, copy));
1922
+ return;
1923
+ }
1924
+ const recent = store.recentJobs();
1925
+ const latest = recent[0];
1926
+ if (latest !== void 0 && TERMINAL_PHASES.has(latest.phase)) {
1927
+ jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)));
1928
+ const older = recentJobsList(recent, copy);
1929
+ if (older !== null) jobSection.append(older);
1930
+ }
1931
+ };
1413
1932
  const render = () => {
1414
1933
  const report = store.report();
1415
- const installing = store.installing();
1934
+ const jobActive = store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob().phase);
1935
+ const installing = store.installing() || jobActive;
1416
1936
  const stale = report === null;
1417
1937
  refreshBtn.disabled = stale;
1938
+ renderJobSection();
1418
1939
  if (stale) {
1419
1940
  listSection.textContent = "";
1420
1941
  listSection.append(skeleton(copy));
1421
- } else renderList(listSection, report, copy, installing, store);
1942
+ } else renderList(listSection, report, copy, installing, store, () => {
1943
+ store.check();
1944
+ });
1422
1945
  renderPluginSource(pluginSourceSection, report, copy, pluginActions);
1423
1946
  renderRuntime(runtimeSection, report, copy, runtimeActions);
1424
1947
  renderActions(actionBar, report, copy, store, installing);
@@ -2202,6 +2725,97 @@ window.__ModuleLoader__.load({
2202
2725
  padding: 0 24px 20px;
2203
2726
  }
2204
2727
 
2728
+ /* ── Durable job (active / result) ────────────────────────────────────── */
2729
+ .hqpu-job {
2730
+ display: flex;
2731
+ flex-direction: column;
2732
+ gap: 6px;
2733
+ padding: 12px;
2734
+ border: 0.5px solid var(--dsw-alias-border-l2);
2735
+ border-radius: var(--dsw-radius-lg);
2736
+ background: var(--dsw-alias-bg-layer-3);
2737
+ }
2738
+ .hqpu-job-meta {
2739
+ font-variant-numeric: tabular-nums;
2740
+ }
2741
+ .hqpu-progress {
2742
+ height: 6px;
2743
+ border-radius: 999px;
2744
+ background: var(--dsw-alias-bg-module-platform);
2745
+ overflow: hidden;
2746
+ }
2747
+ .hqpu-progress-fill {
2748
+ height: 100%;
2749
+ border-radius: 999px;
2750
+ background: var(--dsw-alias-state-business-primary);
2751
+ transition: width var(--ds-transition-duration, 0.2s) ease;
2752
+ }
2753
+ .hqpu-job-steps {
2754
+ display: flex;
2755
+ flex-direction: column;
2756
+ gap: 4px;
2757
+ }
2758
+ .hqpu-steps {
2759
+ margin: 0;
2760
+ padding: 0;
2761
+ list-style: none;
2762
+ display: flex;
2763
+ flex-direction: column;
2764
+ gap: 4px;
2765
+ }
2766
+ .hqpu-step {
2767
+ display: flex;
2768
+ align-items: center;
2769
+ gap: 6px;
2770
+ font-size: 12.5px;
2771
+ line-height: 18px;
2772
+ color: var(--dsw-alias-label-secondary);
2773
+ }
2774
+ .hqpu-step--skipped,
2775
+ .hqpu-step--pending { opacity: 0.6; }
2776
+
2777
+ /* ── Per-plugin change list + release notes ────────────────────────────── */
2778
+ .hqpu-changes {
2779
+ padding: 0 8px 8px 70px;
2780
+ }
2781
+ .hqpu-changes-list,
2782
+ .hqpu-release-notes {
2783
+ margin: 4px 0 0;
2784
+ padding-left: 18px;
2785
+ font-size: 12.5px;
2786
+ line-height: 19px;
2787
+ color: var(--dsw-alias-label-secondary);
2788
+ }
2789
+ .hqpu-release-link {
2790
+ align-self: flex-start;
2791
+ }
2792
+
2793
+ /* ── Advanced disclosure (source editors) ───────────────────────────────── */
2794
+ .hqpu-advanced > summary {
2795
+ cursor: pointer;
2796
+ font-size: 12.5px;
2797
+ line-height: 20px;
2798
+ color: var(--dsw-alias-label-tertiary);
2799
+ list-style: none;
2800
+ padding: 2px 0;
2801
+ }
2802
+ .hqpu-advanced > summary::-webkit-details-marker { display: none; }
2803
+ .hqpu-advanced > summary::before {
2804
+ content: '▸ ';
2805
+ }
2806
+ .hqpu-advanced[open] > summary::before {
2807
+ content: '▾ ';
2808
+ }
2809
+
2810
+ /* Danger-toned banner (check failure, breaking changes) */
2811
+ .hqpu-banner[data-tone='danger'] {
2812
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
2813
+ color: var(--dsw-alias-label-secondary);
2814
+ }
2815
+ .hqpu-banner[data-tone='danger'] > svg {
2816
+ color: var(--dsw-alias-state-error-primary);
2817
+ }
2818
+
2205
2819
  .hqpu-visually-hidden {
2206
2820
  position: absolute;
2207
2821
  width: 1px;
@@ -2346,33 +2960,114 @@ window.__ModuleLoader__.load({
2346
2960
  var UpdateStore = class {
2347
2961
  current = null;
2348
2962
  installingFlag = false;
2963
+ /** The durable job the server currently reports as active (resumed across
2964
+ * panel close and DSH restart); null when idle. */
2965
+ active = null;
2966
+ recent = [];
2349
2967
  listeners = /* @__PURE__ */ new Set();
2350
2968
  refreshTimer = null;
2969
+ pollTimer = null;
2970
+ pollBackoffMs = 1e3;
2971
+ pollInFlight = false;
2351
2972
  report() {
2352
2973
  return this.current;
2353
2974
  }
2354
2975
  installing() {
2355
2976
  return this.installingFlag;
2356
2977
  }
2978
+ activeJob() {
2979
+ return this.active;
2980
+ }
2981
+ recentJobs() {
2982
+ return [...this.recent];
2983
+ }
2357
2984
  subscribe(listener) {
2358
2985
  this.listeners.add(listener);
2359
2986
  return () => {
2360
2987
  this.listeners.delete(listener);
2361
2988
  };
2362
2989
  }
2363
- /** Badge count per spec §6.3: actionable `update` + `install`, excluding the
2364
- * ignored offer (the server already folds ignore into item states; keep the
2365
- * client-side filter as a defensive second pass). */
2990
+ /**
2991
+ * Badge: counts actionable plugin updates AND an available runtime update AND
2992
+ * (while a job runs) an attention pill. A recent failed/rolled-back job with
2993
+ * nothing actionable left also keeps a dot, so the outcome is never silently
2994
+ * forgotten after the panel closes.
2995
+ */
2366
2996
  badgeCount() {
2997
+ const job = this.active;
2998
+ if (job !== null && !TERMINAL_PHASES.has(job.phase)) return 1;
2367
2999
  const report = this.current;
2368
3000
  if (report === null) return 0;
2369
- return report.items.filter((item) => item.state === "update" || item.state === "install").length;
3001
+ let count = report.items.filter((item) => item.state === "update" || item.state === "install").length;
3002
+ if (report.runtime?.updateAvailable) count += 1;
3003
+ if (count === 0) {
3004
+ const latest = this.recent[0];
3005
+ if (latest !== void 0 && (latest.phase === "failed" || latest.phase === "rolled-back")) count = 1;
3006
+ }
3007
+ return count;
2370
3008
  }
2371
3009
  emit() {
2372
3010
  for (const listener of [...this.listeners]) try {
2373
3011
  listener();
2374
3012
  } catch {}
2375
3013
  }
3014
+ /** Adopt a fresh `/status` snapshot and (re)arm the poll loop. */
3015
+ applyStatus(status) {
3016
+ const wasActive = this.active !== null;
3017
+ this.active = status.activeJob;
3018
+ this.recent = status.recentJobs;
3019
+ if (wasActive && this.active === null) this.installingFlag = false;
3020
+ this.emit();
3021
+ this.schedulePoll();
3022
+ }
3023
+ /**
3024
+ * Fetch the durable job status. Called on mount, on panel open (BEFORE
3025
+ * discovery) and by the poll loop. A failure (older hq-edge with no
3026
+ * `/status`, or a transient HTTP error) is swallowed: the last known job is
3027
+ * kept and the poller backs off and retries.
3028
+ */
3029
+ async fetchStatus() {
3030
+ try {
3031
+ const status = await fetchJobStatus();
3032
+ this.pollBackoffMs = 1e3;
3033
+ this.applyStatus(status);
3034
+ } catch {
3035
+ this.schedulePoll();
3036
+ }
3037
+ }
3038
+ /** ~1 s poll while a job is active; stop on terminal; back off on error
3039
+ * without discarding the last known job; never overlap requests. */
3040
+ schedulePoll() {
3041
+ if (this.pollTimer !== null) {
3042
+ window.clearTimeout(this.pollTimer);
3043
+ this.pollTimer = null;
3044
+ }
3045
+ const job = this.active;
3046
+ if (job === null || TERMINAL_PHASES.has(job.phase)) {
3047
+ this.pollBackoffMs = 1e3;
3048
+ return;
3049
+ }
3050
+ this.pollTimer = window.setTimeout(() => {
3051
+ this.pollTick();
3052
+ }, this.pollBackoffMs);
3053
+ }
3054
+ async pollTick() {
3055
+ if (this.pollInFlight) {
3056
+ this.schedulePoll();
3057
+ return;
3058
+ }
3059
+ this.pollInFlight = true;
3060
+ try {
3061
+ const status = await fetchJobStatus();
3062
+ this.pollBackoffMs = 1e3;
3063
+ this.applyStatus(status);
3064
+ } catch {
3065
+ this.pollBackoffMs = Math.min(5e3, Math.max(1e3, this.pollBackoffMs * 2));
3066
+ this.schedulePoll();
3067
+ } finally {
3068
+ this.pollInFlight = false;
3069
+ }
3070
+ }
2376
3071
  async refresh() {
2377
3072
  try {
2378
3073
  const report = await fetchReport();
@@ -2410,6 +3105,10 @@ window.__ModuleLoader__.load({
2410
3105
  window.clearInterval(this.refreshTimer);
2411
3106
  this.refreshTimer = null;
2412
3107
  }
3108
+ if (this.pollTimer !== null) {
3109
+ window.clearTimeout(this.pollTimer);
3110
+ this.pollTimer = null;
3111
+ }
2413
3112
  this.listeners.clear();
2414
3113
  }
2415
3114
  };
@@ -2450,9 +3149,23 @@ window.__ModuleLoader__.load({
2450
3149
  });
2451
3150
  const openView = () => {
2452
3151
  ensureDesignTokens();
2453
- store.check().then((report) => {
2454
- if (report !== null) openUpdateView(store, copyFor());
2455
- });
3152
+ store.fetchStatus();
3153
+ openUpdateView(store, copyFor());
3154
+ store.check();
3155
+ };
3156
+ const statusLabel = () => {
3157
+ const base = entryCopy(locale);
3158
+ const job = store.activeJob();
3159
+ if (job !== null) {
3160
+ const cc = copyFor();
3161
+ const pct = job.progress.percent;
3162
+ const phase = cc.job.phase[job.phase] ?? job.phase;
3163
+ return {
3164
+ label: pct !== null ? `${base.label} · ${Math.round(pct)}%` : `${base.label} · ${phase}`,
3165
+ tooltip: `${base.tooltip} · ${phase}`
3166
+ };
3167
+ }
3168
+ return base;
2456
3169
  };
2457
3170
  const disposeEntry = mountSidebarEntry({
2458
3171
  rowAttribute: UPDATE_ENTRY_ATTRIBUTE,
@@ -2460,8 +3173,8 @@ window.__ModuleLoader__.load({
2460
3173
  plugin: PLUGIN_ID,
2461
3174
  icon: UPDATE_ICON,
2462
3175
  css: ENTRY_CSS,
2463
- label: () => entryCopy(locale).label,
2464
- tooltip: () => entryCopy(locale).tooltip,
3176
+ label: () => statusLabel().label,
3177
+ tooltip: () => statusLabel().tooltip,
2465
3178
  refresh: { subscribe: refreshSubscribe },
2466
3179
  badge: {
2467
3180
  subscribe: (listener) => store.subscribe(listener),
@@ -2471,8 +3184,14 @@ window.__ModuleLoader__.load({
2471
3184
  position: "after",
2472
3185
  familySelectors: HUAQIU_SIDEBAR_ENTRY_FAMILY
2473
3186
  });
3187
+ store.subscribe(() => {
3188
+ for (const fn of [...localeSubscribers]) try {
3189
+ fn();
3190
+ } catch {}
3191
+ });
2474
3192
  store.startIdleRefresh();
2475
3193
  store.refresh();
3194
+ store.fetchStatus();
2476
3195
  const dispose = () => {
2477
3196
  try {
2478
3197
  disposeEntry();