@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.8
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 +1852 -397
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +36 -4
- package/package.json +3 -3
- package/src/client/api.ts +281 -1
- package/src/client/copy.ts +230 -6
- package/src/client/index.ts +881 -12
- package/src/client/update-view.ts +848 -242
- package/src/routes.ts +49 -7
|
@@ -26,11 +26,14 @@ import {
|
|
|
26
26
|
setPluginUpdateSource,
|
|
27
27
|
setRuntimeUpdateSource,
|
|
28
28
|
unignoreOffer,
|
|
29
|
+
TERMINAL_PHASES,
|
|
29
30
|
type PluginUpdateItem,
|
|
30
31
|
type PluginUpdateReport,
|
|
32
|
+
type ReleaseInfo,
|
|
31
33
|
type RuntimeUpdateSource,
|
|
34
|
+
type UpdateJob,
|
|
32
35
|
} from './api.js'
|
|
33
|
-
import type
|
|
36
|
+
import { formatMb, type UpdateCopy } from './copy.js'
|
|
34
37
|
import {
|
|
35
38
|
CHECK_CIRCLE_ICON,
|
|
36
39
|
CLOSE_ICON,
|
|
@@ -44,7 +47,17 @@ import {
|
|
|
44
47
|
export interface UpdateStoreLike {
|
|
45
48
|
report(): PluginUpdateReport | null
|
|
46
49
|
installing(): boolean
|
|
50
|
+
/** Mark the installing footer state (set by the panel before the restart). */
|
|
51
|
+
setInstalling(flag: boolean): void
|
|
52
|
+
/** The durable job the server currently reports as active (resumed). */
|
|
53
|
+
activeJob(): UpdateJob | null
|
|
54
|
+
/** Bounded recent jobs (newest first), for the result/history view. */
|
|
55
|
+
recentJobs(): UpdateJob[]
|
|
47
56
|
refresh(): Promise<PluginUpdateReport | null>
|
|
57
|
+
/** Force a fresh update check (used by the failed-check Retry action). */
|
|
58
|
+
check(): Promise<PluginUpdateReport | null>
|
|
59
|
+
/** Re-read `/status` (resume an active job immediately after submitting). */
|
|
60
|
+
fetchStatus(): Promise<void>
|
|
48
61
|
subscribe(listener: () => void): () => void
|
|
49
62
|
}
|
|
50
63
|
|
|
@@ -149,80 +162,142 @@ function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undef
|
|
|
149
162
|
return by.length > 0 ? by.join(', ') : undefined
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
/**
|
|
165
|
+
/** Two-letter avatar initials for a card (CJK-safe). */
|
|
166
|
+
function avatarInitials(item: PluginUpdateItem): string {
|
|
167
|
+
const src =
|
|
168
|
+
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
169
|
+
? item.displayName.trim()
|
|
170
|
+
: shortName(item.name)
|
|
171
|
+
if (/[⺀-鿿]/.test(src)) return src.slice(0, 2)
|
|
172
|
+
const words = src.split(/[\s_\-./@]+/).filter(Boolean)
|
|
173
|
+
if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
|
|
174
|
+
return src.replace(/^@/, '').slice(0, 2).toUpperCase()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Shared per-card changelog toggle drawer (the per-plugin changes[] list). */
|
|
178
|
+
function changelogDrawer(item: PluginUpdateItem, copy: UpdateCopy, open: boolean): HTMLElement {
|
|
179
|
+
const drawer = el('div', 'hqpu-changelog-drawer')
|
|
180
|
+
drawer.style.display = open ? '' : 'none'
|
|
181
|
+
drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
|
|
182
|
+
const ul = el('ul')
|
|
183
|
+
for (const change of item.changes ?? []) {
|
|
184
|
+
if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
|
|
185
|
+
}
|
|
186
|
+
drawer.append(ul)
|
|
187
|
+
return drawer
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One package card per the mockup: avatar initials, display name + package
|
|
191
|
+
* subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
|
|
192
|
+
* inline changelog. Blocked/incompatible cards are dimmed with a rose
|
|
193
|
+
* 无法安装 badge and a reason line, and carry NO install button. */
|
|
153
194
|
function packageCard(
|
|
154
195
|
item: PluginUpdateItem,
|
|
155
196
|
copy: UpdateCopy,
|
|
156
|
-
|
|
157
|
-
|
|
197
|
+
open: boolean,
|
|
198
|
+
onToggle: () => void,
|
|
158
199
|
): HTMLLIElement {
|
|
159
|
-
const
|
|
200
|
+
const blocked = item.state === 'blocked'
|
|
201
|
+
const card = el('li', blocked ? 'hqpu-card hqpu-card--blocked' : 'hqpu-card')
|
|
160
202
|
card.dataset.pluginName = item.name
|
|
161
203
|
card.dataset.state = item.state
|
|
162
|
-
// The full package name is the card's tooltip; the row shows the short name.
|
|
163
|
-
card.title = `${item.name} · ${versionLine(item)}`
|
|
164
204
|
|
|
165
205
|
const head = el('div', 'hqpu-card-head')
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
glyph.innerHTML = PLUGIN_ICON(24)
|
|
206
|
+
const left = el('div')
|
|
207
|
+
const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
|
|
208
|
+
avatar.setAttribute('aria-hidden', 'true')
|
|
170
209
|
|
|
171
210
|
const main = el('div', 'hqpu-card-main')
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
const titleText =
|
|
212
|
+
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
213
|
+
? item.displayName.trim()
|
|
214
|
+
: shortName(item.name)
|
|
215
|
+
main.append(el('span', 'hqpu-card-title', titleText))
|
|
216
|
+
main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
|
|
217
|
+
|
|
218
|
+
// old → new version row; the NEW version renders in emerald.
|
|
219
|
+
const ver = el('div', 'hqpu-version-row')
|
|
220
|
+
if (item.currentVersion !== null && item.currentVersion !== undefined) {
|
|
221
|
+
ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
|
|
222
|
+
ver.append(el('span', 'hqpu-ver-arrow', '→'))
|
|
223
|
+
}
|
|
224
|
+
const size = formatMb(item.sizeBytes ?? 0)
|
|
225
|
+
ver.append(
|
|
226
|
+
el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
|
|
227
|
+
)
|
|
228
|
+
main.append(ver)
|
|
229
|
+
left.append(avatar, main)
|
|
230
|
+
|
|
231
|
+
const right = el('div', 'hqpu-card-end')
|
|
232
|
+
if (blocked) {
|
|
233
|
+
right.append(el('span', 'hqpu-blocked-badge', copy.ui.blockedBadge))
|
|
234
|
+
} else if (Array.isArray(item.changes) && item.changes.length > 0) {
|
|
235
|
+
const toggle = el('button', 'hqpu-log-toggle')
|
|
236
|
+
toggle.type = 'button'
|
|
237
|
+
toggle.dataset.open = open ? 'true' : 'false'
|
|
238
|
+
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>`
|
|
239
|
+
toggle.addEventListener('click', onToggle)
|
|
240
|
+
right.append(toggle)
|
|
194
241
|
}
|
|
195
|
-
head.append(
|
|
242
|
+
head.append(left, right)
|
|
196
243
|
card.append(head)
|
|
197
244
|
|
|
198
|
-
//
|
|
199
|
-
if (item.
|
|
245
|
+
// Inline changelog drawer (actionable items only).
|
|
246
|
+
if (!blocked && Array.isArray(item.changes) && item.changes.length > 0) {
|
|
247
|
+
card.append(changelogDrawer(item, copy, open))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Blocked reason line(s): mapped copy plus the server's full reasons/notes.
|
|
251
|
+
if (blocked) {
|
|
200
252
|
const reason = blockedReason(item, copy)
|
|
201
|
-
|
|
202
|
-
|
|
253
|
+
const entries = [
|
|
254
|
+
...(reason !== undefined ? [reason] : []),
|
|
255
|
+
...[...(item.reasons ?? []), ...(item.notes ?? [])].filter(
|
|
256
|
+
(e): e is string => typeof e === 'string' && e.length > 0,
|
|
257
|
+
),
|
|
258
|
+
]
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
const line = el('p', 'hqpu-reason-line')
|
|
261
|
+
line.innerHTML = `<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="M8 5V8.5M8 11.25h.01M14.5 8a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0Z"/></svg>`
|
|
262
|
+
line.append(document.createTextNode(entry))
|
|
263
|
+
card.append(line)
|
|
203
264
|
}
|
|
204
265
|
}
|
|
205
266
|
return card
|
|
206
267
|
}
|
|
207
268
|
|
|
208
|
-
/** A section:
|
|
269
|
+
/** A section: head (title + count pill + expand-all toggle) then its cards. */
|
|
209
270
|
function group(
|
|
210
271
|
heading: string,
|
|
211
272
|
items: PluginUpdateItem[],
|
|
212
273
|
copy: UpdateCopy,
|
|
213
|
-
|
|
214
|
-
|
|
274
|
+
changelogOpen: Set<string>,
|
|
275
|
+
allOpen: boolean,
|
|
276
|
+
onToggle: (name: string) => void,
|
|
277
|
+
onToggleAll: () => void,
|
|
215
278
|
): HTMLElement | null {
|
|
216
279
|
if (items.length === 0) return null
|
|
217
280
|
const section = el('section', 'hqpu-group')
|
|
218
281
|
section.dataset.group = heading
|
|
219
282
|
|
|
220
|
-
const head = el('div', 'hqpu-
|
|
221
|
-
|
|
222
|
-
|
|
283
|
+
const head = el('div', 'hqpu-section-head')
|
|
284
|
+
const titleWrap = el('div', 'hqpu-section-title')
|
|
285
|
+
titleWrap.append(el('span', '', heading))
|
|
286
|
+
titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
|
|
287
|
+
head.append(titleWrap)
|
|
288
|
+
// Expand-all only makes sense over actionable, changelog-bearing cards.
|
|
289
|
+
const actionable = items.some((i) => i.state !== 'blocked' && Array.isArray(i.changes) && i.changes.length > 0)
|
|
290
|
+
if (actionable) {
|
|
291
|
+
const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
|
|
292
|
+
all.type = 'button'
|
|
293
|
+
all.addEventListener('click', onToggleAll)
|
|
294
|
+
head.append(all)
|
|
295
|
+
}
|
|
223
296
|
|
|
224
297
|
const list = el('ul', 'hqpu-cards')
|
|
225
|
-
for (const item of items)
|
|
298
|
+
for (const item of items) {
|
|
299
|
+
list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name)))
|
|
300
|
+
}
|
|
226
301
|
|
|
227
302
|
section.append(head, list)
|
|
228
303
|
return section
|
|
@@ -267,6 +342,370 @@ function skeleton(copy: UpdateCopy): HTMLElement {
|
|
|
267
342
|
return section
|
|
268
343
|
}
|
|
269
344
|
|
|
345
|
+
// ── Durable job views (active / result / recent) ───────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Format a byte count for the progress line, or null when unknown. */
|
|
348
|
+
function formatBytes(n: number | undefined): string | null {
|
|
349
|
+
if (n === undefined || !Number.isFinite(n) || n < 0) return null
|
|
350
|
+
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`
|
|
351
|
+
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
|
352
|
+
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
|
|
353
|
+
return `${n} B`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Format an ETA in seconds to a short human string, or null when unknown. */
|
|
357
|
+
function formatEta(seconds: number | undefined): string | null {
|
|
358
|
+
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
|
|
359
|
+
if (seconds < 60) return `${Math.round(seconds)} s`
|
|
360
|
+
const minutes = Math.round(seconds / 60)
|
|
361
|
+
if (minutes < 60) return `${minutes} min`
|
|
362
|
+
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
|
|
366
|
+
function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
|
|
367
|
+
if (typeof iso !== 'string' || iso.length === 0) return null
|
|
368
|
+
const then = new Date(iso).getTime()
|
|
369
|
+
if (!Number.isFinite(then)) return null
|
|
370
|
+
const diffMin = Math.round((Date.now() - then) / 60_000)
|
|
371
|
+
if (diffMin < 1) return copy.ui.agoJust
|
|
372
|
+
if (diffMin < 60) return copy.ui.agoMinutes(diffMin)
|
|
373
|
+
const hours = Math.round(diffMin / 60)
|
|
374
|
+
if (hours < 24) return copy.ui.agoHours(hours)
|
|
375
|
+
return copy.ui.agoDays(Math.round(hours / 24))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
379
|
+
return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The phase in human words, falling back to the raw code. */
|
|
383
|
+
function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
384
|
+
return copy.job.phase[job.phase] ?? job.phase
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One per-step line for plugin-set installs (and any job that reports steps). */
|
|
388
|
+
function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
|
|
389
|
+
if (!Array.isArray(job.steps) || job.steps.length === 0) return null
|
|
390
|
+
const wrap = el('div', 'hqpu-job-steps')
|
|
391
|
+
wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
|
|
392
|
+
const ol = el('ol', 'hqpu-steps')
|
|
393
|
+
for (const step of job.steps) {
|
|
394
|
+
const li = el('li', `hqpu-step hqpu-step--${step.state}`)
|
|
395
|
+
const dot = el('span', 'hqpu-dot')
|
|
396
|
+
dot.dataset.state =
|
|
397
|
+
step.state === 'installed'
|
|
398
|
+
? 'done'
|
|
399
|
+
: step.state === 'failed'
|
|
400
|
+
? 'error'
|
|
401
|
+
: step.state === 'running'
|
|
402
|
+
? 'warning'
|
|
403
|
+
: 'idle'
|
|
404
|
+
li.append(dot)
|
|
405
|
+
const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
|
|
406
|
+
li.append(el('span', '', text))
|
|
407
|
+
ol.append(li)
|
|
408
|
+
}
|
|
409
|
+
wrap.append(ol)
|
|
410
|
+
return wrap
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
|
|
414
|
+
function stepIndex(phase: UpdateJob['phase']): number {
|
|
415
|
+
switch (phase) {
|
|
416
|
+
case 'downloading':
|
|
417
|
+
return 0
|
|
418
|
+
case 'verifying':
|
|
419
|
+
case 'resolving':
|
|
420
|
+
return 1
|
|
421
|
+
case 'staging':
|
|
422
|
+
case 'activating':
|
|
423
|
+
case 'awaiting-host':
|
|
424
|
+
return 2
|
|
425
|
+
case 'restarting':
|
|
426
|
+
case 'verifying-after-restart':
|
|
427
|
+
return 3
|
|
428
|
+
case 'queued':
|
|
429
|
+
return -1
|
|
430
|
+
default:
|
|
431
|
+
return -1
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The ACTIVE-JOB banner per the mockup: pulsing brand dot + job title with the
|
|
437
|
+
* target version, mono percentage, a gradient bar only when `percent != null`,
|
|
438
|
+
* phase + speed line ("3.1 MB/s (286MB / 450MB)"), and a 4-step indicator row
|
|
439
|
+
* (done=emerald, active=pulsing brand, upcoming=muted).
|
|
440
|
+
*/
|
|
441
|
+
function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
442
|
+
const section = el('section', 'hqpu-group')
|
|
443
|
+
section.dataset.group = 'active-job'
|
|
444
|
+
|
|
445
|
+
const card = el('div', 'hqpu-active')
|
|
446
|
+
|
|
447
|
+
// Top row: pulse dot + title, mono percent.
|
|
448
|
+
const top = el('div', 'hqpu-active-top')
|
|
449
|
+
const titleWrap = el('div', 'hqpu-active-title')
|
|
450
|
+
const pulse = el('span', 'hqpu-pulse-dot')
|
|
451
|
+
pulse.append(el('span', 'hqpu-pulse-ring'))
|
|
452
|
+
const title =
|
|
453
|
+
job.kind === 'runtime'
|
|
454
|
+
? copy.ui.jobRuntimeTitle(job.targetVersion)
|
|
455
|
+
: copy.ui.jobPluginTitle(job.targetVersion)
|
|
456
|
+
titleWrap.append(pulse, el('span', '', title))
|
|
457
|
+
const pct = job.progress.percent
|
|
458
|
+
if (pct !== null) {
|
|
459
|
+
top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
|
|
460
|
+
}
|
|
461
|
+
card.append(top)
|
|
462
|
+
|
|
463
|
+
// Gradient bar — only when the server publishes a real percentage.
|
|
464
|
+
if (pct !== null) {
|
|
465
|
+
const bar = el('div', 'hqpu-progress')
|
|
466
|
+
const fill = el('div', 'hqpu-progress-fill')
|
|
467
|
+
fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
|
|
468
|
+
bar.append(fill)
|
|
469
|
+
card.append(bar)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
|
|
473
|
+
const meta = el('div', 'hqpu-active-meta')
|
|
474
|
+
const phaseText =
|
|
475
|
+
typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
|
|
476
|
+
meta.append(el('span', '', phaseText))
|
|
477
|
+
const speed = formatBytes(job.progress.bytesPerSecond)
|
|
478
|
+
const done = formatBytes(job.progress.bytesDone)
|
|
479
|
+
const total = formatBytes(job.progress.bytesTotal)
|
|
480
|
+
const telemetry =
|
|
481
|
+
speed !== null && done !== null && total !== null
|
|
482
|
+
? `${speed}/s (${done} / ${total})`
|
|
483
|
+
: speed !== null
|
|
484
|
+
? `${speed}/s`
|
|
485
|
+
: done !== null && total !== null
|
|
486
|
+
? copy.job.bytes(done, total)
|
|
487
|
+
: ''
|
|
488
|
+
if (telemetry.length > 0) meta.append(el('span', '', telemetry))
|
|
489
|
+
card.append(meta)
|
|
490
|
+
|
|
491
|
+
// previous → target transition + what happens next (restart / elevation),
|
|
492
|
+
// and the honest "closing does not cancel" note.
|
|
493
|
+
const transition = el('div', 'hqpu-active-meta')
|
|
494
|
+
transition.append(
|
|
495
|
+
el(
|
|
496
|
+
'span',
|
|
497
|
+
'',
|
|
498
|
+
job.previousVersion !== null && job.previousVersion.length > 0
|
|
499
|
+
? `${job.previousVersion} → ${job.targetVersion}`
|
|
500
|
+
: job.targetVersion,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
transition.append(
|
|
504
|
+
el('span', '', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh),
|
|
505
|
+
)
|
|
506
|
+
card.append(transition)
|
|
507
|
+
card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
|
|
508
|
+
|
|
509
|
+
// 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
|
|
510
|
+
const current = stepIndex(job.phase)
|
|
511
|
+
const stepsRow = el('div', 'hqpu-job-steps4')
|
|
512
|
+
copy.ui.jobSteps.forEach((label: string, index: number) => {
|
|
513
|
+
const cls =
|
|
514
|
+
current === -1
|
|
515
|
+
? 'hqpu-job-step--todo'
|
|
516
|
+
: index < current
|
|
517
|
+
? 'hqpu-job-step--done'
|
|
518
|
+
: index === current
|
|
519
|
+
? 'hqpu-job-step--active'
|
|
520
|
+
: 'hqpu-job-step--todo'
|
|
521
|
+
stepsRow.append(el('div', cls, label))
|
|
522
|
+
})
|
|
523
|
+
card.append(stepsRow)
|
|
524
|
+
|
|
525
|
+
section.append(card)
|
|
526
|
+
return section
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The RESULT view for a terminal job: success / failed / rolled-back /
|
|
531
|
+
* abandoned, previous→installed, per-package steps, errorCode + message, a log
|
|
532
|
+
* correlation hint, and retry guidance (a retry starts a NEW job).
|
|
533
|
+
*/
|
|
534
|
+
function resultView(job: UpdateJob, copy: UpdateCopy, onRetry: () => void): HTMLElement {
|
|
535
|
+
const section = el('section', 'hqpu-group')
|
|
536
|
+
section.dataset.group = 'job-result'
|
|
537
|
+
|
|
538
|
+
const tone =
|
|
539
|
+
job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
|
|
540
|
+
const titleText =
|
|
541
|
+
job.phase === 'done'
|
|
542
|
+
? copy.job.resultDone
|
|
543
|
+
: job.phase === 'failed'
|
|
544
|
+
? copy.job.resultFailed
|
|
545
|
+
: job.phase === 'rolled-back'
|
|
546
|
+
? copy.job.resultRolledBack
|
|
547
|
+
: copy.job.resultAbandoned
|
|
548
|
+
|
|
549
|
+
const head = el('div', 'hqpu-group-head')
|
|
550
|
+
const tag = el('span', 'hqpu-tag')
|
|
551
|
+
tag.dataset.tone = tone
|
|
552
|
+
tag.textContent = titleText
|
|
553
|
+
head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
|
|
554
|
+
section.append(head)
|
|
555
|
+
|
|
556
|
+
const card = el('div', 'hqpu-job')
|
|
557
|
+
const res = job.result
|
|
558
|
+
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
559
|
+
const to = res?.installedVersion ?? job.targetVersion
|
|
560
|
+
const finished =
|
|
561
|
+
res !== undefined && res.finishedAt.length > 0
|
|
562
|
+
? ` · ${new Date(res.finishedAt).toLocaleString()}`
|
|
563
|
+
: ''
|
|
564
|
+
card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
|
|
565
|
+
if (res !== undefined && res.message !== undefined && res.message.length > 0) {
|
|
566
|
+
card.append(el('p', 'hqpu-status', res.message))
|
|
567
|
+
}
|
|
568
|
+
if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
|
|
569
|
+
card.append(el('p', 'hqpu-status', res.errorCode))
|
|
570
|
+
}
|
|
571
|
+
if (typeof job.logHint === 'string' && job.logHint.length > 0) {
|
|
572
|
+
card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const steps = stepsList(job, copy)
|
|
576
|
+
if (steps !== null) card.append(steps)
|
|
577
|
+
|
|
578
|
+
// Retry starts a NEW update job — never re-click the settled job id.
|
|
579
|
+
if (job.phase === 'failed' || job.phase === 'abandoned') {
|
|
580
|
+
const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
|
|
581
|
+
retry.type = 'button'
|
|
582
|
+
retry.addEventListener('click', onRetry)
|
|
583
|
+
card.append(retry)
|
|
584
|
+
}
|
|
585
|
+
section.append(card)
|
|
586
|
+
return section
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A one-line row for older recent jobs (newest already shown as the card). */
|
|
590
|
+
function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
|
|
591
|
+
const older = jobs.slice(1, 5)
|
|
592
|
+
if (older.length === 0) return null
|
|
593
|
+
const wrap = el('div', 'hqpu-group')
|
|
594
|
+
wrap.dataset.group = 'recent-jobs'
|
|
595
|
+
wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
|
|
596
|
+
const ul = el('ul', 'hqpu-cards')
|
|
597
|
+
for (const job of older) {
|
|
598
|
+
const li = el('li', 'hqpu-status')
|
|
599
|
+
const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
|
|
600
|
+
li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
|
|
601
|
+
ul.append(li)
|
|
602
|
+
}
|
|
603
|
+
wrap.append(ul)
|
|
604
|
+
return wrap
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Check-status banner: a FAILED check shows a clear error row + Retry (never
|
|
609
|
+
* the silent "no updates" empty state); a successful check shows the time;
|
|
610
|
+
* when a check failed but a prior report exists it is labelled stale.
|
|
611
|
+
*/
|
|
612
|
+
function checkBanner(
|
|
613
|
+
report: PluginUpdateReport,
|
|
614
|
+
copy: UpdateCopy,
|
|
615
|
+
onRetry: () => void,
|
|
616
|
+
): HTMLElement | null {
|
|
617
|
+
const failed = typeof report.lastError === 'string' && report.lastError.length > 0
|
|
618
|
+
const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
|
|
619
|
+
if (!failed && !checked) return null
|
|
620
|
+
|
|
621
|
+
const wrap = el('section', 'hqpu-group')
|
|
622
|
+
wrap.dataset.group = 'check-status'
|
|
623
|
+
if (failed) {
|
|
624
|
+
const banner = el('div', 'hqpu-banner')
|
|
625
|
+
banner.dataset.tone = 'danger'
|
|
626
|
+
banner.innerHTML = WARNING_ICON(14)
|
|
627
|
+
banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
|
|
628
|
+
wrap.append(banner)
|
|
629
|
+
if (report.items.length > 0) {
|
|
630
|
+
wrap.append(el('p', 'hqpu-status', copy.staleNote))
|
|
631
|
+
}
|
|
632
|
+
const row = el('div', 'hqpu-actions')
|
|
633
|
+
const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
|
|
634
|
+
retry.type = 'button'
|
|
635
|
+
retry.addEventListener('click', onRetry)
|
|
636
|
+
row.append(retry)
|
|
637
|
+
wrap.append(row)
|
|
638
|
+
return wrap
|
|
639
|
+
}
|
|
640
|
+
wrap.append(
|
|
641
|
+
el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
|
|
642
|
+
)
|
|
643
|
+
return wrap
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
647
|
+
function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
|
|
648
|
+
if (release === null || release === undefined) return null
|
|
649
|
+
const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
|
|
650
|
+
const breaking = Array.isArray(release.breaking)
|
|
651
|
+
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
652
|
+
: []
|
|
653
|
+
const hasContent =
|
|
654
|
+
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
655
|
+
notes.length > 0 ||
|
|
656
|
+
breaking.length > 0 ||
|
|
657
|
+
typeof release.notesUrl === 'string' ||
|
|
658
|
+
typeof release.publishedAt === 'string'
|
|
659
|
+
if (!hasContent) return null
|
|
660
|
+
|
|
661
|
+
const section = el('section', 'hqpu-group')
|
|
662
|
+
section.dataset.group = 'release'
|
|
663
|
+
section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
|
|
664
|
+
if (typeof release.summary === 'string' && release.summary.length > 0) {
|
|
665
|
+
section.append(el('p', 'hqpu-status', release.summary))
|
|
666
|
+
}
|
|
667
|
+
if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
|
|
668
|
+
section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
|
|
669
|
+
}
|
|
670
|
+
if (release.requiresRestart === true) {
|
|
671
|
+
section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
|
|
672
|
+
}
|
|
673
|
+
if (notes.length > 0) {
|
|
674
|
+
const ul = el('ul', 'hqpu-release-notes')
|
|
675
|
+
for (const note of notes) ul.append(el('li', '', note as string))
|
|
676
|
+
section.append(ul)
|
|
677
|
+
}
|
|
678
|
+
if (breaking.length > 0) {
|
|
679
|
+
const banner = el('div', 'hqpu-banner')
|
|
680
|
+
banner.dataset.tone = 'danger'
|
|
681
|
+
banner.innerHTML = WARNING_ICON(14)
|
|
682
|
+
const inner = el('span')
|
|
683
|
+
inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
|
|
684
|
+
inner.append(document.createTextNode(breaking.join(' · ')))
|
|
685
|
+
banner.append(inner)
|
|
686
|
+
section.append(banner)
|
|
687
|
+
}
|
|
688
|
+
if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
|
|
689
|
+
const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
|
|
690
|
+
link.href = release.notesUrl
|
|
691
|
+
link.target = '_blank'
|
|
692
|
+
link.rel = 'noreferrer'
|
|
693
|
+
section.append(link)
|
|
694
|
+
}
|
|
695
|
+
return section
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Native disclosure that hides the developer-facing source editors by
|
|
699
|
+
* default (end users should never need to type a manifest URL). */
|
|
700
|
+
function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
|
|
701
|
+
const details = el('details', 'hqpu-advanced')
|
|
702
|
+
details.append(el('summary', '', copy.release.advanced))
|
|
703
|
+
for (const node of nodes) {
|
|
704
|
+
if (node !== null) details.append(node)
|
|
705
|
+
}
|
|
706
|
+
return details
|
|
707
|
+
}
|
|
708
|
+
|
|
270
709
|
/** Mutable editor state for one manifest source (lives in the panel closure). */
|
|
271
710
|
interface SourceState {
|
|
272
711
|
editing: boolean
|
|
@@ -431,6 +870,11 @@ function sourceEditor(
|
|
|
431
870
|
const labels = sourceLabels(copy, kind)
|
|
432
871
|
const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
|
|
433
872
|
if (!actions.sourceEditing) {
|
|
873
|
+
// Read mode: show the URL currently in effect (mono, muted), then edit.
|
|
874
|
+
const urlLine = el('p', 'hqpu-source-current-url', source.url)
|
|
875
|
+
urlLine.style.cssText =
|
|
876
|
+
'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#a1a1aa;word-break:break-all;'
|
|
877
|
+
block.append(urlLine)
|
|
434
878
|
block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
|
|
435
879
|
const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
|
|
436
880
|
edit.type = 'button'
|
|
@@ -516,140 +960,184 @@ function pluginSourceGroup(
|
|
|
516
960
|
return section
|
|
517
961
|
}
|
|
518
962
|
|
|
519
|
-
/**
|
|
520
|
-
|
|
963
|
+
/**
|
|
964
|
+
* The host's own report of the last runtime swap. Rolled-back / failed renders
|
|
965
|
+
* as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
|
|
966
|
+
* succeeded renders as a subtle green inline line — not a loud banner. The
|
|
967
|
+
* plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
|
|
968
|
+
*/
|
|
969
|
+
function runtimeResultAlert(
|
|
521
970
|
report: PluginUpdateReport,
|
|
522
971
|
copy: UpdateCopy,
|
|
523
|
-
|
|
524
|
-
|
|
972
|
+
dismissed: boolean,
|
|
973
|
+
onDismiss: () => void,
|
|
974
|
+
): HTMLElement | null {
|
|
975
|
+
const lastResult = report.runtime.lastResult
|
|
976
|
+
if (lastResult === null || lastResult === undefined) return null
|
|
977
|
+
|
|
978
|
+
// Green inline confirmation — quiet by design.
|
|
979
|
+
if (lastResult.state === 'succeeded') {
|
|
980
|
+
const line = el('p', 'hqpu-alert-green')
|
|
981
|
+
line.dataset.role = 'runtime-success'
|
|
982
|
+
line.textContent = copy.ui.successInline
|
|
983
|
+
return line
|
|
984
|
+
}
|
|
985
|
+
if (dismissed) return null
|
|
986
|
+
|
|
525
987
|
const section = el('section', 'hqpu-group')
|
|
526
|
-
section.dataset.group = 'runtime'
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
988
|
+
section.dataset.group = 'runtime-alert'
|
|
989
|
+
const card = el('div', 'hqpu-alert hqpu-alert--amber')
|
|
990
|
+
card.innerHTML = WARNING_ICON(14)
|
|
991
|
+
|
|
992
|
+
const body = el('div')
|
|
993
|
+
const title = el('div', 'hqpu-alert-title')
|
|
994
|
+
title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
|
|
995
|
+
body.append(title)
|
|
996
|
+
|
|
997
|
+
const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
|
|
998
|
+
const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
|
|
999
|
+
? lastResult.errorCode
|
|
1000
|
+
: '—'
|
|
1001
|
+
const text =
|
|
1002
|
+
lastResult.state === 'failed'
|
|
1003
|
+
? copy.ui.failedBody(oldVer, code)
|
|
1004
|
+
: copy.ui.rollbackBody(oldVer, code)
|
|
1005
|
+
// Build the body as text + an inline <code> chip for the error code.
|
|
1006
|
+
const bodyP = el('p', 'hqpu-alert-body')
|
|
1007
|
+
const textOnly =
|
|
1008
|
+
lastResult.state === 'failed'
|
|
1009
|
+
? copy.ui.failedBody(oldVer, '')
|
|
1010
|
+
: copy.ui.rollbackBody(oldVer, '')
|
|
1011
|
+
// Strip the trailing "code" placeholder we appended; re-add as a chip.
|
|
1012
|
+
bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
|
|
1013
|
+
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
1014
|
+
bodyP.append(document.createTextNode(' '))
|
|
1015
|
+
const chip = el('code', '', lastResult.errorCode)
|
|
1016
|
+
bodyP.append(chip)
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
1019
|
+
bodyP.append(el('br'), document.createTextNode(lastResult.message))
|
|
1020
|
+
}
|
|
1021
|
+
body.append(bodyP)
|
|
1022
|
+
|
|
1023
|
+
// Dismiss action (no log button — the plugin has no log-open affordance).
|
|
1024
|
+
const actions = el('div', 'hqpu-alert-actions')
|
|
1025
|
+
const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
|
|
1026
|
+
dismiss.type = 'button'
|
|
1027
|
+
dismiss.addEventListener('click', onDismiss)
|
|
1028
|
+
actions.append(dismiss)
|
|
1029
|
+
body.append(actions)
|
|
1030
|
+
|
|
1031
|
+
card.append(body)
|
|
1032
|
+
section.append(card)
|
|
1033
|
+
return section
|
|
1034
|
+
}
|
|
530
1035
|
|
|
531
|
-
|
|
1036
|
+
/** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
|
|
1037
|
+
* 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
|
|
1038
|
+
* 升级核心 button (existing arm→confirm flow), and the amber restart note. */
|
|
1039
|
+
function runtimeEngineCard(
|
|
1040
|
+
report: PluginUpdateReport,
|
|
1041
|
+
copy: UpdateCopy,
|
|
1042
|
+
actions: RuntimeActions,
|
|
1043
|
+
): HTMLElement | null {
|
|
532
1044
|
const runtime = report.runtime
|
|
533
|
-
// `runtime.latest` is a release ENTRY; only its version is ever displayed or
|
|
534
|
-
// sent back as `targetVersion`.
|
|
535
1045
|
const latestVersion = runtimeLatestVersion(runtime)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
wrap.append(dd)
|
|
548
|
-
facts.append(wrap)
|
|
1046
|
+
const card = el('section', 'hqpu-engine')
|
|
1047
|
+
card.dataset.group = 'runtime'
|
|
1048
|
+
|
|
1049
|
+
const head = el('div', 'hqpu-engine-head')
|
|
1050
|
+
const titleWrap = el('div', 'hqpu-engine-title')
|
|
1051
|
+
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>`
|
|
1052
|
+
titleWrap.append(el('span', '', copy.ui.runtimeEngine))
|
|
1053
|
+
head.append(titleWrap)
|
|
1054
|
+
if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
|
|
1055
|
+
head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
|
|
549
1056
|
}
|
|
1057
|
+
card.append(head)
|
|
550
1058
|
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
row(copy.runtime.set, report.manifestVersion, true)
|
|
568
|
-
}
|
|
569
|
-
if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
|
|
570
|
-
row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
|
|
571
|
-
}
|
|
572
|
-
if (source !== null) {
|
|
573
|
-
sourceFactRow(facts, source, copy)
|
|
1059
|
+
// Update row (only when a newer distribution is known).
|
|
1060
|
+
if (runtime.updateAvailable && latestVersion !== null) {
|
|
1061
|
+
const row = el('div', 'hqpu-engine-update-row')
|
|
1062
|
+
const left = el('div')
|
|
1063
|
+
const newLine = el('div', 'hqpu-engine-new-line')
|
|
1064
|
+
newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
|
|
1065
|
+
const latestEntry = runtime.latest
|
|
1066
|
+
const hasCritical =
|
|
1067
|
+
(Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
|
|
1068
|
+
(Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
|
|
1069
|
+
if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
|
|
1070
|
+
left.append(newLine)
|
|
1071
|
+
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
1072
|
+
const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
|
|
1073
|
+
if (size.length > 0 || summary.length > 0) {
|
|
1074
|
+
left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
|
|
574
1075
|
}
|
|
575
|
-
|
|
576
|
-
section.append(facts)
|
|
1076
|
+
row.append(left)
|
|
577
1077
|
|
|
578
|
-
|
|
579
|
-
// manifest, a local rig) without an env var and a restart. hq-edge persists
|
|
580
|
-
// the override per host, so it survives restarts of both processes.
|
|
581
|
-
if (source !== null) {
|
|
582
|
-
section.append(sourceEditor('runtime', source, copy, actions))
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// ── Runtime update action row ──
|
|
586
|
-
// Only offered when hq-edge reports a newer distribution AND named its
|
|
587
|
-
// version: the request needs `targetVersion`, so an entry without one has
|
|
588
|
-
// nothing to send.
|
|
589
|
-
if (runtime.updateAvailable && latestVersion !== null) {
|
|
590
|
-
const row = el('div', 'hqpu-actions hqpu-runtime-actions')
|
|
1078
|
+
// Upgrade action: arm → confirm, accepted/busy states preserved.
|
|
591
1079
|
if (actions.accepted) {
|
|
592
|
-
|
|
593
|
-
// "update" here could only produce a transport error.
|
|
594
|
-
const line = el('span', 'hqpu-status hqpu-status--dot')
|
|
595
|
-
line.setAttribute('role', 'status')
|
|
596
|
-
line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updateAccepted))
|
|
597
|
-
row.append(line)
|
|
1080
|
+
row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
|
|
598
1081
|
} else if (actions.busy) {
|
|
599
|
-
|
|
600
|
-
// minutes. Say what is happening instead of an open-ended spinner.
|
|
601
|
-
const line = el('span', 'hqpu-status hqpu-status--dot')
|
|
602
|
-
line.setAttribute('role', 'status')
|
|
603
|
-
line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updating))
|
|
604
|
-
row.append(line)
|
|
1082
|
+
row.append(el('span', 'hqpu-status', copy.runtime.updating))
|
|
605
1083
|
} else if (actions.armed) {
|
|
606
|
-
|
|
607
|
-
|
|
1084
|
+
const confirmWrap = el('div')
|
|
1085
|
+
confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
|
|
1086
|
+
const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
|
|
608
1087
|
yes.type = 'button'
|
|
609
1088
|
yes.dataset.runtimeConfirm = ''
|
|
610
1089
|
yes.addEventListener('click', actions.onConfirm)
|
|
611
|
-
const no = el('button', 'hqpu-btn hqpu-btn--
|
|
1090
|
+
const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
|
|
612
1091
|
no.type = 'button'
|
|
613
1092
|
no.addEventListener('click', actions.onCancel)
|
|
614
|
-
|
|
1093
|
+
confirmWrap.append(yes, no)
|
|
1094
|
+
row.append(confirmWrap)
|
|
615
1095
|
} else {
|
|
616
|
-
const start = el('button', 'hqpu-btn hqpu-btn--
|
|
1096
|
+
const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
|
|
617
1097
|
start.type = 'button'
|
|
618
1098
|
start.dataset.runtimeUpdate = ''
|
|
619
|
-
start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`
|
|
620
1099
|
start.addEventListener('click', actions.onArm)
|
|
621
1100
|
row.append(start)
|
|
622
1101
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1102
|
+
card.append(row)
|
|
1103
|
+
const note = el('p', 'hqpu-engine-note')
|
|
1104
|
+
note.innerHTML =
|
|
1105
|
+
`<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>`
|
|
1106
|
+
card.append(note)
|
|
1107
|
+
} else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
1108
|
+
card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
|
|
626
1109
|
}
|
|
627
|
-
|
|
628
|
-
if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
629
|
-
const line = el('p', 'hqpu-status hqpu-status--dot')
|
|
630
|
-
line.setAttribute('role', 'status')
|
|
631
|
-
const dot = el('span', 'hqpu-dot')
|
|
632
|
-
dot.dataset.state = 'error'
|
|
633
|
-
line.append(dot, el('span', '', `${copy.lastError}: ${runtime.lastError}`))
|
|
634
|
-
section.append(line)
|
|
635
|
-
}
|
|
636
|
-
return section
|
|
1110
|
+
return card
|
|
637
1111
|
}
|
|
638
1112
|
|
|
639
1113
|
function renderList(
|
|
640
1114
|
section: HTMLElement,
|
|
641
1115
|
report: PluginUpdateReport | null,
|
|
642
1116
|
copy: UpdateCopy,
|
|
643
|
-
|
|
644
|
-
|
|
1117
|
+
changelogOpen: Set<string>,
|
|
1118
|
+
allOpen: boolean,
|
|
1119
|
+
onToggleCard: (name: string) => void,
|
|
1120
|
+
onToggleAll: () => void,
|
|
1121
|
+
onRetryCheck: () => void,
|
|
645
1122
|
): void {
|
|
646
1123
|
section.textContent = ''
|
|
647
1124
|
if (report === null) {
|
|
648
1125
|
section.append(skeleton(copy))
|
|
649
1126
|
return
|
|
650
1127
|
}
|
|
1128
|
+
// Check-status banner: a failed check shows an error row + Retry (never the
|
|
1129
|
+
// silent "no updates" empty state); a successful check shows the time.
|
|
1130
|
+
const banner = checkBanner(report, copy, onRetryCheck)
|
|
1131
|
+
if (banner !== null) section.append(banner)
|
|
1132
|
+
// Set-level release metadata (v2 manifest): summary, notes, breaking.
|
|
1133
|
+
const release = releaseHeader(report.release, copy)
|
|
1134
|
+
if (release !== null) section.append(release)
|
|
651
1135
|
if (report.items.length === 0) {
|
|
652
|
-
|
|
1136
|
+
// A successful check with zero updates — distinct from a failed check,
|
|
1137
|
+
// which already rendered its error row above.
|
|
1138
|
+
if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
|
|
1139
|
+
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
1140
|
+
}
|
|
653
1141
|
return
|
|
654
1142
|
}
|
|
655
1143
|
|
|
@@ -665,7 +1153,7 @@ function renderList(
|
|
|
665
1153
|
[copy.group.current, byState('current')],
|
|
666
1154
|
]
|
|
667
1155
|
for (const [heading, items] of order) {
|
|
668
|
-
const node = group(heading, items, copy,
|
|
1156
|
+
const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll)
|
|
669
1157
|
if (node !== null) section.append(node)
|
|
670
1158
|
}
|
|
671
1159
|
}
|
|
@@ -675,13 +1163,20 @@ function renderRuntime(
|
|
|
675
1163
|
report: PluginUpdateReport | null,
|
|
676
1164
|
copy: UpdateCopy,
|
|
677
1165
|
actions: RuntimeActions,
|
|
1166
|
+
dismissedAlert: boolean,
|
|
1167
|
+
onDismissAlert: () => void,
|
|
678
1168
|
): void {
|
|
679
1169
|
section.textContent = ''
|
|
680
1170
|
if (report === null) return
|
|
681
|
-
|
|
1171
|
+
const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
|
|
1172
|
+
if (alert !== null) section.append(alert)
|
|
1173
|
+
const engine = runtimeEngineCard(report, copy, actions)
|
|
1174
|
+
if (engine !== null) section.append(engine)
|
|
682
1175
|
}
|
|
683
1176
|
|
|
684
|
-
/** The plugin-source group, above the plugin groups
|
|
1177
|
+
/** The plugin-source group, above the plugin groups, hidden behind an advanced
|
|
1178
|
+
* disclosure (manifest-URL editing is a developer affordance). No-op without
|
|
1179
|
+
* the field. */
|
|
685
1180
|
function renderPluginSource(
|
|
686
1181
|
section: HTMLElement,
|
|
687
1182
|
report: PluginUpdateReport | null,
|
|
@@ -691,10 +1186,10 @@ function renderPluginSource(
|
|
|
691
1186
|
section.textContent = ''
|
|
692
1187
|
if (report === null) return
|
|
693
1188
|
const node = pluginSourceGroup(report, copy, actions)
|
|
694
|
-
if (node !== null) section.append(node)
|
|
1189
|
+
if (node !== null) section.append(advancedBlock(copy, [node]))
|
|
695
1190
|
}
|
|
696
1191
|
|
|
697
|
-
/** Run the install transaction, then
|
|
1192
|
+
/** Run the install transaction (a new durable job), then let the store poll it. */
|
|
698
1193
|
async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
|
|
699
1194
|
const report = store.report()
|
|
700
1195
|
if (report === null) return
|
|
@@ -704,17 +1199,23 @@ async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<voi
|
|
|
704
1199
|
showToast(copy.checkFailed, 'error')
|
|
705
1200
|
return
|
|
706
1201
|
}
|
|
1202
|
+
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1203
|
+
// action buttons and shows the "DSH will restart" footer state (previously
|
|
1204
|
+
// dead code — runInstall never called it).
|
|
1205
|
+
store.setInstalling(true)
|
|
707
1206
|
try {
|
|
708
1207
|
const result = await installSet(report.manifestVersion)
|
|
709
1208
|
if (result.error !== undefined) {
|
|
1209
|
+
store.setInstalling(false)
|
|
710
1210
|
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
711
1211
|
return
|
|
712
1212
|
}
|
|
713
|
-
//
|
|
714
|
-
//
|
|
1213
|
+
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1214
|
+
// poller renders progress and clears the flag when the job settles.
|
|
715
1215
|
showToast(copy.installSuccess, 'success')
|
|
716
|
-
void
|
|
1216
|
+
void store.fetchStatus()
|
|
717
1217
|
} catch (err: unknown) {
|
|
1218
|
+
store.setInstalling(false)
|
|
718
1219
|
showToast(
|
|
719
1220
|
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
720
1221
|
'error',
|
|
@@ -743,90 +1244,99 @@ function showToast(message: string, tone: 'success' | 'error'): void {
|
|
|
743
1244
|
}, TOAST_HOLD_MS)
|
|
744
1245
|
}
|
|
745
1246
|
|
|
746
|
-
|
|
747
|
-
|
|
1247
|
+
/**
|
|
1248
|
+
* The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
|
|
1249
|
+
* EXISTING plugin-source and runtime-source editors (POST /plugin-source,
|
|
1250
|
+
* POST /runtime-source, save→check→refresh already wired). No new endpoints:
|
|
1251
|
+
* if a manifest source has no data, that field is omitted rather than faked.
|
|
1252
|
+
*/
|
|
1253
|
+
function renderSettingsPanel(
|
|
1254
|
+
panel: HTMLElement,
|
|
748
1255
|
report: PluginUpdateReport | null,
|
|
749
1256
|
copy: UpdateCopy,
|
|
750
|
-
|
|
751
|
-
|
|
1257
|
+
open: boolean,
|
|
1258
|
+
pluginActions: SourceEditorActions,
|
|
1259
|
+
runtimeActions: SourceEditorActions,
|
|
752
1260
|
): void {
|
|
753
|
-
|
|
754
|
-
|
|
1261
|
+
panel.textContent = ''
|
|
1262
|
+
panel.style.display = open ? '' : 'none'
|
|
1263
|
+
if (!open || report === null) return
|
|
1264
|
+
|
|
1265
|
+
const row = el('div', 'hqpu-settings-row')
|
|
1266
|
+
row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
|
|
1267
|
+
// Origin badge: custom override (amber) vs the default configured source (green).
|
|
1268
|
+
const pluginSource = normalizeSource(report.pluginSource)
|
|
1269
|
+
const runtimeSource = normalizeSource(report.runtime.source)
|
|
1270
|
+
const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
|
|
1271
|
+
const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
|
|
1272
|
+
badge.dataset.origin = overridden ? 'override' : 'config'
|
|
1273
|
+
row.append(badge)
|
|
1274
|
+
panel.append(row)
|
|
1275
|
+
|
|
1276
|
+
if (pluginSource !== null) {
|
|
1277
|
+
panel.append(el('label', '', copy.ui.pluginUrlLabel))
|
|
1278
|
+
panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
|
|
1279
|
+
}
|
|
1280
|
+
if (runtimeSource !== null) {
|
|
1281
|
+
panel.append(el('label', '', copy.ui.runtimeUrlLabel))
|
|
1282
|
+
panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
755
1285
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1286
|
+
/**
|
|
1287
|
+
* The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
|
|
1288
|
+
* full-width primary "一键更新全套插件 (vX)" (install = one durable job),
|
|
1289
|
+
* a secondary 忽略 wired to the existing ignore/unignore affordance, and the
|
|
1290
|
+
* footnote. The count EXCLUDES blocked / ignored / already-current entries.
|
|
1291
|
+
*/
|
|
1292
|
+
function renderFooter(
|
|
1293
|
+
footer: HTMLElement,
|
|
1294
|
+
installBtn: HTMLButtonElement,
|
|
1295
|
+
ignoreBtn: HTMLButtonElement,
|
|
1296
|
+
countEl: HTMLElement,
|
|
1297
|
+
setEl: HTMLElement,
|
|
1298
|
+
report: PluginUpdateReport | null,
|
|
1299
|
+
copy: UpdateCopy,
|
|
1300
|
+
installing: boolean,
|
|
1301
|
+
store: UpdateStoreLike,
|
|
1302
|
+
): void {
|
|
1303
|
+
if (report === null) {
|
|
1304
|
+
countEl.textContent = ''
|
|
1305
|
+
setEl.textContent = ''
|
|
1306
|
+
installBtn.disabled = true
|
|
1307
|
+
ignoreBtn.style.display = 'none'
|
|
761
1308
|
return
|
|
762
1309
|
}
|
|
763
1310
|
|
|
764
1311
|
const actionable = report.items.filter(
|
|
765
1312
|
(item) => item.state === 'update' || item.state === 'install',
|
|
766
1313
|
).length
|
|
767
|
-
// Every write needs an evaluated set; hq-edge reports `null` until it has
|
|
768
|
-
// read a manifest, and posting `null` would only earn a 400.
|
|
769
1314
|
const hasSet = report.manifestVersion !== null
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
bar.append(installBtn)
|
|
1315
|
+
const setVersion: string = report.manifestVersion ?? ''
|
|
1316
|
+
|
|
1317
|
+
countEl.textContent = copy.ui.footerCount(actionable)
|
|
1318
|
+
setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
|
|
1319
|
+
|
|
1320
|
+
// Install-all: honest set-level label; disabled while busy or nothing to do.
|
|
1321
|
+
installBtn.textContent = hasSet
|
|
1322
|
+
? copy.ui.installAllSet(setVersion)
|
|
1323
|
+
: copy.ui.installAllSet('—')
|
|
1324
|
+
installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
|
|
1325
|
+
installBtn.dataset.busy = installing ? 'true' : 'false'
|
|
1326
|
+
installBtn.onclick = () => {
|
|
1327
|
+
void runInstall(store, copy)
|
|
784
1328
|
}
|
|
785
1329
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
.catch(() => undefined)
|
|
795
|
-
})
|
|
796
|
-
bar.append(unignore)
|
|
797
|
-
} else if (actionable > 0) {
|
|
798
|
-
const ignore = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.ignore)
|
|
799
|
-
ignore.type = 'button'
|
|
800
|
-
ignore.addEventListener('click', () => {
|
|
801
|
-
void ignoreOffer(report.manifestVersion as string)
|
|
802
|
-
.then(() => store.refresh())
|
|
803
|
-
.catch(() => undefined)
|
|
804
|
-
})
|
|
805
|
-
bar.append(ignore)
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/** After an install, poll the backend until the update report settles
|
|
810
|
-
* (installing flag clears or DSH restarts under us — the next load re-reads
|
|
811
|
-
* the fresh state). */
|
|
812
|
-
function pollUntilSettled(store: UpdateStoreLike): void {
|
|
813
|
-
let attempts = 0
|
|
814
|
-
const tick = (): void => {
|
|
815
|
-
void store
|
|
816
|
-
.refresh()
|
|
817
|
-
.then((report) => {
|
|
818
|
-
if (store.installing()) return
|
|
819
|
-
if (report !== null && report.ignoredSet === false && attempts >= 2) return
|
|
820
|
-
})
|
|
1330
|
+
// Secondary ignore button: only when there is an evaluated set to ignore.
|
|
1331
|
+
ignoreBtn.style.display = hasSet ? '' : 'none'
|
|
1332
|
+
ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
|
|
1333
|
+
ignoreBtn.onclick = () => {
|
|
1334
|
+
const version = report.manifestVersion as string
|
|
1335
|
+
const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
|
|
1336
|
+
void action
|
|
1337
|
+
.then(() => store.refresh())
|
|
821
1338
|
.catch(() => undefined)
|
|
822
|
-
.finally(() => {
|
|
823
|
-
attempts += 1
|
|
824
|
-
if (attempts < 10) {
|
|
825
|
-
window.setTimeout(tick, 2000)
|
|
826
|
-
}
|
|
827
|
-
})
|
|
828
1339
|
}
|
|
829
|
-
tick()
|
|
830
1340
|
}
|
|
831
1341
|
|
|
832
1342
|
/**
|
|
@@ -853,48 +1363,71 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
853
1363
|
panel.setAttribute('aria-label', copy.viewTitle)
|
|
854
1364
|
panel.tabIndex = -1
|
|
855
1365
|
|
|
856
|
-
// ──
|
|
1366
|
+
// ── Sticky header: brand icon + title + relative checked time, refresh +
|
|
1367
|
+
// gear (advanced source settings) + close ──
|
|
857
1368
|
const head = el('div', 'hqpu-head')
|
|
858
1369
|
const headMain = el('div', 'hqpu-head-main')
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
info.innerHTML = INFO_ICON(11)
|
|
867
|
-
intro.append(info)
|
|
868
|
-
headMain.append(intro)
|
|
1370
|
+
const brandIcon = el('span', 'hqpu-brand-icon')
|
|
1371
|
+
brandIcon.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.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5.6 2.6a6 6 0 015.6 1.6M12.4 13.4a6 6 0 01-5.6-1.6M10.6 2V5.4H7.2M5.4 14V10.6H8.8"/></svg>`
|
|
1372
|
+
const titleBlock = el('div', 'hqpu-title-block')
|
|
1373
|
+
titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
|
|
1374
|
+
const sub = el('p', 'hqpu-sub')
|
|
1375
|
+
titleBlock.append(sub)
|
|
1376
|
+
headMain.append(brandIcon, titleBlock)
|
|
869
1377
|
|
|
870
1378
|
const toolbar = el('div', 'hqpu-toolbar')
|
|
871
1379
|
const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
|
|
872
|
-
void store.
|
|
1380
|
+
void store.check()
|
|
873
1381
|
})
|
|
874
1382
|
toolbar.append(refreshBtn)
|
|
1383
|
+
const gearBtn = iconButton(
|
|
1384
|
+
'hqpu-icon-btn',
|
|
1385
|
+
`<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"><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>`,
|
|
1386
|
+
copy.ui.gearTooltip,
|
|
1387
|
+
() => {
|
|
1388
|
+
settingsOpen = !settingsOpen
|
|
1389
|
+
render()
|
|
1390
|
+
},
|
|
1391
|
+
)
|
|
1392
|
+
toolbar.append(gearBtn)
|
|
875
1393
|
const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
|
|
876
1394
|
onClose()
|
|
877
1395
|
})
|
|
878
1396
|
toolbar.append(closeBtn)
|
|
879
|
-
|
|
880
1397
|
head.append(headMain, toolbar)
|
|
881
1398
|
panel.append(head)
|
|
882
1399
|
|
|
883
|
-
// ──
|
|
1400
|
+
// ── Collapsible advanced settings panel (gear-toggled) ──
|
|
1401
|
+
const settingsPanel = el('div', 'hqpu-settings')
|
|
1402
|
+
panel.append(settingsPanel)
|
|
1403
|
+
|
|
1404
|
+
// ── Scrolling body: the narrow column ──
|
|
884
1405
|
const scroll = el('div', 'hqpu-scroll')
|
|
885
1406
|
const column = el('div', 'hqpu-column')
|
|
886
|
-
|
|
1407
|
+
// The durable-job view leads: an active install/runtime update is resumed on
|
|
1408
|
+
// open BEFORE anything else, and a recent terminal result is shown here too.
|
|
1409
|
+
const jobSection = el('div', 'hqpu-stack')
|
|
887
1410
|
const listSection = el('div', 'hqpu-stack')
|
|
888
1411
|
const runtimeSection = el('div', 'hqpu-stack')
|
|
889
1412
|
const toastSection = el('div', 'hqpu-toast-host')
|
|
890
|
-
column.append(
|
|
1413
|
+
column.append(jobSection, listSection, runtimeSection, toastSection)
|
|
891
1414
|
scroll.append(column)
|
|
892
1415
|
panel.append(scroll)
|
|
893
1416
|
|
|
894
|
-
// ──
|
|
1417
|
+
// ── Sticky footer ──
|
|
895
1418
|
const footer = el('div', 'hqpu-footer')
|
|
896
|
-
const
|
|
897
|
-
|
|
1419
|
+
const footMeta = el('div', 'hqpu-foot-meta')
|
|
1420
|
+
const footCount = el('span', '')
|
|
1421
|
+
const footSet = el('span', 'hqpu-foot-set')
|
|
1422
|
+
footMeta.append(footCount, footSet)
|
|
1423
|
+
const footActions = el('div', 'hqpu-foot-actions')
|
|
1424
|
+
const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
|
|
1425
|
+
installAllBtn.type = 'button'
|
|
1426
|
+
const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
|
|
1427
|
+
ignoreBtn.type = 'button'
|
|
1428
|
+
footActions.append(installAllBtn, ignoreBtn)
|
|
1429
|
+
const footnote = el('p', 'hqpu-footnote', copy.ui.footnote)
|
|
1430
|
+
footer.append(footMeta, footActions, footnote)
|
|
898
1431
|
panel.append(footer)
|
|
899
1432
|
|
|
900
1433
|
overlay.append(scrim, panel)
|
|
@@ -911,6 +1444,12 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
911
1444
|
let runtimeBusy = false
|
|
912
1445
|
let runtimeAccepted = false
|
|
913
1446
|
|
|
1447
|
+
// ── Panel-closure UI state (survive store refreshes) ──
|
|
1448
|
+
const changelogOpen = new Set<string>()
|
|
1449
|
+
let allOpen = false
|
|
1450
|
+
let settingsOpen = false
|
|
1451
|
+
let dismissedAlert = false
|
|
1452
|
+
|
|
914
1453
|
// Source-editor state, same reasoning: a background refresh must not discard
|
|
915
1454
|
// a half-typed URL or silently close either editor.
|
|
916
1455
|
const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
|
|
@@ -1061,6 +1600,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1061
1600
|
// server that is already gone.
|
|
1062
1601
|
runtimeAccepted = true
|
|
1063
1602
|
showToast(copy.runtime.updateAccepted, 'success')
|
|
1603
|
+
// Adopt the new durable job immediately so the active-job view renders
|
|
1604
|
+
// (it polls from here; this process is about to be swapped away anyway).
|
|
1605
|
+
void store.fetchStatus()
|
|
1064
1606
|
} catch (err: unknown) {
|
|
1065
1607
|
showToast(
|
|
1066
1608
|
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -1072,20 +1614,84 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1072
1614
|
}
|
|
1073
1615
|
}
|
|
1074
1616
|
|
|
1617
|
+
/** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
|
|
1618
|
+
* re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
|
|
1619
|
+
* settled job id. */
|
|
1620
|
+
const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
|
|
1621
|
+
if (kind === 'runtime') {
|
|
1622
|
+
runtimeArmed = true
|
|
1623
|
+
render()
|
|
1624
|
+
} else {
|
|
1625
|
+
void runInstall(store, copy)
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const renderJobSection = (): void => {
|
|
1630
|
+
jobSection.textContent = ''
|
|
1631
|
+
const active = store.activeJob()
|
|
1632
|
+
if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
|
|
1633
|
+
// An active job leads the panel — resumed on open, polled to its end.
|
|
1634
|
+
jobSection.append(activeJobView(active, copy))
|
|
1635
|
+
return
|
|
1636
|
+
}
|
|
1637
|
+
const recent = store.recentJobs()
|
|
1638
|
+
const latest = recent[0]
|
|
1639
|
+
if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
|
|
1640
|
+
jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
|
|
1641
|
+
const older = recentJobsList(recent, copy)
|
|
1642
|
+
if (older !== null) jobSection.append(older)
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1075
1646
|
const render = (): void => {
|
|
1076
1647
|
const report = store.report()
|
|
1077
|
-
|
|
1648
|
+
// The footer "installing" state is owned by the store flag OR any active
|
|
1649
|
+
// durable job — buttons stay disabled while either is true.
|
|
1650
|
+
const jobActive =
|
|
1651
|
+
store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
|
|
1652
|
+
const installing = store.installing() || jobActive
|
|
1078
1653
|
const stale = report === null
|
|
1079
1654
|
refreshBtn.disabled = stale
|
|
1655
|
+
refreshBtn.title = copy.refresh
|
|
1656
|
+
|
|
1657
|
+
// Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
|
|
1658
|
+
const ago = relativeAgo(report?.checkedAt, copy)
|
|
1659
|
+
sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
|
|
1660
|
+
|
|
1661
|
+
// Collapsible advanced settings panel (gear-toggled): existing source
|
|
1662
|
+
// editors — plugin + runtime manifest URL override, save→check→refresh.
|
|
1663
|
+
renderSettingsPanel(settingsPanel, report, copy, settingsOpen, pluginActions, runtimeSourceActions)
|
|
1664
|
+
|
|
1665
|
+
renderJobSection()
|
|
1080
1666
|
if (stale) {
|
|
1081
1667
|
listSection.textContent = ''
|
|
1082
1668
|
listSection.append(skeleton(copy))
|
|
1083
1669
|
} else {
|
|
1084
|
-
renderList(
|
|
1670
|
+
renderList(
|
|
1671
|
+
listSection,
|
|
1672
|
+
report,
|
|
1673
|
+
copy,
|
|
1674
|
+
changelogOpen,
|
|
1675
|
+
allOpen,
|
|
1676
|
+
(name) => {
|
|
1677
|
+
if (changelogOpen.has(name)) changelogOpen.delete(name)
|
|
1678
|
+
else changelogOpen.add(name)
|
|
1679
|
+
render()
|
|
1680
|
+
},
|
|
1681
|
+
() => {
|
|
1682
|
+
allOpen = !allOpen
|
|
1683
|
+
render()
|
|
1684
|
+
},
|
|
1685
|
+
() => {
|
|
1686
|
+
void store.check()
|
|
1687
|
+
},
|
|
1688
|
+
)
|
|
1085
1689
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1690
|
+
renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
|
|
1691
|
+
dismissedAlert = true
|
|
1692
|
+
render()
|
|
1693
|
+
})
|
|
1694
|
+
renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
|
|
1089
1695
|
}
|
|
1090
1696
|
|
|
1091
1697
|
const unsubscribe = store.subscribe(render)
|