improve ui

This commit is contained in:
Nawaz Dhandala
2026-03-25 22:35:36 +00:00
parent 31e1290ecb
commit 467921e899
9 changed files with 565 additions and 90 deletions

View File

@@ -39,7 +39,19 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
// have a grid with width cols and height rows
return (
<div className={`grid grid-cols-${width}`}>
<div
className={`grid grid-cols-${width}`}
style={
props.isEditMode
? {
backgroundImage:
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
backgroundSize: "20px 20px",
borderRadius: "8px",
}
: {}
}
>
{Array.from(Array(height).keys()).map((_: number, index: number) => {
return (
<BlankRowElement

View File

@@ -55,7 +55,7 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
}}
leftFooterElement={
<Button
title={`Delete Component`}
title={`Delete Widget`}
icon={IconProp.Trash}
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
onClick={() => {
@@ -67,12 +67,12 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
<>
{showDeleteConfirmation && (
<ConfirmModal
title={`Delete?`}
description={`Are you sure you want to delete this component? This action is not recoverable.`}
title={`Delete Widget?`}
description={`Are you sure you want to delete this widget? This action cannot be undone.`}
onClose={() => {
setShowDeleteConfirmation(false);
}}
submitButtonText={"Delete"}
submitButtonText={"Delete Widget"}
onSubmit={() => {
props.onComponentDelete(component);
setShowDeleteConfirmation(false);
@@ -82,6 +82,16 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
/>
)}
{/* Widget type indicator */}
<div className="flex items-center gap-2 mb-4 px-1">
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 capitalize">
{component.componentType} Widget
</span>
<span className="text-xs text-gray-400">
{component.widthInDashboardUnits} x {component.heightInDashboardUnits} units
</span>
</div>
<Divider />
<ArgumentsForm

View File

@@ -139,8 +139,25 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
const width: number = DefaultDashboardSize.widthInDashboardUnits;
const canvasClassName: string = props.isEditMode
? `grid grid-cols-${width}`
: `grid grid-cols-${width}`;
return (
<div ref={dashboardCanvasRef} className={`grid grid-cols-${width}`}>
<div
ref={dashboardCanvasRef}
className={canvasClassName}
style={
props.isEditMode
? {
backgroundImage:
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
backgroundSize: "20px 20px",
borderRadius: "8px",
}
: {}
}
>
{finalRenderedComponents}
</div>
);

View File

@@ -66,14 +66,18 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
const [topInPx, setTopInPx] = React.useState<number>(0);
const [leftInPx, setLeftInPx] = React.useState<number>(0);
let className: string = `relative rounded-lg col-span-${widthOfComponent} row-span-${heightOfComponent} p-3 bg-white border border-gray-200 transition-all duration-200`;
let className: string = `relative rounded-lg col-span-${widthOfComponent} row-span-${heightOfComponent} p-3 bg-white border border-gray-200 transition-all duration-200 overflow-hidden`;
if (props.isEditMode) {
className += " cursor-pointer hover:border-gray-300";
if (props.isEditMode && !props.isSelected) {
className += " cursor-pointer hover:border-gray-300 hover:shadow-md";
}
if (props.isSelected && props.isEditMode) {
className += " !border-blue-400 ring-2 ring-blue-100";
className += " !border-blue-400 ring-2 ring-blue-50 shadow-lg shadow-blue-100/50";
}
if (!props.isEditMode) {
className += " hover:shadow-md";
}
const dashboardComponentRef: React.RefObject<HTMLDivElement> =
@@ -386,6 +390,15 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
>
{getMoveElement()}
{/* Component type badge - visible in edit mode */}
{props.isEditMode && props.isSelected && (
<div className="absolute top-1.5 right-1.5 z-10">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 capitalize">
{component.componentType}
</span>
</div>
)}
{component.componentType === DashboardComponentType.Text && (
<DashboardTextComponent
{...props}

View File

@@ -3,12 +3,10 @@ import DashboardChartComponent from "Common/Types/Dashboard/DashboardComponents/
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import MetricCharts from "../../Metrics/MetricCharts";
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, {
MetricChartType,
@@ -143,18 +141,39 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
]);
if (isLoading) {
return <ComponentLoader />;
// Skeleton loading for chart
return (
<div className="w-full h-full flex flex-col p-1 animate-pulse">
<div className="h-3 w-28 bg-gray-100 rounded mb-3"></div>
<div className="flex-1 flex items-end gap-1 px-2 pb-2">
{Array.from({ length: 12 }).map((_: unknown, i: number) => {
return (
<div
key={i}
className="flex-1 bg-gray-100 rounded-t"
style={{
height: `${20 + Math.random() * 60}%`,
opacity: 0.4 + Math.random() * 0.4,
}}
></div>
);
})}
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.ChartBar} />
</div>
</div>
<ErrorMessage message={error} />
<p className="text-xs text-gray-400 text-center max-w-48">
{error}
</p>
</div>
);
}
@@ -224,7 +243,7 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
};
return (
<div>
<div className="w-full h-full overflow-hidden">
<MetricCharts
metricResults={metricResults}
metricTypes={props.metricTypes}

View File

@@ -2,12 +2,10 @@ 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";
@@ -113,11 +111,59 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
}, [props.component.arguments.metricQueryConfig]);
if (isLoading) {
return <ComponentLoader />;
// Skeleton loading for gauge
return (
<div className="w-full h-full flex flex-col items-center justify-center animate-pulse">
<div className="h-3 w-20 bg-gray-100 rounded mb-3"></div>
<div
className="bg-gray-100 rounded-full"
style={{
width: `${Math.min(props.dashboardComponentWidthInPx * 0.5, 120)}px`,
height: `${Math.min(props.dashboardComponentWidthInPx * 0.25, 60)}px`,
borderRadius: "999px 999px 0 0",
}}
></div>
<div className="h-5 w-12 bg-gray-100 rounded mt-2"></div>
</div>
);
}
if (error) {
return <ErrorMessage message={error} />;
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-300" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
</svg>
</div>
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
</div>
);
}
// Show setup state if no metric configured
if (
!props.component.arguments.metricQueryConfig ||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
Object.keys(
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
).length === 0
) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-300" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
</svg>
</div>
<p className="text-xs font-medium text-gray-500">
{props.component.arguments.gaugeTitle || "Gauge Widget"}
</p>
<p className="text-xs text-gray-400 text-center">
Click to configure metric
</p>
</div>
);
}
// Calculate aggregated value
@@ -213,6 +259,46 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
// Generate a unique gradient ID for this component instance
const gradientId: string = `gauge-gradient-${props.componentId?.toString() || "default"}`;
// Threshold marker positions on arc
type ThresholdMarker = {
angle: number;
x: number;
y: number;
color: string;
};
const thresholdMarkers: Array<ThresholdMarker> = [];
if (warningThreshold !== undefined && range > 0) {
const warningPct: number = Math.min(
Math.max((warningThreshold - minValue) / range, 0),
1,
);
const warningAngle: number = startAngle - sweepAngle * warningPct;
thresholdMarkers.push({
angle: warningAngle,
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(warningAngle),
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(warningAngle),
color: "#f59e0b",
});
}
if (criticalThreshold !== undefined && range > 0) {
const criticalPct: number = Math.min(
Math.max((criticalThreshold - minValue) / range, 0),
1,
);
const criticalAngle: number = startAngle - sweepAngle * criticalPct;
thresholdMarkers.push({
angle: criticalAngle,
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(criticalAngle),
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(criticalAngle),
color: "#ef4444",
});
}
const percentDisplay: number = Math.round(percentage * 100);
return (
<div className="w-full text-center h-full flex flex-col items-center justify-center">
{props.component.arguments.gaugeTitle && (
@@ -220,31 +306,36 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
style={{
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
}}
className="text-center font-medium text-gray-500 mb-2 truncate uppercase tracking-wide"
className="text-center font-medium text-gray-400 mb-2 truncate uppercase tracking-wider"
>
{props.component.arguments.gaugeTitle}
</div>
)}
<svg
width={gaugeSize}
height={gaugeSize / 2 + strokeWidth + 4}
viewBox={`0 0 ${gaugeSize} ${gaugeSize / 2 + strokeWidth + 4}`}
height={gaugeSize / 2 + strokeWidth + 8}
viewBox={`0 0 ${gaugeSize} ${gaugeSize / 2 + strokeWidth + 8}`}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.7" />
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
<stop offset="50%" stopColor={gaugeColor} stopOpacity="0.85" />
<stop offset="100%" stopColor={gaugeColor} stopOpacity="1" />
</linearGradient>
<filter id={`gauge-shadow-${props.componentId?.toString() || "default"}`}>
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.1" />
<filter id={`gauge-glow-${props.componentId?.toString() || "default"}`}>
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background track */}
<path
d={backgroundPath}
fill="none"
stroke="#f3f4f6"
strokeWidth={strokeWidth + 2}
stroke="#f0f0f0"
strokeWidth={strokeWidth + 4}
strokeLinecap="round"
/>
{/* Value arc */}
@@ -255,14 +346,41 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
filter={`url(#gauge-shadow-${props.componentId?.toString() || "default"})`}
filter={`url(#gauge-glow-${props.componentId?.toString() || "default"})`}
/>
)}
{/* Threshold markers */}
{thresholdMarkers.map(
(marker: ThresholdMarker, index: number) => {
return (
<circle
key={index}
cx={marker.x}
cy={marker.y}
r={3}
fill={marker.color}
stroke="white"
strokeWidth={1.5}
/>
);
},
)}
{/* Needle tip dot at current position */}
{percentage > 0 && (
<circle
cx={arcCurrentX}
cy={arcCurrentY}
r={strokeWidth * 0.4}
fill="white"
stroke={gaugeColor}
strokeWidth={2}
/>
)}
</svg>
{/* Value display */}
{/* Value + percentage display */}
<div
style={{
marginTop: `-${gaugeSize * 0.18}px`,
marginTop: `-${gaugeSize * 0.2}px`,
}}
>
<div
@@ -270,19 +388,31 @@ const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
lineHeight: 1.1,
letterSpacing: "-0.02em",
letterSpacing: "-0.03em",
}}
>
{aggregatedValue}
</div>
<div
className="text-gray-400 font-medium"
style={{
fontSize: `${Math.max(valueHeightInPx * 0.45, 10)}px`,
}}
>
{percentDisplay}%
</div>
</div>
{/* Min/Max labels */}
<div
className="flex justify-between w-full px-4 mt-1"
className="flex justify-between w-full px-2 mt-0.5"
style={{ maxWidth: `${gaugeSize + 10}px` }}
>
<span className="text-xs text-gray-400">{minValue}</span>
<span className="text-xs text-gray-400">{maxValue}</span>
<span className="text-gray-300 tabular-nums" style={{ fontSize: "10px" }}>
{minValue}
</span>
<span className="text-gray-300 tabular-nums" style={{ fontSize: "10px" }}>
{maxValue}
</span>
</div>
</div>
);

