From d0ef3539939a8177211d3addabc18ab4c903af9e Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Wed, 25 Mar 2026 21:17:48 +0000 Subject: [PATCH 01/26] feat: enhance dashboard capabilities with unified query plugin interface and support for Perses/Grafana import/export --- Internal/Roadmap/Dashboards.md | 216 +++++++++++++++++++++++++++------ 1 file changed, 177 insertions(+), 39 deletions(-) diff --git a/Internal/Roadmap/Dashboards.md b/Internal/Roadmap/Dashboards.md index 8db64ba417..1eb859ffd1 100644 --- a/Internal/Roadmap/Dashboards.md +++ b/Internal/Roadmap/Dashboards.md @@ -36,6 +36,7 @@ The following features have been implemented: | Threshold lines / color coding | None | Yes | Yes | Yes | **P0** | | Legend interaction (show/hide) | None | Yes | Yes | Yes | **P0** | | Chart zoom | None | Yes | Yes | Yes | **P0** | +| Unified query plugin interface | None | Datasource plugins | Yes | NRQL | **P0** | | Dashboard linking / drill-down | None | Data links | Yes | Facet linking | **P1** | | Annotations / event overlays | None | Yes | Yes | Yes (Labs) | **P1** | | Row/section grouping | None | Collapsible rows | Groups | No | **P1** | @@ -46,12 +47,99 @@ The following features have been implemented: | TV/Kiosk mode | Full-screen only | Kiosk mode | Yes | Auto-cycling | **P1** | | CSV export | None | Yes | Yes | Yes | **P1** | | Custom time per widget | None | No | No | No | **P1** | +| Perses/Grafana import | None | N/A | No | No | **P1** | | AI dashboard creation | None | None | None | None | **P2** | | Dashboard-as-code SDK | None | Foundation SDK | No | No | **P2** | | Terraform provider | None | Yes | Yes | Yes | **P2** | --- +## Architecture: Query Plugin Interface & Perses Compatibility + +Before implementing features, we should establish a `QueryPlugin` interface that all widget data sources use. This is a foundational architectural change that enables Phase 2 (logs, traces) and Phase 4 (Dashboard-as-Code) cleanly. + +### Why Not Adopt Perses Wholesale? + +[Perses](https://perses.dev) is a CNCF Sandbox project providing an open dashboard specification and embeddable UI components. We evaluated it as a potential protocol for our dashboard system. The decision is to **selectively borrow patterns** rather than adopt it fully: + +**Against full adoption:** +- Our `AggregateBy` API queries ClickHouse directly. Perses assumes Prometheus/PromQL as the primary query language — mapping our ClickHouse aggregation queries into Perses's `PrometheusTimeSeriesQuery` plugin model adds unnecessary indirection. +- Phase 2 (click-to-correlate, cross-signal correlation) is our biggest differentiator. Perses has basic Tempo/Loki plugins but nothing like unified correlation. Adopting their panel model would constrain our ability to build these features. +- Perses is still CNCF Sandbox stage with data model structs marked deprecated in favor of a new `perses/spec` repo. The spec is not yet stable enough to build a product on. +- Maintaining a translation layer between Perses spec and our internal `DashboardViewConfig` format for every feature would add ongoing overhead. + +**What we selectively adopt:** + +| Perses Concept | Where It Helps | How | +|---|---|---| +| `kind`+`spec` plugin pattern | Phase 1.9 (QueryPlugin), Phase 2.1-2.2 | Formalize widget data sources as plugins instead of hardcoding every widget type | +| Variable model with scoping | Phase 1.2 (Template Variables) | Adopt query-based, list, and text variable types with dashboard → project → global scoping | +| Decoupled layout from panels | Phase 3.4 (Sections) | Separate panel definitions from grid positions to make sections/grouping cleaner | +| Dashboard JSON schema | Phase 3.2 (Import/Export) | Support importing Perses-format dashboards alongside native format for Grafana migration path | + +### 1.9 Unified Query Plugin Interface + +**Current**: Widgets are hardcoded to query metrics via `MetricQueryConfigData` and the `AggregateBy` API. Adding logs or traces as data sources requires duplicating the entire query path. +**Target**: A `QueryPlugin` interface that abstracts data sources, enabling any widget to query metrics, logs, or traces through a unified contract. + +**Design**: + +```typescript +// The plugin pattern borrowed from Perses: kind + spec +interface QueryPlugin { + kind: "MetricQuery" | "LogQuery" | "TraceQuery" | "FormulaQuery"; + spec: MetricQuerySpec | LogQuerySpec | TraceQuerySpec | FormulaQuerySpec; +} + +interface MetricQuerySpec { + metricName: string; + attributes: JSONObject; + aggregationType: AggregationType; + groupBy?: string[]; +} + +interface LogQuerySpec { + severityFilter?: SeverityLevel[]; + serviceFilter?: string[]; + bodyContains?: string; + attributes?: JSONObject; +} + +interface TraceQuerySpec { + serviceFilter?: string[]; + operationFilter?: string[]; + statusFilter?: TraceStatus[]; + minDuration?: Duration; +} + +interface FormulaQuerySpec { + formula: string; // e.g., "a / b * 100" + queries: Record; // named sub-queries +} + +// Each widget stores an array of QueryPlugins instead of MetricQueryConfigData +interface DashboardWidgetConfig { + queries: QueryPlugin[]; + // ... other widget config +} +``` + +**Benefits**: +- Log stream and trace list widgets (Phase 2.1, 2.2) plug in without new query plumbing +- Cross-signal correlation (Phase 2.3) becomes a multi-query widget with mixed `kind` values +- Formula queries (Phase 1.4) compose naturally across query types +- Future data sources (e.g., external Prometheus, custom APIs) add a new `kind` without touching widget code +- Aligns with Perses's extensibility model without coupling to their spec + +**Files to modify**: +- `Common/Types/Dashboard/QueryPlugin.ts` (new - interface definitions) +- `Common/Types/Metrics/MetricsQuery.ts` (refactor to implement MetricQuerySpec) +- `Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts` (add query resolver that dispatches by `kind`) +- `Common/Server/API/BaseAnalyticsAPI.ts` (add unified query endpoint) +- `App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics.ts` (refactor fetchResults to use QueryPlugin) + +--- + ## Phase 1: Foundation (P0) — Close Critical Gaps These gaps make OneUptime dashboards fundamentally non-competitive. Every major competitor has these. @@ -98,6 +186,7 @@ Each chart type needs: - Variables can be referenced in metric queries as `$variable_name` - When a variable changes, all widgets re-query with the new value - Support cascading variables (variable B's query depends on variable A's value) +- **Scoping model (from Perses)**: Variables can be defined at dashboard, project, or global scope. Dashboard-level overrides project-level, which overrides global. This lets teams define org-wide variables (e.g., `$environment`) once and reuse across dashboards. **Files to modify**: - `Common/Types/Dashboard/DashboardVariable.ts` (new) @@ -124,22 +213,24 @@ Each chart type needs: - `App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx` (implement refresh timer) - `Common/Types/Dashboard/DashboardViewConfig.ts` (store refresh interval) -### 1.4 Multiple Queries per Chart +### 1.4 Multiple Queries per Chart with Formulas **Current**: Single `MetricQueryConfigData` per chart. -**Target**: Overlay multiple metric series on a single chart for correlation. +**Target**: Overlay multiple metric series on a single chart for correlation, with cross-query formulas. **Implementation**: -- Change chart component's data source from single `MetricQueryConfigData` to `MetricQueryConfigData[]` +- Change chart component's data source from single `MetricQueryConfigData` to `QueryPlugin[]` (using the new unified interface) - Each query gets its own alias and legend entry -- Support formula references across queries (e.g., `a / b * 100`) +- Support `FormulaQuery` plugin kind for cross-query formulas (e.g., `a / b * 100` where `a` and `b` reference other queries by alias) - Y-axis: support dual Y-axes for metrics with different scales +- Formula evaluation happens server-side to avoid shipping raw data to the client **Files to modify**: -- `Common/Utils/Dashboard/Components/DashboardChartComponent.ts` (change to array) +- `Common/Utils/Dashboard/Components/DashboardChartComponent.ts` (change to QueryPlugin array) - `App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx` (render multiple series) - `App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/ComponentSettingsSideOver.tsx` (multi-query config UI) +- `Common/Server/Services/FormulaEvaluator.ts` (new - server-side formula evaluation) ### 1.5 Full Markdown Support for Text Widget @@ -208,7 +299,7 @@ Each chart type needs: ## Phase 2: Observability Integration (P0-P1) — Leverage the Full Platform -This is where OneUptime can differentiate: metrics, logs, and traces in one platform. +This is where OneUptime can differentiate: metrics, logs, and traces in one platform. The `QueryPlugin` interface from Phase 1.9 makes this phase significantly easier — each new signal type is a new `kind` in the plugin system rather than a new query pipeline. ### 2.1 Log Stream Widget @@ -218,6 +309,7 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat **Implementation**: - New `DashboardComponentType.LogStream` widget type +- Uses `QueryPlugin` with `kind: "LogQuery"` — same interface as metric widgets - Configuration: log query filter, severity filter, service filter, max rows - Renders as a scrolling log list with severity color coding, timestamp, and body - Click a log entry to expand and see full details @@ -227,6 +319,7 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - `Common/Types/Dashboard/DashboardComponentType.ts` (add LogStream) - `Common/Utils/Dashboard/Components/DashboardLogStreamComponent.ts` (new - config) - `App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardLogStreamComponent.tsx` (new - rendering) +- `Common/Server/Services/LogQueryResolver.ts` (new - implements QueryPlugin for logs) ### 2.2 Trace List Widget @@ -236,6 +329,7 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat **Implementation**: - New `DashboardComponentType.TraceList` widget type +- Uses `QueryPlugin` with `kind: "TraceQuery"` — same interface as metric and log widgets - Configuration: service filter, operation filter, status filter, min duration - Renders as a table: trace ID, operation, service, duration, status, timestamp - Click a row to navigate to the full trace view @@ -245,6 +339,7 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - `Common/Types/Dashboard/DashboardComponentType.ts` (add TraceList) - `Common/Utils/Dashboard/Components/DashboardTraceListComponent.ts` (new) - `App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTraceListComponent.tsx` (new) +- `Common/Server/Services/TraceQueryResolver.ts` (new - implements QueryPlugin for traces) ### 2.3 Click-to-Correlate Across Signals @@ -257,8 +352,10 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - Logs from the same service and time window (+/- 5 minutes around the clicked point) - Traces from the same service and time window - Filtered by the same template variables +- The correlation panel uses the `QueryPlugin` interface internally — it fires a `LogQuery` and `TraceQuery` scoped to the clicked timestamp and service context - The correlation panel appears as a slide-over or split view below the chart - This is a major differentiator vs Grafana (which requires separate datasources) and ties into OneUptime's all-in-one advantage +- No competitor, including Perses, has this level of built-in cross-signal correlation **Files to modify**: - `App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx` (add click handler) @@ -337,21 +434,25 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - `Common/Models/DatabaseModels/Dashboard.ts` (add isPublic, publicAccessToken) - `App/FeatureSet/Dashboard/src/Pages/Public/Dashboard.tsx` (new - public dashboard view) -### 3.2 JSON Import/Export +### 3.2 JSON Import/Export with Perses & Grafana Compatibility **Current**: No import/export capability. -**Target**: Export dashboards as JSON and re-import for backup, migration, and dashboard-as-code. +**Target**: Export dashboards as JSON and re-import for backup, migration, and dashboard-as-code. Support importing Perses and Grafana dashboard formats. **Implementation**: -- Export: serialize `dashboardViewConfig` + metadata (name, description, variables) as a JSON file download -- Import: upload a JSON file, validate schema, create a new dashboard from the config +- **Native export**: Serialize `dashboardViewConfig` + metadata (name, description, variables) as a JSON file download. Include a schema version for forward compatibility. +- **Perses-compatible export**: Alongside native format, output a Perses-spec-compatible JSON. This gives users interoperability with the CNCF ecosystem without coupling our internals. Map our `QueryPlugin` kinds to Perses panel plugin types where possible. +- **Grafana import**: Perses already has tooling to convert Grafana dashboards to Perses format. By supporting Perses import, we get Grafana migration for free: Grafana → Perses → OneUptime. +- **Import pipeline**: Upload a JSON file → detect format (native, Perses, Grafana) → translate to `DashboardViewConfig` → validate → create dashboard. - Handle version compatibility (include a schema version in the export) **Files to modify**: - `App/FeatureSet/Dashboard/src/Pages/Dashboards/Dashboards.tsx` (add import button) - `App/FeatureSet/Dashboard/src/Pages/Dashboards/View/Settings.tsx` (add export button) - `Common/Server/API/DashboardImportExportAPI.ts` (new) +- `Common/Server/Utils/Dashboard/PersesConverter.ts` (new - bidirectional Perses format conversion) +- `Common/Server/Utils/Dashboard/GrafanaConverter.ts` (new - Grafana JSON to native format) ### 3.3 Dashboard Versioning @@ -371,22 +472,25 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - `Common/Server/Services/DashboardService.ts` (create version on save) - `App/FeatureSet/Dashboard/src/Pages/Dashboards/View/VersionHistory.tsx` (new) -### 3.4 Row/Section Grouping +### 3.4 Row/Section Grouping with Decoupled Layout -**Current**: Components placed freely with no grouping. -**Target**: Collapsible rows/sections for organizing related panels. +**Current**: Components placed freely with no grouping. Panel definitions and grid positions are mixed together in each component. +**Target**: Collapsible rows/sections for organizing related panels, with layout decoupled from panel definitions. **Implementation**: +- **Decouple layout from panels** (pattern from Perses): Separate panel definitions (what to render) from layout definitions (where to render it). Panels are stored in a `panels` map keyed by ID. Layouts reference panels by `$ref`. This makes it easier to rearrange panels without modifying their query/display config. - Add a "Section" component type that acts as a collapsible container - Section has a title bar that can be clicked to collapse/expand - When collapsed, hides all components within the section's vertical range - Sections can be nested one level deep +- Migration: existing `DashboardViewConfig` components are automatically split into panel + layout entries on first load **Files to modify**: +- `Common/Types/Dashboard/DashboardViewConfig.ts` (add panels map + layouts array, deprecate inline component positions) - `Common/Types/Dashboard/DashboardComponentType.ts` (add Section) - `App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardSectionComponent.tsx` (new) -- `App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx` (handle section collapse) +- `App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx` (handle section collapse, resolve panel refs) ### 3.5 TV/Kiosk Mode @@ -489,10 +593,10 @@ This is where OneUptime can differentiate: metrics, logs, and traces in one plat - Respect public/private data boundaries (only show metrics the customer should see) - This is unique to OneUptime - no competitor has integrated observability dashboards with status pages -### 4.5 Dashboard-as-Code SDK +### 4.5 Dashboard-as-Code SDK (Perses-Compatible) **Current**: No programmatic dashboard creation. -**Target**: TypeScript SDK for defining dashboards as code. +**Target**: TypeScript SDK for defining dashboards as code, with optional Perses-compatible output. **Implementation**: @@ -504,10 +608,18 @@ const dashboard = new Dashboard("Service Health") .addChart({ metric: "http.server.duration", aggregation: "p50", groupBy: ["$service"] }) .addRow("Throughput") .addChart({ metric: "http.server.request.count", aggregation: "rate", groupBy: ["$service"] }) + +// Output native OneUptime format +dashboard.toJSON(); + +// Output Perses-compatible format for ecosystem interop +dashboard.toPerses(); ``` - SDK generates the JSON config and uses the Dashboard API to create/update - Git-based provisioning: store dashboard definitions in repo, CI/CD syncs to OneUptime +- `toPerses()` output allows users to share dashboard definitions with teams using Perses or other CNCF-compatible tools +- Perses's CUE SDK patterns can inform our builder API design ### 4.6 Anomaly Detection Overlays @@ -520,6 +632,18 @@ const dashboard = new Dashboard("Service Health") - Highlight data points outside the expected range with color indicators - Click an anomaly to see correlated changes across metrics, logs, and traces +### 4.7 Terraform / OpenTofu Provider + +**Current**: No infrastructure-as-code support for dashboards. +**Target**: Manage dashboards via Terraform/OpenTofu for GitOps workflows. + +**Implementation**: + +- Expose dashboard CRUD via a well-documented REST API (already exists) +- Build a Terraform provider that maps dashboard resources to the API +- Support `oneuptime_dashboard`, `oneuptime_dashboard_variable`, and `oneuptime_dashboard_template` resources +- This complements the Dashboard-as-Code SDK (4.5) — SDK for developers, Terraform for ops teams + --- ## Quick Wins (Can Ship This Week) @@ -534,35 +658,49 @@ const dashboard = new Dashboard("Service Health") ## Recommended Implementation Order -1. **Quick Wins** - Auto-refresh, markdown, legend toggle, stacked area, chart zoom -2. **Phase 1.1** - More chart types (Area, Pie, Table, Gauge) -3. **Phase 1.2** - Template variables (highest-impact feature for dashboard usability) -4. **Phase 1.4** - Multiple queries per chart -5. **Phase 1.6** - Threshold lines & color coding -6. **Phase 2.1** - Log stream widget (leverages all-in-one platform) -7. **Phase 2.2** - Trace list widget -8. **Phase 2.3** - Click-to-correlate (major differentiator) -9. **Phase 2.4** - Annotations / event overlays -10. **Phase 2.5** - Alert integration -11. **Phase 3.1** - Public/shared dashboards -12. **Phase 3.2** - JSON import/export -13. **Phase 3.4** - Row/section grouping -14. **Phase 3.5** - TV/Kiosk mode -15. **Phase 3.3** - Dashboard versioning -16. **Phase 2.6** - SLO widget (depends on SLO/SLI from Metrics roadmap) -17. **Phase 4.2** - Pre-built dashboard templates -18. **Phase 4.3** - Auto-generated dashboards -19. **Phase 4.1** - AI-powered dashboard creation -20. **Phase 4.4** - Customer-facing dashboards on status pages -21. **Phase 4.5** - Dashboard-as-code SDK +### Phase 0: Architecture Foundation +1. **Phase 1.9** - QueryPlugin interface (enables everything else; do this first) + +### Phase 1: Core Features +2. **Quick Wins** - Auto-refresh, markdown, legend toggle, stacked area, chart zoom +3. **Phase 1.1** - More chart types (Area, Pie, Table, Gauge) +4. **Phase 1.2** - Template variables with scoping (highest-impact feature for dashboard usability) +5. **Phase 1.4** - Multiple queries per chart with formulas +6. **Phase 1.6** - Threshold lines & color coding + +### Phase 2: Platform Leverage (Differentiators) +7. **Phase 2.1** - Log stream widget (leverages all-in-one platform + QueryPlugin) +8. **Phase 2.2** - Trace list widget (leverages all-in-one platform + QueryPlugin) +9. **Phase 2.3** - Click-to-correlate (major differentiator — no competitor has this built-in) +10. **Phase 2.4** - Annotations / event overlays +11. **Phase 2.5** - Alert integration + +### Phase 3: Collaboration +12. **Phase 3.1** - Public/shared dashboards +13. **Phase 3.2** - JSON import/export with Perses & Grafana compatibility +14. **Phase 3.4** - Row/section grouping with decoupled layout +15. **Phase 3.5** - TV/Kiosk mode +16. **Phase 3.3** - Dashboard versioning +17. **Phase 2.6** - SLO widget (depends on SLO/SLI from Metrics roadmap) + +### Phase 4: Differentiation +18. **Phase 4.2** - Pre-built dashboard templates +19. **Phase 4.3** - Auto-generated dashboards +20. **Phase 4.1** - AI-powered dashboard creation +21. **Phase 4.4** - Customer-facing dashboards on status pages +22. **Phase 4.5** - Dashboard-as-code SDK (Perses-compatible) +23. **Phase 4.7** - Terraform / OpenTofu provider +24. **Phase 4.6** - Anomaly detection overlays ## Verification For each feature: -1. Unit tests for new widget types, template variable resolution, CSV export logic -2. Integration tests for new API endpoints (annotations, public dashboards, import/export) +1. Unit tests for new widget types, template variable resolution, CSV export logic, QueryPlugin dispatching +2. Integration tests for new API endpoints (annotations, public dashboards, import/export, Perses/Grafana conversion) 3. Manual verification via the dev server at `https://oneuptimedev.genosyn.com/dashboard/{projectId}/dashboards` 4. Visual regression testing for new chart types (ensure correct rendering across browsers) 5. Performance testing: verify dashboards with 20+ widgets and auto-refresh don't cause excessive API load 6. Test template variables with edge cases: empty results, special characters, multi-value selections 7. Verify public dashboards don't leak private data +8. Test Perses/Grafana import with real-world dashboard exports to validate conversion fidelity +9. Test QueryPlugin interface with mixed query types (metric + log + trace) on a single dashboard From be90693ad82ac21f8e89162613144b5b32a80c19 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Wed, 25 Mar 2026 21:38:41 +0000 Subject: [PATCH 02/26] feat: Add Kubernetes resource breakdown to MetricMonitorResponse - Introduced KubernetesAffectedResource and KubernetesResourceBreakdown interfaces to enhance metric monitoring capabilities. - Updated MetricMonitorResponse to include kubernetesResourceBreakdown. - Enhanced AreaChart, BarChart, and LineChart components with onValueChange prop for better interactivity. - Added new Dashboard components: Gauge and Table, with respective configuration arguments. - Implemented DashboardVariableSelector for dynamic variable selection in dashboards. - Added warning and critical thresholds to DashboardValueComponent and DashboardChartComponent for improved data visualization. - Updated DashboardChartComponentUtil to support additional queries and chart types. - Enhanced error handling and loading states in Dashboard components. --- .../src/Components/Dashboard/Canvas/Index.tsx | 2 + .../Components/DashboardBaseComponent.tsx | 21 ++ .../Components/DashboardChartComponent.tsx | 104 ++++-- .../Components/DashboardGaugeComponent.tsx | 264 ++++++++++++++++ .../Components/DashboardTableComponent.tsx | 191 +++++++++++ .../Components/DashboardTextComponent.tsx | 9 + .../Components/DashboardValueComponent.tsx | 28 +- .../Components/Dashboard/DashboardView.tsx | 136 +++++++- .../Dashboard/Toolbar/DashboardToolbar.tsx | 88 +++++- .../Toolbar/DashboardVariableSelector.tsx | 65 ++++ .../Form/Monitor/CriteriaFilter.tsx | 44 ++- .../Form/Monitor/CriteriaFilters.tsx | 48 ++- .../Form/Monitor/MonitorCriteriaInstance.tsx | 24 +- .../src/Components/Metrics/MetricCharts.tsx | 15 +- .../Utils/Monitor/MonitorCriteriaEvaluator.ts | 296 ++++++++++++++++++ Common/Types/Dashboard/Chart/ChartType.ts | 5 + .../Types/Dashboard/DashboardComponentType.ts | 2 + .../DashboardComponents/ComponentArgument.ts | 1 + .../DashboardChartComponent.ts | 9 + .../DashboardGaugeComponent.ts | 17 + .../DashboardTableComponent.ts | 14 + .../DashboardTextComponent.ts | 1 + .../DashboardValueComponent.ts | 2 + Common/Types/Dashboard/DashboardVariable.ts | 23 ++ Common/Types/Dashboard/DashboardViewConfig.ts | 59 ++++ Common/Types/Metrics/MetricQueryConfigData.ts | 1 + .../Types/Monitor/KubernetesAlertTemplates.ts | 83 ++++- .../MetricMonitor/MetricMonitorResponse.ts | 20 ++ .../UI/Components/Charts/Area/AreaChart.tsx | 1 + Common/UI/Components/Charts/Bar/BarChart.tsx | 1 + .../UI/Components/Charts/Line/LineChart.tsx | 1 + .../Components/DashboardChartComponent.ts | 42 +++ .../Components/DashboardGaugeComponent.ts | 99 ++++++ .../Components/DashboardTableComponent.ts | 69 ++++ .../Components/DashboardTextComponent.ts | 11 + .../Components/DashboardValueComponent.ts | 20 ++ Common/Utils/Dashboard/Components/Index.ts | 14 + .../MonitorTelemetryMonitor.ts | 135 +++++++- 38 files changed, 1891 insertions(+), 74 deletions(-) create mode 100644 App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx create mode 100644 App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardVariableSelector.tsx create mode 100644 Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent.ts create mode 100644 Common/Types/Dashboard/DashboardComponents/DashboardTableComponent.ts create mode 100644 Common/Types/Dashboard/DashboardVariable.ts create mode 100644 Common/Utils/Dashboard/Components/DashboardGaugeComponent.ts create mode 100644 Common/Utils/Dashboard/Components/DashboardTableComponent.ts diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx index 54aa822aed..d546472b84 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Canvas/Index.tsx @@ -25,6 +25,7 @@ export interface ComponentProps { telemetryAttributes: string[]; }; dashboardStartAndEndDate: RangeStartAndEndDateTime; + refreshTick?: number | undefined; } const DashboardCanvas: FunctionComponent = ( @@ -221,6 +222,7 @@ const DashboardCanvas: FunctionComponent = ( updateComponent(updatedComponent); }} isSelected={isSelected} + refreshTick={props.refreshTick} onClick={() => { // component is selected props.onComponentSelected(componentId); diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx index f9821932f8..6868ade163 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent.tsx @@ -2,10 +2,14 @@ import React, { FunctionComponent, ReactElement, useEffect } from "react"; import DashboardTextComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent"; import DashboardChartComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardChartComponent"; import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent"; +import DashboardTableComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent"; +import DashboardGaugeComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent"; import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent"; import DashboardChartComponent from "./DashboardChartComponent"; import DashboardValueComponent from "./DashboardValueComponent"; import DashboardTextComponent from "./DashboardTextComponent"; +import DashboardTableComponent from "./DashboardTableComponent"; +import DashboardGaugeComponent from "./DashboardGaugeComponent"; import DefaultDashboardSize, { GetDashboardComponentHeightInDashboardUnits, GetDashboardComponentWidthInDashboardUnits, @@ -37,6 +41,7 @@ export interface DashboardBaseComponentProps { dashboardViewConfig: DashboardViewConfig; dashboardStartAndEndDate: RangeStartAndEndDateTime; metricTypes: Array; + refreshTick?: number | undefined; } export interface ComponentProps extends DashboardBaseComponentProps { @@ -404,6 +409,22 @@ const DashboardBaseComponentElement: FunctionComponent = ( component={component as DashboardValueComponentType} /> )} + {component.componentType === DashboardComponentType.Table && ( + + )} + {component.componentType === DashboardComponentType.Gauge && ( + + )} {getResizeWidthElement()} {getResizeHeightElement()} diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx index 8230e13d95..62e0b58a98 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardChartComponent.tsx @@ -31,10 +31,24 @@ const DashboardChartComponentElement: FunctionComponent = ( const [error, setError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); + // Resolve query configs - support both single and multi-query + const resolveQueryConfigs: () => Array = () => { + if ( + props.component.arguments.metricQueryConfigs && + props.component.arguments.metricQueryConfigs.length > 0 + ) { + return props.component.arguments.metricQueryConfigs; + } + if (props.component.arguments.metricQueryConfig) { + return [props.component.arguments.metricQueryConfig]; + } + return []; + }; + + const queryConfigs: Array = resolveQueryConfigs(); + const metricViewData: MetricViewData = { - queryConfigs: props.component.arguments.metricQueryConfig - ? [props.component.arguments.metricQueryConfig] - : [], + queryConfigs: queryConfigs, startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( props.dashboardStartAndEndDate, ), @@ -97,24 +111,36 @@ const DashboardChartComponentElement: FunctionComponent = ( useEffect(() => { fetchAggregatedResults(); - }, [props.dashboardStartAndEndDate, props.metricTypes]); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); - const [metricQueryConfig, setMetricQueryConfig] = React.useState< - MetricQueryConfigData | undefined - >(props.component.arguments.metricQueryConfig); + const [prevQueryConfigs, setPrevQueryConfigs] = React.useState< + Array | MetricQueryConfigData | undefined + >( + props.component.arguments.metricQueryConfigs || + props.component.arguments.metricQueryConfig, + ); useEffect(() => { - // set metricQueryConfig to the new value only if it is different from the previous value + const currentConfigs: + | Array + | MetricQueryConfigData + | undefined = + props.component.arguments.metricQueryConfigs || + props.component.arguments.metricQueryConfig; + if ( JSONFunctions.isJSONObjectDifferent( - metricQueryConfig || {}, - props.component.arguments.metricQueryConfig || {}, + prevQueryConfigs || {}, + currentConfigs || {}, ) ) { - setMetricQueryConfig(props.component.arguments.metricQueryConfig); + setPrevQueryConfigs(currentConfigs); fetchAggregatedResults(); } - }, [props.component.arguments.metricQueryConfig]); + }, [ + props.component.arguments.metricQueryConfig, + props.component.arguments.metricQueryConfigs, + ]); useEffect(() => { fetchAggregatedResults(); @@ -142,35 +168,57 @@ const DashboardChartComponentElement: FunctionComponent = ( heightOfChart = undefined; } - // add title and description. - type GetMetricChartType = () => MetricChartType; - // Convert dashboard chart type to metric chart type const getMetricChartType: GetMetricChartType = (): MetricChartType => { if (props.component.arguments.chartType === DashboardChartType.Bar) { return MetricChartType.BAR; } + if ( + props.component.arguments.chartType === DashboardChartType.Area || + props.component.arguments.chartType === DashboardChartType.StackedArea + ) { + return MetricChartType.AREA; + } return MetricChartType.LINE; }; const chartMetricViewData: MetricViewData = { - queryConfigs: props.component.arguments.metricQueryConfig - ? [ - { - ...props.component.arguments.metricQueryConfig!, + queryConfigs: queryConfigs.map( + (config: MetricQueryConfigData, index: number) => { + // For the first query, apply the chart-level title/description/legend + if (index === 0) { + return { + ...config, metricAliasData: { - title: props.component.arguments.chartTitle || undefined, + title: + config.metricAliasData?.title || + props.component.arguments.chartTitle || + undefined, description: - props.component.arguments.chartDescription || undefined, - metricVariable: undefined, - legend: props.component.arguments.legendText || undefined, - legendUnit: props.component.arguments.legendUnit || undefined, + config.metricAliasData?.description || + props.component.arguments.chartDescription || + undefined, + metricVariable: + config.metricAliasData?.metricVariable || undefined, + legend: + config.metricAliasData?.legend || + props.component.arguments.legendText || + undefined, + legendUnit: + config.metricAliasData?.legendUnit || + props.component.arguments.legendUnit || + undefined, }, - chartType: getMetricChartType(), - }, - ] - : [], + chartType: config.chartType || getMetricChartType(), + }; + } + return { + ...config, + chartType: config.chartType || getMetricChartType(), + }; + }, + ), startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( props.dashboardStartAndEndDate, ), diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx new file mode 100644 index 0000000000..29f3a5932d --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardGaugeComponent.tsx @@ -0,0 +1,264 @@ +import React, { FunctionComponent, ReactElement, useEffect } from "react"; +import DashboardGaugeComponent from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent"; +import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricUtil from "../../Metrics/Utils/Metrics"; +import API from "Common/UI/Utils/API/API"; +import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader"; +import JSONFunctions from "Common/Types/JSONFunctions"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import AggregationType from "Common/Types/BaseDatabase/AggregationType"; +import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime"; + +export interface ComponentProps extends DashboardBaseComponentProps { + component: DashboardGaugeComponent; +} + +const DashboardGaugeComponentElement: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [metricResults, setMetricResults] = React.useState< + Array + >([]); + const [aggregationType, setAggregationType] = + React.useState(AggregationType.Avg); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + + const metricViewData: MetricViewData = { + queryConfigs: props.component.arguments.metricQueryConfig + ? [props.component.arguments.metricQueryConfig] + : [], + startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( + props.dashboardStartAndEndDate, + ), + formulaConfigs: [], + }; + + const fetchAggregatedResults: PromiseVoidFunction = + async (): Promise => { + setIsLoading(true); + + if ( + !metricViewData.startAndEndDate?.startValue || + !metricViewData.startAndEndDate?.endValue + ) { + setIsLoading(false); + return; + } + + if ( + !metricViewData.queryConfigs || + metricViewData.queryConfigs.length === 0 || + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData) + .length === 0 + ) { + setIsLoading(false); + return; + } + + if ( + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + !metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType + ) { + setIsLoading(false); + return; + } + + setAggregationType( + (metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType as AggregationType) || AggregationType.Avg, + ); + + try { + const results: Array = await MetricUtil.fetchResults({ + metricViewData: metricViewData, + }); + + setMetricResults(results); + setError(""); + } catch (err: unknown) { + setError(API.getFriendlyErrorMessage(err as Error)); + } + + setIsLoading(false); + }; + + const [metricQueryConfig, setMetricQueryConfig] = React.useState< + MetricQueryConfigData | undefined + >(props.component.arguments.metricQueryConfig); + + useEffect(() => { + fetchAggregatedResults(); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); + + useEffect(() => { + fetchAggregatedResults(); + }, []); + + useEffect(() => { + if ( + JSONFunctions.isJSONObjectDifferent( + metricQueryConfig || {}, + props.component.arguments.metricQueryConfig || {}, + ) + ) { + setMetricQueryConfig(props.component.arguments.metricQueryConfig); + fetchAggregatedResults(); + } + }, [props.component.arguments.metricQueryConfig]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + // Calculate aggregated value + let aggregatedValue: number = 0; + let avgCount: number = 0; + + for (const result of metricResults) { + for (const item of result.data) { + const value: number = item.value; + + if (aggregationType === AggregationType.Avg) { + aggregatedValue += value; + avgCount += 1; + } else if (aggregationType === AggregationType.Sum) { + aggregatedValue += value; + } else if (aggregationType === AggregationType.Min) { + aggregatedValue = Math.min(aggregatedValue, value); + } else if (aggregationType === AggregationType.Max) { + aggregatedValue = Math.max(aggregatedValue, value); + } else if (aggregationType === AggregationType.Count) { + aggregatedValue += 1; + } + } + } + + if (aggregationType === AggregationType.Avg && avgCount > 0) { + aggregatedValue = aggregatedValue / avgCount; + } + + aggregatedValue = Math.round(aggregatedValue * 100) / 100; + + const minValue: number = props.component.arguments.minValue ?? 0; + const maxValue: number = props.component.arguments.maxValue ?? 100; + const warningThreshold: number | undefined = + props.component.arguments.warningThreshold; + const criticalThreshold: number | undefined = + props.component.arguments.criticalThreshold; + + // Calculate percentage for the gauge arc + const range: number = maxValue - minValue; + const percentage: number = + range > 0 + ? Math.min(Math.max((aggregatedValue - minValue) / range, 0), 1) + : 0; + + // Determine color based on thresholds + let gaugeColor: string = "#10b981"; // green + if ( + criticalThreshold !== undefined && + aggregatedValue >= criticalThreshold + ) { + gaugeColor = "#ef4444"; // red + } else if ( + warningThreshold !== undefined && + aggregatedValue >= warningThreshold + ) { + gaugeColor = "#f59e0b"; // yellow + } + + // SVG gauge rendering + const size: number = Math.min( + props.dashboardComponentWidthInPx - 20, + props.dashboardComponentHeightInPx - 50, + ); + const gaugeSize: number = Math.max(size, 60); + const strokeWidth: number = Math.max(gaugeSize * 0.12, 8); + const radius: number = (gaugeSize - strokeWidth) / 2; + const centerX: number = gaugeSize / 2; + const centerY: number = gaugeSize / 2; + + // Semi-circle arc (180 degrees, from left to right) + const startAngle: number = Math.PI; + const endAngle: number = 0; + const sweepAngle: number = startAngle - endAngle; + const currentAngle: number = startAngle - sweepAngle * percentage; + + const arcStartX: number = centerX + radius * Math.cos(startAngle); + const arcStartY: number = centerY - radius * Math.sin(startAngle); + const arcEndX: number = centerX + radius * Math.cos(endAngle); + const arcEndY: number = centerY - radius * Math.sin(endAngle); + const arcCurrentX: number = centerX + radius * Math.cos(currentAngle); + const arcCurrentY: number = centerY - radius * Math.sin(currentAngle); + + const backgroundPath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 0 1 ${arcEndX} ${arcEndY}`; + const valuePath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${percentage > 0.5 ? 1 : 0} 1 ${arcCurrentX} ${arcCurrentY}`; + + const titleHeightInPx: number = Math.max( + props.dashboardComponentHeightInPx * 0.1, + 12, + ); + const valueHeightInPx: number = Math.max(gaugeSize * 0.2, 14); + + return ( +
+ {props.component.arguments.gaugeTitle && ( +
0 ? `${titleHeightInPx}px` : "", + }} + className="text-center font-semibold text-gray-700 mb-1 truncate" + > + {props.component.arguments.gaugeTitle} +
+ )} + + + {percentage > 0 && ( + + )} + +
0 ? `${valueHeightInPx}px` : "", + marginTop: `-${gaugeSize * 0.15}px`, + }} + > + {aggregatedValue} +
+
+ ); +}; + +export default DashboardGaugeComponentElement; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx new file mode 100644 index 0000000000..f5721be604 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTableComponent.tsx @@ -0,0 +1,191 @@ +import React, { FunctionComponent, ReactElement, useEffect } from "react"; +import DashboardTableComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent"; +import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult"; +import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; +import MetricViewData from "Common/Types/Metrics/MetricViewData"; +import MetricUtil from "../../Metrics/Utils/Metrics"; +import API from "Common/UI/Utils/API/API"; +import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader"; +import JSONFunctions from "Common/Types/JSONFunctions"; +import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData"; +import Icon from "Common/UI/Components/Icon/Icon"; +import IconProp from "Common/Types/Icon/IconProp"; +import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime"; +import OneUptimeDate from "Common/Types/Date"; + +export interface ComponentProps extends DashboardBaseComponentProps { + component: DashboardTableComponent; +} + +const DashboardTableComponentElement: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [metricResults, setMetricResults] = React.useState< + Array + >([]); + const [error, setError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + + const metricViewData: MetricViewData = { + queryConfigs: props.component.arguments.metricQueryConfig + ? [props.component.arguments.metricQueryConfig] + : [], + startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate( + props.dashboardStartAndEndDate, + ), + formulaConfigs: [], + }; + + const fetchAggregatedResults: PromiseVoidFunction = + async (): Promise => { + setIsLoading(true); + + if ( + !metricViewData.startAndEndDate?.startValue || + !metricViewData.startAndEndDate?.endValue + ) { + setIsLoading(false); + setError("Please select a valid start and end date."); + return; + } + + if ( + !metricViewData.queryConfigs || + metricViewData.queryConfigs.length === 0 || + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData) + .length === 0 + ) { + setIsLoading(false); + setError("Please select a metric. Click here to add a metric."); + return; + } + + if ( + !metricViewData.queryConfigs[0] || + !metricViewData.queryConfigs[0].metricQueryData.filterData || + !metricViewData.queryConfigs[0].metricQueryData.filterData + ?.aggegationType + ) { + setIsLoading(false); + setError( + "Please select an aggregation. Click here to add an aggregation.", + ); + return; + } + + try { + const results: Array = await MetricUtil.fetchResults({ + metricViewData: metricViewData, + }); + + setMetricResults(results); + setError(""); + } catch (err: unknown) { + setError(API.getFriendlyErrorMessage(err as Error)); + } + + setIsLoading(false); + }; + + useEffect(() => { + fetchAggregatedResults(); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); + + const [metricQueryConfig, setMetricQueryConfig] = React.useState< + MetricQueryConfigData | undefined + >(props.component.arguments.metricQueryConfig); + + useEffect(() => { + if ( + JSONFunctions.isJSONObjectDifferent( + metricQueryConfig || {}, + props.component.arguments.metricQueryConfig || {}, + ) + ) { + setMetricQueryConfig(props.component.arguments.metricQueryConfig); + fetchAggregatedResults(); + } + }, [props.component.arguments.metricQueryConfig]); + + useEffect(() => { + fetchAggregatedResults(); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+
+ +
+ +
+ ); + } + + const maxRows: number = props.component.arguments.maxRows || 20; + + const allData: Array = []; + for (const result of metricResults) { + for (const item of result.data) { + allData.push(item); + } + } + + const displayData: Array = allData.slice(0, maxRows); + + return ( +
+ {props.component.arguments.tableTitle && ( +
+ {props.component.arguments.tableTitle} +
+ )} + + + + + + + + + {displayData.map((item: AggregatedModel, index: number) => { + return ( + + + + + ); + })} + {displayData.length === 0 && ( + + + + )} + +
TimestampValue
+ {OneUptimeDate.getDateAsLocalFormattedString( + OneUptimeDate.fromString(item.timestamp), + )} + + {Math.round(item.value * 100) / 100} +
+ No data available +
+
+ ); +}; + +export default DashboardTableComponentElement; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx index 05f65b34ea..a6ee3e23e9 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardTextComponent.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, ReactElement } from "react"; import DashboardTextComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent"; import { DashboardBaseComponentProps } from "./DashboardBaseComponent"; +import LazyMarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer"; export interface ComponentProps extends DashboardBaseComponentProps { component: DashboardTextComponent; @@ -9,6 +10,14 @@ export interface ComponentProps extends DashboardBaseComponentProps { const DashboardTextComponentElement: FunctionComponent = ( props: ComponentProps, ): ReactElement => { + if (props.component.arguments.isMarkdown) { + return ( +
+ +
+ ); + } + const textClassName: string = `m-auto truncate flex flex-col justify-center h-full ${props.component.arguments.isBold ? "font-medium" : ""} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline" : ""}`; const textHeightInxPx: number = props.dashboardComponentHeightInPx * 0.4; diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx index 8e1d0fd6df..25dce6daa3 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardValueComponent.tsx @@ -99,7 +99,7 @@ const DashboardValueComponent: FunctionComponent = ( useEffect(() => { fetchAggregatedResults(); - }, [props.dashboardStartAndEndDate, props.metricTypes]); + }, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]); useEffect(() => { fetchAggregatedResults(); @@ -173,8 +173,30 @@ const DashboardValueComponent: FunctionComponent = ( ); })?.unit || ""; + // Determine color based on thresholds + let valueColorClass: string = "text-gray-800"; + let bgColorClass: string = ""; + const warningThreshold: number | undefined = + props.component.arguments.warningThreshold; + const criticalThreshold: number | undefined = + props.component.arguments.criticalThreshold; + + if ( + criticalThreshold !== undefined && + aggregatedValue >= criticalThreshold + ) { + valueColorClass = "text-red-700"; + bgColorClass = "bg-red-50"; + } else if ( + warningThreshold !== undefined && + aggregatedValue >= warningThreshold + ) { + valueColorClass = "text-yellow-700"; + bgColorClass = "bg-yellow-50"; + } + return ( -
+
0 ? `${titleHeightInPx}px` : "", @@ -184,7 +206,7 @@ const DashboardValueComponent: FunctionComponent = ( {props.component.arguments.title || " "}
0 ? `${valueHeightInPx}px` : "", }} diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx index a8d5c0a607..5e4f7fa69f 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/DashboardView.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent, ReactElement, + useCallback, useEffect, useRef, useState, @@ -9,12 +10,17 @@ import DashboardToolbar from "./Toolbar/DashboardToolbar"; import DashboardCanvas from "./Canvas/Index"; import DashboardMode from "Common/Types/Dashboard/DashboardMode"; import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType"; -import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardViewConfig, { + AutoRefreshInterval, + getAutoRefreshIntervalInMs, +} from "Common/Types/Dashboard/DashboardViewConfig"; import { ObjectType } from "Common/Types/JSON"; import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent"; import DashboardChartComponentUtil from "Common/Utils/Dashboard/Components/DashboardChartComponent"; import DashboardValueComponentUtil from "Common/Utils/Dashboard/Components/DashboardValueComponent"; import DashboardTextComponentUtil from "Common/Utils/Dashboard/Components/DashboardTextComponent"; +import DashboardTableComponentUtil from "Common/Utils/Dashboard/Components/DashboardTableComponent"; +import DashboardGaugeComponentUtil from "Common/Utils/Dashboard/Components/DashboardGaugeComponent"; import BadDataException from "Common/Types/Exception/BadDataException"; import ObjectID from "Common/Types/ObjectID"; import Dashboard from "Common/Models/DatabaseModels/Dashboard"; @@ -30,6 +36,7 @@ import MetricUtil from "../Metrics/Utils/Metrics"; import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime"; import TimeRange from "Common/Types/Time/TimeRange"; import MetricType from "Common/Models/DatabaseModels/MetricType"; +import DashboardVariable from "Common/Types/Dashboard/DashboardVariable"; export interface ComponentProps { dashboardId: ObjectID; @@ -49,6 +56,23 @@ const DashboardViewer: FunctionComponent = ( const [isSaving, setIsSaving] = useState(false); + // Auto-refresh state + const [autoRefreshInterval, setAutoRefreshInterval] = + useState(AutoRefreshInterval.OFF); + const [isRefreshing, setIsRefreshing] = useState(false); + const [dashboardVariables, setDashboardVariables] = useState< + Array + >([]); + + // Zoom stack for time range + const [timeRangeStack, setTimeRangeStack] = useState< + Array + >([]); + const autoRefreshTimerRef: React.MutableRefObject | null> = useRef | null>(null); + const [refreshTick, setRefreshTick] = useState(0); + // ref for dashboard div. const dashboardViewRef: React.RefObject = @@ -140,13 +164,23 @@ const DashboardViewer: FunctionComponent = ( return; } - setDashboardViewConfig( - JSONFunctions.deserializeValue( - dashboard.dashboardViewConfig || - DashboardViewConfigUtil.createDefaultDashboardViewConfig(), - ) as DashboardViewConfig, - ); + const config: DashboardViewConfig = JSONFunctions.deserializeValue( + dashboard.dashboardViewConfig || + DashboardViewConfigUtil.createDefaultDashboardViewConfig(), + ) as DashboardViewConfig; + + setDashboardViewConfig(config); setDashboardName(dashboard.name || "Untitled Dashboard"); + + // Restore saved auto-refresh interval + if (config.refreshInterval) { + setAutoRefreshInterval(config.refreshInterval); + } + + // Restore saved variables + if (config.variables) { + setDashboardVariables(config.variables); + } }; const loadPage: PromiseVoidFunction = async (): Promise => { @@ -169,6 +203,47 @@ const DashboardViewer: FunctionComponent = ( }); }, []); + // Auto-refresh timer management + const triggerRefresh: () => void = useCallback(() => { + setIsRefreshing(true); + setRefreshTick((prev: number) => { + return prev + 1; + }); + // Brief indicator + setTimeout(() => { + setIsRefreshing(false); + }, 500); + }, []); + + useEffect(() => { + // Clear existing timer + if (autoRefreshTimerRef.current) { + clearInterval(autoRefreshTimerRef.current); + autoRefreshTimerRef.current = null; + } + + // Don't auto-refresh in edit mode + if (dashboardMode === DashboardMode.Edit) { + return; + } + + const intervalMs: number | null = + getAutoRefreshIntervalInMs(autoRefreshInterval); + + if (intervalMs !== null) { + autoRefreshTimerRef.current = setInterval(() => { + triggerRefresh(); + }, intervalMs); + } + + return () => { + if (autoRefreshTimerRef.current) { + clearInterval(autoRefreshTimerRef.current); + autoRefreshTimerRef.current = null; + } + }; + }, [autoRefreshInterval, dashboardMode, triggerRefresh]); + const isEditMode: boolean = dashboardMode === DashboardMode.Edit; const sideBarWidth: number = isEditMode && selectedComponentId ? 650 : 0; @@ -219,15 +294,33 @@ const DashboardViewer: FunctionComponent = ( dashboardName={dashboardName} isSaving={isSaving} onSaveClick={() => { + // Save auto-refresh interval with the config + const configWithRefresh: DashboardViewConfig = { + ...dashboardViewConfig, + refreshInterval: autoRefreshInterval, + }; + setDashboardViewConfig(configWithRefresh); + saveDashboardViewConfig().catch((err: Error) => { setError(API.getFriendlyErrorMessage(err)); }); setDashboardMode(DashboardMode.View); }} startAndEndDate={startAndEndDate} + canResetZoom={timeRangeStack.length > 0} + onResetZoom={() => { + if (timeRangeStack.length > 0) { + const previousRange: RangeStartAndEndDateTime = + timeRangeStack[timeRangeStack.length - 1]!; + setStartAndEndDate(previousRange); + setTimeRangeStack(timeRangeStack.slice(0, -1)); + } + }} onStartAndEndDateChange={( newStartAndEndDate: RangeStartAndEndDateTime, ) => { + // Push current range to zoom stack before changing + setTimeRangeStack([...timeRangeStack, startAndEndDate]); setStartAndEndDate(newStartAndEndDate); }} onCancelEditClick={async () => { @@ -238,6 +331,26 @@ const DashboardViewer: FunctionComponent = ( onEditClick={() => { setDashboardMode(DashboardMode.Edit); }} + autoRefreshInterval={autoRefreshInterval} + onAutoRefreshIntervalChange={(interval: AutoRefreshInterval) => { + setAutoRefreshInterval(interval); + }} + isRefreshing={isRefreshing} + variables={dashboardVariables} + onVariableValueChange={(variableId: string, value: string) => { + const updatedVariables: Array = + dashboardVariables.map((v: DashboardVariable) => { + if (v.id === variableId) { + return { ...v, selectedValue: value }; + } + return v; + }); + setDashboardVariables(updatedVariables); + // Trigger refresh when variable changes + setRefreshTick((prev: number) => { + return prev + 1; + }); + }} onAddComponentClick={(componentType: DashboardComponentType) => { let newComponent: DashboardBaseComponent | null = null; @@ -253,6 +366,14 @@ const DashboardViewer: FunctionComponent = ( newComponent = DashboardTextComponentUtil.getDefaultComponent(); } + if (componentType === DashboardComponentType.Table) { + newComponent = DashboardTableComponentUtil.getDefaultComponent(); + } + + if (componentType === DashboardComponentType.Gauge) { + newComponent = DashboardGaugeComponentUtil.getDefaultComponent(); + } + if (!newComponent) { throw new BadDataException( `Unknown component type: ${componentType}`, @@ -291,6 +412,7 @@ const DashboardViewer: FunctionComponent = ( telemetryAttributes, metricTypes, }} + refreshTick={refreshTick} />
diff --git a/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx b/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx index 96ba7d8660..48fb0415ea 100644 --- a/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Dashboard/Toolbar/DashboardToolbar.tsx @@ -7,9 +7,14 @@ import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem"; import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType"; import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime"; import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView"; -import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardViewConfig, { + AutoRefreshInterval, + getAutoRefreshIntervalLabel, +} from "Common/Types/Dashboard/DashboardViewConfig"; +import DashboardVariable from "Common/Types/Dashboard/DashboardVariable"; import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; import Loader from "Common/UI/Components/Loader/Loader"; +import DashboardVariableSelector from "./DashboardVariableSelector"; export interface ComponentProps { onEditClick: () => void; @@ -23,6 +28,13 @@ export interface ComponentProps { startAndEndDate: RangeStartAndEndDateTime; onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void; dashboardViewConfig: DashboardViewConfig; + autoRefreshInterval: AutoRefreshInterval; + onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void; + isRefreshing?: boolean | undefined; + variables?: Array | undefined; + onVariableValueChange?: ((variableId: string, value: string) => void) | undefined; + canResetZoom?: boolean | undefined; + onResetZoom?: (() => void) | undefined; } const DashboardToolbar: FunctionComponent = ( @@ -58,6 +70,66 @@ const DashboardToolbar: FunctionComponent = (
)} + {/* Template variables */} + {props.variables && + props.variables.length > 0 && + props.onVariableValueChange && ( +
+ +
+ )} + + {/* Reset Zoom button */} + {props.canResetZoom && props.onResetZoom && !isEditMode && ( +