diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 6a181abc16b..76ed0faeaa1 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -860,6 +860,18 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa service.command ?? "No URL" )} + {service.exposure && service.exposure.state !== "removed" ? ( +
+ HTTPS {service.exposure.state.replace("_", " ")} +
+ ) : null}
{service.lifecycle} diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index 1bc5a8ac571..baa1c5e0190 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -47,6 +47,7 @@ function createRuntimeService(overrides: Partial = {}): stoppedAt: overrides.stoppedAt ?? null, stopPolicy: overrides.stopPolicy ?? null, healthStatus: overrides.healthStatus ?? "unknown", + exposure: overrides.exposure ?? null, configIndex: overrides.configIndex ?? null, createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00.000Z"), updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00.000Z"), @@ -627,6 +628,36 @@ describe("buildWorkspaceServiceControlEntries", () => { expect(entries[0].state).toBe("failed"); expect(entries[0].failureDetail).toMatch(/^Service failed · /); }); + + it("surfaces HTTPS failure independently while the backend remains running", () => { + const running = createRuntimeService({ + status: "running", + healthStatus: "healthy", + port: 42000, + url: null, + exposure: { + provider: "tailscale_https", + state: "failed", + publicUrl: null, + hostname: "runner.tail123.ts.net", + listeners: [{ purpose: "app", publicPort: 42000, targetPort: 42000 }], + brokerRef: "service-1", + lastError: "cli_error", + updatedAt: "2026-08-11T00:00:00.000Z", + }, + }); + const built = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] }, + runtimeServices: [running], + canStartServices: true, + }); + const [entry] = buildWorkspaceServiceControlEntries({ sections: built, runtimeServices: [running] }); + + expect(entry.state).toBe("running"); + expect(entry.exposureState).toBe("failed"); + expect(entry.exposureDetail).toMatch(/^HTTPS unavailable/); + expect(entry.exposureDetail).not.toContain("cli_error"); + }); }); describe("resolveWorkspaceServiceControlRequests", () => { diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index 3afed0427bc..d3d2708da87 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -1,5 +1,6 @@ import type { WorkspaceCommandDefinition, + RuntimeExposureStatus, WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, } from "@paperclipai/shared"; @@ -30,6 +31,7 @@ export type WorkspaceRuntimeControlItem = { statusLabel: string; lifecycle: "shared" | "ephemeral" | null; healthStatus: "unknown" | "healthy" | "unhealthy" | null; + exposure: RuntimeExposureStatus | null; command: string | null; cwd: string | null; port: number | null; @@ -96,6 +98,7 @@ function buildServiceItem( statusLabel: runtimeService?.status ?? "stopped", lifecycle: runtimeService?.lifecycle ?? command.lifecycle, healthStatus: runtimeService?.healthStatus ?? "unknown", + exposure: runtimeService?.exposure ?? null, command: runtimeService?.command ?? command.command, cwd: runtimeService?.cwd ?? command.cwd, port: runtimeService?.port ?? null, @@ -120,6 +123,7 @@ function buildJobItem( statusLabel: "run once", lifecycle: null, healthStatus: null, + exposure: null, command: command.command, cwd: command.cwd, port: null, @@ -169,6 +173,7 @@ export function buildWorkspaceRuntimeControlSections(input: { statusLabel: runtimeService.status, lifecycle: runtimeService.lifecycle, healthStatus: runtimeService.healthStatus, + exposure: runtimeService.exposure ?? null, command: runtimeService.command ?? null, cwd: runtimeService.cwd ?? null, port: runtimeService.port ?? null, @@ -260,6 +265,16 @@ export function buildWorkspaceServiceControlEntries(input: { const failureDetail = state === "failed" ? `Service failed${runtimeService?.stoppedAt ? ` · ${timeAgo(runtimeService.stoppedAt)}` : ""}` : null; + const exposure = runtimeService?.exposure ?? item.exposure; + const exposureDetail = exposure?.state === "pending" + ? "Provisioning HTTPS…" + : exposure?.state === "ready" + ? "HTTPS ready" + : exposure?.state === "failed" + ? "HTTPS unavailable · Check the Tailscale broker and node HTTPS configuration." + : exposure?.state === "cleanup_pending" + ? "HTTPS cleanup pending · Restart the host broker before reusing this port." + : null; return { key: item.key, @@ -269,6 +284,8 @@ export function buildWorkspaceServiceControlEntries(input: { url: item.url, port: item.port, failureDetail, + exposureState: exposure?.state ?? null, + exposureDetail, canStart: item.canStart, }; }); diff --git a/ui/src/components/WorkspaceServiceControlBar.test.tsx b/ui/src/components/WorkspaceServiceControlBar.test.tsx index 113f1253600..3f3617da950 100644 --- a/ui/src/components/WorkspaceServiceControlBar.test.tsx +++ b/ui/src/components/WorkspaceServiceControlBar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar"; @@ -8,6 +8,17 @@ import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let pending: void | Promise; + flushSync(() => { + pending = callback(); + }); + await pending!; + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); +} + describe("WorkspaceServiceControlBar", () => { let container: HTMLDivElement; let root: Root; @@ -105,4 +116,26 @@ describe("WorkspaceServiceControlBar", () => { expect(runningSegment).not.toBeNull(); expect(runningSegment?.className).toBe(stoppedSegment?.className); }); + + it("renders HTTPS cleanup separately from process running state", async () => { + await act(() => { + root.render( + {}} + />, + ); + }); + + expect(container.textContent).toContain("Running"); + expect(container.textContent).toContain("HTTPS cleanup pending"); + expect(container.querySelector(".text-destructive")).not.toBeNull(); + }); }); diff --git a/ui/src/components/WorkspaceServiceControlBar.tsx b/ui/src/components/WorkspaceServiceControlBar.tsx index 8862e2bf40e..4e3b632a106 100644 --- a/ui/src/components/WorkspaceServiceControlBar.tsx +++ b/ui/src/components/WorkspaceServiceControlBar.tsx @@ -35,6 +35,9 @@ export type WorkspaceServiceControlEntry = { port?: number | null; /** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */ failureDetail?: string | null; + /** HTTPS exposure lifecycle, intentionally separate from process health. */ + exposureState?: "pending" | "ready" | "failed" | "cleanup_pending" | "removed" | null; + exposureDetail?: string | null; canStart?: boolean; }; @@ -276,18 +279,20 @@ function ActionSlots({ ); } -function FailureDetail({ +function ServiceDetail({ entry, onViewLogs, }: { entry: WorkspaceServiceControlEntry; onViewLogs?: () => void; }) { - if (entry.state !== "failed" || !entry.failureDetail) return null; + const detail = entry.exposureDetail ?? (entry.state === "failed" ? entry.failureDetail : null); + if (!detail) return null; + const exposureFailed = entry.exposureState === "failed" || entry.exposureState === "cleanup_pending"; return ( -
- {entry.failureDetail} - {onViewLogs ? ( +
+ {detail} + {onViewLogs && entry.state === "failed" ? ( <> ·
- +
); } @@ -354,8 +359,10 @@ function ServicePopoverRow({ const meta = statusMeta(entry); const displayUrl = formatServiceUrl(entry.url); const live = entry.state === "running" && Boolean(entry.url); - const secondary = live - ? displayUrl + const secondary = entry.exposureDetail + ? entry.exposureDetail + : live + ? displayUrl : entry.state === "starting" && entry.port ? `starting on :${entry.port}…` : entry.state === "failed" && entry.failureDetail