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 <): 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/Core/Reference.ts b/src/Slices/ResourceDetails/Core/Reference.ts new file mode 100644 index 0000000000..4eaf2c1e0a --- /dev/null +++ b/src/Slices/ResourceDetails/Core/Reference.ts @@ -0,0 +1,59 @@ +/** + * Domain types for the `references` array carried on a resource's desired state + * (`details.attributes.references`). Scoped to the ResourceDetails slice — the + * canonical `attributes` field on `Resource.Details` stays typed as + * `Record` 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/AttributesTab/AttributesTab.tsx b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx index fcac938eac..9d4aed2ab6 100644 --- a/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx +++ b/src/Slices/ResourceDetails/UI/Tabs/AttributesTab/AttributesTab.tsx @@ -1,36 +1,190 @@ -import React from "react"; -import { Card, CardBody } from "@patternfly/react-core"; +import React, { useMemo, useState } from "react"; +import { CodeEditor, Language } from "@patternfly/react-code-editor"; +import { + Card, + CardBody, + DescriptionList, + Flex, + FlexItem, + Stack, + StackItem, + ToggleGroup, + ToggleGroupItem, +} from "@patternfly/react-core"; import { Details } from "@/Core/Domain/Resource/Resource"; import { JsonFormatter, XmlFormatter } from "@/Data"; import { AttributeClassifier, AttributeList } from "@/UI/Components"; +import { + CodeEditorCopyControl, + CodeEditorHeightToggleControl, +} from "@/UI/Components/CodeEditorControls"; +import { getThemePreference } from "@/UI/Components/DarkmodeOption"; +import { words } from "@/UI/words"; +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; } -/** - * 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 - */ -export const AttributesTab: React.FC = ({ details }) => { +const HIDDEN_KEYS = new Set(["mutators", "references"]); + +type ViewMode = "structured" | "json"; + +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 [viewMode, setViewMode] = useState("structured"); + const [isEditorExpanded, setIsEditorExpanded] = useState(true); + + 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 rawJson = useMemo( + () => JSON.stringify(details.attributes, null, 2), + [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 mutatedEntries = Array.from(mutatedKeys) + .filter((key) => !HIDDEN_KEYS.has(key)) + .sort() + .map((key) => ({ key, value: substituted[key] })); - const classifiedAttributes = classifier.classify(details.attributes); + const editorHeight = isEditorExpanded ? "calc(100vh - 300px)" : "sizeToFit"; return ( - - - - - + + + + + + setViewMode("structured")} + /> + setViewMode("json")} + /> + + + + + + {viewMode === "json" ? ( + + + setIsEditorExpanded(!isEditorExpanded)} + /> + + } + height={editorHeight} + /> + ) : ( + + + + {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 new file mode 100644 index 0000000000..9016bb5339 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ExpandedReferenceView.tsx @@ -0,0 +1,115 @@ +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; + getReferenceType: (id: string) => string | undefined; +} + +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, + getReferenceType, +}) => { + 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..747019cbbd --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/MjsonValueView.tsx @@ -0,0 +1,60 @@ +import React from "react"; +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 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; +`; + +/** + * 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, + getReferenceType, +}) => { + const references = arg.references ?? {}; + + if (Object.keys(references).length === 0) { + return {JSON.stringify(arg.value, null, 2)}; + } + + 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/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..1b27701bc4 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTable.tsx @@ -0,0 +1,85 @@ +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"; +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 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); + + 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..fc2446cdb1 --- /dev/null +++ b/src/Slices/ResourceDetails/UI/Tabs/ReferencesTab/ReferencesTableRow.tsx @@ -0,0 +1,56 @@ +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; + getReferenceType: (id: string) => string | undefined; + rowRef: React.RefObject; + numberOfColumns: number; + index: number; +} + +export const ReferencesTableRow: React.FC = ({ + reference, + isExpanded, + onToggle, + onNavigateToReference, + getReferenceType, + 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/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 `