Start, stop and pause flows, and show what their nodes print
Flows can now be taken off the engine and put back. Stopped state lives in a runtime.json beside the flow, not in the flow document: the canvas autosaves that document, so a stopped flow would otherwise start itself again on the next edit. A stopped flow gets no subscriptions, schedules or webhooks, its nodes are skipped by the scheduler, and running it answers 409. Pausing holds a flow's nodes while its values keep arriving, so the canvas still shows what is coming in. Node code is user code and print is how it says things, so stdout is teed through a contextvar sink active only during a node execution — one event per execution, capped, so a chatty node cannot outrun the stream. A node that fails sends its traceback the same way, trimmed to the author's own frames. The dock gains a logs panel and a pause control; the dashboard replaces its placeholder with what is running, stopped or failing; the edge inspector can send the last message again. Single-stepping is deferred and noted: the scheduler keeps no progress between calls, so a step button would re-run the same node rather than advance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
606ab3c423
commit
7344eac262
@@ -166,6 +166,16 @@ export const FlowDetailSchema = {
|
||||
type: 'boolean',
|
||||
title: 'Has Draft',
|
||||
default: false
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
title: 'Enabled',
|
||||
default: true
|
||||
},
|
||||
paused: {
|
||||
type: 'boolean',
|
||||
title: 'Paused',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -266,6 +276,16 @@ export const FlowSummarySchema = {
|
||||
type: 'boolean',
|
||||
title: 'Has Draft',
|
||||
default: false
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
title: 'Enabled',
|
||||
default: true
|
||||
},
|
||||
paused: {
|
||||
type: 'boolean',
|
||||
title: 'Paused',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
|
||||
export class FlowsService {
|
||||
/**
|
||||
@@ -225,6 +225,90 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Flow
|
||||
* Let the engine run this flow again.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static startFlow(data: FlowsStartFlowData): CancelablePromise<FlowsStartFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/start',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Flow
|
||||
* Take this flow off the engine: no subscriptions, schedules or webhooks.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static stopFlow(data: FlowsStopFlowData): CancelablePromise<FlowsStopFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/stop',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause Flow
|
||||
* Hold the flow's nodes so its messages can be stepped through.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static pauseFlow(data: FlowsPauseFlowData): CancelablePromise<FlowsPauseFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/pause',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume Flow
|
||||
* Let the flow carry on, running whatever was held back.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static resumeFlow(data: FlowsResumeFlowData): CancelablePromise<FlowsResumeFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/resume',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Flow
|
||||
* Report what would keep this flow from running.
|
||||
|
||||
@@ -51,6 +51,8 @@ export type FlowDetail = {
|
||||
nodes?: Array<NodeStatusPublic>;
|
||||
issues?: Array<ValidationIssue>;
|
||||
has_draft?: boolean;
|
||||
enabled?: boolean;
|
||||
paused?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -87,6 +89,8 @@ export type FlowSummary = {
|
||||
node_count?: number;
|
||||
error_count?: number;
|
||||
has_draft?: boolean;
|
||||
enabled?: boolean;
|
||||
paused?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -382,6 +386,30 @@ export type FlowsSaveNodeSourceData = {
|
||||
|
||||
export type FlowsSaveNodeSourceResponse = (NodeStatusPublic);
|
||||
|
||||
export type FlowsStartFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsStartFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsStopFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsStopFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsPauseFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsPauseFlowResponse = (Message);
|
||||
|
||||
export type FlowsResumeFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsResumeFlowResponse = (Message);
|
||||
|
||||
export type FlowsValidateFlowData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ArrowRight, Trash2 } from "lucide-react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { FlowsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { displayName } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
@@ -77,6 +80,8 @@ export type InspectedEdge = {
|
||||
/** Node titles, so the popover names the two ends in the user's own words. */
|
||||
from: string
|
||||
to: string
|
||||
/** The producing node's id, which is what replaying the message goes through. */
|
||||
sourceId: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
@@ -96,6 +101,19 @@ export function EdgeInspector({
|
||||
onUnbind: (message: string) => void
|
||||
}) {
|
||||
const live = useLiveValue(edge?.message)
|
||||
const { showErrorToast } = useCustomToast()
|
||||
// Publishing the value again from the node that produced it runs everything
|
||||
// downstream exactly as the original did.
|
||||
const replay = useMutation({
|
||||
mutationFn: (value: unknown) =>
|
||||
FlowsService.triggerNode({
|
||||
name: flow,
|
||||
nodeId: edge?.sourceId ?? "",
|
||||
requestBody: { values: { [edge?.message ?? ""]: value } },
|
||||
}),
|
||||
onError: () => showErrorToast("The message could not be sent again."),
|
||||
})
|
||||
|
||||
if (!edge) return null
|
||||
|
||||
const scalar = live === undefined ? null : formatScalar(live.value)
|
||||
@@ -115,6 +133,20 @@ export function EdgeInspector({
|
||||
<Marquee text={edge.from} className="flex-1 font-medium" />
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Marquee text={edge.to} className="flex-1 text-right font-medium" />
|
||||
{live === undefined ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="-my-1 shrink-0 text-muted-foreground"
|
||||
onClick={() => replay.mutate(live.value)}
|
||||
disabled={replay.isPending}
|
||||
aria-label="Send this message again"
|
||||
title="Send this message again"
|
||||
data-testid="replay-message"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LogsPanel } from "./LogsPanel"
|
||||
|
||||
/**
|
||||
* What "fit" means on this canvas: the view a flow opens with, and the one the
|
||||
@@ -38,16 +42,24 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
||||
* affordance on this view; everything else stays quiet.
|
||||
*/
|
||||
export function FlowDock({
|
||||
flow,
|
||||
issues,
|
||||
running,
|
||||
enabled,
|
||||
paused,
|
||||
onAddNode,
|
||||
onRun,
|
||||
onTogglePause,
|
||||
onFocusNode,
|
||||
}: {
|
||||
flow: string
|
||||
issues: ValidationIssue[]
|
||||
running: boolean
|
||||
enabled: boolean
|
||||
paused: boolean
|
||||
onAddNode: () => void
|
||||
onRun: () => void
|
||||
onTogglePause: () => void
|
||||
onFocusNode: (nodeId: string) => void
|
||||
}) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
||||
@@ -152,21 +164,58 @@ export function FlowDock({
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={onRun}
|
||||
disabled={running}
|
||||
data-testid="run-flow"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
<LogsPanel flow={flow} />
|
||||
|
||||
<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>
|
||||
|
||||
<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 ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{enabled ? "Run every node once" : "Start the flow to run it"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import { FlowTabs } from "./FlowTabs"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
import { NodePanel } from "./NodePanel"
|
||||
import "./flow.css"
|
||||
import { liveStore } from "./liveStore"
|
||||
import { liveStore, useFlowPaused } from "./liveStore"
|
||||
import {
|
||||
flowKeys,
|
||||
flowQueryOptions,
|
||||
@@ -230,6 +230,7 @@ function FlowEditorInner({
|
||||
const [editorExpanded, setEditorExpanded] = useState(false)
|
||||
|
||||
const issues = detail.issues ?? []
|
||||
const paused = useFlowPaused(flowName)
|
||||
|
||||
// Keep the latest document in a ref so autosave never captures a stale copy.
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
@@ -389,6 +390,27 @@ function FlowEditorInner({
|
||||
showErrorToast("The flow could not run. Check the node errors."),
|
||||
})
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (next: boolean) =>
|
||||
next
|
||||
? FlowsService.startFlow({ name: flowName })
|
||||
: FlowsService.stopFlow({ name: flowName }),
|
||||
onSuccess: (detail) => {
|
||||
queryClient.setQueryData(flowKeys.detail(flowName), detail)
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
|
||||
},
|
||||
onError: () => showErrorToast("The flow could not be started or stopped."),
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: (next: boolean) =>
|
||||
next
|
||||
? FlowsService.pauseFlow({ name: flowName })
|
||||
: FlowsService.resumeFlow({ name: flowName }),
|
||||
// The engine answers with a flow_paused event, which is what the dock reads.
|
||||
onError: () => showErrorToast("The flow could not be paused."),
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (newName: string) =>
|
||||
FlowsService.renameFlow({
|
||||
@@ -659,6 +681,7 @@ function FlowEditorInner({
|
||||
message: (edge.data as { message: string }).message,
|
||||
from: label(edge.source),
|
||||
to: label(edge.target),
|
||||
sourceId: edge.source,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
@@ -710,14 +733,18 @@ function FlowEditorInner({
|
||||
{panelOpen ? null : (
|
||||
<FlowDock
|
||||
key="flow-dock"
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
enabled={detail.enabled ?? true}
|
||||
paused={paused}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={async () => {
|
||||
// Running executes what is stored, so the queued edit goes first.
|
||||
await flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onTogglePause={() => pauseMutation.mutate(!paused)}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
)}
|
||||
@@ -752,6 +779,9 @@ function FlowEditorInner({
|
||||
renameMutation.mutate(newName)
|
||||
}}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
enabled={detail.enabled ?? true}
|
||||
toggling={enableMutation.isPending}
|
||||
onToggleEnabled={(next) => enableMutation.mutate(next)}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
discarding={discard.isPending}
|
||||
onDiscardDraft={() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
||||
|
||||
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
|
||||
@@ -32,6 +33,9 @@ export function FlowPanel({
|
||||
hasDraft,
|
||||
discarding,
|
||||
onDiscardDraft,
|
||||
enabled,
|
||||
toggling,
|
||||
onToggleEnabled,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
@@ -44,6 +48,9 @@ export function FlowPanel({
|
||||
hasDraft: boolean
|
||||
discarding: boolean
|
||||
onDiscardDraft: () => void
|
||||
enabled: boolean
|
||||
toggling: boolean
|
||||
onToggleEnabled: (next: boolean) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(definition.name)
|
||||
@@ -85,6 +92,24 @@ export function FlowPanel({
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-4">
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Running</span>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{enabled
|
||||
? "The engine runs this flow: subscriptions, schedules and webhooks are live."
|
||||
: "Stopped. Nothing of this flow is subscribed, scheduled or reachable."}
|
||||
</p>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggling}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
aria-label="Run this flow"
|
||||
data-testid="flow-enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Name</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Terminal } from "lucide-react"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { liveStore, useLiveLogs } from "./liveStore"
|
||||
|
||||
function shortTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/** Drop the flow prefix: every line in here belongs to the open flow. */
|
||||
function nodeLabel(nodeId: string, flow: string): string {
|
||||
return nodeId.startsWith(`${flow}.`) ? nodeId.slice(flow.length + 1) : nodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* What the nodes of this flow printed, and the tracebacks of the ones that
|
||||
* failed — the detail the one-line error bubble on a node has no room for.
|
||||
*/
|
||||
export function LogsPanel({ flow }: { flow: string }) {
|
||||
const lines = useLiveLogs().filter((line) => line.flow === flow)
|
||||
const bottom = useRef<HTMLLIElement | null>(null)
|
||||
|
||||
// Follow the tail, which is where a running flow puts what just happened.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: a new line is what scrolls.
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ block: "end" })
|
||||
}, [lines.length])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
aria-label="Logs"
|
||||
data-testid="flow-logs"
|
||||
>
|
||||
<Terminal />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>What this flow printed</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent align="center" className="w-[28rem] p-0">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
Logs
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => liveStore.clearLogs()}
|
||||
disabled={lines.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lines.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nothing yet. Anything a node prints shows up here.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-72">
|
||||
<ul className="grid gap-1.5 p-3 font-mono text-xs">
|
||||
{lines.map((line, index) => (
|
||||
<li
|
||||
// Lines are append-only and repeat freely, so position is the
|
||||
// only stable identity they have.
|
||||
key={`${line.ts}-${line.node}-${index}`}
|
||||
className="grid grid-cols-[auto_1fr] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{shortTime(line.ts)}{" "}
|
||||
<span className="text-foreground/70">
|
||||
{nodeLabel(line.node, flow)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
line.level === "error" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{line.text.replace(/\n+$/, "")}
|
||||
{line.truncated ? "\n… truncated" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li ref={bottom} aria-hidden />
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -13,14 +13,28 @@ export type LiveStatus = {
|
||||
status: "active" | "error" | "running" | "success"
|
||||
error?: string | null
|
||||
}
|
||||
/** One node execution's output, as the log panel shows it. */
|
||||
export type LogLine = {
|
||||
flow: string
|
||||
node: string
|
||||
text: string
|
||||
level: "info" | "error"
|
||||
truncated?: boolean
|
||||
ts: number
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
/** Enough to see what a flow has been doing, not a log store. */
|
||||
const LOG_LIMIT = 500
|
||||
|
||||
const values = new Map<string, LiveValue>()
|
||||
const statuses = new Map<string, LiveStatus>()
|
||||
// How many times a node has emitted. The number itself means nothing; a change
|
||||
// is what restarts the pulse.
|
||||
const emits = new Map<string, number>()
|
||||
let logLines: LogLine[] = []
|
||||
const paused = new Set<string>()
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
let connected = false
|
||||
@@ -79,6 +93,33 @@ export const liveStore = {
|
||||
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
|
||||
notify(`emit:${nodeId}`)
|
||||
},
|
||||
appendLog(line: LogLine) {
|
||||
// A new array each time, so the hook's snapshot comparison sees the change.
|
||||
logLines = [...logLines, line].slice(-LOG_LIMIT)
|
||||
notify("logs")
|
||||
},
|
||||
setLogs(lines: LogLine[]) {
|
||||
logLines = lines.slice(-LOG_LIMIT)
|
||||
notify("logs")
|
||||
},
|
||||
clearLogs() {
|
||||
logLines = []
|
||||
notify("logs")
|
||||
},
|
||||
setPaused(flow: string, isPaused: boolean) {
|
||||
if (isPaused) paused.add(flow)
|
||||
else paused.delete(flow)
|
||||
notify(`paused:${flow}`)
|
||||
},
|
||||
setPausedFlows(flows: string[]) {
|
||||
const next = new Set(flows)
|
||||
for (const flow of new Set([...paused, ...next])) {
|
||||
if (paused.has(flow) === next.has(flow)) continue
|
||||
if (next.has(flow)) paused.add(flow)
|
||||
else paused.delete(flow)
|
||||
notify(`paused:${flow}`)
|
||||
}
|
||||
},
|
||||
setConnected(next: boolean) {
|
||||
if (connected === next) return
|
||||
connected = next
|
||||
@@ -94,6 +135,10 @@ export const liveStore = {
|
||||
statuses.clear()
|
||||
for (const key of emits.keys()) notify(`emit:${key}`)
|
||||
emits.clear()
|
||||
logLines = []
|
||||
notify("logs")
|
||||
for (const flow of paused) notify(`paused:${flow}`)
|
||||
paused.clear()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -119,6 +164,21 @@ export function useNodeEmits(nodeId: string): number {
|
||||
)
|
||||
}
|
||||
|
||||
/** Every captured line, newest last. Filtered by the panel that shows it. */
|
||||
export function useLiveLogs(): LogLine[] {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey("logs", listener),
|
||||
() => logLines,
|
||||
)
|
||||
}
|
||||
|
||||
export function useFlowPaused(flow: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey(`paused:${flow}`, listener),
|
||||
() => paused.has(flow),
|
||||
)
|
||||
}
|
||||
|
||||
export function useLiveConnection(): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { liveStore } from "./liveStore"
|
||||
import { type LogLine, liveStore } from "./liveStore"
|
||||
import { flowKeys } from "./queries"
|
||||
|
||||
const RECONNECT_MIN = 1000
|
||||
@@ -13,14 +13,19 @@ type FlowEvent =
|
||||
type: "snapshot"
|
||||
values: Record<string, { value: unknown; ts: number | null }>
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
paused?: string[]
|
||||
logs?: LogLine[]
|
||||
}
|
||||
| { type: "message_value"; name: string; value: unknown; ts: number }
|
||||
| { type: "node_executed"; node: string; outputs: number }
|
||||
| { type: "node_error"; node: string; error: string }
|
||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||
| ({ type: "node_log" } & LogLine)
|
||||
| { type: "flow_paused"; flow: string; paused: boolean }
|
||||
| {
|
||||
type: "pipeline_rebuilt"
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
paused?: string[]
|
||||
}
|
||||
|
||||
function socketUrl(): string {
|
||||
@@ -65,6 +70,8 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
case "snapshot":
|
||||
liveStore.setValues(message.values)
|
||||
liveStore.setStatuses(message.nodes)
|
||||
liveStore.setPausedFlows(message.paused ?? [])
|
||||
liveStore.setLogs(message.logs ?? [])
|
||||
break
|
||||
case "message_value":
|
||||
liveStore.setValue(message.name, {
|
||||
@@ -88,10 +95,17 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
error: message.error,
|
||||
})
|
||||
break
|
||||
case "node_log":
|
||||
liveStore.appendLog(message)
|
||||
break
|
||||
case "flow_paused":
|
||||
liveStore.setPaused(message.flow, message.paused)
|
||||
break
|
||||
case "pipeline_rebuilt":
|
||||
liveStore.setStatuses(message.nodes)
|
||||
// Someone published, here or in another tab: the draft markers on
|
||||
// the flow chips are stale until the list is fetched again.
|
||||
liveStore.setPausedFlows(message.paused ?? [])
|
||||
// Someone published or started a flow, here or in another tab: the
|
||||
// markers on the flow chips are stale until the list is refetched.
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { AlertCircle, Workflow } from "lucide-react"
|
||||
|
||||
import { type FlowSummary, FlowsService } from "@/client"
|
||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
@@ -13,19 +22,137 @@ export const Route = createFileRoute("/_layout/")({
|
||||
}),
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
/** Another tab can stop a flow, and the engine can fail one on its own. */
|
||||
const REFRESH_INTERVAL = 10_000
|
||||
|
||||
function Tile({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card className="gap-1 py-4">
|
||||
<div className="px-5">
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowRow({ flow }: { flow: FlowSummary }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const enabled = flow.enabled ?? true
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (next: boolean) =>
|
||||
next
|
||||
? FlowsService.startFlow({ name: flow.name })
|
||||
: FlowsService.stopFlow({ name: flow.name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) })
|
||||
},
|
||||
onError: () => showErrorToast("The flow could not be started or stopped."),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-2xl truncate max-w-sm">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome back, nice to see you again!!!
|
||||
<div
|
||||
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
|
||||
data-testid="dashboard-flow-row"
|
||||
>
|
||||
<Link
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flow.name }}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<p className="truncate font-medium">{flow.title || flow.name}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`}
|
||||
{flow.has_draft ? " · unpublished changes" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{(flow.error_count ?? 0) > 0 ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle />
|
||||
{flow.error_count}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
enabled
|
||||
? flow.paused
|
||||
? "border-transparent bg-primary/15 text-primary"
|
||||
: "border-transparent bg-status-success/15 text-status-success"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"}
|
||||
</Badge>
|
||||
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-label={`Run ${flow.title || flow.name}`}
|
||||
data-testid="flow-enabled-switch"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { data, isPending } = useQuery({
|
||||
...flowsQueryOptions(),
|
||||
refetchInterval: REFRESH_INTERVAL,
|
||||
})
|
||||
|
||||
const flows = data?.data ?? []
|
||||
const running = flows.filter((flow) => flow.enabled ?? true).length
|
||||
const failing = flows.filter((flow) => (flow.error_count ?? 0) > 0).length
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div>
|
||||
<h1 className="max-w-sm truncate text-2xl">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{flows.length === 0
|
||||
? "No flows yet."
|
||||
: `${running} of ${flows.length} flows are running.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Tile label="Flows" value={flows.length} />
|
||||
<Tile label="Running" value={running} />
|
||||
<Tile label="With errors" value={failing} />
|
||||
</div>
|
||||
|
||||
<Card className="gap-0 py-0">
|
||||
{isPending ? (
|
||||
<div className="grid gap-3 p-5">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Workflow className="size-5" />
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Flows you build show up here, with what they are doing.
|
||||
</p>
|
||||
<Link to="/flows" className="text-sm font-medium underline">
|
||||
Go to flows
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ test.describe.configure({ mode: "serial" })
|
||||
|
||||
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||
|
||||
async function api(page: Page, path: string, init: Record<string, unknown> = {}) {
|
||||
async function api(
|
||||
page: Page,
|
||||
path: string,
|
||||
init: Record<string, unknown> = {},
|
||||
) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
||||
return page.request.fetch(`${apiUrl}/api/v1${path}`, {
|
||||
...init,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Flows can be taken off the engine and put back, and what a node prints — or
|
||||
* the traceback of one that fails — is readable without leaving the canvas.
|
||||
*/
|
||||
|
||||
const flowName = `test_runtime_${Date.now().toString(36)}`
|
||||
|
||||
test.use({ storageState: "playwright/.auth/user.json" })
|
||||
|
||||
test.describe.configure({ mode: "serial" })
|
||||
|
||||
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||
|
||||
const PRINTING_NODE = `def process(params):
|
||||
print("sensor read 21.5 degrees")
|
||||
return {"reading": 21.5}
|
||||
`
|
||||
const BROKEN_NODE = `def process(reading, params):
|
||||
raise RuntimeError("downstream blew up")
|
||||
`
|
||||
|
||||
async function api(
|
||||
page: Page,
|
||||
path: string,
|
||||
init: Record<string, unknown> = {},
|
||||
) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
||||
return page.request.fetch(`${apiUrl}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage({
|
||||
storageState: "playwright/.auth/user.json",
|
||||
})
|
||||
await page.goto("/")
|
||||
await api(page, `/flows/${flowName}`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
name: flowName,
|
||||
title: "Runtime",
|
||||
nodes: [
|
||||
{
|
||||
id: "sensor",
|
||||
type: "python",
|
||||
position: { x: 0, y: 0 },
|
||||
provides: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "logger",
|
||||
type: "python",
|
||||
position: { x: 260, y: 0 },
|
||||
requires: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await api(page, `/flows/${flowName}/nodes/sensor/source`, {
|
||||
method: "PUT",
|
||||
data: { code: PRINTING_NODE },
|
||||
})
|
||||
await api(page, `/flows/${flowName}/nodes/logger/source`, {
|
||||
method: "PUT",
|
||||
data: { code: BROKEN_NODE },
|
||||
})
|
||||
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
||||
await api(page, `/flows/${flowName}/publish`, {
|
||||
method: "POST",
|
||||
data: { version: detail.definition.version },
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test("the dashboard lists flows and can stop one", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const row = page
|
||||
.getByTestId("dashboard-flow-row")
|
||||
.filter({ hasText: "Runtime" })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row).toContainText("Running")
|
||||
|
||||
await row.getByTestId("flow-enabled-switch").click()
|
||||
await expect(row).toContainText("Stopped")
|
||||
|
||||
// A stopped flow is not something the engine will run.
|
||||
const refused = await api(page, `/flows/${flowName}/run`, {
|
||||
method: "POST",
|
||||
data: { inputs: {} },
|
||||
})
|
||||
expect(refused.status()).toBe(409)
|
||||
|
||||
await row.getByTestId("flow-enabled-switch").click()
|
||||
await expect(row).toContainText("Running")
|
||||
})
|
||||
|
||||
test("the logs panel shows what a node printed and why one failed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
await page.waitForSelector(".react-flow__node")
|
||||
|
||||
await page.getByTestId("run-flow").click()
|
||||
await page.getByTestId("flow-logs").click()
|
||||
|
||||
const panel = page.locator('[data-slot="popover-content"]')
|
||||
await expect(panel).toContainText("sensor read 21.5 degrees")
|
||||
await expect(panel).toContainText("RuntimeError")
|
||||
})
|
||||
|
||||
test("a flow can be paused and let go again", async ({ page }) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
await page.waitForSelector(".react-flow__node")
|
||||
|
||||
await page.getByTestId("pause-flow").click()
|
||||
await expect(page.getByTestId("resume-flow")).toBeVisible()
|
||||
expect((await (await api(page, `/flows/${flowName}`)).json()).paused).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
await page.getByTestId("resume-flow").click()
|
||||
await expect(page.getByTestId("pause-flow")).toBeVisible()
|
||||
|
||||
await api(page, `/flows/${flowName}`, { method: "DELETE" })
|
||||
})
|
||||
@@ -7,7 +7,7 @@ export async function logInUser(page: Page, email: string, password: string) {
|
||||
await page.getByTestId("password-input").fill(password)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
await page.waitForURL("/")
|
||||
await expect(
|
||||
page.getByText("Welcome back, nice to see you again!"),
|
||||
).toBeVisible()
|
||||
// The greeting is the one part of the dashboard that is there whether or not
|
||||
// any flows are.
|
||||
await expect(page.getByRole("heading", { name: /^Hi, / })).toBeVisible()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user