feat: Add dashboard template selection and creation functionality

- Introduced DashboardTemplateCard component for displaying dashboard templates.
- Added DashboardTemplates enum and DashboardTemplate interface to define available templates.
- Implemented template selection in the Dashboards page to allow users to create dashboards from predefined templates.
- Enhanced MetricQueryConfig to manage metric attributes and display settings more effectively.
- Updated MetricView to improve loading states and error handling for metric results.
- Refactored DashboardChartComponent to streamline metric alias data handling and improve UI presentation.
This commit is contained in:
Nawaz Dhandala
2026-03-30 14:54:54 +01:00
parent 3a19e600d5
commit 796c52da4d
7 changed files with 917 additions and 272 deletions

View File

@@ -209,22 +209,14 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
const chartMetricViewData: MetricViewData = {
queryConfigs: queryConfigs.map(
(config: MetricQueryConfigData, index: number) => {
(config: MetricQueryConfigData) => {
return {
...config,
metricAliasData: {
metricVariable:
config.metricAliasData?.metricVariable || undefined,
title:
(index === 0
? config.metricAliasData?.title ||
props.component.arguments.chartTitle
: config.metricAliasData?.title) || undefined,
description:
(index === 0
? config.metricAliasData?.description ||
props.component.arguments.chartDescription
: config.metricAliasData?.description) || undefined,
title: config.metricAliasData?.title || undefined,
description: config.metricAliasData?.description || undefined,
legend: config.metricAliasData?.legend || undefined,
legendUnit: config.metricAliasData?.legendUnit || undefined,
},
@@ -246,6 +238,21 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
transition: "opacity 0.2s ease-in-out",
}}
>
{(props.component.arguments.chartTitle ||
props.component.arguments.chartDescription) && (
<div className="px-2 pt-2 pb-1">
{props.component.arguments.chartTitle && (
<h3 className="text-sm font-semibold text-gray-700 tracking-tight">
{props.component.arguments.chartTitle}
</h3>
)}
{props.component.arguments.chartDescription && (
<p className="mt-0.5 text-xs text-gray-400">
{props.component.arguments.chartDescription}
</p>
)}
</div>
)}
<MetricCharts
metricResults={metricResults}
metricTypes={props.metricTypes}

View File

@@ -0,0 +1,40 @@
import IconProp from "Common/Types/Icon/IconProp";
import Icon, { SizeProp } from "Common/UI/Components/Icon/Icon";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
title: string;
description: string;
icon: IconProp;
onClick: () => void;
}
const DashboardTemplateCard: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<div
className="cursor-pointer border border-gray-200 rounded-lg p-4 hover:border-indigo-500 hover:shadow-md transition-all duration-200 bg-white"
onClick={props.onClick}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
props.onClick();
}
}}
>
<div className="flex items-center mb-2">
<div className="flex-shrink-0 mr-3 text-indigo-500">
<Icon icon={props.icon} size={SizeProp.Large} />
</div>
<h3 className="text-sm font-semibold text-gray-900">{props.title}</h3>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{props.description}
</p>
</div>
);
};
export default DashboardTemplateCard;

View File

@@ -8,6 +8,7 @@ export interface ComponentProps {
data: MetricAliasData;
isFormula: boolean;
onDataChanged: (data: MetricAliasData) => void;
hideVariableBadge?: boolean | undefined;
}
const MetricAlias: FunctionComponent<ComponentProps> = (
@@ -16,25 +17,26 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
return (
<Fragment>
<div className="space-y-3">
{/* Variable badge row */}
{((!props.isFormula && props.data.metricVariable) ||
props.isFormula) && (
<div className="flex items-center space-x-2">
{!props.isFormula && props.data.metricVariable && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
{props.data.metricVariable}
</div>
)}
{props.isFormula && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
</div>
)}
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Display Settings
</span>
</div>
)}
{/* Variable badge row — hidden when parent already shows it */}
{!props.hideVariableBadge &&
((!props.isFormula && props.data.metricVariable) ||
props.isFormula) && (
<div className="flex items-center space-x-2">
{!props.isFormula && props.data.metricVariable && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
{props.data.metricVariable}
</div>
)}
{props.isFormula && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
</div>
)}
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Display Settings
</span>
</div>
)}
{/* Title and Description */}
<div className="grid grid-cols-2 gap-3">

