paperclip/ui/src/components/WorkspaceServiceControlBar.tsx

553 lines
18 KiB
TypeScript

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";
import { copyTextToClipboard } from "@/lib/clipboard";
export type WorkspaceServiceControlState =
| "stopped"
| "provisioning"
| "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;
/** HTTPS exposure lifecycle, intentionally separate from process health. */
exposureState?: "pending" | "ready" | "failed" | "cleanup_pending" | "removed" | null;
exposureDetail?: 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[] = ["provisioning", "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 "provisioning":
return { label: "Provisioning…", unhealthy: false };
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 {
await copyTextToClipboard(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 ServiceDetail({
entry,
onViewLogs,
}: {
entry: WorkspaceServiceControlEntry;
onViewLogs?: () => void;
}) {
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={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
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>
<ServiceDetail 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 exposureFailed = entry.exposureState === "failed" || entry.exposureState === "cleanup_pending";
const secondary = entry.exposureDetail
? entry.exposureDetail
: 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={cn(
"min-w-0 text-xs",
exposureFailed
? "whitespace-normal break-words text-destructive"
: "truncate 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}
/>
);
}