feat(ui): surface runtime HTTPS lifecycle separately (PAP-17051)

Show provisioning, ready, failed, cleanup-pending, and removed exposure state without conflating it with backend process health, including actionable sanitized remediation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 19:26:07 +00:00
1 parent 831a2097ec
commit 90cf40bf0f
5 files changed
+109 -9

No files matched your search

+12
View File
@@ -860,6 +860,18 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
service.command ?? "No URL"
)}
</div>
{service.exposure && service.exposure.state !== "removed" ? (
<div
className={cn(
"text-(length:--text-nano)",
service.exposure.state === "failed" || service.exposure.state === "cleanup_pending"
? "text-destructive"
: "text-muted-foreground",
)}
>
HTTPS {service.exposure.state.replace("_", " ")}
</div>
) : null}
</div>
<div className="text-(length:--text-nano) text-muted-foreground whitespace-nowrap">
{service.lifecycle}
@@ -47,6 +47,7 @@ function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}):
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", () => {
@@ -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,
};
});
@@ -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<void>) {
let pending: void | Promise<void>;
flushSync(() => {
pending = callback();
});
await pending!;
await Promise.resolve();
await new Promise<void>((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(
<WorkspaceServiceControlBar
services={[{
key: "web",
name: "Web",
state: "running",
healthStatus: "healthy",
exposureState: "cleanup_pending",
exposureDetail: "HTTPS cleanup pending · Restart the host broker before reusing this port.",
}]}
onAction={() => {}}
/>,
);
});
expect(container.textContent).toContain("Running");
expect(container.textContent).toContain("HTTPS cleanup pending");
expect(container.querySelector(".text-destructive")).not.toBeNull();
});
});
@@ -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 (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{entry.failureDetail}</span>
{onViewLogs ? (
<div className={cn("flex items-center gap-1 text-xs", exposureFailed ? "text-destructive" : "text-muted-foreground")}>
<span>{detail}</span>
{onViewLogs && entry.state === "failed" ? (
<>
<span aria-hidden>·</span>
<button
@@ -339,7 +344,7 @@ function SingleServiceBar({
<UrlSegment entry={entry} compact />
</div>
</div>
<FailureDetail entry={entry} onViewLogs={onViewLogs} />
<ServiceDetail entry={entry} onViewLogs={onViewLogs} />
</div>
);
}
@@ -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