View File

@@ -8,7 +8,6 @@ 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";
@@ -114,7 +113,26 @@ const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
}, [props.component.arguments.metricQueryConfig]);
if (isLoading) {
return <ComponentLoader />;
// Skeleton loading for table
return (
<div className="h-full flex flex-col animate-pulse">
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
<div className="flex-1 space-y-2">
<div className="flex gap-4">
<div className="h-3 w-32 bg-gray-100 rounded"></div>
<div className="h-3 w-16 bg-gray-100 rounded ml-auto"></div>
</div>
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
return (
<div key={i} className="flex gap-4" style={{ opacity: 1 - i * 0.15 }}>
<div className="h-3 w-28 bg-gray-50 rounded"></div>
<div className="h-3 w-14 bg-gray-50 rounded ml-auto"></div>
</div>
);
})}
</div>
</div>
);
}
if (error) {
@@ -141,42 +159,85 @@ const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
const displayData: Array<AggregatedModel> = allData.slice(0, maxRows);
// Calculate max value for bar visualization
const maxDataValue: number =
displayData.length > 0
? Math.max(
...displayData.map((item: AggregatedModel) => {
return Math.abs(item.value);
}),
)
: 1;
return (
<div className="h-full overflow-auto flex flex-col">
{props.component.arguments.tableTitle && (
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2 px-1">
{props.component.arguments.tableTitle}
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
{props.component.arguments.tableTitle}
</span>
<span className="text-xs text-gray-300 tabular-nums">
{displayData.length} rows
</span>
</div>
)}
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
<tr>
<th className="px-4 py-2.5 font-medium tracking-wide">Timestamp</th>
<th className="px-4 py-2.5 font-medium tracking-wide text-right">Value</th>
<th className="px-4 py-2.5 font-medium tracking-wider" style={{ width: "45%" }}>
Timestamp
</th>
<th className="px-4 py-2.5 font-medium tracking-wider text-right" style={{ width: "25%" }}>
Value
</th>
<th className="px-4 py-2.5 font-medium tracking-wider" style={{ width: "30%" }}>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{displayData.map((item: AggregatedModel, index: number) => {
const roundedValue: number =
Math.round(item.value * 100) / 100;
const barWidth: number =
maxDataValue > 0
? (Math.abs(roundedValue) / maxDataValue) * 100
: 0;
return (
<tr
key={index}
className="hover:bg-gray-50/50 transition-colors duration-100"
className="hover:bg-gray-50/50 transition-colors duration-100 group"
>
<td className="px-4 py-2 text-gray-500 text-xs">
{OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.fromString(item.timestamp),
)}
</td>
<td className="px-4 py-2 font-semibold text-gray-900 text-right tabular-nums">
{Math.round(item.value * 100) / 100}
<td className="px-4 py-2 font-semibold text-gray-900 text-right tabular-nums text-xs">
{roundedValue}
</td>
<td className="px-3 py-2">
<div className="w-full h-3 bg-gray-50 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${barWidth}%`,
background:
"linear-gradient(90deg, rgba(99, 102, 241, 0.2) 0%, rgba(99, 102, 241, 0.4) 100%)",
}}
></div>
</div>
</td>
</tr>
);
})}
{displayData.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-8 text-center text-gray-400 text-sm">
<td
colSpan={3}
className="px-4 py-8 text-center text-gray-400 text-sm"
>
No data available
</td>
</tr>

View File

@@ -1,7 +1,7 @@
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricUtil from "../../Metrics/Utils/Metrics";
@@ -10,14 +10,79 @@ import DashboardValueComponentType from "Common/Types/Dashboard/DashboardCompone
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import JSONFunctions from "Common/Types/JSONFunctions";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
export interface ComponentProps extends DashboardBaseComponentProps {
component: DashboardValueComponentType;
}
// Mini sparkline SVG component
interface SparklineProps {
data: Array<number>;
width: number;
height: number;
color: string;
fillColor: string;
}
const Sparkline: FunctionComponent<SparklineProps> = (
sparklineProps: SparklineProps,
): ReactElement => {
if (sparklineProps.data.length < 2) {
return <></>;
}
const dataPoints: Array<number> = sparklineProps.data;
const minVal: number = Math.min(...dataPoints);
const maxVal: number = Math.max(...dataPoints);
const range: number = maxVal - minVal || 1;
const padding: number = 2;
const points: string = dataPoints
.map((value: number, index: number) => {
const x: number =
padding +
(index / (dataPoints.length - 1)) *
(sparklineProps.width - padding * 2);
const y: number =
sparklineProps.height -
padding -
((value - minVal) / range) * (sparklineProps.height - padding * 2);
return `${x},${y}`;
})
.join(" ");
// Create fill area path
const firstX: number = padding;
const lastX: number =
padding +
((dataPoints.length - 1) / (dataPoints.length - 1)) *
(sparklineProps.width - padding * 2);
const fillPoints: string = `${firstX},${sparklineProps.height} ${points} ${lastX},${sparklineProps.height}`;
return (
<svg
width={sparklineProps.width}
height={sparklineProps.height}
viewBox={`0 0 ${sparklineProps.width} ${sparklineProps.height}`}
className="overflow-visible"
>
<polygon points={fillPoints} fill={sparklineProps.fillColor} />
<polyline
points={points}
fill="none"
stroke={sparklineProps.color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
@@ -102,7 +167,6 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
useEffect(() => {
// set metricQueryConfig to the new value only if it is different from the previous value
if (
JSONFunctions.isJSONObjectDifferent(
metricQueryConfig || {},
@@ -115,39 +179,89 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
}, [props.component.arguments.metricQueryConfig]);
if (isLoading) {
return <ComponentLoader />;
// Skeleton loading state
return (
<div className="w-full h-full flex flex-col items-center justify-center rounded-md animate-pulse">
<div className="h-3 w-16 bg-gray-100 rounded mb-3"></div>
<div className="h-8 w-24 bg-gray-100 rounded mb-2"></div>
<div className="h-6 w-32 bg-gray-50 rounded mt-1"></div>
</div>
);
}
if (error) {
return <ErrorMessage message={error} />;
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
<div className="h-5 w-5 text-gray-300">
<Icon icon={IconProp.ChartBar} />
</div>
</div>
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
</div>
);
}
let heightOfText: number | undefined =
(props.dashboardComponentHeightInPx || 0) - 100;
// Show setup state if no metric configured
if (
!props.component.arguments.metricQueryConfig ||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
Object.keys(
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
).length === 0
) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
<svg
className="w-5 h-5 text-indigo-300"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-500">
{props.component.arguments.title || "Value Widget"}
</p>
<p className="text-xs text-gray-400 text-center">
Click to configure metric
</p>
</div>
);
}
if (heightOfText < 0) {
heightOfText = undefined;
// Collect all data points for sparkline and aggregation
const allDataPoints: Array<AggregatedModel> = [];
for (const result of metricResults) {
for (const item of result.data) {
allDataPoints.push(item);
}
}
let aggregatedValue: number = 0;
let avgCount: number = 0;
for (const result of metricResults) {
for (const item of result.data) {
const value: number = item.value;
for (const item of allDataPoints) {
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) {
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;
}
}
@@ -158,8 +272,17 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
// round to 2 decimal places
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.4;
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.13;
// Sparkline data - take raw values in order
const sparklineData: Array<number> = allDataPoints.map(
(item: AggregatedModel) => {
return item.value;
},
);
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.35;
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.11;
const showSparkline: boolean =
sparklineData.length >= 2 && props.dashboardComponentHeightInPx > 100;
const unit: string | undefined =
props.metricTypes?.find((item: MetricType) => {
@@ -172,7 +295,8 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
// Determine color based on thresholds
let valueColorClass: string = "text-gray-900";
let bgStyle: React.CSSProperties = {};
let statusDotColor: string = "";
let sparklineColor: string = "#6366f1"; // indigo
let sparklineFill: string = "rgba(99, 102, 241, 0.08)";
const warningThreshold: number | undefined =
props.component.arguments.warningThreshold;
const criticalThreshold: number | undefined =
@@ -183,53 +307,127 @@ const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
aggregatedValue >= criticalThreshold
) {
valueColorClass = "text-red-600";
bgStyle = { background: "linear-gradient(135deg, rgba(254, 226, 226, 0.5) 0%, rgba(254, 202, 202, 0.3) 100%)" };
statusDotColor = "bg-red-500";
bgStyle = {
background:
"linear-gradient(135deg, rgba(254, 226, 226, 0.4) 0%, rgba(254, 202, 202, 0.2) 100%)",
};
sparklineColor = "#ef4444";
sparklineFill = "rgba(239, 68, 68, 0.08)";
} else if (
warningThreshold !== undefined &&
aggregatedValue >= warningThreshold
) {
valueColorClass = "text-amber-600";
bgStyle = { background: "linear-gradient(135deg, rgba(254, 243, 199, 0.5) 0%, rgba(253, 230, 138, 0.3) 100%)" };
statusDotColor = "bg-amber-500";
bgStyle = {
background:
"linear-gradient(135deg, rgba(254, 243, 199, 0.4) 0%, rgba(253, 230, 138, 0.2) 100%)",
};
sparklineColor = "#f59e0b";
sparklineFill = "rgba(245, 158, 11, 0.08)";
}
// Calculate trend (compare first half avg to second half avg)
let trendPercent: number | null = null;
let trendDirection: "up" | "down" | "flat" = "flat";
if (sparklineData.length >= 4) {
const midpoint: number = Math.floor(sparklineData.length / 2);
const firstHalf: Array<number> = sparklineData.slice(0, midpoint);
const secondHalf: Array<number> = sparklineData.slice(midpoint);
const firstAvg: number =
firstHalf.reduce((a: number, b: number) => {
return a + b;
}, 0) / firstHalf.length;
const secondAvg: number =
secondHalf.reduce((a: number, b: number) => {
return a + b;
}, 0) / secondHalf.length;
if (firstAvg !== 0) {
trendPercent =
Math.round(((secondAvg - firstAvg) / Math.abs(firstAvg)) * 1000) / 10;
trendDirection =
trendPercent > 0.5 ? "up" : trendPercent < -0.5 ? "down" : "flat";
}
}
const sparklineWidth: number = Math.min(
props.dashboardComponentWidthInPx * 0.6,
120,
);
const sparklineHeight: number = Math.min(
props.dashboardComponentHeightInPx * 0.18,
30,
);
return (
<div
className="w-full h-full flex flex-col items-center justify-center rounded-md"
className="w-full h-full flex flex-col items-center justify-center rounded-md relative overflow-hidden"
style={bgStyle}
>
<div className="flex items-center gap-1.5 mb-1">
{statusDotColor && (
<span className={`w-2 h-2 rounded-full ${statusDotColor} inline-block`}></span>
)}
{/* Title */}
<div className="flex items-center gap-1.5 mb-0.5">
<span
style={{
fontSize: titleHeightInPx > 0 ? `${Math.min(titleHeightInPx, 16)}px` : "13px",
fontSize:
titleHeightInPx > 0
? `${Math.max(Math.min(titleHeightInPx, 14), 11)}px`
: "12px",
}}
className="text-center font-medium text-gray-500 truncate uppercase tracking-wide"
className="text-center font-medium text-gray-400 truncate uppercase tracking-wider"
>
{props.component.arguments.title || " "}
</span>
</div>
{/* Value */}
<div
className={`text-center font-bold truncate ${valueColorClass}`}
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
lineHeight: 1.1,
letterSpacing: "-0.02em",
lineHeight: 1.15,
letterSpacing: "-0.03em",
}}
>
{aggregatedValue || "0"}
<span
className="text-gray-400 font-normal"
style={{
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx * 0.35}px` : "",
fontSize:
valueHeightInPx > 0 ? `${valueHeightInPx * 0.3}px` : "",
}}
>
{unit ? ` ${unit}` : ""}
</span>
</div>
{/* Trend indicator */}
{trendPercent !== null && trendDirection !== "flat" && (
<div
className={`flex items-center gap-0.5 mt-0.5 ${
trendDirection === "up" ? "text-emerald-500" : "text-red-500"
}`}
style={{ fontSize: `${Math.max(Math.min(titleHeightInPx, 12), 10)}px` }}
>
<span>{trendDirection === "up" ? "\u2191" : "\u2193"}</span>
<span className="font-medium tabular-nums">
{Math.abs(trendPercent)}%
</span>
</div>
)}
{/* Sparkline */}
{showSparkline && (
<div className="mt-1">
<Sparkline
data={sparklineData}
width={sparklineWidth}
height={sparklineHeight}
color={sparklineColor}
fillColor={sparklineFill}
/>
</div>
)}
</div>
);
};

View File

@@ -54,11 +54,20 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
return (
<div
className="mx-3 mt-3 mb-2 rounded-lg bg-white border border-gray-200"
className="mx-3 mt-3 mb-2 rounded-lg bg-white border border-gray-200 overflow-hidden"
style={{
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.04)",
}}
>
{/* Accent top bar */}
<div
className="h-0.5"
style={{
background: isEditMode
? "linear-gradient(90deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%)"
: "linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)",
}}
></div>
{/* Top row: Dashboard name + action buttons */}
<div className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
@@ -66,10 +75,16 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
{props.dashboardName}
</h1>
{isEditMode && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mr-1.5"></span>
Editing
</span>
)}
{hasComponents && !isEditMode && (
<span className="text-xs text-gray-400 tabular-nums">
{props.dashboardViewConfig.components.length} widget{props.dashboardViewConfig.components.length !== 1 ? "s" : ""}
</span>
)}
{/* Refreshing indicator */}
{props.isRefreshing &&
props.autoRefreshInterval !== AutoRefreshInterval.OFF && (