From 12817617f3cde2568a4010481320e319bcec654b Mon Sep 17 00:00:00 2001 From: Adam Debreceni Date: Thu, 25 Sep 2025 17:37:39 +0200 Subject: [PATCH 1/3] Upgrade manifest of edited flow --- examples/minifi/agent-cpp.dockerfile | 1 + website/src/components/flow-editor/index.scss | 4 +-- website/src/components/flow-editor/index.tsx | 30 ++++++++++++++++++- website/src/services/agent.ts | 7 ++--- website/src/services/index.d.ts | 2 +- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/examples/minifi/agent-cpp.dockerfile b/examples/minifi/agent-cpp.dockerfile index 99e0a33..3d2d013 100644 --- a/examples/minifi/agent-cpp.dockerfile +++ b/examples/minifi/agent-cpp.dockerfile @@ -1,6 +1,7 @@ FROM apache/nifi-minifi-cpp:latest RUN echo "nifi.c2.enable=true" >> /opt/minifi/minifi-current/conf/minifi.properties && \ + echo "nifi.c2.rest.path.base=http://c2:13405/api" >> /opt/minifi/minifi-current/conf/minifi.properties && \ echo "nifi.c2.flow.base.url=http://c2:13405/api/flows" >> /opt/minifi/minifi-current/conf/minifi.properties && \ echo "nifi.c2.rest.url=http://c2:13405/api/heartbeat" >> /opt/minifi/minifi-current/conf/minifi.properties && \ echo "nifi.c2.rest.url.ack=http://c2:13405/api/acknowledge" >> /opt/minifi/minifi-current/conf/minifi.properties && \ diff --git a/website/src/components/flow-editor/index.scss b/website/src/components/flow-editor/index.scss index 3f90aa8..1dbbedb 100644 --- a/website/src/components/flow-editor/index.scss +++ b/website/src/components/flow-editor/index.scss @@ -82,7 +82,7 @@ gap: 10px; } - .open-publish, .export-btn, .rearrange-btn { + .open-publish, .export-btn, .rearrange-btn, .upgrade-manifest-btn { padding: 8px 20px; border-radius: 5px; color: var(--text-inactive-color); @@ -91,7 +91,7 @@ cursor: pointer; } - .open-publish:hover, .export-btn:hover, .rearrange-btn:hover { + .open-publish:hover, .export-btn:hover, .rearrange-btn:hover, .upgrade-manifest-btn:hover { color: var(--highlight-green); background-color: var(--light-green); border-color: var(--highlight-green); diff --git a/website/src/components/flow-editor/index.tsx b/website/src/components/flow-editor/index.tsx index 3c29c22..5f42430 100644 --- a/website/src/components/flow-editor/index.tsx +++ b/website/src/components/flow-editor/index.tsx @@ -42,6 +42,7 @@ export type ResizeDir = 'top' | 'top-left' | 'left' | 'bottom-left' | 'bottom' | interface FlowEditorState { saved: boolean, flow: FlowObject + classManifest: AgentManifest|null menu: { position: { x: number, y: number }, items: { name: string, on: () => void }[] } | null panning: boolean, editingComponent: Uuid | null, @@ -152,7 +153,7 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { const [state, setState] = useState({ saved: true, publish: {agents: [], classes: [], targetFlow: null, modal: false, pending: false}, flow: props.flow, panning: false, menu: null, editingComponent: null, newConnection: null, newComponent: null, - resizeGroup: null, movingComponent: null, newProcessGroup: null, selected: [] + resizeGroup: null, movingComponent: null, newProcessGroup: null, selected: [], classManifest: null }); const [errors, setErrors] = useState([]); const areaRef = React.useRef(null); @@ -256,6 +257,24 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { const isSavePending = React.useRef(false); + React.useEffect(() => { + if (!props.flow.className) return; + let mounted = true; + let fn = () => { + if (!mounted) return; + services?.agents.fetchManifestForClass(props.flow.className!).then(manifest => { + if (!mounted || !manifest?.hash || manifest?.hash === props.flow.manifest.hash) return; + setState(st => ({...st, classManifest: manifest!})); + }) + }; + fn(); + const timer = setInterval(fn, 2000); + return () => { + mounted = false; + clearInterval(timer); + } + }, [props.flow.className, props.flow.manifest.hash]) + React.useEffect(() => { let errors: ErrorObject[] = []; for (let proc of state.flow.processors) { @@ -1038,6 +1057,15 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) {
Rearrange
+ { + state.classManifest && state.flow.manifest.hash !== state.classManifest?.hash ? +
{ + setState(st => ({...st, flow: {...st.flow, manifest: st.classManifest!}})) + notif.emit('Manifest successfully upgraded', 'success'); + }}> + Upgrade manifest +
: null + } { !state.publish.modal ? null : diff --git a/website/src/services/agent.ts b/website/src/services/agent.ts index ecd7d1a..d184a86 100644 --- a/website/src/services/agent.ts +++ b/website/src/services/agent.ts @@ -97,8 +97,7 @@ export class AgentServiceImpl implements AgentService { // const response = await SendRequest("GET", this.api + "/agent/manifest/" + encodeURIComponent(id)); // return JSON.parse(response); // } - // async fetchManifestForClass(name: string): Promise { - // const response = await SendRequest("GET", this.api + "/class/manifest/" + encodeURIComponent(name)); - // return JSON.parse(response); - // } + async fetchManifestForClass(name: string): Promise { + return SendRequest("GET", this.api + "/class/manifest/" + encodeURIComponent(name)); + } } diff --git a/website/src/services/index.d.ts b/website/src/services/index.d.ts index 2404cac..590397a 100644 --- a/website/src/services/index.d.ts +++ b/website/src/services/index.d.ts @@ -131,7 +131,7 @@ interface AgentService { saveConfig(agentId: string, data: any): Promise linkClass(agentId: string): Promise // fetchManifestForAgent(id: string): Promise; - // fetchManifestForClass(name: string): Promise; + fetchManifestForClass(name: string): Promise; } interface AlertService { From db7c892b831fff55ff13a5f71e55f81ace2e404a Mon Sep 17 00:00:00 2001 From: Adam Debreceni Date: Mon, 2 Feb 2026 13:59:27 +0100 Subject: [PATCH 2/3] Fix dropdown visibility --- website/src/components/flow-editor/index.tsx | 2 +- website/src/components/processor-editor/index.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/flow-editor/index.tsx b/website/src/components/flow-editor/index.tsx index 5f42430..ae87898 100644 --- a/website/src/components/flow-editor/index.tsx +++ b/website/src/components/flow-editor/index.tsx @@ -299,7 +299,7 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { } } for (let property_key in proc.properties) { - let is_required = proc_manifest.propertyDescriptors?.[property_key].required ?? false; + let is_required = proc_manifest.propertyDescriptors?.[property_key]?.required ?? false; let is_null = proc.properties[property_key] === null; if (is_required && is_null) { errors.push({ diff --git a/website/src/components/processor-editor/index.scss b/website/src/components/processor-editor/index.scss index c24b6e4..c9197f1 100644 --- a/website/src/components/processor-editor/index.scss +++ b/website/src/components/processor-editor/index.scss @@ -56,7 +56,7 @@ &:not(.active) { display: none; } - overflow: hidden; + overflow: visible; } .uuid { From 4355db3149b84da94ae010c9a4c0d723daf5cc80 Mon Sep 17 00:00:00 2001 From: Adam Debreceni Date: Wed, 4 Feb 2026 17:11:42 +0100 Subject: [PATCH 3/3] Report manifest upgrade errors --- common/flow.d.ts | 10 +- server/src/utils/flow-serializer.ts | 2 +- server/src/utils/json-flow-serializer.ts | 25 +- .../components/connection-editor/index.tsx | 19 +- website/src/components/connection/index.tsx | 20 +- .../components/create-string-modal/index.scss | 3 + .../components/create-string-modal/index.tsx | 14 +- website/src/components/dropdown/index.tsx | 2 +- .../extended-widget/ai-widget/index.tsx | 2 +- .../src/components/extended-widget/index.tsx | 2 +- website/src/components/flow-editor/index.tsx | 338 ++++++++++-------- .../src/components/flow-readonly/index.tsx | 6 +- .../components/processor-editor/index.scss | 2 +- .../src/components/processor-editor/index.tsx | 45 ++- .../src/components/service-editor/index.tsx | 8 +- website/src/utils/attribute-expression.ts | 2 +- 16 files changed, 293 insertions(+), 207 deletions(-) diff --git a/common/flow.d.ts b/common/flow.d.ts index 6cdb1ff..b2a4c31 100644 --- a/common/flow.d.ts +++ b/common/flow.d.ts @@ -112,11 +112,16 @@ interface ImportState { classes: string[] } +type PropertyValue = { + value: string|null, + type: "default"|"custom" +} + interface Component extends Positionable { id: Uuid, type: string, name: string, - properties: {[name: string]: string|null} + properties: {[name: string]: PropertyValue} visibleProperties?: string[] running?: ComponentState parentGroup?: Uuid|null @@ -194,10 +199,9 @@ interface ConnectionSize { interface Connection { id: Uuid, name: string|null, - errors: string[], attributes: string[], source: {id: Uuid, port: string|null}, - sourceRelationships: {[name: string]: boolean}, + sourceRelationships: string[], destination: {id: Uuid, port: string|null}, flowFileExpiration: string, swapThreshold: string|null, diff --git a/server/src/utils/flow-serializer.ts b/server/src/utils/flow-serializer.ts index 5586a06..c3f9b9e 100644 --- a/server/src/utils/flow-serializer.ts +++ b/server/src/utils/flow-serializer.ts @@ -37,7 +37,7 @@ export function SerializeFlow(id: string, flow: FlowObject): string { for (const conn of flow.connections) { const src = flow.processors.find(proc => conn.source.id === proc.id)!; const dst = flow.processors.find(proc => conn.destination.id === proc.id)!; - const rels = Object.keys(conn.sourceRelationships).filter(rel => conn.sourceRelationships[rel]); + const rels = conn.sourceRelationships; result += " - name: " + (conn.name ?? `${src.name}/${rels.join(",")}/${dst.name}`) + "\n"; result += " id: " + conn.id + "\n"; result += " source name: " + src.name + "\n"; diff --git a/server/src/utils/json-flow-serializer.ts b/server/src/utils/json-flow-serializer.ts index 5fe21be..cd0ef93 100644 --- a/server/src/utils/json-flow-serializer.ts +++ b/server/src/utils/json-flow-serializer.ts @@ -108,7 +108,7 @@ function serializeProcessGroup(id: Uuid | null, flow: FlowObject): object { }).map(conn => { const src = findComponent(flow, conn.source.id)!; const dst = findComponent(flow, conn.destination.id)!; - const rels = Object.keys(conn.sourceRelationships).filter(rel => conn.sourceRelationships[rel]); + const rels = conn.sourceRelationships; return { "position": { "x": typeof conn.midPoint === "number" ? conn.midPoint : (conn.midPoint?.x ?? 0.0), @@ -159,11 +159,11 @@ function findComponent(flow: FlowObject, id: Uuid): Component | undefined | null flow.funnels.find(val => val.id === id); } -function filterNullish(obj: { [key: string]: string | null }): { [key: string]: string } { +function filterNullish(obj: { [key: string]: PropertyValue }): { [key: string]: string } { let result: { [key: string]: string } = {}; for (let key in obj) { - if (isNullish(obj[key])) continue; - result[key] = obj[key]!; + if (isNullish(obj[key].value)) continue; + result[key] = obj[key].value; } return result; } @@ -268,7 +268,6 @@ function deserializeProcessGroup(flow_object: FlowObject, group_id: Uuid | null, flow_object.connections = flow_object.connections.concat(process_group_json.connections.map((conn: any) => ({ id: conn.identifier, name: conn.name, - errors: [], attributes: [], source: { id: conn.source.id, @@ -362,7 +361,7 @@ function fixFlowObject(flow_object: FlowObject) { if (processor_manifest && processor_manifest.propertyDescriptors) { for (let property_name in processor_manifest.propertyDescriptors) { if (!(property_name in processor.properties)) { - processor.properties[property_name] = null; + processor.properties[property_name].value = null; } } } @@ -379,22 +378,12 @@ function fixFlowObject(flow_object: FlowObject) { if (service_manifest && service_manifest.propertyDescriptors) { for (let property_name in service_manifest.propertyDescriptors) { if (!(property_name in service.properties)) { - service.properties[property_name] = null; + service.properties[property_name].value = null; } } } } for (let connection of flow_object.connections) { - const source_processor = flow_object.processors.find(processor => processor.id === connection.source.id); - if (source_processor) { - const source_manifest = flow_object.manifest.processors.find(processor_manifest => processor_manifest.type === source_processor.type); - if (source_manifest) { - for (let relationship of source_manifest.supportedRelationships) { - if (!(relationship.name in connection.sourceRelationships)) { - connection.sourceRelationships[relationship.name] = false; - } - } - } - } + connection.sourceRelationships = [] } } diff --git a/website/src/components/connection-editor/index.tsx b/website/src/components/connection-editor/index.tsx index 9d70830..63d9e2e 100644 --- a/website/src/components/connection-editor/index.tsx +++ b/website/src/components/connection-editor/index.tsx @@ -5,7 +5,7 @@ import { InputField } from "../component-editor-input"; import { Toggle } from "../component-editor-toggle"; import "./index.scss" -export function ConnectionEditor(props: {model: Connection, readonly?: boolean}) { +export function ConnectionEditor(props: {model: Connection, supportedRels: string[], readonly?: boolean, errors: ErrorObject[]}) { const flow_context = useContext(FlowContext); const setModel = React.useMemo(()=>{ return (fn: (curr: Connection)=>Connection) => flow_context!.updateConnection(props.model.id, fn); @@ -24,7 +24,7 @@ export function ConnectionEditor(props: {model: Connection, readonly?: boolean})
General
- {props.model.errors.map(err =>
{err}
)} + {props.errors.map(err =>
{err.message}
)} setModel(curr => ({...curr, name: val})) : undefined}/> setModel(curr => ({...curr, flowFileExpiration: val})) : undefined}/> setModel(curr => ({...curr, backpressureThreshold: {...curr.backpressureThreshold, count: val}})) : undefined}/> @@ -33,11 +33,16 @@ export function ConnectionEditor(props: {model: Connection, readonly?: boolean})
Source relationships
- { - Object.keys(props.model.sourceRelationships).sort().map(rel=>{ - return setModel(curr => ({...curr, sourceRelationships: {...curr.sourceRelationships, [rel]: val}})) : undefined} /> - }) - } + {[...new Set([...props.supportedRels, ...props.model.sourceRelationships])].map(rel => { + return err.target === rel)?.message} + initial={props.model.sourceRelationships.includes(rel)} + onChange={flow_context?.editable ? val => setModel(curr => { + if (curr.sourceRelationships.includes(rel)) { + return {...curr, sourceRelationships: curr.sourceRelationships.filter(curr_rel => curr_rel !== rel)} + } + return {...curr, sourceRelationships: [...curr.sourceRelationships, rel]} + }) : undefined} /> + })}
Flow File Attributes
diff --git a/website/src/components/connection/index.tsx b/website/src/components/connection/index.tsx index 4231378..57a0dc1 100644 --- a/website/src/components/connection/index.tsx +++ b/website/src/components/connection/index.tsx @@ -12,11 +12,11 @@ export function IsInside(area: {x: number, y: number, w: number, h: number, circ return area.x - area.w/2 <= x && x <= area.x + area.w/2 && area.y - area.h/2 <= y && y <= area.y + area.h/2; } -export function ConnectionView(props: {model?: Connection, id?: Uuid, +export function ConnectionView(props: {model?: Connection, id?: Uuid, supportedRel: string[], from: {x: number, y: number, w: number, h: number, circular: boolean}, to: {x: number, y: number, w: number, h: number, circular: boolean}, name?: string, midPoint?: {x: number, y: number}|number, readonly?: boolean, container?: Positionable|null, - selected?: boolean}) { + selected?: boolean, errors: ErrorObject[]}) { const midPoint = props.model?.midPoint ?? props.midPoint; let v_x = props.to.x - props.from.x; @@ -196,7 +196,7 @@ export function ConnectionView(props: {model?: Connection, id?: Uuid, { props.id === undefined ? null : - + }
@@ -224,7 +224,7 @@ const usage_colors = [ {bg: "#CD0000", color: "#ffffff"}, ] -function ConnectionName(props: {id: Uuid, model: Connection, x: number, y: number, name?: string}) { +function ConnectionName(props: {id: Uuid, model: Connection, supportedRels: string[], x: number, y: number, name?: string, errors: ErrorObject[]}) { const [inline_rels, setInlineRels] = React.useState(false); const flow_context = React.useContext(FlowContext); const view_ref = React.useRef(null); @@ -275,10 +275,16 @@ function ConnectionName(props: {id: Uuid, model: Connection, x: number, y: numbe return
{props.name ? props.name : ""} - {props.model?.errors.length !== 0 ? : null} + {props.errors.length !== 0 ? : null}
- {Object.keys(props.model.sourceRelationships).map(rel => { - return flow_context!.updateConnection(props.model.id, curr => ({...curr, sourceRelationships: {...curr.sourceRelationships, [rel]: val}}))} /> + {[...new Set([...props.supportedRels, ...props.model.sourceRelationships])].map(rel => { + return err.target === rel)?.message} initial={props.model.sourceRelationships.includes(rel)} + onChange={val => flow_context!.updateConnection(props.model.id, curr => { + if (curr.sourceRelationships.includes(rel)) { + return {...curr, sourceRelationships: curr.sourceRelationships.filter(curr_rel => curr_rel !== rel)} + } + return {...curr, sourceRelationships: [...curr.sourceRelationships, rel]} + })} /> })}
{props.model.size ? diff --git a/website/src/components/create-string-modal/index.scss b/website/src/components/create-string-modal/index.scss index 67f60e5..976335b 100644 --- a/website/src/components/create-string-modal/index.scss +++ b/website/src/components/create-string-modal/index.scss @@ -16,6 +16,9 @@ border-radius: 3px; outline: none; background-color: var(--bg-color); + &:focus { + border-color: var(--highlight-blue); + } } .ok { align-self: flex-end; diff --git a/website/src/components/create-string-modal/index.tsx b/website/src/components/create-string-modal/index.tsx index 1fd896e..783deaa 100644 --- a/website/src/components/create-string-modal/index.tsx +++ b/website/src/components/create-string-modal/index.tsx @@ -8,9 +8,19 @@ export function CreateStringModal(props: {text: string, onSubmit: (val: string)= const onChange = React.useCallback((e: React.ChangeEvent)=>{ setPropName(e.currentTarget.value); }, [prop_name]) - return
+ return
{props.text}
- + { + if (e.code === "Escape") { + e.stopPropagation(); + openModal(null as any) + return; + } + if (e.code === "Enter") { + props.onSubmit(prop_name); openModal(null as any) + } + }}/>
{props.onSubmit(prop_name); openModal(null as any)}}>Create
} \ No newline at end of file diff --git a/website/src/components/dropdown/index.tsx b/website/src/components/dropdown/index.tsx index 8cb0d6c..ec2566f 100644 --- a/website/src/components/dropdown/index.tsx +++ b/website/src/components/dropdown/index.tsx @@ -31,7 +31,7 @@ export function Dropdown(props: {name: string, items: string[], initial?: string }
-
{state.current === '' ?   : state.current} +
{state.current === '' || state.current === null ?   : state.current} { props.onChange ?
diff --git a/website/src/components/extended-widget/ai-widget/index.tsx b/website/src/components/extended-widget/ai-widget/index.tsx index 0698ca0..0267e54 100644 --- a/website/src/components/extended-widget/ai-widget/index.tsx +++ b/website/src/components/extended-widget/ai-widget/index.tsx @@ -7,6 +7,6 @@ export function AiWidget(props: {value: Component}) {
-
{props.value.properties["Prompt"]}
+
{props.value.properties["Prompt"].value}
} \ No newline at end of file diff --git a/website/src/components/extended-widget/index.tsx b/website/src/components/extended-widget/index.tsx index 9cf1419..965ee2f 100644 --- a/website/src/components/extended-widget/index.tsx +++ b/website/src/components/extended-widget/index.tsx @@ -12,7 +12,7 @@ export function ExtendedWidget(props: {value: Component}) { props.value.visibleProperties!.map(property => { return
{property}
-
{`${props.value.properties[property]}`}
+
{`${props.value.properties[property].value}`}
}) } diff --git a/website/src/components/flow-editor/index.tsx b/website/src/components/flow-editor/index.tsx index ae87898..081434f 100644 --- a/website/src/components/flow-editor/index.tsx +++ b/website/src/components/flow-editor/index.tsx @@ -70,33 +70,16 @@ function createDefaultConnection(flow: FlowObject, src: Uuid, dst: Uuid, midPoin x: number, y: number } | number | null): Connection { - const proc = flow.processors.find(proc => proc.id === src)!; - const rels: { [name: string]: boolean } = {}; - if (proc) { - for (const rel in proc.autoterminatedRelationships) { - rels[rel] = false; - } - const manifest = flow.manifest.processors.find(proc_manifest => proc_manifest.type === proc.type); - if (manifest?.supportsDynamicRelationships) { - for (const prop in proc.properties) { - if (!manifest.propertyDescriptors || !(prop in manifest.propertyDescriptors)) { - rels[prop] = false; - } - } - } - } else { - rels['success'] = true; - } + const proc = flow.processors.find(proc => proc.id === src); return { id: uuid.v4() as Uuid, name: null, source: {id: src, port: null}, - sourceRelationships: rels, + sourceRelationships: proc ? [] : ['success'], destination: {id: dst, port: null}, flowFileExpiration: "0 seconds", backpressureThreshold: {count: "10000", size: "10 MB"}, swapThreshold: null, - errors: [], attributes: [], midPoint: midPoint ?? undefined } @@ -277,42 +260,30 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { React.useEffect(() => { let errors: ErrorObject[] = []; - for (let proc of state.flow.processors) { - const proc_manifest = state.flow.manifest.processors.find(proc_manifest => proc_manifest.type === proc.type)!; - for (let rel in proc.autoterminatedRelationships) { - const conn = state.flow.connections.find(conn => conn.source.id === proc.id && (rel in conn.sourceRelationships) && conn.sourceRelationships[rel]); - if (conn && proc.autoterminatedRelationships[rel]) { - errors.push({ - component: proc.id, - type: "RELATIONSHIP", - target: rel, - message: `Relationship '${rel}' is both connected and auto-terminated` - }); - } - if (!conn && (!(rel in proc.autoterminatedRelationships) || !proc.autoterminatedRelationships[rel])) { + const report_property_errors = (item: Component, manifest: ComponentManifest) => { + for (let property_key in item.properties) { + if (!manifest.propertyDescriptors?.[property_key] && !manifest.supportsDynamicProperties) { errors.push({ - component: proc.id, - type: "RELATIONSHIP", - target: rel, - message: `Relationship '${rel}' has to be either connected or auto-terminated` + component: item.id, + type: "PROPERTY", + target: property_key, + message: `Property '${property_key}' is not supported` }); } - } - for (let property_key in proc.properties) { - let is_required = proc_manifest.propertyDescriptors?.[property_key]?.required ?? false; - let is_null = proc.properties[property_key] === null; + let is_required = manifest.propertyDescriptors?.[property_key]?.required ?? false; + let is_null = item.properties[property_key].value === null; if (is_required && is_null) { errors.push({ - component: proc.id, + component: item.id, type: "PROPERTY", target: property_key, message: `Property '${property_key}' is required` }); } - if (proc.properties[property_key]) { + if (item.properties[property_key].value) { const asset_pattern = /@\{asset-id:([^}]*)\}/g; - const ms = proc.properties[property_key]?.matchAll?.(asset_pattern); + const ms = item.properties[property_key].value?.matchAll?.(asset_pattern); if (ms) { for (const m of ms) { const find_asset: (entries: FlowAssetDirectory['entries'])=>boolean = (entries) => { @@ -325,7 +296,7 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { } if (!find_asset(state.flow.assets ?? [])) { errors.push({ - component: proc.id, + component: item.id, type: "PROPERTY", target: property_key, message: `No such asset '${m[1]}' id` @@ -334,7 +305,73 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { } } } - + } + } + for (let proc of state.flow.processors) { + const proc_manifest = state.flow.manifest.processors.find(proc_manifest => proc_manifest.type === proc.type); + if (!proc_manifest) { + errors.push({ + component: proc.id, + type: "PROPERTY", + target: "PROCESSOR-TYPE", + message: `This processor type is not available` + }); + continue; + } + for (let rel in proc.autoterminatedRelationships) { + const conn = state.flow.connections.find(conn => conn.source.id === proc.id && conn.sourceRelationships.includes(rel)); + if (conn && proc.autoterminatedRelationships[rel]) { + errors.push({ + component: proc.id, + type: "RELATIONSHIP", + target: rel, + message: `Relationship '${rel}' is both connected and auto-terminated` + }); + } + if (!conn && (!(rel in proc.autoterminatedRelationships) || !proc.autoterminatedRelationships[rel])) { + errors.push({ + component: proc.id, + type: "RELATIONSHIP", + target: rel, + message: `Relationship '${rel}' has to be either connected or auto-terminated` + }); + } + if (!proc_manifest.supportsDynamicRelationships && !proc_manifest.supportedRelationships.find(sup_rel => sup_rel.name === rel)) { + errors.push({ + component: proc.id, + type: "RELATIONSHIP", + target: rel, + message: `Relationship '${rel}' is not supported` + }); + } + } + report_property_errors(proc, proc_manifest); + } + for (let service of state.flow.services) { + const service_manifest = state.flow.manifest.controllerServices.find(serv_manifest => serv_manifest.type === service.type); + if (!service_manifest) { + errors.push({ + component: service.id, + type: "PROPERTY", + target: "SERVICE-TYPE", + message: `This service type is not available` + }); + continue; + } + report_property_errors(service, service_manifest); + } + for (const conn of state.flow.connections) { + const src_proc = state.flow.processors.find(proc => proc.id === conn.source.id); + const supported_rels = src_proc ? Object.keys(src_proc.autoterminatedRelationships) : ['success']; + for (const rel of conn.sourceRelationships) { + if (!supported_rels.includes(rel)) { + errors.push({ + component: conn.id, + type: "RELATIONSHIP", + target: rel, + message: `Relationship '${rel}' is not available on source` + }); + } } } setErrors(errors); @@ -1014,7 +1051,7 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { } else { to = {...state.newConnection.to, w: 0, h: 0, circular: true}; } - return { - setState(st => ({...st, flow: {...st.flow, manifest: st.classManifest!}})) + setState(st => { + let new_processors: Processor[] = []; + for (const proc of st.flow.processors) { + const procManifest = st.classManifest!.processors.find(proc_manifest => proc_manifest.type === proc.type); + if (!procManifest) { + // this processor is no longer supported, use as-is + new_processors.push(proc); + continue; + } + let autoterminatedRelationships = {...createDefaultRelationshipStatus(procManifest.supportedRelationships), ...proc.autoterminatedRelationships}; + let properties = createDefaultProperties(procManifest.propertyDescriptors ?? {}); + for (const prop_name in proc.properties) { + if (proc.properties[prop_name].type === "custom") { + properties[prop_name] = proc.properties[prop_name]; + } + } + new_processors.push({...proc, properties, autoterminatedRelationships}); + } + let new_services: MiNiFiService[] = []; + for (const service of st.flow.services) { + const service_manifest = st.classManifest!.controllerServices.find(serv_manifest => serv_manifest.type === service.type); + if (!service_manifest) { + // this service is no longer supported, use as-is + new_services.push(service); + continue; + } + let properties = createDefaultProperties(service_manifest.propertyDescriptors ?? {}); + for (const prop_name in service.properties) { + if (service.properties[prop_name].type === "custom") { + properties[prop_name] = service.properties[prop_name]; + } + } + new_services.push({...service, properties}); + } + return {...st, flow: {...st.flow, processors: new_processors, services: new_services, manifest: st.classManifest!}} + }); notif.emit('Manifest successfully upgraded', 'success'); }}> Upgrade manifest @@ -1090,7 +1162,9 @@ export function FlowEditor(props: { id: string, flow: FlowObject }) { (() => { const conn = state.flow.connections.find(conn => conn.id === state.editingComponent); if (conn) { - return ; + const src_proc = state.flow.processors.find(proc => proc.id === conn.source.id); + const supported_rels = src_proc ? Object.keys(src_proc.autoterminatedRelationships) : ['success']; + return err.component === conn.id)} />; } const proc = state.flow.processors.find(proc => proc.id === state.editingComponent); if (proc) { @@ -1269,7 +1343,10 @@ export function emitProcessGroupItems(state: { } else { return null; } + const src_proc = state.flow.processors.find(proc => proc.id === conn.source.id); + const supported_rels = src_proc ? Object.keys(src_proc.autoterminatedRelationships) : ['success']; return err.component === conn.id)} container={conn_container} selected={state.selected.includes(srcProc.id) && state.selected.includes(dstProc.id)} - name={conn.name ? conn.name : Object.keys(conn.sourceRelationships).filter(key => conn.sourceRelationships[key]).sort().join(", ")}/> + name={conn.name ? conn.name : conn.sourceRelationships.sort().join(", ")}/> }) ] } @@ -1822,35 +1900,7 @@ function useFlowContext(areaRef: React.RefObject, state: const updated = fn(curr); const new_procs = st.flow.processors.filter(proc => proc.id !== updated.id); new_procs.push(updated); - let changed_any = false; - let connections = st.flow.connections; - const manifest = st.flow.manifest.processors.find(man => man.type === updated.type); - if (manifest?.supportsDynamicRelationships) { - connections = st.flow.connections.map(out => { - if (out.source.id !== updated.id) return out; - let changed = false; - const newSourceRelationships: { [name: string]: boolean } = {}; - for (const rel in out.sourceRelationships) { - if (rel in updated.autoterminatedRelationships) { - newSourceRelationships[rel] = out.sourceRelationships[rel]; - continue; - } - // relationship removed - changed = true; - } - for (const rel in updated.autoterminatedRelationships) { - if (rel in newSourceRelationships) continue; - // new relationship - newSourceRelationships[rel] = false; - changed = true; - } - if (!changed) return out; - changed_any = true; - return {...out, sourceRelationships: newSourceRelationships}; - }); - } - if (!changed_any) connections = st.flow.connections; - return {...st, flow: {...st.flow, processors: new_procs, connections}} + return {...st, flow: {...st.flow, processors: new_procs}} }) }, []); @@ -1953,22 +2003,22 @@ function useFlowContext(areaRef: React.RefObject, state: // if (flow !== state.flow) setState(curr => ({...curr, flow})); // }, [state.flow.connections, state.flow.processors]); - const closeNewProcessor = React.useCallback((id: string | null) => { + const closeNewProcessor = React.useCallback((type: string | null) => { setState(st => { if (!st.newComponent) return st; - if (id === null) { + if (type === null) { return {...st, newComponent: null, newConnection: null}; } - const procManifest = st.flow.manifest.processors.find(proc => proc.type === id); + const procManifest = st.flow.manifest.processors.find(proc => proc.type === type); if (!procManifest) { return {...st, newComponent: null, newConnection: null}; } - const name = getUnqualifiedName(id); + const name = getUnqualifiedName(type); const newProcessor: Processor = { position: {x: st.newComponent.x, y: st.newComponent.y}, id: uuid.v4() as Uuid, name: name, - type: id, + type: type, penalty: mapDefined(st.flow.manifest.schedulingDefaults.penalizationPeriodMillis, val => `${val} ms`, ""), yield: mapDefined(st.flow.manifest.schedulingDefaults.yieldDurationMillis, val => `${val} ms`, ""), autoterminatedRelationships: createDefaultRelationshipStatus(procManifest.supportedRelationships), @@ -2058,12 +2108,12 @@ function createDefaultRelationshipStatus(rels: { name: string }[]): { [name: str return result; } -function createDefaultProperties(props: { [name: string]: PropertyDescriptor }): { [name: string]: string | null } { +function createDefaultProperties(props: { [name: string]: PropertyDescriptor }): { [name: string]: PropertyValue } { console.log(props); - const result: { [name: string]: string | null } = {}; + const result: { [name: string]: PropertyValue } = {}; for (const name in props) { const prop = props[name]; - result[name] = prop.defaultValue ?? null; + result[name] = {value: prop.defaultValue ?? null, type: "default"}; } return result; } @@ -2073,63 +2123,63 @@ function mapDefined(value: T | undefined, fn: (val: T) => R, fallback: R): return fn(value); } -function PropagateAttributes(flow: FlowObject): FlowObject { - const errors = new Map(); - const attributes = new Map(); - const visited = new Set(); - for (const proc of flow.processors) { - VerifyProcessor(flow, proc, visited, attributes, errors); - } - let changed = false; - const connections = flow.connections.map(conn => { - const new_errors = errors.get(conn) ?? []; - if (new_errors.length !== conn.errors.length || new_errors.some((err, idx) => err !== conn.errors[idx])) { - changed = true; - } - const attr = attributes.get(conn) ?? []; - if (attr.length !== conn.attributes.length || attr.some((a, idx) => a !== conn.attributes[idx])) { - changed = true; - } - return {...conn, attributes: attr, errors: new_errors}; - }); - if (!changed) return flow; - return {...flow, connections}; -} - -function VerifyProcessor(flow: FlowObject, target: Processor, visited: Set, attributes: Map, errors: Map) { - if (visited.has(target)) return; - visited.add(target); - const incoming = flow.connections.filter(conn => conn.destination.id === target.id); - for (const conn of incoming) { - const src = flow.processors.find(proc => proc.id === conn.source.id)!; - VerifyProcessor(flow, src, visited, attributes, errors); - } - const manifest = flow.manifest.processors.find(proc => proc.type === target.type); - if (!manifest) return; - if (manifest.inputAttributeRequirements) { - for (const req of manifest.inputAttributeRequirements) { - if (Eval(target, req.condition!)) { - for (const attr of GetAttributeCandidates(target, manifest, null, req)) { - for (const conn of incoming) { - if (!(attributes.get(conn)?.includes(attr))) { - let err = errors.get(conn); - if (!err) errors.set(conn, err = []); - err.push(`Destination processor expects '${attr}' attribute`); - } - } - } - } - } - } - const outgoing = flow.connections.filter(conn => conn.source.id === target.id); - const input_attributes = Intersect(incoming.map(conn => attributes.get(conn) ?? [])) ?? []; - for (const conn of outgoing) { - const attrs = Intersect(Object.keys(conn.sourceRelationships).filter(rel_name => conn.sourceRelationships[rel_name]).map(rel_name => { - return DetermineOutputAttributes(target, manifest, input_attributes, rel_name); - })); - attributes.set(conn, attrs); - } -} +// function PropagateAttributes(flow: FlowObject): FlowObject { +// const errors = new Map(); +// const attributes = new Map(); +// const visited = new Set(); +// for (const proc of flow.processors) { +// VerifyProcessor(flow, proc, visited, attributes, errors); +// } +// let changed = false; +// const connections = flow.connections.map(conn => { +// const new_errors = errors.get(conn) ?? []; +// if (new_errors.length !== conn.errors.length || new_errors.some((err, idx) => err !== conn.errors[idx])) { +// changed = true; +// } +// const attr = attributes.get(conn) ?? []; +// if (attr.length !== conn.attributes.length || attr.some((a, idx) => a !== conn.attributes[idx])) { +// changed = true; +// } +// return {...conn, attributes: attr, errors: new_errors}; +// }); +// if (!changed) return flow; +// return {...flow, connections}; +// } + +// function VerifyProcessor(flow: FlowObject, target: Processor, visited: Set, attributes: Map, errors: Map) { +// if (visited.has(target)) return; +// visited.add(target); +// const incoming = flow.connections.filter(conn => conn.destination.id === target.id); +// for (const conn of incoming) { +// const src = flow.processors.find(proc => proc.id === conn.source.id)!; +// VerifyProcessor(flow, src, visited, attributes, errors); +// } +// const manifest = flow.manifest.processors.find(proc => proc.type === target.type); +// if (!manifest) return; +// if (manifest.inputAttributeRequirements) { +// for (const req of manifest.inputAttributeRequirements) { +// if (Eval(target, req.condition!)) { +// for (const attr of GetAttributeCandidates(target, manifest, null, req)) { +// for (const conn of incoming) { +// if (!(attributes.get(conn)?.includes(attr))) { +// let err = errors.get(conn); +// if (!err) errors.set(conn, err = []); +// err.push(`Destination processor expects '${attr}' attribute`); +// } +// } +// } +// } +// } +// } +// const outgoing = flow.connections.filter(conn => conn.source.id === target.id); +// const input_attributes = Intersect(incoming.map(conn => attributes.get(conn) ?? [])) ?? []; +// for (const conn of outgoing) { +// const attrs = Intersect(Object.keys(conn.sourceRelationships).filter(rel_name => conn.sourceRelationships[rel_name]).map(rel_name => { +// return DetermineOutputAttributes(target, manifest, input_attributes, rel_name); +// })); +// attributes.set(conn, attrs); +// } +// } function DetermineOutputAttributes(processor: Processor, manifest: ProcessorManifest, input_attrs: string[], rel_name: string): string[] { const rel = manifest.supportedRelationships.find(rel => rel.name === rel_name); @@ -2172,7 +2222,7 @@ function GetAttributeCandidates(processor: Processor, manifest: ProcessorManifes } } } else if (desc.source === "Property") { - const val = processor.properties[desc.value]; + const val = processor.properties[desc.value].value; if (val) output = [val]; } else { output = [...desc.source]; diff --git a/website/src/components/flow-readonly/index.tsx b/website/src/components/flow-readonly/index.tsx index a039cc5..4a2fda9 100644 --- a/website/src/components/flow-readonly/index.tsx +++ b/website/src/components/flow-readonly/index.tsx @@ -369,7 +369,7 @@ export function FlowReadonlyEditor(props: {id: string, flow: FlowObject, agentId for (let proc of state.flow.processors) { const proc_manifest = state.flow.manifest.processors.find(proc_manifest => proc_manifest.type === proc.type)!; for (let rel in proc.autoterminatedRelationships) { - const conn = state.flow.connections.find(conn => conn.source.id === proc.id && (rel in conn.sourceRelationships) && conn.sourceRelationships[rel]); + const conn = state.flow.connections.find(conn => conn.source.id === proc.id && conn.sourceRelationships.includes(rel)); if (conn && proc.autoterminatedRelationships[rel]) { errors.push({component: proc.id, type: "RELATIONSHIP", target: rel, message: `Relationship '${rel}' is both connected and auto-terminated`}); } @@ -473,7 +473,9 @@ export function FlowReadonlyEditor(props: {id: string, flow: FlowObject, agentId (()=>{ const conn = state.flow.connections.find(conn => conn.id === state.editingComponent); if (conn) { - return ; + const src_proc = state.flow.processors.find(proc => proc.id === conn.source.id); + const supported_rels = src_proc ? Object.keys(src_proc.autoterminatedRelationships) : ['success']; + return err.component === conn.id)} />; } const proc = state.flow.processors.find(proc => proc.id === state.editingComponent); if (proc) { diff --git a/website/src/components/processor-editor/index.scss b/website/src/components/processor-editor/index.scss index c9197f1..1cf315c 100644 --- a/website/src/components/processor-editor/index.scss +++ b/website/src/components/processor-editor/index.scss @@ -114,7 +114,7 @@ } } - .dynamic-property { + .dynamic-property, .non-existent-property { display: flex; align-items: center; margin-bottom: 10px; diff --git a/website/src/components/processor-editor/index.tsx b/website/src/components/processor-editor/index.tsx index e5e4c0b..c84592e 100644 --- a/website/src/components/processor-editor/index.tsx +++ b/website/src/components/processor-editor/index.tsx @@ -80,7 +80,7 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan notif.emit(`Property '${prop}' already exists`, "error"); return curr; } - return {...curr, properties: {...curr.properties, [prop]: ""}} + return {...curr, properties: {...curr.properties, [prop]: {value: "", type: "custom"}}} }); }, []); const onNewDynamicRelationship = React.useCallback((rel: string) => { @@ -234,11 +234,28 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan
Properties
{ Object.keys(model.properties).sort().map(prop_name => { + let err = props.errors.find(err => err.type === "PROPERTY" && err.target === prop_name); if (!props.manifest.propertyDescriptors || !(prop_name in props.manifest.propertyDescriptors)) { - // dynamic property - return null; + if (props.manifest.supportsDynamicProperties) { + // dynamic property + return null; + } + return
+ + + { + flow_context?.editable ? + { + setModel(model => { + let new_props = {...model.properties}; + delete new_props[prop_name]; + return {...model, properties: new_props, visibleProperties: model.visibleProperties?.filter(vis_prop => vis_prop in new_props)}; + }) + }}/> + : null + } +
} - let err = props.errors.find(err => err.type === "PROPERTY" && err.target === prop_name); if (isSpecialInputField(props.model.type, prop_name)) { return null; @@ -248,10 +265,10 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan if (values) { return val.value)} - initial={model.properties[prop_name]} + initial={model.properties[prop_name].value} onChange={flow_context?.editable ? val => setModel(curr => ({ ...curr, - properties: {...curr.properties, [prop_name]: val} + properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}} })) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility} error={err?.message}/> @@ -274,10 +291,10 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan if (values.length > 0) { return setModel(curr => ({ ...curr, - properties: {...curr.properties, [prop_name]: val} + properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}} })) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility} error={err?.message}/> @@ -285,8 +302,8 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan } } - return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility} error={err?.message}/> + return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility} error={err?.message}/> }) }
@@ -308,7 +325,7 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan return null; } return
- setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined}/> + setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined}/> { flow_context?.editable ? @@ -316,7 +333,7 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan setModel(model => { let new_props = {...model.properties}; delete new_props[prop_name]; - return {...model, properties: new_props}; + return {...model, properties: new_props, visibleProperties: model.visibleProperties?.filter(vis_prop => vis_prop in new_props)}; }) }}/> : null @@ -331,8 +348,8 @@ export function ProcessorEditor(props: {model: Processor, manifest: ProcessorMan if (!isSpecialInputField(props.model.type, prop_name)) { return null; } - return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility}/> + return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined} visible={model.visibleProperties?.includes(prop_name) ?? false} onChangeVisibility={onChangeVisibility}/> }) }
diff --git a/website/src/components/service-editor/index.tsx b/website/src/components/service-editor/index.tsx index 831ab10..bb02944 100644 --- a/website/src/components/service-editor/index.tsx +++ b/website/src/components/service-editor/index.tsx @@ -24,7 +24,7 @@ export function ServiceEditor(props: {model: MiNiFiService, manifest: Controller notif.emit(`Property '${prop}' already exists`, "error"); return curr; } - return {...curr, properties: {...curr.properties, [prop]: ""}} + return {...curr, properties: {...curr.properties, [prop]: {value: "", type: "custom"}}} }); }, []); const openModalCb = React.useCallback(()=>{ @@ -56,9 +56,9 @@ export function ServiceEditor(props: {model: MiNiFiService, manifest: Controller } const values = props.manifest.propertyDescriptors[prop_name].allowableValues; if (values) { - return val.value)} initial={model.properties[prop_name]} onChange={flow_context?.editable ? val=>setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined}/> + return val.value)} initial={model.properties[prop_name].value} onChange={flow_context?.editable ? val=>setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined}/> } - return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined}/> + return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined}/> }) }
@@ -79,7 +79,7 @@ export function ServiceEditor(props: {model: MiNiFiService, manifest: Controller // not dynamic property return null; } - return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: val}})) : undefined}/> + return setModel(curr => ({...curr, properties: {...curr.properties, [prop_name]: {value: val, type: "custom"}}})) : undefined}/> }) }
diff --git a/website/src/utils/attribute-expression.ts b/website/src/utils/attribute-expression.ts index ba7da86..e03fdfd 100644 --- a/website/src/utils/attribute-expression.ts +++ b/website/src/utils/attribute-expression.ts @@ -1,7 +1,7 @@ export function Eval(ctx: Processor, expr: AttributeExpression): string|null|boolean { switch (expr.kind) { case "Literal": return expr.value; - case "Property": return ctx.properties[expr.value] ?? null; + case "Property": return ctx.properties[expr.value]?.value ?? null; case "Equals": return Eval(ctx, expr.arguments[0]) === Eval(ctx, expr.arguments[1]); case "Or": return AsBool(Eval(ctx, expr.arguments[0])) || AsBool(Eval(ctx, expr.arguments[1])); case "And": return AsBool(Eval(ctx, expr.arguments[0])) && AsBool(Eval(ctx, expr.arguments[1]));