From dd22ca7c1ef73b3c7492cabe8fbed8e7ac6ccf99 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Thu, 31 Oct 2024 15:30:28 +0100 Subject: [PATCH 1/2] Add inventory centric POC module. --- config/webpack.plugins.js | 1 + src/components/Routes/Routes.tsx | 13 ++ src/inventoryPoc/InventoryColumn.ts | 114 ++++++++++ src/inventoryPoc/ModularInventory.tsx | 312 ++++++++++++++++++++++++++ src/inventoryPoc/api.ts | 115 ++++++++++ src/inventoryPoc/index.ts | 4 + 6 files changed, 559 insertions(+) create mode 100644 src/inventoryPoc/InventoryColumn.ts create mode 100644 src/inventoryPoc/ModularInventory.tsx create mode 100644 src/inventoryPoc/api.ts create mode 100644 src/inventoryPoc/index.ts diff --git a/config/webpack.plugins.js b/config/webpack.plugins.js index 195d6ad98..ce88bbed0 100644 --- a/config/webpack.plugins.js +++ b/config/webpack.plugins.js @@ -39,6 +39,7 @@ const plugins = (dev = false, beta = false, restricted = false) => { './LandingNavFavorites': resolve(__dirname, '../src/components/FavoriteServices/LandingNavFavorites.tsx'), './DashboardFavorites': resolve(__dirname, '../src/components/FavoriteServices/DashboardFavorites.tsx'), './SatelliteToken': resolve(__dirname, '../src/layouts/SatelliteToken.tsx'), + './ModularInventory': resolve(__dirname, '../src/inventoryPoc/index.ts'), }, shared: [ { react: { singleton: true, eager: true, requiredVersion: deps.react } }, diff --git a/src/components/Routes/Routes.tsx b/src/components/Routes/Routes.tsx index dd30eb4b7..336acde0a 100644 --- a/src/components/Routes/Routes.tsx +++ b/src/components/Routes/Routes.tsx @@ -10,6 +10,7 @@ import { moduleRoutesAtom } from '../../state/atoms/chromeModuleAtom'; const INTEGRATION_SOURCES = 'platform.sources.integrations'; const QuickstartCatalogRoute = lazy(() => import('../QuickstartsCatalogRoute')); +const ModularInventoryRoute = lazy(() => import('../../inventoryPoc')); const redirects = [ { @@ -64,6 +65,7 @@ export type RoutesProps = { const ChromeRoutes = ({ routesProps }: RoutesProps) => { const enableIntegrations = useFlag(INTEGRATION_SOURCES); + const enableInventoryPOC = useFlag('platform.chrome.poc.inventory'); const featureFlags = useMemo>(() => ({ INTEGRATION_SOURCES: enableIntegrations }), [enableIntegrations]); const moduleRoutes = useAtomValue(moduleRoutesAtom); const showBundleCatalog = localStorage.getItem('chrome:experimental:quickstarts') === 'true'; @@ -92,6 +94,17 @@ const ChromeRoutes = ({ routesProps }: RoutesProps) => { {moduleRoutes.map((app) => ( } /> ))} + {/* Inventory POC route only available for certain accounts */} + {enableInventoryPOC ? ( + + + + } + /> + ) : null} } /> ); diff --git a/src/inventoryPoc/InventoryColumn.ts b/src/inventoryPoc/InventoryColumn.ts new file mode 100644 index 000000000..163f54bf8 --- /dev/null +++ b/src/inventoryPoc/InventoryColumn.ts @@ -0,0 +1,114 @@ +import { ReactNode } from 'react'; +import { getModule } from '@scalprum/core'; + +export type RemoteColumnData = unknown[]> = { + scope: string; + module: string; + importName?: string; + initArgs?: T; +}; +export type BaseColumnData = ReactNode[]; +export type ColumnData = BaseColumnData | RemoteColumnData | (() => Promise); +export type LocalColumnData = ReactNode[]; + +export function isRemoteColumn(columnData: ColumnData): columnData is RemoteColumnData { + return (columnData as RemoteColumnData).module !== undefined || (columnData as RemoteColumnData).scope !== undefined; +} + +export function isAsyncColumnData(columnData: ColumnData): columnData is () => Promise { + return typeof columnData === 'function'; +} + +export class BaseInventoryColumn { + private columnId: string; + private title: ReactNode; + private columnData: BaseColumnData; + + constructor(columnId: string, title: ReactNode, { columnData }: { columnData: BaseColumnData }) { + this.columnId = columnId; + this.title = title; + this.columnData = columnData; + } + + getColumnId(): string { + return this.columnId; + } + + getTitle(): ReactNode { + return this.title; + } + + getColumnData(): BaseColumnData { + return this.columnData; + } + + setColumnData(columnData: BaseColumnData): void { + this.columnData = columnData; + } + + setColumnId(columnId: string): void { + this.columnId = columnId; + } + + setColumnTitle(title: ReactNode): void { + this.title = title; + } +} + +export class InventoryColumn extends BaseInventoryColumn { + private asyncModule?: boolean = false; + private ready?: boolean = true; + private observeReadyCallbacks: (() => void)[] = []; + + constructor(columnId: string, title: ReactNode, { columnData }: { columnData: ColumnData }) { + if (isRemoteColumn(columnData)) { + super(columnId, title, { columnData: [] }); + this.asyncModule = true; + this.ready = false; + getModule<(...args: unknown[]) => InventoryColumn>(columnData.scope, columnData.module, columnData.importName).then((remoteColumnInit) => { + const remoteColumn = remoteColumnInit(...(columnData.initArgs || [])); + if (remoteColumn?.isAsync?.()) { + const p = new Promise((res) => { + remoteColumn.observeReady(res); + }); + p.then(() => { + this.setColumnId(remoteColumn.getColumnId()); + this.setColumnTitle(remoteColumn.getTitle()); + this.setColumnData(remoteColumn.getColumnData()); + this.ready = true; + this.observeReadyCallbacks.forEach((callback) => callback()); + }); + } else { + this.setColumnId(remoteColumn.getColumnId()); + this.setColumnTitle(remoteColumn.getTitle()); + this.setColumnData(remoteColumn.getColumnData()); + this.ready = true; + this.observeReadyCallbacks.forEach((callback) => callback()); + } + }); + } else if (isAsyncColumnData(columnData)) { + super(columnId, title, { columnData: [] }); + this.asyncModule = true; + this.ready = false; + columnData().then((data) => { + this.setColumnData(data); + this.ready = true; + this.observeReadyCallbacks.forEach((callback) => callback()); + }); + } else { + super(columnId, title, { columnData: columnData as BaseColumnData }); + } + } + + isAsync(): boolean { + return !!this.asyncModule; + } + + isReady(): boolean { + return !!this.ready; + } + + observeReady(callback: () => void): void { + this.observeReadyCallbacks.push(callback); + } +} diff --git a/src/inventoryPoc/ModularInventory.tsx b/src/inventoryPoc/ModularInventory.tsx new file mode 100644 index 000000000..d20cf5269 --- /dev/null +++ b/src/inventoryPoc/ModularInventory.tsx @@ -0,0 +1,312 @@ +import React, { ReactNode, useEffect, useMemo, useState } from 'react'; +import { BaseInventoryColumn, InventoryColumn, LocalColumnData } from './InventoryColumn'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { DateFormat } from '@redhat-cloud-services/frontend-components/DateFormat'; +import SecurityIcon from '@patternfly/react-icons/dist/dynamic/icons/security-icon'; +import TagIcon from '@patternfly/react-icons/dist/dynamic/icons/tag-icon'; + +import { Host, getHostCVEs, getHostTags, getHosts } from './api'; +import { Checkbox } from '@patternfly/react-core/dist/dynamic/components/Checkbox'; +import { Icon } from '@patternfly/react-core/dist/dynamic/components/Icon'; +import { Skeleton } from '@patternfly/react-core/dist/dynamic/components/Skeleton'; +import { Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core/dist/dynamic/components/Toolbar'; + +function createRows( + columns: { + isReady?: () => boolean; + isAsync?: () => boolean; + getColumnData: () => LocalColumnData; + }[] +) { + const rowNumber = columns.reduce((acc, column) => { + if (!column.isAsync?.()) { + return Math.max(acc, column.getColumnData().length); + } + return acc; + }, 0); + const allData = columns.reduce((acc, column) => { + if (column.isAsync?.() && !column.isReady?.()) { + for (let i = 0; i < rowNumber; i++) { + if (!acc[i]) { + acc[i] = []; + } + acc[i].push(); + } + + return acc; + } + const data = column.getColumnData(); + for (let i = 0; i < data.length; i++) { + if (!acc[i]) { + acc[i] = []; + } + acc[i].push(data[i]); + } + return acc; + }, []); + + return allData; +} + +function useColumnData(columns: InventoryColumn[]) { + const hasRemoteColumns = columns.some((column) => { + return column.isAsync?.(); + }); + const [ready, setReady] = React.useState(!hasRemoteColumns); + function initLocalData() { + if (hasRemoteColumns) { + return new Array(columns.length).fill([]); + } + return createRows( + columns as { + isAsync?: () => boolean; + getColumnData: () => LocalColumnData; + }[] + ); + } + const [data, setData] = React.useState(initLocalData); + console.log({ data }); + + useEffect(() => { + setReady(!hasRemoteColumns); + setData( + createRows( + columns as { + isAsync?: () => boolean; + getColumnData: () => LocalColumnData; + }[] + ) + ); + const promises: Promise[] = []; + for (let i = 0; i < columns.length; i++) { + if (columns[i].isAsync?.() && typeof columns[i].observeReady === 'function') { + const P = new Promise((resolve) => { + columns[i].observeReady?.(resolve); + }); + promises.push(P); + P.then(() => { + setData( + createRows( + columns as { + isAsync?: () => boolean; + getColumnData: () => LocalColumnData; + }[] + ) + ); + }); + } + } + + return () => { + setReady(true); + setData([]); + }; + }, [columns]); + + const res = useMemo<[ReactNode[][], boolean]>(() => [data, ready], [data, ready]); + + return res; +} + +const ModularInventory = ({ columns }: { columns: Omit[] }) => { + const [allData] = useColumnData(columns as InventoryColumn[]); + return ( + + + + {columns.map((column) => ( + + ))} + + + + {allData.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{column.getTitle()}
{cell}
+ ); +}; + +const columnIds = ['id', 'name', 'all-cves', 'cves', 'tags', 'os', 'lastCheckIn']; +const ColumnEnabler = ({ + enabledColumns, + handleCheckboxChange, +}: { + enabledColumns: { [key: string]: boolean }; + handleCheckboxChange: (columnId: string) => void; +}) => { + return ( + + + {columnIds.map((columnId) => ( + + handleCheckboxChange(columnId)} label={columnId} id={columnId} /> + + ))} + + + ); +}; + +const columnsRegistry: { + [key: string]: (hosts: Host[], cvePromises: ReturnType[]) => InventoryColumn | BaseInventoryColumn; +} = { + id: (hosts: Host[]) => { + return new BaseInventoryColumn('id', 'System ID', { + columnData: hosts.map((host) => host.id), + }); + }, + + name: (hosts: Host[]) => { + return new BaseInventoryColumn('name', 'System Name', { + columnData: hosts.map((host) => ( + + {host.display_name} + + )), + }); + }, + + 'all-cves': (_e, cvePromises: ReturnType[]) => { + return new InventoryColumn('all-cves', 'Total CVEs', { + columnData: async () => { + const res = await Promise.all(cvePromises); + return res.map((r, index) => ( + + {r.allCount} + + )); + }, + }); + }, + + cves: (_e, cvePromises: ReturnType[]) => { + return new InventoryColumn('cves', 'High severity CVEs', { + columnData: async () => { + const res = await Promise.all(cvePromises); + return res.map((r, index) => { + return ( + <> + + + + + {r.criticalCount} + + + + + + {r.highCount} + + + ); + }); + }, + }); + }, + + tags: (hosts: Host[]) => + new InventoryColumn('tags', 'Tags??', { + columnData: async () => { + const promises = hosts.map((host) => { + if (!host.id) { + return { count: 0, results: {} }; + } + return getHostTags(host.id); + }); + const res = await Promise.all(promises); + return res.map((r, index) => { + const tagCount = Object.values(r.results).reduce((acc, curr) => acc + curr, 0); + return ( + + + {tagCount} + + ); + }); + }, + }), + + os: (hosts: Host[]) => { + return new BaseInventoryColumn('os', 'OS', { + columnData: hosts.map((host) => + host.system_profile.operating_system ? ( + + {host.system_profile.operating_system.name}  + {host.system_profile.operating_system.major}.{host.system_profile.operating_system.minor} + + ) : ( + 'Not available' + ) + ), + }); + }, + + lastCheckIn: (hosts: Host[]) => { + return new BaseInventoryColumn('lastCheckIn', 'Last check-in', { + columnData: hosts.map((host) => + host.per_reporter_staleness.puptoo?.last_check_in ? ( + + ) : null + ), + }); + }, +}; + +const ModularInventoryRoute = () => { + const [hosts, setHosts] = React.useState([]); + const [enabledColumns, setEnabledColumns] = useState( + columnIds.reduce<{ [key: string]: boolean }>((acc, curr) => { + acc[curr] = true; + return acc; + }, {}) + ); + + const handleCheckboxChange = (columnId: string) => { + setEnabledColumns((prev) => ({ + ...prev, + [columnId]: !prev[columnId], + })); + }; + const cols = useMemo(() => { + const cvePromises = hosts.map((host) => { + if (!host.id) { + return { criticalCount: 0, highCount: 0, allCount: 0 }; + } + return getHostCVEs(host.id); + }); + + const cols = columnIds + .filter((columnId) => enabledColumns[columnId]) + .map((columnId) => { + return columnsRegistry[columnId](hosts, cvePromises as any); + }); + + return cols; + }, [hosts, enabledColumns]); + + async function initData() { + const response = await getHosts(); + setHosts(response.results); + getHostTags(response.results[0].insights_id); + } + + useEffect(() => { + initData(); + }, []); + + return ( +
+ + +
+ ); +}; + +export default ModularInventoryRoute; diff --git a/src/inventoryPoc/api.ts b/src/inventoryPoc/api.ts new file mode 100644 index 000000000..838f18a54 --- /dev/null +++ b/src/inventoryPoc/api.ts @@ -0,0 +1,115 @@ +import axios, { AxiosResponse } from 'axios'; + +export type Host = { + id: string; + insights_id: string; + display_name: string; + per_reporter_staleness: { + puptoo?: { + last_check_in: string; + }; + }; + system_profile: { + operating_system?: { + major: number; + minor: number; + name: string; + }; + }; +}; + +export const getHosts = async () => { + const response = await axios.get<{ results: Host[] }>('/api/inventory/v1/hosts', { + params: { + page: 1, + per_page: 20, + order_by: 'updated', + order_how: 'DESC', + 'fields[system_profile]': ['operating_system'], + }, + }); + + return response.data; +}; + +const hostCache: { + [key: string]: Promise>; +} = {}; + +export const getHostTags = async (hostId: string) => { + if (!hostId) { + return { count: 0, results: {} }; + } + if (!hostCache[hostId]) { + const p = axios.get<{ + count: number; + results: { [key: string]: number }; + }>(`/api/inventory/v1/hosts/${hostId}/tags/count`); + + hostCache[hostId] = p; + const result = await p; + return result.data; + } + + const result = await hostCache[hostId]; + return result.data; +}; + +const cveCache: { + [hostId: string]: Promise<{ + criticalCount: number; + highCount: number; + allCount: number; + }>; +} = {}; + +export const getHostCVEs = async (hostId: string): Promise<{ criticalCount: number; highCount: number; allCount: number }> => { + if (!cveCache[hostId]) { + const p = new Promise<{ + criticalCount: number; + highCount: number; + allCount: number; + }>((resolve) => { + const criticalPromise = axios.get<{ + meta: { + total_items: number; + }; + }>(`/api/vulnerability/v1/systems/${hostId}/cves`, { + params: { + business_risk_id: 4, + }, + }); + const highPromise = axios.get<{ + meta: { + total_items: number; + }; + }>(`/api/vulnerability/v1/systems/${hostId}/cves`, { + params: { + business_risk_id: 3, + }, + }); + const allPromise = axios.get<{ + meta: { + total_items: number; + }; + }>(`/api/vulnerability/v1/systems/${hostId}/cves`, {}); + + return Promise.all([criticalPromise, highPromise, allPromise]) + .then((result) => { + return resolve({ + criticalCount: result[0].data.meta.total_items, + highCount: result[1].data.meta.total_items, + allCount: result[2].data.meta.total_items, + }); + }) + .catch(() => { + return resolve({ criticalCount: 0, highCount: 0, allCount: 0 }); + }); + }); + + cveCache[hostId] = p; + return p; + } + + return cveCache[hostId]; +}; diff --git a/src/inventoryPoc/index.ts b/src/inventoryPoc/index.ts new file mode 100644 index 000000000..cca6ce91a --- /dev/null +++ b/src/inventoryPoc/index.ts @@ -0,0 +1,4 @@ +// This module is only used for a POC and should not be used in production +// Once the POC is complete, this module will be removed +// Ownership will be decided in future +export { default } from './ModularInventory'; From f9547e7c65bb1576e210a3d5cda00b91cc7985a5 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 5 Nov 2024 12:41:34 +0100 Subject: [PATCH 2/2] Enhance inventory centric view with patch and advisor columns --- src/inventoryPoc/ModularInventory.tsx | 132 +++++++++++++++++++++++++- src/inventoryPoc/api.ts | 33 +++++++ 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/inventoryPoc/ModularInventory.tsx b/src/inventoryPoc/ModularInventory.tsx index d20cf5269..9413b2866 100644 --- a/src/inventoryPoc/ModularInventory.tsx +++ b/src/inventoryPoc/ModularInventory.tsx @@ -5,10 +5,13 @@ import { DateFormat } from '@redhat-cloud-services/frontend-components/DateForma import SecurityIcon from '@patternfly/react-icons/dist/dynamic/icons/security-icon'; import TagIcon from '@patternfly/react-icons/dist/dynamic/icons/tag-icon'; -import { Host, getHostCVEs, getHostTags, getHosts } from './api'; +import { AdvisorSystem, Host, getHostCVEs, getHostInsights, getHostPatch, getHostTags, getHosts } from './api'; import { Checkbox } from '@patternfly/react-core/dist/dynamic/components/Checkbox'; import { Icon } from '@patternfly/react-core/dist/dynamic/components/Icon'; import { Skeleton } from '@patternfly/react-core/dist/dynamic/components/Skeleton'; +import ShieldIcon from '@patternfly/react-icons/dist/dynamic/icons/shield-alt-icon'; +import BugIcon from '@patternfly/react-icons/dist/dynamic/icons/bug-icon'; +import CogIcon from '@patternfly/react-icons/dist/dynamic/icons/cog-icon'; import { Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core/dist/dynamic/components/Toolbar'; function createRows( @@ -65,7 +68,6 @@ function useColumnData(columns: InventoryColumn[]) { ); } const [data, setData] = React.useState(initLocalData); - console.log({ data }); useEffect(() => { setReady(!hasRemoteColumns); @@ -132,7 +134,21 @@ const ModularInventory = ({ columns }: { columns: Omit[]) => InventoryColumn | BaseInventoryColumn; + [key: string]: ( + hosts: Host[], + cvePromises: ReturnType[], + systemPromises: Promise[], + patchPromises: ReturnType[] + ) => InventoryColumn | BaseInventoryColumn; } = { + criticalCves: (_h, _c, systemPromises) => { + return new InventoryColumn('criticalCves', 'Critical', { + columnData: async () => { + const res = await Promise.all(systemPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'Unknown'; + } + return r.critical_hits; + }); + }, + }); + }, + importantCves: (_h, _c, systemPromises) => { + return new InventoryColumn('importantCves', 'Important', { + columnData: async () => { + const res = await Promise.all(systemPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'Unknown'; + } + return r.important_hits; + }); + }, + }); + }, + moderateCves: (_h, _c, systemPromises) => { + return new InventoryColumn('moderateCves', 'Moderate', { + columnData: async () => { + const res = await Promise.all(systemPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'Unknown'; + } + return r.moderate_hits; + }); + }, + }); + }, + lowCves: (_h, _c, systemPromises) => { + return new InventoryColumn('lowCves', 'Low', { + columnData: async () => { + const res = await Promise.all(systemPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'Unknown'; + } + return r.low_hits; + }); + }, + }); + }, + recommendations: (_h, _c, systemPromises) => { + return new InventoryColumn('recommendations', 'Recommendations', { + columnData: async () => { + const res = await Promise.all(systemPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'Unknown'; + } + return r.low_hits + r.moderate_hits + r.important_hits + r.critical_hits; + }); + }, + }); + }, + installAbleAdvisories: (_h, _c, _s, patchPromises) => { + return new InventoryColumn('installAbleAdvisories', 'Installable advisories', { + columnData: async () => { + const res = await Promise.all(patchPromises); + return res.map((r) => { + if (r === 'unknown') { + return 'unknown'; + } + return ( + <> + + + {r.attributes.installable_rhsa_count} + + + + {r.attributes.installable_rhba_count} + + + + {r.attributes.installable_rhea_count} + + + ); + }); + }, + }); + }, + id: (hosts: Host[]) => { return new BaseInventoryColumn('id', 'System ID', { columnData: hosts.map((host) => host.id), @@ -281,11 +396,18 @@ const ModularInventoryRoute = () => { } return getHostCVEs(host.id); }); + const systemPromises = hosts.map((host) => { + return getHostInsights(host.id); + }); + + const patchPromises = hosts.map((host) => { + return getHostPatch(host.id); + }); const cols = columnIds .filter((columnId) => enabledColumns[columnId]) .map((columnId) => { - return columnsRegistry[columnId](hosts, cvePromises as any); + return columnsRegistry[columnId](hosts, cvePromises as any, systemPromises, patchPromises); }); return cols; diff --git a/src/inventoryPoc/api.ts b/src/inventoryPoc/api.ts index 838f18a54..ef7832d20 100644 --- a/src/inventoryPoc/api.ts +++ b/src/inventoryPoc/api.ts @@ -113,3 +113,36 @@ export const getHostCVEs = async (hostId: string): Promise<{ criticalCount: numb return cveCache[hostId]; }; + +export type AdvisorSystem = { + critical_hits: number; + important_hits: number; + moderate_hits: number; + low_hits: number; +}; + +export const getHostInsights = async (hostId: string) => { + try { + const { data } = await axios.get(`/api/insights/v1/system/${hostId}`); + return data; + } catch (error) { + return 'unknown'; + } +}; + +export type PatchSystem = { + attributes: { + installable_rhba_count: number; + installable_rhea_count: number; + installable_rhsa_count: number; + }; +}; + +export const getHostPatch = async (hostId: string) => { + try { + const { data } = await axios.get<{ data: PatchSystem }>(`/api/patch/v3/systems/${hostId}`); + return data.data; + } catch (error) { + return 'unknown'; + } +};