@huaqiu/dsh-tool-plugin-update 0.6.16 → 0.6.18
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 +197 -10
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +15 -2
- package/package.json +3 -3
- package/src/client/api.ts +56 -0
- package/src/client/copy.ts +6 -0
- package/src/client/index.ts +149 -15
- package/src/client/update-view.ts +53 -5
- package/src/routes.ts +32 -2
package/lib/index.mjs
CHANGED
|
@@ -35,6 +35,12 @@ function parsePath(req) {
|
|
|
35
35
|
if (!pathname.startsWith("/api/v1/huaqiu/plugin-update")) return null;
|
|
36
36
|
return pathname.slice(28);
|
|
37
37
|
}
|
|
38
|
+
/** The raw `?…` of the incoming request ('' when there is none). */
|
|
39
|
+
function parseQuery(req) {
|
|
40
|
+
const url = req.url ?? "";
|
|
41
|
+
const q = url.indexOf("?");
|
|
42
|
+
return q >= 0 ? url.slice(q) : "";
|
|
43
|
+
}
|
|
38
44
|
/** Read the request body (bounded) as UTF-8 text. */
|
|
39
45
|
function readBody(req) {
|
|
40
46
|
return new Promise((resolve, reject) => {
|
|
@@ -108,6 +114,11 @@ function routeFor(sub, method) {
|
|
|
108
114
|
method: "GET",
|
|
109
115
|
path: "/api/v1/plugin-updates/status"
|
|
110
116
|
} : null;
|
|
117
|
+
if (sub === "/activity") return method === "GET" ? {
|
|
118
|
+
method: "GET",
|
|
119
|
+
path: "/api/v1/plugin-updates/activity",
|
|
120
|
+
passQuery: true
|
|
121
|
+
} : null;
|
|
111
122
|
const jobId = matchJobStatusPath(sub);
|
|
112
123
|
if (jobId !== null) return method === "GET" ? {
|
|
113
124
|
method: "GET",
|
|
@@ -163,7 +174,8 @@ const STATIC_SUB_PATHS = /* @__PURE__ */ new Set([
|
|
|
163
174
|
"/runtime-request",
|
|
164
175
|
"/runtime-source",
|
|
165
176
|
"/plugin-source",
|
|
166
|
-
"/status"
|
|
177
|
+
"/status",
|
|
178
|
+
"/activity"
|
|
167
179
|
]);
|
|
168
180
|
/**
|
|
169
181
|
* Whether a sub-path names a route this forwarder recognizes at all. A
|
|
@@ -206,8 +218,9 @@ function createUpdateForwarder(baseUrlResolver, timeoutMs = DEFAULT_FORWARD_TIME
|
|
|
206
218
|
const budget = target.timeoutMs ?? timeoutMs;
|
|
207
219
|
const controller = new AbortController();
|
|
208
220
|
const timer = setTimeout(() => controller.abort(), budget);
|
|
221
|
+
const upstreamUrl = `${base}${target.path}${target.passQuery === true ? parseQuery(req) : ""}`;
|
|
209
222
|
try {
|
|
210
|
-
const upstream = await fetch(
|
|
223
|
+
const upstream = await fetch(upstreamUrl, {
|
|
211
224
|
method: target.method,
|
|
212
225
|
headers: {
|
|
213
226
|
"content-type": "application/json; charset=utf-8",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huaqiu/dsh-tool-plugin-update",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./lib/index.mjs",
|
|
6
6
|
"types": "./lib/index.d.mts",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.2"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@huaqiu/dsh-
|
|
35
|
-
"@huaqiu/dsh-
|
|
34
|
+
"@huaqiu/dsh-sidebar-entry": "0.6.18",
|
|
35
|
+
"@huaqiu/dsh-plugin-log": "0.6.18"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"lib",
|
package/src/client/api.ts
CHANGED
|
@@ -286,6 +286,28 @@ export interface UpdateJobStatus {
|
|
|
286
286
|
recentJobs: UpdateJob[]
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* `GET /activity`: the re-attach snapshot.
|
|
291
|
+
*
|
|
292
|
+
* The panel can be updating its own package, which restarts the DSH process
|
|
293
|
+
* that serves this plugin — the page then has to rebuild its state from
|
|
294
|
+
* scratch. `bootId` changes when that process is recreated, `stalled` says the
|
|
295
|
+
* active job has stopped advancing (so the UI must stop animating a progress
|
|
296
|
+
* bar that will never move), and `setVersion` is what is actually installed.
|
|
297
|
+
*/
|
|
298
|
+
export interface UpdateActivity {
|
|
299
|
+
bootId: string
|
|
300
|
+
startedAt: string
|
|
301
|
+
now: string
|
|
302
|
+
activeJob: UpdateJob | null
|
|
303
|
+
/** The job named by `?job=`, settled or not; null when not asked/unknown. */
|
|
304
|
+
job: UpdateJob | null
|
|
305
|
+
phaseAgeMs: number
|
|
306
|
+
stalled: boolean
|
|
307
|
+
setVersion: string | null
|
|
308
|
+
actionableCount: number
|
|
309
|
+
}
|
|
310
|
+
|
|
289
311
|
const PHASE_VALUES: ReadonlySet<string> = new Set<string>([
|
|
290
312
|
'queued',
|
|
291
313
|
'resolving',
|
|
@@ -566,3 +588,37 @@ export function fetchJob(id: string): Promise<UpdateJob> {
|
|
|
566
588
|
return job
|
|
567
589
|
})
|
|
568
590
|
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Normalize `GET /activity`. Unknown/missing fields degrade to a usable
|
|
594
|
+
* snapshot: a `bootId` empty string simply never triggers a re-attach, and an
|
|
595
|
+
* unparseable age reads as 0 rather than "stalled".
|
|
596
|
+
*/
|
|
597
|
+
export function normalizeActivity(raw: unknown): UpdateActivity {
|
|
598
|
+
const r = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
599
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
|
600
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
601
|
+
return {
|
|
602
|
+
bootId: str(r.bootId),
|
|
603
|
+
startedAt: str(r.startedAt),
|
|
604
|
+
now: str(r.now),
|
|
605
|
+
activeJob: normalizeJob(r.activeJob),
|
|
606
|
+
job: normalizeJob(r.job),
|
|
607
|
+
phaseAgeMs: Math.max(0, num(r.phaseAgeMs)),
|
|
608
|
+
stalled: r.stalled === true,
|
|
609
|
+
setVersion: typeof r.setVersion === 'string' && r.setVersion.length > 0 ? r.setVersion : null,
|
|
610
|
+
actionableCount: num(r.actionableCount),
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* GET the re-attach snapshot, optionally naming the job being watched.
|
|
616
|
+
*
|
|
617
|
+
* Used instead of `/status` whenever the panel is following a job it started:
|
|
618
|
+
* it answers "what happened to MY job" across a DSH restart, and each read lets
|
|
619
|
+
* hq-edge settle a job that its own process could not finish.
|
|
620
|
+
*/
|
|
621
|
+
export function fetchActivity(jobId?: string): Promise<UpdateActivity> {
|
|
622
|
+
const query = jobId ? `?job=${encodeURIComponent(jobId)}` : ''
|
|
623
|
+
return request<unknown>(`/activity${query}`).then(normalizeActivity)
|
|
624
|
+
}
|
package/src/client/copy.ts
CHANGED
|
@@ -185,6 +185,7 @@ const zh = {
|
|
|
185
185
|
ctaNoSet: '暂无可安装的更新',
|
|
186
186
|
footerCount: (n: number) => `准备安装 ${n} 项兼容插件`,
|
|
187
187
|
footerBusy: '后台处理中,请勿关闭 DSH',
|
|
188
|
+
footerConfirming: '正在确认安装结果…',
|
|
188
189
|
footerFailed: '无法确认插件版本兼容性',
|
|
189
190
|
footerAllGood: '所有插件与运行时均正常',
|
|
190
191
|
footerRuntimeOnly: '运行时更新需在上方单独确认',
|
|
@@ -218,6 +219,8 @@ const zh = {
|
|
|
218
219
|
jobRuntimeTitle: (v: string) => `正在升级 HQ Edge 运行时 (v${v})`,
|
|
219
220
|
jobSteps: ['下载', '校验', '替换', '重启'],
|
|
220
221
|
activeCloseNote: '关闭此面板不会取消更新,完成后将自动重启。',
|
|
222
|
+
stalledNote: '进度已停止回执,正在向 HQ Edge 确认安装结果…',
|
|
223
|
+
stalledAction: '立即确认',
|
|
221
224
|
// Runtime result alert
|
|
222
225
|
rollbackTitle: '上次 HQ Edge 运行时更新已自动回滚',
|
|
223
226
|
failedTitle: '上次 HQ Edge 运行时更新失败',
|
|
@@ -401,6 +404,7 @@ const en: UpdateCopy = {
|
|
|
401
404
|
ctaNoSet: 'Nothing to install yet',
|
|
402
405
|
footerCount: (n: number) => `Ready to install ${n} compatible plugin${n === 1 ? '' : 's'}`,
|
|
403
406
|
footerBusy: 'Running in the background — keep DSH open',
|
|
407
|
+
footerConfirming: 'Confirming the install result…',
|
|
404
408
|
footerFailed: 'Cannot confirm plugin compatibility',
|
|
405
409
|
footerAllGood: 'All plugins and the runtime are current',
|
|
406
410
|
footerRuntimeOnly: 'Confirm the runtime upgrade on its card',
|
|
@@ -431,6 +435,8 @@ const en: UpdateCopy = {
|
|
|
431
435
|
jobRuntimeTitle: (v: string) => `Upgrading HQ Edge runtime (v${v})`,
|
|
432
436
|
jobSteps: ['Download', 'Verify', 'Swap', 'Restart'],
|
|
433
437
|
activeCloseNote: 'Closing this panel does not cancel the update; DSH restarts when it finishes.',
|
|
438
|
+
stalledNote: 'Progress has stopped reporting — confirming the result with HQ Edge…',
|
|
439
|
+
stalledAction: 'Check now',
|
|
434
440
|
rollbackTitle: 'Last HQ Edge runtime update rolled back automatically',
|
|
435
441
|
failedTitle: 'Last HQ Edge runtime update failed',
|
|
436
442
|
rollbackBody: (oldVer: string, code: string) =>
|
package/src/client/index.ts
CHANGED
|
@@ -30,10 +30,12 @@ import {
|
|
|
30
30
|
} from '@huaqiu/dsh-sidebar-entry'
|
|
31
31
|
import {
|
|
32
32
|
checkUpdates,
|
|
33
|
+
fetchActivity,
|
|
33
34
|
fetchJobStatus,
|
|
34
35
|
fetchReport,
|
|
35
36
|
TERMINAL_PHASES,
|
|
36
37
|
type PluginUpdateReport,
|
|
38
|
+
type UpdateActivity,
|
|
37
39
|
type UpdateJob,
|
|
38
40
|
type UpdateJobStatus,
|
|
39
41
|
} from './api.js'
|
|
@@ -1227,6 +1229,45 @@ const VIEW_CSS_TEXT = `
|
|
|
1227
1229
|
line-height: 14px;
|
|
1228
1230
|
color: var(--hq-faint);
|
|
1229
1231
|
}
|
|
1232
|
+
/* A stalled job: the card keeps its shape but stops claiming progress. The
|
|
1233
|
+
* pulse stops, the bar dims, and the note line carries the retry. */
|
|
1234
|
+
.hqpu-active[data-stalled='1'] .hqpu-pulse-ring {
|
|
1235
|
+
animation: none;
|
|
1236
|
+
opacity: 0;
|
|
1237
|
+
}
|
|
1238
|
+
.hqpu-active[data-stalled='1'] .hqpu-pulse-dot::before {
|
|
1239
|
+
background: var(--hq-amber);
|
|
1240
|
+
}
|
|
1241
|
+
.hqpu-active[data-stalled='1'] .hqpu-active-percent {
|
|
1242
|
+
color: var(--hq-amber-ink);
|
|
1243
|
+
}
|
|
1244
|
+
.hqpu-active[data-stalled='1'] .hqpu-progress-fill {
|
|
1245
|
+
background: color-mix(in srgb, var(--hq-amber) 55%, var(--hq-border));
|
|
1246
|
+
transition: none;
|
|
1247
|
+
}
|
|
1248
|
+
/* The active step stops blinking too: nothing is advancing. */
|
|
1249
|
+
.hqpu-active[data-stalled='1'] .hqpu-job-step--active {
|
|
1250
|
+
color: var(--hq-amber-ink);
|
|
1251
|
+
animation: none;
|
|
1252
|
+
}
|
|
1253
|
+
.hqpu-active-stalled {
|
|
1254
|
+
display: flex;
|
|
1255
|
+
align-items: baseline;
|
|
1256
|
+
justify-content: space-between;
|
|
1257
|
+
gap: 8px;
|
|
1258
|
+
padding: 6px 8px;
|
|
1259
|
+
border-radius: 6px;
|
|
1260
|
+
background: color-mix(in srgb, var(--hq-amber) 10%, transparent);
|
|
1261
|
+
font-size: var(--hq-fs-micro);
|
|
1262
|
+
line-height: 14px;
|
|
1263
|
+
color: var(--hq-amber-ink);
|
|
1264
|
+
}
|
|
1265
|
+
.hqpu-active-stalled .hqpu-link {
|
|
1266
|
+
flex: none;
|
|
1267
|
+
font-size: var(--hq-fs-micro);
|
|
1268
|
+
color: var(--hq-amber-ink);
|
|
1269
|
+
text-decoration: underline;
|
|
1270
|
+
}
|
|
1230
1271
|
.hqpu-job-steps4 {
|
|
1231
1272
|
display: grid;
|
|
1232
1273
|
grid-template-columns: repeat(4, 1fr);
|
|
@@ -1849,8 +1890,14 @@ export interface ClientContext {
|
|
|
1849
1890
|
effect?(fn: () => (() => void) | void, label?: string): void
|
|
1850
1891
|
}
|
|
1851
1892
|
|
|
1852
|
-
/**
|
|
1853
|
-
|
|
1893
|
+
/**
|
|
1894
|
+
* In-memory update-report store shared by the badge and the modal.
|
|
1895
|
+
*
|
|
1896
|
+
* Exported for tests: the re-attach logic (watch a job by id, notice that the
|
|
1897
|
+
* server was replaced, stop claiming progress) is the part that cannot be
|
|
1898
|
+
* exercised through the view alone.
|
|
1899
|
+
*/
|
|
1900
|
+
export class UpdateStore implements UpdateStoreLike {
|
|
1854
1901
|
private current: PluginUpdateReport | null = null
|
|
1855
1902
|
private installingFlag = false
|
|
1856
1903
|
/** The durable job the server currently reports as active (resumed across
|
|
@@ -1862,6 +1909,12 @@ class UpdateStore implements UpdateStoreLike {
|
|
|
1862
1909
|
private pollTimer: number | null = null
|
|
1863
1910
|
private pollBackoffMs = 1_000
|
|
1864
1911
|
private pollInFlight = false
|
|
1912
|
+
/** The job this panel started (or adopted) and is following by id. */
|
|
1913
|
+
private watchedJobId: string | null = null
|
|
1914
|
+
/** hq-edge process identity from the last `/activity` read. */
|
|
1915
|
+
private activityBootId: string | null = null
|
|
1916
|
+
private stalledFlag = false
|
|
1917
|
+
private phaseAgeMsValue = 0
|
|
1865
1918
|
|
|
1866
1919
|
report(): PluginUpdateReport | null {
|
|
1867
1920
|
return this.current
|
|
@@ -1879,6 +1932,27 @@ class UpdateStore implements UpdateStoreLike {
|
|
|
1879
1932
|
return [...this.recent]
|
|
1880
1933
|
}
|
|
1881
1934
|
|
|
1935
|
+
/** True when the server says the active job stopped advancing. */
|
|
1936
|
+
stalled(): boolean {
|
|
1937
|
+
return this.stalledFlag
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** Milliseconds since the active job last advanced (server clock). */
|
|
1941
|
+
phaseAgeMs(): number {
|
|
1942
|
+
return this.phaseAgeMsValue
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Follow a job by id. The panel calls this with the `jobId` an accept
|
|
1947
|
+
* response handed back, so every later read asks about THAT job — the only
|
|
1948
|
+
* way to learn its outcome when the install replaced this very plugin (the
|
|
1949
|
+
* page comes back to a server that has no "active job" left to report).
|
|
1950
|
+
*/
|
|
1951
|
+
watchJob(id: string | null): void {
|
|
1952
|
+
this.watchedJobId = id
|
|
1953
|
+
if (id !== null) this.schedulePoll()
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1882
1956
|
subscribe(listener: () => void): () => void {
|
|
1883
1957
|
this.listeners.add(listener)
|
|
1884
1958
|
return () => {
|
|
@@ -1912,37 +1986,98 @@ class UpdateStore implements UpdateStoreLike {
|
|
|
1912
1986
|
const wasActive = this.active !== null
|
|
1913
1987
|
this.active = status.activeJob
|
|
1914
1988
|
this.recent = status.recentJobs
|
|
1915
|
-
if (
|
|
1916
|
-
|
|
1917
|
-
|
|
1989
|
+
if (this.active !== null) this.watchedJobId = this.active.id
|
|
1990
|
+
if (wasActive && this.active === null) this.observeSettled()
|
|
1991
|
+
this.emit()
|
|
1992
|
+
this.schedulePoll()
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Adopt a `/activity` snapshot.
|
|
1997
|
+
*
|
|
1998
|
+
* Two things only this read can tell us:
|
|
1999
|
+
*
|
|
2000
|
+
* - `bootId` changed → hq-edge was recreated underneath the page. That is
|
|
2001
|
+
* what an install of this plugin's own package looks like from here: the
|
|
2002
|
+
* DSH process serving this route was replaced. Nothing in memory can be
|
|
2003
|
+
* trusted, so the installing flag is cleared and the report re-read.
|
|
2004
|
+
* - `stalled` → the active job has stopped advancing. The progress bar must
|
|
2005
|
+
* stop pretending; the panel shows a "confirming" note with a retry.
|
|
2006
|
+
*/
|
|
2007
|
+
private applyActivity(activity: UpdateActivity): void {
|
|
2008
|
+
const rebooted = this.activityBootId !== null && this.activityBootId !== activity.bootId
|
|
2009
|
+
this.activityBootId = activity.bootId
|
|
2010
|
+
this.stalledFlag = activity.stalled
|
|
2011
|
+
this.phaseAgeMsValue = activity.phaseAgeMs
|
|
2012
|
+
|
|
2013
|
+
const wasActive = this.active !== null
|
|
2014
|
+
this.active = activity.activeJob
|
|
2015
|
+
if (activity.job !== null) this.upsertRecent(activity.job)
|
|
2016
|
+
|
|
2017
|
+
if (rebooted) {
|
|
1918
2018
|
this.installingFlag = false
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
// job, so one cheap read of `/report` is enough to bring the list (and
|
|
1922
|
-
// the badge) down to zero. Without it the badge kept counting updates
|
|
1923
|
-
// that no longer exist until the 60 s idle poll came round.
|
|
2019
|
+
this.stalledFlag = false
|
|
2020
|
+
this.phaseAgeMsValue = 0
|
|
1924
2021
|
void this.refresh()
|
|
1925
2022
|
}
|
|
2023
|
+
if (wasActive && this.active === null) this.observeSettled()
|
|
1926
2024
|
this.emit()
|
|
1927
2025
|
this.schedulePoll()
|
|
1928
2026
|
}
|
|
1929
2027
|
|
|
1930
2028
|
/**
|
|
1931
|
-
*
|
|
2029
|
+
* The job we were following reached a terminal phase (or vanished from the
|
|
2030
|
+
* active slot). Clear the transient flags and re-read the report — it still
|
|
2031
|
+
* names the versions that job installed.
|
|
2032
|
+
*/
|
|
2033
|
+
private observeSettled(): void {
|
|
2034
|
+
this.installingFlag = false
|
|
2035
|
+
this.stalledFlag = false
|
|
2036
|
+
this.phaseAgeMsValue = 0
|
|
2037
|
+
this.watchedJobId = null
|
|
2038
|
+
void this.refresh()
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/** Keep one job fresh in the local history without duplicating it. */
|
|
2042
|
+
private upsertRecent(job: UpdateJob): void {
|
|
2043
|
+
const rest = this.recent.filter((entry) => entry.id !== job.id)
|
|
2044
|
+
this.recent = [job, ...rest].slice(0, 20)
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Fetch the durable job state. Called on mount, on panel open (BEFORE
|
|
1932
2049
|
* discovery) and by the poll loop. A failure (older hq-edge with no
|
|
1933
2050
|
* `/status`, or a transient HTTP error) is swallowed: the last known job is
|
|
1934
2051
|
* kept and the poller backs off and retries.
|
|
1935
2052
|
*/
|
|
1936
2053
|
async fetchStatus(): Promise<void> {
|
|
1937
2054
|
try {
|
|
1938
|
-
|
|
2055
|
+
await this.pollOnce()
|
|
1939
2056
|
this.pollBackoffMs = 1_000
|
|
1940
|
-
this.applyStatus(status)
|
|
1941
2057
|
} catch {
|
|
1942
2058
|
this.schedulePoll()
|
|
1943
2059
|
}
|
|
1944
2060
|
}
|
|
1945
2061
|
|
|
2062
|
+
/**
|
|
2063
|
+
* One read. While a job is being followed, ask `/activity` about THAT job
|
|
2064
|
+
* (survives the restart an update of this plugin causes); otherwise fall back
|
|
2065
|
+
* to the plain `/status` list. An older hq-edge without `/activity` 404s and
|
|
2066
|
+
* the status read still answers.
|
|
2067
|
+
*/
|
|
2068
|
+
private async pollOnce(): Promise<void> {
|
|
2069
|
+
const watched = this.watchedJobId ?? this.active?.id ?? null
|
|
2070
|
+
if (watched !== null) {
|
|
2071
|
+
try {
|
|
2072
|
+
this.applyActivity(await fetchActivity(watched))
|
|
2073
|
+
return
|
|
2074
|
+
} catch {
|
|
2075
|
+
/* fall through to /status: an older hq-edge has no /activity */
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
this.applyStatus(await fetchJobStatus())
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1946
2081
|
/** ~1 s poll while a job is active; stop on terminal; back off on error
|
|
1947
2082
|
* without discarding the last known job; never overlap requests. */
|
|
1948
2083
|
private schedulePoll(): void {
|
|
@@ -1967,9 +2102,8 @@ class UpdateStore implements UpdateStoreLike {
|
|
|
1967
2102
|
}
|
|
1968
2103
|
this.pollInFlight = true
|
|
1969
2104
|
try {
|
|
1970
|
-
|
|
2105
|
+
await this.pollOnce()
|
|
1971
2106
|
this.pollBackoffMs = 1_000
|
|
1972
|
-
this.applyStatus(status)
|
|
1973
2107
|
} catch {
|
|
1974
2108
|
this.pollBackoffMs = Math.min(5_000, Math.max(1_000, this.pollBackoffMs * 2))
|
|
1975
2109
|
this.schedulePoll()
|
|
@@ -81,6 +81,16 @@ export interface UpdateStoreLike {
|
|
|
81
81
|
check(): Promise<PluginUpdateReport | null>
|
|
82
82
|
/** Re-read `/status` (resume an active job immediately after submitting). */
|
|
83
83
|
fetchStatus(): Promise<void>
|
|
84
|
+
/**
|
|
85
|
+
* Follow a job by id after an accept response. Optional so a minimal store
|
|
86
|
+
* (tests, older hosts) still satisfies the contract; the real store uses it
|
|
87
|
+
* to keep asking about THIS job across a DSH restart.
|
|
88
|
+
*/
|
|
89
|
+
watchJob?(id: string | null): void
|
|
90
|
+
/** Server-reported "the active job stopped advancing". */
|
|
91
|
+
stalled?(): boolean
|
|
92
|
+
/** Milliseconds since the active job last advanced (server clock). */
|
|
93
|
+
phaseAgeMs?(): number
|
|
84
94
|
subscribe(listener: () => void): () => void
|
|
85
95
|
}
|
|
86
96
|
|
|
@@ -438,11 +448,23 @@ function checkBanner(
|
|
|
438
448
|
* The active job card: title (with the target version) + percent, a
|
|
439
449
|
* determinate bar when the server publishes one, phase + telemetry, the honest
|
|
440
450
|
* "closing does not cancel" note, and the 4-step pipeline.
|
|
451
|
+
*
|
|
452
|
+
* `stalled` marks a job the server has stopped advancing. That is the normal
|
|
453
|
+
* shape of "the set being installed contains this very plugin": activating it
|
|
454
|
+
* restarts DSH, so the process that owned the job is gone and the last thing
|
|
455
|
+
* the page saw was 100 % on the final step. Instead of animating a bar that
|
|
456
|
+
* will never move, the card says it is confirming the outcome and offers a
|
|
457
|
+
* retry — and the next `/activity` read settles it from the installed set.
|
|
441
458
|
*/
|
|
442
|
-
function activeJobView(
|
|
459
|
+
function activeJobView(
|
|
460
|
+
job: UpdateJob,
|
|
461
|
+
copy: UpdateCopy,
|
|
462
|
+
opts: { stalled?: boolean; onRecheck?: () => void } = {},
|
|
463
|
+
): HTMLElement {
|
|
443
464
|
const section = el('section', 'hqpu-group')
|
|
444
465
|
section.dataset.group = 'active-job'
|
|
445
466
|
const card = el('div', 'hqpu-active')
|
|
467
|
+
if (opts.stalled === true) card.dataset.stalled = '1'
|
|
446
468
|
|
|
447
469
|
const top = el('div', 'hqpu-active-top')
|
|
448
470
|
const titleWrap = el('div', 'hqpu-active-title')
|
|
@@ -508,6 +530,15 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
|
|
|
508
530
|
),
|
|
509
531
|
)
|
|
510
532
|
|
|
533
|
+
if (opts.stalled === true) {
|
|
534
|
+
const stalled = el('div', 'hqpu-active-stalled')
|
|
535
|
+
stalled.append(el('span', '', copy.ui.stalledNote))
|
|
536
|
+
const recheck = linkButton('hqpu-link', copy.ui.stalledAction, () => opts.onRecheck?.())
|
|
537
|
+
recheck.setAttribute('data-stalled-recheck', '')
|
|
538
|
+
stalled.append(recheck)
|
|
539
|
+
card.append(stalled)
|
|
540
|
+
}
|
|
541
|
+
|
|
511
542
|
const current = stepIndex(job.phase)
|
|
512
543
|
const stepsRow = el('div', 'hqpu-job-steps4')
|
|
513
544
|
copy.ui.jobSteps.forEach((label: string, index: number) => {
|
|
@@ -1266,6 +1297,9 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1266
1297
|
// relaunch. Latch it, so a store refresh cannot re-offer the button for
|
|
1267
1298
|
// a server that is already gone.
|
|
1268
1299
|
runtimeAccepted = true
|
|
1300
|
+
// The runtime swap restarts HQ Edge itself; follow the job it accepted so
|
|
1301
|
+
// the panel can report the outcome after the runtime is back.
|
|
1302
|
+
store.watchJob?.(result.jobId ?? null)
|
|
1269
1303
|
showToast(copy.runtime.updateAccepted, 'success')
|
|
1270
1304
|
void store.fetchStatus()
|
|
1271
1305
|
} catch (err: unknown) {
|
|
@@ -1443,7 +1477,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1443
1477
|
jobSection.textContent = ''
|
|
1444
1478
|
const active = store.activeJob()
|
|
1445
1479
|
if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
|
|
1446
|
-
jobSection.append(
|
|
1480
|
+
jobSection.append(
|
|
1481
|
+
activeJobView(active, copy, {
|
|
1482
|
+
stalled: store.stalled?.() === true,
|
|
1483
|
+
onRecheck: () => {
|
|
1484
|
+
void store.fetchStatus()
|
|
1485
|
+
},
|
|
1486
|
+
}),
|
|
1487
|
+
)
|
|
1447
1488
|
}
|
|
1448
1489
|
}
|
|
1449
1490
|
|
|
@@ -1634,7 +1675,11 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
|
|
|
1634
1675
|
if (busy) {
|
|
1635
1676
|
ctaLabel.textContent = copy.ui.ctaInstalling
|
|
1636
1677
|
cta.disabled = true
|
|
1637
|
-
|
|
1678
|
+
// A stalled job is still "busy" — the install may have completed and only
|
|
1679
|
+
// the report is missing — so the footer must not promise progress it
|
|
1680
|
+
// cannot see. The card above carries the retry.
|
|
1681
|
+
footCount.textContent =
|
|
1682
|
+
store.stalled?.() === true ? copy.ui.footerConfirming : copy.ui.footerBusy
|
|
1638
1683
|
} else if (report === null) {
|
|
1639
1684
|
ctaLabel.textContent = copy.ui.ctaNoSet
|
|
1640
1685
|
cta.disabled = true
|
|
@@ -1880,8 +1925,11 @@ async function runInstall(
|
|
|
1880
1925
|
showToast(`${copy.installError}: ${result.error}`, 'error')
|
|
1881
1926
|
return
|
|
1882
1927
|
}
|
|
1883
|
-
// 202 accepted: a durable job now owns the lifecycle.
|
|
1884
|
-
//
|
|
1928
|
+
// 202 accepted: a durable job now owns the lifecycle. Follow it BY ID —
|
|
1929
|
+
// that is what survives the restart when this very plugin is in the set
|
|
1930
|
+
// being installed (the server then has no "active job" to hand back, only
|
|
1931
|
+
// the record of the id we asked about).
|
|
1932
|
+
store.watchJob?.(result.jobId ?? null)
|
|
1885
1933
|
showToast(copy.installSuccess, 'success')
|
|
1886
1934
|
void store.fetchStatus()
|
|
1887
1935
|
} catch (err: unknown) {
|
package/src/routes.ts
CHANGED
|
@@ -36,12 +36,21 @@
|
|
|
36
36
|
* GET /plugin-update/status → GET /api/v1/plugin-updates/status
|
|
37
37
|
* GET /plugin-update/status/<id>
|
|
38
38
|
* → GET /api/v1/plugin-updates/status/<id>
|
|
39
|
+
* GET /plugin-update/activity?job=<id>
|
|
40
|
+
* → GET /api/v1/plugin-updates/activity?job=<id>
|
|
39
41
|
*
|
|
40
42
|
* The status routes expose the durable update-job lifecycle (active job +
|
|
41
43
|
* recent jobs). They are read-only passthroughs: an unknown job id upstream
|
|
42
44
|
* answers `404 { error: 'unknown job' }`, which this forwarder passes through
|
|
43
45
|
* verbatim rather than turning into a plugin-side 500.
|
|
44
46
|
*
|
|
47
|
+
* `/activity` is the re-attach read: an install that includes this very plugin
|
|
48
|
+
* restarts DSH, so the browser loses this route for a moment and cannot trust
|
|
49
|
+
* its in-memory job. The answer carries the hq-edge process identity, the named
|
|
50
|
+
* job (settled or not), a stall flag and the installed set version, and each
|
|
51
|
+
* read also settles a job orphaned by that restart. Its `?job=` is the one
|
|
52
|
+
* query this forwarder passes upstream.
|
|
53
|
+
*
|
|
45
54
|
* The runtime-update request is user-confirmed in this plugin's review panel:
|
|
46
55
|
* hq-edge validates the version against a fresh manifest, downloads and
|
|
47
56
|
* verifies the bundle, stages it, then hands it to the EDA host and answers
|
|
@@ -93,6 +102,13 @@ function parsePath(req: IncomingMessage): string | null {
|
|
|
93
102
|
return pathname.slice(UPDATE_ROUTE_PREFIX.length)
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/** The raw `?…` of the incoming request ('' when there is none). */
|
|
106
|
+
function parseQuery(req: IncomingMessage): string {
|
|
107
|
+
const url = req.url ?? ''
|
|
108
|
+
const q = url.indexOf('?')
|
|
109
|
+
return q >= 0 ? url.slice(q) : ''
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/** Read the request body (bounded) as UTF-8 text. */
|
|
97
113
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
98
114
|
return new Promise((resolve, reject) => {
|
|
@@ -141,7 +157,7 @@ function matchJobStatusPath(sub: string): string | null {
|
|
|
141
157
|
function routeFor(
|
|
142
158
|
sub: string,
|
|
143
159
|
method: string,
|
|
144
|
-
): { method: string; path: string; timeoutMs?: number } | null {
|
|
160
|
+
): { method: string; path: string; timeoutMs?: number; passQuery?: boolean } | null {
|
|
145
161
|
if (sub === '/runtime-source') {
|
|
146
162
|
if (method === 'GET') return { method: 'GET', path: '/api/v1/runtime-update/source' }
|
|
147
163
|
if (method === 'POST') return { method: 'POST', path: '/api/v1/runtime-update/source' }
|
|
@@ -160,6 +176,15 @@ function routeFor(
|
|
|
160
176
|
? { method: 'GET', path: '/api/v1/plugin-updates/status' }
|
|
161
177
|
: null
|
|
162
178
|
}
|
|
179
|
+
// Re-attach snapshot: process identity + the named job + stall detection.
|
|
180
|
+
// It is the read the panel uses while updating its OWN package (that install
|
|
181
|
+
// restarts DSH, so the page loses this forwarder for a moment and must be
|
|
182
|
+
// able to rebuild its state afterwards).
|
|
183
|
+
if (sub === '/activity') {
|
|
184
|
+
return method === 'GET'
|
|
185
|
+
? { method: 'GET', path: '/api/v1/plugin-updates/activity', passQuery: true }
|
|
186
|
+
: null
|
|
187
|
+
}
|
|
163
188
|
const jobId = matchJobStatusPath(sub)
|
|
164
189
|
if (jobId !== null) {
|
|
165
190
|
return method === 'GET'
|
|
@@ -224,6 +249,7 @@ const STATIC_SUB_PATHS = new Set([
|
|
|
224
249
|
'/runtime-source',
|
|
225
250
|
'/plugin-source',
|
|
226
251
|
'/status',
|
|
252
|
+
'/activity',
|
|
227
253
|
])
|
|
228
254
|
|
|
229
255
|
/**
|
|
@@ -280,8 +306,12 @@ export function createUpdateForwarder(
|
|
|
280
306
|
const budget = target.timeoutMs ?? timeoutMs
|
|
281
307
|
const controller = new AbortController()
|
|
282
308
|
const timer = setTimeout(() => controller.abort(), budget)
|
|
309
|
+
// Only `/activity` carries a query today (`?job=`). Every other route maps
|
|
310
|
+
// to a fixed upstream path, so forwarding the query blindly would leak
|
|
311
|
+
// unrelated client params into hq-edge.
|
|
312
|
+
const upstreamUrl = `${base}${target.path}${target.passQuery === true ? parseQuery(req) : ''}`
|
|
283
313
|
try {
|
|
284
|
-
const upstream = await fetch(
|
|
314
|
+
const upstream = await fetch(upstreamUrl, {
|
|
285
315
|
method: target.method,
|
|
286
316
|
headers: {
|
|
287
317
|
'content-type': 'application/json; charset=utf-8',
|