mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-14 21:32:04 +00:00
Compare commits
2 Commits
feat/histo
...
perses
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b429acae | ||
|
|
2c948ef9f6 |
1466
docs/earlier-thoughts/dashboard-v6-schema.json
Normal file
1466
docs/earlier-thoughts/dashboard-v6-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
436
docs/earlier-thoughts/perses-feasibility-analysis.md
Normal file
436
docs/earlier-thoughts/perses-feasibility-analysis.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Feasibility Analysis: Adopting Perses.dev Specification for SigNoz Dashboards
|
||||
|
||||
## Executive Summary
|
||||
|
||||
SigNoz's dashboard JSON has been a free-form `map[string]interface{}` with no schema enforcement. This document evaluates adopting [Perses.dev](https://perses.dev/) (a CNCF Sandbox project) as a structured dashboard specification. The conclusion is that **wholesale adoption is not recommended**, but several Perses design patterns should be borrowed into a SigNoz-native v6 dashboard schema.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State: SigNoz Dashboard JSON
|
||||
|
||||
### 1.1 Structure Overview
|
||||
|
||||
The dashboard is stored as `StorableDashboardData = map[string]interface{}` in Go (see `pkg/types/dashboardtypes/dashboard.go`). Top-level fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | `string` | Dashboard display name |
|
||||
| `description` | `string` | Dashboard description text |
|
||||
| `tags` | `string[]` | Categorization tags (e.g., `["redis", "database"]`) |
|
||||
| `image` | `string` | Base64 or URL-encoded SVG for dashboard icon/thumbnail |
|
||||
| `version` | `string` | Schema version: `"v4"` or `"v5"` |
|
||||
| `layout` | `Layout[]` | React-grid-layout positioning for each widget |
|
||||
| `panelMap` | `Record<string, {widgets: Layout[], collapsed: boolean}>` | Groups panels under row widgets for collapsible sections |
|
||||
| `widgets` | `Widget[]` | Array of panel/widget definitions |
|
||||
| `variables` | `Record<string, IDashboardVariable>` | Dashboard variable definitions |
|
||||
| `uploadedGrafana` | `boolean` | Flag indicating if imported from Grafana |
|
||||
|
||||
### 1.2 Panel/Widget Types
|
||||
|
||||
| Panel Type | Constant | API Request Type |
|
||||
|-----------|----------|-----------------|
|
||||
| Time Series | `graph` | `time_series` |
|
||||
| Bar Chart | `bar` | `time_series` |
|
||||
| Table | `table` | `scalar` |
|
||||
| Pie Chart | `pie` | `scalar` |
|
||||
| Single Value | `value` | `scalar` |
|
||||
| List/Logs | `list` | `raw` |
|
||||
| Trace | `trace` | `trace` |
|
||||
| Histogram | `histogram` | `distribution` |
|
||||
| Row (group header) | `row` | N/A |
|
||||
|
||||
### 1.3 Query System
|
||||
|
||||
Each widget carries a `query` object with three modes simultaneously:
|
||||
|
||||
```json
|
||||
{
|
||||
"queryType": "builder|clickhouse_sql|promql",
|
||||
"builder": {
|
||||
"queryData": [],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [],
|
||||
"promql": []
|
||||
}
|
||||
```
|
||||
|
||||
Only `queryType` determines which mode is active; the other sections carry empty placeholder defaults.
|
||||
|
||||
#### Builder Query v4 (legacy, widely used)
|
||||
- `aggregateOperator` + `aggregateAttribute` as separate fields
|
||||
- `filters` with structured `items[]` containing key objects with synthetic IDs
|
||||
- `having: []` as array
|
||||
|
||||
#### Builder Query v5 (newer, integration dashboards)
|
||||
- `aggregations[]` array with `metricName`, `timeAggregation`, `spaceAggregation` combined
|
||||
- `filter` with expression string (e.g., `"host_name IN $host_name"`)
|
||||
- `having: {expression: ""}` as object
|
||||
|
||||
### 1.4 Query Range V5 API
|
||||
|
||||
The V5 execution API wraps queries in a `QueryEnvelope` discriminated union:
|
||||
|
||||
```
|
||||
QueryEnvelope = {type: QueryType, spec: any}
|
||||
```
|
||||
|
||||
Seven query types: `builder_query`, `builder_formula`, `builder_sub_query`, `builder_join`, `builder_trace_operator`, `promql`, `clickhouse_sql`
|
||||
|
||||
Six request types: `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`
|
||||
|
||||
Three signals with distinct aggregation models:
|
||||
- **Metrics**: `metricName + temporality + timeAggregation + spaceAggregation`
|
||||
- **Traces**: Expression-based (e.g., `"COUNT()"`, `"p99(duration_nano)"`)
|
||||
- **Logs**: Expression-based (e.g., `"COUNT()"`, `"count_distinct(host.name)"`)
|
||||
|
||||
17 post-processing functions, server-side formula evaluation, SQL-style joins, and trace span relationship operators.
|
||||
|
||||
### 1.5 Documented Pain Points
|
||||
|
||||
1. **`StorableDashboardData` is `map[string]interface{}`**: All nested property access requires manual type assertions with fragile `ok` checks.
|
||||
|
||||
2. **Two incompatible query schema versions coexisting**: v4 and v5 query formats coexist in the same codebase. The backend migration layer (`pkg/transition/migrate_common.go`) converts at execution time.
|
||||
|
||||
3. **Massive boilerplate**: Every widget carries `selectedLogFields` and `selectedTracesFields` arrays even for metrics-only panels. Identical 5-element arrays copy-pasted hundreds of times.
|
||||
|
||||
4. **Duplicate query slots**: Every widget carries all three query types (`builder`, `clickhouse_sql`, `promql`) with empty placeholders for inactive types.
|
||||
|
||||
5. **Variable key inconsistency**: Variables keyed by human-readable name (e.g., `"Account"`) OR UUID depending on dashboard.
|
||||
|
||||
6. **Variables coupled to ClickHouse SQL**: Variable queries use raw `SELECT ... FROM signoz_metrics.distributed_time_series_v4_1day`, coupling dashboard definitions to internal storage schema.
|
||||
|
||||
7. **Redundant synthetic IDs**: Filter keys contain derived `id` fields like `"cloud_account_id--string--tag--false"`.
|
||||
|
||||
8. **Spelling errors baked in**: `"timePreferance"` (misspelled) is embedded in the serialized JSON contract.
|
||||
|
||||
9. **Layout/widget coupling implicit**: Layout items reference widgets by matching `i` to `id` with no schema enforcement. `panelMap` adds another implicit layer.
|
||||
|
||||
10. **No schema validation**: Dashboard data has no Go struct for validation. Relies entirely on frontend TypeScript types with extensive optional markers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Perses.dev Specification Overview
|
||||
|
||||
### 2.1 What is Perses?
|
||||
|
||||
Perses is a CNCF Sandbox project providing:
|
||||
- An **open dashboard specification** (implemented in Go, CUE, TypeScript)
|
||||
- A **plugin-based extension model** for panels, queries, datasources, and variables
|
||||
- **Dashboard-as-Code** via CUE and Go SDKs
|
||||
- **Static validation** via `percli` CLI
|
||||
- **Grafana migration** tooling
|
||||
|
||||
Adopters: Chronosphere, RedHat, SAP, Amadeus.
|
||||
|
||||
### 2.2 Dashboard Structure
|
||||
|
||||
```yaml
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
name: "..."
|
||||
project: "..."
|
||||
spec:
|
||||
display: {name, description}
|
||||
datasources: {name: DatasourceSpec} # inline or referenced
|
||||
variables: [Variable] # ordered list
|
||||
panels: {id: Panel} # map of panel definitions
|
||||
layouts: [Layout] # separate from panels
|
||||
duration: "5m"
|
||||
refreshInterval: "30s"
|
||||
```
|
||||
|
||||
### 2.3 Core Design: Plugin = `{kind: string, spec: any}`
|
||||
|
||||
The universal extension point. Panels, queries, datasources, and variables are all plugins:
|
||||
|
||||
```go
|
||||
type Plugin struct {
|
||||
Kind string `json:"kind"`
|
||||
Metadata *PluginMetadata `json:"metadata,omitempty"`
|
||||
Spec any `json:"spec"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Panel Structure
|
||||
|
||||
```yaml
|
||||
kind: Panel
|
||||
spec:
|
||||
display: {name, description}
|
||||
plugin: {kind: "TimeSeriesChart", spec: {...}}
|
||||
queries:
|
||||
- kind: TimeSeriesQuery
|
||||
spec:
|
||||
plugin:
|
||||
kind: PrometheusTimeSeriesQuery
|
||||
spec: {query: "up", datasource: "$ds"}
|
||||
```
|
||||
|
||||
### 2.5 Layout System
|
||||
|
||||
Panels separated from layout. Grid-based positioning with JSON `$ref` pointers:
|
||||
|
||||
```yaml
|
||||
kind: Grid
|
||||
spec:
|
||||
display:
|
||||
title: "Section Name"
|
||||
collapse: {open: true}
|
||||
items:
|
||||
- x: 0, y: 0, width: 6, height: 6
|
||||
content: {"$ref": "#/spec/panels/my_panel"}
|
||||
```
|
||||
|
||||
### 2.6 Supported Datasources
|
||||
|
||||
| Datasource | Plugin Kind | Protocol |
|
||||
|---|---|---|
|
||||
| Prometheus | `PrometheusDatasource` | PromQL |
|
||||
| Tempo | `TempoDatasource` | TraceQL |
|
||||
| Loki | `LokiDatasource` | LogQL |
|
||||
| Pyroscope | `PyroscopeDatasource` | Pyroscope API |
|
||||
| ClickHouse | Community plugin | SQL |
|
||||
| VictoriaLogs | Community plugin | VictoriaLogs API |
|
||||
|
||||
### 2.7 Plugin System
|
||||
|
||||
Five plugin categories: datasource, query, panel, variable, explore. Each distributes as a compressed archive with CUE schemas, React components (via module federation), and optional Grafana migration logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feasibility Assessment
|
||||
|
||||
### 3.1 Support for Logs/Metrics/Traces/Events/Profiles
|
||||
|
||||
| Signal | Perses Status | SigNoz Requirement | Gap |
|
||||
|---|---|---|---|
|
||||
| **Metrics** | Prometheus plugin (mature) | ClickHouse-backed with dual aggregation model (time + space) | **Significant** - Perses assumes PromQL |
|
||||
| **Traces** | Tempo plugin (exists) | ClickHouse-backed with trace operators, span-level queries, joins | **Significant** - Perses Tempo does basic TraceQL |
|
||||
| **Logs** | Loki plugin (exists) | ClickHouse-backed with builder queries, raw list views, streaming | **Moderate** - Perses Loki uses LogQL |
|
||||
| **Profiles** | Pyroscope plugin (exists) | Not yet core in SigNoz dashboards | Low gap |
|
||||
| **Events** | No plugin | Future SigNoz need | Would require custom plugin |
|
||||
|
||||
Perses has plugins for all four pillars, but each assumes a specific backend protocol (PromQL, TraceQL, LogQL). SigNoz uses a **unified query builder** abstracting over ClickHouse. This is a fundamental architectural mismatch.
|
||||
|
||||
### 3.2 Extensibility
|
||||
|
||||
Perses's plugin architecture is genuinely extensible. SigNoz could create custom plugins (`SigNozDatasource`, `SigNozBuilderQuery`, etc.). However, this means:
|
||||
- Writing and maintaining a **full Perses plugin ecosystem** for SigNoz
|
||||
- Plugin must handle all 7 query types and 3 signal types
|
||||
- CUE schema definitions for all SigNoz query structures
|
||||
- Tracking Perses upstream changes (still a Sandbox project, not graduated)
|
||||
|
||||
### 3.3 Coupling Analysis
|
||||
|
||||
| Dimension | Current SigNoz | With Perses | Assessment |
|
||||
|---|---|---|---|
|
||||
| Dashboard to Storage | Variables use raw ClickHouse SQL | Would need SigNoz query plugin | Improvement possible |
|
||||
| Dashboard to Frontend | Widget types tightly coupled to React | Perses separates panel spec from rendering | Improvement |
|
||||
| Dashboard to Query API | Widgets carry full query objects | Plugin-typed, referenced via datasource | Improvement, but adds indirection |
|
||||
| Dashboard to Perses | N/A | Depends on Perses versioning, plugin compat, CUE toolchain | **New coupling** |
|
||||
|
||||
### 3.4 Support for Query Range V5
|
||||
|
||||
This is the **most critical gap**:
|
||||
|
||||
| SigNoz V5 Feature | Perses Equivalent | Plugin Solvable? |
|
||||
|---|---|---|
|
||||
| `builder_query` with signal-specific aggregation | Plugin `spec: any` | Yes, but SigNoz-specific |
|
||||
| `builder_formula` (cross-query math) | No formula concept | **Partially** - needs custom panel logic |
|
||||
| `builder_join` (SQL-style cross-signal joins) | No equivalent | **No** - fundamentally different model |
|
||||
| `builder_trace_operator` (span relationships) | No equivalent | **No** - unique to SigNoz |
|
||||
| `builder_sub_query` (nested queries) | No equivalent | Would need plugin extension |
|
||||
| Multiple query types per panel | Single-typed queries | Would need wrapper plugin |
|
||||
| Post-processing functions (ewma, anomaly, timeShift) | No equivalent | Would need to be in plugin spec |
|
||||
|
||||
---
|
||||
|
||||
## 4. Why NOT Adopt Perses Wholesale
|
||||
|
||||
### 4.1 SigNoz-inside-Perses
|
||||
|
||||
Every SigNoz query feature would live inside `spec: any` blobs within Perses plugin wrappers:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "SigNozBuilderQuery",
|
||||
"spec": { /* ALL SigNoz-specific content here */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Perses validates the envelope (`kind: TimeSeriesQuery` exists, `plugin.kind` is registered). But the actual content is opaque to Perses. You'd still need your own validation for everything inside `spec`.
|
||||
|
||||
### 4.2 Formulas, Joins, and Trace Operators Have No Home
|
||||
|
||||
Perses model: `Panel -> queries[] -> Query`. Each query is independent.
|
||||
|
||||
SigNoz model: `Panel -> compositeQuery -> {queries[], formulas[], joins[], traceOperators[]}`. Queries reference each other by name. Formulas combine results. Joins cross signals.
|
||||
|
||||
You'd have to either:
|
||||
- Shove the entire compositeQuery into a single plugin spec (making Perses query structure meaningless)
|
||||
- Fork/extend Perses core spec (ongoing merge conflicts)
|
||||
|
||||
### 4.3 Plugin Maintenance Burden
|
||||
|
||||
Required custom plugins:
|
||||
|
||||
| Plugin | Purpose |
|
||||
|---|---|
|
||||
| `SigNozDatasource` | Points to SigNoz query-service |
|
||||
| `SigNozBuilderQuery` | Wraps v5 builder queries for metrics/logs/traces |
|
||||
| `SigNozFormulaQuery` | Wraps formula evaluation |
|
||||
| `SigNozTraceOperatorQuery` | Wraps trace structural operators |
|
||||
| `SigNozJoinQuery` | Wraps cross-signal joins |
|
||||
| `SigNozSubQuery` | Wraps nested queries |
|
||||
| `SigNozAttributeValuesVariable` | Variable from attribute values |
|
||||
| `SigNozQueryVariable` | Variable from query results |
|
||||
|
||||
Each needs: CUE schema, React component, Grafana migration handler, and tests. Every v5 feature addition requires plugin schema updates.
|
||||
|
||||
### 4.4 Community Mismatch
|
||||
|
||||
Perses adopters are primarily Prometheus-centric. A `SigNozDatasource` plugin is useful only to SigNoz. You'd be the sole maintainer of the plugin suite.
|
||||
|
||||
### 4.5 The Counterargument
|
||||
|
||||
The one strong argument FOR wholesale adoption: **you get out of the "dashboard spec" business entirely**. Even if 80% is SigNoz-specific plugins, the 20% Perses handles (metadata, layout, display, variable ordering, versioning, RBAC scoping) is real work you don't have to maintain. If Perses graduates from CNCF sandbox, ecosystem benefits compound.
|
||||
|
||||
This trade-off doesn't justify the ongoing plugin maintenance tax, especially since those patterns are straightforward to implement natively. However, if SigNoz plans to eventually expose PromQL/TraceQL/LogQL-compatible endpoints, the calculus changes significantly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendation: Borrow Patterns, Build Native
|
||||
|
||||
### 5.1 Patterns to Adopt from Perses
|
||||
|
||||
| Perses Pattern | SigNoz Adoption |
|
||||
|---|---|
|
||||
| **`kind` + `spec` envelope** | Use for query types, panel types, variables. Consistent with v5 `QueryEnvelope`. |
|
||||
| **Panels separated from Layout** | `panels: {}` map + `layouts: []` referencing by ID. |
|
||||
| **Ordered variables as array** | Move from `variables: {name: {...}}` to `variables: [...]`. |
|
||||
| **CUE or JSON Schema validation** | Define formal schema for dashboards. Use for CI and import/export. |
|
||||
| **Dashboard-as-Code SDK** | Go/TypeScript SDK for programmatic dashboard generation. |
|
||||
| **Metadata structure** | `kind` + `apiVersion` + `metadata` + `spec` top-level (Kubernetes-style). |
|
||||
|
||||
### 5.2 Proposed v6 Dashboard Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"apiVersion": "signoz.io/v1",
|
||||
"metadata": {
|
||||
"name": "redis-overview",
|
||||
"title": "Redis Overview",
|
||||
"description": "...",
|
||||
"tags": ["redis", "database"],
|
||||
"image": "..."
|
||||
},
|
||||
"spec": {
|
||||
"defaults": {
|
||||
"timeRange": "5m",
|
||||
"refreshInterval": "30s"
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "QueryVariable",
|
||||
"spec": {
|
||||
"name": "host_name",
|
||||
"signal": "metrics",
|
||||
"attributeName": "host_name",
|
||||
"multiSelect": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"hit_rate": {
|
||||
"kind": "TimeSeriesPanel",
|
||||
"spec": {
|
||||
"title": "Hit Rate",
|
||||
"description": "Cache hit rate across hosts",
|
||||
"display": {
|
||||
"yAxisUnit": "percent",
|
||||
"legend": {"position": "bottom"}
|
||||
},
|
||||
"query": {
|
||||
"type": "composite",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "redis_keyspace_hits", "timeAggregation": "rate", "spaceAggregation": "sum"}],
|
||||
"filter": {"expression": "host_name IN $host_name"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"formulas": [
|
||||
{"type": "builder_formula", "spec": {"expression": "A / (A + B) * 100"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"title": "Overview",
|
||||
"collapsible": true,
|
||||
"collapsed": false,
|
||||
"items": [
|
||||
{"panel": "hit_rate", "x": 0, "y": 0, "w": 6, "h": 6}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Key Improvements Over Current Format
|
||||
|
||||
| Issue | Current | v6 |
|
||||
|---|---|---|
|
||||
| No validation | `map[string]interface{}` | JSON Schema enforced |
|
||||
| Boilerplate | Every widget has selectedLogFields, all query modes | Only active query mode stored, display options per panel type |
|
||||
| Variable ordering | `order` field inside map entries | Array position |
|
||||
| Variable keys | Name or UUID inconsistently | `name` field in spec, array position |
|
||||
| Layout coupling | Implicit `i` matches `id` | Explicit `panel` reference in layout items |
|
||||
| Spelling errors | `timePreferance` | `timePreference` (fixed) |
|
||||
| Query structure | Flat list of all queries + formulas | Typed envelope matching v5 API |
|
||||
| Row grouping | Separate `panelMap` with duplicate layout entries | Integrated into `layouts[]` with `collapsible` flag |
|
||||
|
||||
### 5.4 Migration Path
|
||||
|
||||
1. **v4/v5 to v6 migration**: Build a Go migration function that transforms existing dashboards to v6 format. Handle both v4 and v5 query formats.
|
||||
2. **Backward compatibility**: Support reading v4/v5 dashboards with automatic upgrade to v6 on save.
|
||||
3. **Frontend**: Update TypeScript interfaces to match v6 schema. Remove legacy response converters once v5 API is fully adopted.
|
||||
4. **Validation**: Add JSON Schema validation on dashboard create/update API endpoints.
|
||||
5. **Integration dashboards**: Regenerate all dashboards in `SigNoz/dashboards` repo using v6 format.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Perses Homepage](https://perses.dev/)
|
||||
- [Perses Dashboard API](https://perses.dev/perses/docs/api/dashboard/)
|
||||
- [Perses Open Specification](https://perses.dev/perses/docs/concepts/open-specification/)
|
||||
- [Perses Plugin Creation](https://perses.dev/perses/docs/plugins/creation/)
|
||||
- [Perses Prometheus Plugin Model](https://perses.dev/plugins/docs/prometheus/model/)
|
||||
- [Perses GitHub Repository](https://github.com/perses/perses)
|
||||
- [SigNoz Dashboards Repository](https://github.com/SigNoz/dashboards)
|
||||
- SigNoz source: `pkg/types/dashboardtypes/dashboard.go`
|
||||
- SigNoz source: `pkg/types/querybuildertypes/querybuildertypesv5/`
|
||||
- SigNoz source: `frontend/src/types/api/dashboard/getAll.ts`
|
||||
- SigNoz source: `frontend/src/types/api/queryBuilder/queryBuilderData.ts`
|
||||
- SigNoz source: `frontend/src/types/api/v5/queryRange.ts`
|
||||
- SigNoz source: `pkg/transition/migrate_common.go`
|
||||
1463
docs/rfc-perses-native-dashboard.md
Normal file
1463
docs/rfc-perses-native-dashboard.md
Normal file
File diff suppressed because it is too large
Load Diff
378
docs/signoz-perses-plugins.cue
Normal file
378
docs/signoz-perses-plugins.cue
Normal file
@@ -0,0 +1,378 @@
|
||||
// SigNoz Perses Plugin Schemas
|
||||
//
|
||||
// CUE schemas for all SigNoz-specific Perses plugin types.
|
||||
// These define the `spec` shape inside Perses Plugin wrappers:
|
||||
// { "kind": "<PluginKind>", "spec": <validated by this file> }
|
||||
//
|
||||
// Perses core types (Dashboard, Panel, Layout, Variable envelopes)
|
||||
// are validated by Perses itself. This file only covers SigNoz plugins.
|
||||
//
|
||||
// Usage:
|
||||
// percli lint --schema-dir ./signoz-perses-plugins dashboard.json
|
||||
//
|
||||
// Reference: https://perses.dev/perses/docs/api/plugin/
|
||||
|
||||
package signoz
|
||||
|
||||
// ============================================================================
|
||||
// Datasource Plugin
|
||||
// ============================================================================
|
||||
|
||||
// SigNozDatasource configures the connection to a SigNoz query service.
|
||||
// Used inside: DatasourceSpec.plugin { kind: "SigNozDatasource", spec: ... }
|
||||
#SigNozDatasource: {
|
||||
// Direct URL for embedded mode (SigNoz serves its own dashboards).
|
||||
// The frontend calls SigNoz APIs directly at this base URL.
|
||||
directUrl?: string & =~"^/|^https?://"
|
||||
|
||||
// HTTP proxy config for external mode (standalone Perses connecting to SigNoz).
|
||||
proxy?: #HTTPProxy
|
||||
}
|
||||
|
||||
#HTTPProxy: {
|
||||
kind: "HTTPProxy"
|
||||
spec: {
|
||||
url: string & =~"^https?://"
|
||||
allowedEndpoints?: [...#AllowedEndpoint]
|
||||
}
|
||||
}
|
||||
|
||||
#AllowedEndpoint: {
|
||||
endpointPattern: string
|
||||
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Plugins
|
||||
// ============================================================================
|
||||
|
||||
// SigNozBuilderQuery is a single builder query for one signal.
|
||||
// Used inside: TimeSeriesQuery.spec.plugin { kind: "SigNozBuilderQuery", spec: ... }
|
||||
// Use this when the panel has independent queries (no formulas, joins, or trace operators).
|
||||
#SigNozBuilderQuery: {
|
||||
name: #QueryName
|
||||
signal: #Signal
|
||||
source?: string
|
||||
|
||||
disabled?: bool | *false
|
||||
|
||||
// Metrics use structured aggregations; traces/logs use expression aggregations
|
||||
aggregations?: [...#MetricAggregation | #ExpressionAggregation]
|
||||
|
||||
filter?: #Filter
|
||||
groupBy?: [...#GroupByKey]
|
||||
order?: [...#OrderBy]
|
||||
having?: #Having
|
||||
|
||||
selectFields?: [...#TelemetryFieldKey]
|
||||
|
||||
limit?: int & >=0 & <=10000
|
||||
limitBy?: #LimitBy
|
||||
offset?: int & >=0
|
||||
cursor?: string
|
||||
|
||||
secondaryAggregations?: [...#SecondaryAggregation]
|
||||
functions?: [...#PostProcessingFunction]
|
||||
|
||||
legend?: string
|
||||
reduceTo?: #ReduceTo
|
||||
stepInterval?: #StepInterval
|
||||
}
|
||||
|
||||
// SigNozCompositeQuery bundles multiple queries with formulas, joins, or trace operators.
|
||||
// Used inside: TimeSeriesQuery.spec.plugin { kind: "SigNozCompositeQuery", spec: ... }
|
||||
// Use this when a panel needs cross-query references (A/B formulas, joins, trace operators).
|
||||
#SigNozCompositeQuery: {
|
||||
queries: [...#CompositeQueryEntry] & [_, ...] // at least one query
|
||||
|
||||
formulas?: [...#FormulaEntry]
|
||||
joins?: [...#JoinEntry]
|
||||
traceOperators?: [...#TraceOperatorEntry]
|
||||
}
|
||||
|
||||
// A query within a composite. Same shape as SigNozBuilderQuery.
|
||||
#CompositeQueryEntry: #SigNozBuilderQuery
|
||||
|
||||
// A formula referencing other queries by name.
|
||||
#FormulaEntry: {
|
||||
name: #QueryName
|
||||
expression: string & !="" // e.g. "A/B * 100"
|
||||
|
||||
disabled?: bool | *false
|
||||
|
||||
order?: [...#OrderBy]
|
||||
limit?: int & >=0 & <=10000
|
||||
having?: #Having
|
||||
functions?: [...#PostProcessingFunction]
|
||||
legend?: string
|
||||
}
|
||||
|
||||
// A join combining results from two queries.
|
||||
#JoinEntry: {
|
||||
name: #QueryName
|
||||
|
||||
disabled?: bool | *false
|
||||
|
||||
left: #QueryRef
|
||||
right: #QueryRef
|
||||
joinType: "inner" | "left" | "right" | "full" | "cross"
|
||||
on: string & !="" // join condition expression
|
||||
|
||||
aggregations?: [...#MetricAggregation | #ExpressionAggregation]
|
||||
selectFields?: [...#TelemetryFieldKey]
|
||||
filter?: #Filter
|
||||
groupBy?: [...#GroupByKey]
|
||||
having?: #Having
|
||||
order?: [...#OrderBy]
|
||||
limit?: int & >=0 & <=10000
|
||||
|
||||
secondaryAggregations?: [...#SecondaryAggregation]
|
||||
functions?: [...#PostProcessingFunction]
|
||||
}
|
||||
|
||||
// A trace operator expressing span relationships.
|
||||
#TraceOperatorEntry: {
|
||||
name: #QueryName
|
||||
|
||||
disabled?: bool | *false
|
||||
|
||||
// Operators: => (direct descendant), -> (indirect descendant),
|
||||
// && (AND), || (OR), NOT (exclude). Example: "A => B && C"
|
||||
expression: string & !=""
|
||||
|
||||
filter?: #Filter
|
||||
returnSpansFrom?: string
|
||||
|
||||
order?: [...#TraceOrderBy]
|
||||
aggregations?: [...#ExpressionAggregation]
|
||||
stepInterval?: #StepInterval
|
||||
groupBy?: [...#GroupByKey]
|
||||
having?: #Having
|
||||
limit?: int & >=0 & <=10000
|
||||
offset?: int & >=0
|
||||
cursor?: string
|
||||
legend?: string
|
||||
selectFields?: [...#TelemetryFieldKey]
|
||||
functions?: [...#PostProcessingFunction]
|
||||
}
|
||||
|
||||
// SigNozPromQL wraps a raw PromQL query.
|
||||
// Used inside: TimeSeriesQuery.spec.plugin { kind: "SigNozPromQL", spec: ... }
|
||||
#SigNozPromQL: {
|
||||
name: string
|
||||
query: string & !=""
|
||||
|
||||
disabled?: bool | *false
|
||||
step?: #StepInterval
|
||||
stats?: bool | *false
|
||||
legend?: string
|
||||
}
|
||||
|
||||
// SigNozClickHouseSQL wraps a raw ClickHouse SQL query.
|
||||
// Used inside: TimeSeriesQuery.spec.plugin { kind: "SigNozClickHouseSQL", spec: ... }
|
||||
#SigNozClickHouseSQL: {
|
||||
name: string
|
||||
query: string & !=""
|
||||
|
||||
disabled?: bool | *false
|
||||
legend?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Variable Plugins
|
||||
// ============================================================================
|
||||
|
||||
// SigNozQueryVariable resolves variable values using a builder query.
|
||||
// Used inside: ListVariable.spec.plugin { kind: "SigNozQueryVariable", spec: ... }
|
||||
#SigNozQueryVariable: {
|
||||
// The query that produces variable values.
|
||||
// Uses the same builder query model as panels.
|
||||
query: #SigNozBuilderQuery | #SigNozCompositeQuery | #SigNozPromQL | #SigNozClickHouseSQL
|
||||
}
|
||||
|
||||
// SigNozAttributeValues resolves variable values from attribute autocomplete.
|
||||
// Used inside: ListVariable.spec.plugin { kind: "SigNozAttributeValues", spec: ... }
|
||||
// This is a simpler alternative to SigNozQueryVariable for common cases
|
||||
// like "list all values of host.name for metric X".
|
||||
#SigNozAttributeValues: {
|
||||
signal: #Signal
|
||||
metricName?: string // required when signal is "metrics"
|
||||
attributeName: string & !=""
|
||||
|
||||
filter?: #Filter // optional pre-filter
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Signals
|
||||
// ============================================================================
|
||||
|
||||
#Signal: "metrics" | "traces" | "logs"
|
||||
// Extensible to "events" | "profiles" in future
|
||||
|
||||
// ============================================================================
|
||||
// Aggregations
|
||||
// ============================================================================
|
||||
|
||||
// MetricAggregation defines the two-level aggregation model for metrics:
|
||||
// time aggregation (within each time bucket) then space aggregation (across dimensions).
|
||||
#MetricAggregation: {
|
||||
metricName: string & !=""
|
||||
|
||||
temporality?: #MetricTemporality
|
||||
timeAggregation?: #TimeAggregation
|
||||
spaceAggregation?: #SpaceAggregation
|
||||
reduceTo?: #ReduceTo
|
||||
}
|
||||
|
||||
// ExpressionAggregation uses ClickHouse aggregate function syntax for traces/logs.
|
||||
// Examples: "count()", "sum(item_price)", "p99(duration_nano)", "countIf(day > 10)"
|
||||
#ExpressionAggregation: {
|
||||
expression: string & !=""
|
||||
alias?: string
|
||||
}
|
||||
|
||||
#MetricTemporality: "delta" | "cumulative" | "unspecified"
|
||||
|
||||
#TimeAggregation:
|
||||
"latest" | "sum" | "avg" | "min" | "max" |
|
||||
"count" | "count_distinct" | "rate" | "increase"
|
||||
|
||||
#SpaceAggregation:
|
||||
"sum" | "avg" | "min" | "max" | "count" |
|
||||
"p50" | "p75" | "p90" | "p95" | "p99"
|
||||
|
||||
#ReduceTo: "sum" | "count" | "avg" | "min" | "max" | "last" | "median"
|
||||
|
||||
// ============================================================================
|
||||
// Filters
|
||||
// ============================================================================
|
||||
|
||||
// Filter uses expression syntax instead of structured items with synthetic IDs.
|
||||
// Supports: =, !=, >, >=, <, <=, IN, NOT IN, LIKE, NOT LIKE, ILIKE, NOT ILIKE,
|
||||
// BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP, NOT REGEXP, CONTAINS, NOT CONTAINS.
|
||||
// Variable interpolation: $variable_name
|
||||
#Filter: {
|
||||
expression: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group By, Order By, Having
|
||||
// ============================================================================
|
||||
|
||||
#GroupByKey: {
|
||||
name: string & !=""
|
||||
signal?: #Signal
|
||||
fieldContext?: #FieldContext
|
||||
fieldDataType?: #FieldDataType
|
||||
}
|
||||
|
||||
#OrderBy: {
|
||||
key: #OrderByKey
|
||||
direction: "asc" | "desc"
|
||||
}
|
||||
|
||||
#OrderByKey: {
|
||||
name: string & !=""
|
||||
signal?: #Signal
|
||||
fieldContext?: #FieldContext
|
||||
fieldDataType?: #FieldDataType
|
||||
}
|
||||
|
||||
#TraceOrderBy: {
|
||||
key: {
|
||||
name: "span_count" | "trace_duration"
|
||||
}
|
||||
direction: "asc" | "desc"
|
||||
}
|
||||
|
||||
// Having applies a post-aggregation filter.
|
||||
// Example: "count() > 100"
|
||||
#Having: {
|
||||
expression: string & !=""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secondary Aggregations & Limits
|
||||
// ============================================================================
|
||||
|
||||
#SecondaryAggregation: {
|
||||
expression: string
|
||||
alias?: string
|
||||
|
||||
stepInterval?: #StepInterval
|
||||
groupBy?: [...#GroupByKey]
|
||||
order?: [...#OrderBy]
|
||||
limit?: int & >=0 & <=10000
|
||||
limitBy?: #LimitBy
|
||||
}
|
||||
|
||||
#LimitBy: {
|
||||
keys: [...string] & [_, ...] // at least one key
|
||||
value: string // max rows per group (string for compatibility)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Post-Processing Functions
|
||||
// ============================================================================
|
||||
|
||||
#PostProcessingFunction: {
|
||||
name: #FunctionName
|
||||
args?: [...#FunctionArg]
|
||||
}
|
||||
|
||||
#FunctionName:
|
||||
// Threshold functions
|
||||
"cutOffMin" | "cutOffMax" | "clampMin" | "clampMax" |
|
||||
// Math functions
|
||||
"absolute" | "runningDiff" | "log2" | "log10" | "cumulativeSum" |
|
||||
// Smoothing functions (exponentially weighted moving average)
|
||||
"ewma3" | "ewma5" | "ewma7" |
|
||||
// Smoothing functions (sliding median window)
|
||||
"median3" | "median5" | "median7" |
|
||||
// Time functions
|
||||
"timeShift" |
|
||||
// Analysis functions
|
||||
"anomaly" |
|
||||
// Gap filling
|
||||
"fillZero"
|
||||
|
||||
#FunctionArg: {
|
||||
name?: string
|
||||
value: number | string | bool
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Telemetry Field References
|
||||
// ============================================================================
|
||||
|
||||
#TelemetryFieldKey: {
|
||||
name: string & !=""
|
||||
description?: string
|
||||
unit?: string
|
||||
signal?: #Signal
|
||||
fieldContext?: #FieldContext
|
||||
fieldDataType?: #FieldDataType
|
||||
}
|
||||
|
||||
#FieldContext:
|
||||
"resource" | "scope" | "span" | "event" | "link" |
|
||||
"log" | "metric" | "body" | "trace"
|
||||
|
||||
#FieldDataType:
|
||||
"string" | "int64" | "float64" | "bool" |
|
||||
"array(string)" | "array(int64)" | "array(float64)" | "array(bool)"
|
||||
|
||||
// ============================================================================
|
||||
// Common Types
|
||||
// ============================================================================
|
||||
|
||||
// QueryName must be a valid identifier: starts with letter, contains letters/digits/underscores.
|
||||
#QueryName: =~"^[A-Za-z][A-Za-z0-9_]*$"
|
||||
|
||||
// QueryRef references another query by name within a composite query.
|
||||
#QueryRef: {
|
||||
name: string & !=""
|
||||
}
|
||||
|
||||
// StepInterval accepts seconds (numeric) or duration string ("15s", "1m", "1h").
|
||||
#StepInterval: number & >=0 | =~"^[0-9]+(ns|us|ms|s|m|h)$"
|
||||
@@ -23,7 +23,6 @@ export default function ChartWrapper({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
syncMode,
|
||||
syncKey,
|
||||
@@ -37,9 +36,6 @@ export default function ChartWrapper({
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
@@ -48,7 +44,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -64,7 +60,6 @@ export default function ChartWrapper({
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
showLegend={showLegend}
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
HistogramTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { HistogramChartProps } from '../types';
|
||||
|
||||
export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
renderTooltip: customRenderTooltip,
|
||||
isQueriesMerged,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customRenderTooltip) {
|
||||
return customRenderTooltip(props);
|
||||
}
|
||||
const content = buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
uPlotInstance: props.uPlotInstance,
|
||||
yAxisUnit: rest.yAxisUnit ?? '',
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
});
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
content,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
showLegend={!isQueriesMerged}
|
||||
{...rest}
|
||||
renderTooltip={renderTooltip}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
@@ -18,7 +17,6 @@ interface BaseChartProps {
|
||||
interface UPlotBasedChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
data: uPlot.AlignedData;
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
@@ -28,20 +26,14 @@ interface UPlotBasedChartProps {
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
isQueriesMerged?: boolean;
|
||||
legendConfig: LegendConfig;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
export type ChartProps = TimeSeriesChartProps | BarChartProps;
|
||||
|
||||
@@ -4,7 +4,7 @@ import uPlot, { AlignedData } from 'uplot';
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stack(
|
||||
export function stackSeries(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
@@ -9,7 +9,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stack } from '../charts/utils/stackUtils';
|
||||
import { stackSeries } from '../charts/utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
@@ -17,15 +17,15 @@ function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstacked: uPlot.AlignedData | null,
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstacked &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstacked[0]?.length === plot.data[0]?.length
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function useBarChartStacking({
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
const { data: stacked } = stack(data, noSeriesHidden);
|
||||
const { data: stacked } = stackSeries(data, noSeriesHidden);
|
||||
return stacked;
|
||||
}, [data, isStackedBarChart]);
|
||||
|
||||
@@ -104,7 +104,7 @@ export function useBarChartStacking({
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stack(unstacked, shouldExcludeSeries);
|
||||
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import './ChartLayout.styles.scss';
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
showLegend?: boolean;
|
||||
legendComponent: (legendPerSet: number) => React.ReactNode;
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
@@ -22,7 +20,6 @@ export interface ChartLayoutProps {
|
||||
config: UPlotConfigBuilder;
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
@@ -33,15 +30,6 @@ export default function ChartLayout({
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
if (!showLegend) {
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
@@ -54,7 +42,7 @@ export default function ChartLayout({
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,17 +60,15 @@ export default function ChartLayout({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
</div>
|
||||
{layoutChildren}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -15,6 +15,8 @@ import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
panelMode,
|
||||
@@ -115,20 +117,24 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onToggleModelHandler,
|
||||
]);
|
||||
|
||||
const onPlotDestroy = useCallback(() => {
|
||||
uPlotRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onPlotRef = useCallback((plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
plotRef={(plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}}
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone.value}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackUtils';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import {
|
||||
prepareHistogramPanelConfig,
|
||||
prepareHistogramPanelData,
|
||||
} from './utils';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
panelMode,
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
panelMode,
|
||||
});
|
||||
}, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!queryResponse?.data?.payload) {
|
||||
return [];
|
||||
}
|
||||
return prepareHistogramPanelData({
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
bucketWidth: widget?.bucketWidth,
|
||||
bucketCount: widget?.bucketCount,
|
||||
mergeAllActiveQueries: widget?.mergeAllActiveQueries,
|
||||
});
|
||||
}, [
|
||||
queryResponse?.data?.payload,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
if (!isFullViewMode) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onCancel={onToggleModelHandler}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isFullViewMode,
|
||||
config,
|
||||
chartData,
|
||||
widget.yAxisUnit,
|
||||
onToggleModelHandler,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
plotRef={(plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}}
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanel;
|
||||
@@ -1,214 +0,0 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle, VisibilityMode } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { incrRoundDn } from 'lib/uPlotV2/utils/scale';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
import {
|
||||
addNullToFirstHistogram,
|
||||
histogram,
|
||||
join,
|
||||
replaceUndefinedWithNull,
|
||||
roundDecimals,
|
||||
} from '../utils/histogram';
|
||||
|
||||
export interface PrepareHistogramPanelDataParams {
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const HIST_SORT = (a: number, b: number): number => a - b;
|
||||
|
||||
function extractNumericValues(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
for (const item of result) {
|
||||
for (const [, valueStr] of item.values) {
|
||||
values.push(Number.parseFloat(valueStr) || 0);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildFrames(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = result.map((item) =>
|
||||
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelData({
|
||||
apiResponse,
|
||||
bucketWidth,
|
||||
bucketCount: bucketCountProp = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramPanelDataParams): AlignedData {
|
||||
const bucketCount = bucketCountProp ?? DEFAULT_BUCKET_COUNT;
|
||||
const result = apiResponse.data.result;
|
||||
|
||||
const seriesValues = extractNumericValues(result);
|
||||
if (seriesValues.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...seriesValues].sort((a, b) => a - b);
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const range = max - min;
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(result, mergeAllActiveQueries);
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => frame.length > 0)
|
||||
.map((frame) => histogram(frame, getBucket, HIST_SORT));
|
||||
|
||||
if (histograms.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const joined = join(histograms);
|
||||
replaceUndefinedWithNull(joined);
|
||||
addNullToFirstHistogram(joined, bucketSize);
|
||||
return joined;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelConfig({
|
||||
widget,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
isDarkMode,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
panelMode: PanelMode;
|
||||
isDarkMode: boolean;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
});
|
||||
builder.setCursor({
|
||||
drag: {
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: true,
|
||||
},
|
||||
focus: {
|
||||
prox: 1e3,
|
||||
},
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: true,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const currentQuery = widget.query;
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = currentQuery
|
||||
? getLegend(series, currentQuery, baseLabelName)
|
||||
: baseLabelName;
|
||||
|
||||
builder.addSeries({
|
||||
label: label,
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
showPoints: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ export interface BaseConfigBuilderProps {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
isDarkMode: boolean;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
timezone?: Timezone;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
panelType: PANEL_TYPES;
|
||||
minTimeScale?: number;
|
||||
@@ -40,10 +40,8 @@ export function buildBaseConfig({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
onDragSelect,
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import {
|
||||
NULL_EXPAND,
|
||||
NULL_REMOVE,
|
||||
NULL_RETAIN,
|
||||
} from 'container/PanelWrapper/constants';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
export function incrRoundDn(num: number, incr: number): number {
|
||||
return Math.floor(num / incr) * incr;
|
||||
}
|
||||
|
||||
export function roundDecimals(val: number, dec = 0): number {
|
||||
if (Number.isInteger(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
const p = 10 ** dec;
|
||||
const n = val * p * (1 + Number.EPSILON);
|
||||
return Math.round(n) / p;
|
||||
}
|
||||
|
||||
function nullExpand(
|
||||
yVals: Array<number | null>,
|
||||
nullIdxs: number[],
|
||||
alignedLen: number,
|
||||
): void {
|
||||
for (let i = 0, xi, lastNullIdx = -1; i < nullIdxs.length; i++) {
|
||||
const nullIdx = nullIdxs[i];
|
||||
|
||||
if (nullIdx > lastNullIdx) {
|
||||
xi = nullIdx - 1;
|
||||
while (xi >= 0 && yVals[xi] == null) {
|
||||
yVals[xi--] = null;
|
||||
}
|
||||
|
||||
xi = nullIdx + 1;
|
||||
while (xi < alignedLen && yVals[xi] == null) {
|
||||
yVals[(lastNullIdx = xi++)] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function join(
|
||||
tables: AlignedData[],
|
||||
nullModes?: number[][],
|
||||
): AlignedData {
|
||||
let xVals: Set<number>;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
xVals = new Set();
|
||||
|
||||
for (let ti = 0; ti < tables.length; ti++) {
|
||||
const t = tables[ti];
|
||||
const xs = t[0];
|
||||
const len = xs.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
xVals.add(xs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const data = [Array.from(xVals).sort((a, b) => a - b)];
|
||||
|
||||
const alignedLen = data[0].length;
|
||||
|
||||
const xIdxs = new Map();
|
||||
|
||||
for (let i = 0; i < alignedLen; i++) {
|
||||
xIdxs.set(data[0][i], i);
|
||||
}
|
||||
|
||||
for (let ti = 0; ti < tables.length; ti++) {
|
||||
const t = tables[ti];
|
||||
const xs = t[0];
|
||||
|
||||
for (let si = 1; si < t.length; si++) {
|
||||
const ys = t[si];
|
||||
|
||||
const yVals = Array(alignedLen).fill(undefined);
|
||||
|
||||
const nullMode = nullModes ? nullModes[ti][si] : NULL_RETAIN;
|
||||
|
||||
const nullIdxs = [];
|
||||
|
||||
for (let i = 0; i < ys.length; i++) {
|
||||
const yVal = ys[i];
|
||||
const alignedIdx = xIdxs.get(xs[i]);
|
||||
|
||||
if (yVal === null) {
|
||||
if (nullMode !== NULL_REMOVE) {
|
||||
yVals[alignedIdx] = yVal;
|
||||
|
||||
if (nullMode === NULL_EXPAND) {
|
||||
nullIdxs.push(alignedIdx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yVals[alignedIdx] = yVal;
|
||||
}
|
||||
}
|
||||
|
||||
nullExpand(yVals, nullIdxs, alignedLen);
|
||||
|
||||
data.push(yVals);
|
||||
}
|
||||
}
|
||||
|
||||
return data as AlignedData;
|
||||
}
|
||||
|
||||
export function histogram(
|
||||
vals: number[],
|
||||
getBucket: (v: number) => number,
|
||||
sort?: ((a: number, b: number) => number) | null,
|
||||
): AlignedData {
|
||||
const hist = new Map();
|
||||
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
let v = vals[i];
|
||||
|
||||
if (v != null) {
|
||||
v = getBucket(v);
|
||||
}
|
||||
|
||||
const entry = hist.get(v);
|
||||
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
} else {
|
||||
hist.set(v, { value: v, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const bins = [...hist.values()];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
sort && bins.sort((a, b) => sort(a.value, b.value));
|
||||
|
||||
const values = Array(bins.length);
|
||||
const counts = Array(bins.length);
|
||||
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
values[i] = bins[i].value;
|
||||
counts[i] = bins[i].count;
|
||||
}
|
||||
|
||||
return [values, counts];
|
||||
}
|
||||
|
||||
export function replaceUndefinedWithNull(data: AlignedData): AlignedData {
|
||||
const arrays = data as (number | null | undefined)[][];
|
||||
for (let i = 0; i < arrays.length; i++) {
|
||||
for (let j = 0; j < arrays[i].length; j++) {
|
||||
if (arrays[i][j] === undefined) {
|
||||
arrays[i][j] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function addNullToFirstHistogram(
|
||||
data: AlignedData,
|
||||
bucketSize: number,
|
||||
): void {
|
||||
const histograms = data as (number | null)[][];
|
||||
if (
|
||||
histograms.length > 0 &&
|
||||
histograms[0].length > 0 &&
|
||||
histograms[0][0] !== null
|
||||
) {
|
||||
histograms[0].unshift(histograms[0][0] - bucketSize);
|
||||
for (let i = 1; i < histograms.length; i++) {
|
||||
histograms[i].unshift(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { HistogramTooltipProps } from '../types';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default function HistogramTooltip(
|
||||
props: HistogramTooltipProps,
|
||||
): JSX.Element {
|
||||
return <Tooltip {...props} showTooltipHeader={false} />;
|
||||
}
|
||||
@@ -16,16 +16,12 @@ export default function Tooltip({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const tooltipContent = content ?? [];
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const data = uPlotInstance.data;
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
@@ -34,12 +30,7 @@ export default function Tooltip({
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
.tz(timezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [
|
||||
timezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -48,11 +39,9 @@ export default function Tooltip({
|
||||
isDarkMode ? 'darkMode' : 'lightMode',
|
||||
)}
|
||||
>
|
||||
{showTooltipHeader && (
|
||||
<div className="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: Math.min(
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface TooltipRenderArgs {
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
@@ -75,14 +74,7 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps,
|
||||
TooltipRenderArgs {}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
| HistogramTooltipProps;
|
||||
export type TooltipProps = TimeSeriesTooltipProps | BarTooltipProps;
|
||||
|
||||
export enum LegendPosition {
|
||||
BOTTOM = 'bottom',
|
||||
|
||||
@@ -45,13 +45,13 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
}
|
||||
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
resolvedLineColor,
|
||||
}: {
|
||||
lineColor: string;
|
||||
resolvedLineColor: string;
|
||||
}): Partial<Series> {
|
||||
const { lineWidth, lineStyle, lineCap } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: lineColor,
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? 2,
|
||||
};
|
||||
|
||||
@@ -64,9 +64,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
}
|
||||
|
||||
if (this.props.panelType === PANEL_TYPES.BAR) {
|
||||
lineConfig.fill = lineColor;
|
||||
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
|
||||
lineConfig.fill = `${lineColor}40`;
|
||||
lineConfig.fill = resolvedLineColor;
|
||||
}
|
||||
|
||||
return lineConfig;
|
||||
@@ -120,9 +118,9 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
* Build points configuration
|
||||
*/
|
||||
private buildPointsConfig({
|
||||
lineColor,
|
||||
resolvedLineColor,
|
||||
}: {
|
||||
lineColor: string;
|
||||
resolvedLineColor: string;
|
||||
}): Partial<Series.Points> {
|
||||
const {
|
||||
lineWidth,
|
||||
@@ -133,8 +131,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
showPoints,
|
||||
} = this.props;
|
||||
const pointsConfig: Partial<Series.Points> = {
|
||||
stroke: lineColor,
|
||||
fill: lineColor,
|
||||
stroke: resolvedLineColor,
|
||||
fill: resolvedLineColor,
|
||||
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
|
||||
filter: pointsFilter || undefined,
|
||||
};
|
||||
@@ -170,14 +168,14 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
getConfig(): Series {
|
||||
const { scaleKey, label, spanGaps, show = true } = this.props;
|
||||
|
||||
const lineColor = this.getLineColor();
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
const lineConfig = this.buildLineConfig({
|
||||
lineColor,
|
||||
resolvedLineColor,
|
||||
});
|
||||
const pathConfig = this.buildPathConfig();
|
||||
const pointsConfig = this.buildPointsConfig({
|
||||
lineColor,
|
||||
resolvedLineColor,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -218,21 +216,20 @@ function getPathBuilder({
|
||||
barMaxWidth?: number;
|
||||
barWidthFactor?: number;
|
||||
}): Series.PathBuilder {
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Bar) {
|
||||
const barsCfgKey = `bars|${barAlignment}|${barWidthFactor}|${barMaxWidth}`;
|
||||
if (!builders[barsCfgKey] && pathBuilders.bars) {
|
||||
builders[barsCfgKey] = pathBuilders.bars({
|
||||
const pathBuilders = uPlot.paths;
|
||||
const barsConfigKey = `bars|${barAlignment}|${barWidthFactor}|${barMaxWidth}`;
|
||||
if (!builders[barsConfigKey] && pathBuilders.bars) {
|
||||
builders[barsConfigKey] = pathBuilders.bars({
|
||||
size: [barWidthFactor, barMaxWidth],
|
||||
align: barAlignment,
|
||||
});
|
||||
}
|
||||
return builders[barsCfgKey];
|
||||
return builders[barsConfigKey];
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Line) {
|
||||
|
||||
Reference in New Issue
Block a user