View File

@@ -1,17 +1,15 @@
import React, { FunctionComponent, ReactElement } from "react";
import React, { FunctionComponent, ReactElement, useState } from "react";
import MetricAlias from "./MetricAlias";
import MetricQuery from "./MetricQuery";
import Card from "Common/UI/Components/Card/Card";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import MetricAliasData from "Common/Types/Metrics/MetricAliasData";
import MetricQueryData from "Common/Types/Metrics/MetricQueryData";
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import Input, { InputType } from "Common/UI/Components/Input/Input";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import Dictionary from "Common/Types/Dictionary";
export interface ComponentProps {
data: MetricQueryConfigData;
@@ -35,6 +33,10 @@ export interface ComponentProps {
const MetricGraphConfig: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [showDisplaySettings, setShowDisplaySettings] =
useState<boolean>(false);
const defaultAliasData: MetricAliasData = {
metricVariable: undefined,
title: undefined,
@@ -43,139 +45,365 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
legendUnit: undefined,
};
const getContent: GetReactElementFunction = (): ReactElement => {
// Compute active attribute count for the header summary
const attributes: Dictionary<string | number | boolean> | undefined = (
props.data?.metricQueryData?.filterData as Record<string, unknown>
)?.["attributes"] as Dictionary<string | number | boolean> | undefined;
const activeAttributeCount: number = attributes
? Object.keys(attributes).length
: 0;
const metricName: string =
props.data?.metricQueryData?.filterData?.metricName?.toString() ||
"No metric selected";
const aggregationType: string =
props.data?.metricQueryData?.filterData?.aggegationType?.toString() ||
"Avg";
// Remove a single attribute filter
const handleRemoveAttribute: (key: string) => void = (
key: string,
): void => {
if (!attributes) {
return;
}
const newAttributes: Dictionary<string | number | boolean> = {
...attributes,
};
delete newAttributes[key];
const newFilterData: Record<string, unknown> = {
...(props.data.metricQueryData.filterData as Record<string, unknown>),
};
if (Object.keys(newAttributes).length > 0) {
newFilterData["attributes"] = newAttributes;
} else {
delete newFilterData["attributes"];
}
if (props.onChange) {
props.onChange({
...props.data,
metricQueryData: {
...props.data.metricQueryData,
filterData: newFilterData as MetricQueryData["filterData"],
},
});
}
};
// Clear all attribute filters
const handleClearAllAttributes: () => void = (): void => {
const newFilterData: Record<string, unknown> = {
...(props.data.metricQueryData.filterData as Record<string, unknown>),
};
delete newFilterData["attributes"];
if (props.onChange) {
props.onChange({
...props.data,
metricQueryData: {
...props.data.metricQueryData,
filterData: newFilterData as MetricQueryData["filterData"],
},
});
}
};
const getHeader: () => ReactElement = (): ReactElement => {
return (
<div className="space-y-4">
{/* Metric query selection — always on top */}
{props.data?.metricQueryData && (
<MetricQuery
data={props.data?.metricQueryData || {}}
onDataChanged={(data: MetricQueryData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
const selectedMetricName: string | undefined =
data.filterData?.metricName?.toString();
const previousMetricName: string | undefined =
props.data?.metricQueryData?.filterData?.metricName?.toString();
// If metric changed, prefill all alias fields from MetricType
if (
selectedMetricName &&
selectedMetricName !== previousMetricName
) {
const metricType: MetricType | undefined =
props.metricTypes.find((m: MetricType) => {
return m.name === selectedMetricName;
});
if (metricType) {
const currentAlias: MetricAliasData =
props.data.metricAliasData || defaultAliasData;
props.onChange({
...props.data,
metricQueryData: data,
metricAliasData: {
...currentAlias,
title: metricType.name || "",
description: metricType.description || "",
legend: metricType.name || "",
legendUnit: metricType.unit || "",
},
});
return;
}
}
props.onChange({ ...props.data, metricQueryData: data });
}
}}
metricTypes={props.metricTypes}
telemetryAttributes={props.telemetryAttributes}
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
isAttributesLoading={props.attributesLoading}
attributesError={props.attributesError}
onAttributesRetry={props.onAttributesRetry}
/>
)}
{/* Display settings — title, description, legend, unit */}
<div className="border-t border-gray-200 pt-3">
<MetricAlias
data={props.data?.metricAliasData || defaultAliasData}
onDataChanged={(data: MetricAliasData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({ ...props.data, metricAliasData: data });
}
}}
isFormula={false}
/>
</div>
{/* Thresholds */}
<div className="flex space-x-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">
Warning Threshold
</label>
<Input
value={props.data?.warningThreshold?.toString() || ""}
type={InputType.NUMBER}
onChange={(value: string) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({
...props.data,
warningThreshold: value ? Number(value) : undefined,
});
}
}}
placeholder="e.g. 80"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">
Critical Threshold
</label>
<Input
value={props.data?.criticalThreshold?.toString() || ""}
type={InputType.NUMBER}
onChange={(value: string) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({
...props.data,
criticalThreshold: value ? Number(value) : undefined,
});
}
}}
placeholder="e.g. 95"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Variable badge */}
{props.data?.metricAliasData?.metricVariable && (
<div className="bg-indigo-500 h-8 w-8 min-w-8 rounded-lg flex items-center justify-center text-sm font-bold text-white shadow-sm">
{props.data.metricAliasData.metricVariable}
</div>
)}
{/* Summary info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900 truncate">
{metricName}
</span>
<span className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
{aggregationType}
</span>
{activeAttributeCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-xs font-medium text-indigo-700">
<Icon
icon={IconProp.Filter}
className="h-3 w-3 text-indigo-500"
/>
{activeAttributeCount}{" "}
{activeAttributeCount === 1 ? "filter" : "filters"}
</span>
)}
</div>
{props.data?.metricAliasData?.title &&
props.data.metricAliasData.title !== metricName && (
<p className="text-xs text-gray-500 mt-0.5 truncate">
{props.data.metricAliasData.title}
</p>
)}
</div>
</div>
{/* Remove button */}
{props.onRemove && (
<div>
<Button
title={"Remove"}
{/* Action buttons */}
<div className="flex items-center gap-1 ml-3">
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onClick={() => {
setIsExpanded(!isExpanded);
}}
title={isExpanded ? "Collapse" : "Expand"}
>
<Icon
icon={isExpanded ? IconProp.ChevronUp : IconProp.ChevronDown}
className="h-4 w-4"
/>
</button>
{props.onRemove && (
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500"
onClick={() => {
props.onBlur?.();
props.onFocus?.();
return props.onRemove?.();
}}
buttonSize={ButtonSize.Small}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
/>
title="Remove query"
>
<Icon icon={IconProp.Trash} className="h-4 w-4" />
</button>
)}
</div>
</div>
);
};
const getAttributeChips: () => ReactElement | null = (): ReactElement | null => {
if (!attributes || activeAttributeCount === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-1.5 mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400 font-medium mr-1">
Filtered by:
</span>
{Object.entries(attributes).map(
([key, value]: [string, string | number | boolean]) => {
return (
<span
key={key}
className="inline-flex items-center gap-1 rounded-md border border-indigo-200 bg-indigo-50 py-0.5 pl-2 pr-1 text-xs text-indigo-700"
>
<span className="font-medium text-indigo-500">{key}:</span>
<span>{String(value)}</span>
<button
type="button"
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-indigo-400 transition-colors hover:bg-indigo-100 hover:text-indigo-600"
onClick={() => {
handleRemoveAttribute(key);
}}
title={`Remove ${key}: ${String(value)}`}
>
<Icon icon={IconProp.Close} className="h-2.5 w-2.5" />
</button>
</span>
);
},
)}
{activeAttributeCount > 1 && (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onClick={handleClearAllAttributes}
>
Clear all
</button>
)}
</div>
);
};
const getContent: () => ReactElement = (): ReactElement => {
return (
<div>
{/* Header with summary */}
{getHeader()}
{/* Attribute filter chips - always visible */}
{!isExpanded && getAttributeChips()}
{/* Expandable content */}
{isExpanded && (
<div className="mt-4 space-y-4">
{/* Metric query selection */}
{props.data?.metricQueryData && (
<MetricQuery
data={props.data?.metricQueryData || {}}
onDataChanged={(data: MetricQueryData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
const selectedMetricName: string | undefined =
data.filterData?.metricName?.toString();
const previousMetricName: string | undefined =
props.data?.metricQueryData?.filterData?.metricName?.toString();
// If metric changed, prefill all alias fields from MetricType
if (
selectedMetricName &&
selectedMetricName !== previousMetricName
) {
const metricType: MetricType | undefined =
props.metricTypes.find((m: MetricType) => {
return m.name === selectedMetricName;
});
if (metricType) {
const currentAlias: MetricAliasData =
props.data.metricAliasData || defaultAliasData;
props.onChange({
...props.data,
metricQueryData: data,
metricAliasData: {
...currentAlias,
title: metricType.name || "",
description: metricType.description || "",
legend: metricType.name || "",
legendUnit: metricType.unit || "",
},
});
return;
}
}
props.onChange({ ...props.data, metricQueryData: data });
}
}}
metricTypes={props.metricTypes}
telemetryAttributes={props.telemetryAttributes}
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
isAttributesLoading={props.attributesLoading}
attributesError={props.attributesError}
onAttributesRetry={props.onAttributesRetry}
/>
)}
{/* Attribute filter chips */}
{getAttributeChips()}
{/* Display Settings - collapsible */}
<div className="border-t border-gray-200 pt-3">
<button
type="button"
className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wide hover:text-gray-700 transition-colors w-full"
onClick={() => {
setShowDisplaySettings(!showDisplaySettings);
}}
>
<Icon
icon={
showDisplaySettings
? IconProp.ChevronDown
: IconProp.ChevronRight
}
className="h-3 w-3"
/>
<span>Display Settings</span>
{(props.data?.metricAliasData?.title ||
props.data?.warningThreshold !== undefined ||
props.data?.criticalThreshold !== undefined) && (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-indigo-400" />
)}
</button>
{showDisplaySettings && (
<div className="mt-3 space-y-4">
<MetricAlias
data={props.data?.metricAliasData || defaultAliasData}
onDataChanged={(data: MetricAliasData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({
...props.data,
metricAliasData: data,
});
}
}}
isFormula={false}
hideVariableBadge={true}
/>
{/* Thresholds */}
<div className="flex space-x-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">
Warning Threshold
</label>
<Input
value={
props.data?.warningThreshold?.toString() || ""
}
type={InputType.NUMBER}
onChange={(value: string) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({
...props.data,
warningThreshold: value
? Number(value)
: undefined,
});
}
}}
placeholder="e.g. 80"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">
Critical Threshold
</label>
<Input
value={
props.data?.criticalThreshold?.toString() || ""
}
type={InputType.NUMBER}
onChange={(value: string) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({
...props.data,
criticalThreshold: value
? Number(value)
: undefined,
});
}
}}
placeholder="e.g. 95"
/>
</div>
</div>
</div>
)}
</div>
</div>
)}
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p data-testid="error-message" className="mt-3 text-sm text-red-400">
{props.error}
</p>
)}

