feat(ui): stabilize workspace service controls (#9705)
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent work. > - Execution workspaces provide the local service loop where operators start, stop, restart, inspect, and open workspace services. > - The existing header exposed those actions through separate controls whose position and labeling changed across runtime states. > - That movement made the most common development actions harder to scan and easier to misclick, especially with multiple services or long URLs. > - The design therefore uses one fixed-geometry, state-aware control bar and keeps service-specific detail behind a compact disclosure. > - This pull request adds that control surface, maps existing runtime data and every pending mutation into it, and integrates it into the execution-workspace header without changing server contracts. > - The benefit is a calmer, predictable service-control loop across stopped, transitional, running, unhealthy, failed, multi-service, and narrow-width states. ## Linked Issues or Issue Description ### Subsystem affected `ui/ — React + Vite board UI` ### Problem or motivation Execution-workspace service actions move and change shape as runtime state changes, while URLs and multi-service status compete for header space. During bulk actions, operators also need every targeted service to show its transitional state immediately. ### Proposed solution Use one fixed-geometry, state-aware service control bar in the workspace header. Map existing runtime records into a stable status, URL, and actions model, and track each in-flight bulk request independently until it settles. ### Alternatives considered Keeping the separate quick-control buttons was rejected because their geometry changes by state. Showing every service inline was rejected because it makes the header too wide; per-service detail remains in a compact disclosure and the Services tab. ### Roadmap alignment Reviewed `ROADMAP.md`; this focused execution-workspace UI improvement does not duplicate a listed roadmap initiative. ### Additional context The published design and state viewer is available at https://pages.paperclip.ing/pap-14233-workspace-service-controls/. ## What Changed - Added `WorkspaceServiceControlBar`, a fixed-geometry responsive control for single- and multi-service runtime states. - Added 15 Storybook states covering running, stopped, transitions, unhealthy, failed, disabled, long-URL, mobile, and multi-service behavior. - Replaced `WorkspaceRuntimeQuickControls` in the execution-workspace header with adapters that map live services and all pending requests into the new control model. - Added focused unit coverage for service-entry construction, bulk pending overlays, request resolution, clipboard feedback, and header integration. ## Verification - `cd ui && NODE_ENV=development pnpm vitest run src/components/WorkspaceServiceControlBar.test.tsx src/components/WorkspaceRuntimeControls.test.tsx src/pages/ExecutionWorkspaceDetail.test.tsx` — 29 tests passed. - `NODE_ENV=development pnpm --dir ui typecheck` — passed. - `pnpm check:token-gates` — all token gates clean. - Reviewed the Storybook captures for all primary states. ## Risks - Low-to-moderate UI risk: service controls depend on adapter mapping from existing runtime records; focused tests cover single-service and bulk-action mapping and integration paths. - Multi-service bulk actions intentionally apply to all eligible services, while per-service actions remain in the disclosure. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.3-codex`; runtime-managed context window; coding/reasoning mode with repository, terminal, Git, GitHub CLI, and test execution tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b07b2994cc
commit
5d42382df4
|
|
@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import {
|
||||
buildWorkspaceRuntimeControlItems,
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
buildWorkspaceServiceControlEntries,
|
||||
resolveWorkspaceServiceControlRequests,
|
||||
WorkspaceRuntimeQuickControls,
|
||||
WorkspaceRuntimeControls,
|
||||
} from "./WorkspaceRuntimeControls";
|
||||
|
|
@ -487,3 +489,149 @@ describe("WorkspaceRuntimeControls", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspaceServiceControlEntries", () => {
|
||||
const sections = () => buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({
|
||||
id: "service-web",
|
||||
serviceName: "web",
|
||||
status: "running",
|
||||
url: "http://localhost:3100",
|
||||
port: 3100,
|
||||
healthStatus: "healthy",
|
||||
}),
|
||||
],
|
||||
canStartServices: true,
|
||||
canRunJobs: true,
|
||||
});
|
||||
|
||||
it("maps service items to control bar entries and excludes jobs", () => {
|
||||
const entries = buildWorkspaceServiceControlEntries({ sections: sections() });
|
||||
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "web",
|
||||
state: "running",
|
||||
url: "http://localhost:3100",
|
||||
port: 3100,
|
||||
healthStatus: "healthy",
|
||||
failureDetail: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("overlays transitional states from the pending mutation", () => {
|
||||
const built = sections();
|
||||
const entries = buildWorkspaceServiceControlEntries({
|
||||
sections: built,
|
||||
isPending: true,
|
||||
pendingRequest: {
|
||||
action: "stop",
|
||||
workspaceCommandId: built.services[0].workspaceCommandId ?? null,
|
||||
runtimeServiceId: built.services[0].runtimeServiceId ?? null,
|
||||
serviceIndex: built.services[0].serviceIndex ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries[0].state).toBe("stopping");
|
||||
});
|
||||
|
||||
it("overlays every service targeted by a bulk mutation", () => {
|
||||
const built = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "api", name: "api", kind: "service", command: "pnpm api" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
createRuntimeService({
|
||||
id: "service-api",
|
||||
serviceName: "api",
|
||||
status: "running",
|
||||
command: "pnpm api",
|
||||
}),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
const pendingRequests = resolveWorkspaceServiceControlRequests(built, "stop", null);
|
||||
|
||||
const entries = buildWorkspaceServiceControlEntries({ sections: built, pendingRequests });
|
||||
|
||||
expect(entries.map((entry) => entry.state)).toEqual(["stopping", "stopping"]);
|
||||
});
|
||||
|
||||
it("builds a failure detail line from the stopped runtime service", () => {
|
||||
const failed = createRuntimeService({
|
||||
id: "service-web",
|
||||
serviceName: "web",
|
||||
status: "failed",
|
||||
stoppedAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
const built = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] },
|
||||
runtimeServices: [failed],
|
||||
canStartServices: true,
|
||||
});
|
||||
const entries = buildWorkspaceServiceControlEntries({
|
||||
sections: built,
|
||||
runtimeServices: [failed],
|
||||
});
|
||||
|
||||
expect(entries[0].state).toBe("failed");
|
||||
expect(entries[0].failureDetail).toMatch(/^Service failed · /);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceServiceControlRequests", () => {
|
||||
const mixedSections = () => buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
{ id: "api", name: "api", kind: "service", command: "pnpm api" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
|
||||
it("targets a single service by key", () => {
|
||||
const built = mixedSections();
|
||||
const requests = resolveWorkspaceServiceControlRequests(built, "stop", built.services[0].key);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ action: "stop", workspaceCommandId: "web", runtimeServiceId: "service-web" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops only active services for the aggregate stop", () => {
|
||||
const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "stop", null);
|
||||
|
||||
expect(requests).toEqual([expect.objectContaining({ action: "stop", workspaceCommandId: "web" })]);
|
||||
});
|
||||
|
||||
it("starts only inactive services for the aggregate start", () => {
|
||||
const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "start", null);
|
||||
|
||||
expect(requests).toEqual([expect.objectContaining({ action: "start", workspaceCommandId: "api" })]);
|
||||
});
|
||||
|
||||
it("restarts active services and starts stopped ones for the aggregate restart", () => {
|
||||
const requests = resolveWorkspaceServiceControlRequests(mixedSections(), "restart", null);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ action: "restart", workspaceCommandId: "web" }),
|
||||
expect.objectContaining({ action: "start", workspaceCommandId: "api" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import type {
|
||||
WorkspaceServiceControlAction,
|
||||
WorkspaceServiceControlEntry,
|
||||
} from "@/components/WorkspaceServiceControlBar";
|
||||
|
||||
export type WorkspaceRuntimeAction = "start" | "stop" | "restart" | "run";
|
||||
|
||||
|
|
@ -202,6 +207,101 @@ export function getRunningRuntimeServiceUrl(
|
|||
return runningService?.url ?? null;
|
||||
}
|
||||
|
||||
function isActiveStatusLabel(statusLabel: string) {
|
||||
return statusLabel === "running" || statusLabel === "starting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps runtime control sections onto the fixed-geometry service control bar
|
||||
* model. In-flight mutations overlay the transitional states (starting /
|
||||
* stopping / restarting) that the server status enum does not carry.
|
||||
*/
|
||||
export function buildWorkspaceServiceControlEntries(input: {
|
||||
sections: WorkspaceRuntimeControlSections;
|
||||
runtimeServices?: WorkspaceRuntimeService[] | null;
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
pendingRequests?: WorkspaceRuntimeControlRequest[];
|
||||
}): WorkspaceServiceControlEntry[] {
|
||||
const runtimeServicesById = new Map(
|
||||
(input.runtimeServices ?? []).map((runtimeService) => [runtimeService.id, runtimeService]),
|
||||
);
|
||||
const pendingRequests = input.pendingRequests
|
||||
?? (input.isPending && input.pendingRequest ? [input.pendingRequest] : []);
|
||||
|
||||
return [...input.sections.services, ...input.sections.otherServices].map((item) => {
|
||||
let state: WorkspaceServiceControlEntry["state"] =
|
||||
item.statusLabel === "running"
|
||||
? "running"
|
||||
: item.statusLabel === "starting"
|
||||
? "starting"
|
||||
: item.statusLabel === "failed"
|
||||
? "failed"
|
||||
: "stopped";
|
||||
|
||||
const pendingRequest = pendingRequests.find((request) =>
|
||||
request.action !== "run"
|
||||
&& (request.workspaceCommandId ?? null) === (item.workspaceCommandId ?? null)
|
||||
&& (request.runtimeServiceId ?? null) === (item.runtimeServiceId ?? null)
|
||||
&& (request.serviceIndex ?? null) === (item.serviceIndex ?? null));
|
||||
if (pendingRequest) {
|
||||
state = pendingRequest.action === "stop"
|
||||
? "stopping"
|
||||
: pendingRequest.action === "restart"
|
||||
? "restarting"
|
||||
: "starting";
|
||||
}
|
||||
|
||||
const runtimeService = item.runtimeServiceId ? runtimeServicesById.get(item.runtimeServiceId) ?? null : null;
|
||||
const failureDetail = state === "failed"
|
||||
? `Service failed${runtimeService?.stoppedAt ? ` · ${timeAgo(runtimeService.stoppedAt)}` : ""}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
name: item.title,
|
||||
state,
|
||||
healthStatus: item.healthStatus,
|
||||
url: item.url,
|
||||
port: item.port,
|
||||
failureDetail,
|
||||
canStart: item.canStart,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a control-bar action into the runtime control requests to fire.
|
||||
* A null serviceKey targets every applicable service (the aggregate bar and
|
||||
* popover bulk actions).
|
||||
*/
|
||||
export function resolveWorkspaceServiceControlRequests(
|
||||
sections: WorkspaceRuntimeControlSections,
|
||||
action: WorkspaceServiceControlAction,
|
||||
serviceKey: string | null,
|
||||
): WorkspaceRuntimeControlRequest[] {
|
||||
const items = [...sections.services, ...sections.otherServices];
|
||||
if (serviceKey !== null) {
|
||||
const item = items.find((candidate) => candidate.key === serviceKey);
|
||||
return item ? [buildRequest(item, action)] : [];
|
||||
}
|
||||
if (action === "stop") {
|
||||
return items
|
||||
.filter((item) => isActiveStatusLabel(item.statusLabel))
|
||||
.map((item) => buildRequest(item, "stop"));
|
||||
}
|
||||
if (action === "start") {
|
||||
return items
|
||||
.filter((item) => !isActiveStatusLabel(item.statusLabel) && item.canStart)
|
||||
.map((item) => buildRequest(item, "start"));
|
||||
}
|
||||
return items.flatMap((item) => {
|
||||
if (isActiveStatusLabel(item.statusLabel)) return [buildRequest(item, "restart")];
|
||||
if (item.canStart) return [buildRequest(item, "start")];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function requestMatchesPending(
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
||||
nextRequest: WorkspaceRuntimeControlRequest,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("WorkspaceServiceControlBar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function renderRunningService() {
|
||||
await act(() => {
|
||||
root.render(
|
||||
<WorkspaceServiceControlBar
|
||||
services={[{
|
||||
key: "web",
|
||||
name: "Web",
|
||||
state: "running",
|
||||
healthStatus: "healthy",
|
||||
url: "http://127.0.0.1:3100",
|
||||
}]}
|
||||
onAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return container.querySelector<HTMLButtonElement>('button[aria-label="Copy URL"]')!;
|
||||
}
|
||||
|
||||
it("shows success only after the URL reaches the clipboard", async () => {
|
||||
const copyButton = await renderRunningService();
|
||||
|
||||
await act(async () => {
|
||||
copyButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("http://127.0.0.1:3100");
|
||||
expect(copyButton.getAttribute("aria-label")).toBe("URL copied");
|
||||
});
|
||||
|
||||
it("shows failure when the clipboard rejects the write", async () => {
|
||||
writeText.mockRejectedValueOnce(new Error("permission denied"));
|
||||
const copyButton = await renderRunningService();
|
||||
|
||||
await act(async () => {
|
||||
copyButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(copyButton.getAttribute("aria-label")).toBe("Copy failed");
|
||||
expect(copyButton.querySelector(".text-destructive")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reserves the desktop URL segment across service states", async () => {
|
||||
const renderService = async (state: "stopped" | "running", url: string | null) => {
|
||||
await act(() => {
|
||||
root.render(
|
||||
<WorkspaceServiceControlBar
|
||||
services={[{
|
||||
key: "web",
|
||||
name: "Web",
|
||||
state,
|
||||
healthStatus: state === "running" ? "healthy" : null,
|
||||
url,
|
||||
port: 3100,
|
||||
}]}
|
||||
onAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const urlText = state === "running"
|
||||
? container.querySelector<HTMLAnchorElement>('a[href="http://127.0.0.1:3100"]')
|
||||
: Array.from(container.querySelectorAll("span")).find((element) => element.textContent === ":3100");
|
||||
return urlText?.parentElement;
|
||||
};
|
||||
|
||||
const stoppedSegment = await renderService("stopped", null);
|
||||
expect(stoppedSegment).not.toBeNull();
|
||||
expect(stoppedSegment?.classList.contains("w-56")).toBe(true);
|
||||
expect(stoppedSegment?.classList.contains("shrink-0")).toBe(true);
|
||||
|
||||
const runningSegment = await renderService("running", "http://127.0.0.1:3100");
|
||||
expect(runningSegment).not.toBeNull();
|
||||
expect(runningSegment?.className).toBe(stoppedSegment?.className);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type WorkspaceServiceControlState =
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "restarting"
|
||||
| "failed";
|
||||
|
||||
export type WorkspaceServiceControlAction = "start" | "stop" | "restart";
|
||||
|
||||
export type WorkspaceServiceControlEntry = {
|
||||
key: string;
|
||||
name: string;
|
||||
state: WorkspaceServiceControlState;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy" | null;
|
||||
url?: string | null;
|
||||
port?: number | null;
|
||||
/** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */
|
||||
failureDetail?: string | null;
|
||||
canStart?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceServiceControlBarProps = {
|
||||
services: WorkspaceServiceControlEntry[];
|
||||
/** serviceKey is null when the action targets all services (aggregate bar / popover footer). */
|
||||
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
||||
onViewLogs?: () => void;
|
||||
/** Optional link target for "Manage in Services tab" in the multi-service popover. */
|
||||
onManageServices?: () => void;
|
||||
/** Initial open state for the multi-service popover (used by Storybook/static captures). */
|
||||
defaultServicesOpen?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["starting", "stopping", "restarting"];
|
||||
|
||||
function isTransitional(state: WorkspaceServiceControlState) {
|
||||
return TRANSITIONAL_STATES.includes(state);
|
||||
}
|
||||
|
||||
function formatServiceUrl(url: string | null | undefined) {
|
||||
if (!url) return null;
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function statusMeta(entry: WorkspaceServiceControlEntry): { label: string; unhealthy: boolean } {
|
||||
switch (entry.state) {
|
||||
case "starting":
|
||||
return { label: "Starting…", unhealthy: false };
|
||||
case "stopping":
|
||||
return { label: "Stopping…", unhealthy: false };
|
||||
case "restarting":
|
||||
return { label: "Restarting…", unhealthy: false };
|
||||
case "failed":
|
||||
return { label: "Failed", unhealthy: false };
|
||||
case "running":
|
||||
return entry.healthStatus === "unhealthy"
|
||||
? { label: "Unhealthy", unhealthy: true }
|
||||
: { label: "Running", unhealthy: false };
|
||||
default:
|
||||
return { label: "Stopped", unhealthy: false };
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIndicator({ entry, className }: { entry: WorkspaceServiceControlEntry; className?: string }) {
|
||||
if (isTransitional(entry.state)) {
|
||||
return <Loader2 className={cn("size-3 shrink-0 animate-spin text-muted-foreground", className)} />;
|
||||
}
|
||||
if (entry.state === "failed") {
|
||||
return <TriangleAlert className={cn("size-3 shrink-0 text-destructive", className)} />;
|
||||
}
|
||||
const unhealthy = entry.state === "running" && entry.healthStatus === "unhealthy";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full",
|
||||
entry.state === "running"
|
||||
? unhealthy
|
||||
? "bg-amber-500 ring-2 ring-amber-500/30"
|
||||
: "bg-emerald-500"
|
||||
: "border border-muted-foreground/60 bg-transparent",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
const copyLabel = copyState === "copied" ? "URL copied" : copyState === "failed" ? "Copy failed" : "Copy URL";
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={disabled}
|
||||
aria-label={copyLabel}
|
||||
title={copyLabel}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (!navigator.clipboard) throw new Error("Clipboard API unavailable");
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setCopyState("idle"), 1500);
|
||||
}}
|
||||
>
|
||||
{copyState === "copied" ? (
|
||||
<Check className="size-3" />
|
||||
) : copyState === "failed" ? (
|
||||
<TriangleAlert className="size-3 text-destructive" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
<span className="sr-only" aria-live="polite">{copyLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlSegment({ entry, compact }: { entry: WorkspaceServiceControlEntry; compact?: boolean }) {
|
||||
const displayUrl = formatServiceUrl(entry.url) ?? (entry.port ? `:${entry.port}` : null);
|
||||
const live = entry.state === "running" && Boolean(entry.url);
|
||||
|
||||
if (!displayUrl) {
|
||||
return <span className="font-mono text-xs text-muted-foreground/70">no url</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{live ? (
|
||||
<a
|
||||
href={entry.url ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={entry.url ?? undefined}
|
||||
className={cn("min-w-0 truncate font-mono text-xs text-foreground hover:underline", compact ? "max-w-44" : "max-w-56")}
|
||||
>
|
||||
{displayUrl}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
title={entry.url ?? undefined}
|
||||
className={cn("min-w-0 truncate font-mono text-xs text-muted-foreground/70", compact ? "max-w-44" : "max-w-56")}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("flex items-center", live ? null : "invisible")} aria-hidden={live ? undefined : true}>
|
||||
<CopyUrlButton url={entry.url ?? ""} disabled={!live} />
|
||||
<Button
|
||||
asChild={live}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!live}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Open in new tab"
|
||||
>
|
||||
{live ? (
|
||||
<a href={entry.url ?? undefined} target="_blank" rel="noreferrer" aria-label="Open in new tab">
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : (
|
||||
<ExternalLink className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionSlots({
|
||||
entry,
|
||||
onAction,
|
||||
}: {
|
||||
entry: Pick<WorkspaceServiceControlEntry, "state" | "canStart">;
|
||||
onAction: (action: WorkspaceServiceControlAction) => void;
|
||||
}) {
|
||||
const transitional = isTransitional(entry.state);
|
||||
const canStart = entry.canStart ?? true;
|
||||
|
||||
if (entry.state === "stopped") {
|
||||
return (
|
||||
<Button
|
||||
variant="cta"
|
||||
size="xs"
|
||||
className="w-13 justify-center"
|
||||
disabled={!canStart}
|
||||
onClick={() => onAction("start")}
|
||||
aria-label="Start"
|
||||
title="Start"
|
||||
>
|
||||
<Play className="size-3" />
|
||||
Start
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.state === "failed") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="cta"
|
||||
size="icon-xs"
|
||||
disabled={!canStart}
|
||||
onClick={() => onAction("start")}
|
||||
aria-label="Start"
|
||||
title="Start"
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!canStart}
|
||||
onClick={() => onAction("restart")}
|
||||
aria-label="Restart"
|
||||
title="Restart"
|
||||
className="border border-border text-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={transitional}
|
||||
onClick={() => onAction("stop")}
|
||||
aria-label="Stop"
|
||||
title="Stop"
|
||||
className="border border-border text-foreground"
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={transitional || !canStart}
|
||||
onClick={() => onAction("restart")}
|
||||
aria-label="Restart"
|
||||
title="Restart"
|
||||
className="border border-border text-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureDetail({
|
||||
entry,
|
||||
onViewLogs,
|
||||
}: {
|
||||
entry: WorkspaceServiceControlEntry;
|
||||
onViewLogs?: () => void;
|
||||
}) {
|
||||
if (entry.state !== "failed" || !entry.failureDetail) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{entry.failureDetail}</span>
|
||||
{onViewLogs ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewLogs}
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80"
|
||||
>
|
||||
View logs
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleServiceBar({
|
||||
entry,
|
||||
onAction,
|
||||
onViewLogs,
|
||||
className,
|
||||
}: {
|
||||
entry: WorkspaceServiceControlEntry;
|
||||
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
||||
onViewLogs?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = statusMeta(entry);
|
||||
return (
|
||||
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
|
||||
<div className="rounded-lg border border-border bg-background">
|
||||
<div className="flex h-9 items-center pl-3 pr-1.5">
|
||||
<div className="flex items-center gap-2 sm:min-w-24">
|
||||
<StatusIndicator entry={entry} />
|
||||
<span className="whitespace-nowrap text-xs font-medium text-foreground">{meta.label}</span>
|
||||
</div>
|
||||
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
||||
<div className="hidden w-56 min-w-0 shrink-0 items-center gap-0.5 sm:flex">
|
||||
<UrlSegment entry={entry} />
|
||||
</div>
|
||||
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
||||
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
|
||||
<ActionSlots
|
||||
entry={entry}
|
||||
onAction={(action) => onAction(action, entry.key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
|
||||
<UrlSegment entry={entry} compact />
|
||||
</div>
|
||||
</div>
|
||||
<FailureDetail entry={entry} onViewLogs={onViewLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicePopoverRow({
|
||||
entry,
|
||||
onAction,
|
||||
}: {
|
||||
entry: WorkspaceServiceControlEntry;
|
||||
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
||||
}) {
|
||||
const meta = statusMeta(entry);
|
||||
const displayUrl = formatServiceUrl(entry.url);
|
||||
const live = entry.state === "running" && Boolean(entry.url);
|
||||
const secondary = live
|
||||
? displayUrl
|
||||
: entry.state === "starting" && entry.port
|
||||
? `starting on :${entry.port}…`
|
||||
: entry.state === "failed" && entry.failureDetail
|
||||
? entry.failureDetail
|
||||
: `${meta.label.toLowerCase().replace(/…$/, "")}${entry.port ? ` · :${entry.port}` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5">
|
||||
<StatusIndicator entry={entry} className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{entry.name}</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{live && entry.url ? (
|
||||
<>
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={entry.url}
|
||||
className="min-w-0 truncate font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{displayUrl}
|
||||
</a>
|
||||
<CopyUrlButton url={entry.url} />
|
||||
</>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">{secondary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<ActionSlots entry={entry} onAction={(action) => onAction(action, entry.key)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiServiceBar({
|
||||
services,
|
||||
onAction,
|
||||
onManageServices,
|
||||
defaultServicesOpen,
|
||||
className,
|
||||
}: {
|
||||
services: WorkspaceServiceControlEntry[];
|
||||
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
||||
onManageServices?: () => void;
|
||||
defaultServicesOpen?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultServicesOpen ?? false);
|
||||
const runningCount = services.filter((entry) => entry.state === "running").length;
|
||||
const anyTransitional = services.some((entry) => isTransitional(entry.state));
|
||||
const anyFailed = services.some((entry) => entry.state === "failed");
|
||||
const anyRunning = runningCount > 0;
|
||||
const primary = services.find((entry) => entry.state === "running" && entry.url) ?? null;
|
||||
|
||||
const aggregateEntry: WorkspaceServiceControlEntry = {
|
||||
key: "__all__",
|
||||
name: "All services",
|
||||
state: anyTransitional
|
||||
? "starting"
|
||||
: anyFailed
|
||||
? "failed"
|
||||
: anyRunning
|
||||
? "running"
|
||||
: "stopped",
|
||||
healthStatus: services.some((entry) => entry.state === "running" && entry.healthStatus === "unhealthy")
|
||||
? "unhealthy"
|
||||
: "healthy",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
|
||||
<div className="rounded-lg border border-border bg-background">
|
||||
<div className="flex h-9 items-center pl-3 pr-1.5">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full items-center gap-2 rounded-l-lg pr-1 text-xs font-medium text-foreground hover:bg-accent"
|
||||
aria-label={`${runningCount} of ${services.length} services running — show services`}
|
||||
>
|
||||
<StatusIndicator entry={aggregateEntry} />
|
||||
<span className="whitespace-nowrap">{runningCount}/{services.length} running</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-0" onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="px-4 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Services · {services.length}
|
||||
</div>
|
||||
<div className="divide-y divide-border px-4">
|
||||
{services.map((entry) => (
|
||||
<ServicePopoverRow key={entry.key} entry={entry} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border-t border-border px-4 py-2">
|
||||
<Button variant="ghost" size="xs" onClick={() => onAction("start", null)}>Start all</Button>
|
||||
<Button variant="ghost" size="xs" onClick={() => onAction("stop", null)}>Stop all</Button>
|
||||
<Button variant="ghost" size="xs" onClick={() => onAction("restart", null)}>Restart all</Button>
|
||||
{onManageServices ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={onManageServices}
|
||||
>
|
||||
Manage in Services tab →
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
||||
<div className="hidden min-w-0 items-center gap-0.5 sm:flex">
|
||||
{primary ? (
|
||||
<>
|
||||
<span className="mr-1 shrink-0 text-xs text-muted-foreground">{primary.name}</span>
|
||||
<UrlSegment entry={primary} />
|
||||
</>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground/70">no url</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
||||
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
|
||||
<ActionSlots
|
||||
entry={{ state: aggregateEntry.state, canStart: true }}
|
||||
onAction={(action) => onAction(action, null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{primary ? (
|
||||
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
|
||||
<UrlSegment entry={primary} compact />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control bar for execution-workspace services: status · URL · actions.
|
||||
* Geometry is identical in every state — transitions are announced by the status
|
||||
* segment (spinner + label) instead of buttons appearing and disappearing.
|
||||
*/
|
||||
export function WorkspaceServiceControlBar({
|
||||
services,
|
||||
onAction,
|
||||
onViewLogs,
|
||||
onManageServices,
|
||||
defaultServicesOpen,
|
||||
className,
|
||||
}: WorkspaceServiceControlBarProps) {
|
||||
if (services.length === 0) return null;
|
||||
if (services.length === 1) {
|
||||
return (
|
||||
<SingleServiceBar
|
||||
entry={services[0]}
|
||||
onAction={onAction}
|
||||
onViewLogs={onViewLogs}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MultiServiceBar
|
||||
services={services}
|
||||
onAction={onAction}
|
||||
onManageServices={onManageServices}
|
||||
defaultServicesOpen={defaultServicesOpen}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,9 +93,13 @@ vi.mock("../components/RoutineRunVariablesDialog", () => ({
|
|||
}));
|
||||
vi.mock("../components/WorkspaceRuntimeControls", () => ({
|
||||
buildWorkspaceRuntimeControlSections: () => [],
|
||||
WorkspaceRuntimeQuickControls: () => <div data-testid="runtime-quick-controls" />,
|
||||
buildWorkspaceServiceControlEntries: () => [],
|
||||
resolveWorkspaceServiceControlRequests: () => [],
|
||||
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
|
||||
}));
|
||||
vi.mock("../components/WorkspaceServiceControlBar", () => ({
|
||||
WorkspaceServiceControlBar: () => <div data-testid="service-control-bar" />,
|
||||
}));
|
||||
vi.mock("../components/PageTabBar", () => ({
|
||||
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
|
||||
<div data-testid="page-tab-bar">
|
||||
|
|
|
|||
|
|
@ -28,10 +28,12 @@ import {
|
|||
} from "../components/RoutineRunVariablesDialog";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeQuickControls,
|
||||
buildWorkspaceServiceControlEntries,
|
||||
resolveWorkspaceServiceControlRequests,
|
||||
WorkspaceRuntimeControls,
|
||||
type WorkspaceRuntimeControlRequest,
|
||||
} from "../components/WorkspaceRuntimeControls";
|
||||
import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceControlBar";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
|
|
@ -694,6 +696,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const [pendingRuntimeActions, setPendingRuntimeActions] = useState<WorkspaceRuntimeControlRequest[]>([]);
|
||||
const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
|
||||
const pluginTabFromSearch = useMemo(() => {
|
||||
const tab = new URLSearchParams(location.search).get("tab");
|
||||
|
|
@ -804,6 +807,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
setForm(formStateFromWorkspace(workspace));
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionErrorMessage(null);
|
||||
setPendingRuntimeActions([]);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -864,6 +868,9 @@ export function ExecutionWorkspaceDetail() {
|
|||
setRuntimeActionMessage(null);
|
||||
setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
|
||||
},
|
||||
onSettled: (_result, _error, request) => {
|
||||
setPendingRuntimeActions((current) => current.filter((pendingRequest) => pendingRequest !== request));
|
||||
},
|
||||
});
|
||||
|
||||
if (workspaceQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
|
|
@ -885,6 +892,11 @@ export function ExecutionWorkspaceDetail() {
|
|||
canRunJobs: canRunWorkspaceCommands,
|
||||
});
|
||||
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
|
||||
const serviceControlEntries = buildWorkspaceServiceControlEntries({
|
||||
sections: runtimeControlSections,
|
||||
runtimeServices: workspace.runtimeServices ?? [],
|
||||
pendingRequests: pendingRuntimeActions,
|
||||
});
|
||||
|
||||
const pluginSlotContext = {
|
||||
companyId: workspace.companyId,
|
||||
|
|
@ -925,6 +937,12 @@ export function ExecutionWorkspaceDetail() {
|
|||
updateWorkspace.mutate(patch);
|
||||
};
|
||||
|
||||
const runRuntimeControlRequests = (requests: WorkspaceRuntimeControlRequest[]) => {
|
||||
if (requests.length === 0) return;
|
||||
setPendingRuntimeActions((current) => [...current, ...requests]);
|
||||
for (const request of requests) controlRuntimeServices.mutate(request);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 overflow-hidden sm:space-y-6">
|
||||
|
|
@ -935,11 +953,15 @@ export function ExecutionWorkspaceDetail() {
|
|||
</div>
|
||||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||
</div>
|
||||
<WorkspaceRuntimeQuickControls
|
||||
sections={runtimeControlSections}
|
||||
isPending={controlRuntimeServices.isPending}
|
||||
pendingRequest={pendingRuntimeAction}
|
||||
onAction={(request) => controlRuntimeServices.mutate(request)}
|
||||
<WorkspaceServiceControlBar
|
||||
services={serviceControlEntries}
|
||||
onAction={(action, serviceKey) => {
|
||||
runRuntimeControlRequests(
|
||||
resolveWorkspaceServiceControlRequests(runtimeControlSections, action, serviceKey),
|
||||
);
|
||||
}}
|
||||
onViewLogs={() => handleTabChange("runtime_logs")}
|
||||
onManageServices={() => handleTabChange("services")}
|
||||
/>
|
||||
</div>
|
||||
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
||||
|
|
@ -979,7 +1001,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
? null
|
||||
: "Execution workspaces need a working directory before local commands can run, and services also need runtime config."
|
||||
}
|
||||
onAction={(request) => controlRuntimeServices.mutate(request)}
|
||||
onAction={(request) => runRuntimeControlRequests([request])}
|
||||
/>
|
||||
) : activeTab === "configuration" ? (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
WorkspaceServiceControlBar,
|
||||
type WorkspaceServiceControlEntry,
|
||||
} from "@/components/WorkspaceServiceControlBar";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function entry(overrides: Partial<WorkspaceServiceControlEntry> = {}): WorkspaceServiceControlEntry {
|
||||
return {
|
||||
key: "svc-dev",
|
||||
name: "dev",
|
||||
state: "running",
|
||||
healthStatus: "healthy",
|
||||
url: "http://paperclip-dev:45439",
|
||||
port: 45439,
|
||||
canStart: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof WorkspaceServiceControlBar> = {
|
||||
title: "Workspaces/Service control bar",
|
||||
component: WorkspaceServiceControlBar,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
args: {
|
||||
onAction: noop,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceServiceControlBar>;
|
||||
|
||||
export const Running: Story = {
|
||||
args: { services: [entry()] },
|
||||
};
|
||||
|
||||
export const Stopped: Story = {
|
||||
args: { services: [entry({ state: "stopped" })] },
|
||||
};
|
||||
|
||||
export const Starting: Story = {
|
||||
args: { services: [entry({ state: "starting" })] },
|
||||
};
|
||||
|
||||
export const Stopping: Story = {
|
||||
args: { services: [entry({ state: "stopping" })] },
|
||||
};
|
||||
|
||||
export const Restarting: Story = {
|
||||
args: { services: [entry({ state: "restarting" })] },
|
||||
};
|
||||
|
||||
export const RunningUnhealthy: Story = {
|
||||
name: "Running · unhealthy",
|
||||
args: { services: [entry({ healthStatus: "unhealthy" })] },
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
services: [
|
||||
entry({
|
||||
state: "failed",
|
||||
failureDetail: "dev exited with code 1, 12s ago",
|
||||
}),
|
||||
],
|
||||
onViewLogs: noop,
|
||||
},
|
||||
};
|
||||
|
||||
export const StartDisabled: Story = {
|
||||
name: "Stopped · start unavailable",
|
||||
args: { services: [entry({ state: "stopped", canStart: false })] },
|
||||
};
|
||||
|
||||
export const LongUrl: Story = {
|
||||
name: "Running · long URL truncates",
|
||||
args: {
|
||||
services: [
|
||||
entry({
|
||||
url: "https://pap-14233-execution-workspace-service-start-stop.preview.paperclip.ing/deeply/nested/path",
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const MULTI_SERVICES: WorkspaceServiceControlEntry[] = [
|
||||
entry({ key: "svc-web", name: "web" }),
|
||||
entry({ key: "svc-api", name: "api", state: "starting", url: null, port: 8080 }),
|
||||
entry({ key: "svc-worker", name: "worker", state: "stopped", url: null, port: null }),
|
||||
];
|
||||
|
||||
export const MultiService: Story = {
|
||||
name: "Multiple services (collapsed)",
|
||||
args: { services: MULTI_SERVICES },
|
||||
};
|
||||
|
||||
export const MultiServiceOpen: Story = {
|
||||
name: "Multiple services (popover open)",
|
||||
args: {
|
||||
services: MULTI_SERVICES,
|
||||
defaultServicesOpen: true,
|
||||
onManageServices: noop,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex min-h-96 justify-end pr-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const HeaderContext: Story = {
|
||||
name: "In header context",
|
||||
render: (args) => (
|
||||
<div className="max-w-5xl rounded-xl border border-border bg-background p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Execution workspace
|
||||
</div>
|
||||
<h1 className="mt-1 truncate text-2xl font-bold text-foreground">
|
||||
PAP-14025-skills-need-to-be-organized-in-folders-the-ta…
|
||||
</h1>
|
||||
</div>
|
||||
<WorkspaceServiceControlBar {...args} />
|
||||
</div>
|
||||
<div className="mt-8 flex gap-6 border-b border-border pb-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Tasks</span>
|
||||
<span>Services</span>
|
||||
<span>Configuration</span>
|
||||
<span>Runtime logs</span>
|
||||
<span>Runs</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: { services: [entry()] },
|
||||
};
|
||||
|
||||
export const HeaderContextFailed: Story = {
|
||||
name: "In header context · failed",
|
||||
render: (args) => (
|
||||
<div className="max-w-5xl rounded-xl border border-border bg-background p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Execution workspace
|
||||
</div>
|
||||
<h1 className="mt-1 truncate text-2xl font-bold text-foreground">
|
||||
PAP-14025-skills-need-to-be-organized-in-folders-the-ta…
|
||||
</h1>
|
||||
</div>
|
||||
<WorkspaceServiceControlBar {...args} />
|
||||
</div>
|
||||
<div className="mt-8 flex gap-6 border-b border-border pb-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Tasks</span>
|
||||
<span>Services</span>
|
||||
<span>Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
services: [
|
||||
entry({
|
||||
state: "failed",
|
||||
failureDetail: "dev exited with code 1, 12s ago",
|
||||
}),
|
||||
],
|
||||
onViewLogs: noop,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileWidth: Story = {
|
||||
name: "Mobile width (two-row card)",
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-80 rounded-xl border border-dashed border-border p-3">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: { services: [entry()] },
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile1" },
|
||||
},
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
name: "All states (overview)",
|
||||
render: () => (
|
||||
<div className="flex max-w-2xl flex-col items-end gap-3">
|
||||
{(
|
||||
[
|
||||
["Stopped", entry({ state: "stopped" })],
|
||||
["Starting", entry({ state: "starting" })],
|
||||
["Running", entry()],
|
||||
["Unhealthy", entry({ healthStatus: "unhealthy" })],
|
||||
["Stopping", entry({ state: "stopping" })],
|
||||
["Restarting", entry({ state: "restarting" })],
|
||||
["Failed", entry({ state: "failed", failureDetail: "dev exited with code 1, 12s ago" })],
|
||||
] as const
|
||||
).map(([label, service]) => (
|
||||
<div key={label} className="flex w-full items-center justify-between gap-6">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
<WorkspaceServiceControlBar services={[service]} onAction={noop} onViewLogs={noop} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
Loading…
Reference in New Issue