Skip to content

Commit 1dd9858

Browse files
committed
db-console: add metrics workspace to debug page
This debug page is similar to `Custom Time Series` but allows for exporting and loading of custom time series dashboards. This page is currently only available when navigating directly to `/debug/metrics_workspace` and there is no visible link to this page as its experimental. Epic: none Release note: None
1 parent 6708f9d commit 1dd9858

File tree

12 files changed

+1033
-19
lines changed

12 files changed

+1033
-19
lines changed

pkg/ui/workspaces/db-console/src/app.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import InsightsOverviewPage from "./views/insights/insightsOverview";
9090
import StatementInsightDetailsPage from "./views/insights/statementInsightDetailsPage";
9191
import TransactionInsightDetailsPage from "./views/insights/transactionInsightDetailsPage";
9292
import { JwtAuthTokenPage } from "./views/jwt/jwtAuthToken";
93+
import MetricsWorkspace from "./views/reports/containers/metricsWorkspace/metricsWorkspace";
9394
import ActiveStatementDetails from "./views/statements/activeStatementDetailsConnected";
9495
import ActiveTransactionDetails from "./views/transactions/activeTransactionDetailsConnected";
9596

@@ -372,6 +373,11 @@ export const App: React.FC<AppProps> = (props: AppProps) => {
372373
path="/debug/chart"
373374
component={CustomChart}
374375
/>
376+
<Route
377+
exact
378+
path="/debug/metrics_workspace"
379+
component={MetricsWorkspace}
380+
/>
375381
<Route
376382
exact
377383
path="/debug/enqueue_range"

pkg/ui/workspaces/db-console/src/redux/metricMetadata.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { createSelector } from "reselect";
88
import { AdminUIState } from "src/redux/state";
99
import { MetricMetadataResponseMessage } from "src/util/api";
1010

11+
import { DropdownOption } from "../views/shared/components/dropdown";
12+
1113
export type MetricsMetadata = MetricMetadataResponseMessage;
1214

1315
// State selectors
@@ -18,3 +20,21 @@ export const metricsMetadataSelector = createSelector(
1820
metricsMetadataStateSelector,
1921
(metricsMetadata): MetricsMetadata => metricsMetadata,
2022
);
23+
24+
export const metricOptionsSelector = createSelector(
25+
metricsMetadataSelector,
26+
(metricsMetadata): DropdownOption[] => {
27+
if (metricsMetadata?.metadata == null) {
28+
return [];
29+
}
30+
31+
return Object.keys(metricsMetadata.metadata).map(k => {
32+
const fullMetricName = metricsMetadata.recordedNames[k];
33+
return {
34+
value: fullMetricName,
35+
label: k,
36+
description: metricsMetadata.metadata[k]?.help,
37+
};
38+
});
39+
},
40+
);

pkg/ui/workspaces/db-console/src/redux/nodes.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { Pick } from "src/util/pick";
2828
import { nullOfReturnType } from "src/util/types";
2929
import { NoConnection } from "src/views/reports/containers/network";
3030

31+
import { DropdownOption } from "../views/shared/components/dropdown";
32+
3133
import { AdminUIState } from "./state";
3234

3335
/**
@@ -637,3 +639,25 @@ export const isSingleNodeCluster = createSelector(
637639
nodeStatusesSelector,
638640
nodeStatuses => nodeStatuses && nodeStatuses.length === 1,
639641
);
642+
643+
export const nodeOptionsSelector = createSelector(
644+
nodesSummarySelector,
645+
(summary: NodesSummary): DropdownOption[] => {
646+
const nodeDisplayNameByID = summary.nodeDisplayNameByID;
647+
const base: DropdownOption[] = [{ value: "", label: "Cluster" }];
648+
const options: DropdownOption[] = summary.nodeStatuses
649+
.map(ns => ({
650+
value: ns.desc.node_id.toString(),
651+
label: nodeDisplayNameByID[ns.desc.node_id] as string,
652+
}))
653+
.sort((a: DropdownOption, b: DropdownOption) => {
654+
// Move decommissioned nodes to the end of the list.
655+
const aDecommissioned = a.label.startsWith("[decommissioned]");
656+
const bDecommissioned = b.label.startsWith("[decommissioned]");
657+
if (aDecommissioned && !bDecommissioned) return 1;
658+
if (!aDecommissioned && bDecommissioned) return -1;
659+
return 0;
660+
});
661+
return base.concat(options);
662+
},
663+
);

pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/customMetric.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ export class CustomMetricRow extends React.Component<CustomMetricRowProps> {
168168
onChange={this.changeMetric}
169169
placeholder="Select a metric..."
170170
optionComponent={MetricOption}
171+
matchProp="label"
172+
ignoreCase={true}
171173
/>
172174
</div>
173175
</td>

pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/metricOption.tsx

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,28 @@ import { OptionComponentProps } from "react-select";
99

1010
import "./metricOption.styl";
1111

12-
export const MetricOption = (props: OptionComponentProps<string>) => {
13-
const { option, className, onSelect, onFocus } = props;
14-
const { label, description } = option;
15-
const classes = classnames("metric-option", className);
12+
export const MetricOption = React.memo(
13+
(props: OptionComponentProps<string>) => {
14+
const { option, className, onSelect, onFocus } = props;
15+
const { label, description } = option;
16+
const classes = classnames("metric-option", className);
1617

17-
return (
18-
<div
19-
className={classes}
20-
role="option"
21-
aria-label={label}
22-
title={option.title}
23-
onMouseDown={event => onSelect(option, event)}
24-
onMouseEnter={event => onFocus(option, event)}
25-
>
26-
<div className="metric-option__label">{label}</div>
27-
<div className="metric-option__description" title={description}>
28-
{description}
18+
return (
19+
<div
20+
className={classes}
21+
role="option"
22+
aria-label={label}
23+
title={option.title || description}
24+
onMouseDown={event => onSelect(option, event)}
25+
onMouseEnter={event => onFocus(option, event)}
26+
>
27+
<div className="metric-option__label">{label}</div>
28+
{description && (
29+
<div className="metric-option__description" title={description}>
30+
{description}
31+
</div>
32+
)}
2933
</div>
30-
</div>
31-
);
32-
};
34+
);
35+
},
36+
);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright 2025 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
import React from "react";
7+
8+
import { MetricsMetadata } from "oss/src/redux/metricMetadata";
9+
import { NodesSummary } from "oss/src/redux/nodes";
10+
import { Metric } from "oss/src/views/shared/components/metricQuery";
11+
import { CustomMetricState } from "src/views/reports/containers/customChart/customMetric";
12+
13+
import { getSources } from "../../customChart";
14+
15+
type Props = {
16+
key: React.Key;
17+
metric: CustomMetricState;
18+
tenants: string[];
19+
nodesSummary: NodesSummary;
20+
metricsMetadata: MetricsMetadata;
21+
};
22+
23+
const CustomMetric = ({
24+
key,
25+
metric,
26+
nodesSummary,
27+
tenants,
28+
metricsMetadata,
29+
}: Props): React.ReactElement => {
30+
const sources = getSources(nodesSummary, metric, metricsMetadata);
31+
if (metric.perSource && metric.perTenant) {
32+
return (
33+
<React.Fragment>
34+
{sources.flatMap(source => {
35+
return tenants.map((tenant: string, i: number) => (
36+
<Metric
37+
key={`${key}${i}${source}${tenant}`}
38+
title={`${source}-${tenant}: ${metric.metric} (${i})`}
39+
name={metric.metric}
40+
aggregator={metric.aggregator}
41+
downsampler={metric.downsampler}
42+
derivative={metric.derivative}
43+
sources={[source]}
44+
tenantSource={tenant}
45+
/>
46+
));
47+
})}
48+
</React.Fragment>
49+
);
50+
}
51+
52+
if (metric.perSource) {
53+
return (
54+
<React.Fragment>
55+
{sources.map((source: string, i: number) => (
56+
<Metric
57+
key={`${key}${i}${source}`}
58+
title={`${source}: ${metric.metric} (${i})`}
59+
name={metric.metric}
60+
aggregator={metric.aggregator}
61+
downsampler={metric.downsampler}
62+
derivative={metric.derivative}
63+
sources={[source]}
64+
tenantSource={metric.tenantSource}
65+
/>
66+
))}
67+
</React.Fragment>
68+
);
69+
}
70+
71+
if (metric.perTenant) {
72+
return (
73+
<React.Fragment>
74+
{tenants.map((tenant: string, i: number) => (
75+
<Metric
76+
key={`${key}${i}${tenant}`}
77+
title={`${tenant}: ${metric.metric} (${i})`}
78+
name={metric.metric}
79+
aggregator={metric.aggregator}
80+
downsampler={metric.downsampler}
81+
derivative={metric.derivative}
82+
sources={sources}
83+
tenantSource={tenant}
84+
/>
85+
))}
86+
</React.Fragment>
87+
);
88+
}
89+
90+
return (
91+
<Metric
92+
key={key}
93+
title={`${metric.metric}`}
94+
name={metric.metric}
95+
aggregator={metric.aggregator}
96+
downsampler={metric.downsampler}
97+
derivative={metric.derivative}
98+
sources={sources}
99+
tenantSource={metric.tenantSource}
100+
/>
101+
);
102+
};
103+
104+
export default CustomMetric;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright 2025 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
import { AxisUnits } from "@cockroachlabs/cluster-ui";
7+
import React from "react";
8+
9+
import { Button } from "src/components/button";
10+
11+
import { CustomMetricState } from "../../customChart/customMetric";
12+
import { DashboardConfig, GraphConfig } from "../dashboardConfig";
13+
14+
import EditableTitle from "./editableTitle";
15+
import GraphContainer from "./graphContainer";
16+
17+
interface DashboardTabProps {
18+
config: DashboardConfig;
19+
onDashboardChange: (
20+
updater: (dashboard: DashboardConfig) => DashboardConfig,
21+
) => void;
22+
}
23+
24+
const DashboardTab: React.FC<DashboardTabProps> = ({
25+
config,
26+
onDashboardChange,
27+
}: DashboardTabProps) => {
28+
const handleNameChange = (newName: string) => {
29+
onDashboardChange(dashboard => ({
30+
...dashboard,
31+
name: newName,
32+
}));
33+
};
34+
35+
const handleCreateNewGraph = () => {
36+
const newMetrics = [new CustomMetricState()];
37+
onDashboardChange(dashboard => ({
38+
...dashboard,
39+
graphs: [
40+
...dashboard.graphs,
41+
{
42+
title: "",
43+
axis: {
44+
units: AxisUnits.Count,
45+
label: "",
46+
},
47+
metrics: newMetrics,
48+
},
49+
],
50+
}));
51+
};
52+
53+
const handleGraphConfigChange = (
54+
graphIndex: number,
55+
newGraphConfig: GraphConfig,
56+
) => {
57+
onDashboardChange(dashboard => ({
58+
...dashboard,
59+
graphs: dashboard.graphs.map((graph, idx) =>
60+
idx === graphIndex ? newGraphConfig : graph,
61+
),
62+
}));
63+
};
64+
65+
const handleGraphDelete = (graphIndex: number) => {
66+
onDashboardChange(dashboard => ({
67+
...dashboard,
68+
graphs: dashboard.graphs.filter((_, idx) => idx !== graphIndex),
69+
}));
70+
};
71+
72+
const handleExport = () => {
73+
// Create export config without the internal 'key' field
74+
const exportConfig = {
75+
name: config.name,
76+
graphs: config.graphs,
77+
};
78+
79+
const jsonString = JSON.stringify(exportConfig, null, 2);
80+
const blob = new Blob([jsonString], { type: "application/json" });
81+
const url = URL.createObjectURL(blob);
82+
const link = document.createElement("a");
83+
link.href = url;
84+
link.download = `${config.name || "dashboard"}.json`;
85+
document.body.appendChild(link);
86+
link.click();
87+
document.body.removeChild(link);
88+
URL.revokeObjectURL(url);
89+
};
90+
91+
return (
92+
<div className="metrics-workspace__dashboard-content">
93+
<div
94+
style={{
95+
marginBottom: "24px",
96+
paddingBottom: "16px",
97+
borderBottom: "1px solid #e8e8e8",
98+
display: "flex",
99+
alignItems: "center",
100+
justifyContent: "space-between",
101+
}}
102+
>
103+
<EditableTitle
104+
value={config.name}
105+
onChange={handleNameChange}
106+
placeholder="Dashboard Name"
107+
size="large"
108+
/>
109+
<Button type="secondary" onClick={handleExport}>
110+
Export Dashboard
111+
</Button>
112+
</div>
113+
{config.graphs.length > 0 &&
114+
config.graphs.map((graph, index) => (
115+
<GraphContainer
116+
key={index}
117+
index={index}
118+
graphConfig={graph}
119+
onConfigChange={handleGraphConfigChange}
120+
onDelete={handleGraphDelete}
121+
/>
122+
))}
123+
<div style={{ marginTop: "24px" }}>
124+
<Button type="primary" onClick={handleCreateNewGraph}>
125+
Add Graph
126+
</Button>
127+
</div>
128+
{!config.graphs?.length && (
129+
<div className="metrics-workspace__empty-state">
130+
No graphs configured. Add graphs to this dashboard.
131+
</div>
132+
)}
133+
</div>
134+
);
135+
};
136+
137+
export default DashboardTab;

0 commit comments

Comments
 (0)