diff --git a/.env.example b/.env.example index e1ac0be790b..55017296cf9 100644 --- a/.env.example +++ b/.env.example @@ -110,3 +110,5 @@ SUPERSET_MCP_API_KEY= # Relay service URL (the v2 tunnel proxy that forwards cloud API calls # to host-service instances on user devices). Local dev: http://localhost:4734 RELAY_URL= +# Browser-exposed relay URL — the web app's host-service tRPC + terminal WS. +NEXT_PUBLIC_RELAY_URL= diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index d28f819f336..1ac81a920c8 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -294,6 +294,7 @@ jobs: NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }} NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }} + NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} @@ -326,6 +327,7 @@ jobs: --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ + --env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index b923ad0eba8..2f5c86b5147 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -215,6 +215,7 @@ jobs: NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} + NEXT_PUBLIC_RELAY_URL: ${{ secrets.RELAY_URL }} BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} @@ -247,6 +248,7 @@ jobs: --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ + --env NEXT_PUBLIC_RELAY_URL=$NEXT_PUBLIC_RELAY_URL \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 681c1fa66cd..fc20084769f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -16,16 +16,21 @@ const isProduction = process.env.NODE_ENV === "production"; const apiOrigin = process.env.NEXT_PUBLIC_API_URL ? new URL(process.env.NEXT_PUBLIC_API_URL).origin : null; -// Remote-control viewers open a WebSocket to the relay. In dev the blanket -// `ws:`/`wss:` below covers it; in prod we need to allow the relay origin -// explicitly so `connect-src` doesn't block `wss://relay…`. The hard-coded -// prod fallback keeps the header correct even if RELAY_URL isn't plumbed -// into the build env. +// The web app reaches host-services through the relay — a WebSocket for the +// terminal stream and HTTP for host tRPC. In dev the blanket `ws:`/`wss:` +// below covers the socket; prod needs the relay origins listed explicitly so +// `connect-src` blocks neither. The hard-coded prod fallback keeps the header +// correct even if RELAY_URL isn't plumbed into the build env. const relayWsOrigin = process.env.RELAY_URL ? new URL(process.env.RELAY_URL).origin.replace(/^http/, "ws") : isProduction ? "wss://relay.superset.sh" : null; +const relayHttpOrigin = process.env.RELAY_URL + ? new URL(process.env.RELAY_URL).origin + : isProduction + ? "https://relay.superset.sh" + : null; const contentSecurityPolicy = [ "default-src 'self'", @@ -34,6 +39,7 @@ const contentSecurityPolicy = [ "connect-src 'self'", apiOrigin, relayWsOrigin, + relayHttpOrigin, "https://*.ingest.sentry.io", "https://*.sentry.io", "https://us.i.posthog.com", diff --git a/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx new file mode 100644 index 00000000000..0c11177a841 --- /dev/null +++ b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { FitAddon } from "@xterm/addon-fit"; +import type { ITheme } from "@xterm/xterm"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getAuthToken } from "../../../../../trpc/auth-token"; +import { getRelayUrl } from "../../../../../trpc/relay-url"; + +const TERMINAL_THEME: ITheme = { + background: "#151110", + foreground: "#eae8e6", + cursor: "#e07850", + cursorAccent: "#151110", + selectionBackground: "rgba(224, 120, 80, 0.25)", + black: "#151110", + red: "#dc6b6b", + green: "#7ec699", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#eae8e6", + brightBlack: "#5c5856", + brightRed: "#e88888", + brightGreen: "#98d1a8", + brightYellow: "#ecd08f", + brightBlue: "#7ec0f5", + brightMagenta: "#d494e6", + brightCyan: "#73c7d3", + brightWhite: "#ffffff", +}; + +const TERMINAL_FONT_FAMILY = + '"JetBrains Mono", "MesloLGS NF", "Menlo", "Monaco", "Courier New", monospace'; + +const KEY_BUTTONS: Array<{ label: string; sequence: string }> = [ + { label: "Tab", sequence: "\t" }, + { label: "Esc", sequence: "\x1b" }, + { label: "Ctrl-C", sequence: "\x03" }, + { label: "Ctrl-D", sequence: "\x04" }, + { label: "↑", sequence: "\x1b[A" }, + { label: "↓", sequence: "\x1b[B" }, + { label: "←", sequence: "\x1b[D" }, + { label: "→", sequence: "\x1b[C" }, +]; + +interface WebTerminalProps { + workspaceId: string; + terminalId: string; + routingKey: string; +} + +type ConnectionState = "connecting" | "open" | "error" | "exited"; + +// Wire protocol mirrors the desktop's terminal transport +// (apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts): binary +// frames are raw PTY bytes, control messages are JSON. +type TerminalServerMessage = + | { type: "attached"; terminalId: string } + | { type: "title"; title: string | null } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number }; + +export function WebTerminal({ + workspaceId, + terminalId, + routingKey, +}: WebTerminalProps) { + const containerRef = useRef(null); + const socketRef = useRef(null); + const [state, setState] = useState("connecting"); + const [errorMessage, setErrorMessage] = useState(null); + + const sendSequence = useCallback((sequence: string) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data: sequence })); + }, []); + + useEffect(() => { + let cancelled = false; + let terminal: Terminal | null = null; + let fitAddon: FitAddon | null = null; + let socket: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + let resizeTimer: ReturnType | null = null; + const visualViewport = window.visualViewport; + + const sendResize = () => { + const activeSocket = socketRef.current; + if ( + !terminal || + !activeSocket || + activeSocket.readyState !== WebSocket.OPEN + ) { + return; + } + activeSocket.send( + JSON.stringify({ + type: "resize", + cols: terminal.cols, + rows: terminal.rows, + }), + ); + }; + + // Refit on every layout change; the visualViewport listeners are what + // keep the prompt above the soft keyboard on mobile, since the keyboard + // resizes the visual viewport rather than the layout viewport. + const refit = () => { + if (!fitAddon) return; + try { + fitAddon.fit(); + } catch { + return; + } + if (resizeTimer !== null) clearTimeout(resizeTimer); + resizeTimer = setTimeout(sendResize, 150); + }; + + (async () => { + try { + const token = await getAuthToken(); + if (cancelled) return; + const container = containerRef.current; + if (!container) return; + + terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontFamily: TERMINAL_FONT_FAMILY, + fontSize: 14, + scrollback: 5000, + theme: TERMINAL_THEME, + allowProposedApi: true, + }); + fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(container); + try { + fitAddon.fit(); + } catch { + // container may not be sized yet + } + + const wsBase = getRelayUrl().replace(/^http/, "ws").replace(/\/$/, ""); + const url = `${wsBase}/hosts/${routingKey}/terminal/${encodeURIComponent(terminalId)}?workspaceId=${encodeURIComponent(workspaceId)}&themeType=dark&token=${encodeURIComponent(token)}`; + socket = new WebSocket(url); + socket.binaryType = "arraybuffer"; + socketRef.current = socket; + + socket.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + terminal?.write(new Uint8Array(event.data)); + return; + } + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + return; + } + switch (message.type) { + case "attached": + setState("open"); + sendResize(); + return; + case "exit": + terminal?.write( + `\r\n\x1b[33m[process exited code=${message.exitCode}]\x1b[0m\r\n`, + ); + setState("exited"); + return; + case "error": + setErrorMessage(message.message); + setState("error"); + return; + default: + return; + } + }; + + socket.onclose = () => { + setState((previous) => + previous === "open" || previous === "connecting" + ? "error" + : previous, + ); + }; + + socket.onerror = () => { + setErrorMessage("WebSocket connection failed."); + }; + + terminal.onData((data) => { + const activeSocket = socketRef.current; + if (activeSocket?.readyState === WebSocket.OPEN) { + activeSocket.send(JSON.stringify({ type: "input", data })); + } + }); + + resizeObserver = new ResizeObserver(refit); + resizeObserver.observe(container); + visualViewport?.addEventListener("resize", refit); + visualViewport?.addEventListener("scroll", refit); + } catch (caught) { + if (cancelled) return; + setErrorMessage( + caught instanceof Error ? caught.message : String(caught), + ); + setState("error"); + } + })(); + + return () => { + cancelled = true; + if (resizeTimer !== null) clearTimeout(resizeTimer); + resizeObserver?.disconnect(); + visualViewport?.removeEventListener("resize", refit); + visualViewport?.removeEventListener("scroll", refit); + try { + socket?.close(); + } catch { + // best-effort + } + terminal?.dispose(); + socketRef.current = null; + }; + }, [workspaceId, terminalId, routingKey]); + + return ( +
+
+
+ {state !== "open" && ( +
+ {state === "connecting" + ? "Connecting…" + : state === "exited" + ? "Process exited." + : (errorMessage ?? "Disconnected.")} +
+ )} +
+
+ {KEY_BUTTONS.map((button) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/index.ts b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/index.ts new file mode 100644 index 00000000000..b6b859de720 --- /dev/null +++ b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/index.ts @@ -0,0 +1 @@ +export { WebTerminal } from "./WebTerminal"; diff --git a/apps/web/src/app/workspaces/[workspaceId]/page.tsx b/apps/web/src/app/workspaces/[workspaceId]/page.tsx new file mode 100644 index 00000000000..06498d6c91e --- /dev/null +++ b/apps/web/src/app/workspaces/[workspaceId]/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import Link from "next/link"; +import { use, useCallback, useEffect, useState } from "react"; +import { trpcClient } from "../../../trpc/client"; +import { + createHostTerminal, + listHostTerminals, +} from "../../../trpc/host-client"; +import { WebTerminal } from "./components/WebTerminal"; + +interface HostTerminal { + terminalId: string; + title: string | null; + exited: boolean; +} + +export default function WorkspaceTerminalPage({ + params, +}: { + params: Promise<{ workspaceId: string }>; +}) { + const { workspaceId } = use(params); + const [routingKey, setRoutingKey] = useState(null); + const [terminals, setTerminals] = useState(null); + const [selectedTerminalId, setSelectedTerminalId] = useState( + null, + ); + const [loadError, setLoadError] = useState(null); + const [creating, setCreating] = useState(false); + const [viewportHeight, setViewportHeight] = useState(null); + + const loadTerminals = useCallback( + async (key: string) => { + try { + const result = await listHostTerminals(key, workspaceId); + setLoadError(null); + setTerminals( + result.sessions.map((session) => ({ + terminalId: session.terminalId, + title: session.title, + exited: session.exited, + })), + ); + } catch (caught) { + setLoadError(caught instanceof Error ? caught.message : String(caught)); + setTerminals([]); + } + }, + [workspaceId], + ); + + useEffect(() => { + (async () => { + try { + const organization = await trpcClient.organization.getActive.query(); + if (!organization) { + setLoadError("No active organization."); + setTerminals([]); + return; + } + const workspace = await trpcClient.v2Workspace.getFromHost.query({ + organizationId: organization.id, + id: workspaceId, + }); + if (!workspace) { + setLoadError("Workspace not found."); + setTerminals([]); + return; + } + const key = buildHostRoutingKey(organization.id, workspace.hostId); + setRoutingKey(key); + await loadTerminals(key); + } catch (caught) { + setLoadError(caught instanceof Error ? caught.message : String(caught)); + setTerminals([]); + } + })(); + }, [workspaceId, loadTerminals]); + + useEffect(() => { + if (selectedTerminalId || !terminals) return; + const first = + terminals.find((terminal) => !terminal.exited) ?? terminals[0]; + if (first) setSelectedTerminalId(first.terminalId); + }, [terminals, selectedTerminalId]); + + useEffect(() => { + const visualViewport = window.visualViewport; + if (!visualViewport) return; + const update = () => setViewportHeight(visualViewport.height); + update(); + visualViewport.addEventListener("resize", update); + visualViewport.addEventListener("scroll", update); + return () => { + visualViewport.removeEventListener("resize", update); + visualViewport.removeEventListener("scroll", update); + }; + }, []); + + const createTerminal = useCallback(async () => { + if (!routingKey) return; + setCreating(true); + try { + const created = await createHostTerminal(routingKey, workspaceId); + await loadTerminals(routingKey); + setSelectedTerminalId(created.terminalId); + } catch (caught) { + setLoadError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setCreating(false); + } + }, [routingKey, workspaceId, loadTerminals]); + + return ( +
+
+ + ← Workspaces + + + +
+ {loadError && ( +
+ {loadError} +
+ )} +
+ {selectedTerminalId && routingKey ? ( + + ) : ( +
+ {terminals === null + ? "Loading terminals…" + : "No terminal sessions. Create one to get started."} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/workspaces/page.tsx b/apps/web/src/app/workspaces/page.tsx new file mode 100644 index 00000000000..24c8261429e --- /dev/null +++ b/apps/web/src/app/workspaces/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { trpcClient } from "../../trpc/client"; + +interface WorkspaceRow { + id: string; + name: string; + branch: string; + projectId: string; + projectName: string; + hostId: string; +} + +interface ProjectRow { + id: string; + name: string; +} + +interface HostRow { + machineId: string; + name: string | null; +} + +function hostLabel(host: HostRow): string { + return host.name?.trim() || `Device ${host.machineId.slice(0, 8)}`; +} + +export default function WorkspacesPage() { + const [organizationId, setOrganizationId] = useState(null); + const [organizationName, setOrganizationName] = useState(null); + const [workspaces, setWorkspaces] = useState(null); + const [projects, setProjects] = useState([]); + const [hosts, setHosts] = useState([]); + const [error, setError] = useState(null); + + const [name, setName] = useState(""); + const [branch, setBranch] = useState(""); + const [projectId, setProjectId] = useState(""); + const [hostId, setHostId] = useState(""); + const [creating, setCreating] = useState(false); + + const [search, setSearch] = useState(""); + const [projectFilter, setProjectFilter] = useState(""); + const [hostFilter, setHostFilter] = useState(""); + + const loadWorkspaces = useCallback(async (organization: string) => { + const rows = await trpcClient.v2Workspace.list.query({ + organizationId: organization, + }); + setWorkspaces( + rows.map((row) => ({ + id: row.id, + name: row.name, + branch: row.branch, + projectId: row.projectId, + projectName: row.projectName, + hostId: row.hostId, + })), + ); + }, []); + + useEffect(() => { + (async () => { + try { + const organization = await trpcClient.organization.getActive.query(); + if (!organization) { + setError("No active organization."); + setWorkspaces([]); + return; + } + setOrganizationId(organization.id); + setOrganizationName(organization.name); + const [, projectRows, hostRows] = await Promise.all([ + loadWorkspaces(organization.id), + trpcClient.v2Project.list.query({ + organizationId: organization.id, + }), + trpcClient.v2Host.list.query(), + ]); + setProjects( + projectRows.map((project) => ({ + id: project.id, + name: project.name, + })), + ); + setHosts( + hostRows.map((host) => ({ + machineId: host.machineId, + name: host.name, + })), + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setWorkspaces([]); + } + })(); + }, [loadWorkspaces]); + + const visibleWorkspaces = useMemo(() => { + const query = search.trim().toLowerCase(); + return (workspaces ?? []) + .filter((workspace) => { + if (projectFilter && workspace.projectId !== projectFilter) { + return false; + } + if (hostFilter && workspace.hostId !== hostFilter) { + return false; + } + if (!query) return true; + return ( + workspace.name.toLowerCase().includes(query) || + workspace.branch.toLowerCase().includes(query) || + workspace.projectName.toLowerCase().includes(query) + ); + }) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + }, [workspaces, search, projectFilter, hostFilter]); + + const canCreate = + !!organizationId && + !!projectId && + !!hostId && + name.trim().length > 0 && + branch.trim().length > 0 && + !creating; + + const createWorkspace = useCallback(async () => { + if (!organizationId) return; + setCreating(true); + setError(null); + try { + await trpcClient.v2Workspace.create.mutate({ + organizationId, + projectId, + name: name.trim(), + branch: branch.trim(), + hostId, + }); + setName(""); + setBranch(""); + await loadWorkspaces(organizationId); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setCreating(false); + } + }, [organizationId, projectId, name, branch, hostId, loadWorkspaces]); + + return ( +
+

Workspaces

+ {organizationName && ( +

{organizationName}

+ )} + + {error && ( +

+ {error} +

+ )} + +
+

New workspace

+
+ setName(event.target.value)} + placeholder="Name" + className="rounded-md border bg-transparent px-3 py-2 text-sm" + /> + setBranch(event.target.value)} + placeholder="Branch" + className="rounded-md border bg-transparent px-3 py-2 text-sm" + /> + + +
+ {hosts.length === 0 && ( +

+ No devices available — register a machine in the desktop app first. +

+ )} + +
+ +
+

Your workspaces

+
+ setSearch(event.target.value)} + placeholder="Search workspaces…" + className="min-w-48 flex-1 rounded-md border bg-transparent px-3 py-2 text-sm" + /> + + +
+ {workspaces === null ? ( +

Loading…

+ ) : visibleWorkspaces.length === 0 ? ( +

+ {workspaces.length === 0 + ? "No workspaces yet." + : "No workspaces match your filters."} +

+ ) : ( +
    + {visibleWorkspaces.map((workspace) => ( +
  • + +
    {workspace.name}
    +
    + {workspace.projectName} · {workspace.branch} +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 00609331de5..cd3c34a2cb6 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -28,6 +28,7 @@ export const env = createEnv({ client: { NEXT_PUBLIC_API_URL: z.string().url(), + NEXT_PUBLIC_RELAY_URL: z.string().url(), NEXT_PUBLIC_WEB_URL: z.string().url(), NEXT_PUBLIC_MARKETING_URL: z.string().url(), NEXT_PUBLIC_DOCS_URL: z.string().url(), @@ -42,6 +43,7 @@ export const env = createEnv({ experimental__runtimeEnv: { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_RELAY_URL: process.env.NEXT_PUBLIC_RELAY_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_MARKETING_URL: process.env.NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_DOCS_URL: process.env.NEXT_PUBLIC_DOCS_URL, diff --git a/apps/web/src/trpc/auth-token.ts b/apps/web/src/trpc/auth-token.ts new file mode 100644 index 00000000000..b1a9146f19d --- /dev/null +++ b/apps/web/src/trpc/auth-token.ts @@ -0,0 +1,27 @@ +import { env } from "../env"; + +// Better Auth user JWT for relay-fronted host-service calls. Cached and +// reused until close to its 1h expiry; the relay verifies it via JWKS. +interface CachedToken { + token: string; + fetchedAt: number; +} + +let cached: CachedToken | null = null; +const TOKEN_TTL_MS = 50 * 60 * 1000; + +export async function getAuthToken(): Promise { + if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) { + return cached.token; + } + const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/auth/token`, { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Auth token request failed (${response.status})`); + } + const body = (await response.json()) as { token?: string }; + if (!body.token) throw new Error("Auth token response missing token"); + cached = { token: body.token, fetchedAt: Date.now() }; + return body.token; +} diff --git a/apps/web/src/trpc/host-client.ts b/apps/web/src/trpc/host-client.ts new file mode 100644 index 00000000000..b3a90364584 --- /dev/null +++ b/apps/web/src/trpc/host-client.ts @@ -0,0 +1,67 @@ +import SuperJSON from "superjson"; +import { getAuthToken } from "./auth-token"; +import { getRelayUrl } from "./relay-url"; + +// Direct browser → relay → host-service tRPC calls, the same path the +// desktop uses. Inputs/outputs are typed at the boundary rather than via +// the host AppRouter: importing `@superset/host-service` drags host-only +// modules into the web's type-check, which is the reason the cloud's +// `relay-client.ts` also hand-types its host calls. + +export interface HostTerminalSession { + terminalId: string; + workspaceId: string; + exited: boolean; + title: string | null; +} + +async function hostCall( + routingKey: string, + procedure: string, + input: unknown, + method: "GET" | "POST", +): Promise { + const token = await getAuthToken(); + const encoded = SuperJSON.serialize(input); + const base = `${getRelayUrl()}/hosts/${routingKey}/trpc/${procedure}`; + const url = + method === "GET" + ? `${base}?input=${encodeURIComponent(JSON.stringify(encoded))}` + : base; + + const response = await fetch(url, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(method === "POST" ? { "content-type": "application/json" } : {}), + }, + body: method === "POST" ? JSON.stringify(encoded) : undefined, + }); + if (!response.ok) { + throw new Error(`host ${procedure} failed (${response.status})`); + } + + const parsed = (await response.json()) as { result?: { data?: unknown } }; + if (!parsed.result || parsed.result.data === undefined) { + throw new Error(`host ${procedure}: malformed relay response`); + } + return SuperJSON.deserialize(parsed.result.data as never) as TOutput; +} + +export function listHostTerminals(routingKey: string, workspaceId: string) { + return hostCall<{ sessions: HostTerminalSession[] }>( + routingKey, + "terminal.listSessions", + { workspaceId }, + "GET", + ); +} + +export function createHostTerminal(routingKey: string, workspaceId: string) { + return hostCall<{ terminalId: string; status: string }>( + routingKey, + "terminal.createSession", + { workspaceId }, + "POST", + ); +} diff --git a/apps/web/src/trpc/relay-url.ts b/apps/web/src/trpc/relay-url.ts new file mode 100644 index 00000000000..307679606de --- /dev/null +++ b/apps/web/src/trpc/relay-url.ts @@ -0,0 +1,19 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import posthog from "posthog-js"; +import { env } from "../env"; + +interface RelayUrlPayload { + url?: string; +} + +// Relay base URL for host-service HTTP + WebSocket access. Mirrors the +// desktop's `useRelayUrl`: a `relay-url-override` PostHog flag payload wins, +// otherwise the build-time NEXT_PUBLIC_RELAY_URL. +export function getRelayUrl(): string { + const payload = posthog.getFeatureFlagPayload( + FEATURE_FLAGS.RELAY_URL_OVERRIDE, + ) as RelayUrlPayload | undefined; + const override = payload?.url; + if (typeof override === "string" && override.length > 0) return override; + return env.NEXT_PUBLIC_RELAY_URL; +} diff --git a/packages/trpc/src/router/v2-host/v2-host.ts b/packages/trpc/src/router/v2-host/v2-host.ts index 64bc7eea18f..76dea397c5b 100644 --- a/packages/trpc/src/router/v2-host/v2-host.ts +++ b/packages/trpc/src/router/v2-host/v2-host.ts @@ -65,6 +65,26 @@ async function requireOrgMember(userId: string, organizationId: string) { } export const v2HostRouter = { + list: protectedProcedure.query(async ({ ctx }) => { + const organizationId = requireActiveOrgId(ctx); + return db + .select({ machineId: v2Hosts.machineId, name: v2Hosts.name }) + .from(v2Hosts) + .innerJoin( + v2UsersHosts, + and( + eq(v2UsersHosts.organizationId, v2Hosts.organizationId), + eq(v2UsersHosts.hostId, v2Hosts.machineId), + ), + ) + .where( + and( + eq(v2Hosts.organizationId, organizationId), + eq(v2UsersHosts.userId, ctx.session.user.id), + ), + ); + }), + rename: protectedProcedure .input( z.object({