| author | |
| committer | |
| log | 61b25b88d7922a480790066341867f3f8ae42fcb |
| tree | 09f9c135c19b5c8ce437e14bec8ed01da6f8bfe7 |
| parent | 3b836f9942143cdcf1ad7f121e78b59476585781 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 222 insertions(+), 30 deletions(-)
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+32-7| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device"; | 1 | import { |
| 2 | deviceToolVersionLabels, | ||
| 3 | deviceToolUpdateOwnership, | ||
| 4 | deviceToolUpdatePolicy, | ||
| 5 | } from "@t3tools/client-runtime/state/device"; | ||
| 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | 7 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| ... | @@ -131,6 +135,18 @@ function DevicePreviewScreen({ | ... | @@ -131,6 +135,18 @@ function DevicePreviewScreen({ |
| 131 | void retryHost({ environmentId, input: { retryHostId: host.id } }); | 135 | void retryHost({ environmentId, input: { retryHostId: host.id } }); |
| 132 | }, | 136 | }, |
| 133 | })) ?? []), | 137 | })) ?? []), |
| 138 | ...(state.data?.supportsToolInspection | ||
| 139 | ? [ | ||
| 140 | { | ||
| 141 | id: "check-device-tools", | ||
| 142 | title: "Check device tool versions", | ||
| 143 | icon: "arrow.clockwise" as const, | ||
| 144 | onPress: () => { | ||
| 145 | void retryHost({ environmentId, input: { inspectOnly: true } }); | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | ] | ||
| 149 | : []), | ||
| 134 | { | 150 | { |
| 135 | id: "device-tools", | 151 | id: "device-tools", |
| 136 | title: "Device tool versions", | 152 | title: "Device tool versions", |
| ... | @@ -138,17 +154,26 @@ function DevicePreviewScreen({ | ... | @@ -138,17 +154,26 @@ function DevicePreviewScreen({ |
| 138 | onPress: () => | 154 | onPress: () => |
| 139 | Alert.alert( | 155 | Alert.alert( |
| 140 | "Device tool versions", | 156 | "Device tool versions", |
| 141 | deviceToolVersionLabels( | 157 | deviceToolUpdateOwnership + |
| 142 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | 158 | "\n\n" + |
| 143 | ).join("\n") + | 159 | deviceToolUpdatePolicy( |
| 160 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | ||
| 161 | ) + | ||
| 162 | "\n\n" + | ||
| 163 | deviceToolVersionLabels( | ||
| 164 | state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, | ||
| 165 | ).join("\n") + | ||
| 144 | "\n" + | 166 | "\n" + |
| 145 | (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""), | 167 | (state.data?.hosts.find((host) => host.id === preview?.session.hostId) |
| 168 | ?.toolInspectionError ?? | ||
| 169 | state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? | ||
| 170 | ""), | ||
| 146 | ), | 171 | ), |
| 147 | }, | 172 | }, |
| 148 | { | 173 | { |
| 149 | id: "reload", | 174 | id: "reload", |
| 150 | title: "Reload stream", | 175 | title: "Reload stream", |
| 151 | icon: "arrow.clockwise", | 176 | icon: "arrow.clockwise" as const, |
| 152 | disabled: !preview || shuttingDown, | 177 | disabled: !preview || shuttingDown, |
| 153 | onPress: () => { | 178 | onPress: () => { |
| 154 | setInputConnected(false); | 179 | setInputConnected(false); |
| ... | @@ -178,7 +203,7 @@ function DevicePreviewScreen({ | ... | @@ -178,7 +203,7 @@ function DevicePreviewScreen({ |
| 178 | { | 203 | { |
| 179 | id: "rotate", | 204 | id: "rotate", |
| 180 | title: "Rotate device", | 205 | title: "Rotate device", |
| 181 | icon: "arrow.clockwise", | 206 | icon: "arrow.clockwise" as const, |
| 182 | disabled: !inputConnected, | 207 | disabled: !inputConnected, |
| 183 | onPress: () => streamRef.current?.rotate(), | 208 | onPress: () => streamRef.current?.rotate(), |
| 184 | }, | 209 | }, |
apps/server/src/device/DeviceHost.ts+1| ... | @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< | ... | @@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service< |
| 80 | { | 80 | { |
| 81 | readonly id: DeviceHostId; | 81 | readonly id: DeviceHostId; |
| 82 | readonly summary: Effect.Effect<DeviceHostSummary>; | 82 | readonly summary: Effect.Effect<DeviceHostSummary>; |
| 83 | readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>; | ||
| 83 | readonly platformAvailability: ( | 84 | readonly platformAvailability: ( |
| 84 | platform: DevicePlatform, | 85 | platform: DevicePlatform, |
| 85 | ) => Effect.Effect<DevicePlatformAvailability>; | 86 | ) => Effect.Effect<DevicePlatformAvailability>; |
apps/server/src/device/DeviceService.test.ts+37| ... | @@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* ( |
| 67 | bootError?: string, | 67 | bootError?: string, |
| 68 | failListAfterShutdown = false, | 68 | failListAfterShutdown = false, |
| 69 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, | 69 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, |
| 70 | inspectError = false, | ||
| 70 | ) { | 71 | ) { |
| 71 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); | 72 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 72 | const starts: string[] = []; | 73 | const starts: string[] = []; |
| ... | @@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* ( | ... | @@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* ( |
| 82 | run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), | 83 | run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), |
| 83 | }; | 84 | }; |
| 84 | const host: DeviceHost.DeviceHost["Service"] = { | 85 | const host: DeviceHost.DeviceHost["Service"] = { |
| 86 | ...(inspectError | ||
| 87 | ? { | ||
| 88 | inspect: Effect.fail( | ||
| 89 | new DeviceHost.DeviceHostError({ | ||
| 90 | hostId: LOCAL_DEVICE_HOST_ID, | ||
| 91 | step: "probe", | ||
| 92 | cause: new Error("offline"), | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | } | ||
| 96 | : {}), | ||
| 85 | id: LOCAL_DEVICE_HOST_ID, | 97 | id: LOCAL_DEVICE_HOST_ID, |
| 86 | summary: Effect.succeed({ | 98 | summary: Effect.succeed({ |
| 87 | id: LOCAL_DEVICE_HOST_ID, | 99 | id: LOCAL_DEVICE_HOST_ID, |
| ... | @@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos | ... | @@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos |
| 623 | }); | 635 | }); |
| 624 | }).pipe(Effect.scoped), | 636 | }).pipe(Effect.scoped), |
| 625 | ); | 637 | ); |
| 638 | |||
| 639 | it.effect("version discovery does not grant consent or start device tools", () => | ||
| 640 | Effect.gen(function* () { | ||
| 641 | const { service, starts, agentStarts, requests } = yield* fixture(); | ||
| 642 | const state = yield* service.inspect; | ||
| 643 | expect(state.supportsToolInspection).toBe(true); | ||
| 644 | expect(state.hostStatus).toBe("disabled"); | ||
| 645 | expect(state.hosts).toHaveLength(1); | ||
| 646 | expect(starts).toEqual([]); | ||
| 647 | expect(agentStarts).toEqual([]); | ||
| 648 | expect(requests).toEqual([]); | ||
| 649 | }).pipe(Effect.scoped), | ||
| 650 | ); | ||
| 651 | |||
| 652 | it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => | ||
| 653 | Effect.gen(function* () { | ||
| 654 | const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); | ||
| 655 | const state = yield* service.inspect; | ||
| 656 | expect(state.supportsToolInspection).toBe(true); | ||
| 657 | expect(state.hostStatus).toBe("disabled"); | ||
| 658 | expect(state.hosts[0]?.hubInstalled).toBe(true); | ||
| 659 | expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); | ||
| 660 | expect(starts).toEqual([]); | ||
| 661 | }).pipe(Effect.scoped), | ||
| 662 | ); |
apps/server/src/device/DeviceService.ts+35| ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< | ... | @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< |
| 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; | 126 | ) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 127 | /** Refreshes devices only after device support has been enabled. */ | 127 | /** Refreshes devices only after device support has been enabled. */ |
| 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; | 128 | readonly list: Effect.Effect<DeviceServiceState, DeviceError>; |
| 129 | readonly inspect: Effect.Effect<DeviceServiceState>; | ||
| 129 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; | 130 | readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>; |
| 130 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; | 131 | readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>; |
| 131 | readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>; | 132 | readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>; |
| ... | @@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 204 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ | 205 | const stateRef = yield* SynchronizedRef.make<ServiceState>({ |
| 205 | state: { | 206 | state: { |
| 206 | supportsHostRetry: true, | 207 | supportsHostRetry: true, |
| 208 | supportsToolInspection: true, | ||
| 207 | hosts: initialHosts, | 209 | hosts: initialHosts, |
| 208 | hostStatus: initialSettings.enabled ? "idle" : "disabled", | 210 | hostStatus: initialSettings.enabled ? "idle" : "disabled", |
| 209 | hostStatuses: {}, | 211 | hostStatuses: {}, |
| ... | @@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 465 | return (yield* SynchronizedRef.get(stateRef)).state; | 467 | return (yield* SynchronizedRef.get(stateRef)).state; |
| 466 | }).pipe(Effect.withSpan("DeviceService.list")); | 468 | }).pipe(Effect.withSpan("DeviceService.list")); |
| 467 | 469 | ||
| 470 | const inspect = Effect.gen(function* () { | ||
| 471 | yield* Effect.forEach( | ||
| 472 | hosts.values(), | ||
| 473 | (host) => | ||
| 474 | Effect.gen(function* () { | ||
| 475 | const result = yield* (host.inspect ?? host.summary).pipe(Effect.result); | ||
| 476 | if (hosts.get(host.id) !== host) return; | ||
| 477 | if (result._tag === "Failure") { | ||
| 478 | yield* publish((state) => ({ | ||
| 479 | ...state, | ||
| 480 | hosts: state.hosts.map((value) => | ||
| 481 | value.id === host.id | ||
| 482 | ? { | ||
| 483 | ...value, | ||
| 484 | toolInspectionError: | ||
| 485 | "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.", | ||
| 486 | } | ||
| 487 | : value, | ||
| 488 | ), | ||
| 489 | })); | ||
| 490 | return; | ||
| 491 | } | ||
| 492 | yield* publish((state) => ({ | ||
| 493 | ...state, | ||
| 494 | hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)), | ||
| 495 | })); | ||
| 496 | }), | ||
| 497 | { concurrency: 4 }, | ||
| 498 | ); | ||
| 499 | return (yield* SynchronizedRef.get(stateRef)).state; | ||
| 500 | }); | ||
| 501 | |||
| 468 | const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( | 502 | const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( |
| 469 | function* (hostId) { | 503 | function* (hostId) { |
| 470 | yield* resolveHost(hostId); | 504 | yield* resolveHost(hostId); |
| ... | @@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* | ... | @@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 866 | ...DeviceService.of({ | 900 | ...DeviceService.of({ |
| 867 | testHost, | 901 | testHost, |
| 868 | retryHost, | 902 | retryHost, |
| 903 | inspect, | ||
| 869 | agentCli: Effect.fail( | 904 | agentCli: Effect.fail( |
| 870 | new DeviceHostUnavailableError({ | 905 | new DeviceHostUnavailableError({ |
| 871 | hostId: LOCAL_DEVICE_HOST_ID, | 906 | hostId: LOCAL_DEVICE_HOST_ID, |
apps/server/src/device/SshDeviceHost.ts+9-2| ... | @@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( | ... | @@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 371 | Effect.gen(function* () { | 371 | Effect.gen(function* () { |
| 372 | stopped = false; | 372 | stopped = false; |
| 373 | if (ready) return ready; | 373 | if (ready) return ready; |
| 374 | summary = yield* provide(probe(config)); | 374 | summary = yield* provide(probe(config, owner)); |
| 375 | yield* onPhase( | 375 | yield* onPhase( |
| 376 | summary.hubInstalled ? "starting" : "installing", | 376 | summary.hubInstalled ? "starting" : "installing", |
| 377 | summary.hubInstalled | 377 | summary.hubInstalled |
| ... | @@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( | ... | @@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 424 | return { | 424 | return { |
| 425 | id: config.id, | 425 | id: config.id, |
| 426 | summary: Effect.sync(() => summary), | 426 | summary: Effect.sync(() => summary), |
| 427 | inspect: provide(probe(config, owner)).pipe( | ||
| 428 | Effect.tap((value) => | ||
| 429 | Effect.sync(() => { | ||
| 430 | summary = value; | ||
| 431 | }), | ||
| 432 | ), | ||
| 433 | ), | ||
| 427 | current: Effect.sync(() => ready), | 434 | current: Effect.sync(() => ready), |
| 428 | ensureReady, | 435 | ensureReady, |
| 429 | ensureAgentReady: (onPhase) => | 436 | ensureAgentReady: (onPhase) => |
| ... | @@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( | ... | @@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( |
| 452 | stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), | 459 | stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), |
| 453 | stop, | 460 | stop, |
| 454 | platformAvailability: (platform) => | 461 | platformAvailability: (platform) => |
| 455 | provide(probe(config)).pipe( | 462 | provide(probe(config, owner)).pipe( |
| 456 | Effect.map((value) => { | 463 | Effect.map((value) => { |
| 457 | summary = { ...summary, platforms: value.platforms }; | 464 | summary = { ...summary, platforms: value.platforms }; |
| 458 | return value.platforms.find((p) => p.platform === platform)!; | 465 | return value.platforms.find((p) => p.platform === platform)!; |
apps/server/src/ws.ts+8-4| ... | @@ -3456,10 +3456,14 @@ const makeWsRpcLayer = ( | ... | @@ -3456,10 +3456,14 @@ const makeWsRpcLayer = ( |
| 3456 | [WS_METHODS.deviceList]: (input) => | 3456 | [WS_METHODS.deviceList]: (input) => |
| 3457 | observeRpcEffect( | 3457 | observeRpcEffect( |
| 3458 | WS_METHODS.deviceList, | 3458 | WS_METHODS.deviceList, |
| 3459 | authorizeEffect( | 3459 | input.inspectOnly |
| 3460 | requiredScopeForDeviceList(input), | 3460 | ? deviceService.inspect |
| 3461 | input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list, | 3461 | : authorizeEffect( |
| 3462 | ), | 3462 | requiredScopeForDeviceList(input), |
| 3463 | input.retryHostId | ||
| 3464 | ? deviceService.retryHost(input.retryHostId) | ||
| 3465 | : deviceService.list, | ||
| 3466 | ), | ||
| 3463 | { | 3467 | { |
| 3464 | "rpc.aggregate": "device", | 3468 | "rpc.aggregate": "device", |
| 3465 | }, | 3469 | }, |
apps/web/src/components/device/DeviceToolVersions.tsx+19-1| ... | @@ -1,12 +1,19 @@ | ... | @@ -1,12 +1,19 @@ |
| 1 | import type { ReactNode } from "react"; | ||
| 1 | import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; | 2 | import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts"; |
| 2 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; | 3 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover"; |
| 3 | 4 | ||
| 4 | export function DeviceToolVersions({ | 5 | export function DeviceToolVersions({ |
| 5 | tools, | 6 | tools, |
| 7 | action, | ||
| 6 | kind, | 8 | kind, |
| 9 | owner, | ||
| 10 | error, | ||
| 7 | }: { | 11 | }: { |
| 8 | tools: ToolVersions | undefined; | 12 | tools: ToolVersions | undefined; |
| 13 | action?: ReactNode; | ||
| 9 | kind?: keyof ToolVersions; | 14 | kind?: keyof ToolVersions; |
| 15 | owner?: string | undefined; | ||
| 16 | error?: string | undefined; | ||
| 10 | }) { | 17 | }) { |
| 11 | const selected = kind ? tools?.[kind] : undefined; | 18 | const selected = kind ? tools?.[kind] : undefined; |
| 12 | const version = | 19 | const version = |
| ... | @@ -33,7 +40,9 @@ export function DeviceToolVersions({ | ... | @@ -33,7 +40,9 @@ export function DeviceToolVersions({ |
| 33 | : selected | 40 | : selected |
| 34 | ? "Not installed" | 41 | ? "Not installed" |
| 35 | : "Version unknown" | 42 | : "Version unknown" |
| 36 | : "Versions"} | 43 | : error |
| 44 | ? "Versions unavailable" | ||
| 45 | : "Versions"} | ||
| 37 | </PopoverTrigger> | 46 | </PopoverTrigger> |
| 38 | <PopoverPopup align="end" className="w-80"> | 47 | <PopoverPopup align="end" className="w-80"> |
| 39 | <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle> | 48 | <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle> |
| ... | @@ -65,6 +74,15 @@ export function DeviceToolVersions({ | ... | @@ -65,6 +74,15 @@ export function DeviceToolVersions({ |
| 65 | ) : ( | 74 | ) : ( |
| 66 | <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p> | 75 | <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p> |
| 67 | )} | 76 | )} |
| 77 | <p className="mt-4 border-t border-border/50 pt-3 text-xs text-muted-foreground"> | ||
| 78 | {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed. | ||
| 79 | </p> | ||
| 80 | {error ? ( | ||
| 81 | <p role="status" className="mt-2 text-xs text-destructive"> | ||
| 82 | {error} | ||
| 83 | </p> | ||
| 84 | ) : null} | ||
| 85 | {action ? <div className="mt-3">{action}</div> : null} | ||
| 68 | </PopoverPopup> | 86 | </PopoverPopup> |
| 69 | </Popover> | 87 | </Popover> |
| 70 | ); | 88 | ); |
apps/web/src/components/settings/DeviceHostsSettings.tsx+36-11| ... | @@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner"; | ... | @@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner"; |
| 5 | import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; | 5 | import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; |
| 6 | import { randomUUID } from "../../lib/utils"; | 6 | import { randomUUID } from "../../lib/utils"; |
| 7 | import { useState } from "react"; | 7 | import { useState } from "react"; |
| 8 | import { useDeviceState } from "../../state/device"; | 8 | import { deviceEnvironment, useDeviceState } from "../../state/device"; |
| 9 | import { serverEnvironment } from "../../state/server"; | 9 | import { serverEnvironment } from "../../state/server"; |
| 10 | import { useAtomCommand } from "../../state/use-atom-command"; | 10 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 11 | import { Button } from "../ui/button"; | 11 | import { Button } from "../ui/button"; |
| ... | @@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null | ... | @@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 110 | </p> | 110 | </p> |
| 111 | ) : null} | 111 | ) : null} |
| 112 | <DeviceHostList | 112 | <DeviceHostList |
| 113 | environmentLabel={environment.label} | ||
| 113 | environmentId={environment.environmentId} | 114 | environmentId={environment.environmentId} |
| 114 | hosts={environment.serverConfig?.settings.deviceHosts ?? []} | 115 | hosts={environment.serverConfig?.settings.deviceHosts ?? []} |
| 115 | busy={projectScope || busy} | 116 | busy={projectScope || busy} |
| ... | @@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null | ... | @@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 158 | } | 159 | } |
| 159 | 160 | ||
| 160 | function DeviceHostList({ | 161 | function DeviceHostList({ |
| 162 | environmentLabel, | ||
| 161 | environmentId, | 163 | environmentId, |
| 162 | hosts, | 164 | hosts, |
| 163 | busy, | 165 | busy, |
| ... | @@ -166,6 +168,7 @@ function DeviceHostList({ | ... | @@ -166,6 +168,7 @@ function DeviceHostList({ |
| 166 | checks, | 168 | checks, |
| 167 | testConnection, | 169 | testConnection, |
| 168 | }: { | 170 | }: { |
| 171 | environmentLabel: string; | ||
| 169 | environmentId: EnvironmentId; | 172 | environmentId: EnvironmentId; |
| 170 | hosts: ReadonlyArray<SshDeviceHostConfig>; | 173 | hosts: ReadonlyArray<SshDeviceHostConfig>; |
| 171 | busy: boolean; | 174 | busy: boolean; |
| ... | @@ -175,6 +178,8 @@ function DeviceHostList({ | ... | @@ -175,6 +178,8 @@ function DeviceHostList({ |
| 175 | testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"]; | 178 | testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"]; |
| 176 | }) { | 179 | }) { |
| 177 | const { state } = useDeviceState(environmentId); | 180 | const { state } = useDeviceState(environmentId); |
| 181 | const retry = useAtomCommand(deviceEnvironment.list); | ||
| 182 | const [retrying, setRetrying] = useState<string | null>(null); | ||
| 178 | return ( | 183 | return ( |
| 179 | <> | 184 | <> |
| 180 | {hosts.length === 0 ? ( | 185 | {hosts.length === 0 ? ( |
| ... | @@ -238,9 +243,11 @@ function DeviceHostList({ | ... | @@ -238,9 +243,11 @@ function DeviceHostList({ |
| 238 | </div> | 243 | </div> |
| 239 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> | 244 | <p className="truncate text-xs text-muted-foreground">{host.target}</p> |
| 240 | <DeviceToolVersions | 245 | <DeviceToolVersions |
| 246 | owner={environmentLabel} | ||
| 247 | error={state.hosts.find((value) => value.id === host.id)?.toolInspectionError} | ||
| 241 | tools={ | 248 | tools={ |
| 242 | (check?.status === "connected" ? check.tools : undefined) ?? | 249 | state.hosts.find((value) => value.id === host.id)?.tools ?? |
| 243 | state.hosts.find((value) => value.id === host.id)?.tools | 250 | (check?.status === "connected" ? check.tools : undefined) |
| 244 | } | 251 | } |
| 245 | /> | 252 | /> |
| 246 | {check?.status === "local" ? ( | 253 | {check?.status === "local" ? ( |
| ... | @@ -290,14 +297,32 @@ function DeviceHostList({ | ... | @@ -290,14 +297,32 @@ function DeviceHostList({ |
| 290 | </MenuItem> | 297 | </MenuItem> |
| 291 | </MenuPopup> | 298 | </MenuPopup> |
| 292 | </Menu> | 299 | </Menu> |
| 293 | <Button | 300 | {status?.status === "failed" && |
| 294 | size="sm" | 301 | state.supportsHostRetry && |
| 295 | variant="outline" | 302 | state.hostStatus !== "disabled" ? ( |
| 296 | disabled={busy || progress !== null} | 303 | <Button |
| 297 | onClick={() => void testConnection(host)} | 304 | size="sm" |
| 298 | > | 305 | variant="outline" |
| 299 | Test connection | 306 | disabled={busy || retrying !== null} |
| 300 | </Button> | 307 | onClick={() => { |
| 308 | setRetrying(host.id); | ||
| 309 | void retry({ environmentId, input: { retryHostId: host.id } }).finally(() => | ||
| 310 | setRetrying(null), | ||
| 311 | ); | ||
| 312 | }} | ||
| 313 | > | ||
| 314 | {retrying === host.id ? "Retrying…" : "Retry"} | ||
| 315 | </Button> | ||
| 316 | ) : ( | ||
| 317 | <Button | ||
| 318 | size="sm" | ||
| 319 | variant="outline" | ||
| 320 | disabled={busy || progress !== null} | ||
| 321 | onClick={() => void testConnection(host)} | ||
| 322 | > | ||
| 323 | Test connection | ||
| 324 | </Button> | ||
| 325 | )} | ||
| 301 | </div> | 326 | </div> |
| 302 | ); | 327 | ); |
| 303 | })} | 328 | })} |
apps/web/src/components/settings/IntegrationsSettings.tsx+22-5| ... | @@ -685,6 +685,21 @@ function DeviceIntegrationControls({ | ... | @@ -685,6 +685,21 @@ function DeviceIntegrationControls({ |
| 685 | } | 685 | } |
| 686 | }; | 686 | }; |
| 687 | 687 | ||
| 688 | const checkVersions = state.supportsToolInspection ? ( | ||
| 689 | <Button | ||
| 690 | size="sm" | ||
| 691 | variant="outline" | ||
| 692 | disabled={!environmentId || pending !== null || busy} | ||
| 693 | onClick={() => { | ||
| 694 | if (!environmentId) return; | ||
| 695 | setPending("check"); | ||
| 696 | void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null)); | ||
| 697 | }} | ||
| 698 | > | ||
| 699 | {pending === "check" ? "Checking…" : "Check versions"} | ||
| 700 | </Button> | ||
| 701 | ) : null; | ||
| 702 | |||
| 688 | return ( | 703 | return ( |
| 689 | <> | 704 | <> |
| 690 | <SettingsRow | 705 | <SettingsRow |
| ... | @@ -695,6 +710,7 @@ function DeviceIntegrationControls({ | ... | @@ -695,6 +710,7 @@ function DeviceIntegrationControls({ |
| 695 | control={ | 710 | control={ |
| 696 | <> | 711 | <> |
| 697 | <DeviceToolVersions | 712 | <DeviceToolVersions |
| 713 | action={checkVersions} | ||
| 698 | kind="hub" | 714 | kind="hub" |
| 699 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | 715 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 700 | /> | 716 | /> |
| ... | @@ -758,6 +774,7 @@ function DeviceIntegrationControls({ | ... | @@ -758,6 +774,7 @@ function DeviceIntegrationControls({ |
| 758 | control={ | 774 | control={ |
| 759 | <> | 775 | <> |
| 760 | <DeviceToolVersions | 776 | <DeviceToolVersions |
| 777 | action={checkVersions} | ||
| 761 | kind="agent" | 778 | kind="agent" |
| 762 | tools={state.hosts.find((host) => host.kind === "local")?.tools} | 779 | tools={state.hosts.find((host) => host.kind === "local")?.tools} |
| 763 | /> | 780 | /> |
| ... | @@ -780,12 +797,12 @@ function DeviceIntegrationControls({ | ... | @@ -780,12 +797,12 @@ function DeviceIntegrationControls({ |
| 780 | </> | 797 | </> |
| 781 | } | 798 | } |
| 782 | /> | 799 | /> |
| 783 | {state.hostStatus === "failed" && state.hostStatusDetail ? ( | 800 | {environmentId ? ( |
| 784 | <p role="alert" className="px-4 py-3 text-xs text-destructive"> | 801 | <DeviceHostUpdates |
| 785 | {state.hostStatusDetail} | 802 | state={{ ...state, hosts: state.hosts.filter((host) => host.kind === "local") }} |
| 786 | </p> | 803 | environmentId={environmentId} |
| 804 | /> | ||
| 787 | ) : null} | 805 | ) : null} |
| 788 | {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null} | ||
| 789 | <DeviceHostsSettings environmentId={environmentId} /> | 806 | <DeviceHostsSettings environmentId={environmentId} /> |
| 790 | </> | 807 | </> |
| 791 | ); | 808 | ); |
docs/user/devices.md+6| ... | @@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds, | ... | @@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds, |
| 102 | installation, and connectivity to development servers such as Metro separately. | 102 | installation, and connectivity to development servers such as Metro separately. |
| 103 | A simulator on another machine cannot reach Metro through your environment's | 103 | A simulator on another machine cannot reach Metro through your environment's |
| 104 | localhost without forwarding or another reachable address. | 104 | localhost without forwarding or another reachable address. |
| 105 | |||
| 106 | ## Device tool updates | ||
| 107 | |||
| 108 | The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Integrations → Check device tool versions reads installed versions without installing tools or starting devices. | ||
| 109 | |||
| 110 | To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. |
packages/client-runtime/src/state/device.ts+13| ... | @@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { | ... | @@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { |
| 84 | return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; | 84 | return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`; |
| 85 | }); | 85 | }); |
| 86 | } | 86 | } |
| 87 | |||
| 88 | export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) { | ||
| 89 | if (!tools) return "Versions have not been checked. Reconnect the host and check versions."; | ||
| 90 | const outdated = [tools.hub, tools.agent].filter( | ||
| 91 | (tool) => | ||
| 92 | tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion), | ||
| 93 | ); | ||
| 94 | return outdated.length > 0 | ||
| 95 | ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback." | ||
| 96 | : "Required tools are installed automatically when needed. Checking versions does not install or start anything."; | ||
| 97 | } | ||
| 98 | export const deviceToolUpdateOwnership = | ||
| 99 | "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server."; |
packages/contracts/src/device.ts+4| ... | @@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({ | ... | @@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({ |
| 93 | label: TrimmedNonEmptyString, | 93 | label: TrimmedNonEmptyString, |
| 94 | platforms: Schema.Array(DevicePlatformAvailability), | 94 | platforms: Schema.Array(DevicePlatformAvailability), |
| 95 | tools: Schema.optional(DeviceToolVersions), | 95 | tools: Schema.optional(DeviceToolVersions), |
| 96 | toolInspectionError: Schema.optional(Schema.String), | ||
| 96 | hubInstalled: Schema.Boolean, | 97 | hubInstalled: Schema.Boolean, |
| 97 | agentDeviceInstalled: Schema.Boolean, | 98 | agentDeviceInstalled: Schema.Boolean, |
| 98 | }); | 99 | }); |
| ... | @@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; | ... | @@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type; |
| 128 | 129 | ||
| 129 | export const DeviceServiceState = Schema.Struct({ | 130 | export const DeviceServiceState = Schema.Struct({ |
| 130 | supportsHostRetry: Schema.optional(Schema.Boolean), | 131 | supportsHostRetry: Schema.optional(Schema.Boolean), |
| 132 | supportsToolInspection: Schema.optional(Schema.Boolean), | ||
| 131 | hosts: Schema.Array(DeviceHostSummary), | 133 | hosts: Schema.Array(DeviceHostSummary), |
| 132 | hostStatus: DeviceHostStatus, | 134 | hostStatus: DeviceHostStatus, |
| 133 | hostStatusDetail: Schema.optional(Schema.String), | 135 | hostStatusDetail: Schema.optional(Schema.String), |
| ... | @@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({ | ... | @@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({ |
| 152 | export type DeviceServiceState = typeof DeviceServiceState.Type; | 154 | export type DeviceServiceState = typeof DeviceServiceState.Type; |
| 153 | 155 | ||
| 154 | export const DeviceListInput = Schema.Struct({ | 156 | export const DeviceListInput = Schema.Struct({ |
| 157 | /** Read inventory without installing tools or starting helpers. */ | ||
| 158 | inspectOnly: Schema.optional(Schema.Boolean), | ||
| 155 | /** Retry this host only, including agent tools if access was already granted. */ | 159 | /** Retry this host only, including agent tools if access was already granted. */ |
| 156 | retryHostId: Schema.optional(DeviceHostId), | 160 | retryHostId: Schema.optional(DeviceHostId), |
| 157 | }); | 161 | }); |