@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +839 -120
- 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 +137 -6
- package/src/client/index.ts +241 -11
- package/src/client/update-view.ts +474 -41
- 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
|
|
|
@@ -159,7 +172,7 @@ function packageCard(
|
|
|
159
172
|
const card = el('li', 'hqpu-card')
|
|
160
173
|
card.dataset.pluginName = item.name
|
|
161
174
|
card.dataset.state = item.state
|
|
162
|
-
// The full package name is the card's tooltip; the row shows the
|
|
175
|
+
// The full package name is the card's tooltip; the row shows the display name.
|
|
163
176
|
card.title = `${item.name} · ${versionLine(item)}`
|
|
164
177
|
|
|
165
178
|
const head = el('div', 'hqpu-card-head')
|
|
@@ -170,15 +183,29 @@ function packageCard(
|
|
|
170
183
|
|
|
171
184
|
const main = el('div', 'hqpu-card-main')
|
|
172
185
|
const titleRow = el('div', 'hqpu-title-row')
|
|
173
|
-
|
|
186
|
+
// Prefer the user-facing display name from a v2 manifest; fall back to the
|
|
187
|
+
// shortened package id (the shell's own naming rule).
|
|
188
|
+
const titleText =
|
|
189
|
+
typeof item.displayName === 'string' && item.displayName.trim().length > 0
|
|
190
|
+
? item.displayName.trim()
|
|
191
|
+
: shortName(item.name)
|
|
192
|
+
const title = el('span', 'hqpu-card-title', titleText)
|
|
174
193
|
const tag = el('span', 'hqpu-tag')
|
|
175
194
|
tag.dataset.tone = stateTone(item)
|
|
176
195
|
tag.textContent = stateLabel(item, copy)
|
|
177
196
|
titleRow.append(title, tag)
|
|
178
197
|
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
// Version line, with the download size appended when the manifest knows it.
|
|
199
|
+
const size = formatMb(item.sizeBytes ?? 0)
|
|
200
|
+
const descText = size.length > 0 ? `${versionLine(item)} · ${size}` : versionLine(item)
|
|
201
|
+
const desc = el('span', 'hqpu-card-desc', descText)
|
|
181
202
|
main.append(titleRow, desc)
|
|
203
|
+
|
|
204
|
+
// Optional one-line description from a v2 manifest.
|
|
205
|
+
if (typeof item.description === 'string' && item.description.trim().length > 0) {
|
|
206
|
+
main.append(el('span', 'hqpu-card-desc', item.description.trim()))
|
|
207
|
+
}
|
|
208
|
+
|
|
182
209
|
head.append(glyph, main)
|
|
183
210
|
|
|
184
211
|
const end = el('div', 'hqpu-card-end')
|
|
@@ -186,7 +213,9 @@ function packageCard(
|
|
|
186
213
|
const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
|
|
187
214
|
button.type = 'button'
|
|
188
215
|
button.disabled = installing
|
|
216
|
+
// Honest label: this installs the WHOLE offered set, not just one card.
|
|
189
217
|
button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
|
|
218
|
+
button.title = copy.installOneTip
|
|
190
219
|
button.addEventListener('click', () => {
|
|
191
220
|
void runInstall(store, copy)
|
|
192
221
|
})
|
|
@@ -195,12 +224,38 @@ function packageCard(
|
|
|
195
224
|
head.append(end)
|
|
196
225
|
card.append(head)
|
|
197
226
|
|
|
198
|
-
//
|
|
227
|
+
// Per-plugin change list (v2 manifest), behind a small expander.
|
|
228
|
+
if (Array.isArray(item.changes) && item.changes.length > 0) {
|
|
229
|
+
const expander = el('div', 'hqpu-changes')
|
|
230
|
+
const toggle = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.release.changesTitle)
|
|
231
|
+
toggle.type = 'button'
|
|
232
|
+
const list = el('ul', 'hqpu-changes-list')
|
|
233
|
+
list.style.display = 'none'
|
|
234
|
+
for (const change of item.changes) {
|
|
235
|
+
if (typeof change === 'string' && change.length > 0) {
|
|
236
|
+
list.append(el('li', '', change))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
toggle.addEventListener('click', () => {
|
|
240
|
+
list.style.display = list.style.display === 'none' ? '' : 'none'
|
|
241
|
+
})
|
|
242
|
+
expander.append(toggle, list)
|
|
243
|
+
card.append(expander)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A block reason belongs to a blocked offer only — render BOTH the mapped
|
|
247
|
+
// code copy and the server's full reasons[]/notes[] (computed but, before
|
|
248
|
+
// now, never shown).
|
|
199
249
|
if (item.state === 'blocked') {
|
|
200
250
|
const reason = blockedReason(item, copy)
|
|
201
251
|
if (reason !== undefined) {
|
|
202
252
|
card.append(el('p', 'hqpu-card-reason', reason))
|
|
203
253
|
}
|
|
254
|
+
for (const entry of [...(item.reasons ?? []), ...(item.notes ?? [])]) {
|
|
255
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
256
|
+
card.append(el('p', 'hqpu-card-reason', entry))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
204
259
|
}
|
|
205
260
|
return card
|
|
206
261
|
}
|
|
@@ -267,6 +322,305 @@ function skeleton(copy: UpdateCopy): HTMLElement {
|
|
|
267
322
|
return section
|
|
268
323
|
}
|
|
269
324
|
|
|
325
|
+
// ── Durable job views (active / result / recent) ───────────────────────────
|
|
326
|
+
|
|
327
|
+
/** Format a byte count for the progress line, or null when unknown. */
|
|
328
|
+
function formatBytes(n: number | undefined): string | null {
|
|
329
|
+
if (n === undefined || !Number.isFinite(n) || n < 0) return null
|
|
330
|
+
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`
|
|
331
|
+
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
|
332
|
+
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`
|
|
333
|
+
return `${n} B`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Format an ETA in seconds to a short human string, or null when unknown. */
|
|
337
|
+
function formatEta(seconds: number | undefined): string | null {
|
|
338
|
+
if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
|
|
339
|
+
if (seconds < 60) return `${Math.round(seconds)} s`
|
|
340
|
+
const minutes = Math.round(seconds / 60)
|
|
341
|
+
if (minutes < 60) return `${minutes} min`
|
|
342
|
+
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
346
|
+
return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The phase in human words, falling back to the raw code. */
|
|
350
|
+
function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
|
|
351
|
+
return copy.job.phase[job.phase] ?? job.phase
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** One per-step line for plugin-set installs (and any job that reports steps). */
|
|
355
|
+
function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
|
|
356
|
+
if (!Array.isArray(job.steps) || job.steps.length === 0) return null
|
|
357
|
+
const wrap = el('div', 'hqpu-job-steps')
|
|
358
|
+
wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
|
|
359
|
+
const ol = el('ol', 'hqpu-steps')
|
|
360
|
+
for (const step of job.steps) {
|
|
361
|
+
const li = el('li', `hqpu-step hqpu-step--${step.state}`)
|
|
362
|
+
const dot = el('span', 'hqpu-dot')
|
|
363
|
+
dot.dataset.state =
|
|
364
|
+
step.state === 'installed'
|
|
365
|
+
? 'done'
|
|
366
|
+
: step.state === 'failed'
|
|
367
|
+
? 'error'
|
|
368
|
+
: step.state === 'running'
|
|
369
|
+
? 'warning'
|
|
370
|
+
: 'idle'
|
|
371
|
+
li.append(dot)
|
|
372
|
+
const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
|
|
373
|
+
li.append(el('span', '', text))
|
|
374
|
+
ol.append(li)
|
|
375
|
+
}
|
|
376
|
+
wrap.append(ol)
|
|
377
|
+
return wrap
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The ACTIVE-JOB view: type + target, previous→target, current phase, a
|
|
382
|
+
* determinate bar only when a real percentage exists, bytes/speed/ETA when the
|
|
383
|
+
* server publishes them, and what happens next. Closing the panel never
|
|
384
|
+
* cancels the job — say so.
|
|
385
|
+
*/
|
|
386
|
+
function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
387
|
+
const section = el('section', 'hqpu-group')
|
|
388
|
+
section.dataset.group = 'active-job'
|
|
389
|
+
|
|
390
|
+
const head = el('div', 'hqpu-group-head')
|
|
391
|
+
const tag = el('span', 'hqpu-tag')
|
|
392
|
+
tag.dataset.tone = 'info'
|
|
393
|
+
tag.textContent = jobKindLabel(job, copy)
|
|
394
|
+
head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} → ${job.targetVersion}`), tag)
|
|
395
|
+
section.append(head)
|
|
396
|
+
|
|
397
|
+
const card = el('div', 'hqpu-job')
|
|
398
|
+
|
|
399
|
+
const phaseLine = el('p', 'hqpu-status hqpu-status--dot')
|
|
400
|
+
phaseLine.append(el('span', 'hqpu-spinner'))
|
|
401
|
+
const transition =
|
|
402
|
+
job.previousVersion !== null && job.previousVersion.length > 0
|
|
403
|
+
? `${job.previousVersion} → ${job.targetVersion} · ${phaseLabel(job, copy)}`
|
|
404
|
+
: `${job.targetVersion} · ${phaseLabel(job, copy)}`
|
|
405
|
+
phaseLine.append(el('span', '', transition))
|
|
406
|
+
card.append(phaseLine)
|
|
407
|
+
|
|
408
|
+
if (typeof job.message === 'string' && job.message.length > 0) {
|
|
409
|
+
card.append(el('p', 'hqpu-status', job.message))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Determinate bar only when the server gives a real percentage — never show
|
|
413
|
+
// 100% before the job is terminal.
|
|
414
|
+
const pct = job.progress.percent
|
|
415
|
+
if (pct !== null) {
|
|
416
|
+
const bar = el('div', 'hqpu-progress')
|
|
417
|
+
const fill = el('div', 'hqpu-progress-fill')
|
|
418
|
+
fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
|
|
419
|
+
bar.append(fill)
|
|
420
|
+
card.append(bar)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const bits: string[] = []
|
|
424
|
+
if (pct !== null) bits.push(`${Math.round(pct)}%`)
|
|
425
|
+
const done = formatBytes(job.progress.bytesDone)
|
|
426
|
+
const total = formatBytes(job.progress.bytesTotal)
|
|
427
|
+
if (done !== null && total !== null) bits.push(copy.job.bytes(done, total))
|
|
428
|
+
const speed = formatBytes(job.progress.bytesPerSecond)
|
|
429
|
+
if (speed !== null) bits.push(copy.job.speed(speed))
|
|
430
|
+
const eta = formatEta(job.progress.etaSeconds)
|
|
431
|
+
if (eta !== null) bits.push(copy.job.eta(eta))
|
|
432
|
+
if (bits.length > 0) card.append(el('p', 'hqpu-status hqpu-job-meta', bits.join(' · ')))
|
|
433
|
+
|
|
434
|
+
const steps = stepsList(job, copy)
|
|
435
|
+
if (steps !== null) card.append(steps)
|
|
436
|
+
|
|
437
|
+
// What happens next — restart / runtime swap + elevation implications.
|
|
438
|
+
card.append(el('p', 'hqpu-status', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh))
|
|
439
|
+
card.append(el('p', 'hqpu-status', copy.job.closeNote))
|
|
440
|
+
section.append(card)
|
|
441
|
+
return section
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The RESULT view for a terminal job: success / failed / rolled-back /
|
|
446
|
+
* abandoned, previous→installed, per-package steps, errorCode + message, a log
|
|
447
|
+
* correlation hint, and retry guidance (a retry starts a NEW job).
|
|
448
|
+
*/
|
|
449
|
+
function resultView(job: UpdateJob, copy: UpdateCopy, onRetry: () => void): HTMLElement {
|
|
450
|
+
const section = el('section', 'hqpu-group')
|
|
451
|
+
section.dataset.group = 'job-result'
|
|
452
|
+
|
|
453
|
+
const tone =
|
|
454
|
+
job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
|
|
455
|
+
const titleText =
|
|
456
|
+
job.phase === 'done'
|
|
457
|
+
? copy.job.resultDone
|
|
458
|
+
: job.phase === 'failed'
|
|
459
|
+
? copy.job.resultFailed
|
|
460
|
+
: job.phase === 'rolled-back'
|
|
461
|
+
? copy.job.resultRolledBack
|
|
462
|
+
: copy.job.resultAbandoned
|
|
463
|
+
|
|
464
|
+
const head = el('div', 'hqpu-group-head')
|
|
465
|
+
const tag = el('span', 'hqpu-tag')
|
|
466
|
+
tag.dataset.tone = tone
|
|
467
|
+
tag.textContent = titleText
|
|
468
|
+
head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
|
|
469
|
+
section.append(head)
|
|
470
|
+
|
|
471
|
+
const card = el('div', 'hqpu-job')
|
|
472
|
+
const res = job.result
|
|
473
|
+
const from = job.previousVersion ?? res?.installedVersion ?? '—'
|
|
474
|
+
const to = res?.installedVersion ?? job.targetVersion
|
|
475
|
+
const finished =
|
|
476
|
+
res !== undefined && res.finishedAt.length > 0
|
|
477
|
+
? ` · ${new Date(res.finishedAt).toLocaleString()}`
|
|
478
|
+
: ''
|
|
479
|
+
card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
|
|
480
|
+
if (res !== undefined && res.message !== undefined && res.message.length > 0) {
|
|
481
|
+
card.append(el('p', 'hqpu-status', res.message))
|
|
482
|
+
}
|
|
483
|
+
if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
|
|
484
|
+
card.append(el('p', 'hqpu-status', res.errorCode))
|
|
485
|
+
}
|
|
486
|
+
if (typeof job.logHint === 'string' && job.logHint.length > 0) {
|
|
487
|
+
card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const steps = stepsList(job, copy)
|
|
491
|
+
if (steps !== null) card.append(steps)
|
|
492
|
+
|
|
493
|
+
// Retry starts a NEW update job — never re-click the settled job id.
|
|
494
|
+
if (job.phase === 'failed' || job.phase === 'abandoned') {
|
|
495
|
+
const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
|
|
496
|
+
retry.type = 'button'
|
|
497
|
+
retry.addEventListener('click', onRetry)
|
|
498
|
+
card.append(retry)
|
|
499
|
+
}
|
|
500
|
+
section.append(card)
|
|
501
|
+
return section
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A one-line row for older recent jobs (newest already shown as the card). */
|
|
505
|
+
function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
|
|
506
|
+
const older = jobs.slice(1, 5)
|
|
507
|
+
if (older.length === 0) return null
|
|
508
|
+
const wrap = el('div', 'hqpu-group')
|
|
509
|
+
wrap.dataset.group = 'recent-jobs'
|
|
510
|
+
wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
|
|
511
|
+
const ul = el('ul', 'hqpu-cards')
|
|
512
|
+
for (const job of older) {
|
|
513
|
+
const li = el('li', 'hqpu-status')
|
|
514
|
+
const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
|
|
515
|
+
li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
|
|
516
|
+
ul.append(li)
|
|
517
|
+
}
|
|
518
|
+
wrap.append(ul)
|
|
519
|
+
return wrap
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Check-status banner: a FAILED check shows a clear error row + Retry (never
|
|
524
|
+
* the silent "no updates" empty state); a successful check shows the time;
|
|
525
|
+
* when a check failed but a prior report exists it is labelled stale.
|
|
526
|
+
*/
|
|
527
|
+
function checkBanner(
|
|
528
|
+
report: PluginUpdateReport,
|
|
529
|
+
copy: UpdateCopy,
|
|
530
|
+
onRetry: () => void,
|
|
531
|
+
): HTMLElement | null {
|
|
532
|
+
const failed = typeof report.lastError === 'string' && report.lastError.length > 0
|
|
533
|
+
const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
|
|
534
|
+
if (!failed && !checked) return null
|
|
535
|
+
|
|
536
|
+
const wrap = el('section', 'hqpu-group')
|
|
537
|
+
wrap.dataset.group = 'check-status'
|
|
538
|
+
if (failed) {
|
|
539
|
+
const banner = el('div', 'hqpu-banner')
|
|
540
|
+
banner.dataset.tone = 'danger'
|
|
541
|
+
banner.innerHTML = WARNING_ICON(14)
|
|
542
|
+
banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
|
|
543
|
+
wrap.append(banner)
|
|
544
|
+
if (report.items.length > 0) {
|
|
545
|
+
wrap.append(el('p', 'hqpu-status', copy.staleNote))
|
|
546
|
+
}
|
|
547
|
+
const row = el('div', 'hqpu-actions')
|
|
548
|
+
const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
|
|
549
|
+
retry.type = 'button'
|
|
550
|
+
retry.addEventListener('click', onRetry)
|
|
551
|
+
row.append(retry)
|
|
552
|
+
wrap.append(row)
|
|
553
|
+
return wrap
|
|
554
|
+
}
|
|
555
|
+
wrap.append(
|
|
556
|
+
el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
|
|
557
|
+
)
|
|
558
|
+
return wrap
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
|
|
562
|
+
function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
|
|
563
|
+
if (release === null || release === undefined) return null
|
|
564
|
+
const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
|
|
565
|
+
const breaking = Array.isArray(release.breaking)
|
|
566
|
+
? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
|
|
567
|
+
: []
|
|
568
|
+
const hasContent =
|
|
569
|
+
(typeof release.summary === 'string' && release.summary.length > 0) ||
|
|
570
|
+
notes.length > 0 ||
|
|
571
|
+
breaking.length > 0 ||
|
|
572
|
+
typeof release.notesUrl === 'string' ||
|
|
573
|
+
typeof release.publishedAt === 'string'
|
|
574
|
+
if (!hasContent) return null
|
|
575
|
+
|
|
576
|
+
const section = el('section', 'hqpu-group')
|
|
577
|
+
section.dataset.group = 'release'
|
|
578
|
+
section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
|
|
579
|
+
if (typeof release.summary === 'string' && release.summary.length > 0) {
|
|
580
|
+
section.append(el('p', 'hqpu-status', release.summary))
|
|
581
|
+
}
|
|
582
|
+
if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
|
|
583
|
+
section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
|
|
584
|
+
}
|
|
585
|
+
if (release.requiresRestart === true) {
|
|
586
|
+
section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
|
|
587
|
+
}
|
|
588
|
+
if (notes.length > 0) {
|
|
589
|
+
const ul = el('ul', 'hqpu-release-notes')
|
|
590
|
+
for (const note of notes) ul.append(el('li', '', note as string))
|
|
591
|
+
section.append(ul)
|
|
592
|
+
}
|
|
593
|
+
if (breaking.length > 0) {
|
|
594
|
+
const banner = el('div', 'hqpu-banner')
|
|
595
|
+
banner.dataset.tone = 'danger'
|
|
596
|
+
banner.innerHTML = WARNING_ICON(14)
|
|
597
|
+
const inner = el('span')
|
|
598
|
+
inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
|
|
599
|
+
inner.append(document.createTextNode(breaking.join(' · ')))
|
|
600
|
+
banner.append(inner)
|
|
601
|
+
section.append(banner)
|
|
602
|
+
}
|
|
603
|
+
if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
|
|
604
|
+
const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
|
|
605
|
+
link.href = release.notesUrl
|
|
606
|
+
link.target = '_blank'
|
|
607
|
+
link.rel = 'noreferrer'
|
|
608
|
+
section.append(link)
|
|
609
|
+
}
|
|
610
|
+
return section
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Native disclosure that hides the developer-facing source editors by
|
|
614
|
+
* default (end users should never need to type a manifest URL). */
|
|
615
|
+
function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
|
|
616
|
+
const details = el('details', 'hqpu-advanced')
|
|
617
|
+
details.append(el('summary', '', copy.release.advanced))
|
|
618
|
+
for (const node of nodes) {
|
|
619
|
+
if (node !== null) details.append(node)
|
|
620
|
+
}
|
|
621
|
+
return details
|
|
622
|
+
}
|
|
623
|
+
|
|
270
624
|
/** Mutable editor state for one manifest source (lives in the panel closure). */
|
|
271
625
|
interface SourceState {
|
|
272
626
|
editing: boolean
|
|
@@ -562,6 +916,10 @@ function runtimeGroup(
|
|
|
562
916
|
row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
|
|
563
917
|
if (runtime.updateAvailable && latestVersion !== null) {
|
|
564
918
|
row(copy.runtime.latest, latestVersion)
|
|
919
|
+
// v2 manifest metadata: download size before committing to the update.
|
|
920
|
+
const latestEntry = runtime.latest
|
|
921
|
+
const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
|
|
922
|
+
if (size.length > 0) row(copy.runtime.sizeLabel, size)
|
|
565
923
|
}
|
|
566
924
|
if (report.manifestVersion !== null) {
|
|
567
925
|
row(copy.runtime.set, report.manifestVersion, true)
|
|
@@ -577,9 +935,10 @@ function runtimeGroup(
|
|
|
577
935
|
|
|
578
936
|
// The editor: point the runtime at another release channel (a staging
|
|
579
937
|
// 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.
|
|
938
|
+
// the override per host, so it survives restarts of both processes. It is a
|
|
939
|
+
// developer affordance, so it hides behind an "advanced" disclosure.
|
|
581
940
|
if (source !== null) {
|
|
582
|
-
section.append(sourceEditor('runtime', source, copy, actions))
|
|
941
|
+
section.append(advancedBlock(copy, [sourceEditor('runtime', source, copy, actions)]))
|
|
583
942
|
}
|
|
584
943
|
|
|
585
944
|
// ── Runtime update action row ──
|
|
@@ -621,10 +980,45 @@ function runtimeGroup(
|
|
|
621
980
|
row.append(start)
|
|
622
981
|
}
|
|
623
982
|
section.append(row)
|
|
983
|
+
// Honest duration note: no fixed "one minute" claim.
|
|
984
|
+
section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.durationNote))
|
|
624
985
|
} else {
|
|
625
986
|
section.append(el('p', 'hqpu-status', copy.runtime.note))
|
|
626
987
|
}
|
|
627
988
|
|
|
989
|
+
// The host's own report of the last runtime swap (succeeded / rolled_back /
|
|
990
|
+
// failed). Before this it was read from disk by hq-edge and never shown — a
|
|
991
|
+
// rolled-back swap was indistinguishable from "no update ever happened".
|
|
992
|
+
const lastResult = runtime.lastResult
|
|
993
|
+
if (lastResult !== null && lastResult !== undefined) {
|
|
994
|
+
const state = lastResult.state
|
|
995
|
+
const line = el('p', 'hqpu-status hqpu-status--dot')
|
|
996
|
+
line.setAttribute('role', 'status')
|
|
997
|
+
const dot = el('span', 'hqpu-dot')
|
|
998
|
+
dot.dataset.state = state === 'succeeded' ? 'done' : state === 'failed' ? 'error' : 'warning'
|
|
999
|
+
const head =
|
|
1000
|
+
state === 'succeeded'
|
|
1001
|
+
? copy.runtime.lastResult.succeeded
|
|
1002
|
+
: state === 'failed'
|
|
1003
|
+
? copy.runtime.lastResult.failed
|
|
1004
|
+
: copy.runtime.lastResult.rolledBack
|
|
1005
|
+
const parts = [head]
|
|
1006
|
+
if (lastResult.targetVersion) parts.push(`→ ${lastResult.targetVersion}`)
|
|
1007
|
+
if (lastResult.installedVersion) parts.push(`(${lastResult.installedVersion})`)
|
|
1008
|
+
if (lastResult.at) parts.push(new Date(lastResult.at).toLocaleString())
|
|
1009
|
+
line.append(dot, el('span', '', parts.join(' ')))
|
|
1010
|
+
section.append(line)
|
|
1011
|
+
if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
|
|
1012
|
+
section.append(el('p', 'hqpu-status', lastResult.message))
|
|
1013
|
+
}
|
|
1014
|
+
if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
|
|
1015
|
+
section.append(el('p', 'hqpu-status hqpu-job-meta', lastResult.errorCode))
|
|
1016
|
+
}
|
|
1017
|
+
if (state !== 'succeeded') {
|
|
1018
|
+
section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.lastResult.elevation))
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
628
1022
|
if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
|
|
629
1023
|
const line = el('p', 'hqpu-status hqpu-status--dot')
|
|
630
1024
|
line.setAttribute('role', 'status')
|
|
@@ -642,14 +1036,26 @@ function renderList(
|
|
|
642
1036
|
copy: UpdateCopy,
|
|
643
1037
|
installing: boolean,
|
|
644
1038
|
store: UpdateStoreLike,
|
|
1039
|
+
onRetryCheck: () => void,
|
|
645
1040
|
): void {
|
|
646
1041
|
section.textContent = ''
|
|
647
1042
|
if (report === null) {
|
|
648
1043
|
section.append(skeleton(copy))
|
|
649
1044
|
return
|
|
650
1045
|
}
|
|
1046
|
+
// Check-status banner: a failed check shows an error row + Retry (never the
|
|
1047
|
+
// silent "no updates" empty state); a successful check shows the time.
|
|
1048
|
+
const banner = checkBanner(report, copy, onRetryCheck)
|
|
1049
|
+
if (banner !== null) section.append(banner)
|
|
1050
|
+
// Set-level release metadata (v2 manifest): summary, notes, breaking.
|
|
1051
|
+
const release = releaseHeader(report.release, copy)
|
|
1052
|
+
if (release !== null) section.append(release)
|
|
651
1053
|
if (report.items.length === 0) {
|
|
652
|
-
|
|
1054
|
+
// A successful check with zero updates — distinct from a failed check,
|
|
1055
|
+
// which already rendered its error row above.
|
|
1056
|
+
if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
|
|
1057
|
+
section.append(el('div', 'hqpu-empty', copy.empty))
|
|
1058
|
+
}
|
|
653
1059
|
return
|
|
654
1060
|
}
|
|
655
1061
|
|
|
@@ -681,7 +1087,9 @@ function renderRuntime(
|
|
|
681
1087
|
section.append(runtimeGroup(report, copy, actions))
|
|
682
1088
|
}
|
|
683
1089
|
|
|
684
|
-
/** The plugin-source group, above the plugin groups
|
|
1090
|
+
/** The plugin-source group, above the plugin groups, hidden behind an advanced
|
|
1091
|
+
* disclosure (manifest-URL editing is a developer affordance). No-op without
|
|
1092
|
+
* the field. */
|
|
685
1093
|
function renderPluginSource(
|
|
686
1094
|
section: HTMLElement,
|
|
687
1095
|
report: PluginUpdateReport | null,
|
|
@@ -691,10 +1099,10 @@ function renderPluginSource(
|
|
|
691
1099
|
section.textContent = ''
|
|
692
1100
|
if (report === null) return
|
|
693
1101
|
const node = pluginSourceGroup(report, copy, actions)
|
|
694
|
-
if (node !== null) section.append(node)
|
|
1102
|
+
if (node !== null) section.append(advancedBlock(copy, [node]))
|
|
695
1103
|
}
|
|
696
1104
|
|
|
697
|
-
/** Run the install transaction, then
|
|
1105
|
+
/** Run the install transaction (a new durable job), then let the store poll it. */
|
|
698
1106
|
async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<void> {
|
|
699
1107
|
const report = store.report()
|
|
700
1108
|
if (report === null) return
|
|
@@ -704,17 +1112,23 @@ async function runInstall(store: UpdateStoreLike, copy: UpdateCopy): Promise<voi
|
|
|
704
1112
|
showToast(copy.checkFailed, 'error')
|
|
705
1113
|
return
|
|
706
1114
|
}
|
|
1115
|
+
// Flip the installing flag BEFORE the request: this is what disables the
|
|
1116
|
+
// action buttons and shows the "DSH will restart" footer state (previously
|
|
1117
|
+
// dead code — runInstall never called it).
|
|
1118
|
+
store.setInstalling(true)
|
|
707
1119
|
try {
|
|
708
1120
|
const result = await installSet(report.manifestVersion)
|
|
709
1121
|
if (result.error !== undefined) {
|
|
1122
|
+
store.setInstalling(false)
|
|
710
1123
|
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
711
1124
|
return
|
|
712
1125
|
}
|
|
713
|
-
//
|
|
714
|
-
//
|
|
1126
|
+
// 202 accepted: a durable job now owns the lifecycle. The store's /status
|
|
1127
|
+
// poller renders progress and clears the flag when the job settles.
|
|
715
1128
|
showToast(copy.installSuccess, 'success')
|
|
716
|
-
void
|
|
1129
|
+
void store.fetchStatus()
|
|
717
1130
|
} catch (err: unknown) {
|
|
1131
|
+
store.setInstalling(false)
|
|
718
1132
|
showToast(
|
|
719
1133
|
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
720
1134
|
'error',
|
|
@@ -806,29 +1220,6 @@ function renderActions(
|
|
|
806
1220
|
}
|
|
807
1221
|
}
|
|
808
1222
|
|
|
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
|
-
})
|
|
821
|
-
.catch(() => undefined)
|
|
822
|
-
.finally(() => {
|
|
823
|
-
attempts += 1
|
|
824
|
-
if (attempts < 10) {
|
|
825
|
-
window.setTimeout(tick, 2000)
|
|
826
|
-
}
|
|
827
|
-
})
|
|
828
|
-
}
|
|
829
|
-
tick()
|
|
830
|
-
}
|
|
831
|
-
|
|
832
1223
|
/**
|
|
833
1224
|
* Open (or focus) the update panel. Re-renders from the store; when the store
|
|
834
1225
|
* is stale the panel first refreshes the report.
|
|
@@ -883,11 +1274,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
883
1274
|
// ── Scrolling body: the centred column ──
|
|
884
1275
|
const scroll = el('div', 'hqpu-scroll')
|
|
885
1276
|
const column = el('div', 'hqpu-column')
|
|
1277
|
+
// The durable-job view leads: an active install/runtime update is resumed on
|
|
1278
|
+
// open BEFORE anything else, and a recent terminal result is shown here too.
|
|
1279
|
+
const jobSection = el('div', 'hqpu-stack')
|
|
886
1280
|
const pluginSourceSection = el('div', 'hqpu-stack')
|
|
887
1281
|
const listSection = el('div', 'hqpu-stack')
|
|
888
1282
|
const runtimeSection = el('div', 'hqpu-stack')
|
|
889
1283
|
const toastSection = el('div', 'hqpu-toast-host')
|
|
890
|
-
column.append(pluginSourceSection, listSection, runtimeSection, toastSection)
|
|
1284
|
+
column.append(jobSection, pluginSourceSection, listSection, runtimeSection, toastSection)
|
|
891
1285
|
scroll.append(column)
|
|
892
1286
|
panel.append(scroll)
|
|
893
1287
|
|
|
@@ -1061,6 +1455,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1061
1455
|
// server that is already gone.
|
|
1062
1456
|
runtimeAccepted = true
|
|
1063
1457
|
showToast(copy.runtime.updateAccepted, 'success')
|
|
1458
|
+
// Adopt the new durable job immediately so the active-job view renders
|
|
1459
|
+
// (it polls from here; this process is about to be swapped away anyway).
|
|
1460
|
+
void store.fetchStatus()
|
|
1064
1461
|
} catch (err: unknown) {
|
|
1065
1462
|
showToast(
|
|
1066
1463
|
`${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -1072,16 +1469,52 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1072
1469
|
}
|
|
1073
1470
|
}
|
|
1074
1471
|
|
|
1472
|
+
/** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
|
|
1473
|
+
* re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
|
|
1474
|
+
* settled job id. */
|
|
1475
|
+
const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
|
|
1476
|
+
if (kind === 'runtime') {
|
|
1477
|
+
runtimeArmed = true
|
|
1478
|
+
render()
|
|
1479
|
+
} else {
|
|
1480
|
+
void runInstall(store, copy)
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const renderJobSection = (): void => {
|
|
1485
|
+
jobSection.textContent = ''
|
|
1486
|
+
const active = store.activeJob()
|
|
1487
|
+
if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
|
|
1488
|
+
// An active job leads the panel — resumed on open, polled to its end.
|
|
1489
|
+
jobSection.append(activeJobView(active, copy))
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
const recent = store.recentJobs()
|
|
1493
|
+
const latest = recent[0]
|
|
1494
|
+
if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
|
|
1495
|
+
jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
|
|
1496
|
+
const older = recentJobsList(recent, copy)
|
|
1497
|
+
if (older !== null) jobSection.append(older)
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1075
1501
|
const render = (): void => {
|
|
1076
1502
|
const report = store.report()
|
|
1077
|
-
|
|
1503
|
+
// The footer "installing" state is owned by the store flag OR any active
|
|
1504
|
+
// durable job — buttons stay disabled while either is true.
|
|
1505
|
+
const jobActive =
|
|
1506
|
+
store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
|
|
1507
|
+
const installing = store.installing() || jobActive
|
|
1078
1508
|
const stale = report === null
|
|
1079
1509
|
refreshBtn.disabled = stale
|
|
1510
|
+
renderJobSection()
|
|
1080
1511
|
if (stale) {
|
|
1081
1512
|
listSection.textContent = ''
|
|
1082
1513
|
listSection.append(skeleton(copy))
|
|
1083
1514
|
} else {
|
|
1084
|
-
renderList(listSection, report, copy, installing, store)
|
|
1515
|
+
renderList(listSection, report, copy, installing, store, () => {
|
|
1516
|
+
void store.check()
|
|
1517
|
+
})
|
|
1085
1518
|
}
|
|
1086
1519
|
renderPluginSource(pluginSourceSection, report, copy, pluginActions)
|
|
1087
1520
|
renderRuntime(runtimeSection, report, copy, runtimeActions)
|