@agent-native/core 0.99.1 → 0.99.2
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/core/CHANGELOG.md +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +12 -38
- package/corpus/core/src/audit/config.ts +0 -1
- package/corpus/core/src/chat-threads/store.ts +57 -41
- package/corpus/core/src/client/AssistantChat.tsx +1 -2
- package/corpus/core/src/client/chat/message-components.tsx +15 -3
- package/corpus/core/src/client/chat/tool-call-display.tsx +18 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +5 -9
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +0 -133
- package/corpus/core/src/client/index.ts +7 -0
- package/corpus/core/src/client/settings/DemoModeSection.tsx +9 -65
- package/corpus/core/src/client/settings/SettingsPanel.tsx +1 -1
- package/corpus/core/src/client/use-db-sync.ts +3 -15
- package/corpus/core/src/client/use-demo-mode-status.ts +16 -30
- package/corpus/core/src/demo/browser-state.ts +47 -0
- package/corpus/core/src/demo/fetch-interceptor.ts +12 -59
- package/corpus/core/src/deploy/workspace-deploy.ts +2 -2
- package/corpus/core/src/server/action-discovery.ts +0 -1
- package/corpus/core/src/server/core-routes-plugin.ts +0 -30
- package/corpus/core/src/templates/workspace-core/.agents/skills/native-navigation/SKILL.md +67 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +48 -7
- package/corpus/templates/analytics/AGENTS.md +4 -4
- package/corpus/templates/analytics/actions/compose-dashboard.ts +12 -1
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +15 -4
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +2 -1
- package/corpus/templates/analytics/actions/update-dashboard.ts +14 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -27
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +4 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +21 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +14 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +31 -12
- package/corpus/templates/analytics/changelog/2026-07-13-dashboard-charts-now-honor-the-selected-time-range-by-defaul.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-13-keep-real-authorized-identities-in-analytics-incident-lookup.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-13-the-analytics-agents-tab-is-available-again-from-the-main-na.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
- package/corpus/templates/analytics/server/lib/agent-readable-resource-context.ts +4 -0
- package/corpus/templates/analytics/server/lib/dashboard-time-scope.ts +170 -0
- package/corpus/templates/analytics/server/lib/error-capture.ts +6 -62
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +20 -5
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +1 -1
- package/corpus/templates/analytics/server/lib/session-replay.ts +5 -111
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/assets/app/routes/library.tsx +3 -4
- package/corpus/templates/assets/changelog/2026-07-13-internal-library-links-open-in-new-tabs.md +6 -0
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +43 -40
- package/corpus/templates/calendar/app/pages/ManageBookingPage.tsx +6 -8
- package/corpus/templates/calendar/changelog/2026-07-13-booking-links-and-previews-open-in-new-tabs.md +6 -0
- package/corpus/templates/clips/app/components/player/sign-in-prompt-dialog.tsx +4 -7
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +19 -9
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +5 -3
- package/corpus/templates/clips/changelog/2026-07-13-recording-links-open-in-new-tabs.md +6 -0
- package/corpus/templates/content/app/components/editor/ContentReferencePreview.tsx +6 -8
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +5 -3
- package/corpus/templates/content/app/components/editor/database/FormView.tsx +6 -9
- package/corpus/templates/content/changelog/2026-07-13-document-links-open-in-new-tabs.md +6 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +33 -16
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +18 -1
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +4 -4
- package/corpus/templates/design/app/pages/DesignSystems.tsx +8 -10
- package/corpus/templates/design/app/pages/Index.tsx +6 -1
- package/corpus/templates/design/app/pages/Present.tsx +12 -6
- package/corpus/templates/design/changelog/2026-07-13-design-navigation-links-open-in-new-tabs.md +6 -0
- package/corpus/templates/design/changelog/2026-07-13-design-session-replays-include-preview-iframes.md +6 -0
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +11 -11
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +6 -8
- package/corpus/templates/forms/changelog/2026-07-13-form-navigation-links-open-in-new-tabs.md +6 -0
- package/corpus/templates/mail/app/pages/DraftQueuePage.tsx +3 -8
- package/corpus/templates/mail/changelog/2026-07-13-settings-links-open-in-new-tabs.md +6 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +13 -37
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/audit/config.d.ts.map +1 -1
- package/dist/audit/config.js +0 -1
- package/dist/audit/config.js.map +1 -1
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +47 -33
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +1 -1
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +9 -3
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts +3 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +10 -2
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +2 -6
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +2 -59
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +2 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/settings/DemoModeSection.d.ts +1 -13
- package/dist/client/settings/DemoModeSection.d.ts.map +1 -1
- package/dist/client/settings/DemoModeSection.js +5 -52
- package/dist/client/settings/DemoModeSection.js.map +1 -1
- package/dist/client/settings/SettingsPanel.js +1 -1
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +3 -7
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/client/use-demo-mode-status.d.ts +3 -4
- package/dist/client/use-demo-mode-status.d.ts.map +1 -1
- package/dist/client/use-demo-mode-status.js +8 -23
- package/dist/client/use-demo-mode-status.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/demo/browser-state.d.ts +10 -0
- package/dist/demo/browser-state.d.ts.map +1 -0
- package/dist/demo/browser-state.js +49 -0
- package/dist/demo/browser-state.js.map +1 -0
- package/dist/demo/fetch-interceptor.d.ts +2 -7
- package/dist/demo/fetch-interceptor.d.ts.map +1 -1
- package/dist/demo/fetch-interceptor.js +12 -56
- package/dist/demo/fetch-interceptor.js.map +1 -1
- package/dist/deploy/workspace-deploy.js +2 -2
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +0 -1
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +0 -27
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/native-navigation/SKILL.md +67 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/native-navigation/SKILL.md +67 -0
- package/corpus/core/src/demo/actions/toggle-demo-mode.ts +0 -23
- package/corpus/core/src/demo/config.ts +0 -57
- package/dist/demo/actions/toggle-demo-mode.d.ts +0 -8
- package/dist/demo/actions/toggle-demo-mode.d.ts.map +0 -1
- package/dist/demo/actions/toggle-demo-mode.js +0 -21
- package/dist/demo/actions/toggle-demo-mode.js.map +0 -1
- package/dist/demo/config.d.ts +0 -9
- package/dist/demo/config.d.ts.map +0 -1
- package/dist/demo/config.js +0 -58
- package/dist/demo/config.js.map +0 -1
|
@@ -119,11 +119,11 @@ details live in `.agents/skills/`.
|
|
|
119
119
|
without raw replay events or storage references. Do not add replay blob or
|
|
120
120
|
dashboard mutation actions to this catalog without an explicit security
|
|
121
121
|
review.
|
|
122
|
-
-
|
|
123
|
-
|
|
122
|
+
- Demo mode is a browser-local presentation preference. Analytics backend
|
|
123
|
+
actions, agent/MCP reads, session replays, and error issues always return real
|
|
124
|
+
authorized identities, so incident lookups should use the actual
|
|
124
125
|
`userId`/email parameter (e.g. `userId: "user@example.com"`) or recording/
|
|
125
|
-
issue id
|
|
126
|
-
output.
|
|
126
|
+
issue id.
|
|
127
127
|
- Analytics keeps its direct MCP surface explicitly curated, so external
|
|
128
128
|
agents should use `ask_app` for multi-step investigation and changes. The
|
|
129
129
|
six incident reads above are bounded, user/org-scoped fallback tools for
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@agent-native/core/server";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
14
|
+
import { validateFirstPartyDashboardTimeScope } from "../server/lib/dashboard-time-scope";
|
|
14
15
|
import {
|
|
15
16
|
getDashboard,
|
|
16
17
|
upsertDashboard,
|
|
@@ -138,7 +139,7 @@ export default defineAction({
|
|
|
138
139
|
"If the dashboard already exists and `overwrite` is false (default), the new panels are APPENDED (panels whose id is already present are skipped); with `overwrite: true` the config is replaced. " +
|
|
139
140
|
"Returns { dashboardId, panelCount, createdMetrics, unknownMetrics, invalidMetrics, urlPath, deepLink, message } — use panelCount as proof-of-done. " +
|
|
140
141
|
`Available metric keys: ${METRIC_KEYS.join(", ")}. ` +
|
|
141
|
-
"Each metric accepts an optional per-metric `window` of '30d' | '90d' | 'all' (only affects windowed virality/time metrics).",
|
|
142
|
+
"Each metric accepts an optional per-metric `window` of '30d' | '90d' | 'all' (only affects windowed virality/time metrics). Catalog panels are time-scoped by construction; custom first-party SQL added later must use `{{timeRange}}` or declare `config.timeScope`.",
|
|
142
143
|
schema: z.object({
|
|
143
144
|
dashboardId: z
|
|
144
145
|
.string()
|
|
@@ -200,6 +201,7 @@ export default defineAction({
|
|
|
200
201
|
const unknownMetrics: string[] = [];
|
|
201
202
|
const invalidMetrics: Array<{ metric: string; reason: string }> = [];
|
|
202
203
|
const composedPanels: ComposedPanel[] = [];
|
|
204
|
+
const catalogDashboard = withFirstPartyDashboardFilters({ filters: [] });
|
|
203
205
|
|
|
204
206
|
for (const req of requests) {
|
|
205
207
|
const panel = buildPanel(req.metric, {
|
|
@@ -220,6 +222,15 @@ export default defineAction({
|
|
|
220
222
|
// rest of the dashboard still builds. (Catalog SQL is known-good, so this
|
|
221
223
|
// is a defensive net, e.g. if a future window/override produces bad SQL.)
|
|
222
224
|
try {
|
|
225
|
+
const timeScopeError = validateFirstPartyDashboardTimeScope(
|
|
226
|
+
panel,
|
|
227
|
+
catalogDashboard,
|
|
228
|
+
0,
|
|
229
|
+
);
|
|
230
|
+
if (timeScopeError) {
|
|
231
|
+
invalidMetrics.push({ metric: req.metric, reason: timeScopeError });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
223
234
|
validateFirstPartyAnalyticsSql(panel.sql);
|
|
224
235
|
} catch (e: any) {
|
|
225
236
|
invalidMetrics.push({
|
|
@@ -32,6 +32,17 @@ type DashboardPatch = {
|
|
|
32
32
|
parentId?: string;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
type PanelTimeScope =
|
|
36
|
+
| "dashboard"
|
|
37
|
+
| "fixed-window"
|
|
38
|
+
| "cohort-history"
|
|
39
|
+
| "all-time";
|
|
40
|
+
|
|
41
|
+
type PanelConfig = Record<string, unknown> & {
|
|
42
|
+
/** Use "dashboard" for AI-generated first-party panels by default. */
|
|
43
|
+
timeScope?: PanelTimeScope;
|
|
44
|
+
};
|
|
45
|
+
|
|
35
46
|
type PanelPatch = {
|
|
36
47
|
title?: string;
|
|
37
48
|
sql?: string;
|
|
@@ -40,7 +51,7 @@ type PanelPatch = {
|
|
|
40
51
|
width?: number;
|
|
41
52
|
columns?: number;
|
|
42
53
|
tab?: string;
|
|
43
|
-
config?:
|
|
54
|
+
config?: PanelConfig;
|
|
44
55
|
description?: string; // shorthand for config.description
|
|
45
56
|
};
|
|
46
57
|
|
|
@@ -112,9 +123,9 @@ export const DASHBOARD_MUTATION_EXAMPLES = [
|
|
|
112
123
|
'dashboard.panelsMatching({"source":"first-party"}).setWidth(2);',
|
|
113
124
|
'dashboard.panel("retention").setConfigPath("yAxis.format","percent");',
|
|
114
125
|
'dashboard.section("retention-activity-section").append(["repeat-users","retention-over-time"]);',
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
`dashboard.insertPanel({"id":"new-kpi","title":"New KPI","source":"first-party","chartType":"metric","width":1,"config":{"timeScope":"dashboard"},"sql":"SELECT COUNT(*) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}'"}).atTop();`,
|
|
127
|
+
`dashboard.insertPanel({"id":"new-chart","title":"New Chart","source":"first-party","chartType":"line","width":1,"config":{"timeScope":"dashboard"},"sql":"SELECT event_date AS date, COUNT(*) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}' GROUP BY event_date ORDER BY event_date"}).nextTo("retention-over-time");`,
|
|
128
|
+
`dashboard.insertPanel({"id":"row-chart","title":"Row Chart","source":"first-party","chartType":"bar","width":1,"config":{"timeScope":"dashboard"},"sql":"SELECT event_name AS name, COUNT(*) AS value FROM analytics_events WHERE event_date >= '{{dateStart}}' AND event_date < '{{dateEnd}}' GROUP BY event_name"}).atRow(2);`,
|
|
118
129
|
'dashboard.insertPanel({"id":"pipeline-widget","title":"Pipeline Widget","chartType":"extension","width":3,"config":{"extensionId":"<existing-extension-id>"}}).atBottom();',
|
|
119
130
|
] as const;
|
|
120
131
|
|
|
@@ -122,6 +122,7 @@ const apiHelp =
|
|
|
122
122
|
"Subjects: dashboard.set, dashboard.panel, dashboard.panels, dashboard.panelsMatching, dashboard.section, dashboard.insertPanel. " +
|
|
123
123
|
"Selection methods: moveToTop, moveToBottom, moveBefore, moveAfter, moveToIndex, moveNextTo, moveToRow, remove, set, setTitle, setSql, setWidth, setConfig, setConfigPath, duplicate. " +
|
|
124
124
|
"Inserted panels support atTop, atBottom, before, after, atIndex, nextTo, atRow, atRowStart, and atRowEnd. Use nextTo(panelId) or atRow(rowNumber) for visible row placement. " +
|
|
125
|
+
"AI-generated first-party panels are dashboard-time-bound by default: set config.timeScope to dashboard and include a matching dashboard time filter in SQL. Allowed values are dashboard, fixed-window, cohort-history, and all-time; use all-time only when the user requests full available history and put all-time, lifetime, or historical in the title or description. A {{timeRange}} token requires the timeRange select filter; {{<id>Start}}/{{<id>End}} require a matching date-range filter. Server validation rejects unbound first-party SQL. " +
|
|
125
126
|
`Examples: ${DASHBOARD_MUTATION_EXAMPLES.slice(0, 5).join(" ")}`;
|
|
126
127
|
|
|
127
128
|
function resolveScope() {
|
|
@@ -198,7 +199,7 @@ export default defineAction({
|
|
|
198
199
|
"Apply general SQL dashboard edits through a small typed mutation API in ONE atomic save. " +
|
|
199
200
|
"Prefer this for dashboard layout and panel edits: move panels by id, edit titles/SQL/width/config, remove panels, duplicate panels, insert panels, or patch dashboard fields. " +
|
|
200
201
|
"For user placement requests like 'second row' or 'next to return rates', use row-aware placement such as `dashboard.insertPanel(...).nextTo(\"retention-over-time\")` or `.atRow(2)`, then verify rendered rows from `get-sql-dashboard.layout.groups`. " +
|
|
201
|
-
"This is code-shaped but not arbitrary code execution: the server parses the allowed dashboard methods, validates the resulting config with the same invariants as update-dashboard, saves once, syncs collab, and returns compact proof. " +
|
|
202
|
+
"This is code-shaped but not arbitrary code execution: the server parses the allowed dashboard methods, validates the resulting config with the same invariants as update-dashboard, saves once, syncs collab, and returns compact proof. First-party SQL must be explicitly time-bound as described in the API help; server validation rejects unbound first-party SQL. " +
|
|
202
203
|
"The main code argument is a string, so it avoids brittle JSON-pointer indexes and native-array serialization issues. " +
|
|
203
204
|
`Common example: ${DASHBOARD_MUTATION_EXAMPLES[0]}`,
|
|
204
205
|
schema: z.object({
|
|
@@ -13,6 +13,7 @@ import { z } from "zod";
|
|
|
13
13
|
|
|
14
14
|
import { interpolate } from "../app/pages/adhoc/sql-dashboard/interpolate";
|
|
15
15
|
import { dryRunQuery } from "../server/lib/bigquery";
|
|
16
|
+
import { validateFirstPartyDashboardTimeScope } from "../server/lib/dashboard-time-scope";
|
|
16
17
|
import {
|
|
17
18
|
upsertDashboard,
|
|
18
19
|
upsertDashboardWithRetry,
|
|
@@ -421,8 +422,20 @@ export async function validatePanelSql(
|
|
|
421
422
|
const raw = typeof p.sql === "string" ? p.sql : "";
|
|
422
423
|
if (raw.trim()) {
|
|
423
424
|
try {
|
|
425
|
+
const timeScopeError = validateFirstPartyDashboardTimeScope(
|
|
426
|
+
p,
|
|
427
|
+
config,
|
|
428
|
+
i,
|
|
429
|
+
);
|
|
430
|
+
if (timeScopeError) return timeScopeError;
|
|
424
431
|
validateFirstPartyAnalyticsSql(interpolate(raw, vars));
|
|
425
432
|
} catch (e: any) {
|
|
433
|
+
if (
|
|
434
|
+
typeof e?.message === "string" &&
|
|
435
|
+
e.message.startsWith("panel[")
|
|
436
|
+
) {
|
|
437
|
+
return e.message;
|
|
438
|
+
}
|
|
426
439
|
return `panel[${i}] "${p.title || p.id}" first-party analytics SQL is invalid: ${e?.message ?? e}`;
|
|
427
440
|
}
|
|
428
441
|
}
|
|
@@ -552,6 +565,7 @@ export default defineAction({
|
|
|
552
565
|
"Use this action when creating a brand-new dashboard from a complete config, for the UI full-config save path, or for an explicitly requested low-level JSON-pointer compatibility edit. " +
|
|
553
566
|
"Do not use `ops` or `panelOrder` for ordinary agent edits like moving charts, adding panels to an existing dashboard, changing widths, or updating panel config; call `mutate-dashboard` once with the full edit script. " +
|
|
554
567
|
"When this action is appropriate, provide only one of `ops`, `panelOrder`, or `config`; `config` replaces the whole dashboard config. " +
|
|
568
|
+
"First-party event panels must bind to a declared dashboard time filter with `{{timeRange}}` or date-range variables. Intentional fixed-window, cohort-history, and all-time exceptions must be explicit in `panel.config.timeScope`; unbounded first-party SQL is rejected at save time. " +
|
|
555
569
|
"To add a shipped catalog template's panels to an existing dashboard, prefer `install-dashboard-template` with `mergePanels: true` — it appends the template's panels in one call without you having to author each panel. " +
|
|
556
570
|
"The result is compact by default: `panelCount`, `appliedOps`, `panelOrder`, `firstPanelIds`, and `summary`. Set `returnConfig: true` only when you truly need the full config in the tool result. " +
|
|
557
571
|
"The UI auto-refreshes after this action — do NOT call `refresh-screen`.",
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
IconReportAnalytics,
|
|
15
15
|
IconSearch,
|
|
16
16
|
IconArchive,
|
|
17
|
+
IconActivity,
|
|
17
18
|
IconHeartbeat,
|
|
18
19
|
IconPin,
|
|
19
20
|
IconPlus,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
IconPlayerPlay,
|
|
27
28
|
IconLayoutSidebarLeftCollapse,
|
|
28
29
|
IconLayoutSidebarLeftExpand,
|
|
29
|
-
IconShieldCheck,
|
|
30
30
|
} from "@tabler/icons-react";
|
|
31
31
|
import {
|
|
32
32
|
useQuery,
|
|
@@ -97,7 +97,7 @@ import {
|
|
|
97
97
|
type ChatThreadSummary,
|
|
98
98
|
} from "@agent-native/core/client";
|
|
99
99
|
import { ExtensionsSidebarSection } from "@agent-native/core/client/extensions";
|
|
100
|
-
import { OrgSwitcher
|
|
100
|
+
import { OrgSwitcher } from "@agent-native/core/client/org";
|
|
101
101
|
|
|
102
102
|
import {
|
|
103
103
|
AlertDialog,
|
|
@@ -1593,7 +1593,6 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
|
|
|
1593
1593
|
const navigate = useNavigate();
|
|
1594
1594
|
const t = useT();
|
|
1595
1595
|
const queryClient = useQueryClient();
|
|
1596
|
-
const { canManageOrg } = useOrgRole();
|
|
1597
1596
|
const { setTheme } = useTheme();
|
|
1598
1597
|
|
|
1599
1598
|
const isAskRoute = location.pathname === "/ask";
|
|
@@ -2428,16 +2427,12 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
|
|
|
2428
2427
|
href: "/monitoring",
|
|
2429
2428
|
active: location.pathname.startsWith("/monitoring"),
|
|
2430
2429
|
},
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
active: location.pathname.startsWith("/agents"),
|
|
2438
|
-
},
|
|
2439
|
-
]
|
|
2440
|
-
: []),
|
|
2430
|
+
{
|
|
2431
|
+
icon: IconActivity,
|
|
2432
|
+
label: t("navigation.agents"),
|
|
2433
|
+
href: "/agents",
|
|
2434
|
+
active: location.pathname.startsWith("/agents"),
|
|
2435
|
+
},
|
|
2441
2436
|
{
|
|
2442
2437
|
icon: IconDatabase,
|
|
2443
2438
|
label: t("navigation.dataSources"),
|
|
@@ -2641,20 +2636,19 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
|
|
|
2641
2636
|
{t("navigation.monitoring")}
|
|
2642
2637
|
</Link>
|
|
2643
2638
|
|
|
2644
|
-
{
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
) : null}
|
|
2639
|
+
{/* Agents link */}
|
|
2640
|
+
<Link
|
|
2641
|
+
to="/agents"
|
|
2642
|
+
className={cn(
|
|
2643
|
+
"order-6 flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:text-primary",
|
|
2644
|
+
location.pathname.startsWith("/agents")
|
|
2645
|
+
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
2646
|
+
: "text-muted-foreground hover:bg-sidebar-accent/50",
|
|
2647
|
+
)}
|
|
2648
|
+
>
|
|
2649
|
+
<IconActivity className="h-4 w-4" />
|
|
2650
|
+
{t("navigation.agents")}
|
|
2651
|
+
</Link>
|
|
2658
2652
|
|
|
2659
2653
|
{/* Data Sources link */}
|
|
2660
2654
|
<Link
|
|
@@ -297,7 +297,10 @@ const PanelCell = memo(function PanelCell({
|
|
|
297
297
|
[panel, vars],
|
|
298
298
|
);
|
|
299
299
|
const resolvedSql = useMemo(
|
|
300
|
-
() =>
|
|
300
|
+
() =>
|
|
301
|
+
interpolate(serializePanelSql(panel.sql), vars, {
|
|
302
|
+
failClosedTimeVariables: true,
|
|
303
|
+
}),
|
|
301
304
|
[panel.sql, vars],
|
|
302
305
|
);
|
|
303
306
|
const handleSelectForChat = useCallback(
|
|
@@ -14,9 +14,23 @@ function escapeSqlValue(value: string): string {
|
|
|
14
14
|
return value.replace(/'/g, "''");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface InterpolateOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Replace missing/empty time variables with a value that cannot satisfy the
|
|
20
|
+
* canonical dashboard predicates. This keeps a stale or malformed panel
|
|
21
|
+
* fail-closed at render time instead of turning into an all-time query.
|
|
22
|
+
*/
|
|
23
|
+
failClosedTimeVariables?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isTimeVariable(name: string): boolean {
|
|
27
|
+
return name === "timeRange" || /(?:Start|End)$/.test(name);
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export function interpolate(
|
|
18
31
|
sql: string | undefined | null,
|
|
19
32
|
vars: Record<string, string> = {},
|
|
33
|
+
options: InterpolateOptions = {},
|
|
20
34
|
): string {
|
|
21
35
|
// Defensive: a malformed dashboard config (e.g. agent wrote a panel without
|
|
22
36
|
// a `sql` field) should not crash the page. Treat missing/non-string SQL
|
|
@@ -33,6 +47,13 @@ export function interpolate(
|
|
|
33
47
|
|
|
34
48
|
return withConditionals.replace(/\{\{(\w+)\}\}/g, (_match, name) => {
|
|
35
49
|
const value = vars[name];
|
|
50
|
+
if (
|
|
51
|
+
options.failClosedTimeVariables &&
|
|
52
|
+
isTimeVariable(name) &&
|
|
53
|
+
(value == null || value.length === 0)
|
|
54
|
+
) {
|
|
55
|
+
return "__missing_dashboard_time_filter__";
|
|
56
|
+
}
|
|
36
57
|
if (value == null) return "";
|
|
37
58
|
return escapeSqlValue(String(value));
|
|
38
59
|
});
|
|
@@ -63,7 +63,21 @@ export interface PivotConfig {
|
|
|
63
63
|
valueKey: string;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Declares how a query's time coverage relates to the dashboard filter.
|
|
68
|
+
* `dashboard` is the default for ordinary event metrics; the other values are
|
|
69
|
+
* explicit exceptions that make intentional history scans visible to agents
|
|
70
|
+
* and reviewers.
|
|
71
|
+
*/
|
|
72
|
+
export type DashboardTimeScope =
|
|
73
|
+
| "dashboard"
|
|
74
|
+
| "fixed-window"
|
|
75
|
+
| "cohort-history"
|
|
76
|
+
| "all-time";
|
|
77
|
+
|
|
66
78
|
export interface SqlPanelConfig {
|
|
79
|
+
/** Time coverage contract for first-party SQL panels. */
|
|
80
|
+
timeScope?: DashboardTimeScope;
|
|
67
81
|
xKey?: string;
|
|
68
82
|
yKey?: string;
|
|
69
83
|
yKeys?: string[];
|
|
@@ -47,6 +47,13 @@ import { Link, useParams } from "react-router";
|
|
|
47
47
|
|
|
48
48
|
import { Button } from "@/components/ui/button";
|
|
49
49
|
import { Card, CardContent } from "@/components/ui/card";
|
|
50
|
+
import {
|
|
51
|
+
DropdownMenu,
|
|
52
|
+
DropdownMenuContent,
|
|
53
|
+
DropdownMenuRadioGroup,
|
|
54
|
+
DropdownMenuRadioItem,
|
|
55
|
+
DropdownMenuTrigger,
|
|
56
|
+
} from "@/components/ui/dropdown-menu";
|
|
50
57
|
import { Input } from "@/components/ui/input";
|
|
51
58
|
import {
|
|
52
59
|
Popover,
|
|
@@ -1087,22 +1094,34 @@ function ReplayPlayer({
|
|
|
1087
1094
|
{formatClock(totalTime)}
|
|
1088
1095
|
</span>
|
|
1089
1096
|
|
|
1090
|
-
<
|
|
1091
|
-
|
|
1097
|
+
<DropdownMenu>
|
|
1098
|
+
<DropdownMenuTrigger asChild>
|
|
1092
1099
|
<button
|
|
1093
|
-
key={option}
|
|
1094
1100
|
type="button"
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
"bg-background text-foreground shadow-sm",
|
|
1099
|
-
)}
|
|
1100
|
-
onClick={() => updateSpeed(option)}
|
|
1101
|
+
disabled={disabled}
|
|
1102
|
+
aria-label={`${speed}x`}
|
|
1103
|
+
className="inline-flex h-8 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium tabular-nums transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
1101
1104
|
>
|
|
1102
|
-
{
|
|
1105
|
+
{speed}x
|
|
1103
1106
|
</button>
|
|
1104
|
-
|
|
1105
|
-
|
|
1107
|
+
</DropdownMenuTrigger>
|
|
1108
|
+
<DropdownMenuContent align="center" side="top">
|
|
1109
|
+
<DropdownMenuRadioGroup
|
|
1110
|
+
value={String(speed)}
|
|
1111
|
+
onValueChange={(value) => updateSpeed(Number(value))}
|
|
1112
|
+
>
|
|
1113
|
+
{SPEED_OPTIONS.map((option) => (
|
|
1114
|
+
<DropdownMenuRadioItem
|
|
1115
|
+
key={option}
|
|
1116
|
+
value={String(option)}
|
|
1117
|
+
className="tabular-nums"
|
|
1118
|
+
>
|
|
1119
|
+
{option}x
|
|
1120
|
+
</DropdownMenuRadioItem>
|
|
1121
|
+
))}
|
|
1122
|
+
</DropdownMenuRadioGroup>
|
|
1123
|
+
</DropdownMenuContent>
|
|
1124
|
+
</DropdownMenu>
|
|
1106
1125
|
|
|
1107
1126
|
<Tooltip>
|
|
1108
1127
|
<TooltipTrigger asChild>
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
"chartType": "bar",
|
|
227
227
|
"source": "first-party",
|
|
228
228
|
"width": 2,
|
|
229
|
-
"sql": "SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20",
|
|
229
|
+
"sql": "SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20",
|
|
230
230
|
"config": {
|
|
231
231
|
"xKey": "app",
|
|
232
232
|
"yKey": "count",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"chartType": "bar",
|
|
241
241
|
"source": "first-party",
|
|
242
242
|
"width": 1,
|
|
243
|
-
"sql": "SELECT COALESCE(NULLIF(signed_in, ''), 'unknown') AS signed_in, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY COALESCE(NULLIF(signed_in, ''), 'unknown') ORDER BY signed_in",
|
|
243
|
+
"sql": "SELECT COALESCE(NULLIF(signed_in, ''), 'unknown') AS signed_in, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY COALESCE(NULLIF(signed_in, ''), 'unknown') ORDER BY signed_in",
|
|
244
244
|
"config": {
|
|
245
245
|
"xKey": "signed_in",
|
|
246
246
|
"yKey": "count",
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
"chartType": "area",
|
|
255
255
|
"source": "first-party",
|
|
256
256
|
"width": 1,
|
|
257
|
-
"sql": "SELECT event_date AS date, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY event_date ORDER BY date",
|
|
257
|
+
"sql": "SELECT event_date AS date, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) GROUP BY event_date ORDER BY date",
|
|
258
258
|
"config": {
|
|
259
259
|
"xKey": "date",
|
|
260
260
|
"yKey": "count",
|
|
@@ -56,6 +56,10 @@ function panelSummaries(config: Record<string, unknown>) {
|
|
|
56
56
|
width: typeof panel.width === "number" ? panel.width : undefined,
|
|
57
57
|
columns: typeof panel.columns === "number" ? panel.columns : undefined,
|
|
58
58
|
tab: typeof panel.tab === "string" ? panel.tab : undefined,
|
|
59
|
+
timeScope:
|
|
60
|
+
typeof panelConfig.timeScope === "string"
|
|
61
|
+
? panelConfig.timeScope
|
|
62
|
+
: undefined,
|
|
59
63
|
description:
|
|
60
64
|
typeof panelConfig.description === "string"
|
|
61
65
|
? panelConfig.description
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { DashboardTimeScope } from "../../app/pages/adhoc/sql-dashboard/types";
|
|
2
|
+
|
|
3
|
+
export const DASHBOARD_TIME_SCOPES: readonly DashboardTimeScope[] = [
|
|
4
|
+
"dashboard",
|
|
5
|
+
"fixed-window",
|
|
6
|
+
"cohort-history",
|
|
7
|
+
"all-time",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
type DashboardFilterLike = {
|
|
11
|
+
id?: unknown;
|
|
12
|
+
key?: unknown;
|
|
13
|
+
type?: unknown;
|
|
14
|
+
default?: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type DashboardPanelLike = {
|
|
18
|
+
id?: unknown;
|
|
19
|
+
title?: unknown;
|
|
20
|
+
chartType?: unknown;
|
|
21
|
+
source?: unknown;
|
|
22
|
+
sql?: unknown;
|
|
23
|
+
config?: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type DashboardConfigLike = {
|
|
27
|
+
filters?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TEMPORAL_VARIABLE_RE = /\{\{(timeRange|[A-Za-z_]\w*(?:Start|End))\}\}/g;
|
|
31
|
+
|
|
32
|
+
/** Return the time variables a panel actually references. */
|
|
33
|
+
export function extractDashboardTimeVariables(sql: string): string[] {
|
|
34
|
+
const variables = new Set<string>();
|
|
35
|
+
for (const match of sql.matchAll(TEMPORAL_VARIABLE_RE)) {
|
|
36
|
+
variables.add(match[1]);
|
|
37
|
+
}
|
|
38
|
+
return [...variables];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function filterId(filter: DashboardFilterLike): string {
|
|
42
|
+
const value = filter.id ?? filter.key;
|
|
43
|
+
return typeof value === "string" ? value.trim() : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function filtersFrom(config: DashboardConfigLike): DashboardFilterLike[] {
|
|
47
|
+
return Array.isArray(config.filters)
|
|
48
|
+
? (config.filters as DashboardFilterLike[])
|
|
49
|
+
: [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function panelConfig(panel: DashboardPanelLike): Record<string, unknown> {
|
|
53
|
+
return panel.config && typeof panel.config === "object"
|
|
54
|
+
? (panel.config as Record<string, unknown>)
|
|
55
|
+
: {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasExplicitLowerBound(sql: string): boolean {
|
|
59
|
+
// This intentionally recognizes the bounded shapes used by the shipped
|
|
60
|
+
// first-party catalog. It is a compatibility escape hatch for fixed-window
|
|
61
|
+
// catalog metrics; new ad-hoc panels should use a dashboard placeholder.
|
|
62
|
+
return /\b(?:event_date|timestamp|started_at|ended_at|cohort_date|created_at|date)\b[\s\S]{0,100}?(?:>=|>)\s*[\s\S]{0,160}?(?:CURRENT_DATE|CURRENT_TIMESTAMP|NOW\s*\(|INTERVAL\s*['"]|DATE\s*['"]|TIMESTAMP\s*['"]|\b20\d{2}-\d{2}-\d{2}\b)/i.test(
|
|
63
|
+
sql,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasIntentionalHistoryDescription(
|
|
68
|
+
panel: DashboardPanelLike,
|
|
69
|
+
config: Record<string, unknown>,
|
|
70
|
+
): boolean {
|
|
71
|
+
const values = [panel.title, config.description].filter(
|
|
72
|
+
(value): value is string => typeof value === "string",
|
|
73
|
+
);
|
|
74
|
+
return /all[- ]?time|lifetime|histor(?:y|ical)/i.test(values.join(" "));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scopeValue(panel: DashboardPanelLike): DashboardTimeScope | undefined {
|
|
78
|
+
const value = panelConfig(panel).timeScope;
|
|
79
|
+
return typeof value === "string" &&
|
|
80
|
+
(DASHBOARD_TIME_SCOPES as readonly string[]).includes(value)
|
|
81
|
+
? (value as DashboardTimeScope)
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate the temporal contract for a first-party dashboard panel.
|
|
87
|
+
*
|
|
88
|
+
* Ordinary panels must bind to a dashboard filter. Intentional exceptions are
|
|
89
|
+
* explicit in `config.timeScope`, while legacy fixed-window catalog SQL is
|
|
90
|
+
* accepted when its lower bound is visible in the SQL itself.
|
|
91
|
+
*/
|
|
92
|
+
export function validateFirstPartyDashboardTimeScope(
|
|
93
|
+
panel: DashboardPanelLike,
|
|
94
|
+
dashboard: DashboardConfigLike,
|
|
95
|
+
index: number,
|
|
96
|
+
): string | null {
|
|
97
|
+
if (panel.source !== "first-party") return null;
|
|
98
|
+
if (panel.chartType === "section" || panel.chartType === "extension") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sql = typeof panel.sql === "string" ? panel.sql : "";
|
|
103
|
+
const variables = extractDashboardTimeVariables(sql);
|
|
104
|
+
const filters = filtersFrom(dashboard);
|
|
105
|
+
const label =
|
|
106
|
+
typeof panel.title === "string" && panel.title.trim()
|
|
107
|
+
? `"${panel.title}"`
|
|
108
|
+
: `at index ${index}`;
|
|
109
|
+
|
|
110
|
+
for (const variable of variables) {
|
|
111
|
+
const expectedFilterId =
|
|
112
|
+
variable === "timeRange"
|
|
113
|
+
? "timeRange"
|
|
114
|
+
: variable.replace(/(?:Start|End)$/, "");
|
|
115
|
+
const filter = filters.find(
|
|
116
|
+
(candidate) => filterId(candidate) === expectedFilterId,
|
|
117
|
+
);
|
|
118
|
+
if (!filter) {
|
|
119
|
+
return `panel[${index}] ${label} uses {{${variable}}} but config.filters has no matching "${expectedFilterId}" filter; bind first-party SQL to a declared dashboard time filter`;
|
|
120
|
+
}
|
|
121
|
+
if (variable === "timeRange") {
|
|
122
|
+
if (filter.type !== "select") {
|
|
123
|
+
return `panel[${index}] ${label} uses {{timeRange}}, but filter "timeRange" must have type "select"`;
|
|
124
|
+
}
|
|
125
|
+
} else if (filter.type !== "date-range") {
|
|
126
|
+
return `panel[${index}] ${label} uses {{${variable}}}, but filter "${expectedFilterId}" must have type "date-range"`;
|
|
127
|
+
}
|
|
128
|
+
if (typeof filter.default !== "string" || !filter.default.trim()) {
|
|
129
|
+
return `panel[${index}] ${label} uses a time filter without a non-empty default; choose a bounded default such as "90d" so missing filter state cannot become all-time`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const configuredScope = panelConfig(panel).timeScope;
|
|
134
|
+
if (
|
|
135
|
+
configuredScope !== undefined &&
|
|
136
|
+
(typeof configuredScope !== "string" ||
|
|
137
|
+
!(DASHBOARD_TIME_SCOPES as readonly string[]).includes(configuredScope))
|
|
138
|
+
) {
|
|
139
|
+
return `panel[${index}] ${label} config.timeScope must be one of ${DASHBOARD_TIME_SCOPES.join(", ")}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const scope = scopeValue(panel);
|
|
143
|
+
const hasDashboardBinding = variables.length > 0;
|
|
144
|
+
const hasLowerBound = hasExplicitLowerBound(sql);
|
|
145
|
+
|
|
146
|
+
if (scope === "dashboard" && !hasDashboardBinding) {
|
|
147
|
+
return `panel[${index}] ${label} declares timeScope "dashboard" but does not reference {{timeRange}} or a date-range variable`;
|
|
148
|
+
}
|
|
149
|
+
if (scope === "fixed-window" && hasDashboardBinding) {
|
|
150
|
+
return `panel[${index}] ${label} declares timeScope "fixed-window" but uses dashboard time variables; use timeScope "dashboard" or remove the placeholders`;
|
|
151
|
+
}
|
|
152
|
+
if (scope === "fixed-window" && !hasLowerBound) {
|
|
153
|
+
return `panel[${index}] ${label} declares timeScope "fixed-window" without a recognizable lower date bound`;
|
|
154
|
+
}
|
|
155
|
+
if (scope === "all-time" && hasDashboardBinding) {
|
|
156
|
+
return `panel[${index}] ${label} declares timeScope "all-time" but uses dashboard time variables; use timeScope "dashboard" for filter-bound output`;
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
scope === "all-time" &&
|
|
160
|
+
!hasIntentionalHistoryDescription(panel, panelConfig(panel))
|
|
161
|
+
) {
|
|
162
|
+
return `panel[${index}] ${label} declares timeScope "all-time"; add a description or title that says lifetime, historical, or all-time so the exception is explicit`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!scope && !hasDashboardBinding && !hasLowerBound) {
|
|
166
|
+
return `panel[${index}] ${label} reads first-party analytics without a time bound; use {{timeRange}} with a non-empty default filter, or explicitly set config.timeScope to "cohort-history" or "all-time" for intentional history scans`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|