Debugger Continuity (FE) (#3318)
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
helpText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -23,7 +30,7 @@ function SwitchBar({ className, highlight, options, value, onChange }: Props) {
|
|||||||
>
|
>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const selected = option.value === value;
|
const selected = option.value === value;
|
||||||
return (
|
const optionElement = (
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -42,6 +49,19 @@ function SwitchBar({ className, highlight, options, value, onChange }: Props) {
|
|||||||
{option.label}
|
{option.label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (option.helpText) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={option.value}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{optionElement}</TooltipTrigger>
|
||||||
|
<TooltipContent>{option.helpText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionElement;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { formatMs } from "@/util/utils";
|
||||||
|
|
||||||
interface HMS {
|
interface HMS {
|
||||||
hour: number;
|
hour: number;
|
||||||
minute: number;
|
minute: number;
|
||||||
@@ -11,21 +13,6 @@ interface Props {
|
|||||||
startAt?: HMS;
|
startAt?: HMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatMs = (elapsed: number) => {
|
|
||||||
let seconds = Math.floor(elapsed / 1000);
|
|
||||||
let minutes = Math.floor(seconds / 60);
|
|
||||||
let hours = Math.floor(minutes / 60);
|
|
||||||
seconds = seconds % 60;
|
|
||||||
minutes = minutes % 60;
|
|
||||||
hours = hours % 24;
|
|
||||||
|
|
||||||
return {
|
|
||||||
hour: hours,
|
|
||||||
minute: minutes,
|
|
||||||
second: seconds,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function Timer({ override, startAt }: Props) {
|
function Timer({ override, startAt }: Props) {
|
||||||
const [time, setTime] = useState<HMS>({
|
const [time, setTime] = useState<HMS>({
|
||||||
hour: 0,
|
hour: 0,
|
||||||
|
|||||||
43
skyvern-frontend/src/components/icons/OutputIcon.tsx
Normal file
43
skyvern-frontend/src/components/icons/OutputIcon.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OutputIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M18.6667 11C20.5513 10.7213 22 9.04574 22 7.02036C22 4.79998 20.2589 3 18.1111 3H5.88889C3.74112 3 2 4.79998 2 7.02036C2 9.04574 3.44873 10.7213 5.33333 11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 6V13M12 13L14 10.6667M12 13L10 10.6667"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 10C5 8.11438 5 7.17157 5.58579 6.58579C6.17157 6 7.11438 6 9 6H15C16.8856 6 17.8284 6 18.4142 6.58579C19 7.17157 19 8.11438 19 10V16C19 17.8856 19 18.8284 18.4142 19.4142C17.8284 20 16.8856 20 15 20H9C7.11438 20 6.17157 20 5.58579 19.4142C5 18.8284 5 17.8856 5 16V10Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 17H19"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OutputIcon };
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { SwitchBar } from "@/components/SwitchBar";
|
||||||
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
|
import { cn, formatMs } from "@/util/utils";
|
||||||
|
|
||||||
|
import { CodeEditor } from "./CodeEditor";
|
||||||
|
|
||||||
|
type PageName = "output" | "override";
|
||||||
|
|
||||||
|
function BlockOutputs({
|
||||||
|
blockLabel,
|
||||||
|
blockOutput,
|
||||||
|
}: {
|
||||||
|
blockLabel: string;
|
||||||
|
blockOutput: { [k: string]: unknown } | null;
|
||||||
|
}) {
|
||||||
|
const { workflowPermanentId } = useParams();
|
||||||
|
const blockOutputStore = useBlockOutputStore();
|
||||||
|
const [pageName, setPageName] = useState<PageName>("output");
|
||||||
|
const [overrideHasError, setOverrideHasError] = useState(false);
|
||||||
|
const useOverride = useBlockOutputStore((state) =>
|
||||||
|
workflowPermanentId
|
||||||
|
? state.useOverrides[workflowPermanentId]?.[blockLabel] ?? false
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let createdAt: Date | null = null;
|
||||||
|
|
||||||
|
if (blockOutput) {
|
||||||
|
delete blockOutput.task_id;
|
||||||
|
delete blockOutput.status;
|
||||||
|
delete blockOutput.failure_reason;
|
||||||
|
delete blockOutput.errors;
|
||||||
|
|
||||||
|
if ("created_at" in blockOutput) {
|
||||||
|
const _createdAt = blockOutput.created_at;
|
||||||
|
|
||||||
|
if (typeof _createdAt === "string") {
|
||||||
|
// ensure UTC parsing by appending 'Z' if not present
|
||||||
|
const utcString = _createdAt.endsWith("Z")
|
||||||
|
? _createdAt
|
||||||
|
: _createdAt + "Z";
|
||||||
|
createdAt = new Date(utcString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeOutput =
|
||||||
|
blockOutput === null ? null : JSON.stringify(blockOutput, null, 2);
|
||||||
|
|
||||||
|
const ago = createdAt ? formatMs(Date.now() - createdAt.getTime()).ago : null;
|
||||||
|
|
||||||
|
const override = blockOutputStore.getOverride({
|
||||||
|
wpid: workflowPermanentId,
|
||||||
|
blockLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const codeOverride = override ? JSON.stringify(override, null, 2) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<SwitchBar
|
||||||
|
className="mb-2 border-none"
|
||||||
|
onChange={(value) => setPageName(value as PageName)}
|
||||||
|
value={pageName}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Output",
|
||||||
|
value: "output",
|
||||||
|
helpText:
|
||||||
|
"The last output from this block, when it completed successfully.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Override",
|
||||||
|
value: "override",
|
||||||
|
helpText: "Supply your own override output.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{pageName === "output" && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<header className="w-full text-right text-xs">{ago}</header>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>When the output was created</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{pageName === "override" && (
|
||||||
|
<header className="flex w-full items-center justify-end gap-2 text-xs">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Use Override
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content="Use this override instead of the last block output" />
|
||||||
|
<Switch
|
||||||
|
checked={useOverride}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
blockOutputStore.setUseOverride({
|
||||||
|
wpid: workflowPermanentId,
|
||||||
|
blockLabel,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{pageName === "output" ? (
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-1 overflow-y-hidden border-2 border-transparent">
|
||||||
|
{codeOutput ? (
|
||||||
|
<>
|
||||||
|
<CodeEditor
|
||||||
|
key="output"
|
||||||
|
className="nopan nowheel h-full w-full flex-1 overflow-y-scroll"
|
||||||
|
language="json"
|
||||||
|
value={codeOutput}
|
||||||
|
lineWrap={false}
|
||||||
|
readOnly
|
||||||
|
fontSize={10}
|
||||||
|
fullHeight
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-1 items-center justify-center bg-slate-950">
|
||||||
|
No output defined
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full flex-1 flex-col overflow-y-hidden border-2 border-transparent",
|
||||||
|
{
|
||||||
|
"border-[red]": overrideHasError,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CodeEditor
|
||||||
|
key="override"
|
||||||
|
className="nopan nowheel h-full w-full flex-1 overflow-y-scroll"
|
||||||
|
language="json"
|
||||||
|
value={codeOverride ?? ""}
|
||||||
|
lineWrap={false}
|
||||||
|
fontSize={10}
|
||||||
|
fullHeight
|
||||||
|
onChange={(value) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(value), setOverrideHasError(false);
|
||||||
|
} catch {
|
||||||
|
setOverrideHasError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasStored = blockOutputStore.setOverride({
|
||||||
|
wpid: workflowPermanentId,
|
||||||
|
blockLabel,
|
||||||
|
data: JSON.parse(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wasStored) {
|
||||||
|
setOverrideHasError(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BlockOutputs };
|
||||||
@@ -26,8 +26,14 @@ type Props = {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
fullHeight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fullHeightExtension = EditorView.theme({
|
||||||
|
"&": { height: "100%" }, // the root
|
||||||
|
".cm-scroller": { flex: 1 }, // makes the scrollable area expand
|
||||||
|
});
|
||||||
|
|
||||||
function CodeEditor({
|
function CodeEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -38,11 +44,19 @@ function CodeEditor({
|
|||||||
className,
|
className,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
fontSize = 12,
|
fontSize = 12,
|
||||||
|
fullHeight = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const extensions = language
|
const extensions = language
|
||||||
? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []]
|
? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []]
|
||||||
: [lineWrap ? EditorView.lineWrapping : []];
|
: [lineWrap ? EditorView.lineWrapping : []];
|
||||||
|
|
||||||
|
const style: React.CSSProperties = { fontSize };
|
||||||
|
|
||||||
|
if (fullHeight) {
|
||||||
|
extensions.push(fullHeightExtension);
|
||||||
|
style.height = "100%";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={value}
|
value={value}
|
||||||
@@ -53,9 +67,7 @@ function CodeEditor({
|
|||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
className={cn("cursor-auto", className)}
|
className={cn("cursor-auto", className)}
|
||||||
style={{
|
style={style}
|
||||||
fontSize: fontSize,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ import { WorkflowSettings } from "../types/workflowTypes";
|
|||||||
import { getElements } from "@/routes/workflows/editor/workflowEditorUtils";
|
import { getElements } from "@/routes/workflows/editor/workflowEditorUtils";
|
||||||
import { getInitialParameters } from "@/routes/workflows/editor/utils";
|
import { getInitialParameters } from "@/routes/workflows/editor/utils";
|
||||||
import { Workspace } from "@/routes/workflows/editor/Workspace";
|
import { Workspace } from "@/routes/workflows/editor/Workspace";
|
||||||
|
import { useDebugSessionBlockOutputsQuery } from "../hooks/useDebugSessionBlockOutputsQuery";
|
||||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||||
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
|
|
||||||
function Debugger() {
|
function Debugger() {
|
||||||
const { workflowPermanentId } = useParams();
|
const { workflowPermanentId } = useParams();
|
||||||
const { data: workflow } = useWorkflowQuery({
|
const { data: workflow } = useWorkflowQuery({
|
||||||
workflowPermanentId,
|
workflowPermanentId,
|
||||||
});
|
});
|
||||||
|
const { data: outputParameters } = useDebugSessionBlockOutputsQuery({
|
||||||
|
workflowPermanentId,
|
||||||
|
});
|
||||||
|
|
||||||
const setParameters = useWorkflowParametersStore(
|
const setParameters = useWorkflowParametersStore(
|
||||||
(state) => state.setParameters,
|
(state) => state.setParameters,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setBlockOutputs = useBlockOutputStore((state) => state.setOutputs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workflow) {
|
if (workflow) {
|
||||||
const initialParameters = getInitialParameters(workflow);
|
const initialParameters = getInitialParameters(workflow);
|
||||||
@@ -26,6 +33,21 @@ function Debugger() {
|
|||||||
}
|
}
|
||||||
}, [workflow, setParameters]);
|
}, [workflow, setParameters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!outputParameters) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockOutputs = Object.entries(outputParameters).reduce<{
|
||||||
|
[k: string]: Record<string, unknown>;
|
||||||
|
}>((acc, [blockLabel, outputs]) => {
|
||||||
|
acc[blockLabel] = outputs ?? null;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
setBlockOutputs(blockOutputs);
|
||||||
|
}, [outputParameters, setBlockOutputs]);
|
||||||
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { getElements } from "./workflowEditorUtils";
|
|||||||
import { LogoMinimized } from "@/components/LogoMinimized";
|
import { LogoMinimized } from "@/components/LogoMinimized";
|
||||||
import { WorkflowSettings } from "../types/workflowTypes";
|
import { WorkflowSettings } from "../types/workflowTypes";
|
||||||
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
||||||
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||||
import { getInitialParameters } from "./utils";
|
import { getInitialParameters } from "./utils";
|
||||||
import { Workspace } from "./Workspace";
|
import { Workspace } from "./Workspace";
|
||||||
|
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||||
|
|
||||||
function WorkflowEditor() {
|
function WorkflowEditor() {
|
||||||
const { workflowPermanentId } = useParams();
|
const { workflowPermanentId } = useParams();
|
||||||
|
|
||||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||||
workflowPermanentId,
|
workflowPermanentId,
|
||||||
});
|
});
|
||||||
@@ -24,6 +25,10 @@ function WorkflowEditor() {
|
|||||||
(state) => state.setParameters,
|
(state) => state.setParameters,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const blockOutputStore = useBlockOutputStore();
|
||||||
|
|
||||||
|
useMountEffect(() => blockOutputStore.reset());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workflow) {
|
if (workflow) {
|
||||||
const initialParameters = getInitialParameters(workflow);
|
const initialParameters = getInitialParameters(workflow);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { ModelSelector } from "@/components/ModelSelector";
|
|||||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
import { NodeHeader } from "../components/NodeHeader";
|
import { NodeHeader } from "../components/NodeHeader";
|
||||||
|
import { NodeFooter } from "../components/NodeFooter";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
||||||
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||||
@@ -103,9 +104,8 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
|
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
|
||||||
{
|
{
|
||||||
"pointer-events-none": thisBlockIsPlaying,
|
"pointer-events-none bg-slate-950": thisBlockIsPlaying,
|
||||||
"bg-slate-950 outline outline-2 outline-slate-300":
|
"outline outline-2 outline-slate-300": thisBlockIsTargetted,
|
||||||
thisBlockIsTargetted,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -278,6 +278,7 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
<NodeFooter blockLabel={label} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BlockCodeEditor blockLabel={label} blockType={type} script={script} />
|
<BlockCodeEditor blockLabel={label} blockType={type} script={script} />
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { CrossCircledIcon } from "@radix-ui/react-icons";
|
||||||
|
import { OutputIcon } from "@/components/icons/OutputIcon";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
||||||
|
import { BlockOutputs } from "@/routes/workflows/components/BlockOutputs";
|
||||||
|
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||||
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
blockLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeFooter({ blockLabel }: Props) {
|
||||||
|
const { blockLabel: urlBlockLabel } = useParams();
|
||||||
|
const blockOutput = useBlockOutputStore((state) => state.outputs[blockLabel]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
|
const workflowRunIsRunningOrQueued =
|
||||||
|
workflowRun && statusIsRunningOrQueued(workflowRun);
|
||||||
|
const thisBlockIsPlaying =
|
||||||
|
workflowRunIsRunningOrQueued &&
|
||||||
|
urlBlockLabel !== undefined &&
|
||||||
|
urlBlockLabel === blockLabel;
|
||||||
|
const thisBlockIsTargetted =
|
||||||
|
urlBlockLabel !== undefined && urlBlockLabel === blockLabel;
|
||||||
|
|
||||||
|
if (thisBlockIsPlaying) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute left-0 top-[-1rem] h-full w-full",
|
||||||
|
{ "opacity-100": isExpanded },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex h-full w-full translate-y-full items-center justify-center bg-slate-elevation3 p-6 transition-all duration-300 ease-in-out",
|
||||||
|
{ "translate-y-0": isExpanded },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BlockOutputs
|
||||||
|
blockLabel={blockLabel}
|
||||||
|
blockOutput={
|
||||||
|
blockOutput ? JSON.parse(JSON.stringify(blockOutput)) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex w-full overflow-visible bg-[pink]">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-[-2.25rem] right-[-0.75rem] flex h-[2.5rem] w-[2.5rem] items-center justify-center gap-2 rounded-[50%] bg-slate-elevation3 p-2",
|
||||||
|
{
|
||||||
|
"opacity-100 outline outline-2 outline-slate-300":
|
||||||
|
thisBlockIsTargetted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"p-0 opacity-80 hover:translate-y-[-1px] hover:opacity-100 active:translate-y-[0px]",
|
||||||
|
{ "opacity-100": isExpanded },
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<CrossCircledIcon className="scale-[110%]" />
|
||||||
|
) : (
|
||||||
|
<OutputIcon className="scale-[80%]" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isExpanded ? "Close Outputs" : "Open Outputs"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NodeFooter };
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { ReloadIcon, PlayIcon, StopIcon } from "@radix-ui/react-icons";
|
import { ReloadIcon, PlayIcon, StopIcon } from "@radix-ui/react-icons";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { getClient } from "@/api/AxiosClient";
|
import { getClient } from "@/api/AxiosClient";
|
||||||
import { ProxyLocation } from "@/api/types";
|
import { ProxyLocation, Status } from "@/api/types";
|
||||||
import { Timer } from "@/components/Timer";
|
import { Timer } from "@/components/Timer";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import { useLogging } from "@/hooks/useLogging";
|
import { useLogging } from "@/hooks/useLogging";
|
||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
|
import { useOnChange } from "@/hooks/useOnChange";
|
||||||
|
|
||||||
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
|
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
|
||||||
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
type WorkflowApiResponse,
|
type WorkflowApiResponse,
|
||||||
} from "@/routes/workflows/types/workflowTypes";
|
} from "@/routes/workflows/types/workflowTypes";
|
||||||
import { getInitialValues } from "@/routes/workflows/utils";
|
import { getInitialValues } from "@/routes/workflows/utils";
|
||||||
|
import { useBlockOutputStore } from "@/store/BlockOutputStore";
|
||||||
import { useDebugStore } from "@/store/useDebugStore";
|
import { useDebugStore } from "@/store/useDebugStore";
|
||||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||||
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
|
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
|
||||||
@@ -54,6 +56,7 @@ interface Props {
|
|||||||
|
|
||||||
type Payload = Record<string, unknown> & {
|
type Payload = Record<string, unknown> & {
|
||||||
block_labels: string[];
|
block_labels: string[];
|
||||||
|
block_outputs: Record<string, unknown>;
|
||||||
browser_session_id: string | null;
|
browser_session_id: string | null;
|
||||||
extra_http_headers: Record<string, string> | null;
|
extra_http_headers: Record<string, string> | null;
|
||||||
max_screenshot_scrolls: number | null;
|
max_screenshot_scrolls: number | null;
|
||||||
@@ -67,6 +70,7 @@ type Payload = Record<string, unknown> & {
|
|||||||
|
|
||||||
const getPayload = (opts: {
|
const getPayload = (opts: {
|
||||||
blockLabel: string;
|
blockLabel: string;
|
||||||
|
blockOutputs: Record<string, unknown>;
|
||||||
browserSessionId: string | null;
|
browserSessionId: string | null;
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
totpIdentifier: string | null;
|
totpIdentifier: string | null;
|
||||||
@@ -109,6 +113,7 @@ const getPayload = (opts: {
|
|||||||
|
|
||||||
const payload: Payload = {
|
const payload: Payload = {
|
||||||
block_labels: [opts.blockLabel],
|
block_labels: [opts.blockLabel],
|
||||||
|
block_outputs: opts.blockOutputs,
|
||||||
browser_session_id: opts.browserSessionId,
|
browser_session_id: opts.browserSessionId,
|
||||||
extra_http_headers: extraHttpHeaders,
|
extra_http_headers: extraHttpHeaders,
|
||||||
max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes,
|
max_screenshot_scrolls: opts.workflowSettings.maxScreenshotScrollingTimes,
|
||||||
@@ -138,6 +143,7 @@ function NodeHeader({
|
|||||||
workflowPermanentId,
|
workflowPermanentId,
|
||||||
workflowRunId,
|
workflowRunId,
|
||||||
} = useParams();
|
} = useParams();
|
||||||
|
const blockOutputsStore = useBlockOutputStore();
|
||||||
const debugStore = useDebugStore();
|
const debugStore = useDebugStore();
|
||||||
const { closeWorkflowPanel } = useWorkflowPanelStore();
|
const { closeWorkflowPanel } = useWorkflowPanelStore();
|
||||||
const workflowSettingsStore = useWorkflowSettingsStore();
|
const workflowSettingsStore = useWorkflowSettingsStore();
|
||||||
@@ -177,6 +183,26 @@ function NodeHeader({
|
|||||||
3500
|
3500
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const [workflowRunStatus, setWorkflowRunStatus] = useState(
|
||||||
|
workflowRun?.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWorkflowRunStatus(workflowRun?.status);
|
||||||
|
}, [workflowRun, setWorkflowRunStatus]);
|
||||||
|
|
||||||
|
useOnChange(workflowRunStatus, (newValue, oldValue) => {
|
||||||
|
if (!thisBlockIsTargetted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue !== oldValue && oldValue && newValue === Status.Completed) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["block-outputs", workflowPermanentId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workflowRun || !workflowPermanentId || !workflowRunId) {
|
if (!workflowRun || !workflowPermanentId || !workflowRunId) {
|
||||||
return;
|
return;
|
||||||
@@ -202,6 +228,7 @@ function NodeHeader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
queryClient,
|
||||||
urlBlockLabel,
|
urlBlockLabel,
|
||||||
navigate,
|
navigate,
|
||||||
workflowPermanentId,
|
workflowPermanentId,
|
||||||
@@ -226,6 +253,10 @@ function NodeHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!debugSession) {
|
if (!debugSession) {
|
||||||
|
// TODO: kind of redundant; investigate if this is necessary; either
|
||||||
|
// Sentry's log should output to the console, or Sentry should just
|
||||||
|
// gather native console.error output.
|
||||||
|
console.error("Run block: there is no debug session, yet");
|
||||||
log.error("Run block: there is no debug session, yet");
|
log.error("Run block: there is no debug session, yet");
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -256,6 +287,8 @@ function NodeHeader({
|
|||||||
|
|
||||||
const body = getPayload({
|
const body = getPayload({
|
||||||
blockLabel,
|
blockLabel,
|
||||||
|
blockOutputs:
|
||||||
|
blockOutputsStore.getOutputsWithOverrides(workflowPermanentId),
|
||||||
browserSessionId: debugSession.browser_session_id,
|
browserSessionId: debugSession.browser_session_id,
|
||||||
parameters,
|
parameters,
|
||||||
totpIdentifier,
|
totpIdentifier,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getClient } from "@/api/AxiosClient";
|
||||||
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workflowPermanentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useDebugSessionBlockOutputsQuery({ workflowPermanentId }: Props) {
|
||||||
|
const credentialGetter = useCredentialGetter();
|
||||||
|
|
||||||
|
return useQuery<{ [k: string]: { extracted_information: unknown } }>({
|
||||||
|
queryKey: ["block-outputs", workflowPermanentId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||||
|
const result = await client
|
||||||
|
.get(`/debug-session/${workflowPermanentId}/block-outputs`)
|
||||||
|
.then((response) => response.data);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
enabled: !!workflowPermanentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useDebugSessionBlockOutputsQuery };
|
||||||
273
skyvern-frontend/src/store/BlockOutputStore.ts
Normal file
273
skyvern-frontend/src/store/BlockOutputStore.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* A store to hold block outputs for the debugger. Overrides for block outputs,
|
||||||
|
* keyed by (wpid, blockLabel), are kept in local storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface BlockOutputStore {
|
||||||
|
outputs: { [blockLabel: string]: { [k: string]: unknown } };
|
||||||
|
useOverrides: { [wpid: string]: { [blockLabel: string]: boolean } };
|
||||||
|
// --
|
||||||
|
getOverride: (opts: {
|
||||||
|
wpid: string | undefined;
|
||||||
|
blockLabel: string;
|
||||||
|
}) => { [k: string]: unknown } | null;
|
||||||
|
getUseOverride: (opts: {
|
||||||
|
wpid: string | undefined;
|
||||||
|
blockLabel: string;
|
||||||
|
}) => boolean;
|
||||||
|
getOutputsWithOverrides: (wpid: string | undefined) => {
|
||||||
|
[blockLabel: string]: { [k: string]: unknown };
|
||||||
|
};
|
||||||
|
setOutputs: (outputs: {
|
||||||
|
[blockLabel: string]: { [k: string]: unknown };
|
||||||
|
}) => void;
|
||||||
|
setOverride: (opts: {
|
||||||
|
wpid: string | undefined;
|
||||||
|
blockLabel: string;
|
||||||
|
data: { [k: string]: unknown };
|
||||||
|
}) => boolean;
|
||||||
|
setUseOverride: (opts: {
|
||||||
|
wpid: string | undefined;
|
||||||
|
blockLabel: string;
|
||||||
|
value: boolean;
|
||||||
|
}) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStorageKey = (wpid: string, blockLabel: string) => {
|
||||||
|
return `skyvern.block-output.${wpid}.${blockLabel}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStorageKeyForUse = (wpid: string, blockLabel: string) => {
|
||||||
|
return `skyvern.block-output.use.${wpid}.${blockLabel}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialize = (
|
||||||
|
blockLabel: string,
|
||||||
|
data: { [k: string]: unknown } | boolean,
|
||||||
|
) => {
|
||||||
|
let serialized: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
serialized = JSON.stringify(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Cannot serialize data for ${blockLabel}`, e, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialized === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialized.trim() === "") {
|
||||||
|
serialized = "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUse = (wpid: string, blockLabel: string) => {
|
||||||
|
const key = getStorageKeyForUse(wpid, blockLabel);
|
||||||
|
const serialized = localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (!serialized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Boolean(JSON.parse(serialized));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Cannot deserialize use override for ${blockLabel}`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = (wpid: string, blockLabel: string) => {
|
||||||
|
const key = getStorageKey(wpid, blockLabel);
|
||||||
|
const serialized = localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (!serialized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(serialized) as { [k: string]: unknown };
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Cannot deserialize block output override for ${blockLabel}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = (
|
||||||
|
wpid: string,
|
||||||
|
blockLabel: string,
|
||||||
|
data: { [k: string]: unknown },
|
||||||
|
) => {
|
||||||
|
const key = getStorageKey(wpid, blockLabel);
|
||||||
|
const serialized = serialize(blockLabel, data);
|
||||||
|
|
||||||
|
if (serialized === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, serialized);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeUse = (wpid: string, blockLabel: string, value: boolean) => {
|
||||||
|
const key = getStorageKeyForUse(wpid, blockLabel);
|
||||||
|
const serialized = serialize(blockLabel, value);
|
||||||
|
|
||||||
|
if (serialized === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, serialized);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to load all useOverrides from localStorage
|
||||||
|
const loadAllUseOverrides = (): {
|
||||||
|
[wpid: string]: { [blockLabel: string]: boolean };
|
||||||
|
} => {
|
||||||
|
const useOverrides: {
|
||||||
|
[wpid: string]: { [blockLabel: string]: boolean };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Iterate through all localStorage keys to find useOverride entries
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith("skyvern.block-output.use.")) {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
// Extract wpid and blockLabel from key: skyvern.block-output.use.{wpid}.{blockLabel}
|
||||||
|
const keyParts = key.split(".");
|
||||||
|
if (keyParts.length >= 5) {
|
||||||
|
const wpid = keyParts[3];
|
||||||
|
const blockLabel = keyParts.slice(4).join(".");
|
||||||
|
|
||||||
|
if (wpid && blockLabel) {
|
||||||
|
useOverrides[wpid] ??= {};
|
||||||
|
useOverrides[wpid][blockLabel] = Boolean(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to parse useOverride from localStorage key: ${key}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return useOverrides;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useBlockOutputStore = create<BlockOutputStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
outputs: {},
|
||||||
|
useOverrides: loadAllUseOverrides(),
|
||||||
|
// --
|
||||||
|
getOverride: (opts) => {
|
||||||
|
const { wpid, blockLabel } = opts;
|
||||||
|
|
||||||
|
if (!wpid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = load(wpid, blockLabel);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
getUseOverride: (opts) => {
|
||||||
|
const { wpid, blockLabel } = opts;
|
||||||
|
|
||||||
|
if (!wpid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const use = loadUse(wpid, blockLabel);
|
||||||
|
|
||||||
|
return use || false;
|
||||||
|
},
|
||||||
|
getOutputsWithOverrides: (wpid) => {
|
||||||
|
const state = get();
|
||||||
|
const baseOutputs = { ...state.outputs };
|
||||||
|
|
||||||
|
if (!wpid) {
|
||||||
|
return baseOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply overrides for blocks where useOverrides[wpid][blockLabel] is true
|
||||||
|
const workflowOverrides = state.useOverrides[wpid];
|
||||||
|
if (workflowOverrides) {
|
||||||
|
Object.entries(workflowOverrides).forEach(
|
||||||
|
([blockLabel, useOverride]) => {
|
||||||
|
if (useOverride) {
|
||||||
|
const override = state.getOverride({ wpid, blockLabel });
|
||||||
|
if (override) {
|
||||||
|
baseOutputs[blockLabel] = override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseOutputs;
|
||||||
|
},
|
||||||
|
setOutputs: (outputs) => {
|
||||||
|
set(() => ({
|
||||||
|
outputs,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setOverride: (opts) => {
|
||||||
|
const { wpid, blockLabel, data } = opts;
|
||||||
|
|
||||||
|
if (!wpid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasStored = store(wpid, blockLabel, data);
|
||||||
|
|
||||||
|
return wasStored;
|
||||||
|
},
|
||||||
|
setUseOverride: (opts) => {
|
||||||
|
const { wpid, blockLabel, value } = opts;
|
||||||
|
|
||||||
|
if (!wpid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasStored = storeUse(wpid, blockLabel, value);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
...state,
|
||||||
|
useOverrides: {
|
||||||
|
...state.useOverrides,
|
||||||
|
[wpid]: {
|
||||||
|
...state.useOverrides[wpid],
|
||||||
|
[blockLabel]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return wasStored;
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
outputs: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export { useBlockOutputStore };
|
||||||
@@ -4,3 +4,35 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clampToZero = (n: number) => Math.max(n, 0);
|
||||||
|
|
||||||
|
export const formatMs = (elapsed: number) => {
|
||||||
|
let seconds = clampToZero(Math.floor(elapsed / 1000));
|
||||||
|
let minutes = clampToZero(Math.floor(seconds / 60));
|
||||||
|
let hours = clampToZero(Math.floor(minutes / 60));
|
||||||
|
const days = clampToZero(Math.floor(hours / 24));
|
||||||
|
|
||||||
|
seconds = seconds % 60;
|
||||||
|
minutes = minutes % 60;
|
||||||
|
hours = hours % 24;
|
||||||
|
|
||||||
|
const ago =
|
||||||
|
days === 0 && hours === 0 && minutes === 0 && seconds === 0
|
||||||
|
? "now"
|
||||||
|
: days === 0 && hours === 0 && minutes === 0
|
||||||
|
? `${seconds}s ago`
|
||||||
|
: days === 0 && hours === 0
|
||||||
|
? `${minutes}m ago`
|
||||||
|
: days === 0
|
||||||
|
? `${hours}h ago`
|
||||||
|
: `${days}d ago`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ago,
|
||||||
|
hour: hours,
|
||||||
|
minute: minutes,
|
||||||
|
second: seconds,
|
||||||
|
day: days,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user