Skip to content

Commit 99953ee

Browse files
authored
Fix stale project issue in query editor (#191)
* fix stale project issue in query editor * additional fix * fix for project selection issue
1 parent 19b5716 commit 99953ee

3 files changed

Lines changed: 201 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
# Changelog
2+
## 1.6.4 (2026-05-06)
3+
* Fix stale project ID persisting in query editor when switching between datasources
4+
* Update OpenTelemetry SDK and other dependencies
5+
* **Recommended Grafana version: 11.2.0 or later.** Earlier versions have an upstream `<Select>` rendering issue ([#89530](https://github.com/grafana/grafana/issues/89530), fixed in 11.2.0) that can cause the project picker to visually display stale options after switching datasources in Explore, even though the underlying query state is correct.
6+
27
## 1.6.3 (2026-04-05)
38
* Add Project List Filter to restrict which projects appear in dropdowns using regex patterns
49
* Add Log Bucket Filter with include/exclude support — prefix patterns with `!` to exclude matching buckets (e.g., `!.*/_Default` to hide Default buckets)
510
* Fix race condition in variable query where default project could be unresolved before bucket/view queries run
11+
* Fix for [CVE-2026-33671](https://nvd.nist.gov/vuln/detail/CVE-2026-33671) (picomatch ReDoS — bumped via yarn `resolutions` override)
612

713
## 1.6.2 (2026-03-18)
814
* Fix project dropdown search failing with "contains global restriction" error

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "googlecloud-logging-datasource",
3-
"version": "1.6.3",
3+
"version": "1.6.4",
44
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Logging logs in Grafana.",
55
"scripts": {
66
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",

src/QueryEditor.tsx

Lines changed: 194 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17-
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
17+
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
1818
import { QueryEditorProps, SelectableValue } from '@grafana/data';
19-
import { Alert, InlineField, InlineFieldRow, AsyncSelect, LinkButton, Select, TextArea, Tooltip } from '@grafana/ui';
19+
import { Alert, InlineField, InlineFieldRow, LinkButton, Select, TextArea, Tooltip } from '@grafana/ui';
2020
import { DataSource } from './datasource';
2121
import { CloudLoggingOptions, defaultQuery, Query } from './types';
2222

@@ -34,33 +34,107 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
3434
}
3535
};
3636

37-
// Apply defaults if needed, and validate against project list filter
38-
if (!query.projectId) {
39-
datasource.getDefaultProject().then(r => {
40-
if (datasource.filterProjects([r]).length > 0) {
41-
query.projectId = r;
37+
// Keep a ref to the latest query so async callbacks avoid stale closures
38+
const queryRef = useRef(query);
39+
queryRef.current = query;
40+
41+
// Compute normalized queryText as a derived value (never mutate the prop directly)
42+
const effectiveQueryText = query.query ?? query.queryText ?? defaultQuery.queryText;
43+
44+
// Initialization effect: validates the carried-over project against the
45+
// datasource's actual filtered project list. The list is the source of truth;
46+
// the regex filter alone is insufficient because "no filter" passes everything,
47+
// so a stale projectId from a different datasource would slip through.
48+
useEffect(() => {
49+
let cancelled = false;
50+
51+
const computeTextUpdates = (q: Query): Partial<Query> => {
52+
const norm: Partial<Query> = {};
53+
if (q.query) {
54+
norm.queryText = q.query;
55+
norm.query = undefined;
56+
}
57+
if ((norm.queryText ?? q.queryText) == null) {
58+
norm.queryText = defaultQuery.queryText;
59+
}
60+
return norm;
61+
};
62+
63+
(async () => {
64+
const latestQuery = queryRef.current;
65+
const textUpdates = computeTextUpdates(latestQuery);
66+
const currentProjectId = latestQuery.projectId;
67+
68+
if (currentProjectId && currentProjectId.startsWith('$')) {
69+
if (!cancelled && Object.keys(textUpdates).length > 0) {
70+
onChange({ ...latestQuery, ...textUpdates });
71+
}
72+
return;
4273
}
43-
});
44-
} else if (!query.projectId.startsWith('$') && datasource.filterProjects([query.projectId]).length === 0) {
45-
// Previously selected project no longer passes the filter — clear it
46-
query.projectId = '';
47-
}
4874

49-
if (query.bucketId && !query.bucketId.startsWith('$') && datasource.filterBuckets([query.bucketId]).length === 0) {
50-
// Previously selected bucket no longer passes the filter — clear it
51-
query.bucketId = '';
52-
}
75+
const defaultProject = await datasource.getDefaultProject();
76+
if (cancelled) { return; }
5377

54-
// Check query field from query params to support default way of propagating query from other parts of grafana.
55-
if (query.query) {
56-
query.queryText = query.query;
57-
query.query = undefined;
58-
}
78+
let cachedList: string[] | null = null;
79+
let isValid = false;
80+
81+
if (!currentProjectId) {
82+
isValid = false;
83+
} else if (datasource.filterProjects([currentProjectId]).length === 0) {
84+
isValid = false;
85+
} else {
86+
try {
87+
cachedList = await datasource.getFilteredProjects();
88+
if (cancelled) { return; }
89+
isValid = cachedList.includes(currentProjectId);
90+
} catch {
91+
// Transient API error — assume valid so we don't reset a saved
92+
// project on a flaky network. The eventual query will surface
93+
// any real access error.
94+
isValid = true;
95+
}
96+
}
97+
98+
if (isValid) {
99+
const updates: Partial<Query> = { ...textUpdates };
100+
if (latestQuery.bucketId && !latestQuery.bucketId.startsWith('$') &&
101+
datasource.filterBuckets([latestQuery.bucketId]).length === 0) {
102+
updates.bucketId = '';
103+
}
104+
if (Object.keys(updates).length > 0) {
105+
onChange({ ...latestQuery, ...updates });
106+
}
107+
return;
108+
}
109+
110+
// Pick a replacement project.
111+
let newProjectId = '';
112+
if (defaultProject && datasource.filterProjects([defaultProject]).length > 0) {
113+
newProjectId = defaultProject;
114+
} else {
115+
let list = cachedList;
116+
if (list === null) {
117+
try {
118+
list = await datasource.getFilteredProjects();
119+
if (cancelled) { return; }
120+
} catch {
121+
list = null;
122+
}
123+
}
124+
if (list && list.length > 0) {
125+
newProjectId = list[0];
126+
}
127+
}
59128

60-
if (query.queryText == null) {
61-
query.queryText = defaultQuery.queryText;
62-
}
129+
if (cancelled) { return; }
130+
onChange({ ...latestQuery, ...textUpdates, projectId: newProjectId, bucketId: '', viewId: '' });
131+
if (newProjectId) {
132+
onRunQuery();
133+
}
134+
})();
63135

136+
return () => { cancelled = true; };
137+
}, [datasource]);
64138

65139
const [fetchError, setFetchError] = useState<string | undefined>();
66140

@@ -82,41 +156,89 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
82156
return text;
83157
};
84158

85-
const loadProjects = useCallback((inputValue: string): Promise<Array<SelectableValue<string>>> => {
86-
return datasource.getFilteredProjects(inputValue || undefined).then(res => {
87-
setFetchError(undefined);
88-
return res.map(project => ({
89-
label: project,
90-
value: project,
91-
}));
92-
}).catch(err => {
93-
setFetchError(sanitizeFetchError(err));
94-
return [];
95-
});
159+
// Project list state, tagged with the DS uid it was loaded for. The uid tag
160+
// lets the picker's `options` derivation (`projectsForCurrentDs` below)
161+
// return [] whenever the loaded data doesn't belong to the current DS —
162+
// preventing react-select from auto-selecting/displaying a stale option
163+
// (e.g. the only entry in test1's list when we've switched to test2 but
164+
// test2's load hasn't resolved yet).
165+
const [projectsState, setProjectsState] = useState<{
166+
uid: string | null;
167+
list: Array<SelectableValue<string>>;
168+
}>({ uid: null, list: [] });
169+
const [projectsLoading, setProjectsLoading] = useState(false);
170+
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
171+
const projectsForCurrentDs = projectsState.uid === datasource.uid
172+
? projectsState.list
173+
: [];
174+
175+
useEffect(() => {
176+
let cancelled = false;
177+
const loadingForUid = datasource.uid;
178+
setProjectsLoading(true);
179+
datasource.getFilteredProjects()
180+
.then(res => {
181+
if (cancelled) { return; }
182+
setProjectsState({
183+
uid: loadingForUid,
184+
list: res.map(project => ({ label: project, value: project })),
185+
});
186+
setFetchError(undefined);
187+
})
188+
.catch(err => {
189+
if (cancelled) { return; }
190+
setProjectsState({ uid: loadingForUid, list: [] });
191+
setFetchError(sanitizeFetchError(err));
192+
})
193+
.finally(() => {
194+
if (!cancelled) { setProjectsLoading(false); }
195+
});
196+
return () => {
197+
cancelled = true;
198+
if (searchTimer.current) { clearTimeout(searchTimer.current); }
199+
};
200+
}, [datasource]);
201+
202+
const onProjectSearchChange = useCallback((value: string) => {
203+
if (searchTimer.current) { clearTimeout(searchTimer.current); }
204+
const searchDsUid = datasource.uid;
205+
setProjectsLoading(true);
206+
searchTimer.current = setTimeout(() => {
207+
datasource.getFilteredProjects(value || undefined)
208+
.then(res => {
209+
// Discard the response if the datasource changed during the debounce
210+
// or in-flight fetch. Without this, a late search response from the
211+
// OLD datasource could clobber a freshly-loaded list from the new one.
212+
if (searchDsUid !== datasource.uid) { return; }
213+
setProjectsState({
214+
uid: searchDsUid,
215+
list: res.map(project => ({ label: project, value: project })),
216+
});
217+
setFetchError(undefined);
218+
})
219+
.catch(err => {
220+
if (searchDsUid !== datasource.uid) { return; }
221+
setFetchError(sanitizeFetchError(err));
222+
})
223+
.finally(() => {
224+
if (searchDsUid === datasource.uid) { setProjectsLoading(false); }
225+
});
226+
}, 300);
96227
}, [datasource]);
97228

98229
const [buckets, setBuckets] = useState<Array<SelectableValue<string>>>();
99230
useEffect(() => {
100-
if (!query.projectId) {
101-
datasource.getDefaultProject().then(r => {
102-
query.projectId = r;
103-
datasource.getFilteredBuckets(query.projectId).then(res => {
104-
setBuckets(res.map(bucket => ({
105-
label: bucket,
106-
value: bucket,
107-
})));
108-
}).catch(err => setFetchError(sanitizeFetchError(err)));
109-
});
110-
} else if (!query.projectId.startsWith('$')) {
111-
datasource.getFilteredBuckets(query.projectId).then(res => {
112-
setBuckets(res.map(bucket => ({
113-
label: bucket,
114-
value: bucket,
115-
})));
116-
setFetchError(undefined);
117-
}).catch(err => setFetchError(sanitizeFetchError(err)));
118-
}
119-
}, [datasource, query.projectId]);
231+
if (!query.projectId || query.projectId.startsWith('$')) { return; }
232+
if (projectsLoading) { return; }
233+
if (projectsForCurrentDs.length > 0 && !projectsForCurrentDs.some(p => p.value === query.projectId)) { return; }
234+
datasource.getFilteredBuckets(query.projectId).then(res => {
235+
setBuckets(res.map(bucket => ({
236+
label: bucket,
237+
value: bucket,
238+
})));
239+
setFetchError(undefined);
240+
}).catch(err => setFetchError(sanitizeFetchError(err)));
241+
}, [datasource, query.projectId, projectsLoading, projectsForCurrentDs]);
120242

121243
const [views, setViews] = useState<Array<SelectableValue<string>>>();
122244
useEffect(() => {
@@ -141,7 +263,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
141263
* Keep an up-to-date URI that links to the equivalent query in the GCP console
142264
*/
143265
const gcpConsoleURI = useMemo<string | undefined>(() => {
144-
if (!query.queryText) {
266+
if (!effectiveQueryText) {
145267
return undefined;
146268
}
147269

@@ -169,7 +291,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
169291
storageScope = `;storageScope=${scopePath.replace(/\//g, '%2F')}`;
170292
}
171293

172-
const encodedText = encodeURIComponent(`${query.queryText}`).replace(/[!'()*]/g, function (c) {
294+
const encodedText = encodeURIComponent(`${effectiveQueryText}`).replace(/[!'()*]/g, function (c) {
173295
if (c === '(' || c === ')') {
174296
return '%25' + c.charCodeAt(0).toString(16);
175297
}
@@ -197,7 +319,8 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
197319
<>
198320
<InlineFieldRow>
199321
<InlineField label='Project ID'>
200-
<AsyncSelect
322+
<Select
323+
key={datasource.uid}
201324
width={30}
202325
allowCustomValue
203326
formatCreateLabel={(v) => `Use project: ${v}`}
@@ -207,8 +330,18 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
207330
bucketId: query.bucketId && query.bucketId.startsWith('$') ? query.bucketId : "",
208331
viewId: query.viewId && query.viewId.startsWith('$') ? query.viewId : "",
209332
})}
210-
loadOptions={loadProjects}
211-
defaultOptions
333+
options={projectsForCurrentDs}
334+
isLoading={projectsLoading}
335+
onInputChange={onProjectSearchChange}
336+
filterOption={() => true}
337+
// Pass value as a self-contained literal — do NOT derive it by
338+
// looking up query.projectId in the options list. react-select's
339+
// internal `cleanValue(value, options)` reconciliation lags under
340+
// React 18 concurrent rendering, which would make the picker
341+
// visually stuck on stale data on DS switch (only "fixed" by
342+
// opening DevTools, which forces a paint flush).
343+
// The init effect keeps query.projectId valid; the picker just
344+
// displays whatever it says. See react-select#4936.
212345
value={query.projectId ? { label: query.projectId, value: query.projectId } : undefined}
213346
placeholder="Select Project"
214347
inputId={`${query.refId}-project`}
@@ -252,7 +385,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
252385
<TextArea
253386
name="Query"
254387
className="slate-query-field"
255-
value={query.queryText}
388+
value={effectiveQueryText}
256389
rows={10}
257390
placeholder="Enter a Cloud Logging query (Run with Shift+Enter)"
258391
onBlur={onRunQuery}

0 commit comments

Comments
 (0)