feat: default fields for add Panel and metrics page (#12509)

Design at: dashboards/default-panel-fields
This commit is contained in:
Abhay Padamani
2026-06-09 08:39:09 +00:00
committed by GitHub
parent f5bdf4daaa
commit 03dfc88bfb
40 changed files with 963 additions and 268 deletions
@@ -1140,6 +1140,9 @@ watch(
() => [
dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]
?.fields?.stream,
// Rebuild the auto query once the stream schema loads (makeAutoSQLQuery bails
// out while groupedFields is empty).
dashboardPanelData.meta?.streamFields?.groupedFields?.length,
dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]
?.fields?.x,
dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]
@@ -520,24 +520,11 @@ export default defineComponent({
});
const addTab = () => {
// addQuery() seeds the new query's default builder fields (and PromQL
// sample query) synchronously, so the tab is ready the moment it activates.
addQuery();
dashboardPanelData.layout.currentQueryIndex =
dashboardPanelData.data.queries.length - 1;
// For metrics page: when switching from custom to builder in PromQL, set sample query
if (
dashboardPanelData.data.queryType === "promql" &&
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.stream
) {
const streamName =
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.stream;
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].query = `${streamName}{}`;
}
};
const updatePromQLQuery = async (value, event) => {
@@ -680,7 +680,19 @@ watch(
.fields.stream_type === dashboardPanelData.meta.stream.streamResultsType
) {
try {
if (promqlMode.value && dashboardPanelDataPageKey === "metrics") {
// On stream change in PromQL mode, reset the query to `${stream}{}`.
// Metrics: custom + builder. Add Panel: PromQL custom only (builder
// regenerates via DashboardQueryBuilder; SQL is excluded — promql-only block).
const promqlQuery =
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
];
const shouldResetPromqlQuery =
promqlMode.value &&
(dashboardPanelDataPageKey === "metrics" ||
(dashboardPanelDataPageKey === "dashboard" &&
promqlQuery?.customQuery));
if (shouldResetPromqlQuery) {
let parsedQuery = null;
try {
parsedQuery = parsePromQlQuery(
@@ -86,6 +86,15 @@ vi.mock("../../../composables/dashboard/useDashboardPanel", () => ({
}),
}));
// Mock useDefaultPanelFields — the selector only needs applyDefaultPanelFields to
// be callable on the Builder toggle; its seeding internals are covered by its own spec.
const mockApplyDefaultPanelFields = vi.fn();
vi.mock("@/composables/dashboard/useDefaultPanelFields", () => ({
default: () => ({
applyDefaultPanelFields: mockApplyDefaultPanelFields,
}),
}));
// Mock ConfirmDialog component
const ConfirmDialogStub = {
template: `
@@ -92,6 +92,7 @@ import {
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import useDashboardPanelData from "../../../composables/dashboard/useDashboardPanel";
import useDefaultPanelFields from "@/composables/dashboard/useDefaultPanelFields";
import ConfirmDialog from "../../ConfirmDialog.vue";
import { useStore } from "vuex";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
@@ -121,6 +122,12 @@ export default defineComponent({
removeXYFilters,
updateXYFieldsForCustomQueryMode,
} = useDashboardPanelData(dashboardPanelDataPageKey);
const { applyDefaultPanelFields } = useDefaultPanelFields(
dashboardPanelDataPageKey,
);
// Pages that re-seed default builder fields on the in-page Builder toggle.
// (Entering a builder surface parses on mount, not via this handler.)
const SEED_ON_TOGGLE_PAGES = ["dashboard", "metrics", "build", "logs"];
const confirmQueryModeChangeDialog = ref(false);
const confirmDialogMessage = ref(
"Are you sure you want to change the query mode? The data saved for X-Axis, Y-Axis and Filters will be wiped off.",
@@ -267,7 +274,9 @@ export default defineComponent({
"Are you sure you want to change the query mode? The data saved for X-Axis, Y-Axis and Filters will be wiped off.";
}
dashboardPanelData.data.queries[0].query != ""
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].query != ""
? (confirmQueryModeChangeDialog.value = true)
: changeToggle();
}
@@ -279,9 +288,6 @@ export default defineComponent({
popupSelectedButtonType.value === "promql" ||
popupSelectedButtonType.value === "sql";
const isSwitchingToBuilder =
!isQueryTypeChange && popupSelectedButtonType.value === "builder";
if (isQueryTypeChange) {
selectedButtonQueryType.value = popupSelectedButtonType.value;
} else {
@@ -304,22 +310,16 @@ export default defineComponent({
}
// For metrics page: when switching from custom to builder in PromQL, set sample query
if (
dashboardPanelDataPageKey === "metrics" &&
isSwitchingToBuilder &&
dashboardPanelData.data.queryType === "promql" &&
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
]?.fields?.stream
) {
const streamName =
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.stream;
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].query = `${streamName}{}`;
// removeXYFilters() above wiped the builder fields; re-seed defaults when
// the resulting mode is a builder.
const seedQueryIdx = dashboardPanelData.layout.currentQueryIndex;
const seedQuery = dashboardPanelData.data.queries[seedQueryIdx];
const resultingBuilderMode = !seedQuery?.customQuery;
const shouldSeedDefaults =
resultingBuilderMode &&
SEED_ON_TOGGLE_PAGES.includes(dashboardPanelDataPageKey);
if (shouldSeedDefaults) {
await applyDefaultPanelFields();
}
// empty the errors
@@ -140,16 +140,26 @@ describe("useDashboardPanel", () => {
panel.addQuery();
expect(panel.dashboardPanelData.data.queries).toHaveLength(2);
expect(panel.dashboardPanelData.data.queries[1].fields.stream).toBe("stream-a");
// New query is seeded synchronously with default builder fields so the tab
// is ready the moment it activates (no async race with stream selection).
expect(panel.dashboardPanelData.data.queries[1].fields.y).toHaveLength(1);
expect(panel.dashboardPanelData.data.queries[1].fields.y[0].functionName).toBe(
"count",
);
panel.removeQuery(1);
expect(panel.dashboardPanelData.data.queries).toHaveLength(1);
});
it("resets panel data and adds default timestamp field", () => {
it("resets panel data and seeds default histogram x + count y fields", () => {
const panel = useDashboardPanelData("dashboard-panel-test-2");
panel.resetDashboardPanelDataAndAddTimeField();
expect(addXAxisItemMock).toHaveBeenCalledWith({ name: "_ts" });
const fields = panel.dashboardPanelData.data.queries[0].fields;
expect(fields.x).toHaveLength(1);
expect(fields.x[0].functionName).toBe("histogram");
expect(fields.y).toHaveLength(1);
expect(fields.y[0].functionName).toBe("count");
});
it("turns off VRL toggle when query type changes to promql", async () => {
@@ -33,6 +33,11 @@ import {
} from "@/utils/dashboard/dashboardAutoQueryBuilder";
import { usePanelFields } from "@/composables/dashboard/usePanelFields";
import { usePanelAggregation } from "@/composables/dashboard/usePanelAggregation";
import {
DEFAULT_SQL_X_FIELD,
DEFAULT_SQL_Y_FIELD_COUNT,
buildDefaultBuilderFields,
} from "@/utils/dashboard/defaultFields";
import {
getDefaultDashboardPanelData,
getDefaultCustomChartText,
@@ -161,6 +166,25 @@ const useDashboardPanelData = (pageKey: string = "dashboard") => {
weight_fixed: 1,
},
};
// Seed the new query's default builder fields synchronously (mirrors the
// first query) so the tab is fully configured the moment it becomes active —
// an async seed would race with the user/test selecting a stream next.
// PromQL gets the `${stream}{}` sample query; SQL gets chart-type-aware x/y.
if (dashboardPanelData.data.queryType === "promql") {
if (newQuery.fields.stream) {
newQuery.query = `${newQuery.fields.stream}{}`;
}
} else {
const { x, y } = buildDefaultBuilderFields(
dashboardPanelData.data.type,
newQuery.fields.stream_type,
dashboardPanelData.meta?.streamFields?.groupedFields ?? [],
newQuery.fields.stream,
);
newQuery.fields.x = x;
newQuery.fields.y = y;
}
dashboardPanelData.data.queries.push(newQuery);
// Initialize per-query field cache in meta
getQueryFields(dashboardPanelData.data.queries.length - 1);
@@ -195,10 +219,16 @@ const useDashboardPanelData = (pageKey: string = "dashboard") => {
const resetDashboardPanelDataAndAddTimeField = () => {
resetDashboardPanelData();
// add _timestamp field in x axis as default
addXAxisItem({
name: store.state.zoConfig.timestamp_column ?? "_timestamp",
});
// Seed default x (histogram(_timestamp)) and y (count(_timestamp)) from the
// shared default-fields builder so a bar chart renders on open — the same
// fields applyDefaultPanelFields seeds on stream/builder changes.
const currentQueryIndex = dashboardPanelData.layout.currentQueryIndex;
dashboardPanelData.data.queries[currentQueryIndex].fields.x = [
DEFAULT_SQL_X_FIELD(),
];
dashboardPanelData.data.queries[currentQueryIndex].fields.y = [
DEFAULT_SQL_Y_FIELD_COUNT(),
];
};
// Watch queryType and toggle off VRL functions when switching to PromQL
@@ -0,0 +1,246 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, vi } from "vitest";
const { mockUpdateGroupedFields, mockMakeAutoSQLQuery, state } = vi.hoisted(
() => ({
mockUpdateGroupedFields: vi.fn(),
mockMakeAutoSQLQuery: vi.fn(),
state: { panel: null as any },
}),
);
vi.mock("@/composables/dashboard/useDashboardPanel", () => ({
default: () => ({
dashboardPanelData: state.panel,
updateGroupedFields: mockUpdateGroupedFields,
makeAutoSQLQuery: mockMakeAutoSQLQuery,
// Mirror the real chart-type axis rules: metric/gauge disallow an x-axis.
isAddXAxisNotAllowed: {
get value() {
const t = state.panel?.data?.type;
const xlen =
state.panel?.data?.queries?.[0]?.fields?.x?.length ?? 0;
if (t === "metric") return xlen >= 0; // metric: x never allowed
if (t === "gauge" || t === "pie" || t === "donut") return xlen >= 1;
if (t === "table") return false;
return xlen >= 1; // bar/line/area/etc.
},
},
isAddYAxisNotAllowed: {
get value() {
const ylen =
state.panel?.data?.queries?.[0]?.fields?.y?.length ?? 0;
const t = state.panel?.data?.type;
if (["pie", "donut", "gauge", "metric"].includes(t)) return ylen >= 1;
return false;
},
},
}),
}));
import useDefaultPanelFields from "./useDefaultPanelFields";
function setPanel({
type = "bar",
queryType = "sql",
stream = "s1",
streamType = "logs",
query = "",
customQuery = true,
groupedFields = [] as any[],
} = {}) {
state.panel = {
data: {
type,
queryType,
queries: [
{
customQuery,
query,
fields: {
stream,
stream_type: streamType,
x: [],
y: [],
breakdown: [],
filter: {},
},
},
],
},
layout: { currentQueryIndex: 0 },
meta: { streamFields: { groupedFields } },
};
}
const currentQuery = () => state.panel.data.queries[0];
describe("useDefaultPanelFields", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("PromQL", () => {
it("seeds `${stream}{}` and sets builder mode, without touching x/y", async () => {
setPanel({ queryType: "promql", stream: "mystream", customQuery: true });
const { applyDefaultPanelFields } = useDefaultPanelFields("metrics");
await applyDefaultPanelFields();
expect(currentQuery().query).toBe("mystream{}");
expect(currentQuery().customQuery).toBe(false);
expect(currentQuery().fields.x).toEqual([]);
expect(mockUpdateGroupedFields).not.toHaveBeenCalled();
expect(mockMakeAutoSQLQuery).not.toHaveBeenCalled();
});
it("leaves the query empty when no stream is selected", async () => {
setPanel({ queryType: "promql", stream: "" });
const { applyDefaultPanelFields } = useDefaultPanelFields("metrics");
await applyDefaultPanelFields();
expect(currentQuery().query).toBe("");
expect(currentQuery().customQuery).toBe(false);
});
});
describe("SQL", () => {
it("logs stream: seeds histogram + count, resets breakdown/filter, regenerates", async () => {
setPanel({
queryType: "sql",
stream: "logs1",
streamType: "logs",
groupedFields: [{ name: "logs1", schema: [{ name: "_timestamp" }] }],
});
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(mockUpdateGroupedFields).toHaveBeenCalledTimes(1);
expect(currentQuery().fields.x[0].functionName).toBe("histogram");
expect(currentQuery().fields.y[0].functionName).toBe("count");
expect(currentQuery().fields.breakdown).toEqual([]);
expect(currentQuery().fields.filter).toMatchObject({
filterType: "group",
logicalOperator: "AND",
conditions: [],
});
expect(currentQuery().customQuery).toBe(false);
expect(mockMakeAutoSQLQuery).toHaveBeenCalledTimes(1);
});
it("metrics stream with a value column: seeds avg(value)", async () => {
setPanel({
queryType: "sql",
stream: "m1",
streamType: "metrics",
groupedFields: [{ name: "m1", schema: [{ name: "value" }] }],
});
const { applyDefaultPanelFields } = useDefaultPanelFields("metrics");
await applyDefaultPanelFields();
expect(currentQuery().fields.y[0].functionName).toBe("avg");
});
it("metric chart: seeds y but NOT x (x-axis not allowed for metric)", async () => {
setPanel({
type: "metric",
queryType: "sql",
stream: "logs1",
streamType: "logs",
groupedFields: [{ name: "logs1", schema: [{ name: "_timestamp" }] }],
});
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(currentQuery().fields.x).toEqual([]);
expect(currentQuery().fields.y[0].functionName).toBe("count");
});
it("gauge chart: seeds x and y (gauge allows one x when empty)", async () => {
setPanel({
type: "gauge",
queryType: "sql",
stream: "logs1",
streamType: "logs",
groupedFields: [{ name: "logs1", schema: [{ name: "_timestamp" }] }],
});
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(currentQuery().fields.x[0].functionName).toBe("histogram");
expect(currentQuery().fields.y[0].functionName).toBe("count");
});
it("no stream: seeds x/y but skips schema load and query regeneration", async () => {
setPanel({ queryType: "sql", stream: "", streamType: "logs" });
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(mockUpdateGroupedFields).not.toHaveBeenCalled();
expect(mockMakeAutoSQLQuery).not.toHaveBeenCalled();
expect(currentQuery().fields.x[0].functionName).toBe("histogram");
expect(currentQuery().fields.y[0].functionName).toBe("count");
});
it("does not reject when updateGroupedFields throws, and falls back to count", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// empty groupedFields = no schema for the current stream (failed/stale load)
setPanel({
queryType: "sql",
stream: "m1",
streamType: "metrics",
groupedFields: [],
});
mockUpdateGroupedFields.mockRejectedValueOnce(new Error("network"));
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await expect(applyDefaultPanelFields()).resolves.toBeUndefined();
expect(currentQuery().fields.y[0].functionName).toBe("count");
expect(mockMakeAutoSQLQuery).toHaveBeenCalledTimes(1);
errSpy.mockRestore();
});
});
describe("guards", () => {
it("is a no-op for non-cartesian / custom chart types", async () => {
setPanel({ type: "custom_chart", customQuery: true });
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(currentQuery().customQuery).toBe(true); // returned before any mutation
expect(mockMakeAutoSQLQuery).not.toHaveBeenCalled();
});
it("is a no-op for heatmap (measure lives on Z; Y disallows aggregation)", async () => {
setPanel({ type: "heatmap", customQuery: true });
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await applyDefaultPanelFields();
expect(currentQuery().customQuery).toBe(true); // returned before any mutation
expect(currentQuery().fields.x).toEqual([]);
expect(currentQuery().fields.y).toEqual([]);
expect(mockMakeAutoSQLQuery).not.toHaveBeenCalled();
});
it("returns early (no throw) when the current query is missing", async () => {
setPanel();
state.panel.data.queries = [];
const { applyDefaultPanelFields } = useDefaultPanelFields("dashboard");
await expect(applyDefaultPanelFields()).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,90 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import useDashboardPanelData from "@/composables/dashboard/useDashboardPanel";
import {
buildDefaultSqlFields,
SKIP_SEED_TYPES,
} from "@/utils/dashboard/defaultFields";
/**
* Shared default-field seeding for the Add Panel and Metrics pages (both render
* the same PanelEditor). Seeds the current query based on queryType + stream_type:
* PromQL -> `${stream}{}`; SQL logs/traces -> count(_timestamp); SQL metrics ->
* avg(value) if the stream has a "value" column, else count(_timestamp).
*/
const useDefaultPanelFields = (pageKey: string = "dashboard") => {
const {
dashboardPanelData,
updateGroupedFields,
makeAutoSQLQuery,
isAddXAxisNotAllowed,
isAddYAxisNotAllowed,
} = useDashboardPanelData(pageKey);
const applyDefaultPanelFields = async () => {
const idx = dashboardPanelData.layout.currentQueryIndex;
const query = dashboardPanelData.data.queries[idx];
if (!query) return;
if (SKIP_SEED_TYPES.includes(dashboardPanelData.data.type)) return;
const queryType = dashboardPanelData.data.queryType;
const stream = query.fields?.stream;
const streamType = query.fields?.stream_type;
query.customQuery = false;
if (queryType === "promql") {
if (stream) query.query = `${stream}{}`;
return;
}
if (stream) {
// updateGroupedFields has no catch of its own; swallow so a failed schema
// load can't reject the toggle/watcher.
try {
await updateGroupedFields();
} catch (e) {
console.error("useDefaultPanelFields: updateGroupedFields failed", e);
}
}
// hasValueColumn is scoped to `stream`, so a missing/stale schema (incl. join
// streams) falls back to count(_timestamp).
const { x, y } = buildDefaultSqlFields(
streamType,
dashboardPanelData.meta?.streamFields?.groupedFields ?? [],
stream,
);
// Respect chart-type axis rules: metric/gauge etc. don't allow an x-axis, so
// only seed x/y where the current chart type permits it (computed from the
// now-empty field state after removeXYFilters).
query.fields.x = isAddXAxisNotAllowed.value ? [] : x;
query.fields.y = isAddYAxisNotAllowed.value ? [] : y;
query.fields.breakdown = [];
query.fields.filter = {
filterType: "group",
logicalOperator: "AND",
conditions: [],
};
if (stream) await makeAutoSQLQuery();
};
return { applyDefaultPanelFields };
};
export default useDefaultPanelFields;
+16 -104
View File
@@ -148,11 +148,7 @@ import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
import { PanelEditor } from "@/components/dashboards/PanelEditor";
import { saveMetricsStream, restoreMetricsStream } from "@/utils/streamPersist";
import {
DEFAULT_METRICS_X_FIELD,
DEFAULT_METRICS_Y_FIELD,
DEFAULT_METRICS_Y_FIELD_COUNT,
} from "@/utils/metrics/constants";
import useDefaultPanelFields from "@/composables/dashboard/useDefaultPanelFields";
const AddToDashboard = defineAsyncComponent(() => {
return import("./../metrics/AddToDashboard.vue");
@@ -189,9 +185,8 @@ export default defineComponent({
resetDashboardPanelData,
resetAggregationFunction,
validatePanel,
updateGroupedFields,
makeAutoSQLQuery,
} = useDashboardPanelData("metrics");
const { applyDefaultPanelFields } = useDefaultPanelFields("metrics");
const editMode = ref(false);
const selectedDate: any = ref({
valueType: "relative",
@@ -227,33 +222,6 @@ export default defineComponent({
// already resets and re-initialises the state correctly.
});
/** Apply default SQL builder fields for metrics.
* Uses avg(value) if the stream has a "value" field, otherwise count(_timestamp). */
const applyMetricsDefaults = () => {
const query = dashboardPanelData.data.queries[0];
query.customQuery = false;
query.fields.x = [DEFAULT_METRICS_X_FIELD()];
// Check if the current stream has a "value" field
const streamFields =
dashboardPanelData.meta?.streamFields?.groupedFields ?? [];
const hasValueField = streamFields.some((stream: any) =>
stream?.schema?.some((field: any) => field?.name === "value"),
);
query.fields.y = [
hasValueField
? DEFAULT_METRICS_Y_FIELD()
: DEFAULT_METRICS_Y_FIELD_COUNT(),
];
query.fields.breakdown = [];
query.fields.filter = {
filterType: "group",
logicalOperator: "AND",
conditions: [],
};
};
// Initialize state before any child components mount so FieldList.vue sees
// stream_type = "metrics" from the start, preventing a spurious
// streams?type=logs request and the double stream-list fetch that results
@@ -294,6 +262,17 @@ export default defineComponent({
// let it call the watchers and then mark the panel config watcher as activated
await nextTick();
isPanelConfigWatcherActivated = true;
// Seed the default builder query on initial load (the PromQL builder only
// rebuilds on label/operation change, so the bar would otherwise be empty).
const initialQuery = dashboardPanelData.data.queries[0];
if (
initialQuery?.fields?.stream &&
!initialQuery.customQuery &&
!initialQuery.query
) {
await applyDefaultPanelFields();
}
});
watch(
@@ -326,84 +305,17 @@ export default defineComponent({
);
}
// When stream changes while in SQL builder mode and query is empty,
// apply defaults and regenerate query. Skip for custom mode.
// Seed the default query when a stream becomes available in builder mode
// with an empty query (the !query guard avoids overwriting an existing one).
const query = dashboardPanelData.data.queries[0];
if (
isPanelConfigWatcherActivated &&
stream &&
oldStream &&
stream !== oldStream &&
dashboardPanelData.data.queryType === "sql" &&
!query?.customQuery &&
!query?.query
) {
await updateGroupedFields();
applyMetricsDefaults();
await makeAutoSQLQuery();
}
},
);
// Handle query type switches on metrics page.
// We use nextTick() to ensure this runs AFTER changeToggle's removeXYFilters()
// has completed, so the defaults we apply here don't get immediately cleared.
// Only applies for builder mode (not custom mode).
watch(
() => dashboardPanelData.data.queryType,
async (newType: string, oldType: string) => {
if (!isPanelConfigWatcherActivated) return;
const query = dashboardPanelData.data.queries[0];
const stream = query?.fields?.stream;
const isCustomMode = query?.customQuery;
if (newType === "sql" && oldType === "promql" && !isCustomMode) {
// Switching to SQL builder: load stream fields first so applyMetricsDefaults
// can check whether the current stream has a "value" field
await nextTick();
if (stream) {
await updateGroupedFields();
}
applyMetricsDefaults();
if (stream) {
await makeAutoSQLQuery();
}
} else if (newType === "promql" && oldType === "sql" && !isCustomMode) {
// Switching to PromQL builder: set default builder query (streamName{})
await nextTick();
if (stream) {
query.query = `${stream}{}`;
}
}
},
);
// When switching from custom to builder mode, apply defaults.
// changeToggle's removeXYFilters() wipes the builder fields, so we
// always need to re-apply defaults regardless of whether query text is empty.
watch(
() => dashboardPanelData.data.queries[0]?.customQuery,
async (isCustom: boolean, wasCustom: boolean) => {
if (!isPanelConfigWatcherActivated) return;
// Only act when switching from custom (true) to builder (false)
if (wasCustom && !isCustom) {
await nextTick();
const query = dashboardPanelData.data.queries[0];
const stream = query?.fields?.stream;
if (dashboardPanelData.data.queryType === "sql") {
if (stream) {
await updateGroupedFields();
}
applyMetricsDefaults();
if (stream) {
await makeAutoSQLQuery();
}
} else if (dashboardPanelData.data.queryType === "promql" && stream) {
query.query = `${stream}{}`;
}
await applyDefaultPanelFields();
}
},
);
@@ -0,0 +1,172 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect } from "vitest";
import {
DEFAULT_SQL_X_FIELD,
DEFAULT_SQL_Y_FIELD_COUNT,
DEFAULT_SQL_Y_FIELD_VALUE,
hasValueColumn,
buildDefaultSqlFields,
buildDefaultBuilderFields,
SKIP_SEED_TYPES,
} from "./defaultFields";
describe("defaultFields", () => {
describe("field factories", () => {
it("DEFAULT_SQL_X_FIELD is histogram(_timestamp) on x_axis_1", () => {
const x = DEFAULT_SQL_X_FIELD();
expect(x).toMatchObject({
alias: "x_axis_1",
column: "_timestamp",
functionName: "histogram",
type: "build",
sortBy: "ASC",
});
expect(x.args[0]).toEqual({
type: "field",
value: { field: "_timestamp" },
});
});
it("DEFAULT_SQL_Y_FIELD_COUNT is count(_timestamp) on y_axis_1", () => {
const y = DEFAULT_SQL_Y_FIELD_COUNT();
expect(y).toMatchObject({
alias: "y_axis_1",
column: "_timestamp",
functionName: "count",
});
});
it("DEFAULT_SQL_Y_FIELD_VALUE is avg(value) on y_axis_1", () => {
const y = DEFAULT_SQL_Y_FIELD_VALUE();
expect(y).toMatchObject({
alias: "y_axis_1",
column: "value",
functionName: "avg",
});
});
it("returns a fresh object each call (factory, not shared ref)", () => {
const a = DEFAULT_SQL_X_FIELD();
const b = DEFAULT_SQL_X_FIELD();
expect(a).not.toBe(b);
expect(a.args).not.toBe(b.args);
});
});
describe("hasValueColumn", () => {
const withValue = { name: "m1", schema: [{ name: "value" }, { name: "le" }] };
const noValue = { name: "logs", schema: [{ name: "_timestamp" }] };
it("returns false for empty / nullish input", () => {
expect(hasValueColumn([])).toBe(false);
expect(hasValueColumn(undefined as any)).toBe(false);
});
it("returns true when any stream has a value column (no streamName)", () => {
expect(hasValueColumn([noValue, withValue])).toBe(true);
});
it("returns false when no stream has a value column", () => {
expect(hasValueColumn([noValue])).toBe(false);
});
it("scopes the check to the named stream only", () => {
// join: main stream has no value, joined stream does
const grouped = [noValue, withValue];
expect(hasValueColumn(grouped, "logs")).toBe(false);
expect(hasValueColumn(grouped, "m1")).toBe(true);
});
it("returns false when the named stream is absent", () => {
expect(hasValueColumn([withValue], "missing")).toBe(false);
});
});
describe("buildDefaultSqlFields", () => {
const metricWithValue = [{ name: "m1", schema: [{ name: "value" }] }];
const logsSchema = [{ name: "logs", schema: [{ name: "_timestamp" }] }];
it("logs -> histogram(_timestamp) + count(_timestamp)", () => {
const { x, y } = buildDefaultSqlFields("logs", logsSchema, "logs");
expect(x[0].functionName).toBe("histogram");
expect(y[0].functionName).toBe("count");
});
it("traces -> count(_timestamp) even if a value column exists", () => {
const traces = [{ name: "t1", schema: [{ name: "value" }] }];
const { y } = buildDefaultSqlFields("traces", traces, "t1");
expect(y[0].functionName).toBe("count");
});
it("metrics with value column -> avg(value)", () => {
const { y } = buildDefaultSqlFields("metrics", metricWithValue, "m1");
expect(y[0].functionName).toBe("avg");
expect(y[0].column).toBe("value");
});
it("metrics without value column -> count(_timestamp)", () => {
const { y } = buildDefaultSqlFields("metrics", logsSchema, "logs");
expect(y[0].functionName).toBe("count");
});
it("metrics: value column on a different (join) stream -> count for the main stream", () => {
const grouped = [
{ name: "main", schema: [{ name: "_timestamp" }] },
{ name: "joined", schema: [{ name: "value" }] },
];
const { y } = buildDefaultSqlFields("metrics", grouped, "main");
expect(y[0].functionName).toBe("count");
});
it("always seeds exactly one x and one y field", () => {
const { x, y } = buildDefaultSqlFields("logs", logsSchema, "logs");
expect(x).toHaveLength(1);
expect(y).toHaveLength(1);
});
});
describe("buildDefaultBuilderFields", () => {
const logsSchema = [{ name: "logs", schema: [{ name: "_timestamp" }] }];
it("cartesian chart (bar): seeds histogram x + count y", () => {
const { x, y } = buildDefaultBuilderFields("bar", "logs", logsSchema, "logs");
expect(x).toHaveLength(1);
expect(x[0].functionName).toBe("histogram");
expect(y).toHaveLength(1);
expect(y[0].functionName).toBe("count");
});
it("metric: seeds y only (no x-axis)", () => {
const { x, y } = buildDefaultBuilderFields("metric", "logs", logsSchema, "logs");
expect(x).toHaveLength(0);
expect(y).toHaveLength(1);
expect(y[0].functionName).toBe("count");
});
it.each(SKIP_SEED_TYPES)("%s: seeds nothing (self-driven builder)", (type) => {
const { x, y } = buildDefaultBuilderFields(type, "logs", logsSchema, "logs");
expect(x).toHaveLength(0);
expect(y).toHaveLength(0);
});
it("metrics stream with value column: seeds avg(value) y", () => {
const grouped = [{ name: "m1", schema: [{ name: "value" }] }];
const { y } = buildDefaultBuilderFields("bar", "metrics", grouped, "m1");
expect(y[0].functionName).toBe("avg");
});
});
});
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Default builder fields shared by the Add Panel and Metrics pages.
/** Default x-axis: histogram(_timestamp). */
export const DEFAULT_SQL_X_FIELD = () => ({
label: "_timestamp",
alias: "x_axis_1",
column: "_timestamp",
color: null,
type: "build",
functionName: "histogram",
args: [
{ type: "field", value: { field: "_timestamp" } },
{ type: "histogramInterval", value: null },
],
sortBy: "ASC",
isDerived: false,
havingConditions: [],
});
/** Default y-axis for logs/traces (and metrics without a "value" column): count(_timestamp). */
export const DEFAULT_SQL_Y_FIELD_COUNT = () => ({
label: "_timestamp",
alias: "y_axis_1",
column: "_timestamp",
color: "#5960b2",
type: "build",
functionName: "count",
args: [{ type: "field", value: { field: "_timestamp" } }],
sortBy: null,
isDerived: false,
havingConditions: [],
});
/** Metrics-only y-axis when the stream has a "value" column: avg(value). */
export const DEFAULT_SQL_Y_FIELD_VALUE = () => ({
label: "value",
alias: "y_axis_1",
column: "value",
color: "#5960b2",
type: "build",
functionName: "avg",
args: [{ type: "field", value: { field: "value" } }],
sortBy: null,
isDerived: false,
havingConditions: [],
});
/**
* True if a stream exposes a "value" column. When `streamName` is given, only
* that stream is checked (groupedFields also holds joined streams for SQL joins).
*/
export const hasValueColumn = (
groupedFields: any[],
streamName?: string,
): boolean => {
const streams = streamName
? (groupedFields ?? []).filter((stream: any) => stream?.name === streamName)
: (groupedFields ?? []);
return streams.some((stream: any) =>
stream?.schema?.some((field: any) => field?.name === "value"),
);
};
/** Default x/y for SQL builder mode; `streamName` scopes the value-column check. */
export const buildDefaultSqlFields = (
streamType: string,
groupedFields: any[],
streamName?: string,
): { x: any[]; y: any[] } => {
const useValueMeasure =
streamType === "metrics" && hasValueColumn(groupedFields, streamName);
return {
x: [DEFAULT_SQL_X_FIELD()],
y: [useValueMeasure ? DEFAULT_SQL_Y_FIELD_VALUE() : DEFAULT_SQL_Y_FIELD_COUNT()],
};
};
// Chart types that drive their own builder and must not get the cartesian x/y seed.
// heatmap is included because its measure lives on the Z/value axis — its Y axis is
// a dimension that disallows aggregation, so a count(_timestamp) Y seed is invalid.
export const SKIP_SEED_TYPES = [
"geomap",
"sankey",
"maps",
"custom_chart",
"html",
"markdown",
"heatmap",
];
/**
* Chart-type-aware default builder fields, computed synchronously from the
* already-loaded stream schema (no network). Mirrors applyDefaultPanelFields'
* axis rules: self-driven builders get nothing, metric gets a measure (y) but no
* x-axis, everything else gets the cartesian histogram-x + count/avg-y seed.
*/
export const buildDefaultBuilderFields = (
chartType: string,
streamType: string,
groupedFields: any[],
streamName?: string,
): { x: any[]; y: any[] } => {
if (SKIP_SEED_TYPES.includes(chartType)) return { x: [], y: [] };
const { x, y } = buildDefaultSqlFields(streamType, groupedFields, streamName);
// metric has no x-axis — it renders a single measure.
if (chartType === "metric") return { x: [], y };
return { x, y };
};
-59
View File
@@ -1,59 +0,0 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/** Default x-axis field for metrics builder: histogram(_timestamp) */
export const DEFAULT_METRICS_X_FIELD = () => ({
label: "_timestamp",
alias: "x_axis_1",
column: "_timestamp",
color: null,
type: "build",
functionName: "histogram",
args: [
{ type: "field", value: { field: "_timestamp" } },
{ type: "histogramInterval", value: null },
],
sortBy: "ASC",
isDerived: false,
havingConditions: [],
});
/** Default y-axis field for metrics builder: avg(value) */
export const DEFAULT_METRICS_Y_FIELD = () => ({
label: "value",
alias: "y_axis_1",
column: "value",
color: "#5960b2",
type: "build",
functionName: "avg",
args: [{ type: "field", value: { field: "value" } }],
sortBy: null,
isDerived: false,
havingConditions: [],
});
/** Fallback y-axis field for streams without a "value" column: count(_timestamp) */
export const DEFAULT_METRICS_Y_FIELD_COUNT = () => ({
label: "_timestamp",
alias: "y_axis_1",
column: "_timestamp",
color: "#5960b2",
type: "build",
functionName: "count",
args: [{ type: "field", value: { field: "_timestamp" } }],
sortBy: null,
isDerived: false,
havingConditions: [],
});