| author | |
| committer | |
| log | ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 |
| tree | 90c0a9a59c5f6884169e36633504ff212c8589ba |
| parent | 3143335fc3a568cbbb5174764961889272135cb9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: PR Batch Tester <agent@local.test>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>3 files changed, 201 insertions(+), 25 deletions(-)
apps/server/src/provider/providerMaintenanceRunner.test.ts+64-2| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { ServerProviderUpdateError } from "@t3tools/contracts"; | 8 | import { ServerProviderUpdateError } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 12 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; |
| 15 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 16 | import * as Sink from "effect/Sink"; | 18 | import * as Sink from "effect/Sink"; |
| 17 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | ||
| 18 | import { HttpClient, HttpClientResponse } from "effect/http"; | 21 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 19 | import { ChildProcessSpawner } from "effect/process"; | 22 | import { ChildProcessSpawner } from "effect/process"; |
| 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 23 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { |
| 259 | ]); | 262 | ]); |
| 260 | assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | 263 | assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); |
| 261 | assert.deepStrictEqual( | 264 | assert.deepStrictEqual( |
| 262 | (yield* Ref.get(updateStatesRef)).map((state) => state.status), | 265 | (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), |
| 263 | ["queued", "running", "succeeded"], | 266 | [ |
| 267 | ["queued", "Waiting for another provider update to finish."], | ||
| 268 | ["running", "Checking for the latest version"], | ||
| 269 | ["running", "Running native-agent update"], | ||
| 270 | ["running", "Verifying the installed version"], | ||
| 271 | ["succeeded", "Provider updated."], | ||
| 272 | ], | ||
| 264 | ); | 273 | ); |
| 265 | }).pipe( | 274 | }).pipe( |
| 266 | Effect.provide( | 275 | Effect.provide( |
| ... | @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { |
| 406 | ); | 415 | ); |
| 407 | }); | 416 | }); |
| 408 | 417 | ||
| 418 | it.effect("reports the installer's latest output line while the update runs", () => { | ||
| 419 | const exited = Deferred.makeUnsafe<ChildProcessSpawner.ExitCode>(); | ||
| 420 | return Effect.gen(function* () { | ||
| 421 | const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); | ||
| 422 | const updater = yield* makeTestRunner(registry); | ||
| 423 | const runningMessages = Ref.get(updateStatesRef).pipe( | ||
| 424 | Effect.map((states) => | ||
| 425 | states.filter((state) => state.status === "running").map((state) => state.message), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | |||
| 429 | const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); | ||
| 430 | yield* TestClock.adjust(Duration.seconds(1)); | ||
| 431 | assert.deepStrictEqual(yield* runningMessages, [ | ||
| 432 | "Checking for the latest version", | ||
| 433 | "Running native-agent update", | ||
| 434 | "80%", | ||
| 435 | ]); | ||
| 436 | |||
| 437 | yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); | ||
| 438 | const result = yield* Fiber.join(fiber); | ||
| 439 | assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | ||
| 440 | }).pipe( | ||
| 441 | Effect.provide( | ||
| 442 | Layer.mergeAll( | ||
| 443 | layerNonWindowsPlatform, | ||
| 444 | layerLatestVersionHttpClient("0.0.0"), | ||
| 445 | layerMockSpawner(() => ({ | ||
| 446 | stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", | ||
| 447 | exitCode: Deferred.await(exited), | ||
| 448 | })), | ||
| 449 | ), | ||
| 450 | ), | ||
| 451 | ); | ||
| 452 | }); | ||
| 453 | |||
| 454 | it("splits installer output into clean status lines", () => { | ||
| 455 | assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { | ||
| 456 | lines: ["Downloading"], | ||
| 457 | partialLine: " 5", | ||
| 458 | }); | ||
| 459 | assert.strictEqual( | ||
| 460 | ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"), | ||
| 461 | "==> Pouring", | ||
| 462 | ); | ||
| 463 | assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null); | ||
| 464 | assert.strictEqual( | ||
| 465 | ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length, | ||
| 466 | 4_096, | ||
| 467 | ); | ||
| 468 | assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); | ||
| 469 | }); | ||
| 470 | |||
| 409 | it.effect("aborts without running when the installation changed since the advisory", () => { | 471 | it.effect("aborts without running when the installation changed since the advisory", () => { |
| 410 | const calls: Array<string> = []; | 472 | const calls: Array<string> = []; |
| 411 | return Effect.gen(function* () { | 473 | return Effect.gen(function* () { |
apps/server/src/provider/providerMaintenanceRunner.ts+89-12| ... | @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; | ... | @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 19 | import * as Ref from "effect/Ref"; | 19 | import * as Ref from "effect/Ref"; |
| 20 | import * as Schedule from "effect/Schedule"; | ||
| 20 | import * as Schema from "effect/Schema"; | 21 | import * as Schema from "effect/Schema"; |
| 22 | import * as Stream from "effect/Stream"; | ||
| 21 | import { HttpClient } from "effect/http"; | 23 | import { HttpClient } from "effect/http"; |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | 24 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 25 | import * as NodeUtil from "node:util"; | ||
| 23 | 26 | ||
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | 27 | import * as ModelManifest from "./ModelManifest.ts"; |
| 25 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; | 28 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; |
| ... | @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); | ... | @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); |
| 38 | 41 | ||
| 39 | const UPDATE_TIMEOUT_MS = 5 * 60_000; | 42 | const UPDATE_TIMEOUT_MS = 5 * 60_000; |
| 40 | const UPDATE_OUTPUT_MAX_BYTES = 10_000; | 43 | const UPDATE_OUTPUT_MAX_BYTES = 10_000; |
| 44 | // Every progress publish resends the provider list to each client, so the | ||
| 45 | // installer's output is sampled rather than forwarded line by line. | ||
| 46 | const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); | ||
| 47 | const UPDATE_PROGRESS_MAX_LENGTH = 200; | ||
| 48 | // An installer may write for minutes without a line ending; only its tail matters. | ||
| 49 | const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; | ||
| 41 | 50 | ||
| 42 | export interface ProviderMaintenanceCommandResult { | 51 | export interface ProviderMaintenanceCommandResult { |
| 43 | readonly stdout: string; | 52 | readonly stdout: string; |
| ... | @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR | ... | @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 83 | readonly command: string; | 92 | readonly command: string; |
| 84 | readonly args: ReadonlyArray<string>; | 93 | readonly args: ReadonlyArray<string>; |
| 85 | readonly env?: NodeJS.ProcessEnv; | 94 | readonly env?: NodeJS.ProcessEnv; |
| 95 | readonly onProgress?: (line: string) => Effect.Effect<void>; | ||
| 86 | }) { | 96 | }) { |
| 87 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( | 97 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( |
| 88 | function* () { | 98 | function* () { |
| ... | @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR | ... | @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 110 | ); | 120 | ); |
| 111 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | 121 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); |
| 112 | 122 | ||
| 123 | // Holds the newest output line not yet reported; the sampler takes it. | ||
| 124 | const pendingProgress = yield* Ref.make<string | null>(null); | ||
| 125 | const onProgress = input.onProgress; | ||
| 126 | if (onProgress) { | ||
| 127 | yield* Ref.getAndSet(pendingProgress, null).pipe( | ||
| 128 | Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))), | ||
| 129 | Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)), | ||
| 130 | Effect.forkScoped, | ||
| 131 | ); | ||
| 132 | } | ||
| 133 | const trackProgress = <E>(stream: Stream.Stream<Uint8Array, E>) => { | ||
| 134 | if (!onProgress) { | ||
| 135 | return stream; | ||
| 136 | } | ||
| 137 | const decoder = new TextDecoder(); | ||
| 138 | let partialLine = ""; | ||
| 139 | return stream.pipe( | ||
| 140 | Stream.tap((chunk) => { | ||
| 141 | const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true })); | ||
| 142 | partialLine = split.partialLine; | ||
| 143 | // A progress bar that redraws with a leading `\r` keeps its newest | ||
| 144 | // frame unterminated, so that frame is the latest status. | ||
| 145 | const line = | ||
| 146 | toProgressLine(split.partialLine) ?? | ||
| 147 | split.lines.map(toProgressLine).findLast((value) => value !== null); | ||
| 148 | return line ? Ref.set(pendingProgress, line) : Effect.void; | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | }; | ||
| 152 | |||
| 113 | const [stdout, stderr, exitCode] = yield* Effect.all( | 153 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 114 | [ | 154 | [ |
| 115 | collectUint8StreamText({ | 155 | collectUint8StreamText({ |
| 116 | stream: child.stdout, | 156 | stream: trackProgress(child.stdout), |
| 117 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, | 157 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, |
| 118 | }), | 158 | }), |
| 119 | collectUint8StreamText({ | 159 | collectUint8StreamText({ |
| 120 | stream: child.stderr, | 160 | stream: trackProgress(child.stderr), |
| 121 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, | 161 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, |
| 122 | }), | 162 | }), |
| 123 | child.exitCode, | 163 | child.exitCode, |
| ... | @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR | ... | @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 165 | }, | 205 | }, |
| 166 | ); | 206 | ); |
| 167 | 207 | ||
| 208 | /** | ||
| 209 | * Split a chunk of installer output into complete lines. `\r` ends a line too, | ||
| 210 | * so a redrawn progress bar reports its latest frame. | ||
| 211 | */ | ||
| 212 | export function splitOutputLines( | ||
| 213 | partialLine: string, | ||
| 214 | text: string, | ||
| 215 | ): { readonly lines: ReadonlyArray<string>; readonly partialLine: string } { | ||
| 216 | const parts = (partialLine + text).split(/\r\n|\r|\n/); | ||
| 217 | return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; | ||
| 218 | } | ||
| 219 | |||
| 220 | /** Turn one raw output line into a short status message, or null if it has no text. */ | ||
| 221 | export function toProgressLine(line: string): string | null { | ||
| 222 | const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); | ||
| 223 | if (text.length === 0) { | ||
| 224 | return null; | ||
| 225 | } | ||
| 226 | return text.length <= UPDATE_PROGRESS_MAX_LENGTH | ||
| 227 | ? text | ||
| 228 | : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`; | ||
| 229 | } | ||
| 230 | |||
| 231 | /** `claude update` reads better in a status line than the full executable path. */ | ||
| 232 | function describeCommand(command: ProviderMaintenanceCommandAction): string { | ||
| 233 | const executable = command.executable.split(/[\\/]/).pop() || command.executable; | ||
| 234 | return [executable, ...command.args].join(" "); | ||
| 235 | } | ||
| 236 | |||
| 168 | function trimNullable(value: string): string | null { | 237 | function trimNullable(value: string): string | null { |
| 169 | const trimmed = value.trim(); | 238 | const trimmed = value.trim(); |
| 170 | return trimmed.length > 0 ? trimmed : null; | 239 | return trimmed.length > 0 ? trimmed : null; |
| ... | @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 223 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 292 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 224 | const httpClient = yield* HttpClient.HttpClient; | 293 | const httpClient = yield* HttpClient.HttpClient; |
| 225 | const versionCache = yield* ProviderVersionCache; | 294 | const versionCache = yield* ProviderVersionCache; |
| 226 | const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => | 295 | const runMaintenanceCommand = ( |
| 296 | update: ProviderMaintenanceCommandAction, | ||
| 297 | onProgress: (line: string) => Effect.Effect<void>, | ||
| 298 | ) => | ||
| 227 | runProviderMaintenanceCommandWithSpawner({ | 299 | runProviderMaintenanceCommandWithSpawner({ |
| 228 | spawner, | 300 | spawner, |
| 229 | command: update.executable, | 301 | command: update.executable, |
| 230 | args: update.args, | 302 | args: update.args, |
| 303 | onProgress, | ||
| 231 | ...(update.env ? { env: update.env } : {}), | 304 | ...(update.env ? { env: update.env } : {}), |
| 232 | }); | 305 | }); |
| 233 | const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ | 306 | const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ |
| ... | @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 355 | function* () { | 428 | function* () { |
| 356 | const startedAt = yield* nowIso; | 429 | const startedAt = yield* nowIso; |
| 357 | yield* Ref.set(startedAtRef, startedAt); | 430 | yield* Ref.set(startedAtRef, startedAt); |
| 358 | yield* setUpdateState( | 431 | const setRunningMessage = (message: string) => |
| 359 | makeUpdateState({ | 432 | setUpdateState( |
| 360 | status: "running", | 433 | makeUpdateState({ |
| 361 | startedAt, | 434 | status: "running", |
| 362 | finishedAt: null, | 435 | startedAt, |
| 363 | message: "Updating provider.", | 436 | finishedAt: null, |
| 364 | }), | 437 | message, |
| 365 | ); | 438 | }), |
| 439 | ).pipe(Effect.asVoid); | ||
| 440 | yield* setRunningMessage("Checking for the latest version"); | ||
| 366 | 441 | ||
| 367 | // The cached capabilities chose the lock; re-derive ownership | 442 | // The cached capabilities chose the lock; re-derive ownership |
| 368 | // now so the command that runs matches the executable as it is | 443 | // now so the command that runs matches the executable as it is |
| ... | @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 420 | }), | 495 | }), |
| 421 | ); | 496 | ); |
| 422 | } | 497 | } |
| 423 | const result = yield* runMaintenanceCommand(command); | 498 | yield* setRunningMessage(`Running ${describeCommand(command)}`); |
| 499 | const result = yield* runMaintenanceCommand(command, setRunningMessage); | ||
| 424 | const finishedAt = yield* nowIso; | 500 | const finishedAt = yield* nowIso; |
| 425 | if (result.timedOut || result.exitCode !== 0) { | 501 | if (result.timedOut || result.exitCode !== 0) { |
| 426 | return yield* finish( | 502 | return yield* finish( |
| ... | @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 434 | ); | 510 | ); |
| 435 | } | 511 | } |
| 436 | 512 | ||
| 513 | yield* setRunningMessage("Verifying the installed version"); | ||
| 437 | // Homebrew's "latest" moves once the upgrade lands; read it again. | 514 | // Homebrew's "latest" moves once the upgrade lands; read it again. |
| 438 | const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( | 515 | const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( |
| 439 | instanceId, | 516 | instanceId, |
apps/web/src/components/settings/ProviderInstanceCard.tsx+48-11| ... | @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ | ... | @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ |
| 590 | enabled, | 590 | enabled, |
| 591 | ); | 591 | ); |
| 592 | const updateCommand = versionAdvisory?.updateCommand ?? null; | 592 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 593 | const updateState = liveProvider?.updateState; | ||
| 594 | // The server reports each update step. `isUpdating` also covers the moment | ||
| 595 | // between the click and the server's first report. | ||
| 596 | const updateProgress = isUpdating | ||
| 597 | ? ((updateState?.status === "queued" || updateState?.status === "running" | ||
| 598 | ? updateState.message | ||
| 599 | : null) ?? "Starting update") | ||
| 600 | : null; | ||
| 601 | const updateProblem = | ||
| 602 | !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") | ||
| 603 | ? updateState.message | ||
| 604 | : null; | ||
| 593 | const hasCompatibilityWarning = | 605 | const hasCompatibilityWarning = |
| 594 | compatibility !== undefined && | 606 | compatibility !== undefined && |
| 595 | compatibility.status !== "supported" && | 607 | compatibility.status !== "supported" && |
| ... | @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ | ... | @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ |
| 791 | size={mode === "list" ? "icon-micro" : "icon-xs"} | 803 | size={mode === "list" ? "icon-micro" : "icon-xs"} |
| 792 | variant="ghost-muted" | 804 | variant="ghost-muted" |
| 793 | className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} | 805 | className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} |
| 794 | aria-label={`${versionAdvisory.title} — view details`} | 806 | aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`} |
| 795 | > | 807 | > |
| 796 | <VersionAdvisoryIcon | 808 | {updateProgress ? ( |
| 797 | className={cn( | 809 | <Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} /> |
| 798 | mode === "list" && "size-3.5", | 810 | ) : ( |
| 799 | hasCompatibilityWarning && "text-warning", | 811 | <VersionAdvisoryIcon |
| 800 | )} | 812 | className={cn( |
| 801 | /> | 813 | mode === "list" && "size-3.5", |
| 814 | hasCompatibilityWarning && "text-warning", | ||
| 815 | )} | ||
| 816 | /> | ||
| 817 | )} | ||
| 802 | </Button> | 818 | </Button> |
| 803 | } | 819 | } |
| 804 | /> | 820 | /> |
| 805 | } | 821 | } |
| 806 | /> | 822 | /> |
| 807 | <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup> | 823 | <TooltipPopup side="top"> |
| 824 | {updateProgress ? "Updating" : versionAdvisory.title} | ||
| 825 | </TooltipPopup> | ||
| 808 | </Tooltip> | 826 | </Tooltip> |
| 809 | <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}> | 827 | <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}> |
| 810 | <div className="grid min-w-0 gap-3"> | 828 | <div className="grid min-w-0 gap-3"> |
| ... | @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ | ... | @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ |
| 838 | : "Update now"} | 856 | : "Update now"} |
| 839 | </Button> | 857 | </Button> |
| 840 | ) : null} | 858 | ) : null} |
| 859 | {updateProgress || updateProblem ? ( | ||
| 860 | <p | ||
| 861 | aria-live="polite" | ||
| 862 | className={cn( | ||
| 863 | "text-xs leading-snug [overflow-wrap:anywhere]", | ||
| 864 | updateProblem ? "text-warning" : "text-muted-foreground", | ||
| 865 | )} | ||
| 866 | > | ||
| 867 | {updateProgress ?? updateProblem} | ||
| 868 | </p> | ||
| 869 | ) : null} | ||
| 841 | {onRunVersionAction && updateCommand ? ( | 870 | {onRunVersionAction && updateCommand ? ( |
| 842 | <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground"> | 871 | <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 843 | <span aria-hidden className="h-px flex-1 bg-border" /> | 872 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| ... | @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ | ... | @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ |
| 908 | {versionAdvisoryNode} | 937 | {versionAdvisoryNode} |
| 909 | </span> | 938 | </span> |
| 910 | <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80"> | 939 | <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80"> |
| 911 | {statusDotNode ? ( | 940 | {/* The dot describes provider health, not the update in progress. */} |
| 941 | {statusDotNode && !updateProgress ? ( | ||
| 912 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> | 942 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> |
| 913 | ) : null} | 943 | ) : null} |
| 914 | <ProviderStatusDiagnostic detail={statusDiagnostic}> | 944 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 915 | <span | 945 | <span |
| 916 | tabIndex={statusDiagnostic ? 0 : undefined} | 946 | tabIndex={statusDiagnostic ? 0 : undefined} |
| 947 | aria-live="polite" | ||
| 917 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" | 948 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" |
| 918 | > | 949 | > |
| 919 | {summary.headline} | 950 | {updateProgress ? ( |
| 920 | {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} | 951 | `Updating · ${updateProgress}` |
| 952 | ) : ( | ||
| 953 | <> | ||
| 954 | {summary.headline} | ||
| 955 | {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} | ||
| 956 | </> | ||
| 957 | )} | ||
| 921 | </span> | 958 | </span> |
| 922 | </ProviderStatusDiagnostic> | 959 | </ProviderStatusDiagnostic> |
| 923 | </span> | 960 | </span> |