mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
1 parent
831a2097ec
commit
90cf40bf0f
5 files changed
+109
-9
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user