dsh-autorestart 0.8.3 → 0.8.5
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/CHANGELOG.md +61 -0
- package/README.md +78 -18
- package/client.js +581 -9
- package/docs/README.zh.md +23 -8
- package/index.js +727 -47
- package/lib/backup.js +219 -4
- package/lib/checkpoint.js +93 -3
- package/lib/restart-phase.js +5 -0
- package/locale/en.json +21 -1
- package/locale/zh.json +21 -1
- package/package.json +2 -2
package/client.js
CHANGED
|
@@ -379,6 +379,27 @@
|
|
|
379
379
|
'settings.backup.size': '备份大小',
|
|
380
380
|
'settings.backup.withPlugins': '(含已装插件)',
|
|
381
381
|
'settings.backup.withoutPlugins': '(不含已装插件)',
|
|
382
|
+
// WHAT a copy is (user decision, 2026-10-30). Only a proven copy may be
|
|
383
|
+
// rolled back to, so the identity is said in plain words on every row.
|
|
384
|
+
'settings.backup.kindLabel': '身份',
|
|
385
|
+
'settings.backup.kind.known-good': '安全份',
|
|
386
|
+
'settings.backup.kind.snapshot': '本次快照',
|
|
387
|
+
'settings.backup.kind.unknown': '旧备份(未验证)',
|
|
388
|
+
'settings.backup.version': '版本 {v}',
|
|
389
|
+
'settings.backup.identityHint': '备份有两种身份:①「安全份」——启动成功后验证过的那一份,出问题只会回滚安全份;第一次安装时会自动生成一份。②「本次快照」——每次重启前刚拍的,还没验证过;旧备份(未验证)和它都不会被自动回滚。两类合计最多保留 {n} 份,超出删最旧的那份(安全份不会被删)。手动回退会先二次确认,然后会自动重启。',
|
|
390
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30): the user may know better
|
|
391
|
+
// than "the newest proven copy", so ANY copy can be restored — but only
|
|
392
|
+
// after a card that says which one, what it replaces, and that a restart
|
|
393
|
+
// follows.
|
|
394
|
+
'settings.backup.rollback': '回退到这一份',
|
|
395
|
+
'settings.backup.rollbackConfirmTitle': '确定回退到这一份?',
|
|
396
|
+
'settings.backup.rollbackConfirmBody': '会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。',
|
|
397
|
+
'settings.backup.rollbackConfirmRestart': '还原完成后会自动重启一次;重启期间这个页面会断开,回来后接着用。',
|
|
398
|
+
'settings.backup.rollbackConfirmYes': '确认回退',
|
|
399
|
+
'settings.backup.rollbackConfirmNo': '取消',
|
|
400
|
+
'settings.backup.rollbackBusy': '正在回退…',
|
|
401
|
+
'settings.backup.rollbackDone': '已回退,正在重启…',
|
|
402
|
+
'settings.backup.rollbackFailed': '回退没成功:{message}',
|
|
382
403
|
'settings.groups.report': '报告问题',
|
|
383
404
|
'settings.groups.rescue': '出事了怎么办',
|
|
384
405
|
'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
|
|
@@ -497,6 +518,12 @@
|
|
|
497
518
|
'restart.overlay.size': '{done} / {total}',
|
|
498
519
|
'restart.overlay.sizeDone': '{done}',
|
|
499
520
|
'restart.overlay.restarting': '正在重启…',
|
|
521
|
+
// THE HONEST CARD (user report, 2026-10-30): a restart that was interrupted
|
|
522
|
+
// by a new conversation said "正在重启…" for 25 s, which was not true. It now
|
|
523
|
+
// says what actually happened — and it may stay, because it IS the truth.
|
|
524
|
+
'restart.overlay.deferred': '有新会话在跑,重启已暂缓',
|
|
525
|
+
'restart.overlay.deferredNote': '等它结束会自动继续 · 已等 {count} 个会话',
|
|
526
|
+
'restart.overlay.deferredNoteZero': '等它结束会自动继续',
|
|
500
527
|
'restart.overlay.back': '已回来',
|
|
501
528
|
}
|
|
502
529
|
|
|
@@ -728,6 +755,26 @@
|
|
|
728
755
|
'settings.backup.size': 'Backup size',
|
|
729
756
|
'settings.backup.withPlugins': '(plugins included)',
|
|
730
757
|
'settings.backup.withoutPlugins': '(plugins excluded)',
|
|
758
|
+
// WHAT a copy is (user decision, 2026-10-30). Only a proven copy may be
|
|
759
|
+
// rolled back to, so the identity is said in plain words on every row.
|
|
760
|
+
'settings.backup.kindLabel': 'Identity',
|
|
761
|
+
'settings.backup.kind.known-good': 'Known good',
|
|
762
|
+
'settings.backup.kind.snapshot': "This restart's snapshot",
|
|
763
|
+
'settings.backup.kind.unknown': 'Old backup (unverified)',
|
|
764
|
+
'settings.backup.version': 'version {v}',
|
|
765
|
+
'settings.backup.identityHint': 'A backup has one of two identities: ① “known good” — the copy a boot has proven; when something breaks, only a known-good copy is rolled back, and the first install creates one automatically. ② “this restart\'s snapshot” — taken right before every restart and not proven yet; old backups (unverified) and that snapshot are never rolled back automatically. At most {n} copies are kept together (the oldest goes first; a known-good copy is never deleted). A manual rollback asks for confirmation first, then restarts automatically.',
|
|
766
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30): any copy can be restored
|
|
767
|
+
// — but only after a card that says which one, what it replaces, and that
|
|
768
|
+
// a restart follows.
|
|
769
|
+
'settings.backup.rollback': 'Roll back to this copy',
|
|
770
|
+
'settings.backup.rollbackConfirmTitle': 'Roll back to this copy?',
|
|
771
|
+
'settings.backup.rollbackConfirmBody': 'The backup from {time} will be restored over the current state — **what you have now is replaced** (a snapshot of it is taken first, so this rollback can be undone).',
|
|
772
|
+
'settings.backup.rollbackConfirmRestart': 'A restart follows automatically; this page disconnects during it and works again afterwards.',
|
|
773
|
+
'settings.backup.rollbackConfirmYes': 'Roll back',
|
|
774
|
+
'settings.backup.rollbackConfirmNo': 'Cancel',
|
|
775
|
+
'settings.backup.rollbackBusy': 'Rolling back…',
|
|
776
|
+
'settings.backup.rollbackDone': 'Rolled back — restarting…',
|
|
777
|
+
'settings.backup.rollbackFailed': 'The rollback did not work: {message}',
|
|
731
778
|
'settings.groups.report': 'Report a problem',
|
|
732
779
|
'settings.groups.rescue': 'If DSH will not start',
|
|
733
780
|
'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
|
|
@@ -837,6 +884,9 @@
|
|
|
837
884
|
'restart.overlay.size': '{done} / {total}',
|
|
838
885
|
'restart.overlay.sizeDone': '{done}',
|
|
839
886
|
'restart.overlay.restarting': 'Restarting…',
|
|
887
|
+
'restart.overlay.deferred': 'A conversation just started — restart postponed',
|
|
888
|
+
'restart.overlay.deferredNote': 'It will retry automatically when idle · {count} waiting',
|
|
889
|
+
'restart.overlay.deferredNoteZero': 'It will retry automatically when idle',
|
|
840
890
|
'restart.overlay.back': 'Back again',
|
|
841
891
|
}
|
|
842
892
|
|
|
@@ -2090,6 +2140,34 @@
|
|
|
2090
2140
|
return Math.round(value) + ' B'
|
|
2091
2141
|
}
|
|
2092
2142
|
|
|
2143
|
+
/**
|
|
2144
|
+
* WHAT A COPY IS — the identity word of the interface.
|
|
2145
|
+
*
|
|
2146
|
+
* Only a copy a boot has PROVEN may be rolled back to ("安全份" / known-good).
|
|
2147
|
+
* The snapshot of a restart is not proven yet ("本次快照"), and a copy without
|
|
2148
|
+
* an identity file was written before the identity layer ("旧备份(未验证)").
|
|
2149
|
+
* An unknown word is shown as unproven on purpose: never claim more than the
|
|
2150
|
+
* copy itself says.
|
|
2151
|
+
*
|
|
2152
|
+
* @param {string|null|undefined} kind
|
|
2153
|
+
* @returns {'known-good'|'snapshot'|'unknown'}
|
|
2154
|
+
*/
|
|
2155
|
+
function backupKindKey(kind) {
|
|
2156
|
+
if (kind === 'known-good') return 'known-good'
|
|
2157
|
+
if (kind === 'snapshot') return 'snapshot'
|
|
2158
|
+
return 'unknown'
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* The version a copy was proven with, or an empty string when it carries none.
|
|
2163
|
+
*
|
|
2164
|
+
* @param {object|null|undefined} item one backup of the state payload
|
|
2165
|
+
* @returns {string}
|
|
2166
|
+
*/
|
|
2167
|
+
function backupVersionOf(item) {
|
|
2168
|
+
return item && typeof item.version === 'string' && item.version !== '' ? item.version : ''
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2093
2171
|
/**
|
|
2094
2172
|
* The local time of one backup, to the minute.
|
|
2095
2173
|
*
|
|
@@ -2192,6 +2270,134 @@
|
|
|
2192
2270
|
* @param {Function} [translator]
|
|
2193
2271
|
* @returns {string}
|
|
2194
2272
|
*/
|
|
2273
|
+
/**
|
|
2274
|
+
* HOW LONG THE "RESTART POSTPONED" CARD STAYS ON SCREEN (milliseconds).
|
|
2275
|
+
*
|
|
2276
|
+
* THE REPORT (user, 2026-10-30): "after the interruption I still have a lot of
|
|
2277
|
+
* work to do — a card hovering for ever is in the way". The postponed card is an
|
|
2278
|
+
* ANNOUNCEMENT, not a progress bar: it says what happened for four seconds and
|
|
2279
|
+
* then folds away, while the capsule keeps saying "pending restart · waiting for
|
|
2280
|
+
* N sessions". A NEW postponement (a new stage timestamp) announces itself again.
|
|
2281
|
+
*
|
|
2282
|
+
* The stages that really are running ('snapshotting', 'restarting') are not
|
|
2283
|
+
* affected: they stay while the work lasts.
|
|
2284
|
+
*/
|
|
2285
|
+
const RESTART_DEFERRED_CARD_MS = 4000
|
|
2286
|
+
|
|
2287
|
+
/**
|
|
2288
|
+
* HOW LONG A STAGE MAY GO UNMENTIONED BEFORE THE CARD STOPS TRUSTING IT.
|
|
2289
|
+
*
|
|
2290
|
+
* WHY (user report, 2026-10-29): a restart was postponed (a conversation
|
|
2291
|
+
* became active again) right after the copy, and the card kept saying
|
|
2292
|
+
* "DSH 重启前备份中" for ever — the stage on disk had not been brought back to
|
|
2293
|
+
* 'waiting'. The Host is fixed, but the interface must not be able to hang on
|
|
2294
|
+
* a stale stage either: a stage is evidence only while it keeps being
|
|
2295
|
+
* refreshed.
|
|
2296
|
+
*/
|
|
2297
|
+
const RESTART_STAGE_STALE_MS = 15000
|
|
2298
|
+
// While files are being copied the Host refreshes the stage on every progress
|
|
2299
|
+
// tick, so ten silent seconds mean the copy is no longer reporting — either it
|
|
2300
|
+
// is over or nobody is copying any more. A 'restarting' stage has no progress
|
|
2301
|
+
// of its own and only has to stay inside the stale window above.
|
|
2302
|
+
const RESTART_SNAPSHOT_FRESH_MS = 10000
|
|
2303
|
+
|
|
2304
|
+
/**
|
|
2305
|
+
* THE TWO CADENCES OF THE SHARED POLL (milliseconds).
|
|
2306
|
+
*
|
|
2307
|
+
* 2 s is right for a page that merely shows whether a restart is pending: a
|
|
2308
|
+
* pending restart sits there for minutes. While a restart is MOVING, though,
|
|
2309
|
+
* the stage can flip at any moment (a conversation starts, the host postpones
|
|
2310
|
+
* the restart, the card has to change its words) — and 2 s of the wrong word is
|
|
2311
|
+
* exactly what the owner complained about. So the cadence follows the state.
|
|
2312
|
+
*/
|
|
2313
|
+
const POLL_MS = 2000
|
|
2314
|
+
const POLL_FAST_MS = 500
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* IS A RESTART MOVING RIGHT NOW?
|
|
2318
|
+
*
|
|
2319
|
+
* 'waiting' counts as well: the restart is requested and the gate may open (or
|
|
2320
|
+
* a conversation may appear) at any moment — that is exactly when the card must
|
|
2321
|
+
* change its words within a second.
|
|
2322
|
+
*
|
|
2323
|
+
* @param {object|null} info the state payload of the Host
|
|
2324
|
+
* @returns {boolean}
|
|
2325
|
+
*/
|
|
2326
|
+
function isRestartMoving(info) {
|
|
2327
|
+
if (!info || typeof info !== 'object') return false
|
|
2328
|
+
if (info.pendingRestart !== true) return false
|
|
2329
|
+
const stage = info.restartPhase
|
|
2330
|
+
const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2331
|
+
return phase === 'waiting' || phase === 'snapshotting' || phase === 'restarting'
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* The cadence the CURRENT state asks for.
|
|
2336
|
+
*
|
|
2337
|
+
* @param {object|null} info the state payload of the Host
|
|
2338
|
+
* @returns {number} milliseconds
|
|
2339
|
+
*/
|
|
2340
|
+
function pollIntervalFor(info) {
|
|
2341
|
+
return isRestartMoving(info) ? POLL_FAST_MS : POLL_MS
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Is the page on screen? A hidden tab has nobody to tell, so it must not spin
|
|
2346
|
+
* (and it asks once more as soon as it is visible again).
|
|
2347
|
+
*
|
|
2348
|
+
* @returns {boolean}
|
|
2349
|
+
*/
|
|
2350
|
+
function isPageVisible() {
|
|
2351
|
+
if (typeof document === 'undefined' || !document) return true
|
|
2352
|
+
if (document.hidden === true) return false
|
|
2353
|
+
if (typeof document.visibilityState === 'string') return document.visibilityState !== 'hidden'
|
|
2354
|
+
return true
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/**
|
|
2358
|
+
* IS THE RESTART POSTPONED BY A CONVERSATION? (the Host says so in `reason`)
|
|
2359
|
+
*
|
|
2360
|
+
* A postponed restart is a stage of 'waiting' with a reason that starts with
|
|
2361
|
+
* "deferred:" (see the Host's deferRestartStage). That state is NOT an activity,
|
|
2362
|
+
* so it is drawn differently: the card names the interruption and promises the
|
|
2363
|
+
* retry, and the word "正在重启" never appears for it.
|
|
2364
|
+
*
|
|
2365
|
+
* @param {object|null} stage the `restartPhase` of the state route
|
|
2366
|
+
* @returns {boolean}
|
|
2367
|
+
*/
|
|
2368
|
+
function isRestartStageDeferred(stage) {
|
|
2369
|
+
if (!stage || typeof stage !== 'object') return false
|
|
2370
|
+
if (stage.phase !== 'waiting') return false
|
|
2371
|
+
return typeof stage.reason === 'string' && stage.reason.indexOf('deferred:') === 0
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Is the reported stage still something the card may draw?
|
|
2376
|
+
*
|
|
2377
|
+
* The card is an ALARM about something happening right now: 'snapshotting'
|
|
2378
|
+
* (files are being copied) or 'restarting' (the host is going down). A stage
|
|
2379
|
+
* that nobody refreshed for 15 s (10 s for a copy, which refreshes on every
|
|
2380
|
+
* file) describes something that is not happening any more, and drawing it
|
|
2381
|
+
* would leave a card hanging — exactly the bug to avoid. The pill keeps
|
|
2382
|
+
* telling the user the restart is still pending; the card simply stops
|
|
2383
|
+
* claiming progress.
|
|
2384
|
+
*
|
|
2385
|
+
* @param {object|null} stage the `restartPhase` of the state route
|
|
2386
|
+
* @param {number} now
|
|
2387
|
+
* @returns {boolean}
|
|
2388
|
+
*/
|
|
2389
|
+
function isRestartStageLive(stage, now) {
|
|
2390
|
+
const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2391
|
+
if (phase !== 'snapshotting' && phase !== 'restarting') return false
|
|
2392
|
+
const at = stage && Number.isFinite(Number(stage.at)) ? Number(stage.at) : null
|
|
2393
|
+
// No timestamp at all: the stage cannot be trusted to describe "now".
|
|
2394
|
+
if (at === null || at <= 0) return false
|
|
2395
|
+
const age = Math.max(0, now - at)
|
|
2396
|
+
if (age > RESTART_STAGE_STALE_MS) return false
|
|
2397
|
+
if (phase === 'restarting') return true
|
|
2398
|
+
return age <= RESTART_SNAPSHOT_FRESH_MS
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2195
2401
|
function restartOverlayTitle(stage, translator) {
|
|
2196
2402
|
const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2197
2403
|
if (!isRestartOverlayPhase(phase)) return ''
|
|
@@ -2275,6 +2481,19 @@
|
|
|
2275
2481
|
// The stage of a restart on the two surfaces that have room for it: the
|
|
2276
2482
|
// composer row (one short line) and the centred overlay (the detail).
|
|
2277
2483
|
isRestartOverlayPhase,
|
|
2484
|
+
// Is the reported stage still worth a card? (a stale stage cannot hang it)
|
|
2485
|
+
isRestartStageLive,
|
|
2486
|
+
isRestartStageDeferred,
|
|
2487
|
+
RESTART_DEFERRED_CARD_MS,
|
|
2488
|
+
// The cadence of the shared poll: fast while a restart is moving, and no
|
|
2489
|
+
// polling at all in a hidden tab.
|
|
2490
|
+
isRestartMoving,
|
|
2491
|
+
pollIntervalFor,
|
|
2492
|
+
isPageVisible,
|
|
2493
|
+
POLL_MS,
|
|
2494
|
+
POLL_FAST_MS,
|
|
2495
|
+
RESTART_STAGE_STALE_MS,
|
|
2496
|
+
RESTART_SNAPSHOT_FRESH_MS,
|
|
2278
2497
|
restartPillStageText,
|
|
2279
2498
|
restartOverlayTitle,
|
|
2280
2499
|
// The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
|
|
@@ -2316,6 +2535,8 @@
|
|
|
2316
2535
|
const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
|
|
2317
2536
|
const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
|
|
2318
2537
|
const DEFAULT_CANCEL_URL = ROUTE_PREFIX + '/cancel'
|
|
2538
|
+
// The route of the MANUAL ROLLBACK (restore ONE copy the user picked).
|
|
2539
|
+
const DEFAULT_ROLLBACK_URL = ROUTE_PREFIX + '/rollback-to'
|
|
2319
2540
|
// The route that persists the one-time install choice.
|
|
2320
2541
|
const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
|
|
2321
2542
|
// The browser-side record of the one-time install choice. The prompt is
|
|
@@ -2329,7 +2550,8 @@
|
|
|
2329
2550
|
* the fallback for a Host that is older than the supervisor.
|
|
2330
2551
|
*/
|
|
2331
2552
|
const DEFAULT_SUPERVISOR_URL = '/__dsh_autorestart/supervisor'
|
|
2332
|
-
|
|
2553
|
+
// The cadence lives with the state it follows (see POLL_MS/POLL_FAST_MS
|
|
2554
|
+
// above): the shared poll speeds up while a restart is moving.
|
|
2333
2555
|
// The brand accent (blue-purple). The pending-restart marker is a brand
|
|
2334
2556
|
// signal, not a warning: no warning/yellow token is used here.
|
|
2335
2557
|
const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
|
|
@@ -2405,11 +2627,15 @@
|
|
|
2405
2627
|
info: null,
|
|
2406
2628
|
listeners: new Set(),
|
|
2407
2629
|
timer: null,
|
|
2630
|
+
// The cadence the current timer was armed with (0 = not armed).
|
|
2631
|
+
interval: 0,
|
|
2408
2632
|
inFlight: false,
|
|
2409
2633
|
controller: null,
|
|
2410
2634
|
// When the pending state was FIRST observed, so the details panel can
|
|
2411
2635
|
// say how long this session has been waiting.
|
|
2412
2636
|
pendingSince: null,
|
|
2637
|
+
// The visibility listener is bound once, on the first subscriber.
|
|
2638
|
+
visibilityBound: false,
|
|
2413
2639
|
}
|
|
2414
2640
|
|
|
2415
2641
|
function notifyListeners() {
|
|
@@ -2442,8 +2668,63 @@
|
|
|
2442
2668
|
}
|
|
2443
2669
|
}
|
|
2444
2670
|
|
|
2671
|
+
/**
|
|
2672
|
+
* (Re-)arm the shared timer with the cadence the state asks for.
|
|
2673
|
+
*
|
|
2674
|
+
* THE CADENCE IS RE-DECIDED AFTER EVERY ANSWER: a restart that starts makes
|
|
2675
|
+
* the next tick 500 ms away, a restart that ends puts it back to 2 s, and a
|
|
2676
|
+
* hidden page is not polled at all (an interval in a background tab only
|
|
2677
|
+
* spends battery — it asks once as soon as it is visible again).
|
|
2678
|
+
*
|
|
2679
|
+
* Re-arming only happens when the cadence really changed, so the ticks stay
|
|
2680
|
+
* evenly spaced and never pile up.
|
|
2681
|
+
*/
|
|
2682
|
+
function armPoll() {
|
|
2683
|
+
if (stateStore.listeners.size === 0) return
|
|
2684
|
+
const wanted = isPageVisible() ? pollIntervalFor(stateStore.info) : 0
|
|
2685
|
+
if (wanted === 0) {
|
|
2686
|
+
if (stateStore.timer !== null) {
|
|
2687
|
+
clearInterval(stateStore.timer)
|
|
2688
|
+
stateStore.timer = null
|
|
2689
|
+
}
|
|
2690
|
+
stateStore.interval = 0
|
|
2691
|
+
return
|
|
2692
|
+
}
|
|
2693
|
+
if (stateStore.timer !== null && stateStore.interval === wanted) return
|
|
2694
|
+
if (stateStore.timer !== null) clearInterval(stateStore.timer)
|
|
2695
|
+
stateStore.interval = wanted
|
|
2696
|
+
stateStore.timer = setInterval(() => {
|
|
2697
|
+
pollOnce().then(armPoll)
|
|
2698
|
+
}, wanted)
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* Ask once more as soon as the page is back on screen.
|
|
2703
|
+
*
|
|
2704
|
+
* A hidden tab stops polling; coming back must not wait for the next tick of
|
|
2705
|
+
* a timer that was stopped, so the visibility event polls immediately and
|
|
2706
|
+
* then re-arms the cadence.
|
|
2707
|
+
*/
|
|
2708
|
+
function bindVisibility() {
|
|
2709
|
+
if (stateStore.visibilityBound) return
|
|
2710
|
+
if (typeof document === 'undefined' || !document || typeof document.addEventListener !== 'function') return
|
|
2711
|
+
stateStore.visibilityBound = true
|
|
2712
|
+
document.addEventListener('visibilitychange', () => {
|
|
2713
|
+
if (stateStore.listeners.size === 0) return
|
|
2714
|
+
if (!isPageVisible()) {
|
|
2715
|
+
armPoll()
|
|
2716
|
+
return
|
|
2717
|
+
}
|
|
2718
|
+
pollOnce().then(armPoll)
|
|
2719
|
+
armPoll()
|
|
2720
|
+
})
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2445
2723
|
async function pollOnce() {
|
|
2724
|
+
// ONE REQUEST AT A TIME: a slow answer must never stack up a queue.
|
|
2446
2725
|
if (stateStore.inFlight) return
|
|
2726
|
+
// Nobody is looking at a hidden tab; the next visible moment asks.
|
|
2727
|
+
if (!isPageVisible()) return
|
|
2447
2728
|
stateStore.inFlight = true
|
|
2448
2729
|
try {
|
|
2449
2730
|
const response = await fetch(HEALTH_URL, {
|
|
@@ -2482,8 +2763,9 @@
|
|
|
2482
2763
|
stateStore.listeners.add(listener)
|
|
2483
2764
|
if (stateStore.listeners.size === 1) {
|
|
2484
2765
|
stateStore.controller = new AbortController()
|
|
2485
|
-
|
|
2486
|
-
|
|
2766
|
+
bindVisibility()
|
|
2767
|
+
pollOnce().then(armPoll)
|
|
2768
|
+
armPoll()
|
|
2487
2769
|
}
|
|
2488
2770
|
return () => {
|
|
2489
2771
|
stateStore.listeners.delete(listener)
|
|
@@ -2492,6 +2774,7 @@
|
|
|
2492
2774
|
clearInterval(stateStore.timer)
|
|
2493
2775
|
stateStore.timer = null
|
|
2494
2776
|
}
|
|
2777
|
+
stateStore.interval = 0
|
|
2495
2778
|
if (stateStore.controller) {
|
|
2496
2779
|
stateStore.controller.abort()
|
|
2497
2780
|
stateStore.controller = null
|
|
@@ -2688,6 +2971,17 @@
|
|
|
2688
2971
|
return DEFAULT_CANCEL_URL
|
|
2689
2972
|
}
|
|
2690
2973
|
|
|
2974
|
+
/**
|
|
2975
|
+
* The route that restores ONE backup the user picked (the manual
|
|
2976
|
+
* rollback): the one the Host sent with the state payload, or the derived
|
|
2977
|
+
* default. It never carries a path — only a backup NAME goes in the body.
|
|
2978
|
+
*/
|
|
2979
|
+
function rollbackUrlFor(info) {
|
|
2980
|
+
const sent = info && typeof info.rollbackUrl === 'string' ? info.rollbackUrl.trim() : ''
|
|
2981
|
+
if (sent.startsWith('/')) return sent
|
|
2982
|
+
return DEFAULT_ROLLBACK_URL
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2691
2985
|
/**
|
|
2692
2986
|
* Whether the one-time install prompt may be shown.
|
|
2693
2987
|
*
|
|
@@ -3817,13 +4111,58 @@
|
|
|
3817
4111
|
// on right now", the state file alone does not.
|
|
3818
4112
|
const pending = Boolean(info && info.pendingRestart === true)
|
|
3819
4113
|
// THE ONLY TWO STAGES THAT ARE "SOMETHING IS REALLY HAPPENING": the
|
|
3820
|
-
// snapshot is being written, or the host is on its way out
|
|
3821
|
-
|
|
4114
|
+
// snapshot is being written, or the host is on its way out — AND the
|
|
4115
|
+
// stage must still be fresh. A stage nobody refreshed (the restart was
|
|
4116
|
+
// postponed and the Host did not say so) cannot leave a card on screen
|
|
4117
|
+
// for ever; the pill still says the restart is pending.
|
|
4118
|
+
const running = pending && isRestartStageLive(stage, Date.now())
|
|
3822
4119
|
|
|
3823
4120
|
useEffect(() => {
|
|
3824
4121
|
if (running) setSawRunning(true)
|
|
3825
4122
|
}, [running])
|
|
3826
4123
|
|
|
4124
|
+
// THE POSTPONED CARD IS AN ANNOUNCEMENT, NOT A PROGRESS BAR (user report,
|
|
4125
|
+
// 2026-10-30): "after the interruption I still have a lot of work to do — a
|
|
4126
|
+
// card hovering for ever is in the way". It says what happened for four
|
|
4127
|
+
// seconds and then folds away; the capsule keeps saying the restart is
|
|
4128
|
+
// pending. A NEW postponement (a new stage timestamp) announces itself
|
|
4129
|
+
// again. Hooks live up here, with the others, because they must run on
|
|
4130
|
+
// every render — including the renders that draw nothing.
|
|
4131
|
+
const deferredKey = pending && isRestartStageDeferred(stage)
|
|
4132
|
+
? (Number.isFinite(Number(stage.at)) ? Number(stage.at) : 0)
|
|
4133
|
+
: null
|
|
4134
|
+
const [announcedKey, setAnnouncedKey] = useState(null)
|
|
4135
|
+
const [folded, setFolded] = useState(false)
|
|
4136
|
+
|
|
4137
|
+
useEffect(() => {
|
|
4138
|
+
if (deferredKey === null) {
|
|
4139
|
+
if (announcedKey !== null) {
|
|
4140
|
+
setAnnouncedKey(null)
|
|
4141
|
+
setFolded(false)
|
|
4142
|
+
}
|
|
4143
|
+
return undefined
|
|
4144
|
+
}
|
|
4145
|
+
if (announcedKey === deferredKey) return undefined
|
|
4146
|
+
setAnnouncedKey(deferredKey)
|
|
4147
|
+
setFolded(false)
|
|
4148
|
+
return undefined
|
|
4149
|
+
}, [deferredKey, announcedKey])
|
|
4150
|
+
|
|
4151
|
+
// The four seconds of THIS announcement.
|
|
4152
|
+
useEffect(() => {
|
|
4153
|
+
if (announcedKey === null || folded) return undefined
|
|
4154
|
+
const timer = setTimeout(() => setFolded(true), RESTART_DEFERRED_CARD_MS)
|
|
4155
|
+
return () => clearTimeout(timer)
|
|
4156
|
+
}, [announcedKey, folded])
|
|
4157
|
+
|
|
4158
|
+
// A POSTPONED RESTART IS NOT A RUNNING ONE (user report, 2026-10-30): when
|
|
4159
|
+
// the Host says the restart went back to waiting because a conversation
|
|
4160
|
+
// started, the card says exactly that. It is drawn AT ONCE (a fresh
|
|
4161
|
+
// postponement must not wait for a second render) and the hook above
|
|
4162
|
+
// records which postponement has been announced, so its four seconds can
|
|
4163
|
+
// run out; every new stage timestamp resets the clock.
|
|
4164
|
+
const postponed = deferredKey !== null && !folded
|
|
4165
|
+
|
|
3827
4166
|
// "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
|
|
3828
4167
|
useEffect(() => {
|
|
3829
4168
|
if (phase !== 'back') {
|
|
@@ -3834,8 +4173,9 @@
|
|
|
3834
4173
|
return () => clearTimeout(timer)
|
|
3835
4174
|
}, [phase])
|
|
3836
4175
|
|
|
3837
|
-
// THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS
|
|
3838
|
-
|
|
4176
|
+
// THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS — or while
|
|
4177
|
+
// the restart is honestly postponed.
|
|
4178
|
+
const visible = running || postponed || (phase === 'back' && sawRunning && !backGone)
|
|
3839
4179
|
if (!visible) return null
|
|
3840
4180
|
|
|
3841
4181
|
const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
|
|
@@ -3845,11 +4185,32 @@
|
|
|
3845
4185
|
? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
|
|
3846
4186
|
: null
|
|
3847
4187
|
|
|
3848
|
-
|
|
4188
|
+
// The postponed card wins over everything else: even a stage that still
|
|
4189
|
+
// says 'restarting' in the same render is downgraded here, so the old word
|
|
4190
|
+
// can never survive a single frame once the Host said "deferred".
|
|
4191
|
+
const deferredCount = postponed ? waitingTaskCount(info, info && Array.isArray(info.blockedBy) ? info.blockedBy : []) : 0
|
|
4192
|
+
const title = postponed
|
|
4193
|
+
? t('restart.overlay.deferred')
|
|
4194
|
+
: restartOverlayTitle(stage, t)
|
|
3849
4195
|
|
|
3850
4196
|
let bar = null
|
|
3851
4197
|
let detail = null
|
|
3852
|
-
if (
|
|
4198
|
+
if (postponed) {
|
|
4199
|
+
// A second, smaller line: what happens next, and how many conversations
|
|
4200
|
+
// are in the way (the same number the capsule shows).
|
|
4201
|
+
detail = h(
|
|
4202
|
+
'p',
|
|
4203
|
+
{ className: 'dsh-autorestart-restart-overlay__size' },
|
|
4204
|
+
h(
|
|
4205
|
+
'span',
|
|
4206
|
+
{ 'data-dsh-autorestart-restart-overlay-deferred': 'true' },
|
|
4207
|
+
deferredCount > 0
|
|
4208
|
+
? fill(t('restart.overlay.deferredNote'), { count: deferredCount })
|
|
4209
|
+
: t('restart.overlay.deferredNoteZero'),
|
|
4210
|
+
),
|
|
4211
|
+
)
|
|
4212
|
+
}
|
|
4213
|
+
if (!postponed && phase === 'snapshotting') {
|
|
3853
4214
|
// The numbers are the ones the copy loop really measured; when there is
|
|
3854
4215
|
// no measurement yet, the card shows no number at all (an invented
|
|
3855
4216
|
// progress bar is worse than none). The unit fits the size, so a 92 KB
|
|
@@ -3894,6 +4255,9 @@
|
|
|
3894
4255
|
'aria-live': 'polite',
|
|
3895
4256
|
'data-dsh-autorestart-restart-overlay': 'true',
|
|
3896
4257
|
'data-dsh-autorestart-restart-overlay-phase': phase,
|
|
4258
|
+
// WHAT THE CARD SAYS, independent of the Host phase: a postponed
|
|
4259
|
+
// restart is drawn from 'waiting'.
|
|
4260
|
+
'data-dsh-autorestart-restart-overlay-card': postponed ? 'deferred' : phase,
|
|
3897
4261
|
},
|
|
3898
4262
|
h(
|
|
3899
4263
|
'p',
|
|
@@ -4906,6 +5270,37 @@
|
|
|
4906
5270
|
' line-height:18px;',
|
|
4907
5271
|
' color:var(--dsw-alias-label-secondary);',
|
|
4908
5272
|
'}',
|
|
5273
|
+
// WHAT a copy is (user decision, 2026-10-30): the identity is the first
|
|
5274
|
+
// thing read on a row, so it sits on its own line and is not muted.
|
|
5275
|
+
'.dsh-autorestart-settings__backup-identity{',
|
|
5276
|
+
' display:flex;',
|
|
5277
|
+
' flex-wrap:wrap;',
|
|
5278
|
+
' gap:8px;',
|
|
5279
|
+
' align-items:baseline;',
|
|
5280
|
+
'}',
|
|
5281
|
+
'.dsh-autorestart-settings__backup-badge{',
|
|
5282
|
+
' font-size:13px;',
|
|
5283
|
+
' line-height:18px;',
|
|
5284
|
+
' font-weight:600;',
|
|
5285
|
+
' color:var(--dsw-alias-label-primary);',
|
|
5286
|
+
'}',
|
|
5287
|
+
'.dsh-autorestart-settings__backup-actions{',
|
|
5288
|
+
' display:flex;',
|
|
5289
|
+
' flex-wrap:wrap;',
|
|
5290
|
+
' gap:8px;',
|
|
5291
|
+
' align-items:baseline;',
|
|
5292
|
+
'}',
|
|
5293
|
+
// THE SECOND CONFIRMATION of a manual rollback: a block that stands
|
|
5294
|
+
// apart from the list, because it is a question, not a fact.
|
|
5295
|
+
'.dsh-autorestart-settings__confirm{',
|
|
5296
|
+
' display:flex;',
|
|
5297
|
+
' flex-direction:column;',
|
|
5298
|
+
' gap:6px;',
|
|
5299
|
+
' padding:10px 12px;',
|
|
5300
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
5301
|
+
' border-radius:8px;',
|
|
5302
|
+
' background:var(--dsw-alias-bg-l1);',
|
|
5303
|
+
'}',
|
|
4909
5304
|
// The folded "technical details": the command itself stays out of the
|
|
4910
5305
|
// way until somebody asks for it.
|
|
4911
5306
|
'.dsh-autorestart-settings__details{',
|
|
@@ -5417,6 +5812,13 @@
|
|
|
5417
5812
|
// a fresh page starts closed, so nobody leaves technical text open by
|
|
5418
5813
|
// accident.
|
|
5419
5814
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
5815
|
+
// THE MANUAL ROLLBACK (user decision, 2026-10-30). Asking is a STATE,
|
|
5816
|
+
// not a `window.confirm`: the card has to say WHICH copy, that the
|
|
5817
|
+
// current state is replaced, and that a restart follows. `null` = nobody
|
|
5818
|
+
// is asking, and only a confirmation ever calls the route.
|
|
5819
|
+
const [rollbackAsk, setRollbackAsk] = useState(null)
|
|
5820
|
+
const [rollbackBusy, setRollbackBusy] = useState(false)
|
|
5821
|
+
const [rollbackNote, setRollbackNote] = useState('')
|
|
5420
5822
|
const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
|
|
5421
5823
|
// THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
|
|
5422
5824
|
// THIS panel (it is not a window overlay), so it is read here and
|
|
@@ -5645,6 +6047,46 @@
|
|
|
5645
6047
|
*
|
|
5646
6048
|
* @param {object} changes the keys to write
|
|
5647
6049
|
*/
|
|
6050
|
+
/**
|
|
6051
|
+
* THE MANUAL ROLLBACK (user decision, 2026-10-30).
|
|
6052
|
+
*
|
|
6053
|
+
* It is ONLY ever called from the confirmation card: the button on a row
|
|
6054
|
+
* opens the question, and nothing is posted until the user says yes. The
|
|
6055
|
+
* route takes a backup NAME (never a path) and does the rest on the Host
|
|
6056
|
+
* — save the current state, restore that copy, request a restart.
|
|
6057
|
+
*
|
|
6058
|
+
* @param {{ name?: string, atLocal?: string|null }|null} ask what the user picked
|
|
6059
|
+
*/
|
|
6060
|
+
const rollbackTo = async (ask) => {
|
|
6061
|
+
const name = ask && typeof ask.name === 'string' ? ask.name : ''
|
|
6062
|
+
if (name === '' || rollbackBusy === true) return
|
|
6063
|
+
setRollbackBusy(true)
|
|
6064
|
+
setRollbackNote('')
|
|
6065
|
+
try {
|
|
6066
|
+
const response = await fetch(rollbackUrlFor(info), {
|
|
6067
|
+
method: 'POST',
|
|
6068
|
+
headers: { 'content-type': 'application/json' },
|
|
6069
|
+
body: JSON.stringify({ name }),
|
|
6070
|
+
})
|
|
6071
|
+
const body = await response.json().catch(() => null)
|
|
6072
|
+
if (!response.ok || !body || typeof body !== 'object' || body.ok !== true) {
|
|
6073
|
+
const message = body && typeof body === 'object'
|
|
6074
|
+
? String(body.error || body.message || response.status)
|
|
6075
|
+
: String(response.status)
|
|
6076
|
+
setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message }))
|
|
6077
|
+
return
|
|
6078
|
+
}
|
|
6079
|
+
// The copy is back and a restart has been requested: the card goes
|
|
6080
|
+
// away, and the panel says what is happening right now.
|
|
6081
|
+
setRollbackAsk(null)
|
|
6082
|
+
setRollbackNote(t('settings.backup.rollbackDone'))
|
|
6083
|
+
} catch (failure) {
|
|
6084
|
+
setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message: describeError(failure) }))
|
|
6085
|
+
} finally {
|
|
6086
|
+
setRollbackBusy(false)
|
|
6087
|
+
}
|
|
6088
|
+
}
|
|
6089
|
+
|
|
5648
6090
|
const save = async (changes) => {
|
|
5649
6091
|
if (busy) return
|
|
5650
6092
|
setBusy(true)
|
|
@@ -5945,6 +6387,15 @@
|
|
|
5945
6387
|
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'keep' },
|
|
5946
6388
|
fill(t('settings.backup.keep'), { n: backupsInfo.max }),
|
|
5947
6389
|
),
|
|
6390
|
+
// WHAT THE TWO IDENTITIES MEAN (user decision, 2026-10-30): the
|
|
6391
|
+
// words on the rows below are useless without the rule behind
|
|
6392
|
+
// them — only a proven copy is ever rolled back, and a manual
|
|
6393
|
+
// rollback asks before it restarts.
|
|
6394
|
+
h(
|
|
6395
|
+
'span',
|
|
6396
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'identity-hint' },
|
|
6397
|
+
fill(t('settings.backup.identityHint'), { n: backupsInfo.max }),
|
|
6398
|
+
),
|
|
5948
6399
|
backupItems.length === 0
|
|
5949
6400
|
? h(
|
|
5950
6401
|
'span',
|
|
@@ -5959,6 +6410,35 @@
|
|
|
5959
6410
|
'data-dsh-autorestart-backup': 'item',
|
|
5960
6411
|
key: 'backup-' + index,
|
|
5961
6412
|
},
|
|
6413
|
+
// WHAT THIS COPY IS — read first, because the identity decides
|
|
6414
|
+
// whether this copy may be restored at all.
|
|
6415
|
+
h(
|
|
6416
|
+
'div',
|
|
6417
|
+
{ className: 'dsh-autorestart-settings__backup-identity' },
|
|
6418
|
+
h(
|
|
6419
|
+
'span',
|
|
6420
|
+
{ className: 'dsh-autorestart-settings__backup-label' },
|
|
6421
|
+
t('settings.backup.kindLabel'),
|
|
6422
|
+
),
|
|
6423
|
+
h(
|
|
6424
|
+
'span',
|
|
6425
|
+
{
|
|
6426
|
+
className: 'dsh-autorestart-settings__backup-badge',
|
|
6427
|
+
'data-dsh-autorestart-backup': 'kind',
|
|
6428
|
+
},
|
|
6429
|
+
t('settings.backup.kind.' + backupKindKey(item && item.kind)),
|
|
6430
|
+
),
|
|
6431
|
+
backupVersionOf(item) === ''
|
|
6432
|
+
? null
|
|
6433
|
+
: h(
|
|
6434
|
+
'span',
|
|
6435
|
+
{
|
|
6436
|
+
className: 'dsh-autorestart-settings__backup-note',
|
|
6437
|
+
'data-dsh-autorestart-backup': 'version',
|
|
6438
|
+
},
|
|
6439
|
+
fill(t('settings.backup.version'), { v: backupVersionOf(item) }),
|
|
6440
|
+
),
|
|
6441
|
+
),
|
|
5962
6442
|
// WHEN it was taken. No timezone offset: what is shown is
|
|
5963
6443
|
// already the local clock of the machine that took the copy.
|
|
5964
6444
|
h(
|
|
@@ -5998,7 +6478,99 @@
|
|
|
5998
6478
|
: t('settings.backup.withoutPlugins'),
|
|
5999
6479
|
),
|
|
6000
6480
|
),
|
|
6481
|
+
// ROLL BACK TO THIS COPY. The button only OPENS the question —
|
|
6482
|
+
// the route is called from the confirmation card and nowhere
|
|
6483
|
+
// else, so a click alone never restores anything.
|
|
6484
|
+
h(
|
|
6485
|
+
'div',
|
|
6486
|
+
{ className: 'dsh-autorestart-settings__backup-actions' },
|
|
6487
|
+
h(
|
|
6488
|
+
'button',
|
|
6489
|
+
{
|
|
6490
|
+
type: 'button',
|
|
6491
|
+
className: 'dsh-autorestart-settings__button',
|
|
6492
|
+
disabled: busy === true || rollbackBusy === true,
|
|
6493
|
+
'data-dsh-autorestart-backup': 'rollback',
|
|
6494
|
+
'data-backup-name': item && typeof item.name === 'string' ? item.name : '',
|
|
6495
|
+
onClick: () => {
|
|
6496
|
+
setRollbackNote('')
|
|
6497
|
+
setRollbackAsk({
|
|
6498
|
+
name: item && typeof item.name === 'string' ? item.name : '',
|
|
6499
|
+
atLocal: item && typeof item.atLocal === 'string' ? item.atLocal : null,
|
|
6500
|
+
})
|
|
6501
|
+
},
|
|
6502
|
+
},
|
|
6503
|
+
t('settings.backup.rollback'),
|
|
6504
|
+
),
|
|
6505
|
+
),
|
|
6001
6506
|
)),
|
|
6507
|
+
// THE SECOND CONFIRMATION OF A MANUAL ROLLBACK (user decision,
|
|
6508
|
+
// 2026-10-30): it names the copy, says that the current state is
|
|
6509
|
+
// replaced (and that it is saved first), and says that a restart
|
|
6510
|
+
// follows. Nothing is posted until the confirm button is pressed.
|
|
6511
|
+
rollbackAsk === null
|
|
6512
|
+
? null
|
|
6513
|
+
: h(
|
|
6514
|
+
'div',
|
|
6515
|
+
{
|
|
6516
|
+
className: 'dsh-autorestart-settings__confirm',
|
|
6517
|
+
'data-dsh-autorestart-backup': 'rollback-confirm',
|
|
6518
|
+
},
|
|
6519
|
+
h(
|
|
6520
|
+
'span',
|
|
6521
|
+
{ className: 'dsh-autorestart-settings__label' },
|
|
6522
|
+
t('settings.backup.rollbackConfirmTitle'),
|
|
6523
|
+
),
|
|
6524
|
+
h(
|
|
6525
|
+
'span',
|
|
6526
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-body' },
|
|
6527
|
+
fill(t('settings.backup.rollbackConfirmBody'), { time: formatBackupTime(rollbackAsk.atLocal) }),
|
|
6528
|
+
),
|
|
6529
|
+
h(
|
|
6530
|
+
'span',
|
|
6531
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-restart' },
|
|
6532
|
+
t('settings.backup.rollbackConfirmRestart'),
|
|
6533
|
+
),
|
|
6534
|
+
h(
|
|
6535
|
+
'div',
|
|
6536
|
+
{ className: 'dsh-autorestart-settings__backup-actions' },
|
|
6537
|
+
h(
|
|
6538
|
+
'button',
|
|
6539
|
+
{
|
|
6540
|
+
type: 'button',
|
|
6541
|
+
className: 'dsh-autorestart-settings__button',
|
|
6542
|
+
disabled: rollbackBusy === true,
|
|
6543
|
+
'data-dsh-autorestart-backup': 'rollback-confirm-yes',
|
|
6544
|
+
onClick: () => void rollbackTo(rollbackAsk),
|
|
6545
|
+
},
|
|
6546
|
+
rollbackBusy === true
|
|
6547
|
+
? t('settings.backup.rollbackBusy')
|
|
6548
|
+
: t('settings.backup.rollbackConfirmYes'),
|
|
6549
|
+
),
|
|
6550
|
+
h(
|
|
6551
|
+
'button',
|
|
6552
|
+
{
|
|
6553
|
+
type: 'button',
|
|
6554
|
+
className: 'dsh-autorestart-settings__button',
|
|
6555
|
+
disabled: rollbackBusy === true,
|
|
6556
|
+
'data-dsh-autorestart-backup': 'rollback-confirm-no',
|
|
6557
|
+
onClick: () => {
|
|
6558
|
+
// Cancel: nothing was posted, nothing is restored.
|
|
6559
|
+
setRollbackAsk(null)
|
|
6560
|
+
setRollbackNote('')
|
|
6561
|
+
},
|
|
6562
|
+
},
|
|
6563
|
+
t('settings.backup.rollbackConfirmNo'),
|
|
6564
|
+
),
|
|
6565
|
+
),
|
|
6566
|
+
),
|
|
6567
|
+
rollbackNote === ''
|
|
6568
|
+
? null
|
|
6569
|
+
: h(
|
|
6570
|
+
'span',
|
|
6571
|
+
{ className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-note' },
|
|
6572
|
+
rollbackNote,
|
|
6573
|
+
),
|
|
6002
6574
|
backupsInfo.dir === ''
|
|
6003
6575
|
? null
|
|
6004
6576
|
: h(
|