@agent-native/core 0.114.13 → 0.114.15
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 +2 -2
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/docs/content/deployment.mdx +1 -1
- package/corpus/core/docs/content/frames.mdx +1 -1
- package/corpus/core/docs/content/integrations.mdx +27 -27
- package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
- package/corpus/core/docs/content/workspace-management.mdx +1 -1
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
- package/corpus/core/src/client/AgentPanel.tsx +10 -4
- package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
- package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
- package/corpus/core/src/client/error-format.ts +2 -1
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
- package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
- package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
- package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
- package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
- package/corpus/core/src/localization/default-messages.ts +1 -7
- package/corpus/core/src/server/builder-browser.ts +4 -0
- package/corpus/core/src/server/builder-design-systems.ts +6 -1
- package/corpus/core/src/server/fusion-app.ts +5 -1
- package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
- package/corpus/core/src/shared/index.ts +1 -0
- package/corpus/templates/analytics/AGENTS.md +10 -1
- package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
- package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
- package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
- package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
- package/corpus/templates/analytics/server/plugins/db.ts +13 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
- package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +66 -75
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
- package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
- package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
- package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
- package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
- package/corpus/templates/clips/desktop/src/styles.css +80 -7
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
- package/corpus/templates/clips/learnings.defaults.md +1 -1
- package/corpus/templates/clips/server/plugins/db.ts +11 -0
- package/corpus/templates/clips/shared/builder-credits.ts +1 -1
- package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
- package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +4 -0
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +10 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
- package/dist/client/ConnectBuilderCard.js +5 -1
- package/dist/client/ConnectBuilderCard.js.map +1 -1
- package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
- package/dist/client/chat/markdown-renderer.js +3 -2
- package/dist/client/chat/markdown-renderer.js.map +1 -1
- package/dist/client/error-format.d.ts +1 -1
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +1 -1
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
- package/dist/client/integrations/IntegrationsPanel.js +3 -4
- package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +21 -7
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.js +2 -2
- package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
- package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
- package/dist/client/settings/BackgroundAgentSection.js +5 -1
- package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
- package/dist/client/settings/SettingsPanel.js +1 -1
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
- package/dist/client/settings/useBuilderStatus.js +2 -0
- package/dist/client/settings/useBuilderStatus.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +0 -6
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +1 -7
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +4 -0
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/builder-design-systems.d.ts.map +1 -1
- package/dist/server/builder-design-systems.js +6 -1
- package/dist/server/builder-design-systems.js.map +1 -1
- package/dist/server/fusion-app.d.ts.map +1 -1
- package/dist/server/fusion-app.js +2 -1
- package/dist/server/fusion-app.js.map +1 -1
- package/dist/shared/builder-link-tracking.d.ts +14 -0
- package/dist/shared/builder-link-tracking.d.ts.map +1 -0
- package/dist/shared/builder-link-tracking.js +26 -0
- package/dist/shared/builder-link-tracking.js.map +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +1 -0
- package/dist/shared/index.js.map +1 -1
- package/docs/content/deployment.mdx +1 -1
- package/docs/content/frames.mdx +1 -1
- package/docs/content/integrations.mdx +27 -27
- package/docs/content/pr-visual-recap.mdx +17 -13
- package/docs/content/workspace-management.mdx +1 -1
- package/package.json +3 -2
- package/src/agent/engine/builder-engine.ts +4 -0
- package/src/client/AgentPanel.tsx +10 -4
- package/src/client/ConnectBuilderCard.tsx +5 -1
- package/src/client/chat/markdown-renderer.tsx +3 -5
- package/src/client/error-format.ts +2 -1
- package/src/client/index.ts +1 -0
- package/src/client/integrations/IntegrationsPanel.tsx +2 -18
- package/src/client/resources/McpIntegrationDialog.tsx +43 -32
- package/src/client/resources/mcp-integration-catalog.ts +4 -2
- package/src/client/settings/BackgroundAgentSection.tsx +5 -1
- package/src/client/settings/SettingsPanel.tsx +4 -4
- package/src/client/settings/useBuilderStatus.ts +3 -0
- package/src/localization/default-messages.ts +1 -7
- package/src/server/builder-browser.ts +4 -0
- package/src/server/builder-design-systems.ts +6 -1
- package/src/server/fusion-app.ts +5 -1
- package/src/shared/builder-link-tracking.ts +41 -0
- package/src/shared/index.ts +1 -0
|
@@ -2857,7 +2857,7 @@ function SettingsPanelContent({
|
|
|
2857
2857
|
/>
|
|
2858
2858
|
<ManualSetupCard
|
|
2859
2859
|
hint="Deploy manually to Netlify, Vercel, Cloudflare, or any Nitro-supported target."
|
|
2860
|
-
docsUrl="https://www.builder.io/c/docs/agent-native-deployment"
|
|
2860
|
+
docsUrl="https://www.builder.io/c/docs/agent-native-deployment?utm_source=agent-native&utm_medium=product&utm_campaign=onboarding&utm_content=deployment_settings"
|
|
2861
2861
|
dim={connected}
|
|
2862
2862
|
/>
|
|
2863
2863
|
</div>
|
|
@@ -2888,7 +2888,7 @@ function SettingsPanelContent({
|
|
|
2888
2888
|
/>
|
|
2889
2889
|
<ManualSetupCard
|
|
2890
2890
|
hint="Set DATABASE_URL in your .env to connect Neon, Supabase, Turso, any Postgres/SQLite database, or local PGlite with pglite:./data/pglite."
|
|
2891
|
-
docsUrl="https://www.builder.io/c/docs/agent-native-database"
|
|
2891
|
+
docsUrl="https://www.builder.io/c/docs/agent-native-database?utm_source=agent-native&utm_medium=product&utm_campaign=onboarding&utm_content=database_settings"
|
|
2892
2892
|
dim={connected}
|
|
2893
2893
|
/>
|
|
2894
2894
|
</div>
|
|
@@ -2919,7 +2919,7 @@ function SettingsPanelContent({
|
|
|
2919
2919
|
/>
|
|
2920
2920
|
<ManualSetupCard
|
|
2921
2921
|
hint="Without a provider, files are stored as base64 in your database. Fine for dev, not recommended for production."
|
|
2922
|
-
docsUrl="https://www.builder.io/c/docs/agent-native-file-uploads"
|
|
2922
|
+
docsUrl="https://www.builder.io/c/docs/agent-native-file-uploads?utm_source=agent-native&utm_medium=product&utm_campaign=onboarding&utm_content=file_upload_settings"
|
|
2923
2923
|
dim={connected}
|
|
2924
2924
|
/>
|
|
2925
2925
|
</div>
|
|
@@ -2950,7 +2950,7 @@ function SettingsPanelContent({
|
|
|
2950
2950
|
/>
|
|
2951
2951
|
<ManualSetupCard
|
|
2952
2952
|
hint="Configure Better Auth with BETTER_AUTH_SECRET and optional Google/GitHub OAuth providers."
|
|
2953
|
-
docsUrl="https://www.builder.io/c/docs/agent-native-authentication"
|
|
2953
|
+
docsUrl="https://www.builder.io/c/docs/agent-native-authentication?utm_source=agent-native&utm_medium=product&utm_campaign=onboarding&utm_content=authentication_settings"
|
|
2954
2954
|
dim={connected}
|
|
2955
2955
|
/>
|
|
2956
2956
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
|
|
3
|
+
import { applyBuilderUtmTrackingParams } from "../../shared/builder-link-tracking.js";
|
|
3
4
|
import { trackEvent } from "../analytics.js";
|
|
4
5
|
import { agentNativePath } from "../api-path.js";
|
|
5
6
|
import { getCallbackOrigin } from "../frame.js";
|
|
@@ -343,6 +344,8 @@ export function withBuilderConnectTrackingParams(
|
|
|
343
344
|
parsed.searchParams.set("redirect_url", parsedRedirect.toString());
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
applyBuilderUtmTrackingParams(parsed.searchParams, { content: source });
|
|
348
|
+
|
|
346
349
|
return parsed.toString();
|
|
347
350
|
} catch {
|
|
348
351
|
return url;
|
|
@@ -725,9 +725,6 @@ const messages = {
|
|
|
725
725
|
chatIntegrations: "Chat Integrations",
|
|
726
726
|
chatIntegrationsDescription: "Talk to this agent from other platforms",
|
|
727
727
|
addIntegration: "Add integration",
|
|
728
|
-
addSomething: "Add something not listed",
|
|
729
|
-
addSomethingTitle: "Add another integration",
|
|
730
|
-
addSomethingPlaceholder: "Which integration should we add?",
|
|
731
728
|
dispatchEntrypoint:
|
|
732
729
|
"For a central Slack or Telegram entrypoint that can route work across multiple apps, use the",
|
|
733
730
|
sharedMessaging:
|
|
@@ -737,12 +734,9 @@ const messages = {
|
|
|
737
734
|
menuLabel: "Integrations",
|
|
738
735
|
menuDescription: "Connect MCP tools to the agent",
|
|
739
736
|
title: "Connect integrations",
|
|
740
|
-
description: "
|
|
737
|
+
description: "Browse {{count}} MCP integrations or add a custom server.",
|
|
741
738
|
searchPlaceholder: "Search integrations",
|
|
742
739
|
addYourOwn: "Add your own",
|
|
743
|
-
addSomething: "Add something not listed",
|
|
744
|
-
addSomethingTitle: "Add another integration",
|
|
745
|
-
addSomethingPlaceholder: "Which MCP or provider should we add?",
|
|
746
740
|
noMatches: "No integrations match that search.",
|
|
747
741
|
connected: "Connected",
|
|
748
742
|
configure: "Configure",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { H3Event } from "h3";
|
|
9
9
|
import { getHeader } from "h3";
|
|
10
10
|
|
|
11
|
+
import { applyBuilderUtmTrackingParams } from "../shared/builder-link-tracking.js";
|
|
11
12
|
import {
|
|
12
13
|
getAuthSecret,
|
|
13
14
|
resolveSignupTrackingIdentity,
|
|
@@ -920,6 +921,9 @@ export function buildBuilderCliAuthUrl(
|
|
|
920
921
|
);
|
|
921
922
|
url.searchParams.set("framework", "agent-native");
|
|
922
923
|
applyBuilderConnectTrackingParams(url.searchParams, tracking);
|
|
924
|
+
applyBuilderUtmTrackingParams(url.searchParams, {
|
|
925
|
+
content: cleanTrackingParam(tracking.agentNativeConnectSource) ?? null,
|
|
926
|
+
});
|
|
923
927
|
return url.toString();
|
|
924
928
|
}
|
|
925
929
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBuilderUtmTrackingParams } from "../shared/builder-link-tracking.js";
|
|
1
2
|
import { FeatureNotConfiguredError } from "./credential-provider.js";
|
|
2
3
|
import {
|
|
3
4
|
getBuilderProxyOrigin,
|
|
@@ -356,11 +357,15 @@ function nonEmptyFiles(
|
|
|
356
357
|
|
|
357
358
|
export function builderDesignSystemUrl(designSystemId?: string | null): string {
|
|
358
359
|
const host = trimTrailingSlash(getBuilderAppHost());
|
|
359
|
-
|
|
360
|
+
const url = designSystemId
|
|
360
361
|
? `${host}/app/design-system-intelligence/${encodeURIComponent(
|
|
361
362
|
designSystemId,
|
|
362
363
|
)}`
|
|
363
364
|
: `${host}/app/design-system-intelligence`;
|
|
365
|
+
return withBuilderUtmTrackingParams(url, {
|
|
366
|
+
campaign: "product",
|
|
367
|
+
content: "design_system_intelligence",
|
|
368
|
+
});
|
|
364
369
|
}
|
|
365
370
|
|
|
366
371
|
export function localBuilderDesignSystemId(
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* respond with newline-delimited JSON over chunked HTTP.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { withBuilderUtmTrackingParams } from "../shared/builder-link-tracking.js";
|
|
27
28
|
import { getBuilderApiHost, getBuilderAppHost } from "./builder-browser.js";
|
|
28
29
|
|
|
29
30
|
export interface FusionBranchRef {
|
|
@@ -86,7 +87,10 @@ function fusionUrl(
|
|
|
86
87
|
/** The Builder visual-editor URL for a fusion branch. */
|
|
87
88
|
export function getFusionBranchEditorUrl(ref: FusionBranchRef): string {
|
|
88
89
|
const host = getBuilderAppHost().replace(/\/+$/, "");
|
|
89
|
-
return
|
|
90
|
+
return withBuilderUtmTrackingParams(
|
|
91
|
+
`${host}/app/projects/${encodeURIComponent(ref.projectId)}/${encodeURIComponent(ref.branchName)}`,
|
|
92
|
+
{ campaign: "product", content: "fusion_editor" },
|
|
93
|
+
);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** Public URL for a reserved fusion hosting slug. */
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const BUILDER_UTM_SOURCE = "agent-native";
|
|
2
|
+
export const BUILDER_UTM_MEDIUM = "product";
|
|
3
|
+
export const BUILDER_UTM_CAMPAIGN = "onboarding";
|
|
4
|
+
|
|
5
|
+
export function applyBuilderUtmTrackingParams(
|
|
6
|
+
params: URLSearchParams,
|
|
7
|
+
options: {
|
|
8
|
+
medium?: string;
|
|
9
|
+
campaign?: string;
|
|
10
|
+
content?: string | null;
|
|
11
|
+
} = {},
|
|
12
|
+
): void {
|
|
13
|
+
params.set("utm_source", BUILDER_UTM_SOURCE);
|
|
14
|
+
params.set("utm_medium", options.medium ?? BUILDER_UTM_MEDIUM);
|
|
15
|
+
params.set("utm_campaign", options.campaign ?? BUILDER_UTM_CAMPAIGN);
|
|
16
|
+
if (options.content) params.set("utm_content", options.content);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function withBuilderUtmTrackingParams(
|
|
20
|
+
url: string,
|
|
21
|
+
options: {
|
|
22
|
+
medium?: string;
|
|
23
|
+
campaign?: string;
|
|
24
|
+
content?: string | null;
|
|
25
|
+
} = {},
|
|
26
|
+
): string {
|
|
27
|
+
let parsed: URL;
|
|
28
|
+
try {
|
|
29
|
+
parsed = new URL(url);
|
|
30
|
+
} catch {
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
parsed.hostname !== "builder.io" &&
|
|
35
|
+
parsed.hostname !== "www.builder.io"
|
|
36
|
+
) {
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
applyBuilderUtmTrackingParams(parsed.searchParams, options);
|
|
40
|
+
return parsed.toString();
|
|
41
|
+
}
|
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
export { agentEnv, type EnvVar } from "./agent-env.js";
|
|
8
8
|
export { extractOAuthStateAppId } from "./oauth-state.js";
|
|
9
9
|
export { truncate } from "./truncate.js";
|
|
10
|
+
export { withBuilderUtmTrackingParams } from "./builder-link-tracking.js";
|
|
10
11
|
export {
|
|
11
12
|
llmConnectionTrackingProperties,
|
|
12
13
|
normalizeLlmConnection,
|
|
@@ -230,7 +230,16 @@ membership id when its native update status reports `update-available`.
|
|
|
230
230
|
filters; do not hand-wire custom email routes around that action surface.
|
|
231
231
|
Report PNGs are Playwright captures of the real dashboard route in
|
|
232
232
|
`reportScreenshot=1` mode, authenticated by a short-lived embed-session token
|
|
233
|
-
and embedded inline in email
|
|
233
|
+
and embedded inline in email as ordered CID images. Complete dashboards are
|
|
234
|
+
captured sequentially in bounded panel windows; every window must match the
|
|
235
|
+
panel ids snapshotted at the start, and a failed or mismatched window
|
|
236
|
+
invalidates the entire image set so the scheduler can retry instead of
|
|
237
|
+
sending a partial report. Capture is capped at 10 windows and 14 MiB of raw
|
|
238
|
+
PNG data, and subscriptions are capped at five distinct recipients; use a
|
|
239
|
+
mailing-list address for larger audiences. The serverless capture deadline
|
|
240
|
+
reserves 90 seconds of the 300-second worker budget for cleanup and delivery.
|
|
241
|
+
The ten-minute retry delay is an eligibility floor; the \*/15 sweep runs the
|
|
242
|
+
retry on its first tick after that floor. Netlify builds emit a scheduled
|
|
234
243
|
trigger plus a background worker from
|
|
235
244
|
`scripts/emit-netlify-dashboard-report-cron.ts`, using a per-deploy internal
|
|
236
245
|
token and disabling the in-process interval scheduler on Netlify to avoid
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
} from "@agent-native/core/server";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
MAX_DASHBOARD_REPORT_RECIPIENTS,
|
|
10
|
+
saveDashboardReportSubscription,
|
|
11
|
+
} from "../server/lib/dashboard-report-subscriptions";
|
|
9
12
|
|
|
10
13
|
export default defineAction({
|
|
11
14
|
description:
|
|
@@ -14,7 +17,12 @@ export default defineAction({
|
|
|
14
17
|
id: z.string().optional().describe("Existing subscription ID to update"),
|
|
15
18
|
dashboardId: z.string().describe("Dashboard ID to email"),
|
|
16
19
|
name: z.string().optional().describe("Human-readable subscription name"),
|
|
17
|
-
recipients: z
|
|
20
|
+
recipients: z
|
|
21
|
+
.array(z.string().email())
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe(
|
|
24
|
+
`Email recipients (maximum ${MAX_DASHBOARD_REPORT_RECIPIENTS}; use a mailing-list address for larger audiences)`,
|
|
25
|
+
),
|
|
18
26
|
filters: z
|
|
19
27
|
.record(z.string(), z.string())
|
|
20
28
|
.optional()
|
|
@@ -87,7 +87,7 @@ const DEFAULT_COLORS = [
|
|
|
87
87
|
];
|
|
88
88
|
|
|
89
89
|
const CHART_TOOLTIP_WRAPPER_STYLE: CSSProperties = {
|
|
90
|
-
zIndex:
|
|
90
|
+
zIndex: 280,
|
|
91
91
|
pointerEvents: "none",
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -117,6 +117,7 @@ const CHART_LEGEND_PROPS = {
|
|
|
117
117
|
} as const;
|
|
118
118
|
|
|
119
119
|
const CHART_RESIZE_DEBOUNCE_MS = 50;
|
|
120
|
+
const LEGEND_ACTION_CLOSE_DELAY_MS = 600;
|
|
120
121
|
|
|
121
122
|
type ChartSize = {
|
|
122
123
|
width: number;
|
|
@@ -586,7 +587,7 @@ export function SeriesLegend({
|
|
|
586
587
|
closeTimeoutRef.current = setTimeout(() => {
|
|
587
588
|
setOpenKey(null);
|
|
588
589
|
closeTimeoutRef.current = null;
|
|
589
|
-
},
|
|
590
|
+
}, LEGEND_ACTION_CLOSE_DELAY_MS);
|
|
590
591
|
}, [clearCloseTimeout]);
|
|
591
592
|
|
|
592
593
|
useEffect(() => () => clearCloseTimeout(), [clearCloseTimeout]);
|
|
@@ -674,7 +675,7 @@ export function SeriesLegend({
|
|
|
674
675
|
<PopoverContent
|
|
675
676
|
side="top"
|
|
676
677
|
align="center"
|
|
677
|
-
sideOffset={
|
|
678
|
+
sideOffset={0}
|
|
678
679
|
collisionPadding={12}
|
|
679
680
|
className="w-auto max-w-[calc(100vw-1.5rem)] rounded-lg p-1 shadow-lg"
|
|
680
681
|
onPointerEnter={clearCloseTimeout}
|
|
@@ -943,7 +944,7 @@ export function ChartTooltip({
|
|
|
943
944
|
<div
|
|
944
945
|
ref={boxRef}
|
|
945
946
|
role="tooltip"
|
|
946
|
-
className="fixed z-[
|
|
947
|
+
className="fixed z-[280] min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg pointer-events-none"
|
|
947
948
|
>
|
|
948
949
|
{labelText && (
|
|
949
950
|
<div className="mb-1.5 truncate font-medium text-foreground">
|
|
@@ -148,6 +148,11 @@ import { dashboardExtensionSlotId } from "./extension-slot";
|
|
|
148
148
|
import { interpolate } from "./interpolate";
|
|
149
149
|
import { serializePanelSql } from "./panel-sql";
|
|
150
150
|
import { AddPanelPopover, PanelEditorDialog } from "./PanelEditorDialog";
|
|
151
|
+
import {
|
|
152
|
+
listReportablePanelIds,
|
|
153
|
+
parseReportPanelWindow,
|
|
154
|
+
windowReportPanels,
|
|
155
|
+
} from "./report-panel-window";
|
|
151
156
|
import { SqlChartCard } from "./SqlChartCard";
|
|
152
157
|
import {
|
|
153
158
|
clampDashboardColumns,
|
|
@@ -188,31 +193,6 @@ function groupDashboardTabs(tabs: string[]): {
|
|
|
188
193
|
return { groups, hasNestedTabs };
|
|
189
194
|
}
|
|
190
195
|
|
|
191
|
-
function parseReportPanelLimit(raw: string | null): number | null {
|
|
192
|
-
if (!raw) return null;
|
|
193
|
-
const parsed = Number.parseInt(raw, 10);
|
|
194
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
195
|
-
return Math.min(50, parsed);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function limitReportPanels(panels: SqlPanel[], limit: number | null) {
|
|
199
|
-
if (!limit) return panels;
|
|
200
|
-
const limited: SqlPanel[] = [];
|
|
201
|
-
let chartCount = 0;
|
|
202
|
-
|
|
203
|
-
for (const panel of panels) {
|
|
204
|
-
if (panel.chartType === "section") {
|
|
205
|
-
if (chartCount < limit) limited.push(panel);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (chartCount >= limit) continue;
|
|
209
|
-
chartCount++;
|
|
210
|
-
limited.push(panel);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return limited;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
196
|
function DashboardDropLine({
|
|
217
197
|
slot,
|
|
218
198
|
activeSlot,
|
|
@@ -504,8 +484,11 @@ export default function SqlDashboardPage() {
|
|
|
504
484
|
const dashboardId = searchParams.get("id") || routeId;
|
|
505
485
|
const reportScreenshot = searchParams.get("reportScreenshot") === "1";
|
|
506
486
|
const reportSettingsRequested = searchParams.get("reportSettings") === "1";
|
|
507
|
-
const
|
|
508
|
-
?
|
|
487
|
+
const reportPanelWindow = reportScreenshot
|
|
488
|
+
? parseReportPanelWindow(
|
|
489
|
+
searchParams.get("reportPanelOffset"),
|
|
490
|
+
searchParams.get("reportPanelLimit"),
|
|
491
|
+
)
|
|
509
492
|
: null;
|
|
510
493
|
|
|
511
494
|
const [dashboard, setDashboard] = useState<SqlDashboardConfig | null>(null);
|
|
@@ -1180,9 +1163,9 @@ export default function SqlDashboardPage() {
|
|
|
1180
1163
|
? requestedTab
|
|
1181
1164
|
: tabs[0]
|
|
1182
1165
|
: null;
|
|
1183
|
-
// Report captures
|
|
1184
|
-
// URL intentionally has no `tab` parameter, so do not apply the
|
|
1185
|
-
// first-tab selection while rendering the screenshot surface.
|
|
1166
|
+
// Report captures cover the complete dashboard across ordered windows. The
|
|
1167
|
+
// report URL intentionally has no `tab` parameter, so do not apply the
|
|
1168
|
+
// normal first-tab selection while rendering the screenshot surface.
|
|
1186
1169
|
const activeTab = reportScreenshot ? null : selectedTab;
|
|
1187
1170
|
const groupedTabs = useMemo(() => groupDashboardTabs(tabs), [tabs]);
|
|
1188
1171
|
const activeTabGroup = activeTab
|
|
@@ -1221,8 +1204,8 @@ export default function SqlDashboardPage() {
|
|
|
1221
1204
|
const tabPanels = activeTab
|
|
1222
1205
|
? dashboard.panels.filter((p) => !p.tab || p.tab === activeTab)
|
|
1223
1206
|
: dashboard.panels;
|
|
1224
|
-
return
|
|
1225
|
-
}, [dashboard, activeTab,
|
|
1207
|
+
return windowReportPanels(tabPanels, reportPanelWindow);
|
|
1208
|
+
}, [dashboard, activeTab, reportPanelWindow]);
|
|
1226
1209
|
|
|
1227
1210
|
// Group panels into "section blocks": each section starts a new block whose
|
|
1228
1211
|
// grid uses the section's `columns` (falling back to the dashboard default).
|
|
@@ -1713,6 +1696,11 @@ export default function SqlDashboardPage() {
|
|
|
1713
1696
|
className="space-y-4"
|
|
1714
1697
|
data-dashboard-report-capture
|
|
1715
1698
|
data-dashboard-report-ready={loaded && dashboard ? "true" : "false"}
|
|
1699
|
+
data-dashboard-report-panel-ids={
|
|
1700
|
+
reportScreenshot
|
|
1701
|
+
? JSON.stringify(listReportablePanelIds(visiblePanels))
|
|
1702
|
+
: undefined
|
|
1703
|
+
}
|
|
1716
1704
|
>
|
|
1717
1705
|
{hiddenAt ? (
|
|
1718
1706
|
<div className="flex flex-wrap items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-200">
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SqlPanel } from "./types";
|
|
2
|
+
|
|
3
|
+
export const REPORT_PANEL_CHUNK_SIZE = 8;
|
|
4
|
+
|
|
5
|
+
export function listReportablePanelIds(panels: SqlPanel[]): string[] {
|
|
6
|
+
return panels
|
|
7
|
+
.filter((panel) => panel.chartType !== "section")
|
|
8
|
+
.map((panel) => panel.id);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseReportPanelWindow(
|
|
12
|
+
offsetRaw: string | null,
|
|
13
|
+
limitRaw: string | null,
|
|
14
|
+
): { offset: number; limit: number } | null {
|
|
15
|
+
if (!offsetRaw || !limitRaw) return null;
|
|
16
|
+
const offset = Number.parseInt(offsetRaw, 10);
|
|
17
|
+
const limit = Number.parseInt(limitRaw, 10);
|
|
18
|
+
if (!Number.isFinite(offset) || offset < 0) return null;
|
|
19
|
+
if (!Number.isFinite(limit) || limit <= 0) return null;
|
|
20
|
+
return { offset, limit: Math.min(REPORT_PANEL_CHUNK_SIZE, limit) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function countReportablePanels(panels: SqlPanel[]): number {
|
|
24
|
+
return listReportablePanelIds(panels).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A report chunk is indexed by chart panels, not section headers. When a
|
|
29
|
+
* chunk starts inside a section, repeat that section header so each image is
|
|
30
|
+
* readable on its own while preserving the dashboard's panel order.
|
|
31
|
+
*/
|
|
32
|
+
export function windowReportPanels(
|
|
33
|
+
panels: SqlPanel[],
|
|
34
|
+
window: { offset: number; limit: number } | null,
|
|
35
|
+
): SqlPanel[] {
|
|
36
|
+
if (!window) return panels;
|
|
37
|
+
|
|
38
|
+
const end = window.offset + window.limit;
|
|
39
|
+
let chartIndex = 0;
|
|
40
|
+
let activeSection: SqlPanel | null = null;
|
|
41
|
+
let includedFirstChart = false;
|
|
42
|
+
const selected: SqlPanel[] = [];
|
|
43
|
+
|
|
44
|
+
for (const panel of panels) {
|
|
45
|
+
if (panel.chartType === "section") {
|
|
46
|
+
activeSection = panel;
|
|
47
|
+
if (includedFirstChart && chartIndex < end) selected.push(panel);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const include = chartIndex >= window.offset && chartIndex < end;
|
|
52
|
+
if (include) {
|
|
53
|
+
if (!includedFirstChart && activeSection) selected.push(activeSection);
|
|
54
|
+
selected.push(panel);
|
|
55
|
+
includedFirstChart = true;
|
|
56
|
+
}
|
|
57
|
+
chartIndex++;
|
|
58
|
+
if (chartIndex >= end) break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
@@ -480,7 +480,7 @@
|
|
|
480
480
|
"chartType": "area",
|
|
481
481
|
"source": "first-party",
|
|
482
482
|
"width": 2,
|
|
483
|
-
"sql": "WITH all_users AS (SELECT NULLIF(user_key, '') AS user_key, event_date, user_id, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' 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'))), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND (a.event_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) GROUP BY 1, 2 ORDER BY date, template",
|
|
483
|
+
"sql": "WITH all_users AS (SELECT NULLIF(user_key, '') AS user_key, event_date, user_id, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' 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')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND (a.event_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) GROUP BY 1, 2 ORDER BY date, template",
|
|
484
484
|
"config": {
|
|
485
485
|
"xKey": "date",
|
|
486
486
|
"yKey": "users",
|
|
@@ -491,7 +491,7 @@
|
|
|
491
491
|
"valueKey": "users"
|
|
492
492
|
},
|
|
493
493
|
"stacked": true,
|
|
494
|
-
"description": "Daily signed-in visitors who are
|
|
494
|
+
"description": "Daily signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that day. Docs traffic and unknown template are excluded."
|
|
495
495
|
}
|
|
496
496
|
},
|
|
497
497
|
{
|
|
@@ -500,7 +500,7 @@
|
|
|
500
500
|
"chartType": "bar",
|
|
501
501
|
"source": "first-party",
|
|
502
502
|
"width": 1,
|
|
503
|
-
"sql": "WITH all_users AS (SELECT NULLIF(user_key, '') AS user_key, event_date, user_id, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' 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'))), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND (a.event_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) GROUP BY 1, 2 ORDER BY date, template",
|
|
503
|
+
"sql": "WITH all_users AS (SELECT NULLIF(user_key, '') AS user_key, event_date, user_id, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' 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')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND (a.event_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND a.event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))) GROUP BY 1, 2 ORDER BY date, template",
|
|
504
504
|
"config": {
|
|
505
505
|
"xKey": "date",
|
|
506
506
|
"yKey": "users",
|
|
@@ -511,7 +511,7 @@
|
|
|
511
511
|
"valueKey": "users"
|
|
512
512
|
},
|
|
513
513
|
"stacked": true,
|
|
514
|
-
"description": "Weekly distinct signed-in visitors who are
|
|
514
|
+
"description": "Weekly distinct signed-in visitors who are not on their first active day observed in the previous 365 days, stacked by inferred template/app used that week. Weeks start Monday; docs traffic and unknown template are excluded."
|
|
515
515
|
}
|
|
516
516
|
},
|
|
517
517
|
{
|
|
@@ -51,7 +51,6 @@ export async function runDashboardReportsOnce(): Promise<{
|
|
|
51
51
|
() =>
|
|
52
52
|
sendDashboardReportSubscription(sub, {
|
|
53
53
|
skipEmailWithoutScreenshot: retryAt !== null,
|
|
54
|
-
allowLimitedFallback: retryAt === null,
|
|
55
54
|
}),
|
|
56
55
|
);
|
|
57
56
|
if (!result.screenshotAttached) {
|
|
@@ -79,13 +78,7 @@ export async function runDashboardReportsOnce(): Promise<{
|
|
|
79
78
|
await markDashboardReportResult(sub, "error", message);
|
|
80
79
|
continue;
|
|
81
80
|
}
|
|
82
|
-
|
|
83
|
-
const detail = `sent with ${result.screenshotMode} screenshot; earlier attempts failed: ${result.screenshotError}`;
|
|
84
|
-
console.warn(`[dashboard-report] Subscription ${sub.id} ${detail}`);
|
|
85
|
-
await markDashboardReportResult(sub, "success", detail);
|
|
86
|
-
} else {
|
|
87
|
-
await markDashboardReportResult(sub, "success");
|
|
88
|
-
}
|
|
81
|
+
await markDashboardReportResult(sub, "success");
|
|
89
82
|
} catch (err: any) {
|
|
90
83
|
failed++;
|
|
91
84
|
const message = err?.message ?? String(err);
|
|
@@ -6,6 +6,10 @@ import { and, asc, eq, isNull, lte, or, sql } from "drizzle-orm";
|
|
|
6
6
|
import { getDb, schema } from "../db/index.js";
|
|
7
7
|
import { loadDashboardSeed } from "./dashboard-seeds";
|
|
8
8
|
import { getDashboard } from "./dashboards-store";
|
|
9
|
+
import {
|
|
10
|
+
FIRST_PARTY_DASHBOARD_ID,
|
|
11
|
+
repairFirstPartyRecurringUserPanels,
|
|
12
|
+
} from "./first-party-metric-catalog";
|
|
9
13
|
|
|
10
14
|
export interface ReportSubscriptionInput {
|
|
11
15
|
id?: string;
|
|
@@ -43,6 +47,8 @@ export interface AccessCtx {
|
|
|
43
47
|
orgId: string | null;
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
export const MAX_DASHBOARD_REPORT_RECIPIENTS = 5;
|
|
51
|
+
|
|
46
52
|
export interface ReportDashboard {
|
|
47
53
|
id: string;
|
|
48
54
|
title: string;
|
|
@@ -55,10 +61,14 @@ export async function getReportDashboard(
|
|
|
55
61
|
): Promise<ReportDashboard | null> {
|
|
56
62
|
const dashboard = await getDashboard(dashboardId, ctx);
|
|
57
63
|
if (dashboard?.kind === "sql") {
|
|
64
|
+
const config =
|
|
65
|
+
dashboardId === FIRST_PARTY_DASHBOARD_ID
|
|
66
|
+
? repairFirstPartyRecurringUserPanels(dashboard.config).config
|
|
67
|
+
: dashboard.config;
|
|
58
68
|
return {
|
|
59
69
|
id: dashboard.id,
|
|
60
70
|
title: dashboard.title,
|
|
61
|
-
config
|
|
71
|
+
config,
|
|
62
72
|
};
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -78,6 +88,19 @@ function nowIso(): string {
|
|
|
78
88
|
return new Date().toISOString();
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
const DASHBOARD_REPORT_ERROR_MAX_LENGTH = 2_000;
|
|
92
|
+
const DASHBOARD_REPORT_ERROR_OMISSION = "\n… [truncated] …\n";
|
|
93
|
+
|
|
94
|
+
export function truncateDashboardReportError(error: string): string {
|
|
95
|
+
if (error.length <= DASHBOARD_REPORT_ERROR_MAX_LENGTH) return error;
|
|
96
|
+
|
|
97
|
+
const retainedLength =
|
|
98
|
+
DASHBOARD_REPORT_ERROR_MAX_LENGTH - DASHBOARD_REPORT_ERROR_OMISSION.length;
|
|
99
|
+
const prefixLength = Math.ceil(retainedLength / 2);
|
|
100
|
+
const suffixLength = Math.floor(retainedLength / 2);
|
|
101
|
+
return `${error.slice(0, prefixLength)}${DASHBOARD_REPORT_ERROR_OMISSION}${error.slice(-suffixLength)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
function safeJsonParse<T>(raw: unknown, fallback: T): T {
|
|
82
105
|
if (typeof raw !== "string") return fallback;
|
|
83
106
|
try {
|
|
@@ -88,7 +111,9 @@ function safeJsonParse<T>(raw: unknown, fallback: T): T {
|
|
|
88
111
|
}
|
|
89
112
|
}
|
|
90
113
|
|
|
91
|
-
function
|
|
114
|
+
export function normalizeDashboardReportRecipients(
|
|
115
|
+
recipients: string[],
|
|
116
|
+
): string[] {
|
|
92
117
|
const seen = new Set<string>();
|
|
93
118
|
const normalized: string[] = [];
|
|
94
119
|
for (const raw of recipients) {
|
|
@@ -97,6 +122,14 @@ function normalizeRecipients(recipients: string[]): string[] {
|
|
|
97
122
|
seen.add(email);
|
|
98
123
|
normalized.push(email);
|
|
99
124
|
}
|
|
125
|
+
if (normalized.length === 0) {
|
|
126
|
+
throw new Error("At least one recipient is required");
|
|
127
|
+
}
|
|
128
|
+
if (normalized.length > MAX_DASHBOARD_REPORT_RECIPIENTS) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Dashboard reports support at most ${MAX_DASHBOARD_REPORT_RECIPIENTS} recipients; use a mailing-list address for larger audiences`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
100
133
|
return normalized;
|
|
101
134
|
}
|
|
102
135
|
|
|
@@ -239,6 +272,8 @@ export function lastDailyRunAt(
|
|
|
239
272
|
}
|
|
240
273
|
|
|
241
274
|
const DASHBOARD_REPORT_RETRY_WINDOW_MS = 60 * 60 * 1000;
|
|
275
|
+
// This is the earliest nextRunAt. The generated */15 cron means the actual
|
|
276
|
+
// retry occurs on the first sweep after this floor, not exactly ten minutes later.
|
|
242
277
|
const DASHBOARD_REPORT_RETRY_DELAY_MS = 10 * 60 * 1000;
|
|
243
278
|
|
|
244
279
|
export function dashboardReportRetryAt(
|
|
@@ -348,10 +383,7 @@ export async function saveDashboardReportSubscription(
|
|
|
348
383
|
throw Object.assign(new Error("Dashboard not found"), { statusCode: 404 });
|
|
349
384
|
}
|
|
350
385
|
|
|
351
|
-
const recipients =
|
|
352
|
-
if (recipients.length === 0) {
|
|
353
|
-
throw new Error("At least one recipient is required");
|
|
354
|
-
}
|
|
386
|
+
const recipients = normalizeDashboardReportRecipients(input.recipients);
|
|
355
387
|
|
|
356
388
|
const timeOfDay = assertTimeOfDay(input.timeOfDay);
|
|
357
389
|
const timezone = assertTimezone(input.timezone);
|
|
@@ -539,7 +571,7 @@ export async function markDashboardReportResult(
|
|
|
539
571
|
.update(schema.dashboardReportSubscriptions)
|
|
540
572
|
.set({
|
|
541
573
|
lastStatus: status,
|
|
542
|
-
lastError: error ? error
|
|
574
|
+
lastError: error ? truncateDashboardReportError(error) : null,
|
|
543
575
|
nextRunAt: sub.enabled
|
|
544
576
|
? (options?.nextRunAt ??
|
|
545
577
|
nextDailyRunAt(sub.timeOfDay, sub.timezone, new Date()))
|