From 91906a4d586537e1edb61966438a2f101d980142 Mon Sep 17 00:00:00 2001 From: Guillaume Everarts de Velp Date: Thu, 21 May 2026 17:35:17 +0200 Subject: [PATCH 1/6] POC --- changelogs/unreleased/add-references-tab.yml | 3 + docker-compose.yml | 25 +++ entrypoint.sh | 38 ++++ resource.json | 181 ++++++++++++++++++ src/Slices/ResourceDetails/Core/Reference.ts | 59 ++++++ .../ReferencesTab/ExpandedReferenceView.tsx | 112 +++++++++++ .../UI/Tabs/ReferencesTab/MjsonValueView.tsx | 158 +++++++++++++++ .../UI/Tabs/ReferencesTab/ReferencesTab.tsx | 30 +++ .../UI/Tabs/ReferencesTab/ReferencesTable.tsx | 71 +++++++ .../Tabs/ReferencesTab/ReferencesTableRow.tsx | 53 +++++ .../UI/Tabs/ReferencesTab/index.ts | 1 + src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx | 19 +- src/UI/words.tsx | 4 + 13 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/add-references-tab.yml create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 resource.json create mode 100644 src/Slices/ResourceDetails/Core/Reference.ts create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTab.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/index.ts diff --git a/changelogs/unreleased/add-references-tab.yml b/changelogs/unreleased/add-references-tab.yml new file mode 100644 index 0000000000..309b37a9ab --- /dev/null +++ b/changelogs/unreleased/add-references-tab.yml @@ -0,0 +1,3 @@ +description: Add a "References" tab to the resource details page, showing each reference with an expandable view of its arguments. +change-type: minor +destination-branches: [master, iso9] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..1fe6d907dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + vite: + image: "node:22" + pull_policy: always + entrypoint: ["/entrypoint.sh"] + volumes: + - ./entrypoint.sh:/entrypoint.sh + - ./:/home/node/web-console + environment: + VITE_API_BASEURL: http://172.25.162.3:8888/ + JOINTJS_NPM_TOKEN: ${JOINTJS_NPM_TOKEN} + user: "root:root" + networks: + mgmt: + ipv4_address: 172.20.0.3 + +volumes: + web-console-data: {} + +networks: + mgmt: + ipam: + config: + - subnet: 172.20.0.0/28 + gateway: 172.20.0.1 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000000..ad12caccdb --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Alternative entrypoint for the node container. +# It starts a code-server to allow easy access to the web console state directory. + +set -x +set -e +set -o allexport + +## SETUP ## +# Make sure that any environment variable prefixed with NODE which is available +# to the container, will also be available to the node user when login in +NODE_USER_HOME_DIR=$(getent passwd node | cut -d: -f6) +NODE_ENV_FILE="${NODE_USER_HOME_DIR}/.node_env" +NODE_PROFILE="${NODE_USER_HOME_DIR}/.profile" +LOAD_ENV_CMD=". $NODE_ENV_FILE" + +# Overwrite environment variables in dedicated file +if export | grep VITE; then + export | grep VITE >> $NODE_ENV_FILE +fi +if export | grep NPM; then + export | grep NPM >> $NODE_ENV_FILE +fi + +# Make sure to load environment variables when login in +touch $NODE_PROFILE +grep -e "$LOAD_ENV_CMD" "$NODE_PROFILE" || echo "$LOAD_ENV_CMD" >> "$NODE_PROFILE" + +# Install sudo +apt-get update -y +apt-get install -y sudo vim tini + +# Install the web-console dependencies +exec /usr/bin/tini -- sudo -i -u node bash <` because not every consumer expects this shape. + */ + +export type ReferenceArg = + | LiteralArg + | ReferenceRefArg + | ResourceRefArg + | MjsonArg; + +export interface LiteralArg { + name: string; + type: "literal"; + value: unknown; +} + +export interface ReferenceRefArg { + name: string; + type: "reference"; + id: string; +} + +export interface ResourceRefArg { + name: string; + type: "resource"; + id: string; +} + +export interface MjsonArg { + name: string; + type: "mjson"; + value: unknown; + references?: Record; +} + +export interface Reference { + id: string; + type: string; + args: ReferenceArg[]; +} + +export const isReferenceArg = (arg: ReferenceArg): arg is ReferenceRefArg => + arg.type === "reference"; + +export const isResourceArg = (arg: ReferenceArg): arg is ResourceRefArg => + arg.type === "resource"; + +/** + * Pulls the `references` array off the raw attributes blob. Returns an empty + * array when the field is missing or not an array. + */ +export const extractReferences = (attributes: Record): Reference[] => { + const value = attributes.references; + + return Array.isArray(value) ? (value as Reference[]) : []; +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx new file mode 100644 index 0000000000..ac2c8f62f0 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { + Card, + CardBody, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Label, + Stack, + StackItem, +} from "@patternfly/react-core"; +import { JsonFormatter, XmlFormatter } from "@/Data"; +import { AttributeClassifier, AttributeList } from "@/UI/Components"; +import { + MjsonArg, + Reference, + ReferenceArg, + isReferenceArg, + isResourceArg, +} from "@S/ResourceDetails/Core/Reference"; +import { MjsonValueView } from "./MjsonValueView"; + +interface Props { + reference: Reference; + onNavigateToReference: (id: string) => void; +} + +const isMjsonArg = (arg: ReferenceArg): arg is MjsonArg => arg.type === "mjson"; + +/** + * Renders the arguments of a single reference in a Desired-State-lookalike + * layout. Literal args go through `AttributeClassifier` so JSON, XML and + * multi-line formatters are reused. Args of type `reference` / `resource` + * render as clickable labels. Args of type `mjson` render via + * `MjsonValueView`, which inlines clickable labels at the JSON paths listed + * in `arg.references`. + */ +export const ExpandedReferenceView: React.FC = ({ + reference, + onNavigateToReference, +}) => { + const linkedArgs = reference.args.filter( + (arg): arg is Extract => + isReferenceArg(arg) || isResourceArg(arg) + ); + const mjsonArgs = reference.args.filter(isMjsonArg); + const literalArgs = reference.args.filter( + (arg) => !isReferenceArg(arg) && !isResourceArg(arg) && !isMjsonArg(arg) + ); + + const classifier = new AttributeClassifier( + new JsonFormatter(), + new XmlFormatter(), + (key: string, value: string) => ({ kind: "Code", key, value }) + ); + const literalRecord = Object.fromEntries( + literalArgs.map((arg) => [arg.name, "value" in arg ? arg.value : undefined]) + ); + const classifiedAttributes = classifier.classify(literalRecord); + + return ( + + + + {linkedArgs.length > 0 && ( + + + {linkedArgs.map((arg) => ( + + {arg.name} + + {isReferenceArg(arg) ? ( + + ) : ( + + )} + + + ))} + + + )} + {mjsonArgs.length > 0 && ( + + + {mjsonArgs.map((arg) => ( + + {arg.name} + + + + + ))} + + + )} + {classifiedAttributes.length > 0 && ( + + + + )} + + + + ); +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx new file mode 100644 index 0000000000..c6f4d2f10a --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { Label } from "@patternfly/react-core"; +import styled from "styled-components"; +import { MjsonArg } from "@S/ResourceDetails/Core/Reference"; + +interface Props { + arg: MjsonArg; + onNavigateToReference: (id: string) => void; +} + +const SENTINEL_PREFIX = "@@INMANTA_REF_START@@"; +const SENTINEL_SUFFIX = "@@INMANTA_REF_END@@"; +const SENTINEL_PATTERN = new RegExp( + `"${SENTINEL_PREFIX}([0-9a-fA-F-]+)${SENTINEL_SUFFIX}"`, + "g" +); + +const CodeBlock = styled.pre` + margin: 0; + padding: 12px; + border-radius: 4px; + background: var(--pf-t--global--background--color--secondary--default, #f5f5f5); + font-family: var(--pf-t--global--font--family--mono, monospace); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +`; + +const InlineLabel = styled(Label)` + vertical-align: middle; + margin: 0 2px; +`; + +type PathToken = string | number; + +const parseJsonPath = (path: string): PathToken[] => { + const tokens: PathToken[] = []; + let i = path.startsWith("$") ? 1 : 0; + + while (i < path.length) { + const ch = path[i]; + + if (ch === "[") { + const end = path.indexOf("]", i); + + if (end === -1) { + break; + } + const inner = path.slice(i + 1, end).trim(); + + if (/^-?\d+$/.test(inner)) { + tokens.push(Number(inner)); + } else { + tokens.push(inner.replace(/^["']|["']$/g, "")); + } + i = end + 1; + } else if (ch === ".") { + i += 1; + const start = i; + + while (i < path.length && path[i] !== "." && path[i] !== "[") { + i += 1; + } + tokens.push(path.slice(start, i)); + } else { + i += 1; + } + } + + return tokens; +}; + +const setAtPath = (root: unknown, path: string, value: unknown): unknown => { + const tokens = parseJsonPath(path); + + if (tokens.length === 0) { + return value; + } + + let cur: Record = root as Record; + + for (let i = 0; i < tokens.length - 1; i += 1) { + const next = cur[tokens[i]]; + + if (next === null || typeof next !== "object") { + return root; + } + cur = next as Record; + } + cur[tokens[tokens.length - 1]] = value; + + return root; +}; + +const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)); + +const buildSubstituted = (arg: MjsonArg): string => { + const references = arg.references ?? {}; + let working: unknown = deepClone(arg.value); + + for (const [path, ref] of Object.entries(references)) { + const sentinel = `${SENTINEL_PREFIX}${ref.id}${SENTINEL_SUFFIX}`; + const tokens = parseJsonPath(path); + + if (tokens.length === 0) { + working = sentinel; + continue; + } + working = setAtPath(working, path, sentinel); + } + + return JSON.stringify(working, null, 2); +}; + +/** + * Renders the value of an `mjson` arg. Reference placeholders carried in + * `arg.references` are substituted at their JSON path and rendered as + * clickable labels inline in the JSON text. + */ +export const MjsonValueView: React.FC = ({ arg, onNavigateToReference }) => { + if (!arg.references || Object.keys(arg.references).length === 0) { + return {JSON.stringify(arg.value, null, 2)}; + } + + const json = buildSubstituted(arg); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + SENTINEL_PATTERN.lastIndex = 0; + while ((match = SENTINEL_PATTERN.exec(json)) !== null) { + if (match.index > lastIndex) { + parts.push(json.slice(lastIndex, match.index)); + } + const refId = match[1]; + + parts.push( + onNavigateToReference(refId)} + > + → {refId} + + ); + key += 1; + lastIndex = match.index + match[0].length; + } + + if (lastIndex < json.length) { + parts.push(json.slice(lastIndex)); + } + + return {parts}; +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTab.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTab.tsx new file mode 100644 index 0000000000..a8d542f240 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTab.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Details } from "@/Core/Domain/Resource/Resource"; +import { EmptyView } from "@/UI/Components"; +import { words } from "@/UI/words"; +import { extractReferences } from "@S/ResourceDetails/Core/Reference"; +import { ReferencesTable } from "./ReferencesTable"; + +interface Props { + details: Details; +} + +/** + * Lists the `references` carried on the resource's desired state. Each row is + * expandable; the expanded view shows the reference's arguments using the same + * key/value layout as the Desired State tab. + */ +export const ReferencesTab: React.FC = ({ details }) => { + const references = extractReferences(details.attributes); + + if (references.length === 0) { + return ( + + ); + } + + return ; +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx new file mode 100644 index 0000000000..599a5a098f --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useRef } from "react"; +import { Table, TableVariant, Th, Thead, Tr } from "@patternfly/react-table"; +import { useUrlStateWithExpansion } from "@/Data"; +import { scrollRowIntoView } from "@/UI/Utils"; +import { words } from "@/UI/words"; +import { Reference } from "@S/ResourceDetails/Core/Reference"; +import { ReferencesTableRow } from "./ReferencesTableRow"; + +interface Props { + references: Reference[]; +} + +export const ReferencesTable: React.FC = ({ references }) => { + const [isExpanded, onExpansion] = useUrlStateWithExpansion({ + route: "ResourceDetails", + key: "references-expansion", + }); + const rowRefs = useRef(new Map>()); + + const getRowRef = useCallback((id: string) => { + let ref = rowRefs.current.get(id); + + if (!ref) { + ref = React.createRef(); + rowRefs.current.set(id, ref); + } + + return ref; + }, []); + + const onNavigateToReference = useCallback( + (id: string) => { + const target = references.find((reference) => reference.id === id); + + if (!target) { + return; + } + + if (!isExpanded(id)) { + onExpansion(id)(); + } + + scrollRowIntoView(getRowRef(id)); + }, + [references, isExpanded, onExpansion, getRowRef] + ); + + return ( + + + + + + + + {references.map((reference, index) => ( + + ))} +
+ {words("resources.references.column.type")}{words("resources.references.column.id")}
+ ); +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx new file mode 100644 index 0000000000..a800f2de35 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Tbody, Td, Tr } from "@patternfly/react-table"; +import { words } from "@/UI/words"; +import { Reference } from "@S/ResourceDetails/Core/Reference"; +import { ExpandedReferenceView } from "./ExpandedReferenceView"; + +interface Props { + reference: Reference; + isExpanded: boolean; + onToggle: () => void; + onNavigateToReference: (id: string) => void; + rowRef: React.RefObject; + numberOfColumns: number; + index: number; +} + +export const ReferencesTableRow: React.FC = ({ + reference, + isExpanded, + onToggle, + onNavigateToReference, + rowRef, + numberOfColumns, + index, +}) => { + return ( + + + + + {reference.type} + + {reference.id} + + {isExpanded && ( + + + + + + )} + + ); +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/index.ts b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/index.ts new file mode 100644 index 0000000000..200afae740 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/index.ts @@ -0,0 +1 @@ +export * from "./ReferencesTab"; diff --git a/src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx b/src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx index a108a43929..c276a6b8b0 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx @@ -1,5 +1,12 @@ import React from "react"; -import { ColumnsIcon, HistoryIcon, ListIcon, ModuleIcon, TableIcon } from "@patternfly/react-icons"; +import { + ColumnsIcon, + HistoryIcon, + LinkIcon, + ListIcon, + ModuleIcon, + TableIcon, +} from "@patternfly/react-icons"; import { Details } from "@/Core/Domain/Resource/Resource"; import { IconTabs, TabDescriptor } from "@/UI/Components"; import { words } from "@/UI/words"; @@ -7,11 +14,13 @@ import { AttributesTab } from "./AttributesTab"; import { FactsTab } from "./FactsTab"; import { ResourceHistoryView } from "./HistoryTab/ResourceHistoryView"; import { ResourceLogView } from "./LogTab"; +import { ReferencesTab } from "./ReferencesTab"; import { RequiresTab } from "./RequiresTab"; export enum TabKey { Requires = "Requires", Attributes = "Attributes", + References = "References", History = "History", Logs = "Logs", Facts = "Facts", @@ -44,6 +53,7 @@ export const Tabs: React.FC = ({ id, activeTab, setActiveTab, data }) => onChange={setActiveTab} tabs={[ attributesTab(data), + referencesTab(data), requiresTab(data), historyTab(id, data), logTab(id), @@ -67,6 +77,13 @@ const attributesTab = (data: Details): TabDescriptor => ({ view: , }); +const referencesTab = (data: Details): TabDescriptor => ({ + id: TabKey.References, + title: words("resources.references.title"), + icon: , + view: , +}); + const historyTab = (id: string, data: Details): TabDescriptor => ({ id: TabKey.History, title: words("resources.history.title"), diff --git a/src/UI/words.tsx b/src/UI/words.tsx index 86067ecd62..a120338830 100644 --- a/src/UI/words.tsx +++ b/src/UI/words.tsx @@ -606,6 +606,10 @@ const dict = { "resources.history.tabs.requires": "Requires", "resources.history.empty.message": "No requirements found", "resources.attributes.title": "Desired State", + "resources.references.title": "References", + "resources.references.empty": "No references defined on this resource.", + "resources.references.column.type": "Type", + "resources.references.column.id": "Id", "resources.logs.title": "Logs", "resources.logs.empty.message": "No logs found", "resources.logs.filterOnAction": (actionType: string) => `Filter on '${actionType}'`, From ece1a44eb3d08bba610ea8e50ce176962d4e0dc6 Mon Sep 17 00:00:00 2001 From: Guillaume Everarts de Velp Date: Thu, 21 May 2026 17:41:11 +0200 Subject: [PATCH 2/6] wip --- REFERENCES_TAB.md | Bin 0 -> 10611 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 REFERENCES_TAB.md diff --git a/REFERENCES_TAB.md b/REFERENCES_TAB.md new file mode 100644 index 0000000000000000000000000000000000000000..0c0db514a7cc2c3787dbefa426bf1483bb4c9f88 GIT binary patch literal 10611 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 literal 0 HcmV?d00001 From e8b593c412abded6ec875fb48a4dbb7d509b0230 Mon Sep 17 00:00:00 2001 From: Guillaume Everarts de Velp Date: Thu, 21 May 2026 23:05:34 +0200 Subject: [PATCH 3/6] mutators rendering --- src/Slices/ResourceDetails/Core/Mutator.ts | 72 ++++++ .../UI/Tabs/AttributesTab/AttributesTab.tsx | 118 ++++++++-- .../AttributesTab/MutatedAttributeRow.tsx | 110 +++++++++ .../ReferencesTab/ExpandedReferenceView.tsx | 7 +- .../UI/Tabs/ReferencesTab/MjsonValueView.tsx | 158 +++---------- .../UI/Tabs/ReferencesTab/ReferencesTable.tsx | 16 +- .../Tabs/ReferencesTab/ReferencesTableRow.tsx | 3 + .../UI/Tabs/ReferencesTab/sentinel.tsx | 216 ++++++++++++++++++ src/Slices/ResourceDetails/UI/Tabs/Tabs.tsx | 63 +++-- 9 files changed, 602 insertions(+), 161 deletions(-) create mode 100644 src/Slices/ResourceDetails/Core/Mutator.ts create mode 100644 src/Slices/ResourceDetails/UI/Tabs/AttributesTab/MutatedAttributeRow.tsx create mode 100644 src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/sentinel.tsx diff --git a/src/Slices/ResourceDetails/Core/Mutator.ts b/src/Slices/ResourceDetails/Core/Mutator.ts new file mode 100644 index 0000000000..f7b420b3d9 --- /dev/null +++ b/src/Slices/ResourceDetails/Core/Mutator.ts @@ -0,0 +1,72 @@ +import { ReferenceArg } from "./Reference"; + +/** + * Mutator carried on a resource's desired state (`attributes.mutators`). A + * mutator describes a deferred mutation that the orchestrator will apply at + * deploy time. Today the orchestrator only ships `core::Replace`, which + * substitutes the value at a given `destination` (a JSONPath / dictpath + * expression on the resource) with the resolution of a `value` reference. + * + * Source of truth for the contract: + * `inmanta_core.references` — `Mutator`, `MutatorModel`, `ReplaceValue`. + */ +export interface Mutator { + type: string; + args: ReferenceArg[]; +} + +export const extractMutators = (attributes: Record): Mutator[] => { + const value = attributes.mutators; + + return Array.isArray(value) ? (value as Mutator[]) : []; +}; + +interface ReplaceMutation { + destination: string; + referenceId: string; +} + +/** + * Best-effort interpretation of a single mutator. Returns null for mutators + * we do not (yet) know how to surface in the UI. + */ +const interpretReplace = (mutator: Mutator): ReplaceMutation | null => { + if (mutator.type !== "core::Replace") { + return null; + } + let destination: string | null = null; + let referenceId: string | null = null; + + for (const arg of mutator.args) { + if (arg.name === "destination" && arg.type === "literal" && typeof arg.value === "string") { + destination = arg.value; + } else if (arg.name === "value" && arg.type === "reference") { + referenceId = arg.id; + } + } + + if (destination === null || referenceId === null) { + return null; + } + + return { destination, referenceId }; +}; + +/** + * Returns a `path → referenceId` map ready to feed `substituteReferences`. + * The map's keys are JSONPath expressions rooted at the resource's + * `attributes` object. + */ +export const buildMutatorSubstitutions = (mutators: Mutator[]): Record => { + const out: Record = {}; + + for (const mutator of mutators) { + const replace = interpretReplace(mutator); + + if (replace) { + out[replace.destination] = replace.referenceId; + } + } + + return out; +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx index fcac938eac..4ed6abdae7 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx @@ -1,35 +1,127 @@ -import React from "react"; -import { Card, CardBody } from "@patternfly/react-core"; +import React, { useMemo } from "react"; +import { + Card, + CardBody, + DescriptionList, + Stack, + StackItem, +} from "@patternfly/react-core"; import { Details } from "@/Core/Domain/Resource/Resource"; import { JsonFormatter, XmlFormatter } from "@/Data"; import { AttributeClassifier, AttributeList } from "@/UI/Components"; +import { buildMutatorSubstitutions, extractMutators } from "@S/ResourceDetails/Core/Mutator"; +import { extractReferences } from "@S/ResourceDetails/Core/Reference"; +import { + deepCloneJson, + setAtPath, + parseJsonPath, + sentinelFor, +} from "../ReferencesTab/sentinel"; +import { MutatedAttributeRow } from "./MutatedAttributeRow"; interface Props { details: Details; + onNavigateToReference: (id: string) => void; } +/** Keys we never want to surface in the Desired State tab. */ +const HIDDEN_KEYS = new Set(["mutators", "references"]); + /** - * The AttributesTab component. - * - * This component is responsible of displaying the attributes of a resource. - * - * @Props {Props} - The props of the component - * @prop {Details} details - The details of the resource - * @returns {React.FC} A React Component displaying the attributes of a resource + * Applies the mutator substitutions on top of `attributes`. Returns the + * substituted attributes together with the set of top-level keys touched by + * at least one mutator — those keys are rendered by `MutatedAttributeRow` + * instead of going through the default `AttributeList`. */ -export const AttributesTab: React.FC = ({ details }) => { +const applyMutatorSubstitutions = ( + attributes: Record, + substitutions: Record +): { substituted: Record; mutatedKeys: Set } => { + const clone = deepCloneJson(attributes); + const mutatedKeys = new Set(); + + for (const [path, refId] of Object.entries(substitutions)) { + const tokens = parseJsonPath(path); + + if (tokens.length === 0) { + continue; + } + const topKey = tokens[0]; + + if (typeof topKey !== "string") { + continue; + } + mutatedKeys.add(topKey); + + if (tokens.length === 1) { + clone[topKey] = sentinelFor(refId); + } else { + setAtPath(clone, path, sentinelFor(refId)); + } + } + + return { substituted: clone, mutatedKeys }; +}; + +export const AttributesTab: React.FC = ({ details, onNavigateToReference }) => { + const { substituted, mutatedKeys, getReferenceType } = useMemo(() => { + const mutators = extractMutators(details.attributes); + const substitutions = buildMutatorSubstitutions(mutators); + const applied = applyMutatorSubstitutions(details.attributes, substitutions); + + const references = extractReferences(details.attributes); + const typeById = new Map(); + + for (const reference of references) { + typeById.set(reference.id, reference.type); + } + + return { + substituted: applied.substituted, + mutatedKeys: applied.mutatedKeys, + getReferenceType: (id: string) => typeById.get(id), + }; + }, [details.attributes]); + const classifier = new AttributeClassifier( new JsonFormatter(), new XmlFormatter(), - (key: string, value: string) => ({ kind: "Code", key, value }) + (key: string, value: string) => ({ kind: "Code", key, value }), + (key: string) => + key === "version" || key === "requires" || HIDDEN_KEYS.has(key) || mutatedKeys.has(key) ); + const classifiedAttributes = classifier.classify(substituted); - const classifiedAttributes = classifier.classify(details.attributes); + const mutatedEntries = Array.from(mutatedKeys) + .filter((key) => !HIDDEN_KEYS.has(key)) + .sort() + .map((key) => ({ key, value: substituted[key] })); return ( - + + {mutatedEntries.length > 0 && ( + + + {mutatedEntries.map(({ key, value }) => ( + + ))} + + + )} + {classifiedAttributes.length > 0 && ( + + + + )} + ); diff --git a/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/MutatedAttributeRow.tsx b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/MutatedAttributeRow.tsx new file mode 100644 index 0000000000..bcff2b6963 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/MutatedAttributeRow.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from "@patternfly/react-core"; +import styled from "styled-components"; +import { + QUOTED_SENTINEL_PATTERN, + RAW_SENTINEL_PATTERN, + ReferenceChip, + splitWithChips, +} from "../ReferencesTab/sentinel"; + +interface Props { + attributeKey: string; + value: unknown; + onNavigateToReference: (id: string) => void; + getReferenceType: (id: string) => string | undefined; +} + +const CodeBlock = styled.pre` + margin: 0; + padding: 12px; + border-radius: 4px; + background: var(--pf-t--global--background--color--secondary--default, #f5f5f5); + font-family: var(--pf-t--global--font--family--mono, monospace); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +`; + +const isPureSentinel = (text: string): boolean => { + RAW_SENTINEL_PATTERN.lastIndex = 0; + const match = RAW_SENTINEL_PATTERN.exec(text); + + return match !== null && match.index === 0 && match[0].length === text.length; +}; + +const extractSentinelId = (text: string): string | null => { + RAW_SENTINEL_PATTERN.lastIndex = 0; + const match = RAW_SENTINEL_PATTERN.exec(text); + + return match ? match[1] : null; +}; + +/** + * One DescriptionList row for an attribute whose value has been touched by a + * mutator. Three render branches: + * + * - Value is exactly a sentinel string → single chip. + * - Value is a string with mixed text + sentinel(s) → inline chips. + * - Value is an object/array → JSON code block with sentinels split into + * chips (same trick used by `MjsonValueView`). + */ +export const MutatedAttributeRow: React.FC = ({ + attributeKey, + value, + onNavigateToReference, + getReferenceType, +}) => { + const body = ((): React.ReactNode => { + if (typeof value === "string") { + if (isPureSentinel(value)) { + const refId = extractSentinelId(value); + + return refId === null ? ( + value + ) : ( + + ); + } + + return splitWithChips({ + text: value, + pattern: RAW_SENTINEL_PATTERN, + onNavigateToReference, + getReferenceType, + }); + } + + const json = JSON.stringify(value, null, 2); + + return ( + + {splitWithChips({ + text: json, + pattern: QUOTED_SENTINEL_PATTERN, + onNavigateToReference, + getReferenceType, + })} + + ); + })(); + + return ( + + {attributeKey} + + {body} + + + ); +}; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx index ac2c8f62f0..9016bb5339 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx @@ -24,6 +24,7 @@ import { MjsonValueView } from "./MjsonValueView"; interface Props { reference: Reference; onNavigateToReference: (id: string) => void; + getReferenceType: (id: string) => string | undefined; } const isMjsonArg = (arg: ReferenceArg): arg is MjsonArg => arg.type === "mjson"; @@ -39,6 +40,7 @@ const isMjsonArg = (arg: ReferenceArg): arg is MjsonArg => arg.type === "mjson"; export const ExpandedReferenceView: React.FC = ({ reference, onNavigateToReference, + getReferenceType, }) => { const linkedArgs = reference.args.filter( (arg): arg is Extract => @@ -72,10 +74,10 @@ export const ExpandedReferenceView: React.FC = ({ {isReferenceArg(arg) ? ( ) : ( - + )} @@ -93,6 +95,7 @@ export const ExpandedReferenceView: React.FC = ({ diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx index c6f4d2f10a..747019cbbd 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx @@ -1,20 +1,18 @@ import React from "react"; -import { Label } from "@patternfly/react-core"; import styled from "styled-components"; import { MjsonArg } from "@S/ResourceDetails/Core/Reference"; +import { + QUOTED_SENTINEL_PATTERN, + splitWithChips, + substituteReferences, +} from "./sentinel"; interface Props { arg: MjsonArg; onNavigateToReference: (id: string) => void; + getReferenceType: (id: string) => string | undefined; } -const SENTINEL_PREFIX = "@@INMANTA_REF_START@@"; -const SENTINEL_SUFFIX = "@@INMANTA_REF_END@@"; -const SENTINEL_PATTERN = new RegExp( - `"${SENTINEL_PREFIX}([0-9a-fA-F-]+)${SENTINEL_SUFFIX}"`, - "g" -); - const CodeBlock = styled.pre` margin: 0; padding: 12px; @@ -27,132 +25,36 @@ const CodeBlock = styled.pre` word-break: break-word; `; -const InlineLabel = styled(Label)` - vertical-align: middle; - margin: 0 2px; -`; - -type PathToken = string | number; - -const parseJsonPath = (path: string): PathToken[] => { - const tokens: PathToken[] = []; - let i = path.startsWith("$") ? 1 : 0; - - while (i < path.length) { - const ch = path[i]; - - if (ch === "[") { - const end = path.indexOf("]", i); - - if (end === -1) { - break; - } - const inner = path.slice(i + 1, end).trim(); - - if (/^-?\d+$/.test(inner)) { - tokens.push(Number(inner)); - } else { - tokens.push(inner.replace(/^["']|["']$/g, "")); - } - i = end + 1; - } else if (ch === ".") { - i += 1; - const start = i; - - while (i < path.length && path[i] !== "." && path[i] !== "[") { - i += 1; - } - tokens.push(path.slice(start, i)); - } else { - i += 1; - } - } - - return tokens; -}; - -const setAtPath = (root: unknown, path: string, value: unknown): unknown => { - const tokens = parseJsonPath(path); - - if (tokens.length === 0) { - return value; - } - - let cur: Record = root as Record; - - for (let i = 0; i < tokens.length - 1; i += 1) { - const next = cur[tokens[i]]; - - if (next === null || typeof next !== "object") { - return root; - } - cur = next as Record; - } - cur[tokens[tokens.length - 1]] = value; - - return root; -}; - -const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)); - -const buildSubstituted = (arg: MjsonArg): string => { - const references = arg.references ?? {}; - let working: unknown = deepClone(arg.value); - - for (const [path, ref] of Object.entries(references)) { - const sentinel = `${SENTINEL_PREFIX}${ref.id}${SENTINEL_SUFFIX}`; - const tokens = parseJsonPath(path); - - if (tokens.length === 0) { - working = sentinel; - continue; - } - working = setAtPath(working, path, sentinel); - } - - return JSON.stringify(working, null, 2); -}; - /** * Renders the value of an `mjson` arg. Reference placeholders carried in * `arg.references` are substituted at their JSON path and rendered as * clickable labels inline in the JSON text. */ -export const MjsonValueView: React.FC = ({ arg, onNavigateToReference }) => { - if (!arg.references || Object.keys(arg.references).length === 0) { - return {JSON.stringify(arg.value, null, 2)}; - } - - const json = buildSubstituted(arg); - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - let key = 0; - - SENTINEL_PATTERN.lastIndex = 0; - while ((match = SENTINEL_PATTERN.exec(json)) !== null) { - if (match.index > lastIndex) { - parts.push(json.slice(lastIndex, match.index)); - } - const refId = match[1]; - - parts.push( - onNavigateToReference(refId)} - > - → {refId} - - ); - key += 1; - lastIndex = match.index + match[0].length; - } +export const MjsonValueView: React.FC = ({ + arg, + onNavigateToReference, + getReferenceType, +}) => { + const references = arg.references ?? {}; - if (lastIndex < json.length) { - parts.push(json.slice(lastIndex)); + if (Object.keys(references).length === 0) { + return {JSON.stringify(arg.value, null, 2)}; } - return {parts}; + const pathToId = Object.fromEntries( + Object.entries(references).map(([path, ref]) => [path, ref.id]) + ); + const substituted = substituteReferences(arg.value, pathToId); + const json = JSON.stringify(substituted, null, 2); + + return ( + + {splitWithChips({ + text: json, + pattern: QUOTED_SENTINEL_PATTERN, + onNavigateToReference, + getReferenceType, + })} + + ); }; diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx index 599a5a098f..1b27701bc4 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { Table, TableVariant, Th, Thead, Tr } from "@patternfly/react-table"; import { useUrlStateWithExpansion } from "@/Data"; import { scrollRowIntoView } from "@/UI/Utils"; @@ -16,6 +16,19 @@ export const ReferencesTable: React.FC = ({ references }) => { key: "references-expansion", }); const rowRefs = useRef(new Map>()); + const typeById = useMemo(() => { + const map = new Map(); + + for (const reference of references) { + map.set(reference.id, reference.type); + } + + return map; + }, [references]); + const getReferenceType = useCallback( + (id: string): string | undefined => typeById.get(id), + [typeById] + ); const getRowRef = useCallback((id: string) => { let ref = rowRefs.current.get(id); @@ -62,6 +75,7 @@ export const ReferencesTable: React.FC = ({ references }) => { isExpanded={isExpanded(reference.id)} onToggle={onExpansion(reference.id)} onNavigateToReference={onNavigateToReference} + getReferenceType={getReferenceType} rowRef={getRowRef(reference.id)} numberOfColumns={3} /> diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx index a800f2de35..fc2446cdb1 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx @@ -9,6 +9,7 @@ interface Props { isExpanded: boolean; onToggle: () => void; onNavigateToReference: (id: string) => void; + getReferenceType: (id: string) => string | undefined; rowRef: React.RefObject; numberOfColumns: number; index: number; @@ -19,6 +20,7 @@ export const ReferencesTableRow: React.FC = ({ isExpanded, onToggle, onNavigateToReference, + getReferenceType, rowRef, numberOfColumns, index, @@ -44,6 +46,7 @@ export const ReferencesTableRow: React.FC = ({ diff --git a/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/sentinel.tsx b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/sentinel.tsx new file mode 100644 index 0000000000..3ad3e75a6c --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/sentinel.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { Label } from "@patternfly/react-core"; +import styled from "styled-components"; + +/** + * Shared utilities for embedding clickable reference chips inside textual + * representations of values. The same trick is used both for `mjson` reference + * arguments and for desired-state attributes that are produced by mutators. + * + * The idea: walk the value, replace each "hole" (identified by a JSONPath) + * with a printable sentinel string carrying the reference id, then serialize + * the result and split the resulting text on the sentinel pattern. Each + * sentinel becomes a `