feat: dashboard stop unnecessary api call for config change (#7713)

### **PR Type**
Enhancement, Bug fix


___

### **Description**
Add config-change detection to avoid API calls
Debounce and auto-apply non-API config updates
Fix metric panel field resets and builder UI
Refactor panel data conversion into reusable helper


___

### Diagram Walkthrough


```mermaid
flowchart LR
  A["Dashboard/Logs/Metrics configs"] -- "watch changes" --> B["checkConfigChangeApiCallOrNot"]
  B -- "needs API" --> C["loadData() / keep state"]
  B -- "no API" --> D["apply local config + resize"]
  E["PanelSchemaRenderer"] -- "convertPanelDataCommon" --> F["panelData refresh"]
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>7
files</summary><table>
<tr>
<td><strong>PanelSchemaRenderer.vue</strong><dd><code>Refactor data
conversion and watch schema changes</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-e5376e7e77bf3aaf6590c2522d8c2dbffcc95a8006ae0d6dc0040a37825367d6">+70/-52</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>DashboardQueryBuilder.vue</strong><dd><code>Hide X-axis
controls for metric panels</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-a4f9abbeaad1156ab6ba5740d59ccb869d6a9172ca1c64b89cff78f3d7783409">+3/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>Index.vue</strong><dd><code>Debounce config updates; skip
API when unnecessary</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-0323c3da69b369843c5d72bf4df5b00ca6b5147192a213baf331659ecf92af62">+23/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>Index.vue</strong><dd><code>Debounced config watcher with
API necessity check</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-a6734928ea4dcb5244e90de5a16fe6f5cafa95ccf58430ac7b548a065daf3d61">+21/-1</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>AddPanel.vue</strong><dd><code>Outdated detection uses
API-need; debounce updates</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-c9632db1ad95af95aba10161bcbc7c5608318919f228637bdafae3f3a31267e8">+32/-5</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>usePanelDataLoader.ts</strong><dd><code>Separate schema/time
watchers; API-need gating</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-5a42e9493a7bcd722b88e61fd38883c4f61001bc9cafa399f65f8eafda3aace9">+22/-1</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>checkConfigChangeApiCall.ts</strong><dd><code>Add utility to
detect API-required config changes</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-5c85b8000a7da159a0fe523af41fa5db2cc5318b164e81a0755178f6df9e0b9d">+65/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Bug
fix</strong></td><td><details><summary>1 files</summary><table>
<tr>
<td><strong>useDashboardPanel.ts</strong><dd><code>Reset fields for
metric type on type switch</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7713/files#diff-fbba1700750053b9eaf38ddd30dab84531bc44131c795ebb713811e4930cd7cb">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___

---------

Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
This commit is contained in:
ktx-vaidehi
2025-08-19 18:34:26 +05:30
committed by GitHub
co-authored by ktx-abhay
parent 111a67b73d
commit ca99331c98
9 changed files with 308 additions and 74 deletions
@@ -616,6 +616,74 @@ export default defineComponent({
tableRendererRef.value = null;
parser = null;
});
const convertPanelDataCommon = async () => {
if (
!errorDetail?.value?.message &&
validatePanelData?.value?.length === 0
) {
try {
panelData.value = await convertPanelData(
panelSchema.value,
data.value,
store,
chartPanelRef,
hoveredSeriesState,
resultMetaData,
metadata.value,
chartPanelStyle.value,
annotations,
loading.value,
);
limitNumberOfSeriesWarningMessage.value =
panelData.value?.extras?.limitNumberOfSeriesWarningMessage ?? "";
errorDetail.value = {
message: "",
code: "",
};
} catch (error: any) {
console.error("error", error);
errorDetail.value = {
message: error?.message,
code: error?.code || "",
};
}
} else {
// if no data is available, then show the default data
// if there is an error config in the panel schema, then show the default data on error
// if no default data on error is set, then show the custom error message
if (
panelSchema.value?.error_config?.custom_error_handeling &&
panelSchema.value?.error_config?.default_data_on_error
) {
data.value = JSON.parse(
panelSchema.value?.error_config?.default_data_on_error,
);
errorDetail.value = {
message: "",
code: "",
};
}
}
};
// Watch for panel schema changes to re-convert panel data
watch(
panelSchema,
async () => {
// Re-convert panel data when schema changes (for non-whitelisted config changes)
if (
!errorDetail?.value?.message &&
validatePanelData?.value?.length === 0 &&
data.value?.length
) {
await convertPanelDataCommon();
}
},
{ deep: true },
);
watch(
[data, store?.state],
async () => {
@@ -648,58 +716,8 @@ export default defineComponent({
code: "",
};
// panelData.value = convertPanelData(panelSchema.value, data.value, store);
if (
!errorDetail?.value?.message &&
validatePanelData?.value?.length === 0
) {
try {
// passing chartpanelref to get width and height of DOM element
panelData.value = await convertPanelData(
panelSchema.value,
data.value,
store,
chartPanelRef,
hoveredSeriesState,
resultMetaData,
metadata.value,
chartPanelStyle.value,
annotations,
loading.value,
);
limitNumberOfSeriesWarningMessage.value =
panelData.value?.extras?.limitNumberOfSeriesWarningMessage ?? "";
errorDetail.value = {
message: "",
code: "",
};
} catch (error: any) {
console.error("error", error);
errorDetail.value = {
message: error?.message,
code: error?.code || "",
};
}
} else {
// if no data is available, then show the default data
// if there is an error config in the panel schema, then show the default data on error
// if no default data on error is set, then show the custom error message
if (
panelSchema.value?.error_config?.custom_error_handeling &&
panelSchema.value?.error_config?.default_data_on_error
) {
data.value = JSON.parse(
panelSchema.value?.error_config?.default_data_on_error,
);
errorDetail.value = {
message: "",
code: "",
};
}
}
// Use the common function to convert panel data
await convertPanelDataCommon();
},
{ deep: true },
);
@@ -27,6 +27,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
style="display: flex; flex-direction: row; width: 100%"
class="q-pl-md"
v-if="
dashboardPanelData.data.type != 'metric'
"
>
<div style="flex: 1">
<div style="display: flex; flex-direction: row">
@@ -45,6 +45,7 @@ import { convertOffsetToSeconds } from "@/utils/dashboard/convertDataIntoUnitVal
import useSearchWebSocket from "@/composables/useSearchWebSocket";
import { useAnnotations } from "./useAnnotations";
import useHttpStreamingSearch from "../useStreamingSearch";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
/**
* debounce time in milliseconds for panel data loader
@@ -1644,13 +1645,33 @@ export const usePanelDataLoader = (
watch(
// Watching for changes in panelSchema, selectedTimeObj and forceLoad
() => [panelSchema?.value, selectedTimeObj?.value, forceLoad?.value],
() => [selectedTimeObj?.value, forceLoad?.value],
async () => {
log("PanelSchema/Time Wather: called");
loadData(); // Loading the data
},
);
watch(
() => [panelSchema?.value],
async (newVal, oldVal) => {
const [newSchema] = newVal;
const [oldSchema] = oldVal;
const configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
oldSchema,
newSchema,
);
if (!configNeedsApiCall) {
return;
}
loadData();
},
{ deep: true },
);
/**
* Replaces the query with the corresponding variable values.
*
+45 -1
View File
@@ -1061,6 +1061,49 @@ const useDashboardPanelData = (pageKey: string = "dashboard") => {
dashboardPanelData.data.customChartContent =
getDefaultCustomChartText();
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].config.time_shift = [];
break;
case "metric":
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.x = [];
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.y.forEach((itemY: any) => {
if (itemY.aggregationFunction === null && !itemY.isDerived) {
itemY.aggregationFunction = "count";
}
});
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.z = [];
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].fields.breakdown = [];
// we have multiple queries for geomap, so if we are moving away, we need to reset
// the values of lat, lng and weight in all the queries
dashboardPanelData.data.queries?.forEach((query: any) => {
query.fields.latitude = null;
query.fields.longitude = null;
query.fields.weight = null;
query.fields.name = null;
query.fields.value_for_maps = null;
query.fields.source = null;
query.fields.target = null;
query.fields.value = null;
});
if (dashboardPanelData.data.queryType === "sql") {
dashboardPanelData.layout.currentQueryIndex = 0;
dashboardPanelData.data.queries =
dashboardPanelData.data.queries.slice(0, 1);
}
dashboardPanelData.data.htmlContent = "";
dashboardPanelData.data.markdownContent = "";
dashboardPanelData.data.customChartContent =
getDefaultCustomChartText();
dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].config.time_shift = [];
@@ -2035,7 +2078,8 @@ const useDashboardPanelData = (pageKey: string = "dashboard") => {
const { sqlParser }: any = useSqlParser.default();
parser = await sqlParser();
updateQueryValue();
// do not allow to modify custom query fields for logs page
updateQueryValue(pageKey == "logs" ? true : false);
};
/**
+21
View File
@@ -389,6 +389,7 @@ import {
getFieldsFromQuery,
} from "@/utils/query/sqlUtils";
import useNotifications from "@/composables/useNotifications";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
import SearchBar from "@/plugins/logs/SearchBar.vue";
import SearchHistory from "@/plugins/logs/SearchHistory.vue";
import SearchSchedulersList from "@/plugins/logs/SearchSchedulersList.vue";
@@ -1666,6 +1667,26 @@ export default defineComponent({
},
);
// Auto-apply config changes that don't require API calls (similar to dashboard)
const debouncedUpdateChartConfig = debounce((newVal) => {
if (searchObj.meta.logsVisualizeToggle === "visualize") {
let configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
visualizeChartData.value,
newVal,
);
if (!configNeedsApiCall) {
visualizeChartData.value = JSON.parse(JSON.stringify(newVal));
window.dispatchEvent(new Event("resize"));
}
}
}, 1000);
watch(() => dashboardPanelData.data, debouncedUpdateChartConfig, {
deep: true,
});
watch(
() => [
searchObj.data.datetime.type,
+29 -14
View File
@@ -404,6 +404,7 @@ import { isEqual } from "lodash-es";
import { onActivated } from "vue";
import useNotifications from "@/composables/useNotifications";
import CustomChartEditor from "@/components/dashboards/addPanel/CustomChartEditor.vue";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
const ConfigPanel = defineAsyncComponent(() => {
return import("@/components/dashboards/addPanel/ConfigPanel.vue");
@@ -466,6 +467,7 @@ export default defineComponent({
const { dashboardPanelData, resetAggregationFunction, validatePanel } =
useDashboardPanelData(dashboardPanelDataPageKey);
const metaData = ref(null);
const resultMetaData = ref(null);
const seriesData = ref([] as any[]);
const seriesDataUpdate = (data: any) => {
seriesData.value = data;
@@ -492,12 +494,17 @@ export default defineComponent({
);
const isOutDated = computed(() => {
//compare chartdata and dashboardpaneldata
// ignore histogram query comparison
return (
!is_ui_histogram.value &&
!isEqual(chartData.value, dashboardPanelData.data)
);
const configChanged = !isEqual(chartData.value, dashboardPanelData.data);
// skip outdate check if doesnt require to call api
let configNeedsApiCall = false;
if (configChanged) {
configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
chartData.value,
dashboardPanelData.data,
);
}
return configNeedsApiCall;
});
watch(isOutDated, () => {
@@ -569,6 +576,13 @@ export default defineComponent({
provide("hoveredSeriesState", hoveredSeriesState);
const addToDashboard = () => {
// only copy if is_ui_histogram is true
if (resultMetaData.value?.[0]?.converted_histogram_query && is_ui_histogram.value === true) {
dashboardPanelData.data.queries[0].query =
resultMetaData.value?.[0]?.converted_histogram_query;
}
const errors: any = [];
// will push errors in errors array
validatePanel(errors, true);
@@ -581,6 +595,7 @@ export default defineComponent({
);
return;
} else {
showAddToDashboardDialog.value = true;
}
};
@@ -767,15 +782,15 @@ export default defineComponent({
window.dispatchEvent(new Event("resize"));
};
const onResultMetadataUpdate = (resultMetaData: any) => {
const onResultMetadataUpdate = (resultMetaDataParam: any) => {
// Store the result metadata
resultMetaData.value = resultMetaDataParam;
// only copy if is_ui_histogram is true
if (
resultMetaData?.[0]?.converted_histogram_query &&
is_ui_histogram.value === true
) {
dashboardPanelData.data.queries[0].query =
resultMetaData?.[0]?.converted_histogram_query;
}
// if (resultMetaDataParam?.[0]?.converted_histogram_query && is_ui_histogram.value === true) {
// dashboardPanelData.data.queries[0].query =
// resultMetaDataParam?.[0]?.converted_histogram_query;
// }
};
return {
+21 -1
View File
@@ -522,12 +522,13 @@ import DashboardErrorsComponent from "@/components/dashboards/addPanel/Dashboard
import VariablesValueSelector from "@/components/dashboards/VariablesValueSelector.vue";
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
import RelativeTime from "@/components/common/RelativeTime.vue";
import { isEqual } from "lodash-es";
import { isEqual, debounce } from "lodash-es";
import { provide } from "vue";
import useNotifications from "@/composables/useNotifications";
import config from "@/aws-exports";
import useCancelQuery from "@/composables/dashboard/useCancelQuery";
import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
import CustomChartEditor from "@/components/dashboards/addPanel/CustomChartEditor.vue";
const AddToDashboard = defineAsyncComponent(() => {
return import("./../metrics/AddToDashboard.vue");
@@ -779,6 +780,25 @@ export default defineComponent({
{ deep: true },
);
// Auto-apply config changes that don't require API calls (similar to dashboard)
const debouncedUpdateChartConfig = debounce((newVal, oldVal) => {
if (!isEqual(chartData.value, newVal)) {
const configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
chartData.value,
newVal,
);
if (!configNeedsApiCall) {
chartData.value = JSON.parse(JSON.stringify(newVal));
window.dispatchEvent(new Event("resize"));
}
}
}, 1000);
watch(() => dashboardPanelData.data, debouncedUpdateChartConfig, {
deep: true,
});
//validate the data
const isValid = (onlyChart = false, isFieldsValidationRequired = true) => {
const errors = errorData.errors;
@@ -0,0 +1,65 @@
// Whitelist of config keys that require API calls when changed
const API_REQUIRING_CHANGES = new Set(["queries", "variables"]);
/**
* Flattens an object's keys with dot notation
*/
const flattenObject = (obj: any, prefix = ""): Record<string, any> => {
if (obj === null || obj === undefined) {
return {};
}
return Object.keys(obj).reduce((acc: Record<string, any>, k: string) => {
const pre = prefix.length ? prefix + "." : "";
if (
typeof obj[k] === "object" &&
obj[k] !== null &&
!Array.isArray(obj[k])
) {
Object.assign(acc, flattenObject(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
}
return acc;
}, {});
};
/**
* Gets changed keys between two objects
*/
const getChangedKeys = (oldObj: any, newObj: any): string[] => {
const flatOld = flattenObject(oldObj);
const flatNew = flattenObject(newObj);
const changedKeys: string[] = [];
// Check all keys in both objects
const allKeys = Array.from(
new Set([...Object.keys(flatOld), ...Object.keys(flatNew)]),
);
for (const key of allKeys) {
if (JSON.stringify(flatOld[key]) !== JSON.stringify(flatNew[key])) {
changedKeys.push(key);
}
}
return changedKeys;
};
/**
* Checks if the changes between old and new config require an API call
*/
export const checkIfConfigChangeRequiredApiCallOrNot = (
oldConfig: any,
newConfig: any,
): boolean => {
const changedKeys = getChangedKeys(oldConfig, newConfig);
const configNeedsApiCall = changedKeys.some((key) =>
Array.from(API_REQUIRING_CHANGES).some((apiKey) => key.startsWith(apiKey)),
);
if (configNeedsApiCall) {
return true;
}
return false;
};
+32 -5
View File
@@ -510,13 +510,14 @@ import VariablesValueSelector from "../../../components/dashboards/VariablesValu
import PanelSchemaRenderer from "../../../components/dashboards/PanelSchemaRenderer.vue";
import RelativeTime from "@/components/common/RelativeTime.vue";
import { useLoading } from "@/composables/useLoading";
import { isEqual } from "lodash-es";
import { debounce, isEqual } from "lodash-es";
import { provide } from "vue";
import useNotifications from "@/composables/useNotifications";
import config from "@/aws-exports";
import useCancelQuery from "@/composables/dashboard/useCancelQuery";
import useAiChat from "@/composables/useAiChat";
import useStreams from "@/composables/useStreams";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
const ConfigPanel = defineAsyncComponent(() => {
return import("../../../components/dashboards/addPanel/ConfigPanel.vue");
@@ -899,11 +900,19 @@ export default defineComponent({
const normalizedCurrent = normalizeVariables(variablesData);
const normalizedRefreshed = normalizeVariables(updatedVariablesData);
const variablesChanged = !isEqual(normalizedCurrent, normalizedRefreshed);
return (
!isEqual(chartData.value, dashboardPanelData.data) ||
!isEqual(normalizedCurrent, normalizedRefreshed)
);
const configChanged = !isEqual(chartData.value, dashboardPanelData.data);
let configNeedsApiCall = false;
if (configChanged) {
configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
chartData.value,
dashboardPanelData.data,
);
}
return configNeedsApiCall || variablesChanged;
});
watch(isOutDated, () => {
@@ -1561,6 +1570,24 @@ export default defineComponent({
return { width: `${contentWidth}px` };
});
const debouncedUpdateChartConfig = debounce((newVal, oldVal) => {
if (!isEqual(chartData.value, newVal)) {
const configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot(
chartData.value,
newVal,
);
if (!configNeedsApiCall) {
chartData.value = JSON.parse(JSON.stringify(newVal));
window.dispatchEvent(new Event("resize"));
}
}
}, 1000);
watch(() => dashboardPanelData.data, debouncedUpdateChartConfig, {
deep: true,
});
// [START] ai chat
// [START] O2 AI Context Handler