View File

@@ -12,13 +12,11 @@ import Button, {
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import Text from "Common/Types/Text";
import HorizontalRule from "Common/UI/Components/HorizontalRule/HorizontalRule";
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
import StartAndEndDate, {
StartAndEndDateType,
} from "Common/UI/Components/Date/StartAndEndDate";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
import Card from "Common/UI/Components/Card/Card";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import API from "Common/UI/Utils/API/API";
@@ -34,6 +32,7 @@ import MetricCharts from "./MetricCharts";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import JSONFunctions from "Common/Types/JSONFunctions";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import IconProp from "Common/Types/Icon/IconProp";
const getFetchRelevantState: (data: MetricViewData) => unknown = (
data: MetricViewData,
@@ -109,9 +108,9 @@ const MetricView: FunctionComponent<ComponentProps> = (
const [isPageLoading, setIsPageLoading] = useState<boolean>(false);
const [pageError, setPageError] = useState<string>("");
const [telemetryAttributes, setTelemetryAttributes] = useState<Array<string>>(
[],
);
const [telemetryAttributes, setTelemetryAttributes] = useState<
Array<string>
>([]);
const [telemetryAttributesLoaded, setTelemetryAttributesLoaded] =
useState<boolean>(false);
const [telemetryAttributesLoading, setTelemetryAttributesLoading] =
@@ -305,29 +304,33 @@ const MetricView: FunctionComponent<ComponentProps> = (
return (
<Fragment>
<div className="space-y-3">
<div className="space-y-4">
{/* Time range selector */}
{!props.hideStartAndEndDate && (
<div className="mb-5">
<Card>
<div className="-mt-5">
<FieldLabelElement title="Start and End Time" required={true} />
<StartAndEndDate
type={StartAndEndDateType.DateTime}
value={props.data.startAndEndDate || undefined}
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
if (props.onChange) {
props.onChange({
...props.data,
startAndEndDate: startAndEndDate,
});
}
}}
/>
<Card>
<div className="-mt-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Time Range
</span>
</div>
</Card>
</div>
<StartAndEndDate
type={StartAndEndDateType.DateTime}
value={props.data.startAndEndDate || undefined}
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
if (props.onChange) {
props.onChange({
...props.data,
startAndEndDate: startAndEndDate,
});
}
}}
/>
</div>
</Card>
)}
{/* Query configs */}
{!props.hideQueryElements && (
<div className="space-y-3">
{props.data.queryConfigs.map(
@@ -382,104 +385,91 @@ const MetricView: FunctionComponent<ComponentProps> = (
)}
</div>
)}
</div>
{!props.hideQueryElements && (
<div className="space-y-3">
{/* Formula configs and Add buttons */}
{!props.hideQueryElements && (
<div className="space-y-3">
{props.data.formulaConfigs.map(
(formulaConfig: MetricFormulaConfigData, index: number) => {
return (
<MetricGraphConfig
key={index}
onDataChanged={(data: MetricFormulaConfigData) => {
const newGraphConfigs: Array<MetricFormulaConfigData> = [
...props.data.formulaConfigs,
];
newGraphConfigs[index] = data;
if (props.onChange) {
props.onChange({
...props.data,
formulaConfigs: newGraphConfigs,
});
}
}}
data={formulaConfig}
onRemove={() => {
const newGraphConfigs: Array<MetricFormulaConfigData> = [
...props.data.formulaConfigs,
];
newGraphConfigs.splice(index, 1);
if (props.onChange) {
props.onChange({
...props.data,
formulaConfigs: newGraphConfigs,
});
}
}}
/>
);
},
)}
</div>
<div>
<div className="flex -ml-3 mt-8 justify-between w-full">
<div>
<Button
title="Add Metric"
buttonSize={ButtonSize.Small}
onClick={() => {
if (props.onChange) {
props.onChange({
...props.data,
queryConfigs: [
...props.data.queryConfigs,
getEmptyQueryConfigData(),
],
});
}
}}
/>
{/* <Button
title="Add Formula"
buttonSize={ButtonSize.Small}
onClick={() => {
setMetricViewData({
...metricViewData,
formulaConfigs: [
...metricViewData.formulaConfigs,
getEmptyFormulaConfigData(),
],
});
}}
/> */}
{props.data.formulaConfigs.length > 0 && (
<div className="space-y-3">
{props.data.formulaConfigs.map(
(formulaConfig: MetricFormulaConfigData, index: number) => {
return (
<MetricGraphConfig
key={index}
onDataChanged={(data: MetricFormulaConfigData) => {
const newGraphConfigs: Array<MetricFormulaConfigData> =
[...props.data.formulaConfigs];
newGraphConfigs[index] = data;
if (props.onChange) {
props.onChange({
...props.data,
formulaConfigs: newGraphConfigs,
});
}
}}
data={formulaConfig}
onRemove={() => {
const newGraphConfigs: Array<MetricFormulaConfigData> =
[...props.data.formulaConfigs];
newGraphConfigs.splice(index, 1);
if (props.onChange) {
props.onChange({
...props.data,
formulaConfigs: newGraphConfigs,
});
}
}}
/>
);
},
)}
</div>
)}
{/* Add metric button */}
<div className="flex items-center">
<Button
title="Add Metric"
buttonSize={ButtonSize.Small}
buttonStyle={ButtonStyleType.OUTLINE}
icon={IconProp.Add}
onClick={() => {
if (props.onChange) {
props.onChange({
...props.data,
queryConfigs: [
...props.data.queryConfigs,
getEmptyQueryConfigData(),
],
});
}
}}
/>
</div>
</div>
<HorizontalRule />
</div>
)}
)}
{isMetricResultsLoading && <ComponentLoader />}
{/* Chart results */}
{isMetricResultsLoading && <ComponentLoader />}
{metricResultsError && <ErrorMessage message={metricResultsError} />}
{metricResultsError && <ErrorMessage message={metricResultsError} />}
{!isMetricResultsLoading && !metricResultsError && (
<div
className={
props.hideCardInCharts ? "" : "grid grid-cols-1 gap-4 mt-3"
}
>
{/** charts */}
<MetricCharts
hideCard={props.hideCardInCharts}
metricResults={metricResults}
metricTypes={metricTypes}
metricViewData={props.data}
chartCssClass={props.chartCssClass}
/>
</div>
)}
{!isMetricResultsLoading && !metricResultsError && (
<div
className={
props.hideCardInCharts ? "" : "grid grid-cols-1 gap-4"
}
>
<MetricCharts
hideCard={props.hideCardInCharts}
metricResults={metricResults}
metricTypes={metricTypes}
metricViewData={props.data}
chartCssClass={props.chartCssClass}
/>
</div>
)}
</div>
{showCannotRemoveOneRemainingQueryError ? (
<ConfirmModal

View File

@@ -11,10 +11,35 @@ import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import Label from "Common/Models/DatabaseModels/Label";
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useCallback,
useState,
} from "react";
import DashboardElement from "../../Components/Dashboard/DashboardElement";
import DashboardTemplateCard from "../../Components/Dashboard/DashboardTemplateCard";
import {
DashboardTemplates,
DashboardTemplateType,
getTemplateConfig,
DashboardTemplate,
} from "Common/Types/Dashboard/DashboardTemplates";
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
import { JSONObject } from "Common/Types/JSON";
import Card from "Common/UI/Components/Card/Card";
const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
const [selectedTemplate, setSelectedTemplate] =
useState<DashboardTemplateType | null>(null);
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
const handleTemplateClick: (type: DashboardTemplateType) => void =
useCallback((type: DashboardTemplateType): void => {
setSelectedTemplate(type);
setShowCreateForm(true);
}, []);
return (
<Page
title={"Dashboards"}
@@ -31,6 +56,29 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
},
]}
>
<Card
title="Create from Template"
description="Choose a template to quickly get started with a pre-configured dashboard."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{DashboardTemplates.map(
(template: DashboardTemplate): ReactElement => {
return (
<DashboardTemplateCard
key={template.type}
title={template.name}
description={template.description}
icon={template.icon}
onClick={() => {
handleTemplateClick(template.type);
}}
/>
);
},
)}
</div>
</Card>
<ModelTable<Dashboard>
modelType={Dashboard}
id="dashboard-table"
@@ -40,6 +88,7 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
isCreateable={true}
name="Dashboards"
isViewable={true}
showCreateForm={showCreateForm}
cardProps={{
title: "Dashboards",
description: "Here is a list of dashboards for this project.",
@@ -69,6 +118,18 @@ const Dashboards: FunctionComponent<PageComponentProps> = (): ReactElement => {
placeholder: "Description",
},
]}
onBeforeCreate={async (item: Dashboard, _miscDataProps: JSONObject): Promise<Dashboard> => {
if (selectedTemplate && selectedTemplate !== DashboardTemplateType.Blank) {
const templateConfig: DashboardViewConfig | null =
getTemplateConfig(selectedTemplate);
if (templateConfig) {
item.dashboardViewConfig = templateConfig;
}
}
setSelectedTemplate(null);
setShowCreateForm(false);
return item;
}}
saveFilterProps={{
tableId: "all-dashboards-table",
}}

View File

@@ -0,0 +1,317 @@
import DashboardViewConfig from "./DashboardViewConfig";
import { ObjectType } from "../JSON";
import DashboardSize from "./DashboardSize";
import DashboardComponentType from "./DashboardComponentType";
import DashboardChartType from "./Chart/ChartType";
import ObjectID from "../ObjectID";
import DashboardBaseComponent from "./DashboardComponents/DashboardBaseComponent";
import IconProp from "../Icon/IconProp";
export enum DashboardTemplateType {
Blank = "Blank",
Monitor = "Monitor",
Incident = "Incident",
Kubernetes = "Kubernetes",
}
export interface DashboardTemplate {
type: DashboardTemplateType;
name: string;
description: string;
icon: IconProp;
}
export const DashboardTemplates: Array<DashboardTemplate> = [
{
type: DashboardTemplateType.Blank,
name: "Blank Dashboard",
description: "Start from scratch with an empty dashboard.",
icon: IconProp.Add,
},
{
type: DashboardTemplateType.Monitor,
name: "Monitor Dashboard",
description:
"Pre-configured with response time, uptime, and throughput widgets.",
icon: IconProp.Activity,
},
{
type: DashboardTemplateType.Incident,
name: "Incident Dashboard",
description:
"Track active incidents, MTTR, MTTA, and view recent logs.",
icon: IconProp.Alert,
},
{
type: DashboardTemplateType.Kubernetes,
name: "Kubernetes Dashboard",
description:
"Monitor CPU, memory, pod count, and resource usage over time.",
icon: IconProp.Kubernetes,
},
];
function createTextComponent(data: {
text: string;
top: number;
left: number;
width: number;
height: number;
isBold?: boolean;
}): DashboardBaseComponent {
return {
_type: ObjectType.DashboardComponent,
componentType: DashboardComponentType.Text,
componentId: ObjectID.generate(),
topInDashboardUnits: data.top,
leftInDashboardUnits: data.left,
widthInDashboardUnits: data.width,
heightInDashboardUnits: data.height,
minHeightInDashboardUnits: 1,
minWidthInDashboardUnits: 3,
arguments: {
text: data.text,
isBold: data.isBold ?? false,
isItalic: false,
isUnderline: false,
isMarkdown: false,
},
};
}
function createValueComponent(data: {
title: string;
top: number;
left: number;
width: number;
}): DashboardBaseComponent {
return {
_type: ObjectType.DashboardComponent,
componentType: DashboardComponentType.Value,
componentId: ObjectID.generate(),
topInDashboardUnits: data.top,
leftInDashboardUnits: data.left,
widthInDashboardUnits: data.width,
heightInDashboardUnits: 1,
minHeightInDashboardUnits: 1,
minWidthInDashboardUnits: 1,
arguments: {
title: data.title,
metricQueryConfig: {
metricQueryData: {
filterData: {},
groupBy: undefined,
},
},
},
};
}
function createChartComponent(data: {
title: string;
chartType: DashboardChartType;
top: number;
left: number;
width: number;
height: number;
}): DashboardBaseComponent {
return {
_type: ObjectType.DashboardComponent,
componentType: DashboardComponentType.Chart,
componentId: ObjectID.generate(),
topInDashboardUnits: data.top,
leftInDashboardUnits: data.left,
widthInDashboardUnits: data.width,
heightInDashboardUnits: data.height,
minHeightInDashboardUnits: 3,
minWidthInDashboardUnits: 6,
arguments: {
chartTitle: data.title,
chartType: data.chartType,
metricQueryConfig: {
metricAliasData: {
metricVariable: "a",
title: undefined,
description: undefined,
legend: undefined,
legendUnit: undefined,
},
metricQueryData: {
filterData: {},
groupBy: undefined,
},
},
},
};
}
function createLogStreamComponent(data: {
title: string;
top: number;
left: number;
width: number;
height: number;
}): DashboardBaseComponent {
return {
_type: ObjectType.DashboardComponent,
componentType: DashboardComponentType.LogStream,
componentId: ObjectID.generate(),
topInDashboardUnits: data.top,
leftInDashboardUnits: data.left,
widthInDashboardUnits: data.width,
heightInDashboardUnits: data.height,
minHeightInDashboardUnits: 3,
minWidthInDashboardUnits: 6,
arguments: {
title: data.title,
maxRows: 50,
},
};
}
function createMonitorDashboardConfig(): DashboardViewConfig {
const components: Array<DashboardBaseComponent> = [
createTextComponent({
text: "Monitor Dashboard",
top: 0,
left: 0,
width: 12,
height: 1,
isBold: true,
}),
createValueComponent({ title: "Response Time", top: 1, left: 0, width: 4 }),
createValueComponent({ title: "Uptime %", top: 1, left: 4, width: 4 }),
createValueComponent({ title: "Error Rate", top: 1, left: 8, width: 4 }),
createChartComponent({
title: "Response Time Over Time",
chartType: DashboardChartType.Line,
top: 2,
left: 0,
width: 6,
height: 3,
}),
createChartComponent({
title: "Request Throughput",
chartType: DashboardChartType.Area,
top: 2,
left: 6,
width: 6,
height: 3,
}),
];
return {
_type: ObjectType.DashboardViewConfig,
components,
heightInDashboardUnits: Math.max(
DashboardSize.heightInDashboardUnits,
5,
),
};
}
function createIncidentDashboardConfig(): DashboardViewConfig {
const components: Array<DashboardBaseComponent> = [
createTextComponent({
text: "Incident Dashboard",
top: 0,
left: 0,
width: 12,
height: 1,
isBold: true,
}),
createValueComponent({
title: "Active Incidents",
top: 1,
left: 0,
width: 4,
}),
createValueComponent({ title: "MTTR", top: 1, left: 4, width: 4 }),
createValueComponent({ title: "MTTA", top: 1, left: 8, width: 4 }),
createChartComponent({
title: "Incidents Over Time",
chartType: DashboardChartType.Line,
top: 2,
left: 0,
width: 6,
height: 3,
}),
createLogStreamComponent({
title: "Recent Logs",
top: 2,
left: 6,
width: 6,
height: 3,
}),
];
return {
_type: ObjectType.DashboardViewConfig,
components,
heightInDashboardUnits: Math.max(
DashboardSize.heightInDashboardUnits,
5,
),
};
}
function createKubernetesDashboardConfig(): DashboardViewConfig {
const components: Array<DashboardBaseComponent> = [
createTextComponent({
text: "Kubernetes Dashboard",
top: 0,
left: 0,
width: 12,
height: 1,
isBold: true,
}),
createValueComponent({ title: "CPU Usage", top: 1, left: 0, width: 4 }),
createValueComponent({
title: "Memory Usage",
top: 1,
left: 4,
width: 4,
}),
createValueComponent({ title: "Pod Count", top: 1, left: 8, width: 4 }),
createChartComponent({
title: "CPU Usage Over Time",
chartType: DashboardChartType.Line,
top: 2,
left: 0,
width: 6,
height: 3,
}),
createChartComponent({
title: "Memory Usage Over Time",
chartType: DashboardChartType.Area,
top: 2,
left: 6,
width: 6,
height: 3,
}),
];
return {
_type: ObjectType.DashboardViewConfig,
components,
heightInDashboardUnits: Math.max(
DashboardSize.heightInDashboardUnits,
5,
),
};
}
export function getTemplateConfig(
type: DashboardTemplateType,
): DashboardViewConfig | null {
switch (type) {
case DashboardTemplateType.Monitor:
return createMonitorDashboardConfig();
case DashboardTemplateType.Incident:
return createIncidentDashboardConfig();
case DashboardTemplateType.Kubernetes:
return createKubernetesDashboardConfig();
case DashboardTemplateType.Blank:
return null;
}
}