From 761a006a2ddb9186832e8c3b6e77f5fda666e121 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 16:58:19 +0200 Subject: [PATCH] Offer settings from other nodes, help with cron, follow the ports Suggest a parameter's value from the nodes of the same type in every flow, the way message names are already offered; secrets stay out of it. Explain the cron fields, and derive the expression an interval asks for. Keep an untouched function scaffold in step with the node's ports. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- frontend/src/components/Flow/NodePanel.tsx | 336 ++++++++++++++++----- frontend/src/components/Flow/queries.ts | 47 +++ 2 files changed, 305 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index c7fd74b..88db10b 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -1,6 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Maximize2, Minimize2, X } from "lucide-react" -import { lazy, Suspense, useEffect, useRef, useState } from "react" +import { + type ComponentProps, + lazy, + Suspense, + useEffect, + useRef, + useState, +} from "react" import { type DType, @@ -37,6 +44,7 @@ import { libraryQueryOptions, nodeSourceQueryOptions, secretsQueryOptions, + useParamSuggestions, } from "./queries" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" @@ -52,6 +60,93 @@ const SECTION = PANEL_SECTION /** Settings the engine reads itself, so they are not the author's to name. */ const RESERVED_PARAMS = new Set(["synchronous"]) +/** + * A text field that offers what is already in use elsewhere. + * + * Typing stays free — the list is a shortcut, never a constraint — but picking + * beats typing wherever two things only work together when they match. + */ +function SuggestInput({ + value, + suggestions, + empty, + onChange, + onFocus, + onBlur, + ...props +}: { + value: string + suggestions: string[] + /** What to say when nothing matches what is typed. */ + empty: string + onChange: (next: string) => void +} & Omit, "value" | "onChange">) { + const [open, setOpen] = useState(false) + const matches = suggestions.filter( + (item) => + item !== value && item.toLowerCase().includes(value.toLowerCase()), + ) + + return ( + 0} onOpenChange={setOpen}> + + { + setOpen(true) + onFocus?.(event) + }} + onBlur={(event) => { + setOpen(false) + onBlur?.(event) + }} + onChange={(event) => { + onChange(event.target.value) + setOpen(true) + }} + onKeyDown={(event) => { + if (event.key === "Escape") setOpen(false) + }} + /> + + event.preventDefault()} + > + + + {empty} + + {matches.map((item) => ( + { + event.preventDefault() + onChange(item) + setOpen(false) + }} + onSelect={() => { + onChange(item) + setOpen(false) + }} + > + {item} + + ))} + + + + + + ) +} + /** * A message name, typed freely or picked from the names already in play. * @@ -74,77 +169,29 @@ function MessageNameInput({ /** The name as it was before this edit, once the field is done with. */ onRenamed?: (previous: string, next: string) => void }) { - const [open, setOpen] = useState(false) // Every keystroke commits, so a rename is only a rename once the user is // finished with the field. const before = useRef(value) - const matches = suggestions.filter( - (name) => - name !== value && name.toLowerCase().includes(value.toLowerCase()), - ) return ( - 0} onOpenChange={setOpen}> - - { - before.current = value - setOpen(true) - }} - onBlur={() => { - setOpen(false) - if (before.current !== value) onRenamed?.(before.current, value) - before.current = value - }} - onChange={(event) => { - onChange(event.target.value) - setOpen(true) - }} - onKeyDown={(event) => { - if (event.key === "Escape") setOpen(false) - }} - /> - - event.preventDefault()} - > - - - No matching message. - - {matches.map((name) => ( - { - event.preventDefault() - onChange(name) - setOpen(false) - }} - onSelect={() => { - onChange(name) - setOpen(false) - }} - > - {name} - - ))} - - - - - + { + before.current = value + }} + onBlur={() => { + if (before.current !== value) onRenamed?.(before.current, value) + before.current = value + }} + /> ) } @@ -423,18 +470,75 @@ function FreeParamsForm({ ) } +/** + * The cron expression that fires as often as `seconds` asks for, if there is + * one. Only divisors of an hour or a day line up — a step of seven minutes + * would jump from :56 back to :00, which is not every seven minutes. + */ +function cronFromInterval(seconds: unknown): string | null { + const value = Number(seconds) + if (!Number.isFinite(value) || value <= 0) return null + const minutes = value / 60 + // Cron's finest grain is the minute. + if (!Number.isInteger(minutes)) return null + if (minutes === 1) return "* * * * *" + if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null + const hours = minutes / 60 + if (!Number.isInteger(hours)) return null + if (hours === 1) return "0 * * * *" + if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null + return hours === 24 ? "0 0 * * *" : null +} + +/** What the five fields mean, and the schedule the interval beside them asks for. */ +function CronHelp({ + params, + onPick, +}: { + params: Record + onPick: (expression: string) => void +}) { + const derived = cronFromInterval(params.interval) + + return ( + <> +

+ Five fields —{" "} + + minute hour day-of-month month weekday + + . * is every,{" "} + */5 every fifth,{" "} + 1-5 a range. +

+ {derived && params.cron !== derived ? ( + + ) : null} + + ) +} + /** A small form built from the node type's declared parameters. */ function ParamsForm({ + type, schema, params, onChange, }: { + type: string | undefined schema: Record | undefined params: Record onChange: (next: Record) => void }) { - const { data: secretList } = useQuery(secretsQueryOptions()) - const secrets = secretList?.data ?? [] const properties = (schema?.properties ?? {}) as Record< string, { @@ -446,6 +550,12 @@ function ParamsForm({ } > const entries = Object.entries(properties) + + const { data: secretList } = useQuery(secretsQueryOptions()) + const secrets = secretList?.data ?? [] + // A type with nothing to fill in has nothing to suggest, and the other flows + // are not worth reading for it. + const suggestions = useParamSuggestions(entries.length ? type : undefined) if (entries.length === 0) return null const set = (key: string, value: unknown) => @@ -519,23 +629,26 @@ function ParamsForm({ - - set( - key, - numeric ? Number(event.target.value) : event.target.value, - ) - } + suggestions={suggestions[key] ?? []} + empty="Nothing like that in use yet." + onChange={(next) => set(key, numeric ? Number(next) : next)} /> {property.description ? (

{property.description}

) : null} + {key === "cron" ? ( + set(key, expression)} + /> + ) : null} ) })} @@ -645,6 +758,57 @@ function SharingSection({ ) } +/** The value a fresh output returns, so the scaffold runs as written. */ +const PLACEHOLDER: Record = { + float: "0.0", + int: "0", + bool: "False", + str: '""', + json: "{}", +} + +const SCAFFOLD_DOC = + '"""A new node. Return a dict keyed by your output ports."""' + +const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +/** + * Exactly what `scaffoldFor` writes, for any set of ports — and the source a + * node starts life with, which is that with no ports at all. + * + * Nothing else matches, which is what makes it safe to overwrite: a returned + * value that is not one of the placeholders is somebody's code. + */ +const SCAFFOLD_SHAPE = (() => { + const value = Object.values(PLACEHOLDER).map(quote).join("|") + const entry = `"[^"]+": (?:${value})` + return new RegExp( + `^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`, + ) +})() + +/** The name the node function sees; the engine derives it the same way. */ +const portName = (spec: MessageSpec) => + spec.port || (spec.name ?? "").split(".").pop() || "" + +/** A `process` that takes this node's inputs and returns its outputs. */ +function scaffoldFor(node: NodeDef_Input): string { + const args = [ + ...new Set( + (node.requires ?? []) + .map(portName) + // Anything else cannot be a keyword argument, so it cannot be a port. + .filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"), + ), + ] + const returns = (node.provides ?? []) + .filter((spec) => portName(spec)) + .map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`) + return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join( + ", ", + )}):\n return {${returns.join(", ")}}\n` +} + function PanelBody({ node, flow, @@ -699,6 +863,21 @@ function PanelBody({ }, 1000) } + /** + * Change the node, and keep an untouched scaffold in step with its ports. + * + * Only code that is still exactly what this panel generates is rewritten — + * one edit of your own and it is yours, ports or no ports. Shared code is + * never rewritten either: the other flows using it have their own ports. + */ + const editNode = (next: NodeDef_Input) => { + onChange(next) + const current = code ?? source?.code + if (!hasSource || next.source_ref || current === undefined) return + const wanted = scaffoldFor(next) + if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted) + } + // ⌘S in the editor means "apply this code"; the editor's own state is here, // so the binding is too. Anywhere else on the canvas it publishes the flow. useShortcuts( @@ -729,7 +908,7 @@ function PanelBody({ flow={flow} emptyHint="Nothing yet. Add a message this node reads." suggestions={suggestions.consumes} - onChange={(requires) => onChange({ ...node, requires })} + onChange={(requires) => editNode({ ...node, requires })} /> onChange({ ...node, provides })} + onChange={(provides) => editNode({ ...node, provides })} // Only the publishing side names a message; an input is as often // re-pointed at a different one as it is renamed. onRenamed={onRenameMessage} /> onChange({ ...node, params })} diff --git a/frontend/src/components/Flow/queries.ts b/frontend/src/components/Flow/queries.ts index 4bbdf14..cb5144f 100644 --- a/frontend/src/components/Flow/queries.ts +++ b/frontend/src/components/Flow/queries.ts @@ -1,6 +1,8 @@ import { type UseMutationResult, useMutation, + useQueries, + useQuery, useQueryClient, } from "@tanstack/react-query" import { useCallback, useEffect, useRef, useState } from "react" @@ -62,6 +64,51 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({ queryFn: () => FlowsService.readMessageHistory({ name, message }), }) +/** Other flows change rarely; suggestions do not need them fresh to the second. */ +const SUGGEST_STALE = 5 * 60 * 1000 + +/** + * Values already in use by nodes of the same type, keyed by parameter. + * + * The second MQTT node points at the same broker as the first one, so the + * settings are worth offering rather than making someone type them again. + * + * Only plain values are collected: a secret is stored as a reference object + * and stays out of this, which is the point of the secret picker. + */ +export function useParamSuggestions( + type: string | undefined, +): Record { + const { data: flows } = useQuery({ + ...flowsQueryOptions(), + staleTime: SUGGEST_STALE, + }) + // ponytail: reads every flow to collect them, sharing the editor's own cache + // entries; an aggregate endpoint if a big installation makes that hurt. + const details = useQueries({ + queries: (type ? (flows?.data ?? []) : []).map((flow) => ({ + ...flowQueryOptions(flow.name), + staleTime: SUGGEST_STALE, + })), + }) + + const seen: Record> = {} + for (const { data } of details) { + for (const node of data?.definition.nodes ?? []) { + if (node.type !== type) continue + for (const [key, value] of Object.entries(node.params ?? {})) { + if (value === null || value === "" || typeof value === "object") + continue + if (!seen[key]) seen[key] = new Set() + seen[key].add(String(value)) + } + } + } + return Object.fromEntries( + Object.entries(seen).map(([key, values]) => [key, [...values].sort()]), + ) +} + const AUTOSAVE_DELAY = 800 /** How long to wait for a save in flight before sending the next one. */ const RETRY_DELAY = 100