@krapwoo/ds-viewer 0.8.0 → 0.9.1
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/dist/cli/dependencyBumpProof.js +216 -0
- package/dist/cli/dev.js +49 -5
- package/dist/cli/update.js +20 -1
- package/dist/cli/updatePlan.js +67 -11
- package/dist/cli/workspace.js +37 -1
- package/native/catalog/UpdatePanel.tsx +507 -49
- package/native/catalog/updatePanelState.ts +173 -0
- package/package.json +1 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
2
|
+
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { CatalogButton } from './CatalogButton';
|
|
5
5
|
import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
|
|
6
6
|
import {
|
|
7
|
-
autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin,
|
|
7
|
+
acceptsDeliveredStatus, autoCheckHelp, backgroundPlanFetchOutcome, bumpLabel, checkedAgoLabel, resultNote, classifyRejectedFetch, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin,
|
|
8
|
+
nextWaitingAttempt, PLAN_FETCH_ERROR, planShowsError, reachabilityNote, recoveredReachabilityState, remoteOriginLocalErrorMessage,
|
|
9
|
+
requestOrUnreachable, resolvePanelPhase, restartReachability, statusPollFailureOutcome, versionOverview,
|
|
10
|
+
type ReachabilityState, type UpdatePlanView, type UpdateStatusView,
|
|
8
11
|
} from './updatePanelState';
|
|
9
12
|
import type { UpdateNotice, VersionStatus } from './types';
|
|
10
13
|
|
|
@@ -19,6 +22,18 @@ function remoteOrigin(endpoint: Endpoint | undefined): boolean {
|
|
|
19
22
|
return isRemoteOrigin(window.location.protocol, window.location.hostname);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** Minor finding, Fable correction pass: bounds the `waiting` poller's own endpoint retry so a hung
|
|
26
|
+
* request (a firewall `DROP`, not a clean refusal) still counts as a failure instead of blocking
|
|
27
|
+
* it — and every later retry behind it — forever. */
|
|
28
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
29
|
+
|
|
30
|
+
/** Minor finding, correction pass 5: a single rejected `/update/status` while `updating` enters
|
|
31
|
+
* `restarting` even when the server never actually went down (a one-off network hiccup, not a
|
|
32
|
+
* real restart) — the Metro-origin probe below then never sees a failure, so it never reloads,
|
|
33
|
+
* and `restarting` is otherwise terminal in-app. Bounds how long the page waits before offering a
|
|
34
|
+
* manual way out, long enough for a real restart's own install-and-relaunch window. */
|
|
35
|
+
const RESTART_RELOAD_TIMEOUT_MS = 15000;
|
|
36
|
+
|
|
22
37
|
type Endpoint = { baseUrl: string; secret: string };
|
|
23
38
|
|
|
24
39
|
async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
|
|
@@ -67,11 +82,42 @@ const NOTE_BOX_VARIANT = {
|
|
|
67
82
|
ok: { box: { backgroundColor: CATALOG_COLOR.successSubtle }, text: { color: CATALOG_COLOR.success } },
|
|
68
83
|
} as const;
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
/** `spinner` is the approved mockup's own `.row .spin` treatment (waiting only) — a leading
|
|
86
|
+
* `ActivityIndicator`, the same primitive `CatalogButton`'s own `busy` state already uses, so
|
|
87
|
+
* this isn't a new component or colour. */
|
|
88
|
+
function NoteBox({
|
|
89
|
+
variant,
|
|
90
|
+
spinner,
|
|
91
|
+
live,
|
|
92
|
+
hint,
|
|
93
|
+
children,
|
|
94
|
+
}: {
|
|
95
|
+
variant: keyof typeof NOTE_BOX_VARIANT;
|
|
96
|
+
spinner?: boolean;
|
|
97
|
+
/** Only the reachability notes (`waiting`/`recovered`/`stuck`) appear, change and disappear with
|
|
98
|
+
* no user action — `ResultNoteBox` already establishes the `role="status"` pattern for its own
|
|
99
|
+
* note at :209. Every other `NoteBox` stays silent to a screen reader, same as today. */
|
|
100
|
+
live?: boolean;
|
|
101
|
+
/** Minor finding, Fable correction pass #2: a recovery command (e.g. `npx ds-viewer dev`) used
|
|
102
|
+
* to render outside this box, so a screen reader announced the problem via `live` but never
|
|
103
|
+
* the fix sitting right next to it. Rendered inside the same `role="status"` container so both
|
|
104
|
+
* are announced together. */
|
|
105
|
+
hint?: React.ReactNode;
|
|
106
|
+
children: React.ReactNode;
|
|
107
|
+
}) {
|
|
71
108
|
const v = NOTE_BOX_VARIANT[variant];
|
|
109
|
+
const text = <Text style={[styles.noteBoxText, v.text]}>{children}</Text>;
|
|
72
110
|
return (
|
|
73
|
-
<View style={[styles.noteBox, v.box]}>
|
|
74
|
-
|
|
111
|
+
<View style={[styles.noteBox, v.box]} {...(live ? { role: 'status' as const } : {})}>
|
|
112
|
+
{spinner ? (
|
|
113
|
+
<View style={styles.noteBoxRow}>
|
|
114
|
+
<ActivityIndicator size="small" color={CATALOG_COLOR.accent} />
|
|
115
|
+
{text}
|
|
116
|
+
</View>
|
|
117
|
+
) : (
|
|
118
|
+
text
|
|
119
|
+
)}
|
|
120
|
+
{hint}
|
|
75
121
|
</View>
|
|
76
122
|
);
|
|
77
123
|
}
|
|
@@ -133,6 +179,44 @@ function Code({ children }: { children: string }) {
|
|
|
133
179
|
return <Text style={styles.code}>{children}</Text>;
|
|
134
180
|
}
|
|
135
181
|
|
|
182
|
+
/** Replaces this page's normal content for `waiting`/`stuck` (approved mockup states 1 and
|
|
183
|
+
* "Edge"): the version line stays, but the action goes unavailable rather than offering a press
|
|
184
|
+
* that cannot work (decision 3), except `stuck`'s own `Reconnect`. */
|
|
185
|
+
function ReachabilityOverride({
|
|
186
|
+
reachability,
|
|
187
|
+
header,
|
|
188
|
+
actionLabel,
|
|
189
|
+
onReconnect,
|
|
190
|
+
}: {
|
|
191
|
+
reachability: ReachabilityState;
|
|
192
|
+
header: React.ReactNode;
|
|
193
|
+
actionLabel: string;
|
|
194
|
+
onReconnect: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
const note = reachabilityNote(reachability.phase);
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
{header}
|
|
200
|
+
<NoteBox
|
|
201
|
+
variant={note.variant}
|
|
202
|
+
spinner={note.spinner}
|
|
203
|
+
live
|
|
204
|
+
hint={note.hint && <Text style={styles.muted}>{note.hint.prefix}<Code>{note.hint.command}</Code></Text>}
|
|
205
|
+
>
|
|
206
|
+
{note.bold ? <Text style={styles.noteBoxBold}>{note.bold}</Text> : null}
|
|
207
|
+
{note.text}
|
|
208
|
+
</NoteBox>
|
|
209
|
+
<View style={styles.actions}>
|
|
210
|
+
{reachability.phase === 'stuck' ? (
|
|
211
|
+
<CatalogButton label="Reconnect" variant="secondary" onPress={onReconnect} />
|
|
212
|
+
) : (
|
|
213
|
+
<CatalogButton label={actionLabel} disabled />
|
|
214
|
+
)}
|
|
215
|
+
</View>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
136
220
|
|
|
137
221
|
|
|
138
222
|
/**
|
|
@@ -150,6 +234,11 @@ function ResultNoteBox({
|
|
|
150
234
|
status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
|
|
151
235
|
current: string;
|
|
152
236
|
latest?: string;
|
|
237
|
+
/** Named for its one legitimate "already running" caller (`noteBusy`/`starting`), but also true
|
|
238
|
+
* while the panel is reachability-unavailable (decision 3) — a press can't work for either
|
|
239
|
+
* reason, so the button renders the same flat-grey/muted-text treatment
|
|
240
|
+
* `CatalogButton.disabled` already uses for both (Minor finding, Fable correction pass #2),
|
|
241
|
+
* rather than the full-colour fill at reduced opacity that used to read as merely pressed. */
|
|
153
242
|
busy: boolean;
|
|
154
243
|
onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
|
|
155
244
|
}) {
|
|
@@ -178,9 +267,13 @@ function ResultNoteBox({
|
|
|
178
267
|
disabled={busy}
|
|
179
268
|
accessibilityRole="button"
|
|
180
269
|
accessibilityState={{ disabled: busy }}
|
|
181
|
-
style={({ pressed }) => [
|
|
270
|
+
style={({ pressed }) => [
|
|
271
|
+
styles.resultButton,
|
|
272
|
+
busy ? styles.resultButtonDisabled : { backgroundColor: tone },
|
|
273
|
+
pressed && !busy && styles.resultButtonPressed,
|
|
274
|
+
]}
|
|
182
275
|
>
|
|
183
|
-
<Text style={styles.resultButtonText}>{note.action.label}</Text>
|
|
276
|
+
<Text style={[styles.resultButtonText, busy && styles.resultButtonTextDisabled]}>{note.action.label}</Text>
|
|
184
277
|
</Pressable>
|
|
185
278
|
{note.hasLog && status.phase === 'failure' && (
|
|
186
279
|
<Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
|
|
@@ -204,12 +297,20 @@ function VersionDetails({
|
|
|
204
297
|
endpoint,
|
|
205
298
|
onStatus,
|
|
206
299
|
onCheck,
|
|
300
|
+
onUnreachable,
|
|
301
|
+
onReachable,
|
|
207
302
|
}: {
|
|
208
303
|
status: VersionStatus;
|
|
209
304
|
endpoint?: Endpoint;
|
|
210
305
|
onStatus: (next: VersionStatus) => void;
|
|
211
306
|
/** Called as Check now starts (clears an update result note). */
|
|
212
307
|
onCheck?: () => void;
|
|
308
|
+
/** `authedFetch` itself rejected (the endpoint socket is gone) — the page-wide reachability flow
|
|
309
|
+
* takes over; this component shows nothing further for the attempt that triggered it. */
|
|
310
|
+
onUnreachable?: () => void;
|
|
311
|
+
/** The endpoint answered, whatever it said — clears a reachability note left over from an
|
|
312
|
+
* earlier unreachable attempt, same as every other endpoint call on this page. */
|
|
313
|
+
onReachable?: () => void;
|
|
213
314
|
}) {
|
|
214
315
|
const [checking, setChecking] = useState(false);
|
|
215
316
|
const [justChecked, setJustChecked] = useState(false);
|
|
@@ -225,19 +326,37 @@ function VersionDetails({
|
|
|
225
326
|
setChecking(true);
|
|
226
327
|
setJustChecked(false);
|
|
227
328
|
setLocalError(undefined);
|
|
329
|
+
// `requestOrUnreachable` separates a rejected fetch (the endpoint socket is gone) from an
|
|
330
|
+
// actual response (401/409/502 included) — only the former goes to the shared reachability
|
|
331
|
+
// flow below; a real HTTP response keeps this `try` block's existing handling untouched.
|
|
332
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/check', { method: 'POST' }));
|
|
333
|
+
if (outcome.kind === 'unreachable') {
|
|
334
|
+
// Critical finding, Fable correction pass (C4): a rejected `Origin` (no CORS headers at
|
|
335
|
+
// all) rejects `fetch` the same way a dead socket does — self-healing is meaningless for a
|
|
336
|
+
// page that can never use the endpoint, so this must keep the preserved remote-origin/
|
|
337
|
+
// tunnel message below instead of entering the page-wide reachability flow.
|
|
338
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
339
|
+
setLocalError(remoteOriginLocalErrorMessage());
|
|
340
|
+
} else {
|
|
341
|
+
onUnreachable?.();
|
|
342
|
+
}
|
|
343
|
+
setChecking(false);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
228
346
|
try {
|
|
229
|
-
const res =
|
|
347
|
+
const res = outcome.response;
|
|
230
348
|
if (!res.ok) throw new Error(String(res.status));
|
|
231
349
|
const next = (await res.json()) as VersionStatus;
|
|
232
350
|
// A newer version moves the whole page into the update flow (the parent re-renders with it).
|
|
233
351
|
setJustChecked(next.lastOutcome === 'ok' && !next.update);
|
|
234
352
|
onStatus(next);
|
|
353
|
+
onReachable?.();
|
|
235
354
|
} catch {
|
|
236
355
|
setLocalError(remoteOrigin(endpoint)
|
|
237
356
|
// The endpoint only answers its own origin, and it can install packages and restart the
|
|
238
357
|
// server — so saying "restart the viewer" here would send the reader after a fault that
|
|
239
358
|
// isn't there. The page itself is fine over a tunnel; only its actions are local.
|
|
240
|
-
?
|
|
359
|
+
? remoteOriginLocalErrorMessage()
|
|
241
360
|
: 'Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
|
|
242
361
|
} finally {
|
|
243
362
|
setChecking(false);
|
|
@@ -248,14 +367,25 @@ function VersionDetails({
|
|
|
248
367
|
if (!endpoint || saving) return;
|
|
249
368
|
setSaving(true);
|
|
250
369
|
setLocalError(undefined);
|
|
370
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/auto-check', {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({ enabled }),
|
|
374
|
+
}));
|
|
375
|
+
if (outcome.kind === 'unreachable') {
|
|
376
|
+
// Same reasoning as `checkNow` above.
|
|
377
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
378
|
+
setLocalError(remoteOriginLocalErrorMessage());
|
|
379
|
+
} else {
|
|
380
|
+
onUnreachable?.();
|
|
381
|
+
}
|
|
382
|
+
setSaving(false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
251
385
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
body: JSON.stringify({ enabled }),
|
|
256
|
-
});
|
|
257
|
-
if (!res.ok) throw new Error(String(res.status));
|
|
258
|
-
onStatus((await res.json()) as VersionStatus);
|
|
386
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
387
|
+
onStatus((await outcome.response.json()) as VersionStatus);
|
|
388
|
+
onReachable?.();
|
|
259
389
|
} catch {
|
|
260
390
|
setLocalError('Couldn’t save the setting. Try again.');
|
|
261
391
|
} finally {
|
|
@@ -358,6 +488,13 @@ export function UpdatePanel({
|
|
|
358
488
|
headingRef?: React.Ref<View>;
|
|
359
489
|
}) {
|
|
360
490
|
const [status, setStatus] = useState<UpdateStatusView>({ phase: 'idle' });
|
|
491
|
+
// Critical finding, Fable correction pass (C6): mirrors `status`, read instead of a value
|
|
492
|
+
// assigned inside a `setStatus` updater — updaters run during React's render phase, not at
|
|
493
|
+
// dispatch, so a read-after-write inside one can see stale or re-invoked state.
|
|
494
|
+
const statusRef = useRef<UpdateStatusView>(status);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
statusRef.current = status;
|
|
497
|
+
}, [status]);
|
|
361
498
|
const [planResult, setPlanResult] = useState<UpdatePlanView | { error: string } | undefined>(undefined);
|
|
362
499
|
// Critical finding, Fable correction pass: while this is true, the endpoint's own `/update` POST
|
|
363
500
|
// is in flight (re-planning before it can even start) — **Update now** must disable itself for
|
|
@@ -366,31 +503,175 @@ export function UpdatePanel({
|
|
|
366
503
|
const [starting, setStarting] = useState(false);
|
|
367
504
|
const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
368
505
|
|
|
506
|
+
// Important finding, Fable correction pass (I4): the catalog is a single-page app, so a probe
|
|
507
|
+
// started before the user navigates away can still resolve after this component unmounts — set
|
|
508
|
+
// once, on unmount, and checked after every `await` below before any state is dispatched or the
|
|
509
|
+
// page is reloaded out from under whatever's there now.
|
|
510
|
+
const cancelledRef = useRef(false);
|
|
511
|
+
useEffect(() => () => {
|
|
512
|
+
cancelledRef.current = true;
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
// The page-wide reachability flow (approved `.hermes-task/approved-unreachable-states.html`):
|
|
516
|
+
// shared by every endpoint call below and by `VersionDetails`' own calls, since the two UI modes
|
|
517
|
+
// are never visible at once. `undefined` means "nothing wrong" — the same convention
|
|
518
|
+
// `UpdateStatusView`'s `idle` phase already uses for "nothing in flight".
|
|
519
|
+
const [reachability, setReachability] = useState<ReachabilityState | undefined>(undefined);
|
|
520
|
+
const reachabilityRef = useRef<ReachabilityState | undefined>(undefined);
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
reachabilityRef.current = reachability;
|
|
523
|
+
}, [reachability]);
|
|
524
|
+
|
|
525
|
+
// Critical finding, Fable correction pass (C4): a page that can never use the endpoint at all
|
|
526
|
+
// (a tunnel, or a plain `127.0.0.1` the endpoint's own Origin check rejects) gets no
|
|
527
|
+
// self-healing — see `routeUnreachable` below. Kept apart from `reachability` because the two
|
|
528
|
+
// conditions are mutually exclusive and need entirely different, non-retrying treatment.
|
|
529
|
+
const [remoteOriginError, setRemoteOriginError] = useState(false);
|
|
530
|
+
|
|
531
|
+
// Called by `routeUnreachable` below for a rejected fetch this page can actually recover from.
|
|
532
|
+
// Final slice: a stable endpoint address means the `waiting` poller's own direct endpoint retry
|
|
533
|
+
// (below) is the whole recovery mechanism, so this just advances the bounded attempt counter —
|
|
534
|
+
// no origin probe, no reload, no marker.
|
|
535
|
+
const markUnreachable = useCallback(() => {
|
|
536
|
+
if (!isWeb()) return;
|
|
537
|
+
setReachability((prev) => nextWaitingAttempt(prev ?? { phase: 'waiting', attempts: 0 }));
|
|
538
|
+
}, []);
|
|
539
|
+
|
|
540
|
+
// Critical finding, Fable correction pass (C4): the one place every rejected-fetch call site on
|
|
541
|
+
// this page routes through. `cli/endpoint.ts` answers a rejected `Origin` with no CORS headers
|
|
542
|
+
// at all, so the browser blocks the response and `fetch` rejects exactly like a dead socket —
|
|
543
|
+
// without this check first, that rejection used to enter the reachability flow and reload, then
|
|
544
|
+
// sit there "still not answering" forever, for a page that was never going to be able to act
|
|
545
|
+
// regardless.
|
|
546
|
+
const routeUnreachable = useCallback(() => {
|
|
547
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
|
|
548
|
+
setRemoteOriginError(true);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
markUnreachable();
|
|
552
|
+
}, [endpoint, markUnreachable]);
|
|
553
|
+
|
|
554
|
+
// Important finding, Fable correction pass #2: a background poll (`fetchStatus`/`fetchPlan`,
|
|
555
|
+
// both run unprompted on mount and on a plain interval) must never latch `remoteOriginError` —
|
|
556
|
+
// that replaces the whole page with a red error for every non-`localhost` page (a tunnel, LAN
|
|
557
|
+
// address, reverse proxy, or plain `127.0.0.1`) the instant it loads, something the user never
|
|
558
|
+
// triggered. Only a user-initiated call (`startUpdate`, `dismissResult`, `resumeResult`, and
|
|
559
|
+
// `VersionDetails`' own handlers, which already own their local copy of the message) may show
|
|
560
|
+
// that error, via `routeUnreachable` above. A genuinely unreachable endpoint (not a
|
|
561
|
+
// remote-origin refusal) still enters the reachability flow either way.
|
|
562
|
+
// Correction pass 3: reports back whether it actually did anything (`true` once it has entered
|
|
563
|
+
// the reachability flow, `false` when it declined to for a remote-origin page) — a background
|
|
564
|
+
// caller that can't just swallow a refusal (`fetchPlan` below) needs to know so it can apply its
|
|
565
|
+
// own fallback instead of leaving required state unset.
|
|
566
|
+
const routeUnreachableInBackground = useCallback((): boolean => {
|
|
567
|
+
if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') return false;
|
|
568
|
+
markUnreachable();
|
|
569
|
+
return true;
|
|
570
|
+
}, [endpoint, markUnreachable]);
|
|
571
|
+
|
|
572
|
+
// Called by any endpoint call on this page that got a real response — clears a reachability
|
|
573
|
+
// note left over from an earlier unreachable attempt (a no-op the vast majority of the time).
|
|
574
|
+
const markReachable = useCallback(() => {
|
|
575
|
+
if (!isWeb()) return;
|
|
576
|
+
// Important finding, Fable correction pass (I2): computed from the ref, not from inside the
|
|
577
|
+
// `setReachability` updater — a re-invoked updater could otherwise read the state it just set
|
|
578
|
+
// on a prior invocation and settle back on "nothing to recover," silently losing the approved
|
|
579
|
+
// "Reconnected." note.
|
|
580
|
+
const next = recoveredReachabilityState(reachabilityRef.current);
|
|
581
|
+
if (next) setReachability(next);
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
// "Recovered: … auto-dismissing" (approved design) — a plain timeout, cleared on unmount or on
|
|
585
|
+
// leaving the state early (e.g. another unreachable attempt right after recovering).
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
if (reachability?.phase !== 'recovered') return;
|
|
588
|
+
const timeout = setTimeout(() => setReachability(undefined), 3000);
|
|
589
|
+
return () => clearTimeout(timeout);
|
|
590
|
+
}, [reachability?.phase]);
|
|
591
|
+
|
|
592
|
+
// Part 3 (final slice): one explicit, user-initiated reload — the only reload left anywhere in
|
|
593
|
+
// this flow. Restarts the bounded cycle first so that if `window.location.reload()` turns out
|
|
594
|
+
// to be a no-op (some embedded webviews block it), the page falls back to retrying the endpoint
|
|
595
|
+
// in place instead of sitting on `stuck` with a press that silently did nothing.
|
|
596
|
+
const reconnect = useCallback(() => {
|
|
597
|
+
if (!isWeb()) return;
|
|
598
|
+
setReachability(restartReachability());
|
|
599
|
+
window.location.reload();
|
|
600
|
+
}, []);
|
|
601
|
+
|
|
369
602
|
const fetchStatus = useCallback(async () => {
|
|
370
603
|
if (!endpoint || !isWeb()) return;
|
|
604
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status'));
|
|
605
|
+
if (outcome.kind === 'unreachable') {
|
|
606
|
+
// Errata 2a: a failed poll while an update was in flight or already restarting means "still
|
|
607
|
+
// restarting," not unreachable — the dedicated restart-handoff effect below already owns
|
|
608
|
+
// that recovery, so this page-wide flow only takes over for every other phase.
|
|
609
|
+
const fallback = statusPollFailureOutcome(statusRef.current.phase);
|
|
610
|
+
if (fallback) {
|
|
611
|
+
setStatus(fallback);
|
|
612
|
+
} else {
|
|
613
|
+
routeUnreachableInBackground();
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
371
617
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
618
|
+
// Critical finding, Fable correction pass (C1): a delivered-but-unusable response (a stale
|
|
619
|
+
// secret on a reused port answers 401 `text/plain`, with CORS headers, so it's delivered and
|
|
620
|
+
// `.json()` rejects) is not "unreachable" — the endpoint answered — so it must not enter the
|
|
621
|
+
// reachability flow; fall back the same way a failed poll mid-update already does.
|
|
622
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
623
|
+
const next = (await outcome.response.json()) as UpdateStatusView;
|
|
624
|
+
// Correction pass 4 (Critical): once restarted, this poll reaches the server on the same
|
|
625
|
+
// reused address and succeeds before Metro has served the new bundle — only the Metro-origin
|
|
626
|
+
// reload poller below may move the page out of `restarting`.
|
|
627
|
+
if (acceptsDeliveredStatus(statusRef.current.phase, next.phase)) setStatus(next);
|
|
628
|
+
markReachable();
|
|
374
629
|
} catch {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
setStatus((prev) => (prev.phase === 'updating' || prev.phase === 'restarting' ? { phase: 'restarting' } : prev));
|
|
630
|
+
const fallback = statusPollFailureOutcome(statusRef.current.phase);
|
|
631
|
+
if (fallback) setStatus(fallback);
|
|
378
632
|
}
|
|
379
|
-
}, [endpoint]);
|
|
633
|
+
}, [endpoint, routeUnreachableInBackground, markReachable]);
|
|
380
634
|
|
|
381
|
-
|
|
382
|
-
|
|
635
|
+
// Part 4 (final slice): returns whether the endpoint was actually reached, so a caller that's
|
|
636
|
+
// about to claim "Reconnected. Everything below is current." (the waiting poller's success
|
|
637
|
+
// branch, below) can gate that claim on this fetch having actually succeeded, instead of
|
|
638
|
+
// assuming it did just because an earlier call in the same tick did.
|
|
639
|
+
// Correction pass 5 (Important): also returns the plan it just stored, taken from the fetch
|
|
640
|
+
// itself rather than left for the caller to read back from a ref — a ref filled in by a passive
|
|
641
|
+
// effect only updates once React flushes effects after this render, which is always later than
|
|
642
|
+
// the very next line of an async caller, so that read would deterministically see the prior
|
|
643
|
+
// plan, never this one.
|
|
644
|
+
const fetchPlan = useCallback(async (): Promise<{ reached: boolean; result?: UpdatePlanView | { error: string } }> => {
|
|
645
|
+
if (!endpoint || !isWeb()) return { reached: true };
|
|
383
646
|
// Important finding, Fable correction pass: an unhandled rejection here (the endpoint
|
|
384
647
|
// unreachable, CORS-blocked, or mid-restart) previously left `planResult` `undefined` forever
|
|
385
648
|
// — the panel sat on "Checking…" with no way out. The same error shape `buildUpdatePlan`
|
|
386
|
-
// itself returns on failure drives the existing `offline` phase.
|
|
649
|
+
// itself returns on failure drives the existing `offline` phase. A genuinely unreachable
|
|
650
|
+
// endpoint (the socket itself is gone) now goes through the shared reachability flow instead.
|
|
651
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/plan', { method: 'POST' }));
|
|
652
|
+
if (outcome.kind === 'unreachable') {
|
|
653
|
+
// Correction pass 3: a remote-origin page's refusal leaves `routeUnreachableInBackground`
|
|
654
|
+
// reporting `false` — apply the same offline plan-error outcome the `catch` below already
|
|
655
|
+
// shows for every other plan failure, rather than leaving `planResult` (and the panel) stuck
|
|
656
|
+
// on `undefined`/"Checking…" forever.
|
|
657
|
+
const fallback = backgroundPlanFetchOutcome(routeUnreachableInBackground());
|
|
658
|
+
if (fallback) setPlanResult(fallback);
|
|
659
|
+
return { reached: false };
|
|
660
|
+
}
|
|
387
661
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
662
|
+
// Critical finding, Fable correction pass (C1): see `fetchStatus` above — a delivered 401
|
|
663
|
+
// (stale secret) must fall back to the pre-existing offline plan-error note, with a working
|
|
664
|
+
// **Try again**, instead of hanging forever on a rejected `.json()`.
|
|
665
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
666
|
+
const result = (await outcome.response.json()) as UpdatePlanView | { error: string };
|
|
667
|
+
setPlanResult(result);
|
|
668
|
+
markReachable();
|
|
669
|
+
return { reached: true, result };
|
|
390
670
|
} catch {
|
|
391
|
-
setPlanResult(
|
|
671
|
+
setPlanResult(PLAN_FETCH_ERROR);
|
|
672
|
+
return { reached: true, result: PLAN_FETCH_ERROR };
|
|
392
673
|
}
|
|
393
|
-
}, [endpoint]);
|
|
674
|
+
}, [endpoint, routeUnreachableInBackground, markReachable]);
|
|
394
675
|
|
|
395
676
|
useEffect(() => {
|
|
396
677
|
// Errata 1c: never call `fetchPlan` when there is no update to plan for.
|
|
@@ -399,6 +680,74 @@ export function UpdatePanel({
|
|
|
399
680
|
});
|
|
400
681
|
}, [fetchStatus, fetchPlan, update]);
|
|
401
682
|
|
|
683
|
+
// Final slice: with a stable endpoint address, this direct retry is the whole recovery
|
|
684
|
+
// mechanism — no origin probe, no reload. A restart lands on the same port and secret, so this
|
|
685
|
+
// keeps failing only while the viewer is actually down, and succeeds the moment it's back.
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
if (!isWeb() || !endpoint || reachability?.phase !== 'waiting') return;
|
|
688
|
+
// Important finding, Fable correction pass (I4): stacked probes (if one tick's probe is still
|
|
689
|
+
// in flight when the next fires) are refused rather than piling up.
|
|
690
|
+
let inFlight = false;
|
|
691
|
+
const interval = setInterval(async () => {
|
|
692
|
+
if (inFlight) return;
|
|
693
|
+
inFlight = true;
|
|
694
|
+
try {
|
|
695
|
+
// Minor finding, Fable correction pass #2: this retry sits behind the `inFlight` latch
|
|
696
|
+
// above with no timeout of its own — a hung request (a firewall `DROP` on the stale port,
|
|
697
|
+
// not a clean refusal) would otherwise pin `inFlight` forever, blocking every later probe
|
|
698
|
+
// and never reaching `stuck`.
|
|
699
|
+
const controller = new AbortController();
|
|
700
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
701
|
+
let outcome: Awaited<ReturnType<typeof requestOrUnreachable>>;
|
|
702
|
+
try {
|
|
703
|
+
outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status', { signal: controller.signal }));
|
|
704
|
+
} finally {
|
|
705
|
+
clearTimeout(timeout);
|
|
706
|
+
}
|
|
707
|
+
if (cancelledRef.current) return;
|
|
708
|
+
if (outcome.kind === 'ok' && outcome.response.ok) {
|
|
709
|
+
// Important finding, Fable correction pass #2: `markReachable` only flips the
|
|
710
|
+
// reachability phase to `recovered` — it never refreshes `status` or re-runs
|
|
711
|
+
// `fetchPlan`, so the note's "Everything below is current" was false by construction,
|
|
712
|
+
// and a page still waiting on its first plan (`planResult === undefined` at mount)
|
|
713
|
+
// was left stranded on "Checking…" with no way out. Feed the response through, and
|
|
714
|
+
// refresh the plan too, *before* calling `markReachable` — so the claim is true by
|
|
715
|
+
// the time it's shown.
|
|
716
|
+
try {
|
|
717
|
+
setStatus((await outcome.response.json()) as UpdateStatusView);
|
|
718
|
+
} catch {
|
|
719
|
+
// The regular poll (once `phase` leaves `waiting`) retries `/update/status` on its
|
|
720
|
+
// own; `markReachable` below still ends the reachability flow cleanly.
|
|
721
|
+
}
|
|
722
|
+
// Part 4: a delivered status does not, by itself, make "Everything below is current"
|
|
723
|
+
// true — the plan refresh it's paired with must also have actually reached the
|
|
724
|
+
// endpoint. Without this gate, a plan fetch that failed right after a successful status
|
|
725
|
+
// poll still showed the recovered note while the endpoint was gone, tore the poller
|
|
726
|
+
// down, and could strand the panel on "Checking…" with no button.
|
|
727
|
+
const planOutcome = update ? await fetchPlan() : { reached: true };
|
|
728
|
+
if (cancelledRef.current) return;
|
|
729
|
+
// Minor finding, correction pass 4: `/plan` answers 200 with an `{ error }` body for the
|
|
730
|
+
// offline case, so a reached-but-erroring plan still leaves this reached — showing
|
|
731
|
+
// "Reconnected. Everything below is current." directly above the red offline note it
|
|
732
|
+
// contradicts. Suppress the note instead of changing the gate above: leave the page on
|
|
733
|
+
// its `offline` state (with a working **Try again**) rather than reporting a reachability
|
|
734
|
+
// failure, which would distort the waiting poller's own bounded-attempt counter.
|
|
735
|
+
// Correction pass 5 (Important): `planShowsError` reads `planOutcome.result` — the value
|
|
736
|
+
// this very `fetchPlan()` call just stored — never a ref a passive effect fills in later.
|
|
737
|
+
if (planOutcome.reached) {
|
|
738
|
+
if (planShowsError(planOutcome.result)) setReachability(undefined);
|
|
739
|
+
else markReachable();
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
setReachability((prev) => (prev && prev.phase === 'waiting' ? nextWaitingAttempt(prev) : prev));
|
|
744
|
+
} finally {
|
|
745
|
+
inFlight = false;
|
|
746
|
+
}
|
|
747
|
+
}, 500);
|
|
748
|
+
return () => clearInterval(interval);
|
|
749
|
+
}, [reachability?.phase, endpoint, markReachable, fetchPlan, update]);
|
|
750
|
+
|
|
402
751
|
// A finished update (success or failure) shows as a note at the top; the page under it behaves
|
|
403
752
|
// as if idle, so Check now, the switch and a newer update all keep working.
|
|
404
753
|
const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
|
|
@@ -406,13 +755,36 @@ export function UpdatePanel({
|
|
|
406
755
|
const [noteBusy, setNoteBusy] = useState(false);
|
|
407
756
|
const dismissResult = useCallback(async () => {
|
|
408
757
|
if (!endpoint || !isWeb()) return;
|
|
758
|
+
// Important finding, Fable correction pass (I3): routed through the same reachability flow as
|
|
759
|
+
// every other endpoint call, instead of silently swallowing a rejected fetch with **Got it**
|
|
760
|
+
// left doing nothing visible.
|
|
761
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/dismiss', { method: 'POST' }));
|
|
762
|
+
if (outcome.kind === 'unreachable') {
|
|
763
|
+
routeUnreachable();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
409
766
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
767
|
+
if (!outcome.response.ok) throw new Error(String(outcome.response.status));
|
|
768
|
+
setStatus((await outcome.response.json()) as UpdateStatusView);
|
|
769
|
+
markReachable();
|
|
412
770
|
} catch {
|
|
413
771
|
// The note stays; the next status poll or reload settles it.
|
|
414
772
|
}
|
|
415
|
-
}, [endpoint]);
|
|
773
|
+
}, [endpoint, routeUnreachable, markReachable]);
|
|
774
|
+
|
|
775
|
+
// Important finding, Fable correction pass (I3): **Finish migration** / **Rerun doctor** had no
|
|
776
|
+
// guard at all — a rejected fetch here did nothing visible. Routed through the same reachability
|
|
777
|
+
// flow as every other endpoint call.
|
|
778
|
+
const resumeResult = useCallback(async () => {
|
|
779
|
+
if (!endpoint || !isWeb()) return;
|
|
780
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/resume', { method: 'POST' }));
|
|
781
|
+
if (outcome.kind === 'unreachable') {
|
|
782
|
+
routeUnreachable();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
markReachable();
|
|
786
|
+
await fetchStatus();
|
|
787
|
+
}, [endpoint, routeUnreachable, markReachable, fetchStatus]);
|
|
416
788
|
|
|
417
789
|
// Polls `/update/status` at a plain interval while an update is in flight — design §5 never
|
|
418
790
|
// asks for push/websocket progress, only "the panel shows progress."
|
|
@@ -422,6 +794,19 @@ export function UpdatePanel({
|
|
|
422
794
|
return () => clearInterval(pollRef.current);
|
|
423
795
|
}, [phase, fetchStatus]);
|
|
424
796
|
|
|
797
|
+
// Minor finding, correction pass 5: offers a manual way out of `restarting` once it's held for
|
|
798
|
+
// longer than a real restart should take — the one case the bounded Metro-origin probe below
|
|
799
|
+
// cannot resolve on its own (the server never actually went down, so it never sees a failure).
|
|
800
|
+
const [restartTimedOut, setRestartTimedOut] = useState(false);
|
|
801
|
+
useEffect(() => {
|
|
802
|
+
if (phase !== 'restarting') {
|
|
803
|
+
setRestartTimedOut(false);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const timeout = setTimeout(() => setRestartTimedOut(true), RESTART_RELOAD_TIMEOUT_MS);
|
|
807
|
+
return () => clearTimeout(timeout);
|
|
808
|
+
}, [phase]);
|
|
809
|
+
|
|
425
810
|
// Restart handoff step 4 (design §5): once the server reports "restarting", Metro itself (not
|
|
426
811
|
// this endpoint — the endpoint's own process is about to exit) is polled on the *page's own
|
|
427
812
|
// origin* until it answers again, then the page reloads onto the new version. Errata 2b: reload
|
|
@@ -433,6 +818,9 @@ export function UpdatePanel({
|
|
|
433
818
|
const interval = setInterval(() => {
|
|
434
819
|
fetch(window.location.origin, { method: 'HEAD' })
|
|
435
820
|
.then((res) => {
|
|
821
|
+
// Important finding, Fable correction pass (I4): the catalog is a single-page app — a
|
|
822
|
+
// probe started before the user navigated away can still resolve after unmount.
|
|
823
|
+
if (cancelledRef.current) return;
|
|
436
824
|
if (res.ok && hadFailure) {
|
|
437
825
|
clearInterval(interval);
|
|
438
826
|
window.location.reload();
|
|
@@ -448,25 +836,31 @@ export function UpdatePanel({
|
|
|
448
836
|
const startUpdate = useCallback(async () => {
|
|
449
837
|
if (!endpoint) return;
|
|
450
838
|
setStarting(true);
|
|
839
|
+
// Critical finding, Fable correction pass (C2): `try`/`finally` so a rejection from
|
|
840
|
+
// `fetchPlan()`/`fetchStatus()` below can never leave **Update now** disabled on "Starting…"
|
|
841
|
+
// until a manual reload.
|
|
451
842
|
try {
|
|
452
|
-
|
|
843
|
+
// Important finding, Fable correction pass: a failed `POST /update` must not leave the panel
|
|
844
|
+
// stuck mid-click with no way forward — a genuinely unreachable endpoint now goes through the
|
|
845
|
+
// shared reachability flow instead of the offline plan-error text.
|
|
846
|
+
const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update', { method: 'POST' }));
|
|
847
|
+
if (outcome.kind === 'unreachable') {
|
|
848
|
+
routeUnreachable();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
453
851
|
// A non-202 (409 dirty/already-running, or 502 "couldn't prepare") means a file was dirtied
|
|
454
852
|
// — or the plan went stale — after this panel's own `ready` plan was fetched; re-fetching the
|
|
455
853
|
// plan surfaces the `dirty`/`offline` phase instead of silently staying on `ready` forever
|
|
456
854
|
// (Minor finding, Fable correction pass).
|
|
457
|
-
if (
|
|
855
|
+
if (outcome.response.status !== 202) {
|
|
458
856
|
await fetchPlan();
|
|
459
|
-
|
|
857
|
+
} else {
|
|
858
|
+
await fetchStatus();
|
|
460
859
|
}
|
|
461
|
-
await fetchStatus();
|
|
462
|
-
} catch {
|
|
463
|
-
// Important finding, Fable correction pass: same reasoning as `fetchPlan` above — a failed
|
|
464
|
-
// `POST /update` must not leave the panel stuck mid-click with no way forward.
|
|
465
|
-
setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
|
|
466
860
|
} finally {
|
|
467
861
|
setStarting(false);
|
|
468
862
|
}
|
|
469
|
-
}, [endpoint, fetchPlan, fetchStatus]);
|
|
863
|
+
}, [endpoint, fetchPlan, fetchStatus, routeUnreachable]);
|
|
470
864
|
|
|
471
865
|
const plan = planResult && !('error' in planResult) ? planResult : undefined;
|
|
472
866
|
|
|
@@ -477,11 +871,53 @@ export function UpdatePanel({
|
|
|
477
871
|
// once the footer link existed). `content` is computed per phase below, then wrapped once.
|
|
478
872
|
let content: React.ReactNode = null;
|
|
479
873
|
|
|
480
|
-
|
|
874
|
+
// Takes priority over every other phase below — it replaces whichever of the two UI modes
|
|
875
|
+
// (an update pending, or just the installed version) was about to render, since neither can be
|
|
876
|
+
// trusted while the endpoint itself is unreachable. Never shown mid-restart: that recovery is
|
|
877
|
+
// already owned by the dedicated restart-handoff effect above (see `fetchStatus`).
|
|
878
|
+
const reachabilityOverride = reachability && reachability.phase !== 'recovered' && phase !== 'updating' && phase !== 'restarting';
|
|
879
|
+
|
|
880
|
+
if (remoteOriginError) {
|
|
881
|
+
// Critical finding, Fable correction pass (C4): a page that can never use the endpoint gets
|
|
882
|
+
// no self-healing at all — just the preserved remote-origin/tunnel message, with nothing to
|
|
883
|
+
// retry (there is nothing a reload or a probe could fix here).
|
|
884
|
+
content = (
|
|
885
|
+
<>
|
|
886
|
+
{update ? (
|
|
887
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
888
|
+
) : versionStatus ? (
|
|
889
|
+
<View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
|
|
890
|
+
) : null}
|
|
891
|
+
<NoteBox variant="err">{remoteOriginLocalErrorMessage()}</NoteBox>
|
|
892
|
+
</>
|
|
893
|
+
);
|
|
894
|
+
} else if (reachabilityOverride && reachability) {
|
|
895
|
+
content = (
|
|
896
|
+
<ReachabilityOverride
|
|
897
|
+
reachability={reachability}
|
|
898
|
+
header={update ? (
|
|
899
|
+
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
900
|
+
) : versionStatus ? (
|
|
901
|
+
// Minor finding, Fable correction pass: without `versionStatus` there's no version to
|
|
902
|
+
// show at all — fall back to the note-only layout instead of an empty 2xl row.
|
|
903
|
+
<View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
|
|
904
|
+
) : null}
|
|
905
|
+
actionLabel={update ? 'Update now' : 'Check now'}
|
|
906
|
+
onReconnect={reconnect}
|
|
907
|
+
/>
|
|
908
|
+
);
|
|
909
|
+
} else if (!update) {
|
|
481
910
|
// Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
|
|
482
911
|
// reflects a just-finished update read from the (surviving) endpoint across the reload.
|
|
483
912
|
content = versionStatus ? (
|
|
484
|
-
<VersionDetails
|
|
913
|
+
<VersionDetails
|
|
914
|
+
status={versionStatus}
|
|
915
|
+
endpoint={endpoint}
|
|
916
|
+
onStatus={onVersionStatus ?? (() => {})}
|
|
917
|
+
onCheck={result ? dismissResult : undefined}
|
|
918
|
+
onUnreachable={markUnreachable}
|
|
919
|
+
onReachable={markReachable}
|
|
920
|
+
/>
|
|
485
921
|
) : (
|
|
486
922
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
487
923
|
);
|
|
@@ -583,11 +1019,18 @@ export function UpdatePanel({
|
|
|
583
1019
|
{' This page reloads by itself.'}
|
|
584
1020
|
</NoteBox>
|
|
585
1021
|
<Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
|
|
1022
|
+
{restartTimedOut && (
|
|
1023
|
+
<View style={styles.actions}>
|
|
1024
|
+
<CatalogButton label="Reload" variant="secondary" onPress={() => isWeb() && window.location.reload()} />
|
|
1025
|
+
</View>
|
|
1026
|
+
)}
|
|
586
1027
|
</>
|
|
587
1028
|
);
|
|
588
1029
|
|
|
589
1030
|
}
|
|
590
1031
|
|
|
1032
|
+
const recoveredNote = reachability?.phase === 'recovered' ? reachabilityNote('recovered') : undefined;
|
|
1033
|
+
|
|
591
1034
|
return (
|
|
592
1035
|
<View style={styles.wrap}>
|
|
593
1036
|
<Text style={styles.crumb}>{`${appName} / DS Viewer`}</Text>
|
|
@@ -601,23 +1044,32 @@ export function UpdatePanel({
|
|
|
601
1044
|
status={result}
|
|
602
1045
|
current={update?.current ?? versionStatus?.current ?? ''}
|
|
603
1046
|
latest={update?.latest}
|
|
604
|
-
|
|
1047
|
+
// Important finding, Fable correction pass (I3): disabled while unreachable (or on a
|
|
1048
|
+
// page that can never be) — otherwise the calm reachability note and a live, enabled
|
|
1049
|
+
// **Retry update** / **Finish migration** / **Rerun doctor** show together, for the
|
|
1050
|
+
// panel's most consequential buttons, contradicting decision 3.
|
|
1051
|
+
busy={noteBusy || starting || remoteOriginError || Boolean(reachability && reachability.phase !== 'recovered')}
|
|
605
1052
|
onAction={async (kind) => {
|
|
606
1053
|
if (!endpoint) return;
|
|
607
1054
|
setNoteBusy(true);
|
|
608
1055
|
try {
|
|
609
1056
|
if (kind === 'dismiss') await dismissResult();
|
|
610
1057
|
else if (kind === 'retry') await startUpdate();
|
|
611
|
-
else
|
|
612
|
-
await authedFetch(endpoint, '/update/resume', { method: 'POST' });
|
|
613
|
-
await fetchStatus();
|
|
614
|
-
}
|
|
1058
|
+
else await resumeResult();
|
|
615
1059
|
} finally {
|
|
616
1060
|
setNoteBusy(false);
|
|
617
1061
|
}
|
|
618
1062
|
}}
|
|
619
1063
|
/>
|
|
620
1064
|
)}
|
|
1065
|
+
{recoveredNote && (
|
|
1066
|
+
// Minor finding, Fable correction pass: reads `variant`/`spinner` from the same
|
|
1067
|
+
// `reachabilityNote('recovered')` the copy comes from, instead of hardcoding `variant="ok"`
|
|
1068
|
+
// separately, so the approved copy and its treatment have one source.
|
|
1069
|
+
<NoteBox variant={recoveredNote.variant} spinner={recoveredNote.spinner} live>
|
|
1070
|
+
{recoveredNote.text}
|
|
1071
|
+
</NoteBox>
|
|
1072
|
+
)}
|
|
621
1073
|
{content}
|
|
622
1074
|
</View>
|
|
623
1075
|
);
|
|
@@ -635,7 +1087,12 @@ const styles = StyleSheet.create({
|
|
|
635
1087
|
// The catalog's smallest control size (the major banner's close button), filled in the note's colour.
|
|
636
1088
|
resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
|
|
637
1089
|
resultButtonPressed: { opacity: 0.8 },
|
|
1090
|
+
// Minor finding, Fable correction pass #2: the same flat-grey treatment `CatalogButton.disabled`
|
|
1091
|
+
// uses, so "unavailable" doesn't read as merely "pressed" (the note's own full-colour fill at
|
|
1092
|
+
// reduced opacity).
|
|
1093
|
+
resultButtonDisabled: { backgroundColor: CATALOG_COLOR.controlDisabled },
|
|
638
1094
|
resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
|
|
1095
|
+
resultButtonTextDisabled: { color: CATALOG_COLOR.controlDisabledText },
|
|
639
1096
|
resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
|
|
640
1097
|
crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
641
1098
|
headingTarget: { marginTop: 4, marginBottom: 6 },
|
|
@@ -681,7 +1138,8 @@ const styles = StyleSheet.create({
|
|
|
681
1138
|
fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
|
|
682
1139
|
actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
|
|
683
1140
|
noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
|
|
684
|
-
|
|
1141
|
+
noteBoxRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
|
|
1142
|
+
noteBoxText: { fontSize: CATALOG_TYPE.md, flexShrink: 1 },
|
|
685
1143
|
noteBoxBold: { fontWeight: '700' },
|
|
686
1144
|
steps: { marginTop: 12 },
|
|
687
1145
|
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 6 },
|