mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
918 lines
36 KiB
TypeScript
918 lines
36 KiB
TypeScript
import { MakeLogicType, actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
import { router, urlToAction } from 'kea-router'
|
|
|
|
import api, { ApiConfig } from 'lib/api'
|
|
import { SetupTaskId, globalSetupLogic } from 'lib/components/ProductSetup'
|
|
import { toast } from 'lib/elements/Toast/Toast'
|
|
import { sqlEditorLogic } from 'scenes/data-warehouse/editor/sqlEditorLogic'
|
|
import { SQLEditorMode } from 'scenes/data-warehouse/editor/sqlEditorModes'
|
|
import { Scene } from 'scenes/sceneTypes'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import {
|
|
DataTableNode,
|
|
DataVisualizationNode,
|
|
EndpointRunRequest,
|
|
InsightVizNode,
|
|
Node,
|
|
NodeKind,
|
|
} from '~/queries/schema/schema-general'
|
|
import { isInsightsQLQuery, isInsightQueryNode } from '~/queries/utils'
|
|
import { Breadcrumb, ChartDisplayType, EndpointType, EndpointVersionType } from '~/types'
|
|
|
|
import { endpointLogic } from './endpointLogic'
|
|
import { endpointsMaterializationSuggestionCreate } from './generated/api'
|
|
import type { EndpointMaterializationSuggestionApi } from './generated/api.schemas'
|
|
|
|
// Default data freshness when none is set on the endpoint version (must match backend DEFAULT_DATA_FRESHNESS_SECONDS)
|
|
const DEFAULT_DATA_FRESHNESS_SECONDS = 86400
|
|
|
|
// Query types that support user-configurable breakdown filtering
|
|
const BREAKDOWN_SUPPORTED_QUERY_TYPES = new Set([NodeKind.TrendsQuery, NodeKind.RetentionQuery])
|
|
|
|
export function extractBreakdownPropertyNames(query: unknown): string[] {
|
|
// Single source of truth for reading breakdown property names out of a query on the
|
|
// frontend — mirrors the backend's canonical extractor, including the legacy list form.
|
|
if (!query || typeof query !== 'object') {
|
|
return []
|
|
}
|
|
const breakdownFilter = (
|
|
query as { breakdownFilter?: { breakdown?: unknown; breakdowns?: { property?: unknown }[] } | null }
|
|
).breakdownFilter
|
|
if (!breakdownFilter) {
|
|
return []
|
|
}
|
|
// Numeric entries (e.g. legacy cohort breakdowns) are stringified like the backend's str(name)
|
|
const asName = (value: unknown): string | null =>
|
|
(typeof value === 'string' || typeof value === 'number') && value ? String(value) : null
|
|
const legacy = breakdownFilter.breakdown
|
|
if (Array.isArray(legacy)) {
|
|
return legacy.map(asName).filter((p): p is string => p !== null)
|
|
}
|
|
const legacyName = asName(legacy)
|
|
if (legacyName !== null) {
|
|
return [legacyName]
|
|
}
|
|
return (breakdownFilter.breakdowns ?? []).map((b) => asName(b?.property)).filter((p): p is string => p !== null)
|
|
}
|
|
|
|
export function generateEndpointPayload(endpoint: EndpointVersionType | null): Record<string, any> {
|
|
if (!endpoint) {
|
|
return {}
|
|
}
|
|
|
|
const query = endpoint.query
|
|
const isMaterialized = endpoint.is_materialized
|
|
const queryKind = query?.kind
|
|
|
|
if (queryKind === NodeKind.InsightsQLQuery) {
|
|
// InsightsQL: include variables with default values
|
|
const variables = query.variables || {}
|
|
const entries = Object.entries(variables)
|
|
|
|
if (entries.length === 0) {
|
|
return {}
|
|
}
|
|
|
|
const variablesValues: Record<string, any> = {}
|
|
entries.forEach(([_, value]: [string, any]) => {
|
|
variablesValues[value.code_name] = value.value
|
|
})
|
|
|
|
return { variables: variablesValues }
|
|
}
|
|
|
|
if (isInsightQueryNode(query)) {
|
|
// Insight query - build variables based on what's available
|
|
const variablesValues: Record<string, any> = {}
|
|
|
|
// Only include breakdown for query types that support it
|
|
if (queryKind && BREAKDOWN_SUPPORTED_QUERY_TYPES.has(queryKind as NodeKind)) {
|
|
const breakdownNames = extractBreakdownPropertyNames(query)
|
|
// The legacy playground payload only supports a single breakdown variable.
|
|
if (breakdownNames.length === 1) {
|
|
variablesValues[breakdownNames[0]] = ''
|
|
}
|
|
}
|
|
|
|
// Non-materialized also supports date filtering
|
|
if (!isMaterialized) {
|
|
variablesValues['date_from'] = '-7d'
|
|
variablesValues['date_to'] = ''
|
|
}
|
|
|
|
if (Object.keys(variablesValues).length > 0) {
|
|
return { variables: variablesValues }
|
|
}
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
function generateInitialPayloadJson(endpoint: EndpointVersionType | null): string {
|
|
return JSON.stringify(generateEndpointPayload(endpoint), null, 2)
|
|
}
|
|
|
|
export enum EndpointTab {
|
|
QUERY = 'query',
|
|
CONFIGURATION = 'configuration',
|
|
VERSIONS = 'versions',
|
|
PLAYGROUND = 'playground',
|
|
LOGS = 'logs',
|
|
HISTORY = 'history',
|
|
}
|
|
|
|
export interface MaterializationPreview {
|
|
can_materialize: boolean
|
|
reason: string | null
|
|
transformed_query: string | null
|
|
execution_query: string | null
|
|
display_execution_query: string | null
|
|
range_pairs: { column: string; variables: string[]; bucket_fn: string }[]
|
|
aggregates: { expression: string; reaggregate_fn: string | null }[]
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface endpointSceneLogicValues {
|
|
endpoint: EndpointVersionType | null // endpointLogic
|
|
endpointLoading: boolean // endpointLogic
|
|
activeTab: EndpointTab
|
|
breadcrumbs: Breadcrumb[]
|
|
bucketOverrides: Record<string, string>
|
|
currentQuery: Node | null
|
|
dataFreshness: number
|
|
debugInfoExpanded: boolean
|
|
debugMode: boolean
|
|
endpointName: string | null
|
|
endpointResult: string | null
|
|
endpointResultLoading: boolean
|
|
isMaterialized: boolean | null
|
|
localQuery: Node | null
|
|
materializationPreview: MaterializationPreview | null
|
|
materializationPreviewLoading: boolean
|
|
materializationSuggestion: EndpointMaterializationSuggestionApi | null
|
|
materializationSuggestionLoading: boolean
|
|
materializationSuggestionModalOpen: boolean
|
|
optionalBreakdownProperties: string[]
|
|
payloadJson: string
|
|
payloadJsonError: string | null
|
|
queryToRender: Node | null
|
|
suggestionMatchesCurrentQuery: boolean
|
|
viewingVersion: EndpointVersionType | null
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface endpointSceneLogicActions {
|
|
clearMaterializationStatus: () => {
|
|
value: true
|
|
} // endpointLogic
|
|
loadEndpoint: (name: string) => string // endpointLogic
|
|
loadEndpointSuccess: (
|
|
endpoint: EndpointVersionType | null,
|
|
payload?: string | undefined
|
|
) => {
|
|
endpoint: EndpointVersionType | null
|
|
payload?: string
|
|
} // endpointLogic
|
|
loadVersions: (name: string) => string // endpointLogic
|
|
setEndpointDescription: (endpointDescription: string | null) => {
|
|
endpointDescription: string | null
|
|
} // endpointLogic
|
|
updateEndpointSuccess: (
|
|
response: any,
|
|
endpointName: string,
|
|
options?:
|
|
| {
|
|
showViewButton?: boolean
|
|
version?: number
|
|
}
|
|
| undefined
|
|
) => {
|
|
endpointName: string
|
|
options:
|
|
| {
|
|
showViewButton?: boolean | undefined
|
|
version?: number | undefined
|
|
}
|
|
| undefined
|
|
response: any
|
|
} // endpointLogic
|
|
applyMaterializationSuggestion: () => {
|
|
value: true
|
|
}
|
|
closeMaterializationSuggestionModal: () => {
|
|
value: true
|
|
}
|
|
keepSqlEditorMounted: (editorTabId: string) => {
|
|
editorTabId: string
|
|
}
|
|
loadEndpointResult: ({ name, data }: { data: EndpointRunRequest; name: string }) => {
|
|
name: string
|
|
data: EndpointRunRequest
|
|
}
|
|
loadEndpointResultFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadEndpointResultSuccess: (
|
|
endpointResult: string,
|
|
payload?: {
|
|
name: string
|
|
data: EndpointRunRequest
|
|
}
|
|
) => {
|
|
endpointResult: string
|
|
payload?: {
|
|
name: string
|
|
data: EndpointRunRequest
|
|
}
|
|
}
|
|
loadMaterializationPreview: () => {
|
|
value: true
|
|
}
|
|
loadMaterializationPreviewFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadMaterializationPreviewSuccess: (
|
|
materializationPreview: {
|
|
aggregates: {
|
|
expression: string
|
|
reaggregate_fn: string | null
|
|
}[]
|
|
can_materialize: boolean
|
|
range_pairs: {
|
|
bucket_fn: string
|
|
column: string
|
|
variables: string[]
|
|
}[]
|
|
reason: string | null
|
|
transformed_query: string | null
|
|
} | null,
|
|
payload?: {
|
|
value: true
|
|
}
|
|
) => {
|
|
materializationPreview: {
|
|
aggregates: {
|
|
expression: string
|
|
reaggregate_fn: string | null
|
|
}[]
|
|
can_materialize: boolean
|
|
range_pairs: {
|
|
bucket_fn: string
|
|
column: string
|
|
variables: string[]
|
|
}[]
|
|
reason: string | null
|
|
transformed_query: string | null
|
|
} | null
|
|
payload?: {
|
|
value: true
|
|
}
|
|
}
|
|
loadMaterializationSuggestion: () => any
|
|
loadMaterializationSuggestionFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadMaterializationSuggestionSuccess: (
|
|
materializationSuggestion: EndpointMaterializationSuggestionApi | null,
|
|
payload?: any
|
|
) => {
|
|
materializationSuggestion: EndpointMaterializationSuggestionApi | null
|
|
payload?: any
|
|
}
|
|
openMaterializationSuggestionModal: () => {
|
|
value: true
|
|
}
|
|
regenerateMaterializationSuggestion: () => {
|
|
value: true
|
|
}
|
|
resetBucketOverrides: (overrides: Record<string, string>) => {
|
|
overrides: Record<string, string>
|
|
}
|
|
resetOptionalBreakdownProperties: (props: string[]) => {
|
|
props: string[]
|
|
}
|
|
setActiveTab: (tab: EndpointTab) => {
|
|
tab: EndpointTab
|
|
}
|
|
setBucketOverride: (
|
|
column: string,
|
|
bucketFn: string
|
|
) => {
|
|
bucketFn: string
|
|
column: string
|
|
}
|
|
setDataFreshness: (dataFreshness: number) => {
|
|
dataFreshness: number
|
|
}
|
|
setDebugInfoExpanded: (debugInfoExpanded: boolean) => {
|
|
debugInfoExpanded: boolean
|
|
}
|
|
setDebugMode: (debugMode: boolean) => {
|
|
debugMode: boolean
|
|
}
|
|
setEndpointName: (name: string | null) => {
|
|
name: string | null
|
|
}
|
|
setIsMaterialized: (isMaterialized: boolean | null) => {
|
|
isMaterialized: boolean | null
|
|
}
|
|
setLocalQuery: (query: Node | null) => {
|
|
query: Node<Record<string, any>> | null
|
|
}
|
|
setPayloadJson: (value: string) => {
|
|
value: string
|
|
}
|
|
setPayloadJsonError: (error: string | null) => {
|
|
error: string | null
|
|
}
|
|
setViewingVersion: (version: EndpointVersionType | null) => {
|
|
version: EndpointVersionType | null
|
|
}
|
|
toggleBreakdownOptional: (property: string) => {
|
|
property: string
|
|
}
|
|
toggleMaterializationFromMenu: () => {
|
|
value: true
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface endpointSceneLogicMeta {
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
currentQuery: (
|
|
localQuery: Node<Record<string, any>> | null,
|
|
endpoint: EndpointVersionType | null,
|
|
viewingVersion: EndpointVersionType | null
|
|
) => Node | null
|
|
suggestionMatchesCurrentQuery: (
|
|
materializationSuggestion: EndpointMaterializationSuggestionApi | null,
|
|
currentQuery: Node<Record<string, any>> | null
|
|
) => boolean
|
|
queryToRender: (currentQuery: Node<Record<string, any>> | null) => Node | null
|
|
breadcrumbs: (endpoint: EndpointVersionType | null) => Breadcrumb[]
|
|
}
|
|
}
|
|
|
|
export type endpointSceneLogicType = MakeLogicType<
|
|
endpointSceneLogicValues,
|
|
endpointSceneLogicActions,
|
|
Record<string, any>,
|
|
endpointSceneLogicMeta
|
|
>
|
|
|
|
export const endpointSceneLogic = kea<endpointSceneLogicType>([
|
|
path(['products', 'endpoints', 'frontend', 'endpointSceneLogic']),
|
|
connect(() => ({
|
|
actions: [
|
|
endpointLogic(),
|
|
[
|
|
'loadEndpoint',
|
|
'loadEndpointSuccess',
|
|
'loadVersions',
|
|
'setEndpointDescription',
|
|
'clearMaterializationStatus',
|
|
'updateEndpointSuccess',
|
|
],
|
|
],
|
|
values: [endpointLogic(), ['endpoint', 'endpointLoading']],
|
|
})),
|
|
actions({
|
|
setLocalQuery: (query: Node | null) => ({ query }),
|
|
setActiveTab: (tab: EndpointTab) => ({ tab }),
|
|
setPayloadJson: (value: string) => ({ value }),
|
|
setPayloadJsonError: (error: string | null) => ({ error }),
|
|
setDataFreshness: (dataFreshness: number) => ({ dataFreshness }),
|
|
setIsMaterialized: (isMaterialized: boolean | null) => ({ isMaterialized }),
|
|
setEndpointName: (name: string | null) => ({ name }),
|
|
setViewingVersion: (version: EndpointVersionType | null) => ({ version }),
|
|
setBucketOverride: (column: string, bucketFn: string) => ({ column, bucketFn }),
|
|
resetBucketOverrides: (overrides: Record<string, string>) => ({ overrides }),
|
|
toggleBreakdownOptional: (property: string) => ({ property }),
|
|
resetOptionalBreakdownProperties: (props: string[]) => ({ props }),
|
|
setDebugMode: (debugMode: boolean) => ({ debugMode }),
|
|
setDebugInfoExpanded: (debugInfoExpanded: boolean) => ({ debugInfoExpanded }),
|
|
loadMaterializationPreview: true,
|
|
keepSqlEditorMounted: (editorTabId: string) => ({ editorTabId }),
|
|
toggleMaterializationFromMenu: true,
|
|
openMaterializationSuggestionModal: true,
|
|
closeMaterializationSuggestionModal: true,
|
|
regenerateMaterializationSuggestion: true,
|
|
applyMaterializationSuggestion: true,
|
|
}),
|
|
reducers({
|
|
localQuery: [
|
|
null as Node | null,
|
|
{
|
|
setLocalQuery: (_, { query }) => query,
|
|
loadEndpointSuccess: () => null,
|
|
},
|
|
],
|
|
activeTab: [
|
|
EndpointTab.QUERY as EndpointTab,
|
|
{
|
|
setActiveTab: (_, { tab }) => tab,
|
|
loadEndpoint: () => EndpointTab.QUERY,
|
|
},
|
|
],
|
|
payloadJson: [
|
|
'' as string,
|
|
{
|
|
setPayloadJson: (_, { value }) => value,
|
|
loadEndpoint: () => '',
|
|
},
|
|
],
|
|
payloadJsonError: [
|
|
null as string | null,
|
|
{
|
|
setPayloadJsonError: (_, { error }) => error,
|
|
setPayloadJson: () => null,
|
|
loadEndpoint: () => null,
|
|
updateEndpointSuccess: () => null,
|
|
},
|
|
],
|
|
dataFreshness: [
|
|
DEFAULT_DATA_FRESHNESS_SECONDS as number,
|
|
{
|
|
setDataFreshness: (_, { dataFreshness }) => dataFreshness,
|
|
loadEndpointSuccess: (_, { endpoint }) =>
|
|
endpoint?.data_freshness_seconds ?? DEFAULT_DATA_FRESHNESS_SECONDS,
|
|
},
|
|
],
|
|
isMaterialized: [
|
|
true as boolean | null,
|
|
{
|
|
setIsMaterialized: (_, { isMaterialized }) => isMaterialized,
|
|
loadEndpointSuccess: (_, { endpoint }) => endpoint?.is_materialized ?? null,
|
|
loadEndpoint: () => null,
|
|
},
|
|
],
|
|
endpointName: [
|
|
null as string | null,
|
|
{
|
|
setEndpointName: (_, { name }) => name,
|
|
},
|
|
],
|
|
viewingVersion: [
|
|
null as EndpointVersionType | null,
|
|
{
|
|
setViewingVersion: (_, { version }) => version,
|
|
// Reset when switching endpoints; loadEndpointSuccess listener restores from URL if needed
|
|
loadEndpoint: () => null,
|
|
},
|
|
],
|
|
debugMode: [
|
|
false,
|
|
{
|
|
setDebugMode: (_, { debugMode }: { debugMode: boolean }) => debugMode,
|
|
loadEndpoint: () => false,
|
|
},
|
|
],
|
|
debugInfoExpanded: [
|
|
false,
|
|
{
|
|
setDebugInfoExpanded: (_, { debugInfoExpanded }: { debugInfoExpanded: boolean }) => debugInfoExpanded,
|
|
loadEndpoint: () => false,
|
|
},
|
|
],
|
|
bucketOverrides: [
|
|
{} as Record<string, string>,
|
|
{
|
|
setBucketOverride: (state, { column, bucketFn }) => ({ ...state, [column]: bucketFn }),
|
|
resetBucketOverrides: (_, { overrides }) => overrides,
|
|
loadEndpointSuccess: (_, { endpoint }) => endpoint?.bucket_overrides ?? {},
|
|
},
|
|
],
|
|
optionalBreakdownProperties: [
|
|
[] as string[],
|
|
{
|
|
toggleBreakdownOptional: (state, { property }) =>
|
|
state.includes(property) ? state.filter((p) => p !== property) : [...state, property],
|
|
resetOptionalBreakdownProperties: (_, { props }) => props,
|
|
loadEndpointSuccess: (_, { endpoint }) => endpoint?.optional_breakdown_properties ?? [],
|
|
},
|
|
],
|
|
// Clear stale playground results when switching endpoints
|
|
endpointResult: [
|
|
null as string | null,
|
|
{
|
|
loadEndpoint: () => null,
|
|
updateEndpointSuccess: () => null,
|
|
},
|
|
],
|
|
// Clear stale materialization preview when switching endpoints
|
|
materializationPreview: [
|
|
null as MaterializationPreview | null,
|
|
{
|
|
loadEndpoint: () => null,
|
|
},
|
|
],
|
|
materializationSuggestionModalOpen: [
|
|
false,
|
|
{
|
|
openMaterializationSuggestionModal: () => true,
|
|
closeMaterializationSuggestionModal: () => false,
|
|
loadEndpoint: () => false,
|
|
},
|
|
],
|
|
// Cached across modal open/close (each request is a full LLM round-trip); cleared when
|
|
// switching endpoints or when the endpoint is updated (the saved query may have changed)
|
|
materializationSuggestion: [
|
|
null as EndpointMaterializationSuggestionApi | null,
|
|
{
|
|
loadEndpoint: () => null,
|
|
updateEndpointSuccess: () => null,
|
|
},
|
|
],
|
|
}),
|
|
loaders(({ values }) => ({
|
|
materializationSuggestion: {
|
|
__default: null as EndpointMaterializationSuggestionApi | null,
|
|
loadMaterializationSuggestion: async () => {
|
|
const endpoint = values.endpoint
|
|
if (!endpoint?.name) {
|
|
return null
|
|
}
|
|
return await endpointsMaterializationSuggestionCreate(
|
|
String(ApiConfig.getCurrentTeamId()),
|
|
endpoint.name,
|
|
{}
|
|
)
|
|
},
|
|
},
|
|
materializationPreview: {
|
|
__default: null as MaterializationPreview | null,
|
|
loadMaterializationPreview: async () => {
|
|
const endpoint = values.endpoint
|
|
if (!endpoint?.name) {
|
|
return null
|
|
}
|
|
const version = values.viewingVersion?.version
|
|
const overrides = Object.keys(values.bucketOverrides).length > 0 ? values.bucketOverrides : undefined
|
|
return await api.endpoint.getMaterializationPreview(endpoint.name, version, overrides)
|
|
},
|
|
},
|
|
endpointResult: {
|
|
__default: null as string | null,
|
|
loadEndpointResult: async ({ name, data }: { name: string; data: EndpointRunRequest }) => {
|
|
try {
|
|
const result = await api.endpoint.run(name, data)
|
|
if (result && typeof result === 'object' && 'datastore' in result) {
|
|
const { datastore, ...rest } = result as any
|
|
return JSON.stringify(rest, null, 2)
|
|
}
|
|
return JSON.stringify(result, null, 2)
|
|
} catch (error: any) {
|
|
const errorResponse = {
|
|
error: true,
|
|
message: error.message || 'Unknown error',
|
|
status: error.status,
|
|
detail: error.detail || error.data,
|
|
}
|
|
return JSON.stringify(errorResponse, null, 2)
|
|
}
|
|
},
|
|
},
|
|
})),
|
|
selectors({
|
|
currentQuery: [
|
|
(s) => [s.localQuery, s.endpoint, s.viewingVersion],
|
|
(
|
|
localQuery: Node | null,
|
|
endpoint: EndpointType | null,
|
|
viewingVersion: EndpointVersionType | null
|
|
): Node | null => localQuery || viewingVersion?.query || endpoint?.query || null,
|
|
],
|
|
suggestionMatchesCurrentQuery: [
|
|
(s) => [s.materializationSuggestion, s.currentQuery],
|
|
(suggestion: EndpointMaterializationSuggestionApi | null, currentQuery: Node | null): boolean => {
|
|
if (!suggestion?.suggested_query || !currentQuery || !isInsightsQLQuery(currentQuery)) {
|
|
return false
|
|
}
|
|
return suggestion.suggested_query.trim() === (currentQuery.query || '').trim()
|
|
},
|
|
],
|
|
queryToRender: [
|
|
(s) => [s.currentQuery],
|
|
(currentQuery: Node | null): Node | null => {
|
|
if (!currentQuery) {
|
|
return null
|
|
}
|
|
|
|
if (isInsightsQLQuery(currentQuery)) {
|
|
return {
|
|
kind: NodeKind.DataTableNode,
|
|
source: currentQuery,
|
|
showInsightsQLEditor: true,
|
|
} as DataTableNode
|
|
}
|
|
|
|
if (isInsightQueryNode(currentQuery)) {
|
|
return {
|
|
kind: NodeKind.InsightVizNode,
|
|
source: currentQuery,
|
|
full: true,
|
|
} as InsightVizNode
|
|
}
|
|
|
|
return currentQuery
|
|
},
|
|
],
|
|
breadcrumbs: [
|
|
(s) => [s.endpoint],
|
|
(endpoint: EndpointType | null): Breadcrumb[] => [
|
|
{
|
|
key: Scene.Endpoints,
|
|
name: 'endpoints',
|
|
path: urls.endpoints(),
|
|
iconType: 'endpoints',
|
|
},
|
|
{
|
|
key: [Scene.Endpoints, endpoint?.name || 'new'],
|
|
name: endpoint?.name || 'New Endpoint',
|
|
},
|
|
],
|
|
],
|
|
}),
|
|
listeners(({ actions, values, cache }) => ({
|
|
keepSqlEditorMounted: ({ editorTabId }) => {
|
|
// Already holding a mount for this editor
|
|
if (cache.sqlEditorTabId === editorTabId) {
|
|
return
|
|
}
|
|
cache.unmountSqlEditor?.()
|
|
cache.sqlEditorTabId = editorTabId
|
|
cache.unmountSqlEditor = sqlEditorLogic({ tabId: editorTabId, mode: SQLEditorMode.Embedded }).mount()
|
|
},
|
|
loadEndpoint: () => {
|
|
cache.unmountSqlEditor?.()
|
|
cache.unmountSqlEditor = null
|
|
cache.sqlEditorTabId = null
|
|
},
|
|
loadEndpointSuccess: async ({ endpoint }: { endpoint: EndpointVersionType | null; payload?: string }) => {
|
|
const initialPayload = generateInitialPayloadJson(endpoint)
|
|
actions.setPayloadJson(initialPayload)
|
|
actions.setDataFreshness(endpoint?.data_freshness_seconds ?? DEFAULT_DATA_FRESHNESS_SECONDS)
|
|
actions.resetOptionalBreakdownProperties(endpoint?.optional_breakdown_properties ?? [])
|
|
|
|
const { searchParams, hashParams } = router.values
|
|
|
|
// Versions populate the File → Open version submenu, so always load them.
|
|
if (endpoint?.name) {
|
|
actions.loadVersions(endpoint.name)
|
|
}
|
|
if (searchParams.tab === EndpointTab.CONFIGURATION && endpoint?.name) {
|
|
actions.loadMaterializationPreview()
|
|
}
|
|
|
|
// Handle version param from URL
|
|
if (searchParams.version && endpoint?.name) {
|
|
const versionNumber = parseInt(searchParams.version, 10)
|
|
if (!isNaN(versionNumber) && versionNumber !== endpoint.current_version) {
|
|
try {
|
|
const versionData = await api.endpoint.get(endpoint.name, versionNumber)
|
|
actions.setViewingVersion(versionData)
|
|
} catch {
|
|
// Version not found, clear the param
|
|
const { version: _, ...nextSearchParams } = searchParams
|
|
router.actions.replace(urls.endpoint(endpoint.name), nextSearchParams, hashParams)
|
|
actions.setViewingVersion(null)
|
|
}
|
|
} else {
|
|
// Version equals current, clear viewing version
|
|
actions.setViewingVersion(null)
|
|
}
|
|
} else {
|
|
// No version param, clear viewing version
|
|
actions.setViewingVersion(null)
|
|
}
|
|
},
|
|
setActiveTab: ({ tab }) => {
|
|
if (tab === EndpointTab.VERSIONS && values.endpoint?.name) {
|
|
actions.loadVersions(values.endpoint.name)
|
|
}
|
|
if (tab === EndpointTab.CONFIGURATION && values.endpoint?.name) {
|
|
actions.loadMaterializationPreview()
|
|
}
|
|
},
|
|
setBucketOverride: () => {
|
|
actions.loadMaterializationPreview()
|
|
},
|
|
openMaterializationSuggestionModal: () => {
|
|
// Reuse a cached suggestion — the saved query can't have changed while it's valid
|
|
// (updates clear it), and each request is a full LLM round-trip
|
|
if (!values.materializationSuggestion && !values.materializationSuggestionLoading) {
|
|
actions.loadMaterializationSuggestion()
|
|
}
|
|
},
|
|
regenerateMaterializationSuggestion: () => {
|
|
actions.loadMaterializationSuggestion()
|
|
},
|
|
applyMaterializationSuggestion: () => {
|
|
const suggestion = values.materializationSuggestion
|
|
const current = values.currentQuery
|
|
if (!suggestion?.suggested_query || !current || !isInsightsQLQuery(current)) {
|
|
return
|
|
}
|
|
if (values.suggestionMatchesCurrentQuery) {
|
|
toast.info('Your query already matches the suggestion — nothing to apply')
|
|
actions.closeMaterializationSuggestionModal()
|
|
return
|
|
}
|
|
|
|
actions.setActiveTab(EndpointTab.QUERY)
|
|
if (values.endpoint?.name) {
|
|
const { searchParams, hashParams } = router.values
|
|
const { tab: _tab, ...nextSearchParams } = searchParams
|
|
router.actions.replace(urls.endpoint(values.endpoint.name), nextSearchParams, hashParams)
|
|
}
|
|
|
|
// Always target the latest version's editor: apply is only reachable on the latest
|
|
// version, and cache.sqlEditorTabId can still point at an old version's editor when
|
|
// the Query tab was last mounted while browsing that version.
|
|
const editorTabId = 'endpoint-query-latest'
|
|
actions.keepSqlEditorMounted(editorTabId)
|
|
const editorLogic = sqlEditorLogic.findMounted({ tabId: editorTabId, mode: SQLEditorMode.Embedded })
|
|
|
|
// If the editor was never initialized (e.g. the user came straight to Configuration and
|
|
// the Query tab never mounted), seed it with the saved query first so the suggestion
|
|
// always renders as an accept/reject diff. The seeded state also keeps the query tab's
|
|
// init effect from re-initializing and clobbering the diff.
|
|
if (editorLogic && !editorLogic.values.queryInput?.trim()) {
|
|
editorLogic.actions.setQueryInput(current.query || '')
|
|
editorLogic.actions.setSourceQuery({
|
|
kind: NodeKind.DataVisualizationNode,
|
|
source: { ...current },
|
|
display: ChartDisplayType.ActionsLineGraph,
|
|
} as DataVisualizationNode)
|
|
}
|
|
editorLogic?.actions.setSuggestedQueryInput(suggestion.suggested_query, 'materialization_fix')
|
|
toast.success('Review the suggested changes in the editor — accept to apply them')
|
|
actions.closeMaterializationSuggestionModal()
|
|
},
|
|
toggleMaterializationFromMenu: () => {
|
|
if (!values.endpoint?.name) {
|
|
return
|
|
}
|
|
const baseIsMaterialized =
|
|
values.viewingVersion?.is_materialized ?? values.endpoint?.is_materialized ?? false
|
|
const effective = values.isMaterialized ?? baseIsMaterialized
|
|
actions.setActiveTab(EndpointTab.CONFIGURATION)
|
|
actions.setIsMaterialized(!effective)
|
|
// Drive the URL so Configuration is loaded if/when Tabs is gone.
|
|
const { searchParams, hashParams } = router.values
|
|
router.actions.replace(
|
|
urls.endpoint(values.endpoint.name),
|
|
{ ...searchParams, tab: EndpointTab.CONFIGURATION },
|
|
hashParams
|
|
)
|
|
},
|
|
setViewingVersion: ({ version }) => {
|
|
// Reset local state so viewed version's data shows through
|
|
actions.setLocalQuery(null)
|
|
actions.setIsMaterialized(null)
|
|
actions.clearMaterializationStatus()
|
|
|
|
// Reset bucket overrides to viewed version's values
|
|
actions.resetBucketOverrides(version?.bucket_overrides ?? values.endpoint?.bucket_overrides ?? {})
|
|
|
|
// Reset optional breakdowns to viewed version's value (or endpoint's if going back to current)
|
|
actions.resetOptionalBreakdownProperties(
|
|
version?.optional_breakdown_properties ?? values.endpoint?.optional_breakdown_properties ?? []
|
|
)
|
|
|
|
// Reset data freshness to viewed version's value (or endpoint's if going back to current)
|
|
actions.setDataFreshness(
|
|
version?.data_freshness_seconds ??
|
|
values.endpoint?.data_freshness_seconds ??
|
|
DEFAULT_DATA_FRESHNESS_SECONDS
|
|
)
|
|
|
|
// Reset description to viewed version's description (or endpoint's if going back to current)
|
|
if (version) {
|
|
actions.setEndpointDescription(version.description || '')
|
|
} else if (values.endpoint) {
|
|
actions.setEndpointDescription(values.endpoint.description || '')
|
|
}
|
|
|
|
// Update payload with version field if viewing a specific version
|
|
if (values.endpoint) {
|
|
const basePayload = generateEndpointPayload(values.endpoint)
|
|
if (version && version.version !== values.endpoint.current_version) {
|
|
const payloadWithVersion = { ...basePayload, version: version.version }
|
|
actions.setPayloadJson(JSON.stringify(payloadWithVersion, null, 2))
|
|
} else {
|
|
actions.setPayloadJson(JSON.stringify(basePayload, null, 2))
|
|
}
|
|
}
|
|
|
|
// Update URL when viewing version changes
|
|
const { searchParams, hashParams } = router.values
|
|
if (values.endpoint?.name) {
|
|
const { version: _, ...nextSearchParams } = searchParams
|
|
if (version && version.version !== values.endpoint.current_version) {
|
|
router.actions.replace(
|
|
urls.endpoint(values.endpoint.name),
|
|
{
|
|
...nextSearchParams,
|
|
version: version.version,
|
|
},
|
|
hashParams
|
|
)
|
|
} else {
|
|
// Clear version param when going back to current version
|
|
router.actions.replace(urls.endpoint(values.endpoint.name), nextSearchParams, hashParams)
|
|
}
|
|
}
|
|
},
|
|
loadEndpointResultSuccess: () => {
|
|
// Mark test endpoint task as completed when user runs an endpoint in the playground
|
|
globalSetupLogic.findMounted()?.actions.markTaskAsCompleted(SetupTaskId.TestEndpoint)
|
|
},
|
|
updateEndpointSuccess: async ({ endpointName, options }) => {
|
|
// Reload the viewed version after update to get fresh data
|
|
const versionToReload = options?.version ?? values.viewingVersion?.version
|
|
if (versionToReload && endpointName) {
|
|
try {
|
|
const versionData = await api.endpoint.get(endpointName, versionToReload)
|
|
actions.setViewingVersion(versionData)
|
|
} catch {
|
|
// Version may have been deleted, clear it
|
|
actions.setViewingVersion(null)
|
|
}
|
|
}
|
|
},
|
|
})),
|
|
urlToAction(({ actions, values }) => ({
|
|
[urls.endpoint(':name')]: ({ name }: { name?: string }, _, __, currentLocation, previousLocation) => {
|
|
const { searchParams } = router.values
|
|
const didPathChange = currentLocation.initial || currentLocation.pathname !== previousLocation?.pathname
|
|
|
|
if (name && didPathChange) {
|
|
const isSameEndpoint = values.endpointName === name
|
|
|
|
if (!currentLocation.initial && isSameEndpoint) {
|
|
// Already viewing this endpoint, skip reload
|
|
} else {
|
|
actions.setEndpointName(name)
|
|
actions.loadEndpoint(name)
|
|
}
|
|
}
|
|
|
|
if (searchParams.tab && searchParams.tab !== values.activeTab) {
|
|
actions.setActiveTab(searchParams.tab as EndpointTab)
|
|
} else if (!searchParams.tab && values.activeTab !== EndpointTab.QUERY) {
|
|
actions.setActiveTab(EndpointTab.QUERY)
|
|
}
|
|
|
|
// Handle version param changes without full endpoint reload
|
|
if (!didPathChange && name) {
|
|
const versionParam = searchParams.version ? parseInt(searchParams.version, 10) : null
|
|
const currentViewingVersion = values.viewingVersion?.version ?? null
|
|
|
|
if (versionParam !== currentViewingVersion) {
|
|
if (versionParam && values.endpoint?.name) {
|
|
// Load the requested version
|
|
const requestedVersion = versionParam
|
|
api.endpoint
|
|
.get(name, versionParam)
|
|
.then((versionData) => {
|
|
// Only apply if this is still the requested version
|
|
const currentParam = router.values.searchParams.version
|
|
if (currentParam && parseInt(currentParam, 10) === requestedVersion) {
|
|
actions.setViewingVersion(versionData)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Version not found
|
|
actions.setViewingVersion(null)
|
|
})
|
|
} else {
|
|
actions.setViewingVersion(null)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
})),
|
|
events(({ cache }) => ({
|
|
beforeUnmount: () => {
|
|
cache.unmountSqlEditor?.()
|
|
cache.unmountSqlEditor = null
|
|
cache.sqlEditorTabId = null
|
|
},
|
|
})),
|
|
])
|