diff --git a/esp/src/src-react/components/Workunits.tsx b/esp/src/src-react/components/Workunits.tsx index a00a73e4670..26cab7c5a6b 100644 --- a/esp/src/src-react/components/Workunits.tsx +++ b/esp/src/src-react/components/Workunits.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, Icon, Image, Link } from "@fluentui/react"; +import { CommandBar, ContextualMenuItemType, DetailsRow, ICommandBarItemProps, IDetailsRowProps, Icon, Image, Link } from "@fluentui/react"; +import { hsl as d3Hsl } from "@hpcc-js/common"; import { SizeMe } from "react-sizeme"; import { CreateWUQueryStore, defaultSort, emptyFilter, Get, WUQueryStore, formatQuery } from "src/ESPWorkunit"; import * as WsWorkunits from "src/WsWorkunits"; @@ -7,6 +8,8 @@ import { formatCost } from "src/Session"; import nlsHPCC from "src/nlsHPCC"; import { useConfirm } from "../hooks/confirm"; import { useMyAccount } from "../hooks/user"; +import { useUserStore } from "../hooks/store"; +import { useLogicalClustersPalette } from "../hooks/platform"; import { calcSearch, pushParams } from "../util/history"; import { useHasFocus, useIsMounted } from "../hooks/util"; import { HolyGrail } from "../layouts/HolyGrail"; @@ -61,12 +64,14 @@ export const Workunits: React.FunctionComponent = ({ const [showFilter, setShowFilter] = React.useState(false); const { currentUser } = useMyAccount(); const [uiState, setUIState] = React.useState({ ...defaultUIState }); + const [showTimeline, setShowTimeline] = useUserStore("workunits_showTimeline", true); const { selection, setSelection, pageNum, setPageNum, pageSize, setPageSize, total, setTotal, refreshTable } = useFluentStoreState({ page }); + const [, , palette] = useLogicalClustersPalette(); // Refresh on focus --- const isMounted = useIsMounted(); @@ -243,7 +248,15 @@ export const Workunits: React.FunctionComponent = ({ pushParams(filter); } }, - ], [currentUser, filter, hasFilter, refreshTable, selection, setShowAbortConfirm, setShowDeleteConfirm, store, total, uiState.hasNotCompleted, uiState.hasNotProtected, uiState.hasProtected, uiState.hasSelection]); + { key: "divider_5", itemType: ContextualMenuItemType.Divider, onRender: () => }, + { + key: "timeline", text: nlsHPCC.Timeline, canCheck: true, checked: showTimeline, iconProps: { iconName: "TimelineProgress" }, + onClick: () => { + setShowTimeline(!showTimeline); + refreshTable.call(); + } + }, + ], [currentUser.username, filter, hasFilter, refreshTable, selection, setShowAbortConfirm, setShowDeleteConfirm, showTimeline, store, total, uiState.hasNotCompleted, uiState.hasNotProtected, uiState.hasProtected, uiState.hasSelection]); // Selection --- React.useEffect(() => { @@ -274,6 +287,34 @@ export const Workunits: React.FunctionComponent = ({ setUIState(state); }, [selection]); + const renderRowTimings = React.useCallback((props: IDetailsRowProps, size: { readonly width: number; readonly height: number; }) => { + if (showTimeline && props) { + const total = props.item.timings.page.end - props.item.timings.page.start; + const startPct = 100 - (props.item.timings.start - props.item.timings.page.start) / total * 100; + const endPct = 100 - (props.item.timings.end - props.item.timings.page.start) / total * 100; + const backgroundColor = palette(props.item.Cluster); + const borderColor = d3Hsl(backgroundColor).darker().toString(); + + return
+ +
+
; + } + return ; + }, [showTimeline]); + return } main={ @@ -293,6 +334,7 @@ export const Workunits: React.FunctionComponent = ({ setSelection={setSelection} setTotal={setTotal} refresh={refreshTable} + onRenderRow={showTimeline ? props => renderRowTimings(props, size) : undefined} >
@@ -302,14 +344,14 @@ export const Workunits: React.FunctionComponent = ({ } - footer={} + >} footerStyles={{}} />; }; diff --git a/esp/src/src-react/components/controls/Grid.tsx b/esp/src/src-react/components/controls/Grid.tsx index 08ccb145962..fff30e920ca 100644 --- a/esp/src/src-react/components/controls/Grid.tsx +++ b/esp/src/src-react/components/controls/Grid.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { DetailsList, DetailsListLayoutMode, Dropdown, IColumn as _IColumn, ICommandBarItemProps, IDetailsHeaderProps, IDetailsListStyles, mergeStyleSets, Selection, Stack, TooltipHost, TooltipOverflowMode, IDetailsList } from "@fluentui/react"; +import { DetailsList, DetailsListLayoutMode, Dropdown, IColumn as _IColumn, ICommandBarItemProps, IDetailsHeaderProps, IDetailsListStyles, mergeStyleSets, Selection, Stack, TooltipHost, TooltipOverflowMode, IDetailsList, IRenderFunction, IDetailsRowProps } from "@fluentui/react"; import { Pagination } from "@fluentui/react-experiments/lib/Pagination"; import { useConst, useId, useMount, useOnEvent } from "@fluentui/react-hooks"; import { BaseStore, Memory, QueryRequest, QuerySortItem } from "src/store/Memory"; @@ -193,6 +193,7 @@ interface FluentStoreGridProps { refresh: RefreshTable, setSelection: (selection: any[]) => void, setTotal: (total: number) => void, + onRenderRow?: IRenderFunction } const FluentStoreGrid: React.FunctionComponent = ({ @@ -206,6 +207,7 @@ const FluentStoreGrid: React.FunctionComponent = ({ refresh, setSelection, setTotal, + onRenderRow }) => { const memoizedColumns = useDeepMemo(() => columns, [], [columns]); const [sorted, setSorted] = React.useState(sort); @@ -320,6 +322,7 @@ const FluentStoreGrid: React.FunctionComponent = ({ onColumnHeaderClick={onColumnClick} onRenderDetailsHeader={renderDetailsHeader} onColumnResize={columnResize} + onRenderRow={onRenderRow} styles={gridStyles(height)} /> ; @@ -334,7 +337,8 @@ interface FluentGridProps { height?: string, setSelection: (selection: any[]) => void, setTotal: (total: number) => void, - refresh: RefreshTable + refresh: RefreshTable, + onRenderRow?: IRenderFunction } export const FluentGrid: React.FunctionComponent = ({ @@ -346,7 +350,8 @@ export const FluentGrid: React.FunctionComponent = ({ height, setSelection, setTotal, - refresh + refresh, + onRenderRow }) => { const constStore = useConst(() => new Memory(primaryID, alphaNumColumns)); @@ -357,7 +362,7 @@ export const FluentGrid: React.FunctionComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [constStore, data, /*refresh*/]); - return + return ; }; @@ -372,7 +377,8 @@ interface FluentPagedGridProps { height?: string, setSelection: (selection: any[]) => void, setTotal: (total: number) => void, - refresh: RefreshTable + refresh: RefreshTable, + onRenderRow?: IRenderFunction } export const FluentPagedGrid: React.FunctionComponent = ({ @@ -386,7 +392,8 @@ export const FluentPagedGrid: React.FunctionComponent = ({ height, setSelection, setTotal, - refresh + refresh, + onRenderRow }) => { const [page, setPage] = React.useState(pageNum - 1); const [sortBy, setSortBy] = React.useState(sort); @@ -407,7 +414,7 @@ export const FluentPagedGrid: React.FunctionComponent = ({ setPage(_page); }, [pageNum]); - return + return ; }; diff --git a/esp/src/src-react/hooks/platform.ts b/esp/src/src-react/hooks/platform.ts index 6c634aed36e..8e90b4ab363 100644 --- a/esp/src/src-react/hooks/platform.ts +++ b/esp/src/src-react/hooks/platform.ts @@ -6,6 +6,7 @@ import { Topology, WsTopology, WorkunitsServiceEx } from "@hpcc-js/comms"; import { getBuildInfo, BuildInfo, fetchModernMode } from "src/Session"; import { cmake_build_type, containerized, ModernMode } from "src/BuildInfo"; import { sessionKeyValStore, userKeyValStore } from "src/KeyValStore"; +import { Palette } from "@hpcc-js/common"; const logger = scopedLogger("src-react/hooks/platform.ts"); @@ -36,14 +37,18 @@ export function useBuildInfo(): [BuildInfo, { isContainer: boolean, currencyCode return [buildInfo, { isContainer, currencyCode, opsCategory }]; } +let g_targetCluster: Promise; export function useLogicalClusters(): [WsTopology.TpLogicalCluster[] | undefined, WsTopology.TpLogicalCluster | undefined] { const [targetClusters, setTargetClusters] = React.useState(); const [defaultCluster, setDefaultCluster] = React.useState(); React.useEffect(() => { - const topology = Topology.attach({ baseUrl: "" }); + if (!g_targetCluster) { + const topology = Topology.attach({ baseUrl: "" }); + g_targetCluster = topology.fetchLogicalClusters(); + } let active = true; - topology.fetchLogicalClusters().then(response => { + g_targetCluster.then(response => { if (active) { setTargetClusters(response); let firstRow: WsTopology.TpLogicalCluster; @@ -70,6 +75,22 @@ export function useLogicalClusters(): [WsTopology.TpLogicalCluster[] | undefined return [targetClusters, defaultCluster]; } +export function useLogicalClustersPalette(): [WsTopology.TpLogicalCluster[] | undefined, WsTopology.TpLogicalCluster | undefined, Palette.OrdinalPaletteFunc] { + const [targetClusters, defaultCluster] = useLogicalClusters(); + + const palette = useConst(() => Palette.ordinal("workunits", ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"])); + + React.useEffect(() => { + if (targetClusters) { + targetClusters.forEach(cluster => { + palette(cluster.Name); + }); + } + }, [palette, targetClusters]); + + return [targetClusters, defaultCluster, palette]; +} + let wuCheckFeaturesPromise; export const fetchCheckFeatures = () => { if (!wuCheckFeaturesPromise) { diff --git a/esp/src/src/ESPWorkunit.ts b/esp/src/src/ESPWorkunit.ts index 428eb156107..28b2806b2e3 100644 --- a/esp/src/src/ESPWorkunit.ts +++ b/esp/src/src/ESPWorkunit.ts @@ -1085,8 +1085,50 @@ export function CreateWUQueryStore(): BaseStore { + const page = { + start: undefined, + end: undefined + }; + const data = response.Workunits.ECLWorkunit.map(wu => { + const start = Utility.wuidToDateTime(wu.Wuid); + if (!page.start || page.start > start) { + page.start = start; + } + let timePartsSection = 0; + const end = new Date(start); + const timeParts = wu.TotalClusterTime.split(":"); + while (timeParts.length) { + const timePart = timeParts.pop(); + switch (timePartsSection) { + case 0: + end.setSeconds(end.getSeconds() + +timePart); + break; + case 1: + end.setMinutes(end.getMinutes() + +timePart); + break; + case 2: + end.setHours(end.getHours() + +timePart); + break; + case 3: + end.setDate(end.getDate() + +timePart); + break; + } + ++timePartsSection; + } + if (!page.end || page.end < end) { + page.end = end; + } + return { + ...Get(wu.Wuid, wu), + timings: { + start, + end, + page + } + }; + }); return { - data: response.Workunits.ECLWorkunit.map(wu => Get(wu.Wuid, wu)), + data, total: response.NumWUs }; }); diff --git a/esp/src/src/Utility.ts b/esp/src/src/Utility.ts index e8ee8e05db8..cc78350ed6d 100644 --- a/esp/src/src/Utility.ts +++ b/esp/src/src/Utility.ts @@ -1303,4 +1303,8 @@ export function wuidToDate(wuid: string): string { export function wuidToTime(wuid: string): string { return `${wuid.substring(10, 12)}:${wuid.substring(12, 14)}:${wuid.substring(14, 16)}`; +} + +export function wuidToDateTime(wuid: string): Date { + return new Date(`${wuidToDate(wuid)}T${wuidToTime(wuid)}Z`); } \ No newline at end of file