@huaqiu/dsh-tool-plugin-update 0.6.13 → 0.6.16
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 +2429 -1806
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +10 -0
- package/package.json +3 -3
- package/src/client/api.ts +19 -0
- package/src/client/badge.ts +59 -0
- package/src/client/copy.ts +171 -103
- package/src/client/icons.ts +45 -0
- package/src/client/index.ts +1156 -1010
- package/src/client/update-view.ts +1397 -1186
- package/src/client/version.ts +36 -3
- package/src/routes.ts +23 -0
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin-update review view (browser half).
|
|
2
|
+
* Plugin-update review view (browser half) — the "更新中心" panel.
|
|
3
3
|
*
|
|
4
|
-
* Plain-DOM
|
|
5
|
-
* reconciliation — same rule as the sidebar entry rows.
|
|
4
|
+
* Plain-DOM (no React tree) so it can never disturb the DSH shell's
|
|
5
|
+
* reconciliation — the same rule as the sidebar entry rows. The layout follows
|
|
6
|
+
* `docs/prototype/plugin_update_ui_ux_prototype.html`; the reasoning behind the
|
|
7
|
+
* order is in `docs/report/plugin-update-ui-ux-baseline.md`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* - state-grouped sections, each a group head + package cards
|
|
9
|
-
* (48px hairline-framed glyph, title row with a state tag, one-line
|
|
10
|
-
* version transition, end actions) — the shell's card geometry
|
|
11
|
-
* - a facts group for the independent HQ Edge runtime report
|
|
12
|
-
* - footer actions (install / ignore / show again), with a toast that
|
|
13
|
-
* reports the outcome of an install the way the shell's Toast does
|
|
9
|
+
* Information architecture, top to bottom, and why:
|
|
14
10
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. **header** — brand, title, `版本集 vX · N 分钟前`, and the four icon
|
|
12
|
+
* actions (refresh · sources · job log · close). The set version and the
|
|
13
|
+
* relative check time live here exactly once; the body never repeats them.
|
|
14
|
+
* 2. **settings strip** (gear) — the two manifest URLs, editable in place.
|
|
15
|
+
* 3. **check-failed banner** — an error, never the silent empty state.
|
|
16
|
+
* 4. **active job card** — pulsing title + percent + bar + telemetry +
|
|
17
|
+
* 4-step pipeline, resumed on open and polled to its end.
|
|
18
|
+
* 5. **failed-result banner** — error code + Retry + 查看日志. The full log
|
|
19
|
+
* lives in the header's drawer; the *outcome* must not hide behind it.
|
|
20
|
+
* 6. **decision band** — one line: how many plugins, and whether the runtime
|
|
21
|
+
* is separately upgradable. This is the "what do I have to decide" row.
|
|
22
|
+
* 7. **actionable cards** — 单独更新 / 忽略 promoted onto each card; the
|
|
23
|
+
* collapsible drawer keeps only the changelog.
|
|
24
|
+
* 8. **runtime card** — amber, because a swap is not a plugin install.
|
|
25
|
+
* 9. **已忽略 / 已是最新 / 版本说明** — collapsed by default: nothing that
|
|
26
|
+
* needs no action may sit above something that does.
|
|
27
|
+
* 10. **footer** — the primary CTA plus its honest meta line, both reactive to
|
|
28
|
+
* every state (idle / installing / failed check / up to date).
|
|
29
|
+
*
|
|
30
|
+
* The job log is a drawer inside the panel (`.hqpu-log`), not an inline block:
|
|
31
|
+
* a full set lists one row per component, which as inline content pushed every
|
|
32
|
+
* actionable card below the fold.
|
|
17
33
|
*
|
|
18
34
|
* @module
|
|
19
35
|
*/
|
|
@@ -24,6 +40,7 @@ import {
|
|
|
24
40
|
installSet,
|
|
25
41
|
requestRuntimeUpdate,
|
|
26
42
|
runtimeLatestVersion,
|
|
43
|
+
runtimeUpdateOffered,
|
|
27
44
|
setPluginUpdateSource,
|
|
28
45
|
setRuntimeUpdateSource,
|
|
29
46
|
unignoreOffer,
|
|
@@ -34,14 +51,18 @@ import {
|
|
|
34
51
|
type ReleaseInfo,
|
|
35
52
|
type RuntimeUpdateSource,
|
|
36
53
|
type UpdateJob,
|
|
54
|
+
type UpdateStep,
|
|
37
55
|
} from './api.js'
|
|
38
56
|
import { formatMb, type UpdateCopy } from './copy.js'
|
|
57
|
+
import { actionableUpdateCount } from './version.js'
|
|
39
58
|
import {
|
|
59
|
+
BOLT_ICON,
|
|
40
60
|
CHECK_CIRCLE_ICON,
|
|
61
|
+
CHEVRON_RIGHT_ICON,
|
|
41
62
|
CLOSE_ICON,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
COPY_ICON,
|
|
64
|
+
EYE_OFF_ICON,
|
|
65
|
+
LOG_ICON,
|
|
45
66
|
REFRESH_ICON,
|
|
46
67
|
WARNING_ICON,
|
|
47
68
|
} from './icons.js'
|
|
@@ -53,7 +74,7 @@ export interface UpdateStoreLike {
|
|
|
53
74
|
setInstalling(flag: boolean): void
|
|
54
75
|
/** The durable job the server currently reports as active (resumed). */
|
|
55
76
|
activeJob(): UpdateJob | null
|
|
56
|
-
/** Bounded recent jobs (newest first), for the
|
|
77
|
+
/** Bounded recent jobs (newest first), for the log drawer. */
|
|
57
78
|
recentJobs(): UpdateJob[]
|
|
58
79
|
refresh(): Promise<PluginUpdateReport | null>
|
|
59
80
|
/** Force a fresh update check (used by the failed-check Retry action). */
|
|
@@ -66,6 +87,11 @@ export interface UpdateStoreLike {
|
|
|
66
87
|
/** How long the outcome toast stays up, in milliseconds. */
|
|
67
88
|
const TOAST_HOLD_MS = 4000
|
|
68
89
|
|
|
90
|
+
/** Which manifest an editor belongs to — drives data attributes + CSS hook. */
|
|
91
|
+
export type SourceKind = 'runtime' | 'plugin'
|
|
92
|
+
|
|
93
|
+
// ── Small DOM helpers ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
69
95
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
70
96
|
tag: K,
|
|
71
97
|
className = '',
|
|
@@ -93,6 +119,14 @@ function iconButton(
|
|
|
93
119
|
return button
|
|
94
120
|
}
|
|
95
121
|
|
|
122
|
+
/** A text-link button (重新尝试, 展开版本日志, 忽略此版本 …). */
|
|
123
|
+
function linkButton(className: string, label: string, onClick: () => void): HTMLButtonElement {
|
|
124
|
+
const button = el('button', className, label)
|
|
125
|
+
button.type = 'button'
|
|
126
|
+
button.addEventListener('click', onClick)
|
|
127
|
+
return button
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
/**
|
|
97
131
|
* Compact a package name to what a person calls it — the shell plugin
|
|
98
132
|
* manager's own rule (`presentation.ts` `shortName`), so both UIs read the
|
|
@@ -103,211 +137,30 @@ export function shortName(name: string): string {
|
|
|
103
137
|
return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
|
|
104
138
|
}
|
|
105
139
|
|
|
106
|
-
/** The one-line version transition under a card's title. */
|
|
107
|
-
function versionLine(item: PluginUpdateItem): string {
|
|
108
|
-
switch (item.state) {
|
|
109
|
-
case 'current':
|
|
110
|
-
return item.currentVersion ?? item.availableVersion
|
|
111
|
-
case 'install':
|
|
112
|
-
return item.availableVersion
|
|
113
|
-
default:
|
|
114
|
-
// An absent plugin names only the version it would gain.
|
|
115
|
-
return item.currentVersion === null
|
|
116
|
-
? item.availableVersion
|
|
117
|
-
: `${item.currentVersion} → ${item.availableVersion}`
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
140
|
/** Two-letter avatar initials for a card (CJK-safe). */
|
|
122
141
|
function avatarInitials(item: PluginUpdateItem): string {
|
|
123
142
|
const src =
|
|
124
143
|
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
125
144
|
? item.displayName.trim()
|
|
126
145
|
: shortName(item.name)
|
|
127
|
-
if (/[
|
|
146
|
+
if (/[\u2E80-\u9FFF]/.test(src)) return src.slice(0, 2)
|
|
128
147
|
const words = src.split(/[\s_\-./@]+/).filter(Boolean)
|
|
129
148
|
if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
|
|
130
149
|
return src.replace(/^@/, '').slice(0, 2).toUpperCase()
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
/**
|
|
134
|
-
function
|
|
135
|
-
item
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
onInstall: () => void,
|
|
139
|
-
onIgnore: () => void,
|
|
140
|
-
): HTMLElement {
|
|
141
|
-
const drawer = el('div', 'hqpu-changelog-drawer')
|
|
142
|
-
drawer.style.display = open ? '' : 'none'
|
|
143
|
-
drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
|
|
144
|
-
const ul = el('ul')
|
|
145
|
-
for (const change of item.changes ?? []) {
|
|
146
|
-
if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
|
|
147
|
-
}
|
|
148
|
-
if (ul.childElementCount > 0) drawer.append(ul)
|
|
149
|
-
const actions = el('div', 'hqpu-changelog-actions')
|
|
150
|
-
if (item.state === 'update' || item.state === 'install') {
|
|
151
|
-
const install = el('button', 'hqpu-card-action hqpu-card-action--install', copy.installOnePlugin)
|
|
152
|
-
install.type = 'button'
|
|
153
|
-
install.title = copy.installOneTip
|
|
154
|
-
install.addEventListener('click', onInstall)
|
|
155
|
-
const ignore = el('button', 'hqpu-card-action', copy.ignore)
|
|
156
|
-
ignore.type = 'button'
|
|
157
|
-
ignore.addEventListener('click', onIgnore)
|
|
158
|
-
actions.append(install, ignore)
|
|
159
|
-
} else if (item.state === 'ignored') {
|
|
160
|
-
const show = el('button', 'hqpu-card-action hqpu-card-action--install', copy.unignore)
|
|
161
|
-
show.type = 'button'
|
|
162
|
-
show.addEventListener('click', onIgnore)
|
|
163
|
-
actions.append(show)
|
|
164
|
-
}
|
|
165
|
-
if (actions.childElementCount > 0) drawer.append(actions)
|
|
166
|
-
return drawer
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** One package card per the mockup: avatar initials, display name + package
|
|
170
|
-
* subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
|
|
171
|
-
* inline changelog. */
|
|
172
|
-
function packageCard(
|
|
173
|
-
item: PluginUpdateItem,
|
|
174
|
-
copy: UpdateCopy,
|
|
175
|
-
open: boolean,
|
|
176
|
-
onToggle: () => void,
|
|
177
|
-
onInstall: () => void,
|
|
178
|
-
onIgnore: () => void,
|
|
179
|
-
): HTMLLIElement {
|
|
180
|
-
const card = el('li', 'hqpu-card')
|
|
181
|
-
card.dataset.pluginName = item.name
|
|
182
|
-
card.dataset.state = item.state
|
|
183
|
-
|
|
184
|
-
const head = el('div', 'hqpu-card-head')
|
|
185
|
-
const left = el('div')
|
|
186
|
-
const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
|
|
187
|
-
avatar.setAttribute('aria-hidden', 'true')
|
|
188
|
-
|
|
189
|
-
const main = el('div', 'hqpu-card-main')
|
|
190
|
-
const titleText =
|
|
191
|
-
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
192
|
-
? item.displayName.trim()
|
|
193
|
-
: shortName(item.name)
|
|
194
|
-
main.append(el('span', 'hqpu-card-title', titleText))
|
|
195
|
-
main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
|
|
196
|
-
|
|
197
|
-
// old → new version row; the NEW version renders in emerald.
|
|
198
|
-
const ver = el('div', 'hqpu-version-row')
|
|
199
|
-
if (item.currentVersion !== null && item.currentVersion !== undefined) {
|
|
200
|
-
ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
|
|
201
|
-
ver.append(el('span', 'hqpu-ver-arrow', '→'))
|
|
202
|
-
}
|
|
203
|
-
const size = formatMb(item.sizeBytes ?? 0)
|
|
204
|
-
ver.append(
|
|
205
|
-
el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
|
|
206
|
-
)
|
|
207
|
-
main.append(ver)
|
|
208
|
-
left.append(avatar, main)
|
|
209
|
-
|
|
210
|
-
const right = el('div', 'hqpu-card-end')
|
|
211
|
-
const hasDetails = (Array.isArray(item.changes) && item.changes.length > 0) ||
|
|
212
|
-
item.state === 'update' || item.state === 'install' || item.state === 'ignored'
|
|
213
|
-
if (hasDetails) {
|
|
214
|
-
const toggle = el('button', 'hqpu-log-toggle')
|
|
215
|
-
toggle.type = 'button'
|
|
216
|
-
toggle.dataset.open = open ? 'true' : 'false'
|
|
217
|
-
toggle.innerHTML = `${copy.ui.changelogBtn}<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>`
|
|
218
|
-
toggle.addEventListener('click', onToggle)
|
|
219
|
-
right.append(toggle)
|
|
220
|
-
}
|
|
221
|
-
head.append(left, right)
|
|
222
|
-
card.append(head)
|
|
223
|
-
|
|
224
|
-
// The actions live in the expanded changelog/details drawer so cards keep
|
|
225
|
-
// their compact layout until a person asks for more information.
|
|
226
|
-
if (hasDetails) {
|
|
227
|
-
card.append(changelogDrawer(item, copy, open, onInstall, onIgnore))
|
|
228
|
-
}
|
|
229
|
-
return card
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** A section: head (title + count pill + expand-all toggle) then its cards. */
|
|
233
|
-
function group(
|
|
234
|
-
heading: string,
|
|
235
|
-
items: PluginUpdateItem[],
|
|
236
|
-
copy: UpdateCopy,
|
|
237
|
-
changelogOpen: Set<string>,
|
|
238
|
-
allOpen: boolean,
|
|
239
|
-
onToggle: (name: string) => void,
|
|
240
|
-
onToggleAll: () => void,
|
|
241
|
-
onInstall: (name: string) => void,
|
|
242
|
-
onIgnore: (item: PluginUpdateItem) => void,
|
|
243
|
-
): HTMLElement | null {
|
|
244
|
-
if (items.length === 0) return null
|
|
245
|
-
const section = el('section', 'hqpu-group')
|
|
246
|
-
section.dataset.group = heading
|
|
247
|
-
|
|
248
|
-
const head = el('div', 'hqpu-section-head')
|
|
249
|
-
const titleWrap = el('div', 'hqpu-section-title')
|
|
250
|
-
titleWrap.append(el('span', '', heading))
|
|
251
|
-
titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
|
|
252
|
-
head.append(titleWrap)
|
|
253
|
-
// Expand-all only makes sense over actionable, changelog-bearing cards.
|
|
254
|
-
const actionable = items.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
|
|
255
|
-
if (actionable) {
|
|
256
|
-
const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
|
|
257
|
-
all.type = 'button'
|
|
258
|
-
all.addEventListener('click', onToggleAll)
|
|
259
|
-
head.append(all)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const list = el('ul', 'hqpu-cards')
|
|
263
|
-
for (const item of items) {
|
|
264
|
-
list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name), () => onInstall(item.name), () => onIgnore(item)))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
section.append(head, list)
|
|
268
|
-
return section
|
|
152
|
+
/** The name a card shows: the manifest's displayName, else the short name. */
|
|
153
|
+
function displayNameOf(item: PluginUpdateItem): string {
|
|
154
|
+
return typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
155
|
+
? item.displayName.trim()
|
|
156
|
+
: shortName(item.name)
|
|
269
157
|
}
|
|
270
158
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const section = el('section', 'hqpu-group')
|
|
274
|
-
section.setAttribute('role', 'status')
|
|
275
|
-
section.dataset.loading = ''
|
|
276
|
-
const head = el('div', 'hqpu-group-head')
|
|
277
|
-
head.setAttribute('aria-hidden', 'true')
|
|
278
|
-
const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
|
|
279
|
-
const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
|
|
280
|
-
heading.append(headingBar)
|
|
281
|
-
head.append(heading)
|
|
282
|
-
|
|
283
|
-
const list = el('ul', 'hqpu-cards')
|
|
284
|
-
list.setAttribute('aria-hidden', 'true')
|
|
285
|
-
for (let index = 0; index < 3; index += 1) {
|
|
286
|
-
const card = el('li', 'hqpu-card')
|
|
287
|
-
const cardHead = el('div', 'hqpu-card-head')
|
|
288
|
-
const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
|
|
289
|
-
const main = el('div', 'hqpu-card-main')
|
|
290
|
-
const titleRow = el('div', 'hqpu-title-row')
|
|
291
|
-
const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
|
|
292
|
-
title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
293
|
-
titleRow.append(title)
|
|
294
|
-
const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
|
|
295
|
-
desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
|
|
296
|
-
main.append(titleRow, desc)
|
|
297
|
-
cardHead.append(glyph, main)
|
|
298
|
-
const end = el('div', 'hqpu-card-end')
|
|
299
|
-
end.setAttribute('aria-hidden', 'true')
|
|
300
|
-
cardHead.append(end)
|
|
301
|
-
card.append(cardHead)
|
|
302
|
-
list.append(card)
|
|
303
|
-
}
|
|
304
|
-
section.append(head, list)
|
|
305
|
-
// The skeleton replaces the whole column, so label it for screen readers.
|
|
306
|
-
section.setAttribute('aria-label', copy.loading)
|
|
307
|
-
return section
|
|
159
|
+
function isActionable(item: PluginUpdateItem): boolean {
|
|
160
|
+
return item.state === 'update' || item.state === 'install'
|
|
308
161
|
}
|
|
309
162
|
|
|
310
|
-
// ──
|
|
163
|
+
// ── Time / size formatting ────────────────────────────────────────────────
|
|
311
164
|
|
|
312
165
|
/** Format a byte count for the progress line, or null when unknown. */
|
|
313
166
|
function formatBytes(n: number | undefined): string | null {
|
|
@@ -318,16 +171,7 @@ function formatBytes(n: number | undefined): string | null {
|
|
|
318
171
|
return `${n} B`
|
|
319
172
|
}
|
|
320
173
|
|
|
321
|
-
/**
|
|
322
|
-
function formatEta(seconds: number | undefined): string | null {
|
|
323
|
-
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
|
|
324
|
-
if (seconds < 60) return `${Math.round(seconds)} s`
|
|
325
|
-
const minutes = Math.round(seconds / 60)
|
|
326
|
-
if (minutes < 60) return `${minutes} min`
|
|
327
|
-
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
|
|
174
|
+
/** "N 分钟前" — relative time from an ISO timestamp. */
|
|
331
175
|
function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
|
|
332
176
|
if (typeof iso !== 'string' || iso.length === 0) return null
|
|
333
177
|
const then = new Date(iso).getTime()
|
|
@@ -340,6 +184,19 @@ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string |
|
|
|
340
184
|
return copy.ui.agoDays(Math.round(hours / 24))
|
|
341
185
|
}
|
|
342
186
|
|
|
187
|
+
/** Short absolute time for log rows: "10/29 14:34". */
|
|
188
|
+
function shortWhen(iso: string | null | undefined): string {
|
|
189
|
+
if (typeof iso !== 'string' || iso.length === 0) return ''
|
|
190
|
+
const at = new Date(iso)
|
|
191
|
+
if (Number.isNaN(at.getTime())) return ''
|
|
192
|
+
return at.toLocaleString(undefined, {
|
|
193
|
+
month: '2-digit',
|
|
194
|
+
day: '2-digit',
|
|
195
|
+
hour: '2-digit',
|
|
196
|
+
minute: '2-digit',
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
343
200
|
function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
344
201
|
return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
|
|
345
202
|
}
|
|
@@ -349,39 +206,13 @@ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
|
349
206
|
return copy.job.phase[job.phase] ?? job.phase
|
|
350
207
|
}
|
|
351
208
|
|
|
352
|
-
/** One per-step line for plugin-set installs (and any job that reports steps). */
|
|
353
|
-
function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
|
|
354
|
-
if (!Array.isArray(job.steps) || job.steps.length === 0) return null
|
|
355
|
-
const wrap = el('div', 'hqpu-job-steps')
|
|
356
|
-
wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
|
|
357
|
-
const ol = el('ol', 'hqpu-steps')
|
|
358
|
-
for (const step of job.steps) {
|
|
359
|
-
const li = el('li', `hqpu-step hqpu-step--${step.state}`)
|
|
360
|
-
const dot = el('span', 'hqpu-dot')
|
|
361
|
-
dot.dataset.state =
|
|
362
|
-
step.state === 'installed'
|
|
363
|
-
? 'done'
|
|
364
|
-
: step.state === 'failed'
|
|
365
|
-
? 'error'
|
|
366
|
-
: step.state === 'running'
|
|
367
|
-
? 'warning'
|
|
368
|
-
: 'idle'
|
|
369
|
-
li.append(dot)
|
|
370
|
-
const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
|
|
371
|
-
li.append(el('span', '', text))
|
|
372
|
-
ol.append(li)
|
|
373
|
-
}
|
|
374
|
-
wrap.append(ol)
|
|
375
|
-
return wrap
|
|
376
|
-
}
|
|
377
|
-
|
|
378
209
|
/** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
|
|
379
210
|
function stepIndex(phase: UpdateJob['phase']): number {
|
|
380
211
|
switch (phase) {
|
|
381
212
|
case 'downloading':
|
|
213
|
+
case 'resolving':
|
|
382
214
|
return 0
|
|
383
215
|
case 'verifying':
|
|
384
|
-
case 'resolving':
|
|
385
216
|
return 1
|
|
386
217
|
case 'staging':
|
|
387
218
|
case 'activating':
|
|
@@ -391,25 +222,228 @@ function stepIndex(phase: UpdateJob['phase']): number {
|
|
|
391
222
|
case 'verifying-after-restart':
|
|
392
223
|
return 3
|
|
393
224
|
case 'queued':
|
|
394
|
-
return -1
|
|
395
225
|
default:
|
|
396
226
|
return -1
|
|
397
227
|
}
|
|
398
228
|
}
|
|
399
229
|
|
|
230
|
+
// ── Section pieces ────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A collapsible section: chevron + title + count, an optional right-hand note,
|
|
234
|
+
* and a body that only exists in the DOM while open.
|
|
235
|
+
*
|
|
236
|
+
* Native `<details>` was the alternative, but it cannot express the
|
|
237
|
+
* "collapsed by default, opened by state" rule the 已是最新 group needs without
|
|
238
|
+
* a JS patch anyway, and its summary cannot host the trailing note in one row.
|
|
239
|
+
*/
|
|
240
|
+
function disclosure(opts: {
|
|
241
|
+
id: string
|
|
242
|
+
title: string
|
|
243
|
+
count?: string
|
|
244
|
+
note?: string
|
|
245
|
+
open: boolean
|
|
246
|
+
onToggle: () => void
|
|
247
|
+
build: () => HTMLElement[]
|
|
248
|
+
}): HTMLElement {
|
|
249
|
+
const section = el('section', 'hqpu-disc')
|
|
250
|
+
// The title doubles as the group hook (`[data-group="已是最新"]`), matching
|
|
251
|
+
// the actionable group's convention so callers need one selector style.
|
|
252
|
+
section.dataset.group = opts.title
|
|
253
|
+
const head = el('button', 'hqpu-disc-head')
|
|
254
|
+
head.type = 'button'
|
|
255
|
+
head.setAttribute('aria-expanded', opts.open ? 'true' : 'false')
|
|
256
|
+
head.setAttribute('aria-controls', opts.id)
|
|
257
|
+
|
|
258
|
+
const left = el('span', 'hqpu-disc-left')
|
|
259
|
+
const chevron = el('span', 'hqpu-disc-chevron')
|
|
260
|
+
chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
|
|
261
|
+
left.append(chevron, el('span', 'hqpu-disc-title', opts.title))
|
|
262
|
+
if (opts.count !== undefined) left.append(el('span', 'hqpu-disc-count', opts.count))
|
|
263
|
+
head.append(left)
|
|
264
|
+
if (opts.note !== undefined && opts.note.length > 0) {
|
|
265
|
+
head.append(el('span', 'hqpu-disc-note', opts.note))
|
|
266
|
+
}
|
|
267
|
+
head.addEventListener('click', opts.onToggle)
|
|
268
|
+
section.append(head)
|
|
269
|
+
|
|
270
|
+
if (opts.open) {
|
|
271
|
+
const body = el('div', 'hqpu-disc-body')
|
|
272
|
+
body.id = opts.id
|
|
273
|
+
for (const node of opts.build()) body.append(node)
|
|
274
|
+
section.append(body)
|
|
275
|
+
}
|
|
276
|
+
return section
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One muted row in a collapsed group (current or ignored plugin). */
|
|
280
|
+
function pluginRow(
|
|
281
|
+
item: PluginUpdateItem,
|
|
282
|
+
copy: UpdateCopy,
|
|
283
|
+
onToggleIgnore: () => void,
|
|
284
|
+
): HTMLLIElement {
|
|
285
|
+
const row = el('li', 'hqpu-row')
|
|
286
|
+
row.dataset.pluginName = item.name
|
|
287
|
+
row.dataset.state = item.state
|
|
288
|
+
row.append(el('span', 'hqpu-row-name', displayNameOf(item)))
|
|
289
|
+
const end = el('span', 'hqpu-row-end')
|
|
290
|
+
const version = item.currentVersion ?? item.availableVersion
|
|
291
|
+
end.append(el('span', 'hqpu-row-meta', copy.ui.upToDateRow(version)))
|
|
292
|
+
if (item.state === 'ignored') {
|
|
293
|
+
end.append(
|
|
294
|
+
linkButton('hqpu-link hqpu-link--tiny', copy.unignore, onToggleIgnore),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
row.append(end)
|
|
298
|
+
return row
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One actionable plugin card.
|
|
303
|
+
*
|
|
304
|
+
* Actions live on the card, not inside the changelog drawer: the drawer is for
|
|
305
|
+
* content (what changed), and hiding 单独更新 / 忽略 behind a disclosure made
|
|
306
|
+
* the set-level footer CTA the only discoverable action.
|
|
307
|
+
*/
|
|
308
|
+
function pluginCard(
|
|
309
|
+
item: PluginUpdateItem,
|
|
310
|
+
copy: UpdateCopy,
|
|
311
|
+
opts: { changesOpen: boolean; onToggleChanges: () => void; onInstall: () => void; onIgnore: () => void; busy: boolean },
|
|
312
|
+
): HTMLLIElement {
|
|
313
|
+
const card = el('li', 'hqpu-card')
|
|
314
|
+
card.dataset.pluginName = item.name
|
|
315
|
+
card.dataset.state = item.state
|
|
316
|
+
|
|
317
|
+
const head = el('div', 'hqpu-card-head')
|
|
318
|
+
const id = el('div', 'hqpu-card-id')
|
|
319
|
+
const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
|
|
320
|
+
avatar.setAttribute('aria-hidden', 'true')
|
|
321
|
+
const main = el('div', 'hqpu-card-main')
|
|
322
|
+
main.append(el('span', 'hqpu-card-title', displayNameOf(item)))
|
|
323
|
+
main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
|
|
324
|
+
id.append(avatar, main)
|
|
325
|
+
|
|
326
|
+
const end = el('div', 'hqpu-card-end')
|
|
327
|
+
const install = el(
|
|
328
|
+
'button',
|
|
329
|
+
'hqpu-card-action hqpu-card-action--install',
|
|
330
|
+
item.state === 'install' ? copy.ui.singleInstall : copy.ui.singleUpdate,
|
|
331
|
+
)
|
|
332
|
+
install.type = 'button'
|
|
333
|
+
install.title = copy.ui.singleUpdateTip
|
|
334
|
+
install.disabled = opts.busy
|
|
335
|
+
install.addEventListener('click', opts.onInstall)
|
|
336
|
+
const ignore = iconButton(
|
|
337
|
+
'hqpu-icon-btn hqpu-icon-btn--sm',
|
|
338
|
+
EYE_OFF_ICON(14),
|
|
339
|
+
copy.ui.ignorePlugin,
|
|
340
|
+
opts.onIgnore,
|
|
341
|
+
)
|
|
342
|
+
ignore.disabled = opts.busy
|
|
343
|
+
end.append(install, ignore)
|
|
344
|
+
head.append(id, end)
|
|
345
|
+
card.append(head)
|
|
346
|
+
|
|
347
|
+
// Version row + the changelog toggle, on one line.
|
|
348
|
+
const foot = el('div', 'hqpu-card-foot')
|
|
349
|
+
const ver = el('div', 'hqpu-version-row')
|
|
350
|
+
if (item.currentVersion !== null && item.currentVersion !== undefined) {
|
|
351
|
+
ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
|
|
352
|
+
ver.append(el('span', 'hqpu-ver-arrow', '→'))
|
|
353
|
+
}
|
|
354
|
+
ver.append(el('span', 'hqpu-ver-new', item.availableVersion))
|
|
355
|
+
const size = formatMb(item.sizeBytes ?? 0)
|
|
356
|
+
if (size.length > 0) ver.append(el('span', 'hqpu-ver-size', `(${size})`))
|
|
357
|
+
foot.append(ver)
|
|
358
|
+
|
|
359
|
+
const changes = Array.isArray(item.changes)
|
|
360
|
+
? item.changes.filter((c) => typeof c === 'string' && c.length > 0)
|
|
361
|
+
: []
|
|
362
|
+
if (changes.length > 0) {
|
|
363
|
+
const toggle = el('button', 'hqpu-toggle')
|
|
364
|
+
toggle.type = 'button'
|
|
365
|
+
toggle.dataset.changesToggle = ''
|
|
366
|
+
toggle.dataset.open = opts.changesOpen ? 'true' : 'false'
|
|
367
|
+
toggle.setAttribute('aria-expanded', opts.changesOpen ? 'true' : 'false')
|
|
368
|
+
toggle.append(el('span', '', copy.ui.changesToggle))
|
|
369
|
+
const chevron = el('span', 'hqpu-toggle-chevron')
|
|
370
|
+
chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
|
|
371
|
+
toggle.append(chevron)
|
|
372
|
+
toggle.addEventListener('click', opts.onToggleChanges)
|
|
373
|
+
foot.append(toggle)
|
|
374
|
+
}
|
|
375
|
+
card.append(foot)
|
|
376
|
+
|
|
377
|
+
if (changes.length > 0 && opts.changesOpen) {
|
|
378
|
+
const drawer = el('div', 'hqpu-changes')
|
|
379
|
+
drawer.append(el('p', 'hqpu-changes-title', copy.ui.changesTitle(item.availableVersion)))
|
|
380
|
+
const ul = el('ul')
|
|
381
|
+
for (const change of changes) ul.append(el('li', '', change))
|
|
382
|
+
drawer.append(ul)
|
|
383
|
+
card.append(drawer)
|
|
384
|
+
}
|
|
385
|
+
return card
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The "what do I actually have to decide" band, above every list. */
|
|
389
|
+
function decisionBand(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement | null {
|
|
390
|
+
const plugins = actionableUpdateCount(report)
|
|
391
|
+
const runtime = runtimeUpdateOffered(report.runtime)
|
|
392
|
+
if (plugins === 0 && !runtime) return null
|
|
393
|
+
const band = el('div', 'hqpu-decision')
|
|
394
|
+
band.dataset.role = 'decision-band'
|
|
395
|
+
if (plugins > 0) band.append(el('span', 'hqpu-decision-main', copy.ui.summaryPlugins(plugins)))
|
|
396
|
+
if (plugins > 0 && runtime) {
|
|
397
|
+
const dot = el('span', 'hqpu-decision-dot')
|
|
398
|
+
dot.setAttribute('aria-hidden', 'true')
|
|
399
|
+
band.append(dot)
|
|
400
|
+
}
|
|
401
|
+
if (runtime) band.append(el('span', 'hqpu-decision-sub', copy.ui.summaryRuntime))
|
|
402
|
+
return band
|
|
403
|
+
}
|
|
404
|
+
|
|
400
405
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
|
|
404
|
-
|
|
406
|
+
* Check-status banner. A FAILED check is a red alert with its reason and a
|
|
407
|
+
* retry — never the silent "no updates" empty state, which reads as success.
|
|
408
|
+
*/
|
|
409
|
+
function checkBanner(
|
|
410
|
+
report: PluginUpdateReport,
|
|
411
|
+
copy: UpdateCopy,
|
|
412
|
+
onRetry: () => void,
|
|
413
|
+
): HTMLElement | null {
|
|
414
|
+
const failed = typeof report.lastError === 'string' && report.lastError.length > 0
|
|
415
|
+
if (!failed) return null
|
|
416
|
+
const section = el('section', 'hqpu-group')
|
|
417
|
+
section.dataset.group = 'check-status'
|
|
418
|
+
const card = el('div', 'hqpu-error')
|
|
419
|
+
card.dataset.tone = 'danger'
|
|
420
|
+
const head = el('div', 'hqpu-error-head')
|
|
421
|
+
const icon = el('span', 'hqpu-error-icon')
|
|
422
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
423
|
+
const text = el('div', 'hqpu-error-text')
|
|
424
|
+
text.append(el('p', 'hqpu-error-title', copy.checkFailedTitle))
|
|
425
|
+
text.append(el('p', 'hqpu-error-body', report.lastError as string))
|
|
426
|
+
head.append(icon, text)
|
|
427
|
+
card.append(head)
|
|
428
|
+
|
|
429
|
+
const foot = el('div', 'hqpu-error-foot')
|
|
430
|
+
if (report.items.length > 0) foot.append(el('span', '', copy.staleNote))
|
|
431
|
+
foot.append(linkButton('hqpu-link', copy.retryNow, onRetry))
|
|
432
|
+
card.append(foot)
|
|
433
|
+
section.append(card)
|
|
434
|
+
return section
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The active job card: title (with the target version) + percent, a
|
|
439
|
+
* determinate bar when the server publishes one, phase + telemetry, the honest
|
|
440
|
+
* "closing does not cancel" note, and the 4-step pipeline.
|
|
405
441
|
*/
|
|
406
442
|
function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
407
443
|
const section = el('section', 'hqpu-group')
|
|
408
444
|
section.dataset.group = 'active-job'
|
|
409
|
-
|
|
410
445
|
const card = el('div', 'hqpu-active')
|
|
411
446
|
|
|
412
|
-
// Top row: pulse dot + title, mono percent.
|
|
413
447
|
const top = el('div', 'hqpu-active-top')
|
|
414
448
|
const titleWrap = el('div', 'hqpu-active-title')
|
|
415
449
|
const pulse = el('span', 'hqpu-pulse-dot')
|
|
@@ -418,23 +452,24 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
418
452
|
job.kind === 'runtime'
|
|
419
453
|
? copy.ui.jobRuntimeTitle(job.targetVersion)
|
|
420
454
|
: copy.ui.jobPluginTitle(job.targetVersion)
|
|
421
|
-
titleWrap.append(pulse, el('span', '', title))
|
|
455
|
+
titleWrap.append(pulse, el('span', 'hqpu-active-label', title))
|
|
456
|
+
top.append(titleWrap)
|
|
422
457
|
const pct = job.progress.percent
|
|
423
|
-
if (pct !== null) {
|
|
424
|
-
top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
|
|
425
|
-
}
|
|
458
|
+
if (pct !== null) top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
|
|
426
459
|
card.append(top)
|
|
427
460
|
|
|
428
|
-
// Gradient bar — only when the server publishes a real percentage.
|
|
429
461
|
if (pct !== null) {
|
|
430
462
|
const bar = el('div', 'hqpu-progress')
|
|
463
|
+
bar.setAttribute('role', 'progressbar')
|
|
464
|
+
bar.setAttribute('aria-valuemin', '0')
|
|
465
|
+
bar.setAttribute('aria-valuemax', '100')
|
|
466
|
+
bar.setAttribute('aria-valuenow', String(Math.round(pct)))
|
|
431
467
|
const fill = el('div', 'hqpu-progress-fill')
|
|
432
468
|
fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
|
|
433
469
|
bar.append(fill)
|
|
434
470
|
card.append(bar)
|
|
435
471
|
}
|
|
436
472
|
|
|
437
|
-
// Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
|
|
438
473
|
const meta = el('div', 'hqpu-active-meta')
|
|
439
474
|
const phaseText =
|
|
440
475
|
typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
|
|
@@ -450,28 +485,29 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
450
485
|
: done !== null && total !== null
|
|
451
486
|
? copy.job.bytes(done, total)
|
|
452
487
|
: ''
|
|
453
|
-
if (telemetry.length > 0) meta.append(el('span', '', telemetry))
|
|
488
|
+
if (telemetry.length > 0) meta.append(el('span', 'hqpu-num', telemetry))
|
|
454
489
|
card.append(meta)
|
|
455
490
|
|
|
456
|
-
// previous → target
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
491
|
+
// previous → target on its own line (mono), then what happens next. A
|
|
492
|
+
// plugin set keeps the "closing does not cancel" note; a runtime swap has a
|
|
493
|
+
// stronger one of its own (HQ Edge exits, the host swaps it out).
|
|
494
|
+
card.append(
|
|
460
495
|
el(
|
|
461
|
-
'
|
|
462
|
-
'',
|
|
496
|
+
'div',
|
|
497
|
+
'hqpu-active-versions',
|
|
463
498
|
job.previousVersion !== null && job.previousVersion.length > 0
|
|
464
499
|
? `${job.previousVersion} → ${job.targetVersion}`
|
|
465
500
|
: job.targetVersion,
|
|
466
501
|
),
|
|
467
502
|
)
|
|
468
|
-
|
|
469
|
-
el(
|
|
503
|
+
card.append(
|
|
504
|
+
el(
|
|
505
|
+
'p',
|
|
506
|
+
'hqpu-active-note',
|
|
507
|
+
job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.ui.activeCloseNote,
|
|
508
|
+
),
|
|
470
509
|
)
|
|
471
|
-
card.append(transition)
|
|
472
|
-
card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
|
|
473
510
|
|
|
474
|
-
// 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
|
|
475
511
|
const current = stepIndex(job.phase)
|
|
476
512
|
const stepsRow = el('div', 'hqpu-job-steps4')
|
|
477
513
|
copy.ui.jobSteps.forEach((label: string, index: number) => {
|
|
@@ -483,7 +519,9 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
483
519
|
: index === current
|
|
484
520
|
? 'hqpu-job-step--active'
|
|
485
521
|
: 'hqpu-job-step--todo'
|
|
486
|
-
|
|
522
|
+
// Numbered like the prototype's pipeline ("1. 下载"): the count is the
|
|
523
|
+
// only cue for how much is left when the active step blinks.
|
|
524
|
+
stepsRow.append(el('div', cls, `${index + 1}. ${label}`))
|
|
487
525
|
})
|
|
488
526
|
card.append(stepsRow)
|
|
489
527
|
|
|
@@ -491,226 +529,410 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
491
529
|
return section
|
|
492
530
|
}
|
|
493
531
|
|
|
532
|
+
/** Phase → the word the log rows use for a component. */
|
|
533
|
+
function stepWord(state: UpdateStep['state'], copy: UpdateCopy): string {
|
|
534
|
+
switch (state) {
|
|
535
|
+
case 'installed':
|
|
536
|
+
return copy.job.phase.done ?? ''
|
|
537
|
+
case 'skipped':
|
|
538
|
+
return copy.ui.stepSkipped
|
|
539
|
+
case 'failed':
|
|
540
|
+
return copy.job.phase.failed ?? ''
|
|
541
|
+
case 'running':
|
|
542
|
+
return copy.job.phase.downloading ?? ''
|
|
543
|
+
default:
|
|
544
|
+
return copy.ui.stepPending
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Dot tone for one step row. */
|
|
549
|
+
function stepDotState(state: UpdateStep['state']): string {
|
|
550
|
+
switch (state) {
|
|
551
|
+
case 'installed':
|
|
552
|
+
return 'done'
|
|
553
|
+
case 'failed':
|
|
554
|
+
return 'error'
|
|
555
|
+
case 'running':
|
|
556
|
+
return 'warning'
|
|
557
|
+
default:
|
|
558
|
+
return 'idle'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
494
562
|
/**
|
|
495
|
-
* The
|
|
496
|
-
*
|
|
497
|
-
*
|
|
563
|
+
* The terminal-result banner for a job that did NOT succeed.
|
|
564
|
+
*
|
|
565
|
+
* A successful install needs no banner — the list and the footer already say
|
|
566
|
+
* "everything is current". A failure does: the error code and the retry must be
|
|
567
|
+
* on screen, not one drawer-click away.
|
|
498
568
|
*/
|
|
499
|
-
function
|
|
569
|
+
function jobResultBanner(
|
|
570
|
+
job: UpdateJob,
|
|
571
|
+
copy: UpdateCopy,
|
|
572
|
+
onRetry: () => void,
|
|
573
|
+
onOpenLog: () => void,
|
|
574
|
+
): HTMLElement {
|
|
500
575
|
const section = el('section', 'hqpu-group')
|
|
501
576
|
section.dataset.group = 'job-result'
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const head = el('div', 'hqpu-group-head')
|
|
515
|
-
const tag = el('span', 'hqpu-tag')
|
|
516
|
-
tag.dataset.tone = tone
|
|
517
|
-
tag.textContent = titleText
|
|
518
|
-
head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
|
|
519
|
-
section.append(head)
|
|
520
|
-
|
|
521
|
-
const card = el('div', 'hqpu-job')
|
|
577
|
+
const tone = job.phase === 'failed' ? 'danger' : 'warning'
|
|
578
|
+
const card = el('div', 'hqpu-error')
|
|
579
|
+
card.dataset.tone = tone
|
|
580
|
+
|
|
581
|
+
const head = el('div', 'hqpu-error-head')
|
|
582
|
+
const icon = el('span', 'hqpu-error-icon')
|
|
583
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
584
|
+
const text = el('div', 'hqpu-error-text')
|
|
585
|
+
const title =
|
|
586
|
+
job.phase === 'rolled-back' ? copy.job.resultRolledBack : copy.job.resultFailed
|
|
587
|
+
text.append(el('p', 'hqpu-error-title', title))
|
|
522
588
|
const res = job.result
|
|
523
589
|
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
524
590
|
const to = res?.installedVersion ?? job.targetVersion
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
|
|
530
|
-
if (res !== undefined && res.message !== undefined && res.message.length > 0) {
|
|
531
|
-
card.append(el('p', 'hqpu-status', res.message))
|
|
591
|
+
const when = res !== undefined && res.finishedAt.length > 0 ? ` · ${shortWhen(res.finishedAt)}` : ''
|
|
592
|
+
text.append(el('p', 'hqpu-error-body', `${from} → ${to}${when}`))
|
|
593
|
+
if (res?.message !== undefined && res.message.length > 0) {
|
|
594
|
+
text.append(el('p', 'hqpu-error-body', res.message))
|
|
532
595
|
}
|
|
533
|
-
if (res
|
|
534
|
-
|
|
596
|
+
if (res?.errorCode !== undefined && res.errorCode.length > 0) {
|
|
597
|
+
text.append(el('code', 'hqpu-error-code', res.errorCode))
|
|
535
598
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const steps = stepsList(job, copy)
|
|
541
|
-
if (steps !== null) card.append(steps)
|
|
599
|
+
head.append(icon, text)
|
|
600
|
+
card.append(head)
|
|
542
601
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
602
|
+
const foot = el('div', 'hqpu-error-foot')
|
|
603
|
+
foot.append(el('span', '', jobKindLabel(job, copy)))
|
|
604
|
+
const actions = el('span', 'hqpu-error-actions')
|
|
605
|
+
actions.append(linkButton('hqpu-link', copy.ui.viewLog, onOpenLog))
|
|
606
|
+
actions.append(linkButton('hqpu-link', copy.job.retry, onRetry))
|
|
607
|
+
foot.append(actions)
|
|
608
|
+
card.append(foot)
|
|
550
609
|
section.append(card)
|
|
551
610
|
return section
|
|
552
611
|
}
|
|
553
612
|
|
|
554
|
-
/** A one-line row for older recent jobs (newest already shown as the card). */
|
|
555
|
-
function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
|
|
556
|
-
const older = jobs.slice(1, 5)
|
|
557
|
-
if (older.length === 0) return null
|
|
558
|
-
const wrap = el('div', 'hqpu-group')
|
|
559
|
-
wrap.dataset.group = 'recent-jobs'
|
|
560
|
-
wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
|
|
561
|
-
const ul = el('ul', 'hqpu-cards')
|
|
562
|
-
for (const job of older) {
|
|
563
|
-
const li = el('li', 'hqpu-status')
|
|
564
|
-
const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
|
|
565
|
-
li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
|
|
566
|
-
ul.append(li)
|
|
567
|
-
}
|
|
568
|
-
wrap.append(ul)
|
|
569
|
-
return wrap
|
|
570
|
-
}
|
|
571
|
-
|
|
572
613
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
614
|
+
* The host's own report of the last runtime swap. Rolled-back / failed renders
|
|
615
|
+
* as an AMBER alert card; succeeded renders as a quiet green line. The plugin
|
|
616
|
+
* has no log-open affordance for this, so none is invented.
|
|
576
617
|
*/
|
|
577
|
-
function
|
|
618
|
+
function runtimeResultAlert(
|
|
578
619
|
report: PluginUpdateReport,
|
|
579
620
|
copy: UpdateCopy,
|
|
580
|
-
|
|
621
|
+
dismissed: boolean,
|
|
622
|
+
onDismiss: () => void,
|
|
581
623
|
): HTMLElement | null {
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
if (!failed && !checked) return null
|
|
624
|
+
const lastResult = report.runtime.lastResult
|
|
625
|
+
if (lastResult === null || lastResult === undefined) return null
|
|
585
626
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
banner.innerHTML = WARNING_ICON(14)
|
|
592
|
-
banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
|
|
593
|
-
wrap.append(banner)
|
|
594
|
-
if (report.items.length > 0) {
|
|
595
|
-
wrap.append(el('p', 'hqpu-status', copy.staleNote))
|
|
596
|
-
}
|
|
597
|
-
const row = el('div', 'hqpu-actions')
|
|
598
|
-
const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
|
|
599
|
-
retry.type = 'button'
|
|
600
|
-
retry.addEventListener('click', onRetry)
|
|
601
|
-
row.append(retry)
|
|
602
|
-
wrap.append(row)
|
|
603
|
-
return wrap
|
|
627
|
+
if (lastResult.state === 'succeeded') {
|
|
628
|
+
const line = el('p', 'hqpu-alert-green')
|
|
629
|
+
line.dataset.role = 'runtime-success'
|
|
630
|
+
line.textContent = copy.ui.successInline
|
|
631
|
+
return line
|
|
604
632
|
}
|
|
605
|
-
|
|
606
|
-
el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
|
|
607
|
-
)
|
|
608
|
-
return wrap
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
612
|
-
function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
|
|
613
|
-
if (release === null || release === undefined) return null
|
|
614
|
-
const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
|
|
615
|
-
const breaking = Array.isArray(release.breaking)
|
|
616
|
-
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
617
|
-
: []
|
|
618
|
-
const hasContent =
|
|
619
|
-
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
620
|
-
notes.length > 0 ||
|
|
621
|
-
breaking.length > 0 ||
|
|
622
|
-
typeof release.notesUrl === 'string' ||
|
|
623
|
-
typeof release.publishedAt === 'string'
|
|
624
|
-
if (!hasContent) return null
|
|
633
|
+
if (dismissed) return null
|
|
625
634
|
|
|
626
635
|
const section = el('section', 'hqpu-group')
|
|
627
|
-
section.dataset.group = '
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
636
|
+
section.dataset.group = 'runtime-alert'
|
|
637
|
+
const card = el('div', 'hqpu-alert hqpu-alert--amber')
|
|
638
|
+
const icon = el('span', 'hqpu-alert-icon')
|
|
639
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
640
|
+
card.append(icon)
|
|
641
|
+
|
|
642
|
+
const body = el('div', 'hqpu-alert-main')
|
|
643
|
+
body.append(
|
|
644
|
+
el(
|
|
645
|
+
'div',
|
|
646
|
+
'hqpu-alert-title',
|
|
647
|
+
lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle,
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
|
|
651
|
+
const bodyP = el('p', 'hqpu-alert-body')
|
|
652
|
+
bodyP.append(
|
|
653
|
+
document.createTextNode(
|
|
654
|
+
lastResult.state === 'failed'
|
|
655
|
+
? copy.ui.failedBody(oldVer, '')
|
|
656
|
+
: copy.ui.rollbackBody(oldVer, ''),
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
660
|
+
bodyP.append(el('code', '', lastResult.errorCode))
|
|
642
661
|
}
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
banner.dataset.tone = 'danger'
|
|
646
|
-
banner.innerHTML = WARNING_ICON(14)
|
|
647
|
-
const inner = el('span')
|
|
648
|
-
inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
|
|
649
|
-
inner.append(document.createTextNode(breaking.join(' · ')))
|
|
650
|
-
banner.append(inner)
|
|
651
|
-
section.append(banner)
|
|
652
|
-
}
|
|
653
|
-
if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
|
|
654
|
-
const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
|
|
655
|
-
link.href = release.notesUrl
|
|
656
|
-
link.target = '_blank'
|
|
657
|
-
link.rel = 'noreferrer'
|
|
658
|
-
section.append(link)
|
|
662
|
+
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
663
|
+
bodyP.append(el('br'), document.createTextNode(lastResult.message))
|
|
659
664
|
}
|
|
665
|
+
body.append(bodyP)
|
|
666
|
+
const actions = el('div', 'hqpu-alert-actions')
|
|
667
|
+
actions.append(linkButton('hqpu-alert-dismiss', copy.ui.dismiss, onDismiss))
|
|
668
|
+
body.append(actions)
|
|
669
|
+
card.append(body)
|
|
670
|
+
section.append(card)
|
|
660
671
|
return section
|
|
661
672
|
}
|
|
662
673
|
|
|
663
|
-
/**
|
|
664
|
-
*
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
674
|
+
/**
|
|
675
|
+
* The HQ Edge runtime card: current chip, the offered version with its size and
|
|
676
|
+
* summary, and the 升级核心 action — arm → confirm, because a runtime swap
|
|
677
|
+
* shuts HQ Edge down and is not undoable from here.
|
|
678
|
+
*/
|
|
679
|
+
function runtimeEngineCard(
|
|
680
|
+
report: PluginUpdateReport,
|
|
681
|
+
copy: UpdateCopy,
|
|
682
|
+
actions: RuntimeActions,
|
|
683
|
+
): HTMLElement | null {
|
|
684
|
+
const runtime = report.runtime
|
|
685
|
+
const latestVersion = runtimeLatestVersion(runtime)
|
|
686
|
+
const card = el('section', 'hqpu-engine')
|
|
687
|
+
card.dataset.group = 'runtime'
|
|
688
|
+
|
|
689
|
+
const head = el('div', 'hqpu-engine-head')
|
|
690
|
+
const titleWrap = el('div', 'hqpu-engine-title')
|
|
691
|
+
const bolt = el('span', 'hqpu-engine-bolt')
|
|
692
|
+
bolt.innerHTML = BOLT_ICON(14)
|
|
693
|
+
titleWrap.append(bolt, el('span', '', copy.ui.runtimeEngine))
|
|
694
|
+
head.append(titleWrap)
|
|
695
|
+
if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
|
|
696
|
+
head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
|
|
697
|
+
}
|
|
698
|
+
card.append(head)
|
|
699
|
+
|
|
700
|
+
const offered = runtime.updateAvailable && latestVersion !== null
|
|
701
|
+
if (!offered && typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
702
|
+
card.append(el('p', 'hqpu-engine-error', `${copy.lastError}: ${runtime.lastError}`))
|
|
703
|
+
return card
|
|
704
|
+
}
|
|
705
|
+
if (!offered) return card
|
|
706
|
+
|
|
707
|
+
const latestEntry = runtime.latest
|
|
708
|
+
const row = el('div', 'hqpu-engine-row')
|
|
709
|
+
const info = el('div', 'hqpu-engine-info')
|
|
710
|
+
const newLine = el('div', 'hqpu-engine-new')
|
|
711
|
+
newLine.append(el('span', 'hqpu-ver-new hqpu-ver-new--lg', copy.ui.newVersion(latestVersion as string)))
|
|
712
|
+
const hasCritical =
|
|
713
|
+
(Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
|
|
714
|
+
(Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
|
|
715
|
+
if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
|
|
716
|
+
info.append(newLine)
|
|
717
|
+
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
718
|
+
const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
|
|
719
|
+
if (size.length > 0 || summary.length > 0) {
|
|
720
|
+
info.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
|
|
721
|
+
}
|
|
722
|
+
row.append(info)
|
|
723
|
+
|
|
724
|
+
if (actions.accepted) {
|
|
725
|
+
row.append(el('span', 'hqpu-engine-state', copy.runtime.updateAccepted))
|
|
726
|
+
} else if (actions.busy) {
|
|
727
|
+
row.append(el('span', 'hqpu-engine-state', copy.runtime.updating))
|
|
728
|
+
} else if (!actions.armed) {
|
|
729
|
+
const start = el('button', 'hqpu-btn--engine', copy.ui.upgradeCore)
|
|
730
|
+
start.type = 'button'
|
|
731
|
+
start.dataset.runtimeUpdate = ''
|
|
732
|
+
start.addEventListener('click', actions.onArm)
|
|
733
|
+
row.append(start)
|
|
670
734
|
}
|
|
671
|
-
|
|
735
|
+
card.append(row)
|
|
736
|
+
|
|
737
|
+
// Confirm replaces the action: a full-width block, never a 150 px column.
|
|
738
|
+
if (actions.armed && !actions.busy && !actions.accepted) {
|
|
739
|
+
const confirm = el('div', 'hqpu-engine-confirm')
|
|
740
|
+
confirm.append(
|
|
741
|
+
el('p', 'hqpu-engine-confirm-text', copy.runtime.updateConfirm(latestVersion as string)),
|
|
742
|
+
)
|
|
743
|
+
const buttons = el('div', 'hqpu-engine-confirm-actions')
|
|
744
|
+
const no = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
|
|
745
|
+
no.type = 'button'
|
|
746
|
+
no.addEventListener('click', actions.onCancel)
|
|
747
|
+
const yes = el('button', 'hqpu-btn--engine hqpu-btn--engine-solid', copy.ui.confirmRuntime)
|
|
748
|
+
yes.type = 'button'
|
|
749
|
+
yes.dataset.runtimeConfirm = ''
|
|
750
|
+
yes.addEventListener('click', actions.onConfirm)
|
|
751
|
+
buttons.append(no, yes)
|
|
752
|
+
confirm.append(buttons)
|
|
753
|
+
card.append(confirm)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const note = el('p', 'hqpu-engine-note')
|
|
757
|
+
note.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2L14.4 13.4H1.6L8 2Z"/><path d="M8 6.4V9.2"/><path d="M8 10.6V11.6"/></svg>`
|
|
758
|
+
note.append(el('span', '', copy.ui.runtimeRestartNote))
|
|
759
|
+
card.append(note)
|
|
760
|
+
return card
|
|
672
761
|
}
|
|
673
762
|
|
|
674
|
-
/**
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
763
|
+
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
764
|
+
function releaseBody(release: ReleaseInfo, copy: UpdateCopy): HTMLElement[] {
|
|
765
|
+
const out: HTMLElement[] = []
|
|
766
|
+
const notes = Array.isArray(release.notes)
|
|
767
|
+
? release.notes.filter((n) => typeof n === 'string' && n.length > 0)
|
|
768
|
+
: []
|
|
769
|
+
const breaking = Array.isArray(release.breaking)
|
|
770
|
+
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
771
|
+
: []
|
|
772
|
+
if (typeof release.summary === 'string' && release.summary.length > 0) {
|
|
773
|
+
out.push(el('p', 'hqpu-release-summary', release.summary))
|
|
774
|
+
}
|
|
775
|
+
if (notes.length > 0) {
|
|
776
|
+
const ul = el('ul', 'hqpu-release-notes')
|
|
777
|
+
for (const note of notes) ul.append(el('li', '', note as string))
|
|
778
|
+
out.push(ul)
|
|
779
|
+
}
|
|
780
|
+
if (breaking.length > 0) {
|
|
781
|
+
const banner = el('div', 'hqpu-banner')
|
|
782
|
+
banner.dataset.tone = 'danger'
|
|
783
|
+
const icon = el('span')
|
|
784
|
+
icon.innerHTML = WARNING_ICON(14)
|
|
785
|
+
banner.append(icon)
|
|
786
|
+
const inner = el('span')
|
|
787
|
+
inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
|
|
788
|
+
inner.append(document.createTextNode(breaking.join(' · ')))
|
|
789
|
+
banner.append(inner)
|
|
790
|
+
out.push(banner)
|
|
791
|
+
}
|
|
792
|
+
if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
|
|
793
|
+
out.push(
|
|
794
|
+
el('p', 'hqpu-release-meta', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())),
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
if (release.requiresRestart === true) {
|
|
798
|
+
out.push(el('p', 'hqpu-release-meta', copy.job.willRestartDsh))
|
|
799
|
+
}
|
|
800
|
+
if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
|
|
801
|
+
const link = el('a', 'hqpu-release-link', copy.release.notesLink)
|
|
802
|
+
link.href = release.notesUrl
|
|
803
|
+
link.target = '_blank'
|
|
804
|
+
link.rel = 'noreferrer'
|
|
805
|
+
out.push(link)
|
|
806
|
+
}
|
|
807
|
+
return out
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function hasReleaseContent(release: ReleaseInfo | null | undefined): boolean {
|
|
811
|
+
if (release === null || release === undefined) return false
|
|
812
|
+
return (
|
|
813
|
+
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
814
|
+
(Array.isArray(release.notes) && release.notes.length > 0) ||
|
|
815
|
+
(Array.isArray(release.breaking) && release.breaking.length > 0) ||
|
|
816
|
+
typeof release.notesUrl === 'string' ||
|
|
817
|
+
typeof release.publishedAt === 'string'
|
|
818
|
+
)
|
|
679
819
|
}
|
|
680
820
|
|
|
681
821
|
/**
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
* hq-edge serves both manifest sources from one shape (plugin release
|
|
685
|
-
* manifest, runtime-update manifest), and both are editable from this panel:
|
|
686
|
-
* an override is persisted per host, so pointing discovery at a staging
|
|
687
|
-
* channel (or a local test rig) needs no env var and no restart. The editor
|
|
688
|
-
* state lives in the panel closure for the same reason `armed` does — a store
|
|
689
|
-
* refresh must not discard a half-typed URL or silently close the editor.
|
|
822
|
+
* The job log drawer: title + outcome tag, the mono component list with a
|
|
823
|
+
* JOB_ID header, the result detail, older runs, and 复制日志 ID / 关闭.
|
|
690
824
|
*/
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
825
|
+
function buildLogDrawer(
|
|
826
|
+
job: UpdateJob,
|
|
827
|
+
recent: UpdateJob[],
|
|
828
|
+
copy: UpdateCopy,
|
|
829
|
+
onCopy: (id: string) => void,
|
|
830
|
+
onClose: () => void,
|
|
831
|
+
): HTMLElement[] {
|
|
832
|
+
const out: HTMLElement[] = []
|
|
833
|
+
|
|
834
|
+
const head = el('div', 'hqpu-log-head')
|
|
835
|
+
const titleWrap = el('div', 'hqpu-log-title')
|
|
836
|
+
titleWrap.append(el('span', 'hqpu-log-name', copy.ui.logTitle))
|
|
837
|
+
const tag = el('span', 'hqpu-tag')
|
|
838
|
+
tag.dataset.tone =
|
|
839
|
+
job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
|
|
840
|
+
tag.textContent =
|
|
841
|
+
job.phase === 'done'
|
|
842
|
+
? copy.job.resultDone
|
|
843
|
+
: job.phase === 'failed'
|
|
844
|
+
? copy.job.resultFailed
|
|
845
|
+
: job.phase === 'rolled-back'
|
|
846
|
+
? copy.job.resultRolledBack
|
|
847
|
+
: copy.job.resultAbandoned
|
|
848
|
+
titleWrap.append(tag)
|
|
849
|
+
const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--sm', CLOSE_ICON(14), copy.close, onClose)
|
|
850
|
+
head.append(titleWrap, closeBtn)
|
|
851
|
+
out.push(head)
|
|
852
|
+
|
|
853
|
+
const body = el('div', 'hqpu-log-body')
|
|
854
|
+
const block = el('div', 'hqpu-log-block')
|
|
855
|
+
const meta = el('div', 'hqpu-log-meta')
|
|
856
|
+
const logId = job.logHint !== undefined && job.logHint.length > 0 ? job.logHint : job.id
|
|
857
|
+
meta.append(el('span', '', `${copy.ui.jobIdLabel}: ${logId}`))
|
|
858
|
+
meta.append(el('span', '', shortWhen(job.result?.finishedAt ?? job.updatedAt)))
|
|
859
|
+
block.append(meta)
|
|
860
|
+
|
|
861
|
+
const steps = Array.isArray(job.steps) ? job.steps : []
|
|
862
|
+
if (steps.length > 0) {
|
|
863
|
+
for (const step of steps) {
|
|
864
|
+
const line = el('div', 'hqpu-log-line')
|
|
865
|
+
const dot = el('span', 'hqpu-log-dot')
|
|
866
|
+
dot.dataset.state = stepDotState(step.state)
|
|
867
|
+
line.append(dot)
|
|
868
|
+
const word = stepWord(step.state, copy)
|
|
869
|
+
const text =
|
|
870
|
+
step.message !== undefined && step.message.length > 0
|
|
871
|
+
? `${step.name} — ${step.message}`
|
|
872
|
+
: step.name
|
|
873
|
+
line.append(el('span', 'hqpu-log-line-text', word.length > 0 ? `${text} (${word})` : text))
|
|
874
|
+
block.append(line)
|
|
875
|
+
}
|
|
876
|
+
} else {
|
|
877
|
+
block.append(el('div', 'hqpu-log-line hqpu-log-line--empty', copy.job.stepsTitle))
|
|
878
|
+
}
|
|
879
|
+
body.append(block)
|
|
880
|
+
|
|
881
|
+
// Result detail: the transition, the error code, the server's own message.
|
|
882
|
+
const res = job.result
|
|
883
|
+
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
884
|
+
const to = res?.installedVersion ?? job.targetVersion
|
|
885
|
+
const detail = el('div', 'hqpu-log-detail')
|
|
886
|
+
detail.append(el('p', '', `${from} → ${to}`))
|
|
887
|
+
if (res?.errorCode !== undefined && res.errorCode.length > 0) {
|
|
888
|
+
detail.append(el('code', 'hqpu-error-code', res.errorCode))
|
|
889
|
+
}
|
|
890
|
+
if (res?.message !== undefined && res.message.length > 0) {
|
|
891
|
+
detail.append(el('p', '', res.message))
|
|
892
|
+
}
|
|
893
|
+
body.append(detail)
|
|
894
|
+
|
|
895
|
+
const older = recent.filter((entry) => entry.id !== job.id).slice(0, 4)
|
|
896
|
+
if (older.length > 0) {
|
|
897
|
+
const list = el('div', 'hqpu-log-recent')
|
|
898
|
+
list.append(el('p', 'hqpu-log-recent-title', copy.job.recentTitle))
|
|
899
|
+
for (const entry of older) {
|
|
900
|
+
const row = el('div', 'hqpu-log-recent-row')
|
|
901
|
+
row.append(el('span', '', `${jobKindLabel(entry, copy)} ${entry.targetVersion}`))
|
|
902
|
+
row.append(el('span', 'hqpu-log-recent-meta', `${phaseLabel(entry, copy)} · ${shortWhen(entry.updatedAt)}`))
|
|
903
|
+
list.append(row)
|
|
904
|
+
}
|
|
905
|
+
body.append(list)
|
|
906
|
+
}
|
|
907
|
+
out.push(body)
|
|
908
|
+
|
|
909
|
+
const foot = el('div', 'hqpu-log-foot')
|
|
910
|
+
const copyBtn = el('button', 'hqpu-link hqpu-link--icon')
|
|
911
|
+
copyBtn.type = 'button'
|
|
912
|
+
copyBtn.dataset.copyLog = ''
|
|
913
|
+
const copyIcon = el('span')
|
|
914
|
+
copyIcon.innerHTML = COPY_ICON(12)
|
|
915
|
+
copyBtn.append(copyIcon, el('span', '', copy.ui.copyLogId))
|
|
916
|
+
copyBtn.addEventListener('click', () => onCopy(logId))
|
|
917
|
+
const closeFoot = el('button', 'hqpu-btn hqpu-btn--sm', copy.close)
|
|
918
|
+
closeFoot.type = 'button'
|
|
919
|
+
closeFoot.addEventListener('click', onClose)
|
|
920
|
+
foot.append(copyBtn, closeFoot)
|
|
921
|
+
out.push(foot)
|
|
922
|
+
|
|
923
|
+
return out
|
|
704
924
|
}
|
|
705
925
|
|
|
926
|
+
// ── The runtime action contract ────────────────────────────────────────────
|
|
927
|
+
|
|
706
928
|
/**
|
|
707
929
|
* The runtime-update action row's state and callbacks.
|
|
708
930
|
*
|
|
709
|
-
* Installing the runtime is destructive in a way a plugin install is not:
|
|
710
|
-
* shuts itself down so the EDA host can swap the runtime directory. So
|
|
711
|
-
* is a two-step confirm (arm → confirm), never a single
|
|
931
|
+
* Installing the runtime is destructive in a way a plugin install is not:
|
|
932
|
+
* hq-edge shuts itself down so the EDA host can swap the runtime directory. So
|
|
933
|
+
* the row is a two-step confirm (arm → confirm), never a single click.
|
|
712
934
|
*/
|
|
713
|
-
export interface RuntimeActions
|
|
935
|
+
export interface RuntimeActions {
|
|
714
936
|
/** A request is in flight (hq-edge accepted it and is restarting). */
|
|
715
937
|
busy: boolean
|
|
716
938
|
/** The confirm step is armed for the version the row names. */
|
|
@@ -718,8 +940,7 @@ export interface RuntimeActions extends SourceEditorActions {
|
|
|
718
940
|
/**
|
|
719
941
|
* hq-edge answered `202 Accepted`: from here on the outcome belongs to the
|
|
720
942
|
* EDA host, and this process is about to be replaced. Terminal — the row
|
|
721
|
-
* must never re-offer the button once this is true
|
|
722
|
-
* already gone and a second request could only fail.
|
|
943
|
+
* must never re-offer the button once this is true.
|
|
723
944
|
*/
|
|
724
945
|
accepted: boolean
|
|
725
946
|
onArm(): void
|
|
@@ -728,11 +949,11 @@ export interface RuntimeActions extends SourceEditorActions {
|
|
|
728
949
|
}
|
|
729
950
|
|
|
730
951
|
/**
|
|
731
|
-
* Normalize
|
|
952
|
+
* Normalize a report's source field to the full shape.
|
|
732
953
|
*
|
|
733
954
|
* An older hq-edge predates the feature and sends no `source` at all — that
|
|
734
|
-
* must degrade to
|
|
735
|
-
*
|
|
955
|
+
* must degrade to no editor, not crash the render. A future server may add
|
|
956
|
+
* fields; unknown extras are dropped here rather than trusted.
|
|
736
957
|
*/
|
|
737
958
|
function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
|
|
738
959
|
if (raw === null || typeof raw !== 'object') return null
|
|
@@ -746,465 +967,36 @@ function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
|
|
|
746
967
|
}
|
|
747
968
|
}
|
|
748
969
|
|
|
749
|
-
|
|
750
|
-
function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
|
|
751
|
-
switch (origin) {
|
|
752
|
-
case 'override':
|
|
753
|
-
return copy.originOverride
|
|
754
|
-
case 'config':
|
|
755
|
-
return copy.originConfig
|
|
756
|
-
default:
|
|
757
|
-
return copy.originUnset
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/** The 更新源 fact row: the URL in effect plus an origin tag. */
|
|
762
|
-
function sourceFactRow(
|
|
763
|
-
facts: HTMLElement,
|
|
764
|
-
source: RuntimeUpdateSource,
|
|
765
|
-
copy: UpdateCopy,
|
|
766
|
-
): void {
|
|
767
|
-
const wrap = el('div')
|
|
768
|
-
wrap.append(el('dt', '', copy.runtime.source))
|
|
769
|
-
const dd = el('dd')
|
|
770
|
-
const line = el('div', 'hqpu-source-line')
|
|
771
|
-
const code = el(
|
|
772
|
-
'code',
|
|
773
|
-
'hqpu-source-url',
|
|
774
|
-
source.url.length > 0 ? source.url : copy.runtime.notConfigured,
|
|
775
|
-
)
|
|
776
|
-
const tag = el('span', 'hqpu-tag')
|
|
777
|
-
tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
|
|
778
|
-
tag.textContent = originLabel(source.origin, copy)
|
|
779
|
-
line.append(code, tag)
|
|
780
|
-
dd.append(line)
|
|
781
|
-
wrap.append(dd)
|
|
782
|
-
facts.append(wrap)
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/** Which manifest an editor belongs to — drives data attributes + CSS hook. */
|
|
786
|
-
export type SourceKind = 'runtime' | 'plugin'
|
|
787
|
-
|
|
788
|
-
/** The localized strings one source editor needs (both kinds share the flow). */
|
|
789
|
-
interface SourceEditorLabels {
|
|
790
|
-
hint: string
|
|
791
|
-
edit: string
|
|
792
|
-
save: string
|
|
793
|
-
reset: string
|
|
794
|
-
inputLabel: string
|
|
795
|
-
placeholder: string
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/** Pick the labels for one kind out of the copy table. */
|
|
799
|
-
function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
|
|
800
|
-
return kind === 'runtime'
|
|
801
|
-
? {
|
|
802
|
-
hint: copy.runtime.sourceHint,
|
|
803
|
-
edit: copy.runtime.sourceEdit,
|
|
804
|
-
save: copy.runtime.sourceSave,
|
|
805
|
-
reset: copy.runtime.sourceReset,
|
|
806
|
-
inputLabel: copy.runtime.sourceInputLabel,
|
|
807
|
-
placeholder: copy.runtime.sourcePlaceholder,
|
|
808
|
-
}
|
|
809
|
-
: {
|
|
810
|
-
hint: copy.pluginSource.hint,
|
|
811
|
-
edit: copy.pluginSource.edit,
|
|
812
|
-
save: copy.pluginSource.save,
|
|
813
|
-
reset: copy.pluginSource.reset,
|
|
814
|
-
inputLabel: copy.pluginSource.inputLabel,
|
|
815
|
-
placeholder: copy.pluginSource.placeholder,
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* One update-source editor, shared by the plugin manifest and the runtime
|
|
821
|
-
* manifest (the only differences are the labels and the data-attribute
|
|
822
|
-
* prefix, so tests and CSS can target each).
|
|
823
|
-
*
|
|
824
|
-
* Read mode is a hint plus a 修改 button; edit mode is the URL field with
|
|
825
|
-
* 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
|
|
826
|
-
* URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
|
|
827
|
-
* a background store refresh re-renders the typed URL instead of discarding it.
|
|
828
|
-
*/
|
|
829
|
-
function sourceEditor(
|
|
830
|
-
kind: SourceKind,
|
|
831
|
-
source: RuntimeUpdateSource,
|
|
832
|
-
copy: UpdateCopy,
|
|
833
|
-
actions: SourceEditorActions,
|
|
834
|
-
): HTMLElement {
|
|
835
|
-
const labels = sourceLabels(copy, kind)
|
|
836
|
-
const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
|
|
837
|
-
if (!actions.sourceEditing) {
|
|
838
|
-
// Read mode: show the URL currently in effect (mono, muted), then edit.
|
|
839
|
-
const urlLine = el('p', 'hqpu-source-current-url', source.url)
|
|
840
|
-
urlLine.style.cssText =
|
|
841
|
-
'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-tertiary);word-break:break-all;'
|
|
842
|
-
block.append(urlLine)
|
|
843
|
-
block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
|
|
844
|
-
const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
|
|
845
|
-
edit.type = 'button'
|
|
846
|
-
edit.setAttribute(`data-${kind}-source-edit`, '')
|
|
847
|
-
edit.disabled = actions.sourceSaving
|
|
848
|
-
edit.addEventListener('click', actions.onSourceEdit)
|
|
849
|
-
block.append(edit)
|
|
850
|
-
return block
|
|
851
|
-
}
|
|
852
|
-
const input = el('input', 'hqpu-source-input')
|
|
853
|
-
input.type = 'url'
|
|
854
|
-
input.value = actions.sourceDraft
|
|
855
|
-
input.placeholder = labels.placeholder
|
|
856
|
-
input.setAttribute('aria-label', labels.inputLabel)
|
|
857
|
-
input.setAttribute(`data-${kind}-source-input`, '')
|
|
858
|
-
input.disabled = actions.sourceSaving
|
|
859
|
-
input.addEventListener('input', () => {
|
|
860
|
-
// Draft-only: no re-render, so the caret and IME stay untouched.
|
|
861
|
-
actions.onSourceDraft(input.value)
|
|
862
|
-
})
|
|
863
|
-
// Enter saves, Escape cancels — the field is the whole editor, and Escape
|
|
864
|
-
// must not fall through to the panel-wide close handler.
|
|
865
|
-
input.addEventListener('keydown', (ev) => {
|
|
866
|
-
if (ev.key === 'Enter') {
|
|
867
|
-
ev.preventDefault()
|
|
868
|
-
actions.onSourceSave()
|
|
869
|
-
} else if (ev.key === 'Escape') {
|
|
870
|
-
ev.preventDefault()
|
|
871
|
-
ev.stopPropagation()
|
|
872
|
-
actions.onSourceCancel()
|
|
873
|
-
}
|
|
874
|
-
})
|
|
875
|
-
const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
|
|
876
|
-
save.type = 'button'
|
|
877
|
-
save.setAttribute(`data-${kind}-source-save`, '')
|
|
878
|
-
save.disabled = actions.sourceSaving
|
|
879
|
-
save.addEventListener('click', actions.onSourceSave)
|
|
880
|
-
const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
|
|
881
|
-
cancel.type = 'button'
|
|
882
|
-
cancel.setAttribute(`data-${kind}-source-cancel`, '')
|
|
883
|
-
cancel.disabled = actions.sourceSaving
|
|
884
|
-
cancel.addEventListener('click', actions.onSourceCancel)
|
|
885
|
-
const controls = el('div', 'hqpu-actions hqpu-source-controls')
|
|
886
|
-
controls.append(input, save, cancel)
|
|
887
|
-
if (source.override) {
|
|
888
|
-
const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
|
|
889
|
-
reset.type = 'button'
|
|
890
|
-
reset.setAttribute(`data-${kind}-source-reset`, '')
|
|
891
|
-
reset.disabled = actions.sourceSaving
|
|
892
|
-
reset.addEventListener('click', actions.onSourceReset)
|
|
893
|
-
controls.append(reset)
|
|
894
|
-
}
|
|
895
|
-
block.append(controls)
|
|
896
|
-
// Focus on open — and on any re-render while editing, which is exactly what
|
|
897
|
-
// a rejected save wants (the user is dropped back into the field to fix it).
|
|
898
|
-
input.focus()
|
|
899
|
-
return block
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/**
|
|
903
|
-
* The plugin release-manifest source, as its own group above the plugin
|
|
904
|
-
* groups: it says where the whole list comes from, so it belongs above the
|
|
905
|
-
* list rather than inside it. Absent on an older hq-edge (no `pluginSource`
|
|
906
|
-
* in the report) — then nothing is rendered at all.
|
|
907
|
-
*/
|
|
908
|
-
function pluginSourceGroup(
|
|
909
|
-
report: PluginUpdateReport,
|
|
910
|
-
copy: UpdateCopy,
|
|
911
|
-
actions: SourceEditorActions,
|
|
912
|
-
): HTMLElement | null {
|
|
913
|
-
const source = normalizeSource(report.pluginSource)
|
|
914
|
-
if (source === null) return null
|
|
915
|
-
const section = el('section', 'hqpu-group')
|
|
916
|
-
section.dataset.group = 'plugin-source'
|
|
917
|
-
const head = el('div', 'hqpu-group-head')
|
|
918
|
-
head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
|
|
919
|
-
section.append(head)
|
|
920
|
-
|
|
921
|
-
const facts = el('dl', 'hqpu-facts')
|
|
922
|
-
sourceFactRow(facts, source, copy)
|
|
923
|
-
section.append(facts)
|
|
924
|
-
section.append(sourceEditor('plugin', source, copy, actions))
|
|
925
|
-
return section
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
/**
|
|
929
|
-
* The host's own report of the last runtime swap. Rolled-back / failed renders
|
|
930
|
-
* as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
|
|
931
|
-
* succeeded renders as a subtle green inline line — not a loud banner. The
|
|
932
|
-
* plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
|
|
933
|
-
*/
|
|
934
|
-
function runtimeResultAlert(
|
|
935
|
-
report: PluginUpdateReport,
|
|
936
|
-
copy: UpdateCopy,
|
|
937
|
-
dismissed: boolean,
|
|
938
|
-
onDismiss: () => void,
|
|
939
|
-
): HTMLElement | null {
|
|
940
|
-
const lastResult = report.runtime.lastResult
|
|
941
|
-
if (lastResult === null || lastResult === undefined) return null
|
|
942
|
-
|
|
943
|
-
// Green inline confirmation — quiet by design.
|
|
944
|
-
if (lastResult.state === 'succeeded') {
|
|
945
|
-
const line = el('p', 'hqpu-alert-green')
|
|
946
|
-
line.dataset.role = 'runtime-success'
|
|
947
|
-
line.textContent = copy.ui.successInline
|
|
948
|
-
return line
|
|
949
|
-
}
|
|
950
|
-
if (dismissed) return null
|
|
951
|
-
|
|
952
|
-
const section = el('section', 'hqpu-group')
|
|
953
|
-
section.dataset.group = 'runtime-alert'
|
|
954
|
-
const card = el('div', 'hqpu-alert hqpu-alert--amber')
|
|
955
|
-
card.innerHTML = WARNING_ICON(14)
|
|
956
|
-
|
|
957
|
-
const body = el('div')
|
|
958
|
-
const title = el('div', 'hqpu-alert-title')
|
|
959
|
-
title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
|
|
960
|
-
body.append(title)
|
|
961
|
-
|
|
962
|
-
const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
|
|
963
|
-
const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
|
|
964
|
-
? lastResult.errorCode
|
|
965
|
-
: '—'
|
|
966
|
-
const text =
|
|
967
|
-
lastResult.state === 'failed'
|
|
968
|
-
? copy.ui.failedBody(oldVer, code)
|
|
969
|
-
: copy.ui.rollbackBody(oldVer, code)
|
|
970
|
-
// Build the body as text + an inline <code> chip for the error code.
|
|
971
|
-
const bodyP = el('p', 'hqpu-alert-body')
|
|
972
|
-
const textOnly =
|
|
973
|
-
lastResult.state === 'failed'
|
|
974
|
-
? copy.ui.failedBody(oldVer, '')
|
|
975
|
-
: copy.ui.rollbackBody(oldVer, '')
|
|
976
|
-
// Strip the trailing "code" placeholder we appended; re-add as a chip.
|
|
977
|
-
bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
|
|
978
|
-
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
979
|
-
bodyP.append(document.createTextNode(' '))
|
|
980
|
-
const chip = el('code', '', lastResult.errorCode)
|
|
981
|
-
bodyP.append(chip)
|
|
982
|
-
}
|
|
983
|
-
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
984
|
-
bodyP.append(el('br'), document.createTextNode(lastResult.message))
|
|
985
|
-
}
|
|
986
|
-
body.append(bodyP)
|
|
987
|
-
|
|
988
|
-
// Dismiss action (no log button — the plugin has no log-open affordance).
|
|
989
|
-
const actions = el('div', 'hqpu-alert-actions')
|
|
990
|
-
const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
|
|
991
|
-
dismiss.type = 'button'
|
|
992
|
-
dismiss.addEventListener('click', onDismiss)
|
|
993
|
-
actions.append(dismiss)
|
|
994
|
-
body.append(actions)
|
|
995
|
-
|
|
996
|
-
card.append(body)
|
|
997
|
-
section.append(card)
|
|
998
|
-
return section
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
/** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
|
|
1002
|
-
* 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
|
|
1003
|
-
* 升级核心 button (existing arm→confirm flow), and the amber restart note. */
|
|
1004
|
-
function runtimeEngineCard(
|
|
1005
|
-
report: PluginUpdateReport,
|
|
1006
|
-
copy: UpdateCopy,
|
|
1007
|
-
actions: RuntimeActions,
|
|
1008
|
-
): HTMLElement | null {
|
|
1009
|
-
const runtime = report.runtime
|
|
1010
|
-
const latestVersion = runtimeLatestVersion(runtime)
|
|
1011
|
-
const card = el('section', 'hqpu-engine')
|
|
1012
|
-
card.dataset.group = 'runtime'
|
|
1013
|
-
|
|
1014
|
-
const head = el('div', 'hqpu-engine-head')
|
|
1015
|
-
const titleWrap = el('div', 'hqpu-engine-title')
|
|
1016
|
-
titleWrap.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.33333 2L4.66667 8H8L6.66667 14L11.33333 8H8L9.33333 2Z"/></svg>`
|
|
1017
|
-
titleWrap.append(el('span', '', copy.ui.runtimeEngine))
|
|
1018
|
-
head.append(titleWrap)
|
|
1019
|
-
if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
|
|
1020
|
-
head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
|
|
1021
|
-
}
|
|
1022
|
-
card.append(head)
|
|
1023
|
-
|
|
1024
|
-
// Update row (only when a newer distribution is known).
|
|
1025
|
-
if (runtime.updateAvailable && latestVersion !== null) {
|
|
1026
|
-
const row = el('div', 'hqpu-engine-update-row')
|
|
1027
|
-
const left = el('div')
|
|
1028
|
-
const newLine = el('div', 'hqpu-engine-new-line')
|
|
1029
|
-
newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
|
|
1030
|
-
const latestEntry = runtime.latest
|
|
1031
|
-
const hasCritical =
|
|
1032
|
-
(Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
|
|
1033
|
-
(Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
|
|
1034
|
-
if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
|
|
1035
|
-
left.append(newLine)
|
|
1036
|
-
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
1037
|
-
const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
|
|
1038
|
-
if (size.length > 0 || summary.length > 0) {
|
|
1039
|
-
left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
|
|
1040
|
-
}
|
|
1041
|
-
row.append(left)
|
|
1042
|
-
|
|
1043
|
-
// Upgrade action: arm → confirm, accepted/busy states preserved.
|
|
1044
|
-
if (actions.accepted) {
|
|
1045
|
-
row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
|
|
1046
|
-
} else if (actions.busy) {
|
|
1047
|
-
row.append(el('span', 'hqpu-status', copy.runtime.updating))
|
|
1048
|
-
} else if (actions.armed) {
|
|
1049
|
-
const confirmWrap = el('div')
|
|
1050
|
-
confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
|
|
1051
|
-
const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
|
|
1052
|
-
yes.type = 'button'
|
|
1053
|
-
yes.dataset.runtimeConfirm = ''
|
|
1054
|
-
yes.addEventListener('click', actions.onConfirm)
|
|
1055
|
-
const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
|
|
1056
|
-
no.type = 'button'
|
|
1057
|
-
no.addEventListener('click', actions.onCancel)
|
|
1058
|
-
confirmWrap.append(yes, no)
|
|
1059
|
-
row.append(confirmWrap)
|
|
1060
|
-
} else {
|
|
1061
|
-
const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
|
|
1062
|
-
start.type = 'button'
|
|
1063
|
-
start.dataset.runtimeUpdate = ''
|
|
1064
|
-
start.addEventListener('click', actions.onArm)
|
|
1065
|
-
row.append(start)
|
|
1066
|
-
}
|
|
1067
|
-
card.append(row)
|
|
1068
|
-
const note = el('p', 'hqpu-engine-note')
|
|
1069
|
-
note.innerHTML =
|
|
1070
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5L14 13H2L8 2.5Z"/></svg><span>${copy.ui.runtimeRestartNote}</span>`
|
|
1071
|
-
card.append(note)
|
|
1072
|
-
} else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
1073
|
-
card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
|
|
1074
|
-
}
|
|
1075
|
-
return card
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
function renderList(
|
|
1079
|
-
section: HTMLElement,
|
|
1080
|
-
report: PluginUpdateReport | null,
|
|
1081
|
-
copy: UpdateCopy,
|
|
1082
|
-
changelogOpen: Set<string>,
|
|
1083
|
-
allOpen: boolean,
|
|
1084
|
-
onToggleCard: (name: string) => void,
|
|
1085
|
-
onToggleAll: () => void,
|
|
1086
|
-
onRetryCheck: () => void,
|
|
1087
|
-
onInstall: (name: string) => void,
|
|
1088
|
-
onIgnore: (item: PluginUpdateItem) => void,
|
|
1089
|
-
): void {
|
|
1090
|
-
section.textContent = ''
|
|
1091
|
-
if (report === null) {
|
|
1092
|
-
section.append(skeleton(copy))
|
|
1093
|
-
return
|
|
1094
|
-
}
|
|
1095
|
-
// Check-status banner: a failed check shows an error row + Retry (never the
|
|
1096
|
-
// silent "no updates" empty state); a successful check shows the time.
|
|
1097
|
-
const banner = checkBanner(report, copy, onRetryCheck)
|
|
1098
|
-
if (banner !== null) section.append(banner)
|
|
1099
|
-
// Set-level release metadata (v2 manifest): summary, notes, breaking.
|
|
1100
|
-
const release = releaseHeader(report.release, copy)
|
|
1101
|
-
if (release !== null) section.append(release)
|
|
1102
|
-
if (report.items.length === 0) {
|
|
1103
|
-
// A successful check with zero updates — distinct from a failed check,
|
|
1104
|
-
// which already rendered its error row above.
|
|
1105
|
-
if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
|
|
1106
|
-
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
1107
|
-
}
|
|
1108
|
-
return
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
1112
|
-
report.items.filter((item) => item.state === state)
|
|
1113
|
-
|
|
1114
|
-
// Most important first: what can be acted on, then what was muted, then what
|
|
1115
|
-
// is already settled. Blocked plugins are intentionally skipped — the server
|
|
1116
|
-
// still reports them, but they carry no user-actionable offer, so surfacing
|
|
1117
|
-
// them only adds noise without a path forward.
|
|
1118
|
-
const order: Array<[string, PluginUpdateItem[]]> = [
|
|
1119
|
-
[copy.group.actionable, [...byState('update'), ...byState('install')]],
|
|
1120
|
-
[copy.group.ignored, byState('ignored')],
|
|
1121
|
-
[copy.group.current, byState('current')],
|
|
1122
|
-
]
|
|
1123
|
-
for (const [heading, items] of order) {
|
|
1124
|
-
const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll, onInstall, onIgnore)
|
|
1125
|
-
if (node !== null) section.append(node)
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function renderRuntime(
|
|
1130
|
-
section: HTMLElement,
|
|
1131
|
-
report: PluginUpdateReport | null,
|
|
1132
|
-
copy: UpdateCopy,
|
|
1133
|
-
actions: RuntimeActions,
|
|
1134
|
-
dismissedAlert: boolean,
|
|
1135
|
-
onDismissAlert: () => void,
|
|
1136
|
-
): void {
|
|
1137
|
-
section.textContent = ''
|
|
1138
|
-
if (report === null) return
|
|
1139
|
-
const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
|
|
1140
|
-
if (alert !== null) section.append(alert)
|
|
1141
|
-
const engine = runtimeEngineCard(report, copy, actions)
|
|
1142
|
-
if (engine !== null) section.append(engine)
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/** The plugin-source group, above the plugin groups, hidden behind an advanced
|
|
1146
|
-
* disclosure (manifest-URL editing is a developer affordance). No-op without
|
|
1147
|
-
* the field. */
|
|
1148
|
-
function renderPluginSource(
|
|
1149
|
-
section: HTMLElement,
|
|
1150
|
-
report: PluginUpdateReport | null,
|
|
1151
|
-
copy: UpdateCopy,
|
|
1152
|
-
actions: SourceEditorActions,
|
|
1153
|
-
): void {
|
|
1154
|
-
section.textContent = ''
|
|
1155
|
-
if (report === null) return
|
|
1156
|
-
const node = pluginSourceGroup(report, copy, actions)
|
|
1157
|
-
if (node !== null) section.append(advancedBlock(copy, [node]))
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/** Run the install transaction (a new durable job), then let the store poll it. */
|
|
1161
|
-
async function runInstall(store: UpdateStoreLike, copy: UpdateCopy, pluginNames?: string[]): Promise<void> {
|
|
1162
|
-
const report = store.report()
|
|
1163
|
-
if (report === null) return
|
|
1164
|
-
// Without an evaluated set there is nothing to install: hq-edge has not read
|
|
1165
|
-
// a manifest yet, so the report is still its empty shell.
|
|
1166
|
-
if (report.manifestVersion === null) {
|
|
1167
|
-
showToast(copy.checkFailed, 'error')
|
|
1168
|
-
return
|
|
1169
|
-
}
|
|
1170
|
-
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1171
|
-
// action buttons and shows the "DSH will restart" footer state (previously
|
|
1172
|
-
// dead code — runInstall never called it).
|
|
1173
|
-
store.setInstalling(true)
|
|
1174
|
-
try {
|
|
1175
|
-
const result = await installSet(report.manifestVersion, pluginNames)
|
|
1176
|
-
if (result.error !== undefined) {
|
|
1177
|
-
store.setInstalling(false)
|
|
1178
|
-
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1179
|
-
return
|
|
1180
|
-
}
|
|
1181
|
-
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1182
|
-
// poller renders progress and clears the flag when the job settles.
|
|
1183
|
-
showToast(copy.installSuccess, 'success')
|
|
1184
|
-
void store.fetchStatus()
|
|
1185
|
-
} catch (err: unknown) {
|
|
1186
|
-
store.setInstalling(false)
|
|
1187
|
-
showToast(
|
|
1188
|
-
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1189
|
-
'error',
|
|
1190
|
-
)
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
970
|
+
// ── Toast ─────────────────────────────────────────────────────────────────
|
|
1193
971
|
|
|
1194
972
|
/** The single live toast; a new message replaces the one on screen. */
|
|
1195
973
|
let toastHost: HTMLElement | null = null
|
|
1196
974
|
let toastTimer: number | null = null
|
|
975
|
+
/** Copy for the toast's close affordance; set when the panel mounts. */
|
|
976
|
+
let activeCopy: UpdateCopy | null = null
|
|
1197
977
|
|
|
1198
978
|
function showToast(message: string, tone: 'success' | 'error'): void {
|
|
1199
979
|
if (toastHost === null) return
|
|
1200
980
|
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
1201
981
|
toastHost.textContent = ''
|
|
1202
982
|
const toast = el('div', 'hqpu-toast')
|
|
1203
|
-
|
|
983
|
+
// An error must interrupt; a success may wait for a pause.
|
|
984
|
+
toast.setAttribute('role', tone === 'error' ? 'alert' : 'status')
|
|
1204
985
|
const icon = el('span', 'hqpu-toast-icon')
|
|
1205
986
|
icon.dataset.tone = tone
|
|
1206
987
|
icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
|
|
1207
988
|
toast.append(icon, el('span', 'hqpu-toast-text', message))
|
|
989
|
+
const close = iconButton(
|
|
990
|
+
'hqpu-toast-close',
|
|
991
|
+
CLOSE_ICON(12),
|
|
992
|
+
activeCopy?.close ?? 'Close',
|
|
993
|
+
() => {
|
|
994
|
+
toastHost?.replaceChildren()
|
|
995
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
996
|
+
toastTimer = null
|
|
997
|
+
},
|
|
998
|
+
)
|
|
999
|
+
toast.append(close)
|
|
1208
1000
|
toastHost.append(toast)
|
|
1209
1001
|
toastTimer = window.setTimeout(() => {
|
|
1210
1002
|
toastHost?.replaceChildren()
|
|
@@ -1212,100 +1004,7 @@ function showToast(message: string, tone: 'success' | 'error'): void {
|
|
|
1212
1004
|
}, TOAST_HOLD_MS)
|
|
1213
1005
|
}
|
|
1214
1006
|
|
|
1215
|
-
|
|
1216
|
-
* The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
|
|
1217
|
-
* EXISTING plugin-source and runtime-source editors (POST /plugin-source,
|
|
1218
|
-
* POST /runtime-source, save→check→refresh already wired). No new endpoints:
|
|
1219
|
-
* if a manifest source has no data, that field is omitted rather than faked.
|
|
1220
|
-
*/
|
|
1221
|
-
function renderSettingsPanel(
|
|
1222
|
-
panel: HTMLElement,
|
|
1223
|
-
report: PluginUpdateReport | null,
|
|
1224
|
-
copy: UpdateCopy,
|
|
1225
|
-
open: boolean,
|
|
1226
|
-
pluginActions: SourceEditorActions,
|
|
1227
|
-
runtimeActions: SourceEditorActions,
|
|
1228
|
-
): void {
|
|
1229
|
-
panel.textContent = ''
|
|
1230
|
-
panel.style.display = open ? '' : 'none'
|
|
1231
|
-
if (!open || report === null) return
|
|
1232
|
-
|
|
1233
|
-
const row = el('div', 'hqpu-settings-row')
|
|
1234
|
-
row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
|
|
1235
|
-
// Origin badge: custom override (amber) vs the default configured source (green).
|
|
1236
|
-
const pluginSource = normalizeSource(report.pluginSource)
|
|
1237
|
-
const runtimeSource = normalizeSource(report.runtime.source)
|
|
1238
|
-
const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
|
|
1239
|
-
const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
|
|
1240
|
-
badge.dataset.origin = overridden ? 'override' : 'config'
|
|
1241
|
-
row.append(badge)
|
|
1242
|
-
panel.append(row)
|
|
1243
|
-
|
|
1244
|
-
if (pluginSource !== null) {
|
|
1245
|
-
panel.append(el('label', '', copy.ui.pluginUrlLabel))
|
|
1246
|
-
panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
|
|
1247
|
-
}
|
|
1248
|
-
if (runtimeSource !== null) {
|
|
1249
|
-
panel.append(el('label', '', copy.ui.runtimeUrlLabel))
|
|
1250
|
-
panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
|
|
1256
|
-
* full-width primary "一键更新全套插件 (vX)" (install = one durable job),
|
|
1257
|
-
* a secondary 忽略 wired to the existing ignore/unignore affordance, and the
|
|
1258
|
-
* footnote. The count EXCLUDES ignored / already-current entries.
|
|
1259
|
-
*/
|
|
1260
|
-
function renderFooter(
|
|
1261
|
-
footer: HTMLElement,
|
|
1262
|
-
installBtn: HTMLButtonElement,
|
|
1263
|
-
ignoreBtn: HTMLButtonElement,
|
|
1264
|
-
countEl: HTMLElement,
|
|
1265
|
-
setEl: HTMLElement,
|
|
1266
|
-
report: PluginUpdateReport | null,
|
|
1267
|
-
copy: UpdateCopy,
|
|
1268
|
-
installing: boolean,
|
|
1269
|
-
store: UpdateStoreLike,
|
|
1270
|
-
): void {
|
|
1271
|
-
if (report === null) {
|
|
1272
|
-
countEl.textContent = ''
|
|
1273
|
-
setEl.textContent = ''
|
|
1274
|
-
installBtn.disabled = true
|
|
1275
|
-
ignoreBtn.style.display = 'none'
|
|
1276
|
-
return
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
const actionable = report.items.filter(
|
|
1280
|
-
(item) => item.state === 'update' || item.state === 'install',
|
|
1281
|
-
).length
|
|
1282
|
-
const hasSet = report.manifestVersion !== null
|
|
1283
|
-
const setVersion: string = report.manifestVersion ?? ''
|
|
1284
|
-
|
|
1285
|
-
countEl.textContent = copy.ui.footerCount(actionable)
|
|
1286
|
-
setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
|
|
1287
|
-
|
|
1288
|
-
// Install-all: honest set-level label; disabled while busy or nothing to do.
|
|
1289
|
-
installBtn.textContent = hasSet
|
|
1290
|
-
? copy.ui.installAllSet(setVersion)
|
|
1291
|
-
: copy.ui.installAllSet('—')
|
|
1292
|
-
installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
|
|
1293
|
-
installBtn.dataset.busy = installing ? 'true' : 'false'
|
|
1294
|
-
installBtn.onclick = () => {
|
|
1295
|
-
void runInstall(store, copy)
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// Secondary ignore button: only when there is an evaluated set to ignore.
|
|
1299
|
-
ignoreBtn.style.display = hasSet ? '' : 'none'
|
|
1300
|
-
ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
|
|
1301
|
-
ignoreBtn.onclick = () => {
|
|
1302
|
-
const version = report.manifestVersion as string
|
|
1303
|
-
const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
|
|
1304
|
-
void action
|
|
1305
|
-
.then(() => store.refresh())
|
|
1306
|
-
.catch(() => undefined)
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1007
|
+
// ── Panel ─────────────────────────────────────────────────────────────────
|
|
1309
1008
|
|
|
1310
1009
|
/**
|
|
1311
1010
|
* Open (or focus) the update panel. Re-renders from the store; when the store
|
|
@@ -1318,6 +1017,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1318
1017
|
return
|
|
1319
1018
|
}
|
|
1320
1019
|
|
|
1020
|
+
const previouslyFocused = document.activeElement as HTMLElement | null
|
|
1021
|
+
|
|
1321
1022
|
const overlay = el('div', 'hqpu-overlay')
|
|
1322
1023
|
overlay.setAttribute('data-hq-plugin-update-view', '')
|
|
1323
1024
|
overlay.addEventListener('click', (ev) => {
|
|
@@ -1328,15 +1029,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1328
1029
|
const panel = el('div', 'hqpu-panel')
|
|
1329
1030
|
panel.setAttribute('role', 'dialog')
|
|
1330
1031
|
panel.setAttribute('aria-modal', 'true')
|
|
1331
|
-
panel.setAttribute('aria-label', copy.
|
|
1032
|
+
panel.setAttribute('aria-label', copy.ui.title)
|
|
1332
1033
|
panel.tabIndex = -1
|
|
1333
1034
|
|
|
1334
|
-
// ──
|
|
1335
|
-
|
|
1336
|
-
const head = el('div', 'hqpu-head')
|
|
1035
|
+
// ── Header ──
|
|
1036
|
+
const head = el('header', 'hqpu-head')
|
|
1337
1037
|
const headMain = el('div', 'hqpu-head-main')
|
|
1338
1038
|
const brandIcon = el('span', 'hqpu-brand-icon')
|
|
1339
|
-
brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1039
|
+
brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13.8 8.6A5.8 5.8 0 012.2 6.4"/><path d="M13.8 4.4V8.6H9.6"/><path d="M2.2 7.4A5.8 5.8 0 0113.8 9.6"/><path d="M2.2 11.6V7.4H6.4"/></svg>`
|
|
1340
1040
|
const titleBlock = el('div', 'hqpu-title-block')
|
|
1341
1041
|
titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
|
|
1342
1042
|
const sub = el('p', 'hqpu-sub')
|
|
@@ -1344,89 +1044,96 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1344
1044
|
headMain.append(brandIcon, titleBlock)
|
|
1345
1045
|
|
|
1346
1046
|
const toolbar = el('div', 'hqpu-toolbar')
|
|
1347
|
-
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(
|
|
1047
|
+
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(15), copy.refresh, () => {
|
|
1348
1048
|
void store.check()
|
|
1349
1049
|
})
|
|
1350
|
-
toolbar.append(refreshBtn)
|
|
1351
1050
|
const gearBtn = iconButton(
|
|
1352
1051
|
'hqpu-icon-btn',
|
|
1353
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
1052
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.2"/><path d="M13.5 9.2v-2.4l-1.8-.6a4.4 4.4 0 00-.5-1.2l.9-1.7-1.7-1.7-1.7.9a4.4 4.4 0 00-1.2-.5L7.2 2.5H4.8l-.6 1.8a4.4 4.4 0 00-1.2.5l-1.7-.9-1.7 1.7.9 1.7a4.4 4.4 0 00-.5 1.2L2.5 7.2v2.4l1.8.6a4.4 4.4 0 00.5 1.2l-.9 1.7 1.7 1.7 1.7-.9a4.4 4.4 0 001.2.5l.6 1.8h2.4l.6-1.8a4.4 4.4 0 001.2-.5l1.7.9 1.7-1.7-.9-1.7a4.4 4.4 0 00.5-1.2l1.8-.6z"/></svg>`,
|
|
1354
1053
|
copy.ui.gearTooltip,
|
|
1355
1054
|
() => {
|
|
1356
|
-
|
|
1357
|
-
render()
|
|
1055
|
+
toggleSettings()
|
|
1358
1056
|
},
|
|
1359
1057
|
)
|
|
1360
|
-
|
|
1361
|
-
|
|
1058
|
+
gearBtn.dataset.settingsToggle = ''
|
|
1059
|
+
gearBtn.setAttribute('aria-pressed', 'false')
|
|
1060
|
+
const logBtn = iconButton('hqpu-icon-btn', LOG_ICON(15), copy.ui.logShow, () => {
|
|
1061
|
+
toggleLog()
|
|
1062
|
+
})
|
|
1063
|
+
logBtn.dataset.logToggle = ''
|
|
1064
|
+
logBtn.setAttribute('aria-pressed', 'false')
|
|
1065
|
+
const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--close', CLOSE_ICON(15), copy.close, () => {
|
|
1362
1066
|
onClose()
|
|
1363
1067
|
})
|
|
1364
|
-
toolbar.append(closeBtn)
|
|
1068
|
+
toolbar.append(refreshBtn, gearBtn, logBtn, closeBtn)
|
|
1365
1069
|
head.append(headMain, toolbar)
|
|
1366
1070
|
panel.append(head)
|
|
1367
1071
|
|
|
1368
|
-
// ──
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1072
|
+
// ── Settings strip (gear-toggled, above the body so it never shrinks it) ──
|
|
1073
|
+
const settingsEl = el('div', 'hqpu-settings')
|
|
1074
|
+
settingsEl.hidden = true
|
|
1075
|
+
panel.append(settingsEl)
|
|
1371
1076
|
|
|
1372
|
-
// ──
|
|
1077
|
+
// ── Body + footer + log drawer share one positioned box, so the drawer can
|
|
1078
|
+
// cover the body without covering the header ──
|
|
1079
|
+
const main = el('div', 'hqpu-main')
|
|
1373
1080
|
const scroll = el('div', 'hqpu-scroll')
|
|
1374
1081
|
const column = el('div', 'hqpu-column')
|
|
1375
|
-
// The durable-job view leads: an active install/runtime update is resumed on
|
|
1376
|
-
// open BEFORE anything else, and a recent terminal result is shown here too.
|
|
1377
1082
|
const jobSection = el('div', 'hqpu-stack')
|
|
1083
|
+
const bannerSection = el('div', 'hqpu-stack')
|
|
1084
|
+
const bandSection = el('div', 'hqpu-stack')
|
|
1378
1085
|
const listSection = el('div', 'hqpu-stack')
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
column.append(jobSection, listSection, runtimeSection, toastSection)
|
|
1086
|
+
const extraSection = el('div', 'hqpu-stack')
|
|
1087
|
+
column.append(jobSection, bannerSection, bandSection, listSection, extraSection)
|
|
1382
1088
|
scroll.append(column)
|
|
1383
|
-
panel.append(scroll)
|
|
1384
1089
|
|
|
1385
|
-
|
|
1386
|
-
const
|
|
1090
|
+
const footer = el('footer', 'hqpu-footer')
|
|
1091
|
+
const cta = el('button', 'hqpu-btn--block')
|
|
1092
|
+
cta.type = 'button'
|
|
1093
|
+
const ctaSpinner = el('span', 'hqpu-cta-spinner')
|
|
1094
|
+
ctaSpinner.hidden = true
|
|
1095
|
+
const ctaLabel = el('span', 'hqpu-cta-label')
|
|
1096
|
+
cta.append(ctaSpinner, ctaLabel)
|
|
1387
1097
|
const footMeta = el('div', 'hqpu-foot-meta')
|
|
1388
1098
|
const footCount = el('span', '')
|
|
1389
|
-
const
|
|
1390
|
-
footMeta.append(footCount, footSet)
|
|
1391
|
-
const footActions = el('div', 'hqpu-foot-actions')
|
|
1392
|
-
const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
|
|
1393
|
-
installAllBtn.type = 'button'
|
|
1394
|
-
const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
|
|
1099
|
+
const ignoreBtn = el('button', 'hqpu-link')
|
|
1395
1100
|
ignoreBtn.type = 'button'
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1101
|
+
footMeta.append(footCount, ignoreBtn)
|
|
1102
|
+
footer.append(cta, footMeta, el('p', 'hqpu-footnote', copy.ui.footnote))
|
|
1103
|
+
|
|
1104
|
+
const logDrawer = el('div', 'hqpu-log')
|
|
1105
|
+
logDrawer.dataset.logDrawer = ''
|
|
1106
|
+
logDrawer.hidden = true
|
|
1107
|
+
|
|
1108
|
+
const toastSection = el('div', 'hqpu-toast-host')
|
|
1109
|
+
main.append(scroll, footer, logDrawer, toastSection)
|
|
1110
|
+
panel.append(main)
|
|
1400
1111
|
|
|
1401
1112
|
overlay.append(scrim, panel)
|
|
1402
1113
|
document.body.appendChild(overlay)
|
|
1403
1114
|
toastHost = toastSection
|
|
1115
|
+
activeCopy = copy
|
|
1404
1116
|
panel.focus()
|
|
1405
1117
|
|
|
1406
1118
|
const disposers: Array<() => void> = []
|
|
1407
1119
|
|
|
1408
|
-
// ──
|
|
1409
|
-
// Live in the panel's closure (not the report) because they are UI state, and
|
|
1410
|
-
// a store refresh must not silently disarm a confirm the user just armed.
|
|
1120
|
+
// ── Panel-closure UI state (survives store refreshes) ──
|
|
1411
1121
|
let runtimeArmed = false
|
|
1412
1122
|
let runtimeBusy = false
|
|
1413
1123
|
let runtimeAccepted = false
|
|
1414
|
-
|
|
1415
|
-
// ── Panel-closure UI state (survive store refreshes) ──
|
|
1416
|
-
const changelogOpen = new Set<string>()
|
|
1417
|
-
let allOpen = false
|
|
1418
1124
|
let settingsOpen = false
|
|
1125
|
+
let settingsBuilt = false
|
|
1126
|
+
let logOpen = false
|
|
1419
1127
|
let dismissedAlert = false
|
|
1128
|
+
let changesAllOpen = false
|
|
1129
|
+
const changesOpen = new Set<string>()
|
|
1130
|
+
let upToDateOpen = false
|
|
1131
|
+
let ignoredOpen = false
|
|
1132
|
+
let releaseOpen = false
|
|
1133
|
+
let saving = false
|
|
1134
|
+
let pluginDraft = ''
|
|
1135
|
+
let runtimeDraft = ''
|
|
1420
1136
|
|
|
1421
|
-
// Source-editor state, same reasoning: a background refresh must not discard
|
|
1422
|
-
// a half-typed URL or silently close either editor.
|
|
1423
|
-
const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
1424
|
-
const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
1425
|
-
|
|
1426
|
-
// Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
|
|
1427
|
-
// getters once and freeze `sourceEditing: false` — the editor would never
|
|
1428
|
-
// open. Read every member through the live object instead.
|
|
1429
|
-
const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
|
|
1430
1137
|
const runtimeActions: RuntimeActions = {
|
|
1431
1138
|
get busy() {
|
|
1432
1139
|
return runtimeBusy
|
|
@@ -1448,87 +1155,51 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1448
1155
|
onConfirm: () => {
|
|
1449
1156
|
void requestRuntime()
|
|
1450
1157
|
},
|
|
1451
|
-
get sourceEditing() {
|
|
1452
|
-
return runtimeSourceActions.sourceEditing
|
|
1453
|
-
},
|
|
1454
|
-
get sourceSaving() {
|
|
1455
|
-
return runtimeSourceActions.sourceSaving
|
|
1456
|
-
},
|
|
1457
|
-
get sourceDraft() {
|
|
1458
|
-
return runtimeSourceActions.sourceDraft
|
|
1459
|
-
},
|
|
1460
|
-
onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
|
|
1461
|
-
onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
|
|
1462
|
-
onSourceSave: () => runtimeSourceActions.onSourceSave(),
|
|
1463
|
-
onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
|
|
1464
|
-
onSourceReset: () => runtimeSourceActions.onSourceReset(),
|
|
1465
1158
|
}
|
|
1466
1159
|
|
|
1467
|
-
|
|
1160
|
+
/** Fill both drafts from the report's URLs in effect. */
|
|
1161
|
+
function syncDrafts(): void {
|
|
1162
|
+
const report = store.report()
|
|
1163
|
+
pluginDraft = normalizeSource(report?.pluginSource)?.url ?? ''
|
|
1164
|
+
runtimeDraft = normalizeSource(report?.runtime.source)?.url ?? ''
|
|
1165
|
+
}
|
|
1468
1166
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
return {
|
|
1475
|
-
get sourceEditing() {
|
|
1476
|
-
return state.editing
|
|
1477
|
-
},
|
|
1478
|
-
get sourceSaving() {
|
|
1479
|
-
return state.saving
|
|
1480
|
-
},
|
|
1481
|
-
get sourceDraft() {
|
|
1482
|
-
return state.draft
|
|
1483
|
-
},
|
|
1484
|
-
onSourceEdit: () => {
|
|
1485
|
-
// Prefill with the URL in effect, so 保存 is a no-op unless edited.
|
|
1486
|
-
const report = store.report()
|
|
1487
|
-
const source =
|
|
1488
|
-
kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
|
|
1489
|
-
state.draft = source?.url ?? ''
|
|
1490
|
-
state.editing = true
|
|
1491
|
-
render()
|
|
1492
|
-
},
|
|
1493
|
-
onSourceDraft: (value: string) => {
|
|
1494
|
-
state.draft = value
|
|
1495
|
-
},
|
|
1496
|
-
onSourceSave: () => {
|
|
1497
|
-
void submitSource(kind, state)
|
|
1498
|
-
},
|
|
1499
|
-
onSourceCancel: () => {
|
|
1500
|
-
state.editing = false
|
|
1501
|
-
render()
|
|
1502
|
-
},
|
|
1503
|
-
onSourceReset: () => {
|
|
1504
|
-
void submitSource(kind, state, '')
|
|
1505
|
-
},
|
|
1167
|
+
function toggleSettings(): void {
|
|
1168
|
+
settingsOpen = !settingsOpen
|
|
1169
|
+
if (settingsOpen) {
|
|
1170
|
+
syncDrafts()
|
|
1171
|
+
settingsBuilt = false
|
|
1506
1172
|
}
|
|
1173
|
+
render()
|
|
1507
1174
|
}
|
|
1508
1175
|
|
|
1509
1176
|
/**
|
|
1510
|
-
* Save
|
|
1511
|
-
* then re-run discovery.
|
|
1177
|
+
* Save both manifest sources, then re-run discovery and close the strip.
|
|
1512
1178
|
*
|
|
1513
1179
|
* The re-check is not optional: the report is cached, so without a fresh
|
|
1514
|
-
* `POST /check` the
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
* the
|
|
1180
|
+
* `POST /check` the list would keep naming the OLD manifest's offer after the
|
|
1181
|
+
* source changed. hq-edge answers 422 with a message meant for a person on
|
|
1182
|
+
* refusal (bad scheme, credentials, …), which the toast shows verbatim —
|
|
1183
|
+
* and the strip stays open with the draft intact so it can be fixed.
|
|
1518
1184
|
*/
|
|
1519
|
-
async function
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1185
|
+
async function saveSources(): Promise<void> {
|
|
1186
|
+
const report = store.report()
|
|
1187
|
+
const pluginSource = normalizeSource(report?.pluginSource)
|
|
1188
|
+
const runtimeSource = normalizeSource(report?.runtime.source)
|
|
1189
|
+
saving = true
|
|
1190
|
+
settingsBuilt = false
|
|
1525
1191
|
render()
|
|
1526
|
-
const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
|
|
1527
1192
|
try {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1193
|
+
const calls: Array<Promise<unknown>> = []
|
|
1194
|
+
if (pluginSource !== null && pluginDraft !== pluginSource.url) {
|
|
1195
|
+
calls.push(setPluginUpdateSource(pluginDraft))
|
|
1196
|
+
}
|
|
1197
|
+
if (runtimeSource !== null && runtimeDraft !== runtimeSource.url) {
|
|
1198
|
+
calls.push(setRuntimeUpdateSource(runtimeDraft))
|
|
1199
|
+
}
|
|
1200
|
+
await Promise.all(calls)
|
|
1201
|
+
settingsOpen = false
|
|
1202
|
+
showToast(copy.pluginSource.saved, 'success')
|
|
1532
1203
|
try {
|
|
1533
1204
|
await checkUpdates()
|
|
1534
1205
|
await store.refresh()
|
|
@@ -1538,11 +1209,40 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1538
1209
|
}
|
|
1539
1210
|
} catch (err: unknown) {
|
|
1540
1211
|
showToast(
|
|
1541
|
-
`${
|
|
1212
|
+
`${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1542
1213
|
'error',
|
|
1543
1214
|
)
|
|
1544
1215
|
} finally {
|
|
1545
|
-
|
|
1216
|
+
saving = false
|
|
1217
|
+
settingsBuilt = false
|
|
1218
|
+
render()
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Clear one user override so the configured URL applies again. */
|
|
1223
|
+
async function resetSource(kind: SourceKind): Promise<void> {
|
|
1224
|
+
saving = true
|
|
1225
|
+
settingsBuilt = false
|
|
1226
|
+
render()
|
|
1227
|
+
try {
|
|
1228
|
+
if (kind === 'runtime') await setRuntimeUpdateSource('')
|
|
1229
|
+
else await setPluginUpdateSource('')
|
|
1230
|
+
showToast(kind === 'runtime' ? copy.runtime.saved : copy.pluginSource.saved, 'success')
|
|
1231
|
+
try {
|
|
1232
|
+
await checkUpdates()
|
|
1233
|
+
await store.refresh()
|
|
1234
|
+
} catch {
|
|
1235
|
+
/* saved anyway */
|
|
1236
|
+
}
|
|
1237
|
+
syncDrafts()
|
|
1238
|
+
} catch (err: unknown) {
|
|
1239
|
+
showToast(
|
|
1240
|
+
`${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1241
|
+
'error',
|
|
1242
|
+
)
|
|
1243
|
+
} finally {
|
|
1244
|
+
saving = false
|
|
1245
|
+
settingsBuilt = false
|
|
1546
1246
|
render()
|
|
1547
1247
|
}
|
|
1548
1248
|
}
|
|
@@ -1563,13 +1263,10 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1563
1263
|
return
|
|
1564
1264
|
}
|
|
1565
1265
|
// 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
|
|
1566
|
-
// relaunch.
|
|
1567
|
-
//
|
|
1568
|
-
// server that is already gone.
|
|
1266
|
+
// relaunch. Latch it, so a store refresh cannot re-offer the button for
|
|
1267
|
+
// a server that is already gone.
|
|
1569
1268
|
runtimeAccepted = true
|
|
1570
1269
|
showToast(copy.runtime.updateAccepted, 'success')
|
|
1571
|
-
// Adopt the new durable job immediately so the active-job view renders
|
|
1572
|
-
// (it polls from here; this process is about to be swapped away anyway).
|
|
1573
1270
|
void store.fetchStatus()
|
|
1574
1271
|
} catch (err: unknown) {
|
|
1575
1272
|
showToast(
|
|
@@ -1585,7 +1282,7 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1585
1282
|
/** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
|
|
1586
1283
|
* re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
|
|
1587
1284
|
* settled job id. */
|
|
1588
|
-
|
|
1285
|
+
function retryJob(kind: 'plugin-set' | 'runtime'): void {
|
|
1589
1286
|
if (kind === 'runtime') {
|
|
1590
1287
|
runtimeArmed = true
|
|
1591
1288
|
render()
|
|
@@ -1594,100 +1291,543 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1594
1291
|
}
|
|
1595
1292
|
}
|
|
1596
1293
|
|
|
1597
|
-
|
|
1294
|
+
/** The newest settled job — what the log drawer is about. */
|
|
1295
|
+
function settledJob(): UpdateJob | null {
|
|
1296
|
+
const latest = store.recentJobs()[0]
|
|
1297
|
+
return latest !== undefined && TERMINAL_PHASES.has(latest.phase) ? latest : null
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function toggleLog(): void {
|
|
1301
|
+
logOpen = !logOpen
|
|
1302
|
+
render()
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function copyLogId(id: string): Promise<void> {
|
|
1306
|
+
try {
|
|
1307
|
+
const clipboard = navigator.clipboard
|
|
1308
|
+
if (clipboard === undefined) throw new Error('clipboard unavailable')
|
|
1309
|
+
await clipboard.writeText(id)
|
|
1310
|
+
showToast(copy.ui.logCopied, 'success')
|
|
1311
|
+
} catch {
|
|
1312
|
+
showToast(copy.ui.logCopyFailed, 'error')
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── Section renderers ──
|
|
1317
|
+
|
|
1318
|
+
function renderSettings(): void {
|
|
1319
|
+
if (!settingsOpen) {
|
|
1320
|
+
settingsEl.hidden = true
|
|
1321
|
+
settingsEl.textContent = ''
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
settingsEl.hidden = false
|
|
1325
|
+
// Rebuilding on every store emit would blow away the caret of whoever is
|
|
1326
|
+
// typing, so the DOM is built once per open (or per explicit force).
|
|
1327
|
+
if (settingsBuilt) return
|
|
1328
|
+
settingsBuilt = true
|
|
1329
|
+
settingsEl.textContent = ''
|
|
1330
|
+
|
|
1331
|
+
const report = store.report()
|
|
1332
|
+
const pluginSource = normalizeSource(report?.pluginSource)
|
|
1333
|
+
const runtimeSource = normalizeSource(report?.runtime.source)
|
|
1334
|
+
|
|
1335
|
+
const row = el('div', 'hqpu-settings-row')
|
|
1336
|
+
row.append(el('span', 'hqpu-settings-title', copy.ui.settingsTitle))
|
|
1337
|
+
const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
|
|
1338
|
+
const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
|
|
1339
|
+
badge.dataset.origin = overridden ? 'override' : 'config'
|
|
1340
|
+
row.append(badge)
|
|
1341
|
+
settingsEl.append(row)
|
|
1342
|
+
|
|
1343
|
+
const field = (
|
|
1344
|
+
kind: SourceKind,
|
|
1345
|
+
source: RuntimeUpdateSource | null,
|
|
1346
|
+
label: string,
|
|
1347
|
+
ariaLabel: string,
|
|
1348
|
+
placeholder: string,
|
|
1349
|
+
draft: string,
|
|
1350
|
+
onDraft: (value: string) => void,
|
|
1351
|
+
): void => {
|
|
1352
|
+
if (source === null) return
|
|
1353
|
+
const wrap = el('div', 'hqpu-field')
|
|
1354
|
+
const labelRow = el('div', 'hqpu-field-head')
|
|
1355
|
+
const id = `hqpu-source-${kind}`
|
|
1356
|
+
const labelEl = el('label', '', label)
|
|
1357
|
+
labelEl.htmlFor = id
|
|
1358
|
+
labelRow.append(labelEl)
|
|
1359
|
+
if (source.override) {
|
|
1360
|
+
const reset = linkButton('hqpu-link hqpu-link--tiny', copy.ui.resetSource, () => {
|
|
1361
|
+
void resetSource(kind)
|
|
1362
|
+
})
|
|
1363
|
+
reset.setAttribute(`data-${kind}-source-reset`, '')
|
|
1364
|
+
labelRow.append(reset)
|
|
1365
|
+
}
|
|
1366
|
+
wrap.append(labelRow)
|
|
1367
|
+
const input = el('input', 'hqpu-source-input')
|
|
1368
|
+
input.id = id
|
|
1369
|
+
input.type = 'url'
|
|
1370
|
+
input.value = draft
|
|
1371
|
+
input.placeholder = placeholder
|
|
1372
|
+
input.setAttribute('aria-label', ariaLabel)
|
|
1373
|
+
input.setAttribute(`data-${kind}-source-input`, '')
|
|
1374
|
+
input.disabled = saving
|
|
1375
|
+
input.spellcheck = false
|
|
1376
|
+
input.addEventListener('input', () => {
|
|
1377
|
+
// Draft-only: no re-render, so the caret and IME stay untouched.
|
|
1378
|
+
onDraft(input.value)
|
|
1379
|
+
})
|
|
1380
|
+
// Enter saves both; Escape closes the strip without saving.
|
|
1381
|
+
input.addEventListener('keydown', (ev) => {
|
|
1382
|
+
if (ev.key === 'Enter') {
|
|
1383
|
+
ev.preventDefault()
|
|
1384
|
+
void saveSources()
|
|
1385
|
+
} else if (ev.key === 'Escape') {
|
|
1386
|
+
ev.preventDefault()
|
|
1387
|
+
ev.stopPropagation()
|
|
1388
|
+
settingsOpen = false
|
|
1389
|
+
syncDrafts()
|
|
1390
|
+
render()
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1393
|
+
wrap.append(input)
|
|
1394
|
+
settingsEl.append(wrap)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
field(
|
|
1398
|
+
'plugin',
|
|
1399
|
+
pluginSource,
|
|
1400
|
+
copy.ui.pluginUrlLabel,
|
|
1401
|
+
copy.pluginSource.inputLabel,
|
|
1402
|
+
copy.pluginSource.placeholder,
|
|
1403
|
+
pluginDraft,
|
|
1404
|
+
(value) => {
|
|
1405
|
+
pluginDraft = value
|
|
1406
|
+
},
|
|
1407
|
+
)
|
|
1408
|
+
field(
|
|
1409
|
+
'runtime',
|
|
1410
|
+
runtimeSource,
|
|
1411
|
+
copy.ui.runtimeUrlLabel,
|
|
1412
|
+
copy.runtime.sourceInputLabel,
|
|
1413
|
+
copy.runtime.sourcePlaceholder,
|
|
1414
|
+
runtimeDraft,
|
|
1415
|
+
(value) => {
|
|
1416
|
+
runtimeDraft = value
|
|
1417
|
+
},
|
|
1418
|
+
)
|
|
1419
|
+
if (pluginSource === null && runtimeSource === null) {
|
|
1420
|
+
settingsEl.append(el('p', 'hqpu-status', copy.runtime.notConfigured))
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const actions = el('div', 'hqpu-settings-actions')
|
|
1424
|
+
const cancel = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
|
|
1425
|
+
cancel.type = 'button'
|
|
1426
|
+
cancel.addEventListener('click', () => {
|
|
1427
|
+
settingsOpen = false
|
|
1428
|
+
syncDrafts()
|
|
1429
|
+
render()
|
|
1430
|
+
})
|
|
1431
|
+
const save = el('button', 'hqpu-btn hqpu-btn--brand hqpu-btn--sm', copy.ui.saveAndRefresh)
|
|
1432
|
+
save.type = 'button'
|
|
1433
|
+
save.disabled = saving
|
|
1434
|
+
save.setAttribute('data-source-save', '')
|
|
1435
|
+
save.addEventListener('click', () => {
|
|
1436
|
+
void saveSources()
|
|
1437
|
+
})
|
|
1438
|
+
actions.append(cancel, save)
|
|
1439
|
+
settingsEl.append(actions)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function renderJobSection(): void {
|
|
1598
1443
|
jobSection.textContent = ''
|
|
1599
1444
|
const active = store.activeJob()
|
|
1600
1445
|
if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
|
|
1601
|
-
// An active job leads the panel — resumed on open, polled to its end.
|
|
1602
1446
|
jobSection.append(activeJobView(active, copy))
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function renderBanners(report: PluginUpdateReport | null, jobActive: boolean): void {
|
|
1451
|
+
bannerSection.textContent = ''
|
|
1452
|
+
if (report !== null) {
|
|
1453
|
+
const banner = checkBanner(report, copy, () => {
|
|
1454
|
+
void store.check()
|
|
1455
|
+
})
|
|
1456
|
+
if (banner !== null) bannerSection.append(banner)
|
|
1457
|
+
}
|
|
1458
|
+
// A terminal non-success job leads the body; the full log is one click away
|
|
1459
|
+
// in the drawer, but the outcome itself must not hide there.
|
|
1460
|
+
if (jobActive) return
|
|
1461
|
+
const settled = settledJob()
|
|
1462
|
+
if (settled !== null && settled.phase !== 'done') {
|
|
1463
|
+
bannerSection.append(
|
|
1464
|
+
jobResultBanner(
|
|
1465
|
+
settled,
|
|
1466
|
+
copy,
|
|
1467
|
+
() => retryJob(settled.kind),
|
|
1468
|
+
() => {
|
|
1469
|
+
logOpen = true
|
|
1470
|
+
render()
|
|
1471
|
+
},
|
|
1472
|
+
),
|
|
1473
|
+
)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function renderList(report: PluginUpdateReport | null, busy: boolean): void {
|
|
1478
|
+
listSection.textContent = ''
|
|
1479
|
+
bandSection.textContent = ''
|
|
1480
|
+
if (report === null) {
|
|
1481
|
+
listSection.append(skeleton(copy))
|
|
1603
1482
|
return
|
|
1604
1483
|
}
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1484
|
+
const band = decisionBand(report, copy)
|
|
1485
|
+
if (band !== null) bandSection.append(band)
|
|
1486
|
+
|
|
1487
|
+
const actionable = report.items.filter(isActionable)
|
|
1488
|
+
if (actionable.length > 0) {
|
|
1489
|
+
const section = el('section', 'hqpu-group')
|
|
1490
|
+
section.dataset.group = copy.group.actionable
|
|
1491
|
+
const head = el('div', 'hqpu-sec-head')
|
|
1492
|
+
const titleWrap = el('div', 'hqpu-sec-title')
|
|
1493
|
+
titleWrap.append(el('span', '', copy.group.actionable))
|
|
1494
|
+
titleWrap.append(el('span', 'hqpu-count-pill', String(actionable.length)))
|
|
1495
|
+
head.append(titleWrap)
|
|
1496
|
+
const anyChanges = actionable.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
|
|
1497
|
+
if (anyChanges) {
|
|
1498
|
+
head.append(
|
|
1499
|
+
linkButton(
|
|
1500
|
+
'hqpu-link',
|
|
1501
|
+
changesAllOpen ? copy.ui.collapseLogs : copy.ui.expandLogs,
|
|
1502
|
+
() => {
|
|
1503
|
+
changesAllOpen = !changesAllOpen
|
|
1504
|
+
if (!changesAllOpen) changesOpen.clear()
|
|
1505
|
+
render()
|
|
1506
|
+
},
|
|
1507
|
+
),
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
section.append(head)
|
|
1511
|
+
const list = el('ul', 'hqpu-cards')
|
|
1512
|
+
for (const item of actionable) {
|
|
1513
|
+
list.append(
|
|
1514
|
+
pluginCard(item, copy, {
|
|
1515
|
+
changesOpen: changesAllOpen || changesOpen.has(item.name),
|
|
1516
|
+
onToggleChanges: () => {
|
|
1517
|
+
if (changesOpen.has(item.name)) changesOpen.delete(item.name)
|
|
1518
|
+
else changesOpen.add(item.name)
|
|
1519
|
+
render()
|
|
1520
|
+
},
|
|
1521
|
+
onInstall: () => {
|
|
1522
|
+
void runInstall(store, copy, [item.name])
|
|
1523
|
+
},
|
|
1524
|
+
onIgnore: () => {
|
|
1525
|
+
void togglePluginIgnore(item)
|
|
1526
|
+
},
|
|
1527
|
+
busy,
|
|
1528
|
+
}),
|
|
1529
|
+
)
|
|
1530
|
+
}
|
|
1531
|
+
section.append(list)
|
|
1532
|
+
listSection.append(section)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function renderExtras(report: PluginUpdateReport | null): void {
|
|
1537
|
+
extraSection.textContent = ''
|
|
1538
|
+
if (report === null) return
|
|
1539
|
+
|
|
1540
|
+
const alert = runtimeResultAlert(report, copy, dismissedAlert, () => {
|
|
1541
|
+
dismissedAlert = true
|
|
1542
|
+
render()
|
|
1543
|
+
})
|
|
1544
|
+
if (alert !== null) extraSection.append(alert)
|
|
1545
|
+
|
|
1546
|
+
const engine = runtimeEngineCard(report, copy, runtimeActions)
|
|
1547
|
+
if (engine !== null) extraSection.append(engine)
|
|
1548
|
+
|
|
1549
|
+
const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
|
|
1550
|
+
report.items.filter((item) => item.state === state)
|
|
1551
|
+
|
|
1552
|
+
const ignored = byState('ignored')
|
|
1553
|
+
if (ignored.length > 0) {
|
|
1554
|
+
extraSection.append(
|
|
1555
|
+
disclosure({
|
|
1556
|
+
id: 'hqpu-disc-ignored',
|
|
1557
|
+
title: copy.group.ignored,
|
|
1558
|
+
count: `(${ignored.length})`,
|
|
1559
|
+
note: copy.ui.ignoredNote,
|
|
1560
|
+
open: ignoredOpen,
|
|
1561
|
+
onToggle: () => {
|
|
1562
|
+
ignoredOpen = !ignoredOpen
|
|
1563
|
+
render()
|
|
1564
|
+
},
|
|
1565
|
+
build: () => {
|
|
1566
|
+
const ul = el('ul', 'hqpu-rows')
|
|
1567
|
+
for (const item of ignored) {
|
|
1568
|
+
ul.append(
|
|
1569
|
+
pluginRow(item, copy, () => {
|
|
1570
|
+
void togglePluginIgnore(item)
|
|
1571
|
+
}),
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
return [ul]
|
|
1575
|
+
},
|
|
1576
|
+
}),
|
|
1577
|
+
)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const current = byState('current')
|
|
1581
|
+
if (current.length > 0) {
|
|
1582
|
+
extraSection.append(
|
|
1583
|
+
disclosure({
|
|
1584
|
+
id: 'hqpu-disc-current',
|
|
1585
|
+
title: copy.group.current,
|
|
1586
|
+
count: `(${current.length})`,
|
|
1587
|
+
note: copy.ui.upToDateNote,
|
|
1588
|
+
open: upToDateOpen,
|
|
1589
|
+
onToggle: () => {
|
|
1590
|
+
upToDateOpen = !upToDateOpen
|
|
1591
|
+
render()
|
|
1592
|
+
},
|
|
1593
|
+
build: () => {
|
|
1594
|
+
const ul = el('ul', 'hqpu-rows')
|
|
1595
|
+
for (const item of current) ul.append(pluginRow(item, copy, () => undefined))
|
|
1596
|
+
return [ul]
|
|
1597
|
+
},
|
|
1598
|
+
}),
|
|
1599
|
+
)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (hasReleaseContent(report.release)) {
|
|
1603
|
+
extraSection.append(
|
|
1604
|
+
disclosure({
|
|
1605
|
+
id: 'hqpu-disc-release',
|
|
1606
|
+
title: copy.ui.releaseTitle,
|
|
1607
|
+
note: report.manifestVersion !== null ? `v${report.manifestVersion}` : undefined,
|
|
1608
|
+
open: releaseOpen,
|
|
1609
|
+
onToggle: () => {
|
|
1610
|
+
releaseOpen = !releaseOpen
|
|
1611
|
+
render()
|
|
1612
|
+
},
|
|
1613
|
+
build: () => releaseBody(report.release as ReleaseInfo, copy),
|
|
1614
|
+
}),
|
|
1615
|
+
)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (report.items.length === 0 && typeof report.lastError !== 'string') {
|
|
1619
|
+
extraSection.append(el('div', 'hqpu-empty', copy.empty))
|
|
1611
1620
|
}
|
|
1612
1621
|
}
|
|
1613
1622
|
|
|
1614
|
-
|
|
1623
|
+
function renderFooter(report: PluginUpdateReport | null, busy: boolean): void {
|
|
1624
|
+
const actionable = report === null ? 0 : actionableUpdateCount(report)
|
|
1625
|
+
const setVersion = report?.manifestVersion ?? null
|
|
1626
|
+
const failed = typeof report?.lastError === 'string' && (report?.lastError ?? '').length > 0
|
|
1627
|
+
const runtimePending = report !== null && runtimeUpdateOffered(report.runtime)
|
|
1628
|
+
const anything = actionable > 0 || runtimePending
|
|
1629
|
+
|
|
1630
|
+
ctaSpinner.hidden = !busy
|
|
1631
|
+
cta.dataset.busy = busy ? 'true' : 'false'
|
|
1632
|
+
cta.setAttribute('aria-busy', busy ? 'true' : 'false')
|
|
1633
|
+
|
|
1634
|
+
if (busy) {
|
|
1635
|
+
ctaLabel.textContent = copy.ui.ctaInstalling
|
|
1636
|
+
cta.disabled = true
|
|
1637
|
+
footCount.textContent = copy.ui.footerBusy
|
|
1638
|
+
} else if (report === null) {
|
|
1639
|
+
ctaLabel.textContent = copy.ui.ctaNoSet
|
|
1640
|
+
cta.disabled = true
|
|
1641
|
+
footCount.textContent = ''
|
|
1642
|
+
} else if (failed) {
|
|
1643
|
+
// Installing against a stale manifest is the one path that can only end
|
|
1644
|
+
// in a 422 — say so instead of offering a button that cannot work.
|
|
1645
|
+
ctaLabel.textContent = copy.ui.ctaFailed
|
|
1646
|
+
cta.disabled = true
|
|
1647
|
+
footCount.textContent = copy.ui.footerFailed
|
|
1648
|
+
} else if (setVersion === null) {
|
|
1649
|
+
ctaLabel.textContent = copy.ui.ctaNoSet
|
|
1650
|
+
cta.disabled = true
|
|
1651
|
+
footCount.textContent = copy.ui.checkedNever
|
|
1652
|
+
} else if (actionable === 0 && runtimePending) {
|
|
1653
|
+
// The plugin set is done; the only remaining offer is the runtime, which
|
|
1654
|
+
// needs its own confirm — the CTA must not imply it would install it.
|
|
1655
|
+
ctaLabel.textContent = copy.ui.ctaNoPlugins
|
|
1656
|
+
cta.disabled = true
|
|
1657
|
+
footCount.textContent = copy.ui.footerRuntimeOnly
|
|
1658
|
+
} else if (actionable === 0 && !runtimePending) {
|
|
1659
|
+
ctaLabel.textContent = copy.ui.ctaUpToDate
|
|
1660
|
+
cta.disabled = true
|
|
1661
|
+
footCount.textContent = copy.ui.footerAllGood
|
|
1662
|
+
} else {
|
|
1663
|
+
ctaLabel.textContent = copy.ui.cta(actionable)
|
|
1664
|
+
cta.disabled = false
|
|
1665
|
+
footCount.textContent = copy.ui.footerCount(actionable)
|
|
1666
|
+
}
|
|
1667
|
+
cta.onclick = () => {
|
|
1668
|
+
void runInstall(store, copy)
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Set-level mute: only with an evaluated set and something to mute.
|
|
1672
|
+
const canMute = setVersion !== null && report !== null && !busy
|
|
1673
|
+
ignoreBtn.hidden = !canMute || (!anything && !report?.ignoredSet)
|
|
1674
|
+
ignoreBtn.textContent = report?.ignoredSet === true ? copy.ui.unignoreBtn : copy.ui.ignoreBtn
|
|
1675
|
+
ignoreBtn.onclick = () => {
|
|
1676
|
+
if (setVersion === null) return
|
|
1677
|
+
const ignored = report?.ignoredSet === true
|
|
1678
|
+
const action = ignored ? unignoreOffer(setVersion) : ignoreOffer(setVersion)
|
|
1679
|
+
// `check()`, never `refresh()`: ignoring only writes the decision to
|
|
1680
|
+
// hq-edge's state file, and its cached report keeps every item at
|
|
1681
|
+
// `update` until discovery runs again.
|
|
1682
|
+
void action
|
|
1683
|
+
.then(() => store.check())
|
|
1684
|
+
.catch((err: unknown) => {
|
|
1685
|
+
showToast(
|
|
1686
|
+
`${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1687
|
+
'error',
|
|
1688
|
+
)
|
|
1689
|
+
})
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function renderLogDrawer(jobActive: boolean): void {
|
|
1694
|
+
const job = settledJob()
|
|
1695
|
+
const available = !jobActive && job !== null
|
|
1696
|
+
logBtn.disabled = !available
|
|
1697
|
+
logBtn.setAttribute('aria-pressed', logOpen && available ? 'true' : 'false')
|
|
1698
|
+
const label =
|
|
1699
|
+
jobActive ? copy.ui.logBusy : job === null ? copy.ui.logNone : logOpen ? copy.ui.logHide : copy.ui.logShow
|
|
1700
|
+
logBtn.title = label
|
|
1701
|
+
logBtn.setAttribute('aria-label', label)
|
|
1702
|
+
|
|
1703
|
+
// A running job (or none at all) cannot have a log; drop the open flag so
|
|
1704
|
+
// the drawer does not snap back open once the job settles.
|
|
1705
|
+
if (!available || !logOpen) {
|
|
1706
|
+
if (!available) logOpen = false
|
|
1707
|
+
logDrawer.hidden = true
|
|
1708
|
+
logDrawer.textContent = ''
|
|
1709
|
+
main.dataset.logOpen = '0'
|
|
1710
|
+
return
|
|
1711
|
+
}
|
|
1712
|
+
logDrawer.hidden = false
|
|
1713
|
+
logDrawer.textContent = ''
|
|
1714
|
+
// The drawer is absolutely positioned, so it cannot size its own box: on a
|
|
1715
|
+
// short body (nothing left to update) it would be squeezed between the
|
|
1716
|
+
// header and the panel edge. The flag gives `.hqpu-main` a floor while it
|
|
1717
|
+
// is open, and nothing at all once it closes.
|
|
1718
|
+
main.dataset.logOpen = '1'
|
|
1719
|
+
for (const node of buildLogDrawer(
|
|
1720
|
+
job,
|
|
1721
|
+
store.recentJobs(),
|
|
1722
|
+
copy,
|
|
1723
|
+
(id) => {
|
|
1724
|
+
void copyLogId(id)
|
|
1725
|
+
},
|
|
1726
|
+
() => {
|
|
1727
|
+
logOpen = false
|
|
1728
|
+
render()
|
|
1729
|
+
},
|
|
1730
|
+
)) {
|
|
1731
|
+
logDrawer.append(node)
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function render(): void {
|
|
1615
1736
|
const report = store.report()
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
const
|
|
1619
|
-
store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
|
|
1620
|
-
const installing = store.installing() || jobActive
|
|
1621
|
-
const stale = report === null
|
|
1622
|
-
refreshBtn.disabled = stale
|
|
1623
|
-
refreshBtn.title = copy.refresh
|
|
1624
|
-
|
|
1625
|
-
// Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
|
|
1626
|
-
const ago = relativeAgo(report?.checkedAt, copy)
|
|
1627
|
-
sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
|
|
1737
|
+
const active = store.activeJob()
|
|
1738
|
+
const jobActive = active !== null && !TERMINAL_PHASES.has(active.phase)
|
|
1739
|
+
const busy = store.installing() || jobActive
|
|
1628
1740
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1741
|
+
refreshBtn.disabled = report === null
|
|
1742
|
+
gearBtn.setAttribute('aria-pressed', settingsOpen ? 'true' : 'false')
|
|
1743
|
+
gearBtn.title = copy.ui.gearTooltip
|
|
1632
1744
|
|
|
1745
|
+
// Header sub-line: the set version and the relative check time, once.
|
|
1746
|
+
const ago = relativeAgo(report?.checkedAt, copy)
|
|
1747
|
+
const setLabel =
|
|
1748
|
+
report?.manifestVersion !== null && report?.manifestVersion !== undefined
|
|
1749
|
+
? copy.ui.setVersion(report.manifestVersion)
|
|
1750
|
+
: null
|
|
1751
|
+
if (setLabel !== null && ago !== null) sub.textContent = copy.ui.subJoined(setLabel, ago)
|
|
1752
|
+
else sub.textContent = setLabel ?? ago ?? copy.ui.checkedUnknown
|
|
1753
|
+
|
|
1754
|
+
renderSettings()
|
|
1633
1755
|
renderJobSection()
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
(
|
|
1657
|
-
|
|
1658
|
-
},
|
|
1659
|
-
(item) => {
|
|
1660
|
-
const report = store.report()
|
|
1661
|
-
if (report?.manifestVersion === null || report?.manifestVersion === undefined) return
|
|
1662
|
-
const action = item.state === 'ignored'
|
|
1663
|
-
? unignorePlugin(report.manifestVersion, item.name)
|
|
1664
|
-
: ignorePlugin(report.manifestVersion, item.name)
|
|
1665
|
-
void action.then(() => store.check()).catch(() => undefined)
|
|
1666
|
-
},
|
|
1756
|
+
renderBanners(report, jobActive)
|
|
1757
|
+
renderList(report, busy)
|
|
1758
|
+
renderExtras(report)
|
|
1759
|
+
renderFooter(report, busy)
|
|
1760
|
+
renderLogDrawer(jobActive)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/** Flip one plugin's per-item ignore flag, then re-run discovery. */
|
|
1764
|
+
async function togglePluginIgnore(item: PluginUpdateItem): Promise<void> {
|
|
1765
|
+
const setVersion = store.report()?.manifestVersion ?? null
|
|
1766
|
+
if (setVersion === null) return
|
|
1767
|
+
const action =
|
|
1768
|
+
item.state === 'ignored'
|
|
1769
|
+
? unignorePlugin(setVersion, item.name)
|
|
1770
|
+
: ignorePlugin(setVersion, item.name)
|
|
1771
|
+
try {
|
|
1772
|
+
await action
|
|
1773
|
+
// Discovery must re-run for the item to come back as `ignored`; a
|
|
1774
|
+
// refusal has to be visible, never silently swallowed.
|
|
1775
|
+
await store.check()
|
|
1776
|
+
} catch (err: unknown) {
|
|
1777
|
+
showToast(
|
|
1778
|
+
`${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1779
|
+
'error',
|
|
1667
1780
|
)
|
|
1668
1781
|
}
|
|
1669
|
-
renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
|
|
1670
|
-
dismissedAlert = true
|
|
1671
|
-
render()
|
|
1672
|
-
})
|
|
1673
|
-
renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
|
|
1674
1782
|
}
|
|
1675
1783
|
|
|
1676
1784
|
const unsubscribe = store.subscribe(render)
|
|
1677
1785
|
disposers.push(unsubscribe)
|
|
1678
1786
|
|
|
1679
1787
|
// Refresh on open (bounded; never block the shell).
|
|
1680
|
-
void store.refresh()
|
|
1681
|
-
// Store notification re-renders already; nothing extra to do.
|
|
1682
|
-
})
|
|
1788
|
+
void store.refresh()
|
|
1683
1789
|
|
|
1684
|
-
// Escape
|
|
1790
|
+
// Escape peels one layer at a time; the shell-wide contract still holds.
|
|
1685
1791
|
const onKeyDown = (ev: KeyboardEvent): void => {
|
|
1686
1792
|
if (ev.key === 'Escape') {
|
|
1687
1793
|
ev.stopPropagation()
|
|
1794
|
+
if (logOpen) {
|
|
1795
|
+
logOpen = false
|
|
1796
|
+
render()
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
if (settingsOpen) {
|
|
1800
|
+
settingsOpen = false
|
|
1801
|
+
syncDrafts()
|
|
1802
|
+
render()
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1688
1805
|
onClose()
|
|
1806
|
+
return
|
|
1807
|
+
}
|
|
1808
|
+
if (ev.key === 'Tab') trapFocus(ev)
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Keep Tab inside the dialog — it declares `aria-modal="true"`. */
|
|
1812
|
+
function trapFocus(ev: KeyboardEvent): void {
|
|
1813
|
+
const focusable = [
|
|
1814
|
+
...panel.querySelectorAll<HTMLElement>(
|
|
1815
|
+
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
|
1816
|
+
),
|
|
1817
|
+
].filter((node) => node.closest('[hidden]') === null)
|
|
1818
|
+
if (focusable.length === 0) return
|
|
1819
|
+
const first = focusable[0]!
|
|
1820
|
+
const last = focusable[focusable.length - 1]!
|
|
1821
|
+
const activeEl = document.activeElement
|
|
1822
|
+
if (ev.shiftKey && (activeEl === first || activeEl === panel)) {
|
|
1823
|
+
ev.preventDefault()
|
|
1824
|
+
last.focus()
|
|
1825
|
+
} else if (!ev.shiftKey && activeEl === last) {
|
|
1826
|
+
ev.preventDefault()
|
|
1827
|
+
first.focus()
|
|
1689
1828
|
}
|
|
1690
1829
|
}
|
|
1830
|
+
|
|
1691
1831
|
document.addEventListener('keydown', onKeyDown)
|
|
1692
1832
|
disposers.push(() => document.removeEventListener('keydown', onKeyDown))
|
|
1693
1833
|
|
|
@@ -1702,8 +1842,79 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1702
1842
|
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
1703
1843
|
toastTimer = null
|
|
1704
1844
|
if (toastHost === toastSection) toastHost = null
|
|
1845
|
+
if (activeCopy === copy) activeCopy = null
|
|
1705
1846
|
overlay.remove()
|
|
1847
|
+
// Focus goes back where it came from — the sidebar row that opened this.
|
|
1848
|
+
if (previouslyFocused !== null && previouslyFocused.isConnected) previouslyFocused.focus()
|
|
1706
1849
|
}
|
|
1707
1850
|
|
|
1708
1851
|
render()
|
|
1709
1852
|
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Run the install transaction (a new durable job), then let the store poll it.
|
|
1856
|
+
*
|
|
1857
|
+
* @param pluginNames - omit for the whole set (the footer CTA), or pass one
|
|
1858
|
+
* name for a card's 单独更新. hq-edge honours the subset.
|
|
1859
|
+
*/
|
|
1860
|
+
async function runInstall(
|
|
1861
|
+
store: UpdateStoreLike,
|
|
1862
|
+
copy: UpdateCopy,
|
|
1863
|
+
pluginNames?: string[],
|
|
1864
|
+
): Promise<void> {
|
|
1865
|
+
const report = store.report()
|
|
1866
|
+
if (report === null) return
|
|
1867
|
+
// Without an evaluated set there is nothing to install: hq-edge has not read
|
|
1868
|
+
// a manifest yet, so the report is still its empty shell.
|
|
1869
|
+
if (report.manifestVersion === null) {
|
|
1870
|
+
showToast(copy.checkFailed, 'error')
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1874
|
+
// action buttons and shows the in-flight footer state.
|
|
1875
|
+
store.setInstalling(true)
|
|
1876
|
+
try {
|
|
1877
|
+
const result = await installSet(report.manifestVersion, pluginNames)
|
|
1878
|
+
if (result.error !== undefined) {
|
|
1879
|
+
store.setInstalling(false)
|
|
1880
|
+
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1884
|
+
// poller renders progress and clears the flag when the job settles.
|
|
1885
|
+
showToast(copy.installSuccess, 'success')
|
|
1886
|
+
void store.fetchStatus()
|
|
1887
|
+
} catch (err: unknown) {
|
|
1888
|
+
store.setInstalling(false)
|
|
1889
|
+
showToast(
|
|
1890
|
+
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1891
|
+
'error',
|
|
1892
|
+
)
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** First-read placeholders, sharing the card layout. */
|
|
1897
|
+
function skeleton(copy: UpdateCopy): HTMLElement {
|
|
1898
|
+
const section = el('section', 'hqpu-group')
|
|
1899
|
+
section.setAttribute('role', 'status')
|
|
1900
|
+
section.dataset.loading = ''
|
|
1901
|
+
section.setAttribute('aria-label', copy.loading)
|
|
1902
|
+
const list = el('ul', 'hqpu-cards')
|
|
1903
|
+
list.setAttribute('aria-hidden', 'true')
|
|
1904
|
+
for (let index = 0; index < 3; index += 1) {
|
|
1905
|
+
const card = el('li', 'hqpu-card')
|
|
1906
|
+
const head = el('div', 'hqpu-card-head')
|
|
1907
|
+
const id = el('div', 'hqpu-card-id')
|
|
1908
|
+
id.append(el('span', 'hqpu-avatar hqpu-skeleton-fill'))
|
|
1909
|
+
const main = el('div', 'hqpu-card-main')
|
|
1910
|
+
const title = el('span', 'hqpu-card-title hqpu-skeleton-fill hqpu-skeleton-title')
|
|
1911
|
+
const sub = el('span', 'hqpu-card-sub hqpu-skeleton-fill hqpu-skeleton-sub')
|
|
1912
|
+
main.append(title, sub)
|
|
1913
|
+
id.append(main)
|
|
1914
|
+
head.append(id)
|
|
1915
|
+
card.append(head)
|
|
1916
|
+
list.append(card)
|
|
1917
|
+
}
|
|
1918
|
+
section.append(list)
|
|
1919
|
+
return section
|
|
1920
|
+
}
|