diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx index 54aa822aed..d546472b84 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx @@ -25,6 +25,7 @@ export interface ComponentProps { telemetryAttributes: string[]; }; dashboardStartAndEndDate: RangeStartAndEndDateTime; + refreshTick?: number | undefined; } const DashboardCanvas: FunctionComponent = ( @@ -221,6 +222,7 @@ const DashboardCanvas: FunctionComponent = ( updateComponent(updatedComponent); }} isSelected={isSelected} + refreshTick={props.refreshTick} onClick={() => { // component is selected props.onComponentSelected(componentId); diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx index f9821932f8..6868ade163 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx @@ -2,10 +2,14 @@ import React, { FunctionComponent, ReactElement, useEffect } from "react"; import DashboardTextComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent"; import DashboardChartComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardChartComponent"; import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent"; +import DashboardTableComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent"; +import DashboardGaugeComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent"; import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent"; import DashboardChartComponent from "./DashboardChartComponent"; import DashboardValueComponent from "./DashboardValueComponent"; import DashboardTextComponent from "./DashboardTextComponent"; +import DashboardTableComponent from "./DashboardTableComponent"; +import DashboardGaugeComponent from "./DashboardGaugeComponent"; import DefaultDashboardSize, { GetDashboardComponentHeightInDashboardUnits, GetDashboardComponentWidthInDashboardUnits, @@ -37,6 +41,7 @@ export interface DashboardBaseComponentProps { dashboardViewConfig: DashboardViewConfig; dashboardStartAndEndDate: RangeStartAndEndDateTime; metricTypes: Array; + refreshTick?: number | undefined; } export interface ComponentProps extends DashboardBaseComponentProps { @@ -404,6 +409,22 @@ const DashboardBaseComponentElement: FunctionComponent = ( component={component as DashboardValueComponentType} /> )} + {component.componentType === DashboardComponentType.Table && ( + + )} + {component.componentType === DashboardComponentType.Gauge && ( + + )} {getResizeWidthElement()} {getResizeHeightElement()} diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx index 8230e13d95..62e0b58a98 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx @@ -31,10 +31,24 @@ const DashboardChartComponentElement: FunctionComponent = ( const [error, setError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); + // Resolve query configs - support both single and multi-query + const resolveQueryConfigs: () => Array = () => { + if ( + props.component.arguments.metricQueryConfigs && + props.component.arguments.metricQueryConfigs.length > 0 + ) { + return props.component.arguments.metricQueryConfigs; + } + if (props.component.arguments.metricQueryConfig) { + return [props.component.arguments.metricQueryConfig]; + } + return []; + }; + + const queryConfigs: Array = resolveQueryConfigs(); + const metricViewData: MetricViewData = { - queryConfigs: props.component.arguments.metricQueryConfig - ? [props.component.arguments.metricQueryConfig] - : [], + queryConfigs: queryConfigs, startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( props.dashboardStartAndEndDate, ), @@ -97,24 +111,36 @@ const DashboardChartComponentElement: FunctionComponent = ( useEffect(() => { fetchAggregatedResults(); - }, [props.dashboardStartAndEndDate, props.metricTypes]); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); - const [metricQueryConfig, setMetricQueryConfig] = React.useState< - MetricQueryConfigData | undefined - >(props.component.arguments.metricQueryConfig); + const [prevQueryConfigs, setPrevQueryConfigs] = React.useState< + Array | MetricQueryConfigData | undefined + >( + props.component.arguments.metricQueryConfigs || + props.component.arguments.metricQueryConfig, + ); useEffect(() => { - // set metricQueryConfig to the new value only if it is different from the previous value + const currentConfigs: + | Array + | MetricQueryConfigData + | undefined = + props.component.arguments.metricQueryConfigs || + props.component.arguments.metricQueryConfig; + if ( JSONFunctions.isJSONObjectDifferent( - metricQueryConfig || {}, - props.component.arguments.metricQueryConfig || {}, + prevQueryConfigs || {}, + currentConfigs || {}, ) ) { - setMetricQueryConfig(props.component.arguments.metricQueryConfig); + setPrevQueryConfigs(currentConfigs); fetchAggregatedResults(); } - }, [props.component.arguments.metricQueryConfig]); + }, [ + props.component.arguments.metricQueryConfig, + props.component.arguments.metricQueryConfigs, + ]); useEffect(() => { fetchAggregatedResults(); @@ -142,35 +168,57 @@ const DashboardChartComponentElement: FunctionComponent = ( heightOfChart = undefined; } - // add title and description. - type GetMetricChartType = () => MetricChartType; - // Convert dashboard chart type to metric chart type const getMetricChartType: GetMetricChartType = (): MetricChartType => { if (props.component.arguments.chartType === DashboardChartType.Bar) { return MetricChartType.BAR; } + if ( + props.component.arguments.chartType === DashboardChartType.Area || + props.component.arguments.chartType === DashboardChartType.StackedArea + ) { + return MetricChartType.AREA; + } return MetricChartType.LINE; }; const chartMetricViewData: MetricViewData = { - queryConfigs: props.component.arguments.metricQueryConfig - ? [ - { - ...props.component.arguments.metricQueryConfig!, + queryConfigs: queryConfigs.map( + (config: MetricQueryConfigData, index: number) => { + // For the first query, apply the chart-level title/description/legend + if (index === 0) { + return { + ...config, metricAliasData: { - title: props.component.arguments.chartTitle || undefined, + title: + config.metricAliasData?.title || + props.component.arguments.chartTitle || + undefined, description: - props.component.arguments.chartDescription || undefined, - metricVariable: undefined, - legend: props.component.arguments.legendText || undefined, - legendUnit: props.component.arguments.legendUnit || undefined, + config.metricAliasData?.description || + props.component.arguments.chartDescription || + undefined, + metricVariable: + config.metricAliasData?.metricVariable || undefined, + legend: + config.metricAliasData?.legend || + props.component.arguments.legendText || + undefined, + legendUnit: + config.metricAliasData?.legendUnit || + props.component.arguments.legendUnit || + undefined, }, - chartType: getMetricChartType(), - }, - ] - : [], + chartType: config.chartType || getMetricChartType(), + }; + } + return { + ...config, + chartType: config.chartType || getMetricChartType(), + }; + }, + ), startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( props.dashboardStartAndEndDate, ), diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx new file mode 100644 index 0000000000..29f3a5932d --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx @@ -0,0 +1,264 @@ +import React, { FunctionComponent, ReactElement, useEffect } from "react"; +import DashboardGaugeComponent from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent"; +import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricUtil from "../../Metrics/Utils/Metrics"; +import API from "Common/UI/Utils/API/API"; +import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader"; +import JSONFunctions from "Common/Types/JSONFunctions"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime"; + +export interface ComponentProps extends DashboardBaseComponentProps { + component: DashboardGaugeComponent; +} + +const DashboardGaugeComponentElement: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [metricResults, setMetricResults] = React.useState< + Array + >([]); + const [aggregationType, setAggregationType] = + React.useState(AggregationType.Avg); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + + const metricViewData: MetricViewData = { + queryConfigs: props.component.arguments.metricQueryConfig + ? [props.component.arguments.metricQueryConfig] + : [], + startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( + props.dashboardStartAndEndDate, + ), + formulaConfigs: [], + }; + + const fetchAggregatedResults: PromiseVoidFunction = + async (): Promise => { + setIsLoading(true); + + if ( + !metricViewData.startAndEndDate?.startValue || + !metricViewData.startAndEndDate?.endValue + ) { + setIsLoading(false); + return; + } + + if ( + !metricViewData.queryConfigs || + metricViewData.queryConfigs.length === 0 || + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData) + .length === 0 + ) { + setIsLoading(false); + return; + } + + if ( + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + !metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType + ) { + setIsLoading(false); + return; + } + + setAggregationType( + (metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType as AggregationType) || AggregationType.Avg, + ); + + try { + const results: Array = await MetricUtil.fetchResults({ + metricViewData: metricViewData, + }); + + setMetricResults(results); + setError(""); + } catch (err: unknown) { + setError(API.getFriendlyErrorMessage(err as Error)); + } + + setIsLoading(false); + }; + + const [metricQueryConfig, setMetricQueryConfig] = React.useState< + MetricQueryConfigData | undefined + >(props.component.arguments.metricQueryConfig); + + useEffect(() => { + fetchAggregatedResults(); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); + + useEffect(() => { + fetchAggregatedResults(); + }, []); + + useEffect(() => { + if ( + JSONFunctions.isJSONObjectDifferent( + metricQueryConfig || {}, + props.component.arguments.metricQueryConfig || {}, + ) + ) { + setMetricQueryConfig(props.component.arguments.metricQueryConfig); + fetchAggregatedResults(); + } + }, [props.component.arguments.metricQueryConfig]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + // Calculate aggregated value + let aggregatedValue: number = 0; + let avgCount: number = 0; + + for (const result of metricResults) { + for (const item of result.data) { + const value: number = item.value; + + if (aggregationType === AggregationType.Avg) { + aggregatedValue += value; + avgCount += 1; + } else if (aggregationType === AggregationType.Sum) { + aggregatedValue += value; + } else if (aggregationType === AggregationType.Min) { + aggregatedValue = Math.min(aggregatedValue, value); + } else if (aggregationType === AggregationType.Max) { + aggregatedValue = Math.max(aggregatedValue, value); + } else if (aggregationType === AggregationType.Count) { + aggregatedValue += 1; + } + } + } + + if (aggregationType === AggregationType.Avg && avgCount > 0) { + aggregatedValue = aggregatedValue / avgCount; + } + + aggregatedValue = Math.round(aggregatedValue * 100) / 100; + + const minValue: number = props.component.arguments.minValue ?? 0; + const maxValue: number = props.component.arguments.maxValue ?? 100; + const warningThreshold: number | undefined = + props.component.arguments.warningThreshold; + const criticalThreshold: number | undefined = + props.component.arguments.criticalThreshold; + + // Calculate percentage for the gauge arc + const range: number = maxValue - minValue; + const percentage: number = + range > 0 + ? Math.min(Math.max((aggregatedValue - minValue) / range, 0), 1) + : 0; + + // Determine color based on thresholds + let gaugeColor: string = "#10b981"; // green + if ( + criticalThreshold !== undefined && + aggregatedValue >= criticalThreshold + ) { + gaugeColor = "#ef4444"; // red + } else if ( + warningThreshold !== undefined && + aggregatedValue >= warningThreshold + ) { + gaugeColor = "#f59e0b"; // yellow + } + + // SVG gauge rendering + const size: number = Math.min( + props.dashboardComponentWidthInPx - 20, + props.dashboardComponentHeightInPx - 50, + ); + const gaugeSize: number = Math.max(size, 60); + const strokeWidth: number = Math.max(gaugeSize * 0.12, 8); + const radius: number = (gaugeSize - strokeWidth) / 2; + const centerX: number = gaugeSize / 2; + const centerY: number = gaugeSize / 2; + + // Semi-circle arc (180 degrees, from left to right) + const startAngle: number = Math.PI; + const endAngle: number = 0; + const sweepAngle: number = startAngle - endAngle; + const currentAngle: number = startAngle - sweepAngle * percentage; + + const arcStartX: number = centerX + radius * Math.cos(startAngle); + const arcStartY: number = centerY - radius * Math.sin(startAngle); + const arcEndX: number = centerX + radius * Math.cos(endAngle); + const arcEndY: number = centerY - radius * Math.sin(endAngle); + const arcCurrentX: number = centerX + radius * Math.cos(currentAngle); + const arcCurrentY: number = centerY - radius * Math.sin(currentAngle); + + const backgroundPath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 0 1 ${arcEndX} ${arcEndY}`; + const valuePath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${percentage > 0.5 ? 1 : 0} 1 ${arcCurrentX} ${arcCurrentY}`; + + const titleHeightInPx: number = Math.max( + props.dashboardComponentHeightInPx * 0.1, + 12, + ); + const valueHeightInPx: number = Math.max(gaugeSize * 0.2, 14); + + return ( +
+ {props.component.arguments.gaugeTitle && ( +
0 ? `${titleHeightInPx}px` : "", + }} + className="text-center font-semibold text-gray-700 mb-1 truncate" + > + {props.component.arguments.gaugeTitle} +
+ )} + + + {percentage > 0 && ( + + )} + +
0 ? `${valueHeightInPx}px` : "", + marginTop: `-${gaugeSize * 0.15}px`, + }} + > + {aggregatedValue} +
+
+ ); +}; + +export default DashboardGaugeComponentElement; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx new file mode 100644 index 0000000000..f5721be604 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx @@ -0,0 +1,191 @@ +import React, { FunctionComponent, ReactElement, useEffect } from "react"; +import DashboardTableComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent"; +import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult"; +import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricUtil from "../../Metrics/Utils/Metrics"; +import API from "Common/UI/Utils/API/API"; +import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader"; +import JSONFunctions from "Common/Types/JSONFunctions"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import Icon from "Common/UI/Components/Icon/Icon"; +import IconProp from "Common/Types/Icon/IconProp"; +import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime"; +import OneUptimeDate from "Common/Types/Date"; + +export interface ComponentProps extends DashboardBaseComponentProps { + component: DashboardTableComponent; +} + +const DashboardTableComponentElement: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [metricResults, setMetricResults] = React.useState< + Array + >([]); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + + const metricViewData: MetricViewData = { + queryConfigs: props.component.arguments.metricQueryConfig + ? [props.component.arguments.metricQueryConfig] + : [], + startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( + props.dashboardStartAndEndDate, + ), + formulaConfigs: [], + }; + + const fetchAggregatedResults: PromiseVoidFunction = + async (): Promise => { + setIsLoading(true); + + if ( + !metricViewData.startAndEndDate?.startValue || + !metricViewData.startAndEndDate?.endValue + ) { + setIsLoading(false); + setError("Please select a valid start and end date."); + return; + } + + if ( + !metricViewData.queryConfigs || + metricViewData.queryConfigs.length === 0 || + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData) + .length === 0 + ) { + setIsLoading(false); + setError("Please select a metric. Click here to add a metric."); + return; + } + + if ( + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + !metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType + ) { + setIsLoading(false); + setError( + "Please select an aggregation. Click here to add an aggregation.", + ); + return; + } + + try { + const results: Array = await MetricUtil.fetchResults({ + metricViewData: metricViewData, + }); + + setMetricResults(results); + setError(""); + } catch (err: unknown) { + setError(API.getFriendlyErrorMessage(err as Error)); + } + + setIsLoading(false); + }; + + useEffect(() => { + fetchAggregatedResults(); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); + + const [metricQueryConfig, setMetricQueryConfig] = React.useState< + MetricQueryConfigData | undefined + >(props.component.arguments.metricQueryConfig); + + useEffect(() => { + if ( + JSONFunctions.isJSONObjectDifferent( + metricQueryConfig || {}, + props.component.arguments.metricQueryConfig || {}, + ) + ) { + setMetricQueryConfig(props.component.arguments.metricQueryConfig); + fetchAggregatedResults(); + } + }, [props.component.arguments.metricQueryConfig]); + + useEffect(() => { + fetchAggregatedResults(); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+
+ +
+ +
+ ); + } + + const maxRows: number = props.component.arguments.maxRows || 20; + + const allData: Array = []; + for (const result of metricResults) { + for (const item of result.data) { + allData.push(item); + } + } + + const displayData: Array = allData.slice(0, maxRows); + + return ( +
+ {props.component.arguments.tableTitle && ( +
+ {props.component.arguments.tableTitle} +
+ )} + + + + + + + + + {displayData.map((item: AggregatedModel, index: number) => { + return ( + + + + + ); + })} + {displayData.length === 0 && ( + + + + )} + +
TimestampValue
+ {OneUptimeDate.getDateAsLocalFormattedString( + OneUptimeDate.fromString(item.timestamp), + )} + + {Math.round(item.value * 100) / 100} +
+ No data available +
+
+ ); +}; + +export default DashboardTableComponentElement; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx index 05f65b34ea..a6ee3e23e9 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, ReactElement } from "react"; import DashboardTextComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent"; import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import LazyMarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer"; export interface ComponentProps extends DashboardBaseComponentProps { component: DashboardTextComponent; @@ -9,6 +10,14 @@ export interface ComponentProps extends DashboardBaseComponentProps { const DashboardTextComponentElement: FunctionComponent = ( props: ComponentProps, ): ReactElement => { + if (props.component.arguments.isMarkdown) { + return ( +
+ +
+ ); + } + const textClassName: string = `m-auto truncate flex flex-col justify-center h-full ${props.component.arguments.isBold ? "font-medium" : ""} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline" : ""}`; const textHeightInxPx: number = props.dashboardComponentHeightInPx * 0.4; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx index 8e1d0fd6df..25dce6daa3 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx @@ -99,7 +99,7 @@ const DashboardValueComponent: FunctionComponent = ( useEffect(() => { fetchAggregatedResults(); - }, [props.dashboardStartAndEndDate, props.metricTypes]); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); useEffect(() => { fetchAggregatedResults(); @@ -173,8 +173,30 @@ const DashboardValueComponent: FunctionComponent = ( ); })?.unit || ""; + // Determine color based on thresholds + let valueColorClass: string = "text-gray-800"; + let bgColorClass: string = ""; + const warningThreshold: number | undefined = + props.component.arguments.warningThreshold; + const criticalThreshold: number | undefined = + props.component.arguments.criticalThreshold; + + if ( + criticalThreshold !== undefined && + aggregatedValue >= criticalThreshold + ) { + valueColorClass = "text-red-700"; + bgColorClass = "bg-red-50"; + } else if ( + warningThreshold !== undefined && + aggregatedValue >= warningThreshold + ) { + valueColorClass = "text-yellow-700"; + bgColorClass = "bg-yellow-50"; + } + return ( -
+
0 ? `${titleHeightInPx}px` : "", @@ -184,7 +206,7 @@ const DashboardValueComponent: FunctionComponent = ( {props.component.arguments.title || " "}
0 ? `${valueHeightInPx}px` : "", }} diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx index a8d5c0a607..5e4f7fa69f 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, ReactElement, + useCallback, useEffect, useRef, useState, @@ -9,12 +10,17 @@ import DashboardToolbar from "./Toolbar/DashboardToolbar"; import DashboardCanvas from "./Canvas/Index"; import DashboardMode from "Common/Types/Dashboard/DashboardMode"; import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType"; -import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardViewConfig, { + AutoRefreshInterval, + getAutoRefreshIntervalInMs, +} from "Common/Types/Dashboard/DashboardViewConfig"; import { ObjectType } from "Common/Types/JSON"; import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent"; import DashboardChartComponentUtil from "Common/Utils/Dashboard/Components/DashboardChartComponent"; import DashboardValueComponentUtil from "Common/Utils/Dashboard/Components/DashboardValueComponent"; import DashboardTextComponentUtil from "Common/Utils/Dashboard/Components/DashboardTextComponent"; +import DashboardTableComponentUtil from "Common/Utils/Dashboard/Components/DashboardTableComponent"; +import DashboardGaugeComponentUtil from "Common/Utils/Dashboard/Components/DashboardGaugeComponent"; import BadDataException from "Common/Types/Exception/BadDataException"; import ObjectID from "Common/Types/ObjectID"; import Dashboard from "Common/Models/DatabaseModels/Dashboard"; @@ -30,6 +36,7 @@ import MetricUtil from "../Metrics/Utils/Metrics"; import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime"; import TimeRange from "Common/Types/Time/TimeRange"; import MetricType from "Common/Models/DatabaseModels/MetricType"; +import DashboardVariable from "Common/Types/Dashboard/DashboardVariable"; export interface ComponentProps { dashboardId: ObjectID; @@ -49,6 +56,23 @@ const DashboardViewer: FunctionComponent = ( const [isSaving, setIsSaving] = useState(false); + // Auto-refresh state + const [autoRefreshInterval, setAutoRefreshInterval] = + useState(AutoRefreshInterval.OFF); + const [isRefreshing, setIsRefreshing] = useState(false); + const [dashboardVariables, setDashboardVariables] = useState< + Array + >([]); + + // Zoom stack for time range + const [timeRangeStack, setTimeRangeStack] = useState< + Array + >([]); + const autoRefreshTimerRef: React.MutableRefObject | null> = useRef | null>(null); + const [refreshTick, setRefreshTick] = useState(0); + // ref for dashboard div. const dashboardViewRef: React.RefObject = @@ -140,13 +164,23 @@ const DashboardViewer: FunctionComponent = ( return; } - setDashboardViewConfig( - JSONFunctions.deserializeValue( - dashboard.dashboardViewConfig || - DashboardViewConfigUtil.createDefaultDashboardViewConfig(), - ) as DashboardViewConfig, - ); + const config: DashboardViewConfig = JSONFunctions.deserializeValue( + dashboard.dashboardViewConfig || + DashboardViewConfigUtil.createDefaultDashboardViewConfig(), + ) as DashboardViewConfig; + + setDashboardViewConfig(config); setDashboardName(dashboard.name || "Untitled Dashboard"); + + // Restore saved auto-refresh interval + if (config.refreshInterval) { + setAutoRefreshInterval(config.refreshInterval); + } + + // Restore saved variables + if (config.variables) { + setDashboardVariables(config.variables); + } }; const loadPage: PromiseVoidFunction = async (): Promise => { @@ -169,6 +203,47 @@ const DashboardViewer: FunctionComponent = ( }); }, []); + // Auto-refresh timer management + const triggerRefresh: () => void = useCallback(() => { + setIsRefreshing(true); + setRefreshTick((prev: number) => { + return prev + 1; + }); + // Brief indicator + setTimeout(() => { + setIsRefreshing(false); + }, 500); + }, []); + + useEffect(() => { + // Clear existing timer + if (autoRefreshTimerRef.current) { + clearInterval(autoRefreshTimerRef.current); + autoRefreshTimerRef.current = null; + } + + // Don't auto-refresh in edit mode + if (dashboardMode === DashboardMode.Edit) { + return; + } + + const intervalMs: number | null = + getAutoRefreshIntervalInMs(autoRefreshInterval); + + if (intervalMs !== null) { + autoRefreshTimerRef.current = setInterval(() => { + triggerRefresh(); + }, intervalMs); + } + + return () => { + if (autoRefreshTimerRef.current) { + clearInterval(autoRefreshTimerRef.current); + autoRefreshTimerRef.current = null; + } + }; + }, [autoRefreshInterval, dashboardMode, triggerRefresh]); + const isEditMode: boolean = dashboardMode === DashboardMode.Edit; const sideBarWidth: number = isEditMode && selectedComponentId ? 650 : 0; @@ -219,15 +294,33 @@ const DashboardViewer: FunctionComponent = ( dashboardName={dashboardName} isSaving={isSaving} onSaveClick={() => { + // Save auto-refresh interval with the config + const configWithRefresh: DashboardViewConfig = { + ...dashboardViewConfig, + refreshInterval: autoRefreshInterval, + }; + setDashboardViewConfig(configWithRefresh); + saveDashboardViewConfig().catch((err: Error) => { setError(API.getFriendlyErrorMessage(err)); }); setDashboardMode(DashboardMode.View); }} startAndEndDate={startAndEndDate} + canResetZoom={timeRangeStack.length > 0} + onResetZoom={() => { + if (timeRangeStack.length > 0) { + const previousRange: RangeStartAndEndDateTime = + timeRangeStack[timeRangeStack.length - 1]!; + setStartAndEndDate(previousRange); + setTimeRangeStack(timeRangeStack.slice(0, -1)); + } + }} onStartAndEndDateChange={( newStartAndEndDate: RangeStartAndEndDateTime, ) => { + // Push current range to zoom stack before changing + setTimeRangeStack([...timeRangeStack, startAndEndDate]); setStartAndEndDate(newStartAndEndDate); }} onCancelEditClick={async () => { @@ -238,6 +331,26 @@ const DashboardViewer: FunctionComponent = ( onEditClick={() => { setDashboardMode(DashboardMode.Edit); }} + autoRefreshInterval={autoRefreshInterval} + onAutoRefreshIntervalChange={(interval: AutoRefreshInterval) => { + setAutoRefreshInterval(interval); + }} + isRefreshing={isRefreshing} + variables={dashboardVariables} + onVariableValueChange={(variableId: string, value: string) => { + const updatedVariables: Array = + dashboardVariables.map((v: DashboardVariable) => { + if (v.id === variableId) { + return { ...v, selectedValue: value }; + } + return v; + }); + setDashboardVariables(updatedVariables); + // Trigger refresh when variable changes + setRefreshTick((prev: number) => { + return prev + 1; + }); + }} onAddComponentClick={(componentType: DashboardComponentType) => { let newComponent: DashboardBaseComponent | null = null; @@ -253,6 +366,14 @@ const DashboardViewer: FunctionComponent = ( newComponent = DashboardTextComponentUtil.getDefaultComponent(); } + if (componentType === DashboardComponentType.Table) { + newComponent = DashboardTableComponentUtil.getDefaultComponent(); + } + + if (componentType === DashboardComponentType.Gauge) { + newComponent = DashboardGaugeComponentUtil.getDefaultComponent(); + } + if (!newComponent) { throw new BadDataException( `Unknown component type: ${componentType}`, @@ -291,6 +412,7 @@ const DashboardViewer: FunctionComponent = ( telemetryAttributes, metricTypes, }} + refreshTick={refreshTick} />
diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx index 96ba7d8660..48fb0415ea 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx @@ -7,9 +7,14 @@ import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem"; import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType"; import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime"; import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView"; -import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardViewConfig, { + AutoRefreshInterval, + getAutoRefreshIntervalLabel, +} from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardVariable from "Common/Types/Dashboard/DashboardVariable"; import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; import Loader from "Common/UI/Components/Loader/Loader"; +import DashboardVariableSelector from "./DashboardVariableSelector"; export interface ComponentProps { onEditClick: () => void; @@ -23,6 +28,13 @@ export interface ComponentProps { startAndEndDate: RangeStartAndEndDateTime; onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void; dashboardViewConfig: DashboardViewConfig; + autoRefreshInterval: AutoRefreshInterval; + onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void; + isRefreshing?: boolean | undefined; + variables?: Array | undefined; + onVariableValueChange?: ((variableId: string, value: string) => void) | undefined; + canResetZoom?: boolean | undefined; + onResetZoom?: (() => void) | undefined; } const DashboardToolbar: FunctionComponent = ( @@ -58,6 +70,66 @@ const DashboardToolbar: FunctionComponent = (
)} + {/* Template variables */} + {props.variables && + props.variables.length > 0 && + props.onVariableValueChange && ( +
+ +
+ )} + + {/* Reset Zoom button */} + {props.canResetZoom && props.onResetZoom && !isEditMode && ( +