mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
improve ui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user