Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s
`GET /api/v1/search/` hands the client one flat index of everything worth jumping to — flows and the nodes inside them, dashboards and the widgets on them, panels, secrets, modules, workers and alert channels — and cmdk matches it in the browser, so results narrow while typing without a round trip per keystroke. A node hit is the one thing no list endpoint could answer: it opens its flow with that node in focus. The panel is reached from **Search** above Documentation in the sidebar, or ⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two. The panels dialog gains an address (`/dashboards?panels`) so a panel hit has somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width animates while the logo is already back, and a flex item short of room is squeezed rather than clipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import { useReactFlow } from "@xyflow/react"
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
Maximize2,
|
|
Pause,
|
|
Pencil,
|
|
Play,
|
|
PlayCircle,
|
|
Plus,
|
|
StepForward,
|
|
WifiOff,
|
|
X,
|
|
} from "lucide-react"
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import { useEffect, useState } from "react"
|
|
|
|
import type { ValidationIssue } from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import { FluksioLoader } from "@/components/ui/fluksio-loader"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { slideUp, transitions } from "@/lib/motion"
|
|
import { cn } from "@/lib/utils"
|
|
import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel"
|
|
import { useLiveConnection } from "./liveStore"
|
|
|
|
/**
|
|
* What "fit" means on this canvas: the view a flow opens with, and the one the
|
|
* fit button returns to. The generous padding keeps the graph clear of the
|
|
* chrome floating over it, and the cap stops a two-node flow from being blown
|
|
* up past legibility.
|
|
*/
|
|
export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
|
|
|
/**
|
|
* The same fit with the settings panel's lane held clear, so an opening panel
|
|
* never lands on the node you are looking at. 432px is the lane the chrome is
|
|
* pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %,
|
|
* so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above
|
|
* resolves to, so the two fits are equally generous.
|
|
*/
|
|
export const FIT_VIEW_PANEL = {
|
|
maxZoom: FIT_VIEW.maxZoom,
|
|
// `as const` because xyflow types each side as `${number}px | ${number}%`,
|
|
// which a widened `string` does not satisfy.
|
|
padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const,
|
|
}
|
|
|
|
/**
|
|
* The action bar, floating bottom-centre. Run is the one brand-secondary
|
|
* affordance on this view; everything else stays quiet.
|
|
*
|
|
* Everything the flow bar used to carry is here too — whether the work is
|
|
* saved, the flow's own settings, and putting it live or throwing it away —
|
|
* so the top of the canvas is left to say which flow this is.
|
|
*
|
|
* It wraps rather than overflows, and drops the fit control on a phone; see
|
|
* DESIGN-GUIDELINES.md → Responsive.
|
|
*/
|
|
export function FlowDock({
|
|
flow,
|
|
issues,
|
|
running,
|
|
enabled,
|
|
paused,
|
|
saving,
|
|
hasDraft,
|
|
publishing,
|
|
logs,
|
|
onAddNode,
|
|
onRun,
|
|
onTogglePause,
|
|
onStep,
|
|
stepping,
|
|
onFocusNode,
|
|
onEditFlow,
|
|
onPublish,
|
|
onDiscard,
|
|
className,
|
|
}: {
|
|
flow: string
|
|
issues: ValidationIssue[]
|
|
running: boolean
|
|
enabled: boolean
|
|
paused: boolean
|
|
saving: boolean
|
|
hasDraft: boolean
|
|
publishing: boolean
|
|
/** Owned by the editor, because a failing node can open it too. */
|
|
logs: LogsFilter
|
|
onAddNode: () => void
|
|
onRun: () => void
|
|
onTogglePause: () => void
|
|
onStep: () => void
|
|
stepping: boolean
|
|
onFocusNode: (nodeId: string) => void
|
|
onEditFlow: () => void
|
|
onPublish: () => void
|
|
onDiscard: () => void
|
|
className?: string
|
|
}) {
|
|
const { fitView } = useReactFlow()
|
|
const connected = useLiveConnection()
|
|
const [issuesOpen, setIssuesOpen] = useState(false)
|
|
// The engine marks the issues it does not count against a flow. They are
|
|
// still worth saying, so they stay in the list — but in the muted tone, and
|
|
// without turning the summary red, since nothing here is actually broken.
|
|
const faults = issues.filter((issue) => !issue.advisory)
|
|
|
|
return (
|
|
// The panels and the bar are siblings in one column, so neither nests its
|
|
// half-pixel centring translate or its backdrop filter inside the other —
|
|
// which is what Firefox was drawing the console's text through. It also
|
|
// gives the two panels a row to share, instead of one landing on the other.
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
|
|
<IssuesPanel
|
|
open={issuesOpen}
|
|
issues={issues}
|
|
onClose={() => setIssuesOpen(false)}
|
|
onFocusNode={onFocusNode}
|
|
/>
|
|
<LogsPanel flow={flow} {...logs} />
|
|
</div>
|
|
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
transition={transitions.emphasized}
|
|
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
|
// put the buttons at its ends out of reach on a phone rather than merely
|
|
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
|
className="pointer-events-auto flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={onAddNode}
|
|
aria-label="Add node"
|
|
data-testid="add-node"
|
|
>
|
|
<Plus />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Add a node (⌘P)</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-0.5 !h-5 hidden md:block"
|
|
/>
|
|
|
|
{/* A phone pinches to zoom and the graph fits itself, so this would only
|
|
be taking room the rest of the bar needs. */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
|
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
|
|
aria-label="Fit the flow to the screen"
|
|
>
|
|
<Maximize2 />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Fit to screen</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{issues.length > 0 ? (
|
|
<>
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-0.5 !h-5 hidden md:block"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
"h-11 gap-1.5 md:h-8",
|
|
faults.length > 0
|
|
? "text-destructive"
|
|
: issuesOpen
|
|
? "text-primary"
|
|
: "text-muted-foreground",
|
|
)}
|
|
onClick={() => setIssuesOpen(!issuesOpen)}
|
|
aria-pressed={issuesOpen}
|
|
data-testid="validation-summary"
|
|
>
|
|
<AlertCircle className="size-4" />
|
|
{issues.length}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-0.5 !h-5 hidden md:block"
|
|
/>
|
|
|
|
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"size-11 text-muted-foreground md:size-8",
|
|
paused && "text-primary",
|
|
)}
|
|
onClick={onTogglePause}
|
|
disabled={!enabled}
|
|
aria-label={paused ? "Resume flow" : "Pause flow"}
|
|
data-testid={paused ? "resume-flow" : "pause-flow"}
|
|
>
|
|
{paused ? <PlayCircle /> : <Pause />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{!enabled
|
|
? "This flow is stopped"
|
|
: paused
|
|
? "Let the flow carry on"
|
|
: "Hold the nodes; values still arrive"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{paused && enabled ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={onStep}
|
|
disabled={stepping}
|
|
aria-label="Step the flow"
|
|
data-testid="step-flow"
|
|
>
|
|
<StepForward />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Let one held-back item through</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="brand"
|
|
size="sm"
|
|
className="h-11 gap-1.5 md:h-8"
|
|
onClick={onRun}
|
|
disabled={running || !enabled}
|
|
data-testid="run-flow"
|
|
>
|
|
{running ? (
|
|
<FluksioLoader className="size-4" />
|
|
) : (
|
|
<Play className="size-4" />
|
|
)}
|
|
Run
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{enabled ? "Run every node once" : "Start the flow to run it"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator
|
|
orientation="vertical"
|
|
className="mx-0.5 !h-5 hidden md:block"
|
|
/>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={onEditFlow}
|
|
aria-label="Flow settings"
|
|
data-testid="edit-flow"
|
|
>
|
|
<Pencil />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Flow settings</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Throwing the edit away sits next to putting it live, and only exists
|
|
while there is something to throw away. */}
|
|
{hasDraft ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={onDiscard}
|
|
aria-label="Discard the unpublished changes"
|
|
data-testid="discard-draft"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Discard the unpublished changes</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
{/*
|
|
* Saved state and publish are one control: the glyph never moves, it
|
|
* simply stops being something you can press once there is nothing left
|
|
* to put live. A button that appears and disappears moved everything
|
|
* beside it just as the work was finished.
|
|
*/}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={onPublish}
|
|
disabled={!hasDraft || publishing || saving || !connected}
|
|
aria-label="Publish this flow"
|
|
data-testid="publish-flow"
|
|
>
|
|
{!connected ? (
|
|
<WifiOff className="size-3.5" />
|
|
) : saving || publishing ? (
|
|
<FluksioLoader className="size-3.5" />
|
|
) : (
|
|
<Check className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{!connected
|
|
? "Reconnecting to the engine"
|
|
: publishing
|
|
? "Publishing"
|
|
: saving
|
|
? "Saving"
|
|
: hasDraft
|
|
? "Saved — publish to put it live"
|
|
: "All changes saved"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What stops this flow running, or is merely worth knowing — the same chrome as
|
|
* the logs, so a canvas with both open shows one pair rather than a panel and a
|
|
* popover disagreeing about what a panel looks like.
|
|
*
|
|
* Not a popover for the same reason the logs are not: clicking a node is what
|
|
* you do while reading this, since each line points at one.
|
|
*/
|
|
function IssuesPanel({
|
|
open,
|
|
issues,
|
|
onClose,
|
|
onFocusNode,
|
|
}: {
|
|
open: boolean
|
|
issues: ValidationIssue[]
|
|
onClose: () => void
|
|
onFocusNode: (nodeId: string) => void
|
|
}) {
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose()
|
|
}
|
|
window.addEventListener("keydown", onKeyDown)
|
|
return () => window.removeEventListener("keydown", onKeyDown)
|
|
}, [open, onClose])
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && issues.length > 0 ? (
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
transition={transitions.emphasized}
|
|
className={PANEL}
|
|
data-testid="issues-panel"
|
|
>
|
|
<div className="border-b border-border px-3 py-2">
|
|
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
|
Needs attention
|
|
</p>
|
|
</div>
|
|
<ul className="grid gap-0.5 p-2">
|
|
{issues.map((issue) => (
|
|
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
|
|
issue.advisory && "text-muted-foreground",
|
|
)}
|
|
onClick={() => issue.node && onFocusNode(issue.node)}
|
|
disabled={!issue.node}
|
|
>
|
|
{issue.message}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
)
|
|
}
|