@agent-native/core-corpus 0.209.2 → 0.209.3-nightly-20261016183441
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/analytics/actions/update-dashboard.ts +9 -0
- package/corpus/templates/analytics/app/components/auth/SqlQuerySnapshotSessionBridge.tsx +25 -0
- package/corpus/templates/analytics/app/components/dashboard/PanelFreshnessClock.tsx +140 -0
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +104 -48
- package/corpus/templates/analytics/app/components/dashboard/panel-freshness.ts +171 -0
- package/corpus/templates/analytics/app/global.css +59 -0
- package/corpus/templates/analytics/app/i18n/en-US.ts +8 -0
- package/corpus/templates/analytics/app/lib/cost-tracker.ts +122 -16
- package/corpus/templates/analytics/app/lib/query-metrics.ts +50 -13
- package/corpus/templates/analytics/app/lib/sql-query-cache.ts +697 -0
- package/corpus/templates/analytics/app/lib/sql-query-freshness.ts +85 -0
- package/corpus/templates/analytics/app/lib/sql-query-snapshot-session.ts +145 -0
- package/corpus/templates/analytics/app/lib/sql-query.ts +366 -36
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +73 -18
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +45 -12
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +5 -0
- package/corpus/templates/analytics/app/root.tsx +2 -0
- package/corpus/templates/analytics/changelog/2026-10-14-dashboard-charts-now-reappear-instantly-after-a-reload-from-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-14-dashboard-panels-show-fetch-time-in-a-clock-and-label-only-st.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-15-dashboard-query-costs-and-refresh-confirmations-reflect-actu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-15-dashboard-results-disappear-when-access-is-withdrawn-and-exp.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-dashboard-freshness-stays-in-the-title-row-with-compact-upda.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-dashboard-update-status-remains-visible-for-sources-without-.md +6 -0
- package/corpus/templates/analytics/server/lib/bigquery.ts +35 -4
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +2 -3
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +9 -6
- package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +2 -2
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +4 -2
- package/corpus/templates/analytics/shared/sql-query-contract.ts +49 -0
- package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +24 -0
- package/corpus/templates/design/app/components/design/types.ts +1 -0
- package/corpus/templates/design/app/lib/design-import.ts +35 -0
- package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +11 -0
- package/corpus/templates/design/app/pages/design-editor/command-types.ts +2 -0
- package/corpus/templates/design/app/pages/design-editor/commands/copy-selection.ts +3 -0
- package/corpus/templates/design/app/pages/design-editor/commands/get-selected-layer-snapshots.ts +19 -0
- package/corpus/templates/design/app/pages/design-editor/commands/paste-selection.ts +118 -20
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-layer-actions.ts +15 -3
- package/corpus/templates/design/app/pages/design-editor/relative-position-offset.ts +109 -0
- package/corpus/templates/design/changelog/2026-10-15-dragged-layers-use-the-final-release-position.md +6 -0
- package/corpus/templates/design/changelog/2026-10-15-repeated-pastes-preserve-relative-layer-offsets.md +6 -0
- package/corpus/templates/design/changelog/2026-10-15-text-wrapping-hug-widths.md +6 -0
- package/corpus/templates/design/shared/code-layer.ts +2 -1
- package/corpus/templates/design/shared/relative-position-containing-block.ts +40 -0
- package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +137 -118
- package/corpus/templates/slides/changelog/2026-10-16-search-and-preview-reference-decks-before-choosing-one.md +6 -0
- package/package.json +1 -1
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { callAction, useSession } from "@agent-native/core/client/hooks";
|
|
2
2
|
import { DASHBOARD_REPORT_ACTION_TIMEOUT_MS } from "@shared/dashboard-report-timeouts";
|
|
3
|
+
import {
|
|
4
|
+
isConfirmedPreQueryFailure,
|
|
5
|
+
isSqlQueryAccessDenied,
|
|
6
|
+
SqlQueryRefusalError,
|
|
7
|
+
supportsSqlQueryCacheBypass,
|
|
8
|
+
type SqlQueryExecutionMetadata,
|
|
9
|
+
} from "@shared/sql-query-contract";
|
|
3
10
|
import {
|
|
4
11
|
MAX_CONCURRENT_FIRST_PARTY_SQL_QUERIES,
|
|
5
12
|
MAX_CONCURRENT_SQL_QUERIES,
|
|
@@ -9,15 +16,27 @@ import { useEffect, useRef } from "react";
|
|
|
9
16
|
|
|
10
17
|
import type { DataSourceType } from "@/pages/adhoc/sql-dashboard/types";
|
|
11
18
|
|
|
12
|
-
import {
|
|
19
|
+
import { recordQueryCost } from "./cost-tracker";
|
|
13
20
|
import {
|
|
14
21
|
DASHBOARD_SESSION_LOADING_SCOPE,
|
|
15
22
|
dashboardCacheScope,
|
|
16
23
|
} from "./prefetch-keys";
|
|
24
|
+
import {
|
|
25
|
+
describeSqlQueryFreshness,
|
|
26
|
+
type SqlQueryFreshness,
|
|
27
|
+
} from "./sql-query-freshness";
|
|
28
|
+
import {
|
|
29
|
+
readSqlQuerySnapshotForSession,
|
|
30
|
+
removeSqlQuerySnapshotForSession,
|
|
31
|
+
writeSqlQuerySnapshotForSession,
|
|
32
|
+
} from "./sql-query-snapshot-session";
|
|
17
33
|
|
|
18
34
|
export { DASHBOARD_REPORT_ACTION_TIMEOUT_MS };
|
|
19
35
|
|
|
20
|
-
export interface SqlQueryResult
|
|
36
|
+
export interface SqlQueryResult extends Omit<
|
|
37
|
+
SqlQueryExecutionMetadata,
|
|
38
|
+
"bytesProcessed"
|
|
39
|
+
> {
|
|
21
40
|
rows: Record<string, unknown>[];
|
|
22
41
|
error?: string;
|
|
23
42
|
schema?: { name: string; type: string }[];
|
|
@@ -139,54 +158,180 @@ type DashboardPanelQueryResponse = SqlQueryResult & {
|
|
|
139
158
|
message?: string;
|
|
140
159
|
};
|
|
141
160
|
|
|
161
|
+
type SqlQueryTimingStatus = "success" | "error" | "cancelled" | "timeout";
|
|
162
|
+
|
|
163
|
+
/** Sources whose queries scan BigQuery, so a lost request may still bill. */
|
|
164
|
+
function scansBigQuery(source: DataSourceType): boolean {
|
|
165
|
+
return source === "bigquery" || source === "first-party";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One line per query with durations and outcome only. No SQL, rows, account
|
|
170
|
+
* or credentials, so it is safe to leave on in production.
|
|
171
|
+
*/
|
|
172
|
+
function logSqlQueryTiming(timing: {
|
|
173
|
+
status: SqlQueryTimingStatus;
|
|
174
|
+
source: DataSourceType;
|
|
175
|
+
cacheType: "none" | "app" | "provider" | "unknown";
|
|
176
|
+
forceRefresh: boolean;
|
|
177
|
+
queueMs: number;
|
|
178
|
+
requestMs: number;
|
|
179
|
+
totalMs: number;
|
|
180
|
+
}): void {
|
|
181
|
+
console.info("[sql-query] timing", {
|
|
182
|
+
...timing,
|
|
183
|
+
queueMs: Math.round(timing.queueMs),
|
|
184
|
+
requestMs: Math.round(timing.requestMs),
|
|
185
|
+
totalMs: Math.round(timing.totalMs),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function responseMetadata(data: DashboardPanelQueryResponse): {
|
|
190
|
+
cached?: boolean;
|
|
191
|
+
providerCacheHit?: boolean;
|
|
192
|
+
truncated?: boolean;
|
|
193
|
+
fetchedAt?: string;
|
|
194
|
+
} {
|
|
195
|
+
const metadata: ReturnType<typeof responseMetadata> = {};
|
|
196
|
+
if (typeof data.cached === "boolean") metadata.cached = data.cached;
|
|
197
|
+
if (typeof data.providerCacheHit === "boolean") {
|
|
198
|
+
metadata.providerCacheHit = data.providerCacheHit;
|
|
199
|
+
}
|
|
200
|
+
if (typeof data.truncated === "boolean") metadata.truncated = data.truncated;
|
|
201
|
+
if (data.fetchedAt !== undefined) {
|
|
202
|
+
if (
|
|
203
|
+
typeof data.fetchedAt === "string" &&
|
|
204
|
+
Number.isFinite(Date.parse(data.fetchedAt))
|
|
205
|
+
) {
|
|
206
|
+
metadata.fetchedAt = data.fetchedAt;
|
|
207
|
+
} else {
|
|
208
|
+
// Left absent so the panel says the time is unknown rather than guessing.
|
|
209
|
+
console.warn("[sql-query] ignored an unreadable fetchedAt");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return metadata;
|
|
213
|
+
}
|
|
214
|
+
|
|
142
215
|
export async function executeSqlQuery(
|
|
143
216
|
sql: string,
|
|
144
217
|
source: DataSourceType,
|
|
145
218
|
signal?: AbortSignal,
|
|
146
219
|
options?: { reportScreenshot?: boolean; forceRefresh?: boolean },
|
|
147
220
|
): Promise<SqlQueryResult> {
|
|
221
|
+
const startedAt = performance.now();
|
|
222
|
+
const forceRefresh = Boolean(
|
|
223
|
+
options?.forceRefresh && supportsSqlQueryCacheBypass(source),
|
|
224
|
+
);
|
|
148
225
|
const deadline = createDeadlineSignal(
|
|
149
226
|
signal,
|
|
150
227
|
DASHBOARD_REPORT_ACTION_TIMEOUT_MS,
|
|
151
228
|
);
|
|
152
229
|
let release: (() => void) | undefined;
|
|
153
230
|
let data: DashboardPanelQueryResponse;
|
|
231
|
+
let dispatchedAt: number | undefined;
|
|
232
|
+
let queueMs = 0;
|
|
233
|
+
let requestMs = 0;
|
|
234
|
+
let status: SqlQueryTimingStatus = "success";
|
|
235
|
+
let cacheType: "none" | "app" | "provider" | "unknown" = "unknown";
|
|
154
236
|
try {
|
|
155
237
|
release = await acquireSqlQuerySlot(source, deadline.signal);
|
|
238
|
+
dispatchedAt = performance.now();
|
|
239
|
+
queueMs = dispatchedAt - startedAt;
|
|
156
240
|
data = await callAction<DashboardPanelQueryResponse>(
|
|
157
241
|
"query-dashboard-panel",
|
|
158
242
|
{
|
|
159
243
|
query: sql,
|
|
160
244
|
source,
|
|
161
|
-
...(
|
|
162
|
-
? { forceRefresh: true }
|
|
163
|
-
: {}),
|
|
245
|
+
...(forceRefresh ? { forceRefresh: true } : {}),
|
|
164
246
|
},
|
|
165
247
|
{
|
|
166
248
|
signal: deadline.signal,
|
|
167
249
|
timeoutMs: DASHBOARD_REPORT_ACTION_TIMEOUT_MS,
|
|
168
250
|
},
|
|
169
251
|
);
|
|
252
|
+
requestMs = performance.now() - dispatchedAt;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const now = performance.now();
|
|
255
|
+
if (dispatchedAt === undefined) queueMs = now - startedAt;
|
|
256
|
+
else requestMs = now - dispatchedAt;
|
|
257
|
+
status = signal?.aborted
|
|
258
|
+
? "cancelled"
|
|
259
|
+
: deadline.signal.aborted
|
|
260
|
+
? "timeout"
|
|
261
|
+
: "error";
|
|
262
|
+
// A query still waiting for a slot never reached the warehouse. One that
|
|
263
|
+
// was sent may have scanned before it failed, so its cost is unknown.
|
|
264
|
+
if (
|
|
265
|
+
dispatchedAt !== undefined &&
|
|
266
|
+
scansBigQuery(source) &&
|
|
267
|
+
!isConfirmedPreQueryFailure(error)
|
|
268
|
+
) {
|
|
269
|
+
recordQueryCost({
|
|
270
|
+
status: status === "cancelled" ? "cancelled" : "error",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
logSqlQueryTiming({
|
|
274
|
+
status,
|
|
275
|
+
source,
|
|
276
|
+
cacheType,
|
|
277
|
+
forceRefresh,
|
|
278
|
+
queueMs,
|
|
279
|
+
requestMs,
|
|
280
|
+
totalMs: now - startedAt,
|
|
281
|
+
});
|
|
282
|
+
throw error;
|
|
170
283
|
} finally {
|
|
171
284
|
release?.();
|
|
172
285
|
deadline.cleanup();
|
|
173
286
|
}
|
|
174
287
|
|
|
175
288
|
if (typeof data?.error === "string") {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
289
|
+
// A refusal in the body (missing key, unsupported backend) ran no scan.
|
|
290
|
+
logSqlQueryTiming({
|
|
291
|
+
status: "error",
|
|
292
|
+
source,
|
|
293
|
+
cacheType,
|
|
294
|
+
forceRefresh,
|
|
295
|
+
queueMs,
|
|
296
|
+
requestMs,
|
|
297
|
+
totalMs: performance.now() - startedAt,
|
|
298
|
+
});
|
|
299
|
+
throw new SqlQueryRefusalError({
|
|
300
|
+
error: data.error,
|
|
301
|
+
message: data.message,
|
|
302
|
+
});
|
|
181
303
|
}
|
|
182
304
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
305
|
+
const metadata = responseMetadata(data);
|
|
306
|
+
recordQueryCost({
|
|
307
|
+
status: "success",
|
|
308
|
+
cached: metadata.cached,
|
|
309
|
+
providerCacheHit: metadata.providerCacheHit,
|
|
310
|
+
bytesProcessed: data.bytesProcessed,
|
|
311
|
+
});
|
|
312
|
+
// "none" is a claim that both layers missed; a response that does not say
|
|
313
|
+
// so for each layer is unknown, never a miss.
|
|
314
|
+
cacheType = metadata.cached
|
|
315
|
+
? "app"
|
|
316
|
+
: metadata.providerCacheHit
|
|
317
|
+
? "provider"
|
|
318
|
+
: metadata.cached === false && metadata.providerCacheHit === false
|
|
319
|
+
? "none"
|
|
320
|
+
: "unknown";
|
|
321
|
+
logSqlQueryTiming({
|
|
322
|
+
status,
|
|
323
|
+
source,
|
|
324
|
+
cacheType,
|
|
325
|
+
forceRefresh,
|
|
326
|
+
queueMs,
|
|
327
|
+
requestMs,
|
|
328
|
+
totalMs: performance.now() - startedAt,
|
|
329
|
+
});
|
|
186
330
|
|
|
187
331
|
return {
|
|
188
332
|
rows: data.rows ?? [],
|
|
189
333
|
schema: data.schema,
|
|
334
|
+
...metadata,
|
|
190
335
|
};
|
|
191
336
|
}
|
|
192
337
|
|
|
@@ -223,6 +368,13 @@ export function useSqlQuery(
|
|
|
223
368
|
* explicit `refreshToken` refresh does. Ignored when `staleTime` is set.
|
|
224
369
|
*/
|
|
225
370
|
reuseCompletedOnEnable?: boolean;
|
|
371
|
+
/**
|
|
372
|
+
* Saves each successful result in this tab's session storage and, after a
|
|
373
|
+
* reload, shows the saved copy for this exact account, SQL and source while
|
|
374
|
+
* the query's one normal fetch runs. The copy keeps its original fetch
|
|
375
|
+
* time and is marked `restored` in `freshness`; it is never fresh data.
|
|
376
|
+
*/
|
|
377
|
+
snapshots?: boolean;
|
|
226
378
|
},
|
|
227
379
|
) {
|
|
228
380
|
// The signed-in user and org are part of the cache key, so one account's
|
|
@@ -244,30 +396,124 @@ export function useSqlQuery(
|
|
|
244
396
|
accountScope: string;
|
|
245
397
|
retainScope: string;
|
|
246
398
|
} | null>(null);
|
|
247
|
-
const successfulRefreshToken = useRef(0);
|
|
248
399
|
const refreshToken = options?.refreshToken ?? 0;
|
|
249
400
|
const wantsQuery = options?.enabled ?? true;
|
|
250
401
|
const enabled = wantsQuery && sessionReady;
|
|
251
402
|
const latestRefreshToken = useRef(refreshToken);
|
|
252
403
|
const latestStartedRefreshToken = useRef(refreshToken);
|
|
253
404
|
latestRefreshToken.current = refreshToken;
|
|
405
|
+
// A manual refresh belongs to the query it was requested on. Sources that
|
|
406
|
+
// support cache bypass keep it pending until success. It is recorded while
|
|
407
|
+
// rendering because the fetch it triggers can start before any effect runs.
|
|
408
|
+
const pendingManualRefresh = useRef<{
|
|
409
|
+
token: number;
|
|
410
|
+
queryKeyHash: string;
|
|
411
|
+
failed?: boolean;
|
|
412
|
+
} | null>(null);
|
|
413
|
+
// The manual request whose own forced fetch last completed, for the caller
|
|
414
|
+
// that shows it. A fetch that started without that request (an automatic
|
|
415
|
+
// revalidation, a cancelled or failed attempt) never writes it, and it is
|
|
416
|
+
// dropped as soon as the account or query it belongs to changes.
|
|
417
|
+
const completedManualRefresh = useRef<{
|
|
418
|
+
token: number;
|
|
419
|
+
queryKeyHash: string;
|
|
420
|
+
completedAt: number;
|
|
421
|
+
} | null>(null);
|
|
422
|
+
if (
|
|
423
|
+
completedManualRefresh.current &&
|
|
424
|
+
completedManualRefresh.current.queryKeyHash !== queryKeyHash
|
|
425
|
+
) {
|
|
426
|
+
completedManualRefresh.current = null;
|
|
427
|
+
}
|
|
428
|
+
const observedRefreshToken = useRef(refreshToken);
|
|
429
|
+
if (refreshToken > observedRefreshToken.current) {
|
|
430
|
+
observedRefreshToken.current = refreshToken;
|
|
431
|
+
pendingManualRefresh.current = { token: refreshToken, queryKeyHash };
|
|
432
|
+
} else if (
|
|
433
|
+
pendingManualRefresh.current &&
|
|
434
|
+
pendingManualRefresh.current.queryKeyHash !== queryKeyHash
|
|
435
|
+
) {
|
|
436
|
+
// The request failed or was superseded, and the panel has moved to
|
|
437
|
+
// another query: that one is an ordinary automatic load.
|
|
438
|
+
pendingManualRefresh.current = null;
|
|
439
|
+
}
|
|
254
440
|
const previousRefreshToken = useRef(refreshToken);
|
|
255
441
|
const previousEnabled = useRef(enabled);
|
|
442
|
+
const snapshotKey = JSON.stringify([queryKey, sql, source]);
|
|
443
|
+
const restoredCopy = useRef<{
|
|
444
|
+
id: string;
|
|
445
|
+
result: SqlQueryResult | null;
|
|
446
|
+
} | null>(null);
|
|
256
447
|
const queryClient = useQueryClient();
|
|
257
448
|
const query = useQuery<SqlQueryResult>({
|
|
258
449
|
queryKey: scopedQueryKey,
|
|
259
450
|
queryFn: async ({ signal }) => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
451
|
+
latestStartedRefreshToken.current = latestRefreshToken.current;
|
|
452
|
+
const manual = pendingManualRefresh.current;
|
|
453
|
+
const manualRequested = manual?.queryKeyHash === queryKeyHash;
|
|
454
|
+
const forceRefresh =
|
|
455
|
+
manualRequested && supportsSqlQueryCacheBypass(source);
|
|
456
|
+
let result: SqlQueryResult;
|
|
457
|
+
try {
|
|
458
|
+
result = await executeSqlQuery(sql, source, signal, {
|
|
459
|
+
reportScreenshot: options?.reportScreenshot,
|
|
460
|
+
forceRefresh,
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (isSqlQueryAccessDenied(error)) {
|
|
464
|
+
if (
|
|
465
|
+
lastShown.current?.accountScope === accountScope &&
|
|
466
|
+
lastShown.current?.retainScope === retainScope
|
|
467
|
+
) {
|
|
468
|
+
lastShown.current = null;
|
|
469
|
+
}
|
|
470
|
+
restoredCopy.current = null;
|
|
471
|
+
removeSqlQuerySnapshotForSession({
|
|
472
|
+
accountScope,
|
|
473
|
+
queryKey: snapshotKey,
|
|
474
|
+
});
|
|
475
|
+
// React Query retains data on failed revalidation by default. Remove
|
|
476
|
+
// just this denied result while letting it commit the real error.
|
|
477
|
+
queryClient
|
|
478
|
+
.getQueryCache()
|
|
479
|
+
.find({ queryKey: scopedQueryKey, exact: true })
|
|
480
|
+
?.setState({
|
|
481
|
+
data: undefined,
|
|
482
|
+
dataUpdatedAt: 0,
|
|
483
|
+
error,
|
|
484
|
+
status: "error",
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// The request stays pending for the cache bypass, but a later success
|
|
488
|
+
// is not the one the user asked for.
|
|
489
|
+
if (manual && forceRefresh && !signal.aborted) manual.failed = true;
|
|
490
|
+
if (
|
|
491
|
+
manualRequested &&
|
|
492
|
+
!forceRefresh &&
|
|
493
|
+
pendingManualRefresh.current === manual
|
|
494
|
+
) {
|
|
495
|
+
pendingManualRefresh.current = null;
|
|
496
|
+
}
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
if (manualRequested && pendingManualRefresh.current === manual) {
|
|
500
|
+
pendingManualRefresh.current = null;
|
|
501
|
+
}
|
|
502
|
+
if (manual && forceRefresh && !manual.failed && !signal.aborted) {
|
|
503
|
+
completedManualRefresh.current = {
|
|
504
|
+
token: manual.token,
|
|
505
|
+
queryKeyHash,
|
|
506
|
+
completedAt: Date.now(),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (options?.snapshots) {
|
|
510
|
+
writeSqlQuerySnapshotForSession({
|
|
511
|
+
accountScope,
|
|
512
|
+
queryKey: snapshotKey,
|
|
513
|
+
result,
|
|
514
|
+
signal,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
271
517
|
return result;
|
|
272
518
|
},
|
|
273
519
|
enabled,
|
|
@@ -275,15 +521,22 @@ export function useSqlQuery(
|
|
|
275
521
|
refetchOnMount: options?.refetchOnMount ?? false,
|
|
276
522
|
refetchOnReconnect: options?.refetchOnReconnect ?? false,
|
|
277
523
|
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? false,
|
|
278
|
-
retry:
|
|
524
|
+
retry: (failureCount, error) => {
|
|
525
|
+
if (isSqlQueryAccessDenied(error)) return false;
|
|
526
|
+
const retry = options?.retry ?? false;
|
|
527
|
+
return typeof retry === "number" ? failureCount < retry : retry;
|
|
528
|
+
},
|
|
279
529
|
staleTime:
|
|
280
530
|
options?.staleTime ??
|
|
281
531
|
(options?.reuseCompletedOnEnable ? Infinity : 5 * 60 * 1000),
|
|
282
532
|
});
|
|
283
533
|
const refetch = query.refetch;
|
|
534
|
+
const accessDenied = isSqlQueryAccessDenied(query.error);
|
|
284
535
|
|
|
285
536
|
useEffect(() => {
|
|
286
|
-
if (
|
|
537
|
+
if (accessDenied) {
|
|
538
|
+
lastShown.current = null;
|
|
539
|
+
} else if (query.data && sessionReady && retainScope !== undefined) {
|
|
287
540
|
lastShown.current = {
|
|
288
541
|
data: query.data,
|
|
289
542
|
queryKeyHash,
|
|
@@ -296,7 +549,14 @@ export function useSqlQuery(
|
|
|
296
549
|
) {
|
|
297
550
|
lastShown.current = null;
|
|
298
551
|
}
|
|
299
|
-
}, [
|
|
552
|
+
}, [
|
|
553
|
+
query.data,
|
|
554
|
+
sessionReady,
|
|
555
|
+
retainScope,
|
|
556
|
+
queryKeyHash,
|
|
557
|
+
accountScope,
|
|
558
|
+
accessDenied,
|
|
559
|
+
]);
|
|
300
560
|
|
|
301
561
|
useEffect(() => {
|
|
302
562
|
const wasEnabled = previousEnabled.current;
|
|
@@ -304,15 +564,15 @@ export function useSqlQuery(
|
|
|
304
564
|
const refreshTokenChanged = refreshToken > previousRefreshToken.current;
|
|
305
565
|
previousRefreshToken.current = refreshToken;
|
|
306
566
|
if (!enabled) return;
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
(!refreshTokenChanged && !enablingPendingRefresh) ||
|
|
311
|
-
refreshToken <= successfulRefreshToken.current
|
|
312
|
-
) {
|
|
567
|
+
const pending = pendingManualRefresh.current;
|
|
568
|
+
const enablingPendingRefresh = !wasEnabled && pending !== null;
|
|
569
|
+
if (!pending || (!refreshTokenChanged && !enablingPendingRefresh)) {
|
|
313
570
|
return;
|
|
314
571
|
}
|
|
315
|
-
if (
|
|
572
|
+
if (
|
|
573
|
+
query.isFetching &&
|
|
574
|
+
latestStartedRefreshToken.current >= pending.token
|
|
575
|
+
) {
|
|
316
576
|
return;
|
|
317
577
|
}
|
|
318
578
|
void queryClient
|
|
@@ -327,12 +587,76 @@ export function useSqlQuery(
|
|
|
327
587
|
refreshToken,
|
|
328
588
|
]);
|
|
329
589
|
|
|
590
|
+
// The saved copy is read once per account and query, and only for a query
|
|
591
|
+
// with nothing in memory. Once the query has data the copy is spent, so a
|
|
592
|
+
// later cache eviction cannot bring back rows older than ones already shown.
|
|
593
|
+
const restoreId = `${accountScope}
|
|
594
|
+
${snapshotKey}`;
|
|
595
|
+
let restored: SqlQueryResult | null = null;
|
|
596
|
+
if (accessDenied) {
|
|
597
|
+
restoredCopy.current = { id: restoreId, result: null };
|
|
598
|
+
} else if (options?.snapshots && sessionReady && query.data === undefined) {
|
|
599
|
+
if (restoredCopy.current?.id !== restoreId) {
|
|
600
|
+
const read = readSqlQuerySnapshotForSession({
|
|
601
|
+
accountScope,
|
|
602
|
+
queryKey: snapshotKey,
|
|
603
|
+
});
|
|
604
|
+
restoredCopy.current = {
|
|
605
|
+
id: restoreId,
|
|
606
|
+
result: read.status === "hit" ? read.snapshot.result : null,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
restored = restoredCopy.current.result;
|
|
610
|
+
} else if (query.data !== undefined && restoredCopy.current) {
|
|
611
|
+
restoredCopy.current = { id: restoreId, result: null };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const freshnessOf = (
|
|
615
|
+
data: SqlQueryResult | undefined,
|
|
616
|
+
isRestored: boolean,
|
|
617
|
+
): SqlQueryFreshness | undefined =>
|
|
618
|
+
describeSqlQueryFreshness(data, { source, restored: isRestored });
|
|
619
|
+
// Offered only once the data it produced has been committed to the query.
|
|
620
|
+
const completion = completedManualRefresh.current;
|
|
621
|
+
const completedManualRefreshToken =
|
|
622
|
+
completion &&
|
|
623
|
+
completion.queryKeyHash === queryKeyHash &&
|
|
624
|
+
query.data !== undefined &&
|
|
625
|
+
query.dataUpdatedAt >= completion.completedAt
|
|
626
|
+
? completion.token
|
|
627
|
+
: undefined;
|
|
330
628
|
if (!sessionReady && wantsQuery) {
|
|
331
629
|
return {
|
|
332
630
|
...query,
|
|
333
631
|
data: undefined,
|
|
334
632
|
isPlaceholderData: false,
|
|
335
633
|
isLoading: true,
|
|
634
|
+
freshness: undefined,
|
|
635
|
+
completedManualRefreshToken: undefined,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (accessDenied) {
|
|
639
|
+
return {
|
|
640
|
+
...query,
|
|
641
|
+
data: undefined,
|
|
642
|
+
isPlaceholderData: false,
|
|
643
|
+
isLoading: false,
|
|
644
|
+
freshness: undefined,
|
|
645
|
+
completedManualRefreshToken: undefined,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (
|
|
649
|
+
restored &&
|
|
650
|
+
query.data === undefined &&
|
|
651
|
+
(enabled || (options?.retainWhilePaused && sessionReady))
|
|
652
|
+
) {
|
|
653
|
+
return {
|
|
654
|
+
...query,
|
|
655
|
+
data: restored,
|
|
656
|
+
isPlaceholderData: false,
|
|
657
|
+
isLoading: false,
|
|
658
|
+
freshness: freshnessOf(restored, true),
|
|
659
|
+
completedManualRefreshToken: undefined,
|
|
336
660
|
};
|
|
337
661
|
}
|
|
338
662
|
const held = lastShown.current;
|
|
@@ -348,7 +672,13 @@ export function useSqlQuery(
|
|
|
348
672
|
data: held.data,
|
|
349
673
|
isPlaceholderData: held.queryKeyHash !== queryKeyHash,
|
|
350
674
|
isLoading: false,
|
|
675
|
+
freshness: freshnessOf(held.data, false),
|
|
676
|
+
completedManualRefreshToken: undefined,
|
|
351
677
|
};
|
|
352
678
|
}
|
|
353
|
-
return
|
|
679
|
+
return {
|
|
680
|
+
...query,
|
|
681
|
+
freshness: freshnessOf(query.data, false),
|
|
682
|
+
completedManualRefreshToken,
|
|
683
|
+
};
|
|
354
684
|
}
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
DataNoteLines,
|
|
35
35
|
UnplacedDataNotesListener,
|
|
36
36
|
} from "@/components/dashboard/data-note-bands";
|
|
37
|
+
import type { PanelFreshness } from "@/components/dashboard/panel-freshness";
|
|
38
|
+
import { PanelFreshnessClock } from "@/components/dashboard/PanelFreshnessClock";
|
|
37
39
|
import { ChartFillHeight, SqlChart } from "@/components/dashboard/SqlChart";
|
|
38
40
|
import {
|
|
39
41
|
AlertDialog,
|
|
@@ -87,6 +89,29 @@ import { ViewSqlPopover } from "./ViewSqlPopover";
|
|
|
87
89
|
|
|
88
90
|
const INHERIT_TIME_RANGE = "__inherit__";
|
|
89
91
|
|
|
92
|
+
function PanelTitle({ title }: { title: string }) {
|
|
93
|
+
const [open, setOpen] = useState(false);
|
|
94
|
+
return (
|
|
95
|
+
<CardTitle className="min-w-0 text-sm font-medium">
|
|
96
|
+
<Tooltip open={open} onOpenChange={setOpen}>
|
|
97
|
+
<TooltipTrigger asChild>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={(event) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
setOpen(true);
|
|
103
|
+
}}
|
|
104
|
+
className="block max-w-full truncate rounded text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
105
|
+
>
|
|
106
|
+
{title}
|
|
107
|
+
</button>
|
|
108
|
+
</TooltipTrigger>
|
|
109
|
+
<TooltipContent>{title}</TooltipContent>
|
|
110
|
+
</Tooltip>
|
|
111
|
+
</CardTitle>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
export function dashboardTileElementId(panelId: string): string {
|
|
91
116
|
return `dashboard-tile-${panelId}`;
|
|
92
117
|
}
|
|
@@ -275,6 +300,28 @@ export function SqlChartCard({
|
|
|
275
300
|
setExportCsv(handler ? () => handler : null);
|
|
276
301
|
}, []);
|
|
277
302
|
|
|
303
|
+
// Keyed by the panel it was reported for, so a card reused for another
|
|
304
|
+
// panel or dashboard never shows the previous one's clock.
|
|
305
|
+
const panelScope = JSON.stringify([
|
|
306
|
+
accountScope,
|
|
307
|
+
dashboardId ?? null,
|
|
308
|
+
panel.id,
|
|
309
|
+
]);
|
|
310
|
+
const [reportedFreshness, setReportedFreshness] = useState<{
|
|
311
|
+
scope: string;
|
|
312
|
+
freshness: PanelFreshness | null;
|
|
313
|
+
} | null>(null);
|
|
314
|
+
const handleFreshnessChange = useCallback(
|
|
315
|
+
(freshness: PanelFreshness | null) => {
|
|
316
|
+
setReportedFreshness({ scope: panelScope, freshness });
|
|
317
|
+
},
|
|
318
|
+
[panelScope],
|
|
319
|
+
);
|
|
320
|
+
const panelFreshness =
|
|
321
|
+
reportedFreshness?.scope === panelScope
|
|
322
|
+
? reportedFreshness.freshness
|
|
323
|
+
: null;
|
|
324
|
+
|
|
278
325
|
const handleCopyTableChange = useCallback(
|
|
279
326
|
(handler: (() => Promise<void>) | null) => {
|
|
280
327
|
setCopyTable(handler ? () => handler : null);
|
|
@@ -716,26 +763,33 @@ export function SqlChartCard({
|
|
|
716
763
|
highlighted && "ring-2 ring-primary",
|
|
717
764
|
)}
|
|
718
765
|
>
|
|
719
|
-
<CardHeader className="
|
|
720
|
-
<div className="flex min-w-0
|
|
721
|
-
<
|
|
722
|
-
{panel.title}
|
|
723
|
-
</CardTitle>
|
|
766
|
+
<CardHeader className="dashboard-panel-header grid space-y-0 shrink-0 pb-2">
|
|
767
|
+
<div className="dashboard-panel-title flex min-w-0 items-center gap-1">
|
|
768
|
+
<PanelTitle title={panel.title} />
|
|
724
769
|
<DataNoteFlag notes={flaggedDataNotes} panelId={panel.id} />
|
|
770
|
+
{timeRangeOverrideLabel ? (
|
|
771
|
+
<Badge
|
|
772
|
+
variant="secondary"
|
|
773
|
+
className="max-w-20 min-w-0 shrink truncate px-1.5 py-0 text-[10px] font-normal"
|
|
774
|
+
title={timeRangeOverrideLabel}
|
|
775
|
+
>
|
|
776
|
+
<span className="sr-only">
|
|
777
|
+
{t("sqlDashboard.chartTimeRangeOverride")}{" "}
|
|
778
|
+
</span>
|
|
779
|
+
{timeRangeOverrideLabel}
|
|
780
|
+
</Badge>
|
|
781
|
+
) : null}
|
|
725
782
|
</div>
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
</Badge>
|
|
737
|
-
) : null}
|
|
738
|
-
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
783
|
+
<div className="dashboard-panel-freshness min-w-0 overflow-hidden">
|
|
784
|
+
{panelFreshness ? (
|
|
785
|
+
<PanelFreshnessClock
|
|
786
|
+
freshness={panelFreshness}
|
|
787
|
+
showAge
|
|
788
|
+
className="h-6 w-full px-1"
|
|
789
|
+
/>
|
|
790
|
+
) : null}
|
|
791
|
+
</div>
|
|
792
|
+
<div className="dashboard-panel-actions flex items-center justify-end gap-1 opacity-0 transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
739
793
|
{!editable || onSaveSql ? (
|
|
740
794
|
<ViewSqlPopover
|
|
741
795
|
panel={panel}
|
|
@@ -926,6 +980,7 @@ export function SqlChartCard({
|
|
|
926
980
|
onRefreshRequested={handleRefresh}
|
|
927
981
|
dashboardId={dashboardId}
|
|
928
982
|
onExportCsvChange={handleExportCsvChange}
|
|
983
|
+
onFreshnessChange={handleFreshnessChange}
|
|
929
984
|
onCopyTableChange={handleCopyTableChange}
|
|
930
985
|
{...tableColumnProps}
|
|
931
986
|
extensionContext={extensionContext}
|