Repository navigation
feat(desktop): surface a host unreachable screen for v2 workspaces #6476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { WorkspaceHostUnreachableState } from "./WorkspaceHostUnreachableState"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export { | ||
| type HostReachability, | ||
| useHostReachability, | ||
| } from "./useHostReachability"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { | ||
| getEventBus, | ||
| type HostConnectionStatus, | ||
| } from "@superset/workspace-client"; | ||
| import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; | ||
| import { useDelayElapsed } from "renderer/hooks/useDelayElapsed"; | ||
| import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; | ||
|
|
||
| /** | ||
| * How long the host has to stay unreachable before the workspace hands over to | ||
| * the unreachable screen. The socket reconnects on its own backoff, so a relay | ||
| * redeploy, a laptop lid, or a half-open TCP flap resolves well inside this | ||
| * window — taking the screen over for those would be the false-positive that | ||
| * got the earlier cloud-presence gate reverted twice (#4430, #4727). | ||
| */ | ||
| const UNREACHABLE_GRACE_MS = 10_000; | ||
|
|
||
| /** | ||
| * While the screen is up, dial on this cadence instead of riding the socket's | ||
| * own backoff. That backoff grows to 30s, so a host that came back could sit | ||
| * behind "Reconnecting…" for half a minute — measured at 20.7s — which reads | ||
| * as broken next to copy promising the workspace returns with the connection. | ||
| * Only runs while the takeover is visible, so it can't hammer a healthy host. | ||
| */ | ||
| const REDIAL_INTERVAL_MS = 5_000; | ||
|
|
||
| export interface HostReachability { | ||
| /** Sustained loss of the host connection — safe to take the screen over. */ | ||
| isUnreachable: boolean; | ||
| /** A dial is in flight right now (auto-backoff or a manual retry). */ | ||
| isReconnecting: boolean; | ||
| /** What the relay preflight says is wrong. Only read while unreachable. */ | ||
| detail: string; | ||
| /** Dial now instead of waiting out the backoff. */ | ||
| retry: () => void; | ||
| } | ||
|
|
||
| function describeFailure( | ||
| status: HostConnectionStatus, | ||
| isRelayHost: boolean, | ||
| ): string { | ||
| if (!isRelayHost) { | ||
| return "The local host service stopped answering. Retry first; if that doesn't take, restart it from the Superset tray menu > Host Service > Restart."; | ||
| } | ||
| const probe = status.probe; | ||
| // No probe result at all: the relay itself never answered. | ||
| if (!probe) { | ||
| return "Couldn't reach the relay service. Check this machine's network connection — the other device is probably fine."; | ||
| } | ||
| if (probe.status === 503) { | ||
| return "That device isn't connected to the relay. Check it's awake, online, and running Superset — it reconnects on its own once it is."; | ||
| } | ||
| if (probe.status === 401 || probe.status === 403) { | ||
| return "You don't have access to this host. If it's your own device, turn on relay access there under Settings > Security."; | ||
| } | ||
| if (probe.status === 502 || probe.status === 504) { | ||
| return "The relay couldn't reach that device right now. This is usually temporary — retrying in a moment normally works."; | ||
| } | ||
| if (probe.status === 200) { | ||
| const where = probe.region ? ` (region ${probe.region})` : ""; | ||
| return `That device is online${where} but the connection couldn't be established — usually relay routing rather than the device itself. Retry, and if it persists restart Superset on that device.`; | ||
| } | ||
| return `The connection failed (relay status ${probe.status}). Retry, and if it persists restart Superset on that device.`; | ||
| } | ||
|
|
||
| /** | ||
| * Live reachability of the host serving this workspace, read off the shared | ||
| * event-bus socket — the same connection the workspace's own data flows over, | ||
| * so it reflects what the UI can actually do rather than the cloud's `isOnline` | ||
| * flag (which drifts through relay redeploys and API blips). | ||
| */ | ||
| export function useHostReachability(hostUrl: string): HostReachability { | ||
| const bus = useMemo( | ||
| () => getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)), | ||
| [hostUrl], | ||
| ); | ||
| // Hold the connection open for as long as this screen is mounted: the | ||
| // panes that normally keep the bus alive are gone once we take over, and a | ||
| // closed socket would never observe the host coming back. | ||
| useEffect(() => bus.retain(), [bus]); | ||
|
|
||
| const status = useSyncExternalStore( | ||
| useCallback((onChange) => bus.subscribeConnectionStatus(onChange), [bus]), | ||
| () => bus.getConnectionStatus(), | ||
| ); | ||
|
|
||
| const isDown = status.state !== "open"; | ||
| const isUnreachable = useDelayElapsed(isDown, UNREACHABLE_GRACE_MS); | ||
| const isRelayHost = /\/hosts\/[^/]+/.test(hostUrl); | ||
|
|
||
| useEffect(() => { | ||
| if (!isUnreachable) return; | ||
| const timer = window.setInterval(() => bus.reconnect(), REDIAL_INTERVAL_MS); | ||
| return () => window.clearInterval(timer); | ||
| }, [isUnreachable, bus]); | ||
|
|
||
| return { | ||
| isUnreachable, | ||
| isReconnecting: isDown && status.state !== "closed", | ||
| detail: describeFailure(status, isRelayHost), | ||
| retry: () => bus.reconnect(), | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { useWorkspaceHostUrl } from "@superset/workspace-client"; | ||
| import type { ReactNode } from "react"; | ||
| import type { HostShapedWorkspace } from "renderer/hooks/host-workspaces/useHostWorkspaces"; | ||
| import { cloudTrpc } from "renderer/lib/cloud-trpc"; | ||
| import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; | ||
| import { StateScreenShell } from "../../../../components/StateScreenShell"; | ||
| import { WorkspaceHostUnreachableState } from "../../../../components/WorkspaceHostUnreachableState"; | ||
| import { useHostReachability } from "../../../../hooks/useHostReachability"; | ||
|
|
||
| const HOST_LIST_STALE_MS = 30_000; | ||
|
|
||
| /** | ||
| * Covers the workspace with the unreachable screen while its host is down. | ||
| * Overlays rather than replaces: panes keep their live state (terminal | ||
| * scrollback, unsaved editor documents, in-flight agent sessions) so a dropped | ||
| * connection costs nothing once the host is back. | ||
| */ | ||
| export function WorkspaceHostGate({ | ||
| workspace, | ||
| children, | ||
| }: { | ||
| workspace: HostShapedWorkspace; | ||
| children: ReactNode; | ||
| }) { | ||
| const hostUrl = useWorkspaceHostUrl(); | ||
| const { isUnreachable, isReconnecting, detail, retry } = | ||
| useHostReachability(hostUrl); | ||
| const { machineId } = useLocalHostService(); | ||
| const { data: hostRows = [] } = cloudTrpc.v2Host.list.useQuery(undefined, { | ||
| staleTime: HOST_LIST_STALE_MS, | ||
| }); | ||
|
|
||
| const hostRow = | ||
| hostRows.find( | ||
| (host) => | ||
| host.organizationId === workspace.organizationId && | ||
| host.machineId === workspace.hostId, | ||
| ) ?? null; | ||
| const hostName = | ||
| hostRow?.name ?? | ||
| (workspace.hostId === machineId ? "This device" : "Unknown host"); | ||
|
|
||
| // The wrapper renders unconditionally — dropping it when the host is | ||
| // reachable would move `children` in the tree and remount the whole | ||
| // workspace on every reconnect. | ||
| return ( | ||
| <div className="relative flex min-h-0 min-w-0 flex-1"> | ||
| {/* Covered panes stay mounted, so without `inert` they keep taking Tab | ||
| stops and screen-reader focus behind the takeover. */} | ||
| <div className="flex min-h-0 min-w-0 flex-1" inert={isUnreachable}> | ||
| {children} | ||
| </div> | ||
| {isUnreachable ? ( | ||
| <div className="absolute inset-0 z-50 bg-background"> | ||
| <StateScreenShell> | ||
| <WorkspaceHostUnreachableState | ||
| hostId={workspace.hostId} | ||
| hostName={hostName} | ||
| detail={detail} | ||
| isReconnecting={isReconnecting} | ||
| onRetry={retry} | ||
| /> | ||
| </StateScreenShell> | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
|
Comment on lines
+46
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: React 19 officially includes native support for the Citations:
🏁 Script executed: #!/bin/bash
set -e
file=$(fd -t f 'WorkspaceHostGate\.tsx$' . | head -n 1)
printf '%s\n' "FILE=$file"
ast-grep outline "$file" || true
printf '%s\n' '--- WorkspaceHostGate.tsx ---'
cat -n "$file"
printf '%s\n' '--- related symbols/usages ---'
rg -n -C 4 'WorkspaceHostGate|WorkspaceHostUnreachableState|isUnreachable|inert|aria-hidden' apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace apps/desktop/package.json package.json 2>/dev/null || true
printf '%s\n' '--- package versions ---'
rg -n '"react"|"react-dom"|"typescript"' package.json apps/desktop/package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null | head -80 || trueRepository: superset-sh/superset Length of output: 50376 🏁 Script executed: #!/bin/bash
set -e
base=apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace
printf '%s\n' '--- StateScreenShell ---'
state=$(fd -t f 'StateScreenShell\.tsx$' "$base" | head -n 1)
printf 'FILE=%s\n' "$state"
cat -n "$state"
printf '%s\n' '--- WorkspaceHostUnreachableState ---'
unreachable=$(fd -t f 'WorkspaceHostUnreachableState\.tsx$' "$base" | head -n 1)
printf 'FILE=%s\n' "$unreachable"
cat -n "$unreachable"
printf '%s\n' '--- WorkspaceProvider ---'
provider=$(fd -t f 'WorkspaceProvider\.tsx$' "$base" | head -n 1)
printf 'FILE=%s\n' "$provider"
cat -n "$provider"
printf '%s\n' '--- direct WorkspaceProvider callers ---'
rg -n -l '<WorkspaceProvider|WorkspaceProvider' apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace apps/desktop/src/renderer/routes/_authenticated/_dashboard | head -30
printf '%s\n' '--- inert and aria-hidden usage ---'
rg -n -C 2 '\binert\b|\baria-hidden\b' apps/desktop/src/renderer --glob '*.{ts,tsx}' | head -160
printf '%s\n' '--- React and JSX metadata ---'
python3 - <<'PY'
import json
for p in ("package.json", "apps/desktop/package.json"):
try:
d=json.load(open(p))
except FileNotFoundError:
continue
print(p)
for section in ("dependencies","devDependencies","peerDependencies"):
for k,v in d.get(section,{}).items():
if k in ("react","react-dom","`@types/react`","`@types/react-dom`","typescript"):
print(f" {section}.{k}={v}")
PY
printf '%s\n' '--- proposed flex-shape probe ---'
python3 - <<'PY'
from pathlib import Path
p=Path("apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/components/WorkspaceHostGate/WorkspaceHostGate.tsx")
s=p.read_text()
print("current direct children:")
for line in s.splitlines()[46:62]:
print(line)
print("proposal adds one direct flex child wrapper around children: yes")
PYRepository: superset-sh/superset Length of output: 35096 🏁 Script executed: #!/bin/bash
set -e
base=apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace
printf '%s\n' '--- route layout ---'
cat -n "$base/layout.tsx" 2>/dev/null || true
printf '%s\n' '--- workspace page ---'
page=$(fd -t f 'page\.tsx$' "$base/\$workspaceId" | head -n 1)
printf 'FILE=%s\n' "$page"
cat -n "$page" 2>/dev/null || true
printf '%s\n' '--- terminal parking ---'
parking=$(fd -t f 'terminal-parking\.ts$' apps/desktop/src/renderer | head -n 1)
printf 'FILE=%s\n' "$parking"
cat -n "$parking" 2>/dev/null || true
printf '%s\n' '--- relevant focus/inert patterns ---'
rg -n -C 4 'document\.activeElement|focus\(|autoFocus|inert\s*=|setAttribute\("inert"|aria-hidden' \
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace \
apps/desktop/src/renderer/lib/terminal apps/desktop/src/renderer/components \
--glob '*.{ts,tsx}' | rg -v 'aria-hidden="true"|aria-hidden\)|aria-hidden$' | head -240Repository: superset-sh/superset Length of output: 50376 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
gate = Path("apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider/components/WorkspaceHostGate/WorkspaceHostGate.tsx").read_text()
page = Path("apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx").read_text()
parking = Path("apps/desktop/src/renderer/lib/terminal/terminal-parking.ts").read_text()
checks = {
"gate keeps children mounted": "{children}" in gate and "isUnreachable ?" in gate,
"overlay is absolute and covers gate": "absolute inset-0 z-50" in gate,
"gate has no inert": "inert" not in gate,
"gate has no focus transfer": ".focus(" not in gate and "document.activeElement" not in gate,
"workspace root uses flex sizing": 'className="flex min-h-0 min-w-0 flex-1"' in page,
"project uses inert and aria-hidden together": 'setAttribute("inert", "")' in parking and 'setAttribute("aria-hidden", "true")' in parking,
}
for name, result in checks.items():
print(f"{name}: {'PASS' if result else 'FAIL'}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: superset-sh/superset Length of output: 379 Make unreachable workspace panes inert When 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { WorkspaceHostGate } from "./WorkspaceHostGate"; |
Uh oh!
There was an error while loading. Please reload this page.