webview-ui/src/InspektorGadget/ResourceSelector.tsx (183 lines of code) (raw):

import { FormEvent, useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; import styles from "./InspektorGadget.module.css"; import { NamespaceResources, PodResources } from "./helpers/clusterResources"; import { Lazy, isLoaded, isNotLoaded, map as lazyMap, orDefault } from "../utilities/lazy"; import { Lookup, asLookup, exclude, intersection } from "../utilities/array"; import { EventHandlers } from "../utilities/state"; import { EventDef, vscode } from "./helpers/state"; import { ProgressRing } from "../components/ProgressRing"; type ChangeEvent = Event | FormEvent<HTMLElement>; type PodItemStatus = { isExpanded: boolean; }; type AllPodItemStatuses = Lookup<PodItemStatus>; type NamespaceItemStatus = { isExpanded: boolean; podStatuses: AllPodItemStatuses; }; type AllNamespaceItemStatuses = Lookup<NamespaceItemStatus>; const resourceProperties = { namespace: "namespace", podName: "podName", container: "container" } as const; type SelectedNamespace = { [resourceProperties.namespace]: string }; type SelectedPod = SelectedNamespace & { [resourceProperties.podName]: string }; type SelectedContainer = SelectedPod & { [resourceProperties.container]: string }; type SelectedResource = Record<string, never> | SelectedNamespace | SelectedPod | SelectedContainer; function isNoResource(selectedResource: SelectedResource): selectedResource is Record<string, never> { return !(resourceProperties.namespace in selectedResource); } function isNamespaceResource(selectedResource: SelectedResource): selectedResource is SelectedNamespace { return ( resourceProperties.namespace in selectedResource && !(resourceProperties.podName in selectedResource) && !(resourceProperties.container in selectedResource) ); } function isPodResource(selectedResource: SelectedResource): selectedResource is SelectedPod { return resourceProperties.podName in selectedResource && !(resourceProperties.container in selectedResource); } function isContainerResource(selectedResource: SelectedResource): selectedResource is SelectedContainer { return resourceProperties.container in selectedResource; } function lazyAsLookup<T>(lazyList: Lazy<T[]>, keyFn: (value: T) => string): Lookup<T> { return orDefault( lazyMap(lazyList, (items) => asLookup(items, keyFn)), {}, ); } function getUpdatedNamespaceItemStatus(status: NamespaceItemStatus, resource: NamespaceResources): NamespaceItemStatus { const resources = lazyAsLookup(resource.children, (p) => p.name); const pods = Object.keys(resources); const podsWithStatus = Object.keys(status.podStatuses); const existingStatuses = intersection(pods, podsWithStatus).map((p) => [p, status.podStatuses[p]]); const newStatuses = exclude(pods, podsWithStatus).map((p) => [p, { isExpanded: false }]); const podStatuses = Object.fromEntries(existingStatuses.concat(newStatuses)); return { ...status, podStatuses }; } function getUpdatedStatus( status: AllNamespaceItemStatuses, clusterResources: NamespaceResources[], ): AllNamespaceItemStatuses { const resources = asLookup(clusterResources, (ns) => ns.name); const namespaces = Object.keys(resources); const namespacesWithStatus = Object.keys(status); const existingStatuses = intersection(namespaces, namespacesWithStatus).map((ns) => [ ns, getUpdatedNamespaceItemStatus(status[ns], resources[ns]), ]); const newStatuses = exclude(namespaces, namespacesWithStatus).map((ns) => [ ns, getUpdatedNamespaceItemStatus({ isExpanded: false, podStatuses: {} }, resources[ns]), ]); return Object.fromEntries(existingStatuses.concat(newStatuses)); } export interface ResourceSelectorProps { id?: string; className?: string; resources: NamespaceResources[]; onSelectionChanged: (selection: { namespace?: string; podName?: string; container?: string }) => void; userMessageHandlers: EventHandlers<EventDef>; } export function ResourceSelector(props: ResourceSelectorProps) { const [status, setStatus] = useState<AllNamespaceItemStatuses>({}); const [selectedResource, setSelectedResource] = useState<SelectedResource>({}); const updatedStatus = getUpdatedStatus(status, props.resources); useEffect(() => { setStatus(updatedStatus); }, [props.resources, updatedStatus]); return ( <ul id={props.id} className={props.className ? `${props.className} ${styles.hierarchyList}` : styles.hierarchyList} > <li> <div className={styles.radioLine}> <input type="radio" onChange={handleNoResourceChange} checked={isNoResource(selectedResource)} ></input> <label className={styles.radioLabel}>All</label> </div> </li> {renderNamespaceItems(props.resources, updatedStatus)} </ul> ); function renderNamespaceItems(items: NamespaceResources[], status: AllNamespaceItemStatuses) { return items.map((item) => ( <li key={item.name}> <FontAwesomeIcon className={styles.expander} onClick={() => toggleNamespaceExpanded(item.name)} icon={status[item.name].isExpanded ? faChevronDown : faChevronRight} /> <div className={styles.radioLine}> <input type="radio" className={styles.selector} onChange={(e) => handleNamespaceChange(e, item.name)} checked={isNamespaceResource(selectedResource) && selectedResource.namespace === item.name} ></input> <label className={styles.radioLabel}>{item.name}</label> </div> {status[item.name].isExpanded && ( <ul className={styles.hierarchyList}> {isLoaded(item.children) ? ( renderPodItems(item.name, item.children.value, status[item.name].podStatuses) ) : ( <ProgressRing /> )} </ul> )} </li> )); } function renderPodItems(namespace: string, items: PodResources[], status: AllPodItemStatuses) { return items.map((item) => ( <li key={item.name}> <FontAwesomeIcon className={styles.expander} onClick={() => togglePodExpanded(namespace, item.name)} icon={status[item.name].isExpanded ? faChevronDown : faChevronRight} /> <div className={styles.radioLine}> <input type="radio" onChange={(e) => handlePodChange(e, namespace, item.name)} checked={ isPodResource(selectedResource) && selectedResource.namespace === namespace && selectedResource.podName === item.name } ></input> <label className={styles.radioLabel}>{item.name}</label> </div> {status[item.name].isExpanded && ( <ul className={styles.hierarchyList}> {isLoaded(item.children) ? ( renderContainerItems(namespace, item.name, item.children.value) ) : ( <ProgressRing /> )} </ul> )} </li> )); } function renderContainerItems(namespace: string, podName: string, containerNames: string[]) { return containerNames.map((c) => ( <li key={c}> <div className={styles.radioLine}> <input type="radio" onChange={(e) => handleContainerChange(e, namespace, podName, c)} checked={ isContainerResource(selectedResource) && selectedResource.namespace === namespace && selectedResource.podName === podName && selectedResource.container === c } ></input> <label className={styles.radioLabel}>{c}</label> </div> </li> )); } function handleNoResourceChange(e: ChangeEvent) { if ((e.target as HTMLInputElement).checked) { setSelectedResource({}); props.onSelectionChanged({}); } } function handleNamespaceChange(e: ChangeEvent, namespace: string) { if ((e.target as HTMLInputElement).checked) { setSelectedResource({ namespace }); props.onSelectionChanged({ namespace }); } } function handlePodChange(e: ChangeEvent, namespace: string, podName: string) { if ((e.target as HTMLInputElement).checked) { setSelectedResource({ namespace, podName }); props.onSelectionChanged({ namespace, podName }); } } function handleContainerChange(e: ChangeEvent, namespace: string, podName: string, container: string) { if ((e.target as HTMLInputElement).checked) { setSelectedResource({ namespace, podName, container }); props.onSelectionChanged({ namespace, podName, container }); } } function toggleNamespaceExpanded(namespace: string) { const namespaceItem = status[namespace]; const newNamespaceItem = { ...namespaceItem, isExpanded: !namespaceItem.isExpanded }; const newStatus = { ...status, [namespace]: newNamespaceItem }; setStatus(newStatus); if (newNamespaceItem.isExpanded) { const nsResources = asLookup(props.resources, (ns) => ns.name); if (isNotLoaded(nsResources[namespace].children)) { props.userMessageHandlers.onSetPodsLoading({ namespace }); vscode.postGetPodsRequest({ namespace }); } } } function togglePodExpanded(namespace: string, podName: string) { const namespaceItem = status[namespace]; const podItem = namespaceItem.podStatuses[podName]; const newPodItem = { ...podItem, isExpanded: !podItem.isExpanded }; const newPodStatuses = { ...namespaceItem.podStatuses, [podName]: newPodItem }; const newNamespaceItem = { ...namespaceItem, podStatuses: newPodStatuses }; const newStatus = { ...status, [namespace]: newNamespaceItem }; setStatus(newStatus); if (newPodItem.isExpanded) { const nsResources = asLookup(props.resources, (ns) => ns.name); const podResources = lazyAsLookup(nsResources[namespace].children, (pod) => pod.name); if (isNotLoaded(podResources[podName].children)) { props.userMessageHandlers.onSetContainersLoading({ namespace, podName }); vscode.postGetContainersRequest({ namespace, podName }); } } } }