Compare commits

...

3 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
bfa6c8a2e3 feat: database analyzer celery job (#36688)
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Geidō <60598000+geido@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Alexandru Soare <37236580+alexandrusoare@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
2025-12-18 21:20:29 +02:00
Enzo Martellucci
f86511a956 feat(datasource-connector): add multi-step wizard for connecting datasources (#36678)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com>
2025-12-17 15:29:36 +02:00
Geidō
b91ff3ab0d feat(dashboard): Dashboard Templates Gallery (#36673)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-17 14:14:30 +02:00
118 changed files with 18632 additions and 338 deletions

View File

@@ -109,6 +109,10 @@ dependencies = [
"wtforms>=2.3.3, <4",
"wtforms-json",
"xlsxwriter>=3.0.7, <3.1",
# --------------------------
# AI/LLM features - dashboard generator, database analyzer
"langchain-core>=1.2.0, <2", # Output parsers, prompts, and utilities
"langgraph>=1.0.5, <2", # Stateful agent orchestration
]
[project.optional-dependencies]

View File

@@ -8,6 +8,8 @@ amqp==5.3.1
# via kombu
annotated-types==0.7.0
# via pydantic
anyio==4.12.0
# via httpx
apispec==6.6.1
# via
# -r requirements/base.in
@@ -50,6 +52,8 @@ celery==5.5.2
# via apache-superset (pyproject.toml)
certifi==2025.6.15
# via
# httpcore
# httpx
# requests
# selenium
cffi==1.17.1
@@ -164,16 +168,26 @@ greenlet==3.1.1
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
# via
# httpcore
# wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.82
# via apache-superset (pyproject.toml)
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# langgraph-sdk
# langsmith
humanize==4.12.3
# via apache-superset (pyproject.toml)
idna==3.10
# via
# anyio
# email-validator
# httpx
# requests
# trio
# url-normalize
@@ -187,8 +201,12 @@ jinja2==3.1.6
# via
# flask
# flask-babel
jsonpatch==1.33
# via langchain-core
jsonpath-ng==1.7.0
# via apache-superset (pyproject.toml)
jsonpointer==3.0.0
# via jsonpatch
jsonschema==4.23.0
# via
# flask-appbuilder
@@ -199,6 +217,24 @@ jsonschema-specifications==2025.4.1
# openapi-schema-validator
kombu==5.5.3
# via celery
langchain-core==1.2.2
# via
# apache-superset (pyproject.toml)
# langgraph
# langgraph-checkpoint
# langgraph-prebuilt
langgraph==1.0.5
# via apache-superset (pyproject.toml)
langgraph-checkpoint==3.0.1
# via
# langgraph
# langgraph-prebuilt
langgraph-prebuilt==1.0.5
# via langgraph
langgraph-sdk==0.3.0
# via langgraph
langsmith==0.5.0
# via langchain-core
limits==5.1.0
# via flask-limiter
mako==1.3.10
@@ -252,6 +288,12 @@ openpyxl==3.1.5
# via pandas
ordered-set==4.1.0
# via flask-limiter
orjson==3.11.5
# via
# langgraph-sdk
# langsmith
ormsgpack==1.12.1
# via langgraph-checkpoint
outcome==1.3.0.post0
# via
# trio
@@ -262,6 +304,8 @@ packaging==25.0
# apispec
# deprecation
# gunicorn
# langchain-core
# langsmith
# limits
# marshmallow
# shillelagh
@@ -301,6 +345,9 @@ pydantic==2.11.7
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# langchain-core
# langgraph
# langsmith
pydantic-core==2.33.2
# via pydantic
pygments==2.19.1
@@ -343,6 +390,7 @@ pyyaml==6.0.2
# via
# apache-superset (pyproject.toml)
# apispec
# langchain-core
redis==5.3.1
# via apache-superset (pyproject.toml)
referencing==0.36.2
@@ -351,10 +399,14 @@ referencing==0.36.2
# jsonschema-specifications
requests==2.32.4
# via
# langsmith
# requests-cache
# requests-toolbelt
# shillelagh
requests-cache==1.2.1
# via shillelagh
requests-toolbelt==1.0.0
# via langsmith
rfc3339-validator==0.1.4
# via openapi-schema-validator
rich==13.9.4
@@ -408,6 +460,8 @@ sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
# via apache-superset (pyproject.toml)
tenacity==9.1.2
# via langchain-core
trio==0.30.0
# via
# selenium
@@ -418,8 +472,10 @@ typing-extensions==4.15.0
# via
# apache-superset (pyproject.toml)
# alembic
# anyio
# apache-superset-core
# cattrs
# langchain-core
# limits
# pydantic
# pydantic-core
@@ -442,6 +498,10 @@ urllib3==2.6.0
# requests
# requests-cache
# selenium
uuid-utils==0.12.0
# via
# langchain-core
# langsmith
vine==5.1.0
# via
# amqp
@@ -478,5 +538,9 @@ xlsxwriter==3.0.9
# via
# apache-superset (pyproject.toml)
# pandas
xxhash==3.6.0
# via langgraph
zstandard==0.23.0
# via flask-compress
# via
# flask-compress
# langsmith

View File

@@ -22,8 +22,9 @@ annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
anyio==4.11.0
anyio==4.12.0
# via
# -c requirements/base-constraint.txt
# httpx
# mcp
# sse-starlette
@@ -401,10 +402,15 @@ holidays==0.82
# apache-superset
# prophet
httpcore==1.0.9
# via httpx
# via
# -c requirements/base-constraint.txt
# httpx
httpx==0.28.1
# via
# -c requirements/base-constraint.txt
# fastmcp
# langgraph-sdk
# langsmith
# mcp
httpx-sse==0.4.1
# via mcp
@@ -458,10 +464,18 @@ jinja2==3.1.6
# apache-superset-extensions-cli
# flask
# flask-babel
jsonpatch==1.33
# via
# -c requirements/base-constraint.txt
# langchain-core
jsonpath-ng==1.7.0
# via
# -c requirements/base-constraint.txt
# apache-superset
jsonpointer==3.0.0
# via
# -c requirements/base-constraint.txt
# jsonpatch
jsonschema==4.23.0
# via
# -c requirements/base-constraint.txt
@@ -486,6 +500,34 @@ kombu==5.5.3
# via
# -c requirements/base-constraint.txt
# celery
langchain-core==1.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
# langgraph
# langgraph-checkpoint
# langgraph-prebuilt
langgraph==1.0.5
# via
# -c requirements/base-constraint.txt
# apache-superset
langgraph-checkpoint==3.0.1
# via
# -c requirements/base-constraint.txt
# langgraph
# langgraph-prebuilt
langgraph-prebuilt==1.0.5
# via
# -c requirements/base-constraint.txt
# langgraph
langgraph-sdk==0.3.0
# via
# -c requirements/base-constraint.txt
# langgraph
langsmith==0.5.0
# via
# -c requirements/base-constraint.txt
# langchain-core
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==5.1.0
@@ -606,6 +648,15 @@ ordered-set==4.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
orjson==3.11.5
# via
# -c requirements/base-constraint.txt
# langgraph-sdk
# langsmith
ormsgpack==1.12.1
# via
# -c requirements/base-constraint.txt
# langgraph-checkpoint
outcome==1.3.0.post0
# via
# -c requirements/base-constraint.txt
@@ -622,6 +673,8 @@ packaging==25.0
# duckdb-engine
# google-cloud-bigquery
# gunicorn
# langchain-core
# langsmith
# limits
# marshmallow
# matplotlib
@@ -747,6 +800,9 @@ pydantic==2.11.7
# apache-superset
# apache-superset-core
# fastmcp
# langchain-core
# langgraph
# langsmith
# mcp
# openapi-pydantic
# pydantic-settings
@@ -866,6 +922,7 @@ pyyaml==6.0.2
# apache-superset
# apispec
# jsonschema-path
# langchain-core
# pre-commit
redis==5.3.1
# via
@@ -887,10 +944,12 @@ requests==2.32.4
# google-api-core
# google-cloud-bigquery
# jsonschema-path
# langsmith
# pydruid
# pyhive
# requests-cache
# requests-oauthlib
# requests-toolbelt
# shillelagh
# trino
requests-cache==1.2.1
@@ -899,6 +958,10 @@ requests-cache==1.2.1
# shillelagh
requests-oauthlib==2.0.0
# via google-auth-oauthlib
requests-toolbelt==1.0.0
# via
# -c requirements/base-constraint.txt
# langsmith
rfc3339-validator==0.1.4
# via
# -c requirements/base-constraint.txt
@@ -965,7 +1028,6 @@ slack-sdk==3.35.0
sniffio==1.3.1
# via
# -c requirements/base-constraint.txt
# anyio
# trio
sortedcontainers==2.4.0
# via
@@ -1014,6 +1076,10 @@ tabulate==0.9.0
# via
# -c requirements/base-constraint.txt
# apache-superset
tenacity==9.1.2
# via
# -c requirements/base-constraint.txt
# langchain-core
tomlkit==0.13.3
# via pylint
tqdm==4.67.1
@@ -1042,6 +1108,7 @@ typing-extensions==4.15.0
# apache-superset-core
# cattrs
# exceptiongroup
# langchain-core
# limits
# mcp
# opentelemetry-api
@@ -1082,6 +1149,11 @@ urllib3==2.6.0
# requests
# requests-cache
# selenium
uuid-utils==0.12.0
# via
# -c requirements/base-constraint.txt
# langchain-core
# langsmith
uvicorn==0.37.0
# via
# fastmcp
@@ -1144,6 +1216,10 @@ xlsxwriter==3.0.9
# -c requirements/base-constraint.txt
# apache-superset
# pandas
xxhash==3.6.0
# via
# -c requirements/base-constraint.txt
# langgraph
zipp==3.23.0
# via importlib-metadata
zope-event==5.0
@@ -1154,3 +1230,4 @@ zstandard==0.23.0
# via
# -c requirements/base-constraint.txt
# flask-compress
# langsmith

View File

@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from '@superset-ui/core/spec';
import userEvent from '@testing-library/user-event';
import { AIInfoBanner } from '.';
test('renders with default props', () => {
render(<AIInfoBanner text="Hello AI" />);
const banner = screen.getByTestId('ai-info-banner');
expect(banner).toBeInTheDocument();
expect(banner).toHaveAttribute('role', 'status');
expect(banner).toHaveAttribute('aria-live', 'polite');
});
test('displays text with typing effect', async () => {
const testText = 'Test message';
render(<AIInfoBanner text={testText} typingSpeed={10} />);
// Wait for text to be fully typed
await waitFor(
() => {
expect(screen.getByText(testText)).toBeInTheDocument();
},
{ timeout: 500 },
);
});
test('shows close button when dismissible is true (default)', () => {
render(<AIInfoBanner text="Test" />);
const closeButton = screen.getByTestId('ai-info-banner-close');
expect(closeButton).toBeInTheDocument();
});
test('hides close button when dismissible is false', () => {
render(<AIInfoBanner text="Test" dismissible={false} />);
expect(
screen.queryByTestId('ai-info-banner-close'),
).not.toBeInTheDocument();
});
test('calls onDismiss and hides banner when close button is clicked', async () => {
const onDismiss = jest.fn();
render(<AIInfoBanner text="Test" onDismiss={onDismiss} />);
const closeButton = screen.getByTestId('ai-info-banner-close');
await userEvent.click(closeButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('ai-info-banner')).not.toBeInTheDocument();
});
test('accepts custom className', () => {
render(<AIInfoBanner text="Test" className="custom-class" />);
const banner = screen.getByTestId('ai-info-banner');
expect(banner).toHaveClass('custom-class');
});
test('accepts custom data-test attribute', () => {
render(<AIInfoBanner text="Test" data-test="custom-test-id" />);
expect(screen.getByTestId('custom-test-id')).toBeInTheDocument();
});

View File

@@ -0,0 +1,293 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useCallback } from 'react';
import { styled, css, keyframes } from '@apache-superset/core/ui';
import type { AIInfoBannerProps } from './types';
// Keyframes for the SIRI-like orb animation
const pulse = keyframes`
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
`;
const wave = keyframes`
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 0.4;
}
100% {
transform: scale(1.6);
opacity: 0;
}
`;
const gradientShift = keyframes`
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
`;
const cursorBlink = keyframes`
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
`;
const BannerContainer = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 3}px;
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
background: linear-gradient(
135deg,
${theme.colorPrimaryBg} 0%,
${theme.colorInfoBg} 50%,
${theme.colorPrimaryBg} 100%
);
background-size: 200% 200%;
animation: ${gradientShift} 8s ease infinite;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${theme.colorPrimaryBorder};
position: relative;
overflow: hidden;
`}
`;
const AIIndicatorWrapper = styled.div`
${({ theme }) => css`
position: relative;
width: ${theme.sizeUnit * 10}px;
height: ${theme.sizeUnit * 10}px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`}
`;
const AIOrb = styled.div`
${({ theme }) => css`
width: ${theme.sizeUnit * 5}px;
height: ${theme.sizeUnit * 5}px;
border-radius: 50%;
background: linear-gradient(
135deg,
${theme.colorPrimary} 0%,
${theme.colorInfo} 25%,
${theme.colorSuccess} 50%,
${theme.colorInfo} 75%,
${theme.colorPrimaryActive} 100%
);
background-size: 300% 300%;
animation:
${pulse} 1.5s ease-in-out infinite,
${gradientShift} 1.2s steps(6) infinite;
box-shadow:
0 0 ${theme.sizeUnit * 2}px ${theme.colorPrimary},
0 0 ${theme.sizeUnit * 5}px ${theme.colorPrimaryBg};
z-index: 2;
`}
`;
const WaveRing = styled.div<{ $delay: number }>`
${({ theme, $delay }) => css`
position: absolute;
width: ${theme.sizeUnit * 5}px;
height: ${theme.sizeUnit * 5}px;
border-radius: 50%;
border: 1px solid ${theme.colorPrimary};
animation: ${wave} 1.8s ease-out infinite;
animation-delay: ${$delay}s;
z-index: 1;
`}
`;
const ContentWrapper = styled.div`
${({ theme }) => css`
flex: 1;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit}px;
`}
`;
const TextContent = styled.div`
${({ theme }) => css`
font-size: ${theme.fontSize}px;
color: ${theme.colorText};
line-height: 1.5;
display: inline;
`}
`;
const TypingCursor = styled.span<{ $isTyping: boolean }>`
${({ theme, $isTyping }) => css`
display: inline-block;
width: 2px;
height: 1em;
background-color: ${theme.colorPrimary};
margin-left: 2px;
vertical-align: text-bottom;
animation: ${$isTyping ? cursorBlink : 'none'} 0.8s step-end infinite;
opacity: ${$isTyping ? 1 : 0};
`}
`;
const CloseButton = styled.button`
${({ theme }) => css`
position: absolute;
top: ${theme.sizeUnit}px;
right: ${theme.sizeUnit}px;
background: transparent;
border: none;
cursor: pointer;
padding: ${theme.sizeUnit * 0.5}px;
display: flex;
align-items: center;
justify-content: center;
border-radius: ${theme.borderRadius}px;
color: ${theme.colorTextTertiary};
transition: all ${theme.motionDurationMid};
&:hover {
background-color: ${theme.colorPrimaryBg};
color: ${theme.colorText};
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px ${theme.colorPrimaryBorder};
}
`}
`;
const CloseIcon = () => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L9 9M1 9L9 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export function AIInfoBanner({
text,
typingSpeed = 20,
dismissible = true,
onDismiss,
className,
'data-test': dataTest,
}: AIInfoBannerProps) {
const [displayedText, setDisplayedText] = useState('');
const [isTyping, setIsTyping] = useState(true);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (!text) return;
setDisplayedText('');
setIsTyping(true);
let currentIndex = 0;
const intervalId = setInterval(() => {
if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1));
currentIndex += 1;
} else {
setIsTyping(false);
clearInterval(intervalId);
}
}, typingSpeed);
return () => clearInterval(intervalId);
}, [text, typingSpeed]);
const handleDismiss = useCallback(() => {
setIsVisible(false);
onDismiss?.();
}, [onDismiss]);
if (!isVisible) {
return null;
}
return (
<BannerContainer
className={className}
data-test={dataTest ?? 'ai-info-banner'}
role="status"
aria-live="polite"
>
<AIIndicatorWrapper>
<WaveRing $delay={0} />
<WaveRing $delay={0.5} />
<WaveRing $delay={1} />
<AIOrb />
</AIIndicatorWrapper>
<ContentWrapper>
<TextContent>
{displayedText}
<TypingCursor $isTyping={isTyping} />
</TextContent>
</ContentWrapper>
{dismissible && (
<CloseButton
onClick={handleDismiss}
aria-label="Dismiss"
data-test="ai-info-banner-close"
>
<CloseIcon />
</CloseButton>
)}
</BannerContainer>
);
}
export type { AIInfoBannerProps };

View File

@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface AIInfoBannerProps {
/** The text content to display with typing effect */
text: string;
/** Typing speed in milliseconds per character (default: 20) */
typingSpeed?: number;
/** Whether the banner can be dismissed (default: true) */
dismissible?: boolean;
/** Callback when the banner is dismissed */
onDismiss?: () => void;
/** Custom className for styling */
className?: string;
/** Data test attribute for testing */
'data-test'?: string;
}

View File

@@ -0,0 +1,266 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled, useTheme, css, keyframes } from '@apache-superset/core/ui';
import { Flex, Typography } from '../index';
import { Icons } from '../Icons';
import type { AsyncProcessPanelProps, ProcessStep } from './types';
const spin = keyframes`
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
`;
const Spinner = styled.div`
${({ theme }) => css`
width: ${theme.sizeUnit * 20}px;
height: ${theme.sizeUnit * 20}px;
border: ${theme.sizeUnit}px solid ${theme.colorBgContainer};
border-top: ${theme.sizeUnit}px solid ${theme.colorPrimary};
border-radius: 50%;
animation: ${spin} 1s linear infinite;
`}
`;
const Subtitle = styled(Typography.Text)`
${() => css`
display: block;
text-align: center;
max-width: 400px;
`}
`;
const ProgressBarContainer = styled.div`
${({ theme }) => css`
width: 100%;
height: ${theme.sizeUnit * 1.5}px;
background-color: ${theme.colorBgContainer};
border-radius: ${theme.borderRadiusSM}px;
overflow: hidden;
`}
`;
const ProgressBar = styled.div<{ progress: number }>`
${({ theme, progress }) => css`
height: 100%;
width: ${progress}%;
background: linear-gradient(
90deg,
${theme.colorPrimary} 0%,
${theme.colorSuccess} 100%
);
border-radius: ${theme.borderRadiusSM}px;
transition: width 0.5s ease-in-out;
`}
`;
const StepsContainer = styled.div`
${({ theme }) => css`
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingLG}px;
`}
`;
const StepIcon = styled.div<{ $isActive: boolean; $isCompleted: boolean }>`
${({ theme, $isActive, $isCompleted }) => css`
width: ${theme.sizeUnit * 6}px;
height: ${theme.sizeUnit * 6}px;
min-width: ${theme.sizeUnit * 6}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: ${theme.fontSizeSM}px;
${$isCompleted
? css`
background-color: ${theme.colorSuccess};
color: ${theme.colorTextLightSolid};
`
: $isActive
? css`
background-color: ${theme.colorPrimary};
color: ${theme.colorTextLightSolid};
`
: css`
background-color: ${theme.colorBgBase};
border: 1px solid ${theme.colorBorder};
color: ${theme.colorTextSecondary};
`}
`}
`;
const StepTitle = styled.p<{ $isActive: boolean; $isCompleted: boolean }>`
${({ theme, $isActive, $isCompleted }) => css`
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${$isActive || $isCompleted
? theme.colorText
: theme.colorTextSecondary};
margin: 0 0 2px 0;
`}
`;
const StepDescription = styled.p`
${({ theme }) => css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
margin: 0;
`}
`;
const InfoBanner = styled.div`
${({ theme }) => css`
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingMD}px ${theme.paddingLG}px;
`}
`;
const InfoIcon = styled.div`
margin-top: 2px;
flex-shrink: 0;
`;
const PanelContainer = styled(Flex)`
${({ theme }) => css`
width: 100%;
max-width: 600px;
gap: ${theme.marginLG}px;
`}
`;
const TitleSection = styled(Flex)`
${({ theme }) => css`
gap: ${theme.marginSM}px;
`}
`;
const StepsListContainer = styled(Flex)`
${({ theme }) => css`
gap: ${theme.marginSM}px;
`}
`;
const StepRow = styled(Flex)`
${({ theme }) => css`
padding: ${theme.paddingSM}px 0;
gap: ${theme.marginSM}px;
`}
`;
const InfoBannerContent = styled(Flex)`
${({ theme }) => css`
gap: ${theme.marginSM}px;
`}
`;
const StepContent = styled(Flex)`
flex: 1;
`;
export function AsyncProcessPanel({
title,
subtitle,
steps,
currentStepIndex,
infoBannerTitle,
infoBannerDescription,
}: AsyncProcessPanelProps) {
const theme = useTheme();
const progress = ((currentStepIndex + 1) / (steps.length + 1)) * 100;
return (
<PanelContainer vertical align="center">
<Spinner />
<TitleSection vertical align="center">
<Typography.Title css={{ margin: 0, textAlign: 'center' }} level={3}>
{title}
</Typography.Title>
{subtitle && <Subtitle type="secondary">{subtitle}</Subtitle>}
</TitleSection>
<ProgressBarContainer>
<ProgressBar progress={progress} />
</ProgressBarContainer>
<StepsContainer>
<StepsListContainer vertical>
{steps.map((step, index) => {
const isCompleted = currentStepIndex > index;
const isActive = currentStepIndex === index;
return (
<StepRow key={step.key} align="flex-start">
<StepIcon $isActive={isActive} $isCompleted={isCompleted}>
{isCompleted ? (
<Icons.CheckOutlined />
) : isActive ? (
<Icons.LoadingOutlined />
) : null}
</StepIcon>
<StepContent vertical>
<StepTitle $isActive={isActive} $isCompleted={isCompleted}>
{step.title}
</StepTitle>
<StepDescription>{step.description}</StepDescription>
</StepContent>
</StepRow>
);
})}
</StepsListContainer>
</StepsContainer>
{(infoBannerTitle || infoBannerDescription) && (
<InfoBanner>
<InfoBannerContent align="flex-start">
<InfoIcon>
<Icons.InfoCircleOutlined
iconSize="m"
iconColor={theme.colorPrimary}
/>
</InfoIcon>
<StepContent vertical>
{infoBannerTitle && (
<Typography.Text
css={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
>
{infoBannerTitle}
</Typography.Text>
)}
{infoBannerDescription && (
<Typography.Text type="secondary">
{infoBannerDescription}
</Typography.Text>
)}
</StepContent>
</InfoBannerContent>
</InfoBanner>
)}
</PanelContainer>
);
}
export type { AsyncProcessPanelProps, ProcessStep };

View File

@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
export interface ProcessStep {
key: string;
title: string;
description: string;
}
export interface AsyncProcessPanelProps {
title: string;
subtitle?: string | ReactNode;
steps: ProcessStep[];
currentStepIndex: number;
infoBannerTitle?: string;
infoBannerDescription?: string;
}

View File

@@ -149,6 +149,8 @@ export { Progress, type ProgressProps } from './Progress';
export { Skeleton, type SkeletonProps } from './Skeleton';
export { Spin } from './Spin';
export { Switch, type SwitchProps } from './Switch';
export { TreeSelect, type TreeSelectProps } from './TreeSelect';
@@ -191,3 +193,9 @@ export {
type CodeEditorTheme,
} from './CodeEditor';
export { ActionButton, type ActionProps } from './ActionButton';
export { AIInfoBanner, type AIInfoBannerProps } from './AIInfoBanner';
export {
AsyncProcessPanel,
type AsyncProcessPanelProps,
type ProcessStep,
} from './AsyncProcessPanel';

View File

@@ -0,0 +1,355 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useMemo } from 'react';
import { t } from '@superset-ui/core';
import { styled, Alert } from '@apache-superset/core/ui';
import {
Modal,
Select,
Input,
Form,
Space,
Typography,
} from '@superset-ui/core/components';
export enum JoinType {
INNER = 'inner',
LEFT = 'left',
RIGHT = 'right',
FULL = 'full',
CROSS = 'cross',
}
export enum Cardinality {
ONE_TO_ONE = '1:1',
ONE_TO_MANY = '1:N',
MANY_TO_ONE = 'N:1',
MANY_TO_MANY = 'N:M',
}
export interface Table {
id: number;
name: string;
columns?: Column[];
}
export interface Column {
id: number;
name: string;
type: string;
}
export interface Join {
id?: number;
source_table: string;
source_table_id?: number;
source_columns: string[];
target_table: string;
target_table_id?: number;
target_columns: string[];
join_type: JoinType;
cardinality: Cardinality;
semantic_context?: string;
}
interface JoinEditorModalProps {
visible: boolean;
join?: Join | null;
tables: Table[];
onSave: (join: Join) => void;
onCancel: () => void;
}
const StyledForm = styled(Form)`
.ant-form-item {
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
}
`;
const ColumnSelectionGroup = styled.div`
display: flex;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
align-items: center;
`;
const JoinEditorModal = ({
visible,
join,
tables,
onSave,
onCancel,
}: JoinEditorModalProps) => {
const [form] = Form.useForm();
const [sourceTable, setSourceTable] = useState<string | undefined>(
join?.source_table,
);
const [targetTable, setTargetTable] = useState<string | undefined>(
join?.target_table,
);
useEffect(() => {
if (visible) {
if (join) {
form.setFieldsValue({
source_table: join.source_table,
source_columns: join.source_columns,
target_table: join.target_table,
target_columns: join.target_columns,
join_type: join.join_type,
cardinality: join.cardinality,
semantic_context: join.semantic_context,
});
setSourceTable(join.source_table);
setTargetTable(join.target_table);
} else {
form.resetFields();
setSourceTable(undefined);
setTargetTable(undefined);
}
}
}, [visible, join, form]);
const sourceTableColumns = useMemo(
() =>
tables.find(table => table.name === sourceTable)?.columns || [],
[tables, sourceTable],
);
const targetTableColumns = useMemo(
() =>
tables.find(table => table.name === targetTable)?.columns || [],
[tables, targetTable],
);
const joinTypeOptions = [
{ value: JoinType.INNER, label: t('Inner Join') },
{ value: JoinType.LEFT, label: t('Left Join') },
{ value: JoinType.RIGHT, label: t('Right Join') },
{ value: JoinType.FULL, label: t('Full Outer Join') },
{ value: JoinType.CROSS, label: t('Cross Join') },
];
const cardinalityOptions = [
{ value: Cardinality.ONE_TO_ONE, label: t('One to One (1:1)') },
{ value: Cardinality.ONE_TO_MANY, label: t('One to Many (1:N)') },
{ value: Cardinality.MANY_TO_ONE, label: t('Many to One (N:1)') },
{ value: Cardinality.MANY_TO_MANY, label: t('Many to Many (N:M)') },
];
const handleSourceTableChange = (value: string) => {
setSourceTable(value);
form.setFieldValue('source_columns', []);
};
const handleTargetTableChange = (value: string) => {
setTargetTable(value);
form.setFieldValue('target_columns', []);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const sourceTableId = tables.find(
t => t.name === values.source_table,
)?.id;
const targetTableId = tables.find(
t => t.name === values.target_table,
)?.id;
onSave({
...join,
...values,
source_table_id: sourceTableId,
target_table_id: targetTableId,
});
form.resetFields();
} catch (error) {
// Form validation failed
}
};
return (
<Modal
title={join ? t('Edit Join Relationship') : t('Add Join Relationship')}
visible={visible}
onOk={handleSubmit}
onCancel={onCancel}
width={800}
okText={join ? t('Update') : t('Add')}
cancelText={t('Cancel')}
>
<StyledForm form={form} layout="vertical">
<Alert
message={t('Join Configuration')}
description={t(
'Define the relationship between tables. This join will be used when generating dashboards and visualizations.',
)}
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Typography.Title level={5}>{t('Tables')}</Typography.Title>
<ColumnSelectionGroup>
<Form.Item
name="source_table"
label={t('Source Table')}
rules={[{ required: true, message: t('Please select a source table') }]}
style={{ flex: 1, marginBottom: 0 }}
>
<Select
placeholder={t('Select source table')}
onChange={handleSourceTableChange}
showSearch
optionFilterProp="label"
options={tables.map(table => ({
value: table.name,
label: table.name,
}))}
/>
</Form.Item>
<Form.Item
name="join_type"
label={t('Join Type')}
rules={[{ required: true, message: t('Please select a join type') }]}
style={{ minWidth: 150, marginBottom: 0 }}
>
<Select
placeholder={t('Select join type')}
options={joinTypeOptions}
/>
</Form.Item>
<Form.Item
name="target_table"
label={t('Target Table')}
rules={[{ required: true, message: t('Please select a target table') }]}
style={{ flex: 1, marginBottom: 0 }}
>
<Select
placeholder={t('Select target table')}
onChange={handleTargetTableChange}
showSearch
optionFilterProp="label"
options={tables.map(table => ({
value: table.name,
label: table.name,
}))}
/>
</Form.Item>
</ColumnSelectionGroup>
<Typography.Title level={5} style={{ marginTop: 24 }}>
{t('Join Columns')}
</Typography.Title>
<ColumnSelectionGroup>
<Form.Item
name="source_columns"
label={t('Source Columns')}
rules={[
{ required: true, message: t('Please select source columns') },
]}
style={{ flex: 1 }}
>
<Select
mode="multiple"
placeholder={t('Select columns from source table')}
disabled={!sourceTable}
options={sourceTableColumns.map(col => ({
value: col.name,
label: `${col.name} (${col.type})`,
}))}
/>
</Form.Item>
<Typography.Text>=</Typography.Text>
<Form.Item
name="target_columns"
label={t('Target Columns')}
rules={[
{ required: true, message: t('Please select target columns') },
({ getFieldValue }) => ({
validator(_, value) {
const sourceColumns = getFieldValue('source_columns') || [];
if (value && value.length !== sourceColumns.length) {
return Promise.reject(
new Error(
t(
'Number of target columns must match source columns',
),
),
);
}
return Promise.resolve();
},
}),
]}
style={{ flex: 1 }}
>
<Select
mode="multiple"
placeholder={t('Select columns from target table')}
disabled={!targetTable}
options={targetTableColumns.map(col => ({
value: col.name,
label: `${col.name} (${col.type})`,
}))}
/>
</Form.Item>
</ColumnSelectionGroup>
<Typography.Title level={5} style={{ marginTop: 24 }}>
{t('Relationship Details')}
</Typography.Title>
<Form.Item
name="cardinality"
label={t('Cardinality')}
rules={[{ required: true, message: t('Please select cardinality') }]}
>
<Select
placeholder={t('Select relationship cardinality')}
options={cardinalityOptions}
/>
</Form.Item>
<Form.Item
name="semantic_context"
label={t('Description (AI Generated)')}
help={t(
'This description was generated by AI and can be edited for clarity',
)}
>
<Input.TextArea
rows={3}
placeholder={t(
'e.g., "Orders are linked to customers through the customer_id field"',
)}
/>
</Form.Item>
</StyledForm>
</Modal>
);
};
export default JoinEditorModal;

View File

@@ -0,0 +1,352 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { t, SupersetClient } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
Table,
Button,
Space,
Typography,
Popconfirm,
Tag,
} from '@superset-ui/core/components';
import { useToasts } from '../MessageToasts/withToasts';
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import JoinEditorModal, {
Join,
JoinType,
Cardinality,
Table as TableType,
} from './JoinEditorModal';
interface JoinsListProps {
databaseReportId: number;
joins: Join[];
tables: TableType[];
onJoinsUpdate?: (joins: Join[]) => void;
editable?: boolean;
}
const StyledTableContainer = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 4}px;
background-color: ${theme.colorBgContainer};
border-radius: ${theme.borderRadiusSM}px;
`}
`;
const HeaderContainer = styled.div`
${({ theme }) => `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${theme.sizeUnit * 4}px;
`}
`;
const JoinTypeTag = styled(Tag)<{ joinType: JoinType }>`
${({ theme, joinType }) => {
const colorMap = {
[JoinType.INNER]: theme.colorSuccess,
[JoinType.LEFT]: theme.colorInfo,
[JoinType.RIGHT]: theme.colorWarning,
[JoinType.FULL]: theme.colorError,
[JoinType.CROSS]: theme.colorTextSecondary,
};
return `
background-color: ${colorMap[joinType]}20;
color: ${colorMap[joinType]};
border-color: ${colorMap[joinType]};
`;
}}
`;
const CardinalityTag = styled(Tag)`
${({ theme }) => `
background-color: ${theme.colorPrimaryBg};
color: ${theme.colorPrimary};
border-color: ${theme.colorPrimary};
`}
`;
const JoinsList = ({
databaseReportId,
joins: initialJoins,
tables,
onJoinsUpdate,
editable = true,
}: JoinsListProps) => {
const [joins, setJoins] = useState<Join[]>(initialJoins);
const [modalVisible, setModalVisible] = useState(false);
const [editingJoin, setEditingJoin] = useState<Join | null>(null);
const [loading, setLoading] = useState(false);
const { addSuccessToast, addDangerToast } = useToasts();
const handleAddJoin = () => {
setEditingJoin(null);
setModalVisible(true);
};
const handleEditJoin = (join: Join) => {
setEditingJoin(join);
setModalVisible(true);
};
const handleDeleteJoin = async (joinId: number) => {
setLoading(true);
try {
const response = await SupersetClient.delete({
endpoint: `/api/v1/datasource/analysis/report/${databaseReportId}/join/${joinId}`,
});
if (response.ok) {
const updatedJoins = joins.filter(j => j.id !== joinId);
setJoins(updatedJoins);
onJoinsUpdate?.(updatedJoins);
addSuccessToast(t('Join deleted successfully'));
} else {
throw new Error('Failed to delete join');
}
} catch (error) {
console.error('Delete join error:', error);
addDangerToast(
t('Failed to delete join: %s', error?.message || String(error)),
);
} finally {
setLoading(false);
}
};
const handleSaveJoin = async (join: Join) => {
setLoading(true);
try {
const endpoint = join.id
? `/api/v1/datasource/analysis/report/${databaseReportId}/join/${join.id}`
: `/api/v1/datasource/analysis/report/${databaseReportId}/join`;
const method = join.id ? 'PUT' : 'POST';
const response = await SupersetClient.request({
endpoint,
method,
jsonPayload: join,
});
if (response.ok) {
const savedJoin = response.json;
let updatedJoins: Join[];
if (join.id) {
updatedJoins = joins.map(j => (j.id === join.id ? savedJoin : j));
} else {
updatedJoins = [...joins, savedJoin];
}
setJoins(updatedJoins);
onJoinsUpdate?.(updatedJoins);
setModalVisible(false);
addSuccessToast(
join.id
? t('Join updated successfully')
: t('Join created successfully'),
);
} else {
throw new Error('Failed to save join');
}
} catch (error) {
console.error('Save join error:', error);
addDangerToast(
t('Failed to save join: %s', error?.message || String(error)),
);
} finally {
setLoading(false);
}
};
const getJoinTypeLabel = (type: JoinType) => {
const labels = {
[JoinType.INNER]: t('INNER'),
[JoinType.LEFT]: t('LEFT'),
[JoinType.RIGHT]: t('RIGHT'),
[JoinType.FULL]: t('FULL'),
[JoinType.CROSS]: t('CROSS'),
};
return labels[type] || type;
};
const columns = [
{
title: t('Source Table'),
dataIndex: 'source_table',
key: 'source_table',
render: (text: string) => (
<Typography.Text strong>{text}</Typography.Text>
),
},
{
title: t('Source Columns'),
dataIndex: 'source_columns',
key: 'source_columns',
render: (cols: string[] | string) => {
const columns = Array.isArray(cols)
? cols
: typeof cols === 'string'
? cols.split(',').map(c => c.trim()).filter(Boolean)
: [];
return <Typography.Text code>{columns.join(', ')}</Typography.Text>;
},
},
{
title: t('Join Type'),
dataIndex: 'join_type',
key: 'join_type',
render: (type: JoinType) => (
<JoinTypeTag joinType={type}>{getJoinTypeLabel(type)}</JoinTypeTag>
),
},
{
title: t('Target Table'),
dataIndex: 'target_table',
key: 'target_table',
render: (text: string) => (
<Typography.Text strong>{text}</Typography.Text>
),
},
{
title: t('Target Columns'),
dataIndex: 'target_columns',
key: 'target_columns',
render: (cols: string[] | string) => {
const columns = Array.isArray(cols)
? cols
: typeof cols === 'string'
? cols.split(',').map(c => c.trim()).filter(Boolean)
: [];
return <Typography.Text code>{columns.join(', ')}</Typography.Text>;
},
},
{
title: t('Cardinality'),
dataIndex: 'cardinality',
key: 'cardinality',
render: (cardinality: Cardinality) => (
<CardinalityTag>{cardinality}</CardinalityTag>
),
},
{
title: t('Description'),
dataIndex: 'semantic_context',
key: 'semantic_context',
ellipsis: true,
render: (text: string) => (
<Typography.Text
ellipsis={{ tooltip: text }}
style={{ maxWidth: 200 }}
>
{text || '-'}
</Typography.Text>
),
},
];
if (editable) {
columns.push({
title: t('Actions'),
key: 'actions',
fixed: 'right',
width: 100,
render: (_: unknown, record: Join) => (
<Space size="small">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEditJoin(record)}
size="small"
/>
<Popconfirm
title={t('Delete Join')}
description={t(
'Are you sure you want to delete this join relationship?',
)}
onConfirm={() => record.id && handleDeleteJoin(record.id)}
okText={t('Yes')}
cancelText={t('No')}
>
<Button
type="link"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>
</Space>
),
});
}
return (
<StyledTableContainer>
<HeaderContainer>
<div>
<Typography.Title level={4}>{t('Table Joins')}</Typography.Title>
<Typography.Text type="secondary">
{t('AI-detected and user-defined relationships between tables')}
</Typography.Text>
</div>
{editable && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddJoin}
>
{t('Add Join')}
</Button>
)}
</HeaderContainer>
<Table
columns={columns}
dataSource={joins}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total, range) =>
t('%s-%s of %s joins', range[0], range[1], total),
}}
scroll={{ x: 'max-content' }}
locale={{
emptyText: t('No joins defined yet'),
}}
/>
<JoinEditorModal
visible={modalVisible}
join={editingJoin}
tables={tables}
onSave={handleSaveJoin}
onCancel={() => setModalVisible(false)}
/>
</StyledTableContainer>
);
};
export default JoinsList;

View File

@@ -0,0 +1,22 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default as JoinEditorModal } from './JoinEditorModal';
export { default as JoinsList } from './JoinsList';
export type { Join, Table, Column } from './JoinEditorModal';
export { JoinType, Cardinality } from './JoinEditorModal';

View File

@@ -317,9 +317,26 @@ export function DatabaseSelector({
const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS;
function changeDatabase(
value: { label: string; value: number },
database: DatabaseValue,
value: { label: string; value: number } | undefined,
database: DatabaseValue | undefined,
) {
// Handle clearing the selection
if (!database) {
setCurrentDb(undefined);
setCurrentCatalog(undefined);
setCurrentSchema(undefined);
if (onDbChange) {
onDbChange(undefined);
}
if (onCatalogChange) {
onCatalogChange(undefined);
}
if (onSchemaChange) {
onSchemaChange(undefined);
}
return;
}
// the database id is actually stored in the value property; the ID is used
// for the DOM, so it can't be an integer
const databaseWithId = { ...database, id: database.value };
@@ -361,6 +378,7 @@ export function DatabaseSelector({
disabled={!isDatabaseSelectEnabled || readOnly}
options={loadDatabases}
sortComparator={sortComparator}
allowClear
/>,
null,
);

View File

@@ -106,6 +106,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const isTemplateDataset = currentDatasource.is_template_dataset;
const isReadOnly =
currentDatasource.is_managed_externally || isTemplateDataset;
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -345,17 +348,17 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
buttonStyle="primary"
data-test="datasource-modal-save"
onClick={onClickSave}
disabled={
isSaving ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
disabled={isSaving || errors.length > 0 || isReadOnly}
tooltip={
currentDatasource.is_managed_externally
isTemplateDataset
? t(
"This dataset is managed externally, and can't be edited in Superset",
'This dataset belongs to a template dashboard and cannot be modified.',
)
: ''
: currentDatasource.is_managed_externally
? t(
"This dataset is managed externally, and can't be edited in Superset",
)
: ''
}
>
{t('Save')}
@@ -364,6 +367,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}
responsive
>
{isTemplateDataset && (
<Alert type="info" banner closable={false}>
{t(
'This dataset belongs to a template dashboard and cannot be modified.',
)}
</Alert>
)}
<DatasourceEditor
showLoadingForImport
height={500}

View File

@@ -299,7 +299,8 @@ export const hydrateDashboard =
css: dashboard.css || '',
colorNamespace: metadata?.color_namespace || null,
colorScheme: metadata?.color_scheme || null,
editMode: canEdit && editMode,
// Templates cannot be edited - block edit mode even if URL param is set
editMode: canEdit && editMode && !metadata?.is_template,
isPublished: dashboard.published,
hasUnsavedChanges: false,
dashboardIsSaving: false,

View File

@@ -26,6 +26,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/components/Header';
import TemplatePreviewHeader from 'src/dashboard/components/TemplatePreviewHeader';
import { Icons } from '@superset-ui/core/components/Icons';
import IconButton from 'src/dashboard/components/IconButton';
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -50,6 +51,7 @@ import {
} from 'src/dashboard/actions/dashboardLayout';
import {
DASHBOARD_GRID_ID,
DASHBOARD_HEADER_ID,
DASHBOARD_ROOT_DEPTH,
DASHBOARD_ROOT_ID,
DashboardStandaloneMode,
@@ -70,6 +72,7 @@ import { getRootLevelTabsComponent, shouldFocusTabs } from './utils';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
import DashboardWrapper from './DashboardWrapper';
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
// @z-index-above-dashboard-charts + 1 = 11
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
@@ -386,6 +389,17 @@ const DashboardBuilder = () => {
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
);
// Template mode detection
const isTemplate = useSelector(selectIsTemplateDashboard);
const dashboardTitle = useSelector<RootState, string>(
state =>
state.dashboardLayout.present[DASHBOARD_HEADER_ID]?.meta?.text ||
'Dashboard',
);
const dashboardNumericId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
dispatch(setDirectPathToChild(pathToTabIndex));
@@ -510,7 +524,15 @@ const DashboardBuilder = () => {
const renderDraggableContent = useCallback(
({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
<div>
{!hideDashboardHeader && <DashboardHeader />}
{!hideDashboardHeader &&
(isTemplate ? (
<TemplatePreviewHeader
dashboardTitle={dashboardTitle}
dashboardId={dashboardNumericId}
/>
) : (
<DashboardHeader />
))}
{showFilterBar &&
filterBarOrientation === FilterBarOrientation.Horizontal && (
<FilterBar
@@ -519,7 +541,8 @@ const DashboardBuilder = () => {
/>
)}
{dropIndicatorProps && <div {...dropIndicatorProps} />}
{!isReport && topLevelTabs && !uiConfig.hideNav && (
{/* Hide tabs editing controls for templates */}
{!isReport && topLevelTabs && !uiConfig.hideNav && !isTemplate && (
<WithPopoverMenu
shouldFocus={shouldFocusTabs}
menuItems={[
@@ -544,16 +567,31 @@ const DashboardBuilder = () => {
/>
</WithPopoverMenu>
)}
{/* Render tabs without editing controls for templates */}
{!isReport && topLevelTabs && !uiConfig.hideNav && isTemplate && (
<DashboardComponent
id={topLevelTabs?.id}
parentId={DASHBOARD_ROOT_ID}
depth={DASHBOARD_ROOT_DEPTH + 1}
index={0}
renderTabContent={false}
renderHoverMenu={false}
onChangeTab={handleChangeTab}
/>
)}
</div>
),
[
nativeFiltersEnabled,
showFilterBar,
filterBarOrientation,
editMode,
handleChangeTab,
handleDeleteTopLevelTabs,
hideDashboardHeader,
isReport,
isTemplate,
dashboardTitle,
dashboardNumericId,
topLevelTabs,
uiConfig.hideNav,
],
@@ -646,8 +684,10 @@ const DashboardBuilder = () => {
</Droppable>
</StyledHeader>
<StyledContent fullSizeChartId={fullSizeChartId}>
{/* Don't show empty state with edit button for templates (they can't be edited) */}
{!editMode &&
!topLevelTabs &&
!isTemplate &&
dashboardLayout[DASHBOARD_GRID_ID]?.children?.length === 0 && (
<EmptyState
title={t('There are no charts added to this dashboard')}

View File

@@ -522,10 +522,15 @@ const Header = () => {
const metadataBar = useDashboardMetadataBar(dashboardInfo);
// Templates cannot be edited - block edit permission
const isTemplate = !!dashboardInfo.metadata?.is_template;
const userCanEdit =
dashboardInfo.dash_edit_perm && !dashboardInfo.is_managed_externally;
dashboardInfo.dash_edit_perm &&
!dashboardInfo.is_managed_externally &&
!isTemplate;
const userCanShare = dashboardInfo.dash_share_perm;
const userCanSaveAs = dashboardInfo.dash_save_perm;
// Templates cannot be saved as (use "Use this template" flow instead)
const userCanSaveAs = dashboardInfo.dash_save_perm && !isTemplate;
const userCanCurate =
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
findPermission('can_set_embedded', 'Dashboard', user.roles);

View File

@@ -61,6 +61,7 @@ import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
const RefreshTooltip = styled.div`
${({ theme }) => css`
@@ -168,14 +169,18 @@ const SliceHeaderControls = (
);
const theme = useTheme();
const isTemplate = useSelector(selectIsTemplateDashboard);
// Templates are read-only - disable editing capabilities
const canEditCrossFilters =
!isTemplate &&
useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
) &&
getChartMetadataRegistry()
.get(props.slice.viz_type)
?.behaviors?.includes(Behavior.InteractiveChart);
const canExplore = props.supersetCanExplore;
// Hide "Edit chart" for templates
const canExplore = !isTemplate && props.supersetCanExplore;
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
const datasetResource = useDatasetDrillInfo(

View File

@@ -0,0 +1,135 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/ui';
import { AIInfoBanner, Button, Breadcrumb } from '@superset-ui/core/components';
const HeaderContainer = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
background: ${theme.colorBgContainer};
border-bottom: 1px solid ${theme.colorBorder};
`}
`;
const TopBar = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
`}
`;
const LeftSection = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 4}px;
`}
`;
const RightSection = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
`}
`;
const AIBannerWrapper = styled.div`
${({ theme }) => css`
padding: 0 ${theme.sizeUnit * 4}px ${theme.sizeUnit * 3}px;
`}
`;
interface TemplatePreviewHeaderProps {
dashboardTitle: string;
dashboardId: number;
}
export const TemplatePreviewHeader: FC<TemplatePreviewHeaderProps> = ({
dashboardTitle,
dashboardId,
}) => {
const history = useHistory();
const handleBackToTemplates = () => {
history.push('/dashboard/templates/');
};
const handleUseTemplate = () => {
// Navigate to datasource connector page with the dashboard_id
history.push(`/datasource-connector/?dashboard_id=${dashboardId}`);
};
const breadcrumbItems = [
{
title: t('Dashboards'),
href: '/dashboard/list/',
},
{
title: t('Templates'),
href: '/dashboard/templates/',
},
{
title: dashboardTitle,
},
];
return (
<HeaderContainer data-test="template-preview-header">
<TopBar>
<LeftSection>
<Breadcrumb items={breadcrumbItems} />
</LeftSection>
<RightSection>
<Button
buttonStyle="tertiary"
onClick={handleBackToTemplates}
data-test="back-to-templates-button"
>
{t('Back to templates')}
</Button>
<Button
buttonStyle="primary"
onClick={handleUseTemplate}
data-test="use-this-template-button"
>
{t('Use this template')}
</Button>
</RightSection>
</TopBar>
<AIBannerWrapper>
<AIInfoBanner
text={t(
'This is a fully functioning dashboard that you can explore and test. If you use this as a template, you will be able to attach your real database connection to power this dashboard in no time.',
)}
data-test="template-preview-ai-hint"
/>
</AIBannerWrapper>
</HeaderContainer>
);
};
export default TemplatePreviewHeader;

View File

@@ -37,6 +37,7 @@ import { useChartCustomizationModal } from '../../ChartCustomization/useChartCus
import ChartCustomizationModal from '../../ChartCustomization/ChartCustomizationModal';
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import FilterConfigurationLink from '../FilterConfigurationLink';
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
type SelectedKey = FilterBarOrientation | string | number;
@@ -76,6 +77,7 @@ const FilterBarSettings = () => {
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const isTemplate = useSelector(selectIsTemplateDashboard);
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const dashboardId = useSelector<RootState, number>(
@@ -258,7 +260,8 @@ const FilterBarSettings = () => {
filterValues,
]);
if (!menuItems.length || !canEdit) {
// Hide settings for templates - templates are read-only
if (!menuItems.length || !canEdit || isTemplate) {
return null;
}

View File

@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { RootState } from 'src/dashboard/types';
/**
* Selector to check if the current dashboard is a template.
* Template dashboards are read-only and cannot be edited.
*
* Template metadata is stored in the nested "template_info" structure
* within the dashboard's metadata.
*/
export const selectIsTemplateDashboard = (state: RootState): boolean =>
!!state.dashboardInfo?.metadata?.template_info?.is_template;

View File

@@ -130,6 +130,21 @@ export type DashboardState = {
};
chartStates?: Record<string, any>;
};
/**
* Template metadata fields that can be stored either:
* 1. Nested under "template_info" key (new format after import)
* 2. At the top level of metadata (legacy format)
*/
export interface TemplateInfo {
is_template?: boolean;
is_featured_template?: boolean;
template_category?: string;
template_thumbnail_url?: string;
template_context?: string;
template_description?: string;
template_tags?: string[];
}
export type DashboardInfo = {
id: number;
common: {
@@ -150,6 +165,8 @@ export type DashboardInfo = {
map_label_colors: JsonObject;
cross_filters_enabled: boolean;
chart_customization_config?: ChartCustomizationItem[];
// Template metadata is stored in the nested template_info structure
template_info?: TemplateInfo;
};
crossFiltersEnabled: boolean;
filterBarOrientation: FilterBarOrientation;

View File

@@ -28,7 +28,7 @@ import {
} from '@superset-ui/core/components';
import { AlteredSliceTag } from 'src/components';
import { logging, SupersetClient, t } from '@superset-ui/core';
import { css } from '@apache-superset/core/ui';
import { css, Alert } from '@apache-superset/core/ui';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import { Icons } from '@superset-ui/core/components/Icons';
import PropertiesModal from 'src/explore/components/PropertiesModal';
@@ -102,6 +102,9 @@ export const ExploreChartHeader = ({
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
// Check if chart belongs to a template dashboard
const isTemplateChart = slice?.is_template_chart ?? false;
const updateCategoricalNamespace = useCallback(async () => {
const { dashboards } = metadata || {};
const dashboard =
@@ -185,6 +188,7 @@ export const ExploreChartHeader = ({
metadata?.dashboards,
showReportModal,
setCurrentReportDeleting,
isTemplateChart,
);
const metadataBar = useExploreMetadataBar(metadata, slice);
@@ -237,13 +241,22 @@ export const ExploreChartHeader = ({
return (
<>
{isTemplateChart && (
<Alert type="info" banner closable={false}>
{t(
'This chart belongs to a template dashboard and cannot be modified.',
)}
</Alert>
)}
<PageHeaderWithActions
editableTitleProps={{
title: sliceName ?? '',
// Disable title editing for template charts
canEdit:
!slice ||
canOverwrite ||
(slice?.owners || []).includes(user?.userId),
!isTemplateChart &&
(!slice ||
canOverwrite ||
(slice?.owners || []).includes(user?.userId)),
onSave: actions.updateChartTitle,
placeholder: t('Add the name of the chart'),
label: t('Chart title'),
@@ -275,27 +288,30 @@ export const ExploreChartHeader = ({
</div>
}
rightPanelAdditionalItems={
<Tooltip
title={
saveDisabled
? t('Add required control values to save chart')
: null
}
>
{/* needed to wrap button in a div - antd tooltip doesn't work with disabled button */}
<div>
<Button
buttonStyle="secondary"
onClick={showModal}
disabled={saveDisabled}
data-test="query-save-button"
css={saveButtonStyles}
icon={<Icons.SaveOutlined />}
>
{t('Save')}
</Button>
</div>
</Tooltip>
// Hide save button for template charts
isTemplateChart ? null : (
<Tooltip
title={
saveDisabled
? t('Add required control values to save chart')
: null
}
>
{/* needed to wrap button in a div - antd tooltip doesn't work with disabled button */}
<div>
<Button
buttonStyle="secondary"
onClick={showModal}
disabled={saveDisabled}
data-test="query-save-button"
css={saveButtonStyles}
icon={<Icons.SaveOutlined />}
>
{t('Save')}
</Button>
</div>
</Tooltip>
)
}
additionalActionsMenu={menu}
menuDropdownProps={{

View File

@@ -129,7 +129,7 @@ export const useExploreAdditionalActionsMenu = (
dashboards,
showReportModal,
setCurrentReportDeleting,
...rest
isTemplateChart = false,
) => {
const theme = useTheme();
const { addDangerToast, addSuccessToast } = useToasts();
@@ -435,8 +435,8 @@ export const useExploreAdditionalActionsMenu = (
const menu = useMemo(() => {
const menuItems = [];
// Edit chart properties
if (slice) {
// Edit chart properties - hidden for template charts
if (slice && !isTemplateChart) {
menuItems.push({
key: MENU_KEYS.EDIT_PROPERTIES,
label: t('Edit chart properties'),
@@ -820,8 +820,8 @@ export const useExploreAdditionalActionsMenu = (
// Divider
menuItems.push({ type: 'divider' });
// Report menu item
if (reportMenuItem) {
// Report menu item - hidden for template charts
if (reportMenuItem && !isTemplateChart) {
menuItems.push(reportMenuItem);
}
@@ -857,7 +857,7 @@ export const useExploreAdditionalActionsMenu = (
});
}
return <Menu selectable={false} items={menuItems} {...rest} />;
return <Menu selectable={false} items={menuItems} />;
}, [
addDangerToast,
canDownloadCSV,
@@ -882,6 +882,7 @@ export const useExploreAdditionalActionsMenu = (
theme.sizeUnit,
ownState,
hasExportCurrentView,
isTemplateChart,
]);
// Return streaming modal state and handlers for parent to render

View File

@@ -75,6 +75,7 @@ export type DatasetObject = {
metrics: MetricObject[];
extra?: string;
is_managed_externally: boolean;
is_template_dataset: boolean;
normalize_columns: boolean;
always_filter_main_dttm: boolean;
type: DatasourceType;

View File

@@ -40,7 +40,6 @@ import { DeleteModal, Loading } from '@superset-ui/core/components';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import DashboardCard from 'src/features/dashboards/DashboardCard';
import { Icons } from '@superset-ui/core/components/Icons';
import { navigateTo } from 'src/utils/navigationUtils';
import EmptyState from './EmptyState';
import SubMenu from './SubMenu';
import { WelcomeTable } from './types';
@@ -200,7 +199,7 @@ function DashboardTable({
name: t('Dashboard'),
buttonStyle: 'secondary',
onClick: () => {
navigateTo('/dashboard/new', { assign: true });
history.push('/dashboard/templates/');
},
},
{

View File

@@ -23,7 +23,7 @@ import {
import { TableTab } from 'src/views/CRUD/types';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { navigateTo } from 'src/utils/navigationUtils';
import { useHistory } from 'react-router-dom';
import { makeUrl } from 'src/utils/pathUtils';
import { WelcomeTable } from './types';
@@ -58,7 +58,7 @@ const LABELS = {
const REDIRECTS = {
create: {
[WelcomeTable.Charts]: '/chart/add',
[WelcomeTable.Dashboards]: '/dashboard/new',
[WelcomeTable.Dashboards]: '/dashboard/templates/',
[WelcomeTable.SavedQueries]: makeUrl('/sqllab?new=true'),
},
viewAll: {
@@ -75,6 +75,8 @@ export interface EmptyStateProps {
}
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
const history = useHistory();
const getActionButton = () => {
if (tableName === WelcomeTable.Recents) {
return null;
@@ -93,7 +95,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) {
<Button
buttonStyle="secondary"
onClick={() => {
navigateTo(url);
history.push(url);
}}
>
{isFavorite

View File

@@ -217,7 +217,7 @@ const RightMenu = ({
},
{
label: t('Dashboard'),
url: '/dashboard/new',
url: '/dashboard/templates/',
icon: (
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
),

View File

@@ -0,0 +1,163 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { SupersetClient } from '@superset-ui/core';
export type PollingStatus = 'idle' | 'polling' | 'completed' | 'error';
export interface UsePollingOptions<T> {
endpoint: string;
interval?: number;
enabled?: boolean;
isComplete?: (data: T) => boolean;
isError?: (data: T) => boolean;
onComplete?: (data: T) => void;
onError?: (error: Error | T) => void;
}
export interface UsePollingResult<T> {
data: T | null;
isPolling: boolean;
error: Error | null;
status: PollingStatus;
startPolling: () => void;
stopPolling: () => void;
}
const DEFAULT_INTERVAL = 2000;
export function usePolling<T>({
endpoint,
interval = DEFAULT_INTERVAL,
enabled = true,
isComplete = () => false,
isError = () => false,
onComplete,
onError,
}: UsePollingOptions<T>): UsePollingResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [status, setStatus] = useState<PollingStatus>('idle');
const intervalIdRef = useRef<NodeJS.Timeout | null>(null);
const mountedRef = useRef(true);
const hasStartedRef = useRef(false);
const savedCallbacksRef = useRef({
onComplete,
onError,
isComplete,
isError,
});
// Keep callbacks up to date
useEffect(() => {
savedCallbacksRef.current = { onComplete, onError, isComplete, isError };
}, [onComplete, onError, isComplete, isError]);
const stopPolling = useCallback(() => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
}, []);
const poll = useCallback(async () => {
if (!mountedRef.current) return;
try {
const response = await SupersetClient.get({ endpoint });
const responseData = response.json?.result as T;
if (!mountedRef.current) return;
setData(responseData);
// Check for error state in response
if (savedCallbacksRef.current.isError(responseData)) {
setStatus('error');
stopPolling();
savedCallbacksRef.current.onError?.(responseData);
return;
}
// Check for completion
if (savedCallbacksRef.current.isComplete(responseData)) {
setStatus('completed');
stopPolling();
savedCallbacksRef.current.onComplete?.(responseData);
}
} catch (err) {
if (!mountedRef.current) return;
const pollingError = err instanceof Error ? err : new Error(String(err));
setError(pollingError);
setStatus('error');
stopPolling();
savedCallbacksRef.current.onError?.(pollingError);
}
}, [endpoint, stopPolling]);
const startPolling = useCallback(() => {
if (intervalIdRef.current) {
stopPolling();
}
setStatus('polling');
setError(null);
// Initial poll
poll();
// Set up interval
intervalIdRef.current = setInterval(poll, interval);
}, [poll, interval, stopPolling]);
// Auto-start polling when enabled changes to true
useEffect(() => {
if (enabled && !hasStartedRef.current) {
hasStartedRef.current = true;
// Use setTimeout to avoid calling setState synchronously in the effect
setTimeout(() => {
if (mountedRef.current) {
startPolling();
}
}, 0);
}
}, [enabled, startPolling]);
// Cleanup on unmount
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
stopPolling();
};
}, [stopPolling]);
return {
data,
isPolling: status === 'polling',
error,
status,
startPolling,
stopPolling,
};
}
export default usePolling;

View File

@@ -25,7 +25,7 @@ import {
import { styled } from '@apache-superset/core/ui';
import { useSelector } from 'react-redux';
import { useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import rison from 'rison';
import {
createFetchRelated,
@@ -73,7 +73,6 @@ import DashboardCard from 'src/features/dashboards/DashboardCard';
import { DashboardStatus } from 'src/features/dashboards/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { findPermission } from 'src/utils/findPermission';
import { navigateTo } from 'src/utils/navigationUtils';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
const PAGE_SIZE = 25;
@@ -147,6 +146,7 @@ function DashboardList(props: DashboardListProps) {
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
const history = useHistory();
const canReadTag = findPermission('can_read', 'Tag', roles);
const {
@@ -722,7 +722,7 @@ function DashboardList(props: DashboardListProps) {
name: t('Dashboard'),
buttonStyle: 'primary',
onClick: () => {
navigateTo('/dashboard/new', { assign: true });
history.push('/dashboard/templates/');
},
});
}

View File

@@ -0,0 +1,360 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, ChangeEvent, useMemo, useState, useCallback } from 'react';
import Fuse from 'fuse.js';
import { t } from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/ui';
import { AIInfoBanner, Input, Collapse } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { DashboardTemplateTile } from './DashboardTemplateTile';
import {
BLANK_TEMPLATE,
FEATURED,
ALL_TEMPLATES,
OTHER_CATEGORY,
} from './constants';
import { DashboardTemplate, DashboardTemplateGalleryProps } from './types';
const GalleryLayout = styled.div`
display: grid;
grid-template-rows: auto auto minmax(100px, 1fr);
grid-template-columns: minmax(14em, auto) 5fr;
grid-template-areas:
'sidebar search'
'sidebar banner'
'sidebar main';
flex: 1;
min-height: 0;
overflow: hidden;
background: ${({ theme }) => theme.colorBgLayout};
`;
const LeftPane = styled.div`
${({ theme }) => css`
grid-area: sidebar;
display: flex;
flex-direction: column;
border-right: 1px solid ${theme.colorBorder};
overflow: auto;
.ant-collapse .ant-collapse-item {
.ant-collapse-header {
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorText};
padding-left: ${theme.sizeUnit * 2}px;
padding-bottom: ${theme.sizeUnit}px;
}
.ant-collapse-content .ant-collapse-content-box {
display: flex;
flex-direction: column;
padding: 0 ${theme.sizeUnit * 2}px;
}
}
`}
`;
const SearchWrapper = styled.div`
${({ theme }) => css`
grid-area: search;
margin-top: ${theme.sizeUnit * 3}px;
margin-bottom: ${theme.sizeUnit}px;
margin-left: ${theme.sizeUnit * 3}px;
margin-right: ${theme.sizeUnit * 3}px;
.ant-input-affix-wrapper {
padding-left: ${theme.sizeUnit * 2}px;
}
`}
`;
const AIBannerWrapper = styled.div`
${({ theme }) => css`
grid-area: banner;
margin-top: ${theme.sizeUnit * 2}px;
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px;
`}
`;
const MainContent = styled.div`
grid-area: main;
padding: ${({ theme }) => theme.sizeUnit * 4}px;
overflow-y: auto;
`;
const TileGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const SelectorLabel = styled.button`
${({ theme }) => css`
all: unset;
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
margin: ${theme.sizeUnit}px 0;
padding: 0 ${theme.sizeUnit}px;
border-radius: ${theme.borderRadius}px;
line-height: 2em;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
color: ${theme.colorText};
&:focus {
outline: initial;
}
&.selected {
background-color: ${theme.colorPrimary};
color: ${theme.colorTextLightSolid};
svg {
color: ${theme.colorTextLightSolid};
}
}
& > span[role='img'] {
margin-right: ${theme.sizeUnit * 2}px;
}
`}
`;
const NoResultsMessage = styled.div`
text-align: center;
padding: ${({ theme }) => theme.sizeUnit * 8}px;
color: ${({ theme }) => theme.colorTextTertiary};
`;
const InputIconAlignment = styled.div`
display: flex;
justify-content: center;
align-items: center;
color: ${({ theme }) => theme.colorIcon};
`;
export const DashboardTemplateGallery: FC<DashboardTemplateGalleryProps> = ({
templates,
loading,
onSelectTemplate,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState(ALL_TEMPLATES);
// Always prepend "Start from blank" as first item
const templatesWithBlank = useMemo(
() => [BLANK_TEMPLATE, ...templates],
[templates],
);
// Group templates by category
const templatesByCategory = useMemo(() => {
const grouped: Record<string, DashboardTemplate[]> = {};
templates.forEach(template => {
const cat = template.template_category || OTHER_CATEGORY;
if (!grouped[cat]) {
grouped[cat] = [];
}
grouped[cat].push(template);
});
return grouped;
}, [templates]);
// Get sorted category list
const categories = useMemo(() => {
const cats = Object.keys(templatesByCategory).sort();
// Move "Other" to end if it exists
const otherIndex = cats.indexOf(OTHER_CATEGORY);
if (otherIndex > -1) {
cats.splice(otherIndex, 1);
cats.push(OTHER_CATEGORY);
}
return cats;
}, [templatesByCategory]);
// Setup Fuse.js for fuzzy search (existing pattern from Chart Gallery)
const fuse = useMemo(
() =>
new Fuse(templates, {
keys: [
{ name: 'dashboard_title', weight: 4 },
{ name: 'template_description', weight: 1 },
],
threshold: 0.3,
ignoreLocation: true,
}),
[templates],
);
// Filter templates based on search and category
const filteredTemplates = useMemo(() => {
let result = templatesWithBlank;
// Apply search (skip blank template in search)
if (searchTerm) {
const searchResults = fuse.search(searchTerm).map(r => r.item);
result = [BLANK_TEMPLATE, ...searchResults];
}
// Apply category filter
if (selectedCategory === FEATURED) {
result = result.filter(t => t.id === null || t.is_featured_template);
} else if (selectedCategory !== ALL_TEMPLATES) {
result = result.filter(
t => t.id === null || t.template_category === selectedCategory,
);
}
return result;
}, [templatesWithBlank, searchTerm, selectedCategory, fuse]);
const handleSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
},
[],
);
const stopSearching = useCallback(() => {
setSearchTerm('');
}, []);
if (loading) {
return <div>{t('Loading templates...')}</div>;
}
return (
<GalleryLayout>
<LeftPane aria-label={t('Choose template type')} role="tablist">
<SelectorLabel
css={({ sizeUnit }) => css`
margin: ${sizeUnit * 2}px;
margin-bottom: 0;
`}
className={selectedCategory === ALL_TEMPLATES ? 'selected' : ''}
onClick={() => setSelectedCategory(ALL_TEMPLATES)}
tabIndex={0}
role="tab"
aria-selected={selectedCategory === ALL_TEMPLATES}
>
<Icons.Ballot iconSize="m" />
{ALL_TEMPLATES}
</SelectorLabel>
<SelectorLabel
css={({ sizeUnit }) => css`
margin: ${sizeUnit * 2}px;
margin-bottom: 0;
`}
className={selectedCategory === FEATURED ? 'selected' : ''}
onClick={() => setSelectedCategory(FEATURED)}
tabIndex={0}
role="tab"
aria-selected={selectedCategory === FEATURED}
>
<Icons.FireOutlined iconSize="m" />
{FEATURED}
</SelectorLabel>
{categories.length > 0 && (
<Collapse
expandIconPosition="end"
ghost
defaultActiveKey="categories"
items={[
{
key: 'categories',
label: <span className="header">{t('Categories')}</span>,
children: (
<>
{categories.map(category => (
<SelectorLabel
key={category}
className={
selectedCategory === category ? 'selected' : ''
}
onClick={() => setSelectedCategory(category)}
tabIndex={0}
role="tab"
aria-selected={selectedCategory === category}
>
<Icons.Category iconSize="m" />
{category} ({templatesByCategory[category].length})
</SelectorLabel>
))}
</>
),
},
]}
/>
)}
</LeftPane>
<SearchWrapper>
<Input
type="text"
value={searchTerm}
placeholder={t('Search templates...')}
onChange={handleSearchChange}
prefix={
<InputIconAlignment>
<Icons.SearchOutlined iconSize="m" />
</InputIconAlignment>
}
suffix={
<InputIconAlignment>
{searchTerm && (
<Icons.CloseOutlined iconSize="m" onClick={stopSearching} />
)}
</InputIconAlignment>
}
/>
</SearchWrapper>
<AIBannerWrapper>
<AIInfoBanner
text={t(
'Start from scratch with a blank dashboard, or pick a template from your preferred category to build a fully-functional dashboard connected to one of your database connections in no time.',
)}
data-test="templates-ai-hint"
/>
</AIBannerWrapper>
<MainContent>
<TileGrid>
{filteredTemplates.map(template => (
<DashboardTemplateTile
key={template.uuid}
template={template}
onClick={onSelectTemplate}
/>
))}
</TileGrid>
{filteredTemplates.length === 0 && (
<NoResultsMessage>
{t('No templates found matching your search.')}
</NoResultsMessage>
)}
</MainContent>
</GalleryLayout>
);
};

View File

@@ -0,0 +1,164 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, KeyboardEvent } from 'react';
import { t } from '@superset-ui/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/ui';
import { Tag, Card } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { DashboardTemplate } from './types';
const THUMBNAIL_HEIGHT = 180;
const StyledCard = styled(Card)`
${({ theme }) => css`
overflow: hidden;
cursor: pointer;
&:hover {
box-shadow: ${theme.boxShadow};
transition: box-shadow ${theme.motionDurationSlow} ease-in-out;
}
.ant-card-body {
padding: ${theme.sizeUnit * 2}px;
}
`}
`;
const ThumbnailWrapper = styled.div`
width: 100%;
height: ${THUMBNAIL_HEIGHT}px;
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
overflow: hidden;
background: ${({ theme }) => theme.colorFillTertiary};
display: flex;
align-items: center;
justify-content: center;
`;
const ThumbnailImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
`;
const TitleRow = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
`;
const Title = styled.div`
font-weight: ${({ theme }) => theme.fontWeightStrong};
font-size: ${({ theme }) => theme.fontSize}px;
color: ${({ theme }) => theme.colorText};
text-align: left;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Description = styled.p`
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorTextSecondary};
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
`;
const TagsWrapper = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
margin-top: auto;
`;
interface DashboardTemplateTileProps {
template: DashboardTemplate;
onClick: (template: DashboardTemplate) => void;
}
export const DashboardTemplateTile: FC<DashboardTemplateTileProps> = ({
template,
onClick,
}) => {
const isBlank = template.id === null;
const handleClick = () => onClick(template);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(template);
}
};
return (
<StyledCard
onClick={handleClick}
aria-label={template.dashboard_title}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<ThumbnailWrapper>
{template.template_thumbnail_url ? (
<ThumbnailImage
src={template.template_thumbnail_url}
alt={template.dashboard_title}
/>
) : (
<Icons.DashboardOutlined
iconSize="xxl"
css={(theme: SupersetTheme) => css`
color: ${theme.colorTextQuaternary};
`}
/>
)}
</ThumbnailWrapper>
<TitleRow>
<Title>{template.dashboard_title}</Title>
{template.is_featured_template && !isBlank && (
<Tag>{t('Featured')}</Tag>
)}
</TitleRow>
{template.template_description && (
<Description>{template.template_description}</Description>
)}
{template.template_tags && template.template_tags.length > 0 && (
<TagsWrapper>
{template.template_tags.map(tag => (
<Tag key={tag}>{tag}</Tag>
))}
</TagsWrapper>
)}
</StyledCard>
);
};

View File

@@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { DashboardTemplate } from './types';
export const BLANK_TEMPLATE: DashboardTemplate = {
id: null,
uuid: 'blank',
dashboard_title: t('Start from blank'),
template_description: t('Create a new dashboard from scratch'),
template_category: null,
is_featured_template: false,
is_template: false,
template_thumbnail_url: null,
template_tags: [],
};
export const FEATURED = t('Featured');
export const ALL_TEMPLATES = t('All Templates');
export const OTHER_CATEGORY = t('Other');

View File

@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { SupersetClient, t, logging } from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/ui';
import { navigateTo } from 'src/utils/navigationUtils';
import { DashboardTemplateGallery } from './DashboardTemplateGallery';
import { DashboardTemplate } from './types';
const PageContainer = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
height: 100%;
background: ${theme.colorBgLayout};
`}
`;
const PageHeader = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
padding: ${theme.sizeUnit * 4}px;
background: ${theme.colorBgContainer};
border-bottom: 1px solid ${theme.colorBorder};
`}
`;
const PageTitle = styled.h2`
${({ theme }) => css`
margin: 0;
font-size: ${theme.fontSizeHeading3}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
`}
`;
const PageSubtitle = styled.p`
${({ theme }) => css`
margin: ${theme.sizeUnit}px 0 0;
font-size: ${theme.fontSize}px;
color: ${theme.colorTextSecondary};
`}
`;
export default function DashboardTemplates() {
const [templates, setTemplates] = useState<DashboardTemplate[]>([]);
const [loading, setLoading] = useState(true);
const history = useHistory();
useEffect(() => {
SupersetClient.get({
endpoint: '/api/v1/dashboard/templates',
})
.then(({ json }) => {
setTemplates(json.result);
})
.catch(error => {
logging.error('Error fetching templates:', error);
})
.finally(() => {
setLoading(false);
});
}, []);
const handleSelectTemplate = (template: DashboardTemplate | null) => {
if (template === null || template.id === null) {
// /dashboard/new/ is a backend Flask route that creates a new dashboard
// and redirects to the edit mode. Use full page navigation.
navigateTo('/dashboard/new/');
} else {
// Existing templates - use React Router for SPA navigation
history.push(`/superset/dashboard/${template.uuid}/`);
}
};
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('Dashboard Templates')}</PageTitle>
<PageSubtitle>
{t('Choose a template to get started or create a blank dashboard')}
</PageSubtitle>
</PageHeader>
<DashboardTemplateGallery
templates={templates}
loading={loading}
onSelectTemplate={handleSelectTemplate}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface DashboardTemplate {
id: number | null; // null for "Start from blank"
uuid: string;
dashboard_title: string;
slug?: string;
template_description?: string;
template_category?: string | null;
is_featured_template: boolean;
is_template: boolean;
template_thumbnail_url?: string | null;
template_tags: string[];
template_context?: string;
}
export interface DashboardTemplateGalleryProps {
templates: DashboardTemplate[];
loading: boolean;
onSelectTemplate: (template: DashboardTemplate | null) => void;
}

View File

@@ -124,6 +124,7 @@ type Dataset = {
owners: Array<Owner>;
schema: string;
table_name: string;
is_template_dataset?: boolean;
};
interface VirtualDataset extends Dataset {
@@ -416,9 +417,37 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{
Cell: ({ row: { original } }: any) => {
// Verify owner or isAdmin
const allowEdit =
const isOwnerOrAdmin =
original.owners.map((o: Owner) => o.id).includes(user.userId) ||
isUserAdmin(user);
const isTemplate = original.is_template_dataset;
// Template datasets cannot be edited or deleted
const allowEdit = isOwnerOrAdmin && !isTemplate;
const allowDelete = !isTemplate;
const getEditTooltip = () => {
if (isTemplate) {
return t(
'This dataset belongs to a template dashboard and cannot be modified.',
);
}
if (!isOwnerOrAdmin) {
return t(
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
);
}
return t('Edit');
};
const getDeleteTooltip = () => {
if (isTemplate) {
return t(
'This dataset belongs to a template dashboard and cannot be deleted.',
);
}
return t('Delete');
};
const handleEdit = () => openDatasetEditModal(original);
const handleDelete = () => openDatasetDeleteModal(original);
@@ -432,14 +461,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{canDelete && (
<Tooltip
id="delete-action-tooltip"
title={t('Delete')}
title={getDeleteTooltip()}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleDelete}
className={`action-button ${allowDelete ? '' : 'disabled'}`}
onClick={allowDelete ? handleDelete : undefined}
>
<Icons.DeleteOutlined iconSize="l" />
</span>
@@ -464,13 +493,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{canEdit && (
<Tooltip
id="edit-action-tooltip"
title={
allowEdit
? t('Edit')
: t(
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
)
}
title={getEditTooltip()}
placement="bottom"
>
<span

View File

@@ -0,0 +1,239 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import DatasourceConnector from './index';
// Mock useHistory
const mockPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockPush,
}),
}));
// Mock DatabaseModal
jest.mock('src/features/databases/DatabaseModal', () => ({
__esModule: true,
default: ({ show, onHide, onDatabaseAdd }: any) =>
show ? (
<div data-testid="database-modal">
<button type="button" data-testid="modal-close" onClick={onHide}>
Close
</button>
<button
type="button"
data-testid="modal-add-db"
onClick={() =>
onDatabaseAdd({
id: 999,
database_name: 'New DB',
backend: 'sqlite',
})
}
>
Add Database
</button>
</div>
) : null,
}));
// Mock DatabaseSelector
jest.mock('src/components/DatabaseSelector', () => ({
__esModule: true,
DatabaseSelector: ({ db, schema, onDbChange, onSchemaChange }: any) => (
<div data-testid="database-selector">
<select
data-testid="database-select"
value={db?.id || ''}
onChange={e => {
const id = parseInt(e.target.value, 10);
if (id) {
onDbChange({ id, database_name: `DB ${id}`, backend: 'sqlite' });
}
}}
>
<option value="">Select database</option>
<option value="1">DB 1</option>
<option value="2">DB 2</option>
</select>
{db && (
<select
data-testid="schema-select"
value={schema || ''}
onChange={e => onSchemaChange(e.target.value || undefined)}
>
<option value="">Select schema</option>
<option value="main">main</option>
<option value="public">public</option>
</select>
)}
</div>
),
}));
// Mock withToasts
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (Component: any) => Component,
useToasts: () => ({
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
}),
}));
const renderComponent = () =>
render(<DatasourceConnector />, {
useRedux: true,
useRouter: true,
useTheme: true,
});
beforeEach(() => {
mockPush.mockClear();
});
test('renders step header with correct steps', async () => {
renderComponent();
expect(
await screen.findByText('Create Dashboard from Template'),
).toBeInTheDocument();
expect(screen.getByText('Connect Data Source')).toBeInTheDocument();
expect(screen.getByText('Review Schema')).toBeInTheDocument();
expect(screen.getByText('Generate Dashboard')).toBeInTheDocument();
});
test('renders centered panel with data source selection', async () => {
renderComponent();
expect(await screen.findByText('Select a data source')).toBeInTheDocument();
expect(
screen.getByText(
'Choose an existing database connection or add a new one to connect your data.',
),
).toBeInTheDocument();
});
test('schema select is hidden until database is chosen', async () => {
renderComponent();
// Initially, schema select should not be visible
expect(screen.queryByTestId('schema-select')).not.toBeInTheDocument();
// Select a database
const dbSelect = await screen.findByTestId('database-select');
await userEvent.selectOptions(dbSelect, '1');
// Now schema select should be visible
expect(await screen.findByTestId('schema-select')).toBeInTheDocument();
});
test('continue button is disabled until schema is chosen', async () => {
renderComponent();
const continueButton = await screen.findByRole('button', {
name: /continue to schema review/i,
});
expect(continueButton).toBeDisabled();
// Select a database
const dbSelect = await screen.findByTestId('database-select');
await userEvent.selectOptions(dbSelect, '1');
// Continue button should still be disabled (no schema selected)
expect(continueButton).toBeDisabled();
// Select a schema
const schemaSelect = await screen.findByTestId('schema-select');
await userEvent.selectOptions(schemaSelect, 'main');
// Now continue button should be enabled
expect(continueButton).toBeEnabled();
});
test('add connection button opens DatabaseModal', async () => {
renderComponent();
const addButton = await screen.findByRole('button', {
name: /add a new database connection/i,
});
await userEvent.click(addButton);
expect(await screen.findByTestId('database-modal')).toBeInTheDocument();
});
test('after modal success, modal closes', async () => {
renderComponent();
// Open modal
const addButton = await screen.findByRole('button', {
name: /add a new database connection/i,
});
await userEvent.click(addButton);
expect(await screen.findByTestId('database-modal')).toBeInTheDocument();
// Click Add Database in mock modal
const addDbButton = screen.getByTestId('modal-add-db');
await userEvent.click(addDbButton);
// Modal should be closed
await waitFor(() => {
expect(screen.queryByTestId('database-modal')).not.toBeInTheDocument();
});
});
test('continue navigates to review schema step', async () => {
renderComponent();
// Select database
const dbSelect = await screen.findByTestId('database-select');
await userEvent.selectOptions(dbSelect, '1');
// Select schema
const schemaSelect = await screen.findByTestId('schema-select');
await userEvent.selectOptions(schemaSelect, 'main');
// Click continue
const continueButton = screen.getByRole('button', {
name: /continue to schema review/i,
});
await userEvent.click(continueButton);
// Wait for the Review Schema panel to appear
await waitFor(() => {
expect(screen.getByText('Analyzing database schema')).toBeInTheDocument();
});
});
test('cancel button navigates to home', async () => {
renderComponent();
const cancelButton = await screen.findByRole('button', { name: /cancel/i });
await userEvent.click(cancelButton);
expect(mockPush).toHaveBeenCalledWith('/');
});

View File

@@ -0,0 +1,256 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { t } from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/ui';
import {
AIInfoBanner,
Flex,
Icons,
Typography,
} from '@superset-ui/core/components';
import { ConnectorStep } from '../types';
interface ConnectorLayoutProps {
currentStep: ConnectorStep;
children: ReactNode;
templateName?: string | null;
databaseName?: string | null;
}
const PageContainer = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
min-height: calc(100vh - 56px);
background-color: ${theme.colorBgBase};
`}
`;
const PageHeader = styled.div`
${({ theme }) => `
padding: ${theme.paddingMD}px ${theme.paddingLG}px;
background-color: ${theme.colorBgContainer};
border-bottom: 1px solid ${theme.colorBorder};
`}
`;
const StepsContainer = styled.div`
${({ theme }) => `
display: flex;
justify-content: center;
padding: ${theme.paddingXL}px 0;
background-color: ${theme.colorBgBase};
`}
`;
const StepCircle = styled.div<{ isActive: boolean; isCompleted: boolean }>`
${({ theme, isActive, isCompleted }) => `
width: ${theme.sizeUnit * 8}px;
height: ${theme.sizeUnit * 8}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
${
isActive || isCompleted
? `
background-color: ${theme.colorPrimary};
color: white;
`
: `
background-color: transparent;
border: 2px solid ${theme.colorTextSecondary};
color: ${theme.colorTextSecondary};
`
}
`}
`;
const StepTitle = styled.span<{ isActive: boolean; isCompleted: boolean }>`
${({ theme, isActive, isCompleted }) => `
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${isActive || isCompleted ? theme.colorText : theme.colorTextSecondary};
white-space: nowrap;
`}
`;
const StepConnector = styled.div<{ isCompleted: boolean }>`
${({ theme, isCompleted }) => `
width: ${theme.sizeUnit * 20}px;
height: 1px;
background-color: ${isCompleted ? theme.colorPrimary : theme.colorBorderSecondary};
margin: 0 ${theme.marginMD}px;
`}
`;
const ContentContainer = styled(Flex)`
${({ theme }) => `
flex: 1;
padding: ${theme.paddingLG}px;
`}
`;
const AIBannerWrapper = styled.div`
${({ theme }) => css`
width: 100%;
max-width: 600px;
margin-bottom: ${theme.marginLG}px;
`}
`;
interface StepConfig {
title: string;
icon: ReactNode;
}
const stepsConfig: StepConfig[] = [
{
title: t('Connect Data Source'),
icon: <Icons.DatabaseOutlined iconSize="m" />,
},
{
title: t('Review Schema'),
icon: <Icons.SearchOutlined iconSize="m" />,
},
{
title: t('Generate Dashboard'),
icon: <Icons.DashboardOutlined iconSize="m" />,
},
];
// Map internal steps to visual step index
// EDIT_SCHEMA is visually part of "Review Schema" step
// REVIEW_MAPPINGS and REVIEW_PENDING are visually part of "Generate Dashboard" step
function getVisualStepIndex(step: ConnectorStep): number {
switch (step) {
case ConnectorStep.CONNECT_DATA_SOURCE:
return 0;
case ConnectorStep.REVIEW_SCHEMA:
case ConnectorStep.EDIT_SCHEMA:
return 1;
case ConnectorStep.REVIEW_MAPPINGS:
case ConnectorStep.GENERATE_DASHBOARD:
case ConnectorStep.REVIEW_PENDING:
return 2;
default:
return 0;
}
}
function getAIBannerText(
currentStep: ConnectorStep,
templateName?: string | null,
databaseName?: string | null,
): string | null {
switch (currentStep) {
case ConnectorStep.CONNECT_DATA_SOURCE:
return templateName
? t(
'Choose a database connection to power the "%s" dashboard. AI will analyze your database schema and automatically connect the dashboard to your real data.',
templateName,
)
: null;
case ConnectorStep.REVIEW_SCHEMA:
return databaseName
? t(
'AI is analyzing your "%s" database schema. This may take a while for large databases.',
databaseName,
)
: t(
'AI is analyzing your database schema. This may take a while for large databases.',
);
case ConnectorStep.EDIT_SCHEMA:
return t(
'Review and edit the AI-generated schema descriptions below before generating your dashboard.',
);
case ConnectorStep.REVIEW_MAPPINGS:
return t(
'AI needs your help to map some columns. Please review the suggestions below.',
);
case ConnectorStep.GENERATE_DASHBOARD:
return templateName
? t(
'AI is customizing your dashboard from the "%s" template. The template is being adapted to match your data schema and generate meaningful visualizations.',
templateName,
)
: t(
'AI is customizing your dashboard. The template is being adapted to match your data schema and generate meaningful visualizations.',
);
default:
return null;
}
}
export default function ConnectorLayout({
currentStep,
children,
templateName,
databaseName,
}: ConnectorLayoutProps) {
const visualStep = getVisualStepIndex(currentStep);
const bannerText = getAIBannerText(currentStep, templateName, databaseName);
return (
<PageContainer>
<PageHeader>
<Typography.Title css={{ margin: 0 }} level={3}>
{t('Create Dashboard from Template')}
</Typography.Title>
</PageHeader>
<StepsContainer>
<Flex align="center" gap={0}>
{stepsConfig.map((step, index) => {
const isActive = index === visualStep;
const isCompleted = index < visualStep;
return (
<Flex key={step.title} align="center" gap={8}>
<StepCircle isActive={isActive} isCompleted={isCompleted}>
{isActive || isCompleted ? step.icon : index + 1}
</StepCircle>
<StepTitle isActive={isActive} isCompleted={isCompleted}>
{step.title}
</StepTitle>
{index < stepsConfig.length - 1 && (
<StepConnector isCompleted={isCompleted} />
)}
</Flex>
);
})}
</Flex>
</StepsContainer>
<ContentContainer vertical align="center">
{bannerText && (
<AIBannerWrapper>
<AIInfoBanner
text={bannerText}
data-test="datasource-connector-ai-hint"
/>
</AIBannerWrapper>
)}
{children}
</ContentContainer>
</PageContainer>
);
}

View File

@@ -0,0 +1,214 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useRef } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
AsyncProcessPanel,
type ProcessStep,
} from '@superset-ui/core/components';
import { usePolling } from 'src/hooks/usePolling';
interface FailedMapping {
type: string;
id: string;
name: string;
error: string;
alternatives: string[];
}
interface PendingReviewData {
dashboardId: number;
datasetId?: number;
reviewReasons: string[];
failedMappings: FailedMapping[];
quality: number;
}
interface DashboardGeneratorPanelProps {
runId: string;
templateName: string | null;
onComplete: (dashboardId: number) => void;
onError: (error: string) => void;
onPendingReview?: (data: PendingReviewData) => void;
}
interface GenerationStatusResponse {
status:
| 'reserved'
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'not_found'
| 'pending_review';
message?: string;
dashboard_id?: number;
error?: string;
requires_human_review?: boolean;
review_reasons?: string[];
failed_mappings?: FailedMapping[];
quality?: number;
}
const GENERATOR_STEPS: ProcessStep[] = [
{
key: 'analyzing',
title: t('Analyzing data schema'),
description: t('Understanding your database structure and relationships'),
},
{
key: 'creating',
title: t('Creating datasets'),
description: t('Mapping template charts to your tables'),
},
{
key: 'adapting',
title: t('Adapting queries'),
description: t('Rewriting chart queries for your schema'),
},
{
key: 'building',
title: t('Building dashboard'),
description: t('Generating final dashboard layout'),
},
];
const SIMULATED_STEP_DURATIONS = [2500, 3200, 3800];
const LAST_STEP_INDEX = GENERATOR_STEPS.length - 1;
const Container = styled.div`
display: flex;
justify-content: center;
width: 100%;
`;
const Subtitle = styled.span`
${({ theme }) => `
.template-name {
color: ${theme.colorPrimary};
font-weight: ${theme.fontWeightStrong};
}
`}
`;
export default function DashboardGeneratorPanel({
runId,
templateName,
onComplete,
onError,
onPendingReview,
}: DashboardGeneratorPanelProps) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const timeoutsRef = useRef<NodeJS.Timeout[]>([]);
// Polling for the last step
const { data, status: pollingStatus } = usePolling<GenerationStatusResponse>({
endpoint: `/api/v1/dashboard/generation/status/${runId}`,
interval: 2000,
enabled: true,
isComplete: data =>
data?.status === 'completed' || data?.status === 'pending_review',
isError: data => data?.status === 'failed' || data?.status === 'not_found',
onComplete: data => {
if (data?.status === 'pending_review') {
// Handle pending review - needs human intervention
if (onPendingReview && data.dashboard_id) {
onPendingReview({
dashboardId: data.dashboard_id,
datasetId: data.dataset_id,
reviewReasons: data.review_reasons || [],
failedMappings: data.failed_mappings || [],
quality: data.quality || 0,
});
} else if (data.dashboard_id) {
// Fallback: if no handler, still complete with dashboard_id
// User can fix issues manually in the dashboard
onComplete(data.dashboard_id);
}
} else if (data?.dashboard_id) {
onComplete(data.dashboard_id);
}
},
onError: err => {
if (err && typeof err === 'object' && 'message' in err) {
onError(
(err as GenerationStatusResponse).message ||
t('Dashboard generation failed'),
);
} else if (err instanceof Error) {
onError(err.message);
} else {
onError(t('Dashboard generation failed'));
}
},
});
// Simulate steps 0 through LAST_STEP_INDEX - 1
useEffect(() => {
const advanceSimulatedSteps = () => {
let accumulatedDelay = 0;
SIMULATED_STEP_DURATIONS.forEach((duration, index) => {
accumulatedDelay += duration;
const timeout = setTimeout(() => {
setCurrentStepIndex(index + 1);
}, accumulatedDelay);
timeoutsRef.current.push(timeout);
});
};
advanceSimulatedSteps();
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
};
}, []);
// Handle polling status changes
useEffect(() => {
if (pollingStatus === 'error' && data) {
const errorData = data as GenerationStatusResponse;
onError(
errorData.message ||
errorData.error ||
t('Dashboard generation failed'),
);
}
}, [pollingStatus, data, onError]);
const subtitleContent = (
<Subtitle>
{t('Generating dashboard from')}{' '}
<span className="template-name">{templateName || 'template'}</span>
</Subtitle>
);
return (
<Container>
<AsyncProcessPanel
title={t('Building your dashboard')}
subtitle={subtitleContent}
steps={GENERATOR_STEPS}
currentStepIndex={currentStepIndex}
/>
</Container>
);
}

View File

@@ -0,0 +1,256 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
Button,
Checkbox,
Collapse,
Divider,
Flex,
Icons,
Typography,
} from '@superset-ui/core/components';
import type { DatabaseObject } from 'src/components/DatabaseSelector/types';
import DatabaseSchemaPicker from './DatabaseSchemaPicker';
interface DataSourcePanelProps {
database: DatabaseObject | null;
catalog: string | null;
schema: string | null;
isSubmitting: boolean;
forceReanalyze: boolean;
hasExistingReport: boolean;
existingReportInfo?: string;
onDatabaseChange: (db: DatabaseObject | null) => void;
onCatalogChange: (catalog: string | null) => void;
onSchemaChange: (schema: string | null) => void;
onForceReanalyzeChange: (checked: boolean) => void;
onError: (msg: string) => void;
onAddNewDatabase: () => void;
onCancel: () => void;
onContinue: () => void;
}
const PanelContainer = styled.div`
${({ theme }) => `
width: 100%;
max-width: 600px;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingLG}px;
`}
`;
const IconCircle = styled.div`
${({ theme }) => `
width: ${theme.sizeUnit * 6}px;
height: ${theme.sizeUnit * 6}px;
flex-shrink: 0;
border-radius: ${theme.borderRadius}px;
display: flex;
align-items: center;
justify-content: center;
background-color: ${theme.colorPrimaryBg};
`}
`;
const FormSection = styled.div`
${({ theme }) => `
margin-bottom: ${theme.marginMD}px;
`}
`;
const StyledDivider = styled(Divider)`
${({ theme }) => `
margin: ${theme.marginLG}px 0;
.ant-divider-inner-text {
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
}
`}
`;
const AddDatabaseButton = styled(Button)`
${({ theme }) => `
width: 100%;
margin-bottom: ${theme.marginLG}px;
`}
`;
const FooterActionsWrapper = styled.div`
${({ theme }) => `
padding-top: ${theme.paddingMD}px;
border-top: 1px solid ${theme.colorBorder};
`}
`;
const PanelHeader = styled(Flex)`
${({ theme }) => `
margin-bottom: ${theme.marginLG}px;
`}
`;
const TitleRow = styled(Flex)`
${({ theme }) => `
margin-bottom: ${theme.marginXS}px;
align-items: baseline;
`}
`;
const AdvancedOptionsWrapper = styled.div`
${({ theme }) => `
margin-bottom: ${theme.marginMD}px;
.ant-collapse-header {
padding: ${theme.paddingSM}px 0 !important;
}
.ant-collapse-content-box {
padding: ${theme.paddingSM}px 0 !important;
}
`}
`;
export default function DataSourcePanel({
database,
catalog,
schema,
isSubmitting,
forceReanalyze,
hasExistingReport,
existingReportInfo,
onDatabaseChange,
onCatalogChange,
onSchemaChange,
onForceReanalyzeChange,
onError,
onAddNewDatabase,
onCancel,
onContinue,
}: DataSourcePanelProps) {
const canContinue = database !== null && !!schema && !isSubmitting;
return (
<PanelContainer>
<PanelHeader vertical align="flex-start">
<TitleRow align="center" gap={8}>
<Typography.Title css={{ margin: 0 }} level={5}>
{t('Select a data source')}
</Typography.Title>
<IconCircle>
<Icons.DatabaseOutlined iconSize="s" />
</IconCircle>
</TitleRow>
<Typography.Text type="secondary">
{t(
'Choose an existing database connection or add a new one to connect your data.',
)}
</Typography.Text>
</PanelHeader>
<FormSection>
<DatabaseSchemaPicker
database={database}
catalog={catalog}
schema={schema}
onDatabaseChange={onDatabaseChange}
onCatalogChange={onCatalogChange}
onSchemaChange={onSchemaChange}
onError={onError}
/>
</FormSection>
{hasExistingReport && (
<AdvancedOptionsWrapper>
<Collapse
ghost
defaultActiveKey={[]}
items={[
{
key: 'advanced',
label: (
<Typography.Text type="secondary">
<Icons.SettingOutlined iconSize="s" /> {t('Advanced options')}
</Typography.Text>
),
children: (
<Flex vertical gap="middle">
{existingReportInfo && (
<Flex align="center" gap="small">
<Icons.CheckCircleFilled
iconSize="s"
css={({ colorSuccess }) => ({ color: colorSuccess })}
/>
<Typography.Text type="secondary">
{existingReportInfo}
</Typography.Text>
</Flex>
)}
<Checkbox
checked={forceReanalyze}
onChange={e => onForceReanalyzeChange(e.target.checked)}
>
<Flex vertical gap="small">
<Typography.Text>
{t('Force database semantic remapping')}
</Typography.Text>
<Typography.Text type="secondary" css={{ fontSize: 12 }}>
{t(
'Re-analyze the database schema even if analysis data already exists. Use this if your schema has changed.',
)}
</Typography.Text>
</Flex>
</Checkbox>
</Flex>
),
},
]}
/>
</AdvancedOptionsWrapper>
)}
<StyledDivider>{t('or')}</StyledDivider>
<AddDatabaseButton
onClick={onAddNewDatabase}
icon={<Icons.PlusOutlined />}
disabled={!!database}
>
{t('Add a new database connection')}
</AddDatabaseButton>
<FooterActionsWrapper>
<Flex justify="space-between" align="center">
<Button onClick={onCancel}>{t('Cancel')}</Button>
<Button
buttonStyle="primary"
onClick={onContinue}
disabled={!canContinue}
loading={isSubmitting}
>
{t('Continue to Schema Review')}
</Button>
</Flex>
</FooterActionsWrapper>
</PanelContainer>
);
}

View File

@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { styled } from '@apache-superset/core/ui';
import { DatabaseSelector } from 'src/components';
import type { DatabaseObject } from 'src/components/DatabaseSelector/types';
interface DatabaseSchemaPickerProps {
database: DatabaseObject | null;
catalog: string | null;
schema: string | null;
onDatabaseChange: (db: DatabaseObject | null) => void;
onCatalogChange: (catalog: string | null) => void;
onSchemaChange: (schema: string | null) => void;
onError: (msg: string) => void;
onRefreshDatabases?: () => void;
}
const PickerContainer = styled.div`
${({ theme }) => `
width: 100%;
& > div {
margin-bottom: ${theme.paddingMD}px;
}
`}
`;
export default function DatabaseSchemaPicker({
database,
catalog,
schema,
onDatabaseChange,
onCatalogChange,
onSchemaChange,
onError,
onRefreshDatabases,
}: DatabaseSchemaPickerProps) {
const handleDbChange = useCallback(
(db: DatabaseObject) => {
onDatabaseChange(db);
// Clear catalog and schema when database changes
onCatalogChange(null);
onSchemaChange(null);
},
[onDatabaseChange, onCatalogChange, onSchemaChange],
);
const handleCatalogChange = useCallback(
(cat?: string) => {
onCatalogChange(cat ?? null);
// Clear schema when catalog changes
onSchemaChange(null);
},
[onCatalogChange, onSchemaChange],
);
const handleSchemaChange = useCallback(
(sch?: string) => {
onSchemaChange(sch ?? null);
},
[onSchemaChange],
);
return (
<PickerContainer>
<DatabaseSelector
db={database}
catalog={catalog}
schema={schema ?? undefined}
onDbChange={handleDbChange}
onCatalogChange={handleCatalogChange}
onSchemaChange={handleSchemaChange}
handleError={onError}
formMode
getDbList={onRefreshDatabases}
/>
</PickerContainer>
);
}

View File

@@ -0,0 +1,175 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useRef } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
AsyncProcessPanel,
type ProcessStep,
} from '@superset-ui/core/components';
import { usePolling } from 'src/hooks/usePolling';
interface DatasourceAnalyzerPanelProps {
runId: string;
databaseName: string | null;
onComplete: (reportId: number) => void;
onError: (error: string) => void;
}
interface AnalysisStatusResponse {
status: 'pending' | 'running' | 'completed' | 'failed' | 'not_found';
message?: string;
database_report_id?: number;
error?: string;
error_message?: string;
}
const ANALYZER_STEPS: ProcessStep[] = [
{
key: 'connecting',
title: t('Connecting to database'),
description: t('Establishing secure connection'),
},
{
key: 'scanning',
title: t('Scanning schema'),
description: t('Discovering tables, views, and relationships'),
},
{
key: 'analyzing',
title: t('Analyzing structure'),
description: t('Identifying primary keys, foreign keys, and data types'),
},
{
key: 'ai_interpretation',
title: t('AI interpretation'),
description: t('Generating semantic descriptions for tables and columns'),
},
];
const SIMULATED_STEP_DURATIONS = [2500, 3200, 3800];
const LAST_STEP_INDEX = ANALYZER_STEPS.length - 1;
const Container = styled.div`
display: flex;
justify-content: center;
width: 100%;
`;
const Subtitle = styled.span`
${({ theme }) => `
.database-name {
color: ${theme.colorPrimary};
font-weight: ${theme.fontWeightStrong};
}
`}
`;
export default function DatasourceAnalyzerPanel({
runId,
databaseName,
onComplete,
onError,
}: DatasourceAnalyzerPanelProps) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const timeoutsRef = useRef<NodeJS.Timeout[]>([]);
// Polling for the last step
const { data, status: pollingStatus } = usePolling<AnalysisStatusResponse>({
endpoint: `/api/v1/datasource/analysis/status/${runId}`,
interval: 2000,
enabled: true,
isComplete: data => data?.status === 'completed',
isError: data => data?.status === 'failed' || data?.status === 'not_found',
onComplete: data => {
if (data?.database_report_id) {
onComplete(data.database_report_id);
}
},
onError: err => {
if (err && typeof err === 'object') {
const errData = err as AnalysisStatusResponse;
onError(
errData.error_message ||
errData.message ||
errData.error ||
t('Analysis failed'),
);
} else if (err instanceof Error) {
onError(err.message);
} else {
onError(t('Analysis failed'));
}
},
});
// Simulate steps 0 through LAST_STEP_INDEX - 1
useEffect(() => {
const advanceSimulatedSteps = () => {
let accumulatedDelay = 0;
SIMULATED_STEP_DURATIONS.forEach((duration, index) => {
accumulatedDelay += duration;
const timeout = setTimeout(() => {
setCurrentStepIndex(index + 1);
}, accumulatedDelay);
timeoutsRef.current.push(timeout);
});
};
advanceSimulatedSteps();
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
};
}, []);
// Handle polling status changes
useEffect(() => {
if (pollingStatus === 'error' && data) {
const errorData = data as AnalysisStatusResponse;
onError(
errorData.error_message ||
errorData.message ||
errorData.error ||
t('Analysis failed'),
);
}
}, [pollingStatus, data, onError]);
const subtitleContent = (
<Subtitle>
{t('Connected to')}{' '}
<span className="database-name">{databaseName || 'database'}</span>
</Subtitle>
);
return (
<Container>
<AsyncProcessPanel
title={t('Analyzing database schema')}
subtitle={subtitleContent}
steps={ANALYZER_STEPS}
currentStepIndex={currentStepIndex}
/>
</Container>
);
}

View File

@@ -0,0 +1,241 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
AIInfoBanner,
Button,
Flex,
Icons,
Spin,
Typography,
} from '@superset-ui/core/components';
import useSchemaReport from '../hooks/useSchemaReport';
import useSchemaEditorMutations from '../hooks/useSchemaEditorMutations';
import SchemaTreeView from './SchemaTreeView';
import SchemaDetailPanel from './SchemaDetailPanel';
import type { SchemaSelection } from '../types';
import { JoinsList } from '../../../components/DatabaseSchemaEditor';
interface DatasourceEditorPanelProps {
reportId: number;
dashboardId: number | null;
onBack: () => void;
onConfirm: (runId: string) => void;
}
const EditorContainer = styled.div`
${({ theme }) => `
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: ${theme.marginLG}px;
`}
`;
const ContentGrid = styled.div`
${({ theme }) => `
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${theme.marginLG}px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
`}
`;
const FooterActions = styled(Flex)`
${({ theme }) => `
padding-top: ${theme.paddingMD}px;
border-top: 1px solid ${theme.colorBorder};
`}
`;
const LoadingContainer = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingXL}px;
min-height: 400px;
`}
`;
const ErrorContainer = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingXL}px;
background-color: ${theme.colorErrorBg};
border: 1px solid ${theme.colorError};
border-radius: ${theme.borderRadius}px;
`}
`;
const AIBannerWrapper = styled.div`
${({ theme }) => `
width: 100%;
margin-bottom: ${theme.marginMD}px;
`}
`;
export default function DatasourceEditorPanel({
reportId,
dashboardId,
onBack,
onConfirm,
}: DatasourceEditorPanelProps) {
const { report, loading, error, refetch } = useSchemaReport(reportId);
const {
updateTableDescription,
updateColumnDescription,
generateDashboard,
mutationState,
} = useSchemaEditorMutations();
const [selection, setSelection] = useState<SchemaSelection>(null);
const [isGenerating, setIsGenerating] = useState(false);
const handleSelectionChange = useCallback((newSelection: SchemaSelection) => {
setSelection(newSelection);
}, []);
const handleUpdateTableDescription = useCallback(
async (tableId: number, description: string | null): Promise<boolean> => {
const success = await updateTableDescription(tableId, description);
if (success) {
refetch();
}
return success;
},
[updateTableDescription, refetch],
);
const handleUpdateColumnDescription = useCallback(
async (columnId: number, description: string | null): Promise<boolean> => {
const success = await updateColumnDescription(columnId, description);
if (success) {
refetch();
}
return success;
},
[updateColumnDescription, refetch],
);
const handleConfirmAndGenerate = useCallback(async () => {
if (!dashboardId) {
return;
}
setIsGenerating(true);
const runId = await generateDashboard(reportId, dashboardId);
setIsGenerating(false);
if (runId) {
onConfirm(runId);
}
}, [reportId, dashboardId, generateDashboard, onConfirm]);
if (loading) {
return (
<EditorContainer>
<LoadingContainer vertical align="center" justify="center">
<Spin size="large" />
<Typography.Text type="secondary" css={{ marginTop: 16 }}>
{t('Loading schema report...')}
</Typography.Text>
</LoadingContainer>
</EditorContainer>
);
}
if (error || !report) {
return (
<EditorContainer>
<ErrorContainer vertical align="center" gap={16}>
<Icons.ExclamationCircleOutlined iconSize="xl" iconColor="error" />
<Typography.Text type="danger">
{error || t('Failed to load schema report')}
</Typography.Text>
<Button onClick={refetch}>{t('Retry')}</Button>
</ErrorContainer>
<FooterActions justify="flex-start">
<Button onClick={onBack}>{t('Back')}</Button>
</FooterActions>
</EditorContainer>
);
}
return (
<EditorContainer>
<AIBannerWrapper>
<AIInfoBanner
text={t(
'Review and edit AI-generated descriptions for your tables and columns. These descriptions help improve data understanding and dashboard generation accuracy.',
)}
data-test="schema-editor-ai-hint"
/>
</AIBannerWrapper>
<ContentGrid>
<SchemaTreeView
tables={report.tables}
selection={selection}
onSelectionChange={handleSelectionChange}
schemaName={report.schema_name}
/>
<SchemaDetailPanel
selection={selection}
onUpdateTableDescription={handleUpdateTableDescription}
onUpdateColumnDescription={handleUpdateColumnDescription}
isUpdating={mutationState.loading}
/>
</ContentGrid>
<JoinsList
databaseReportId={reportId}
joins={report.joins}
tables={report.tables.map(table => ({
id: table.id,
name: table.name,
columns: table.columns.map(col => ({
id: col.id,
name: col.name,
type: col.type,
})),
}))}
onJoinsUpdate={() => {
// Refetch the report to get updated joins
refetch();
}}
/>
<FooterActions justify="space-between" align="center">
<Button onClick={onBack} disabled={isGenerating}>
{t('Back')}
</Button>
<Button
buttonStyle="primary"
onClick={handleConfirmAndGenerate}
loading={isGenerating}
disabled={!dashboardId}
>
{t('Confirm Schema & Generate Dashboard')}
</Button>
</FooterActions>
</EditorContainer>
);
}

View File

@@ -0,0 +1,150 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback, useEffect } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import {
Button,
Flex,
Icons,
Input,
Typography,
} from '@superset-ui/core/components';
const { TextArea } = Input;
interface EditableDescriptionProps {
description: string | null;
placeholder?: string;
onSave: (description: string | null) => Promise<boolean>;
isUpdating: boolean;
}
const SectionTitle = styled(Flex)`
${({ theme }) => `
margin-bottom: ${theme.marginSM}px;
`}
`;
const DescriptionBox = styled.div`
${({ theme }) => `
background-color: ${theme.colorBgLayout};
border: 1px solid ${theme.colorBorderSecondary};
border-radius: ${theme.borderRadiusSM}px;
padding: ${theme.paddingSM}px;
min-height: 80px;
`}
`;
const EditActions = styled(Flex)`
${({ theme }) => `
margin-top: ${theme.marginSM}px;
`}
`;
export default function EditableDescription({
description,
placeholder,
onSave,
isUpdating,
}: EditableDescriptionProps) {
const [isEditing, setIsEditing] = useState(false);
const [editedDescription, setEditedDescription] = useState('');
// Reset editing state when description changes externally
useEffect(() => {
if (!isEditing) {
setEditedDescription(description || '');
}
}, [description, isEditing]);
const handleStartEdit = useCallback(() => {
setEditedDescription(description || '');
setIsEditing(true);
}, [description]);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setEditedDescription(description || '');
}, [description]);
const handleSaveDescription = useCallback(async () => {
const success = await onSave(editedDescription || null);
if (success) {
setIsEditing(false);
}
}, [editedDescription, onSave]);
return (
<>
<SectionTitle align="center" justify="space-between">
<Typography.Text strong>{t('AI-Generated Description')}</Typography.Text>
{!isEditing && (
<Button
buttonSize="small"
buttonStyle="link"
onClick={handleStartEdit}
icon={<Icons.EditOutlined />}
>
{t('Edit')}
</Button>
)}
</SectionTitle>
{isEditing ? (
<>
<TextArea
value={editedDescription}
onChange={e => setEditedDescription(e.target.value)}
rows={4}
placeholder={placeholder || t('Enter a description...')}
/>
<EditActions gap={8} justify="flex-end">
<Button
buttonSize="small"
buttonStyle="tertiary"
onClick={handleCancelEdit}
disabled={isUpdating}
>
{t('Cancel')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={handleSaveDescription}
loading={isUpdating}
>
{t('Save')}
</Button>
</EditActions>
</>
) : (
<DescriptionBox>
<Typography.Text>
{description || (
<Typography.Text type="secondary" italic>
{t('No description available')}
</Typography.Text>
)}
</Typography.Text>
</DescriptionBox>
)}
</>
);
}

View File

@@ -0,0 +1,404 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback, useMemo } from 'react';
import { t } from '@superset-ui/core';
import { styled, css } from '@apache-superset/core/ui';
import {
Button,
Flex,
Typography,
Tag,
Select,
Collapse,
InfoTooltip,
} from '@superset-ui/core/components';
import { AIInfoBanner } from '@superset-ui/core/components';
const PanelContainer = styled(Flex)`
${({ theme }) => css`
width: 100%;
max-width: 800px;
gap: ${theme.marginLG}px;
padding: ${theme.paddingLG}px;
`}
`;
const MappingCard = styled.div`
${({ theme }) => css`
background: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingMD}px;
display: flex;
flex-direction: column;
gap: ${theme.marginSM}px;
`}
`;
const MappingRow = styled(Flex)`
${({ theme }) => css`
gap: ${theme.marginMD}px;
align-items: center;
`}
`;
const ColumnName = styled(Typography.Text)`
${({ theme }) => css`
font-family: monospace;
font-size: ${theme.fontSize}px;
background: ${theme.colorBgLayout};
padding: 2px 8px;
border-radius: ${theme.borderRadiusSM}px;
`}
`;
const ArrowIcon = styled.span`
${({ theme }) => css`
color: ${theme.colorTextSecondary};
font-size: ${theme.fontSizeLG}px;
`}
`;
const SelectContainer = styled.div`
min-width: 220px;
`;
const ButtonRow = styled(Flex)`
${({ theme }) => css`
gap: ${theme.marginMD}px;
margin-top: ${theme.marginMD}px;
`}
`;
const ConfidenceTag = styled(Tag)<{ $level: string }>`
${({ theme, $level }) => {
let color = theme.colorError;
if ($level === 'high') color = theme.colorSuccess;
else if ($level === 'medium') color = theme.colorWarning;
return css`
background: ${color}20;
border-color: ${color};
color: ${color};
`;
}}
`;
const ReasonText = styled(Typography.Text)`
${({ theme }) => css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
`}
`;
interface ColumnMapping {
template_column: string;
user_column: string | null;
user_table: string | null;
confidence: number;
confidence_level: 'high' | 'medium' | 'low' | 'failed';
match_reasons: string[];
alternatives: Array<{
column: string;
table: string;
confidence: number;
}>;
}
interface MetricMapping {
template_metric: string;
user_expression: string | null;
confidence: number;
confidence_level: 'high' | 'medium' | 'low' | 'failed';
match_reasons: string[];
alternatives: string[];
}
interface MappingProposal {
proposal_id: string;
column_mappings: ColumnMapping[];
metric_mappings: MetricMapping[];
unmapped_columns: string[];
unmapped_metrics: string[];
review_reasons: string[];
overall_confidence: number;
}
interface AdjustedMappings {
columns: Record<string, { column: string; table: string }>;
metrics: Record<string, string>;
}
interface MappingReviewPanelProps {
proposal: MappingProposal;
onApprove: (adjustments: AdjustedMappings) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export default function MappingReviewPanel({
proposal,
onApprove,
onCancel,
isSubmitting = false,
}: MappingReviewPanelProps) {
const [adjustments, setAdjustments] = useState<AdjustedMappings>({
columns: {},
metrics: {},
});
// Only show problematic mappings (LOW confidence or unmapped)
const problematicColumns = useMemo(
() =>
proposal.column_mappings.filter(
m => m.confidence_level === 'low' || m.confidence_level === 'failed',
),
[proposal.column_mappings],
);
const problematicMetrics = useMemo(
() =>
proposal.metric_mappings.filter(
m => m.confidence_level === 'low' || m.confidence_level === 'failed',
),
[proposal.metric_mappings],
);
const handleColumnChange = useCallback(
(templateColumn: string, value: string) => {
// value format: "table.column"
const [table, column] = value.split('.');
setAdjustments(prev => ({
...prev,
columns: {
...prev.columns,
[templateColumn]: { column, table },
},
}));
},
[],
);
const handleMetricChange = useCallback(
(templateMetric: string, expression: string) => {
setAdjustments(prev => ({
...prev,
metrics: {
...prev.metrics,
[templateMetric]: expression,
},
}));
},
[],
);
const handleApprove = useCallback(() => {
onApprove(adjustments);
}, [onApprove, adjustments]);
const getConfidenceLabel = (level: string): string => {
switch (level) {
case 'high':
return t('High');
case 'medium':
return t('Medium');
case 'low':
return t('Low');
case 'failed':
return t('Not Found');
default:
return level;
}
};
const formatPercentage = (value: number): string =>
`${Math.round(value * 100)}%`;
return (
<PanelContainer vertical align="center">
<AIInfoBanner
text={t(
'Some column mappings need your attention. Please review and fix the highlighted items below to ensure your dashboard works correctly.',
)}
dismissible={false}
/>
<Typography.Title level={4} css={{ margin: 0, textAlign: 'center' }}>
{t('Review Required Mappings')}
</Typography.Title>
<Typography.Text type="secondary" css={{ textAlign: 'center' }}>
{t(
'%s columns and %s metrics need review. The remaining mappings have high confidence and will be applied automatically.',
problematicColumns.length,
problematicMetrics.length,
)}
</Typography.Text>
{problematicColumns.length > 0 && (
<Collapse
defaultActiveKey={['columns']}
css={{ width: '100%' }}
items={[
{
key: 'columns',
label: t('Column Mappings (%s issues)', problematicColumns.length),
children: (
<Flex vertical gap="small">
{problematicColumns.map(mapping => (
<MappingCard key={mapping.template_column}>
<MappingRow>
<ColumnName>{mapping.template_column}</ColumnName>
<ArrowIcon></ArrowIcon>
<SelectContainer>
<Select
value={
adjustments.columns[mapping.template_column]
? `${adjustments.columns[mapping.template_column].table}.${adjustments.columns[mapping.template_column].column}`
: mapping.user_column
? `${mapping.user_table}.${mapping.user_column}`
: undefined
}
onChange={value =>
handleColumnChange(
mapping.template_column,
value as string,
)
}
placeholder={t('Select column')}
css={{ width: '100%' }}
options={[
...(mapping.user_column
? [
{
label: `${mapping.user_table}.${mapping.user_column}`,
value: `${mapping.user_table}.${mapping.user_column}`,
},
]
: []),
...mapping.alternatives.map(alt => ({
label: `${alt.table}.${alt.column}`,
value: `${alt.table}.${alt.column}`,
})),
]}
/>
</SelectContainer>
<ConfidenceTag $level={mapping.confidence_level}>
{getConfidenceLabel(mapping.confidence_level)} (
{formatPercentage(mapping.confidence)})
</ConfidenceTag>
{mapping.match_reasons.length > 0 && (
<InfoTooltip
tooltip={mapping.match_reasons.join(', ')}
/>
)}
</MappingRow>
{mapping.match_reasons.length > 0 && (
<ReasonText>
{t('Reason')}: {mapping.match_reasons.join(', ')}
</ReasonText>
)}
</MappingCard>
))}
</Flex>
),
},
]}
/>
)}
{problematicMetrics.length > 0 && (
<Collapse
defaultActiveKey={['metrics']}
css={{ width: '100%' }}
items={[
{
key: 'metrics',
label: t('Metric Mappings (%s issues)', problematicMetrics.length),
children: (
<Flex vertical gap="small">
{problematicMetrics.map(mapping => (
<MappingCard key={mapping.template_metric}>
<MappingRow>
<ColumnName>{mapping.template_metric}</ColumnName>
<ArrowIcon></ArrowIcon>
<SelectContainer>
<Select
value={
adjustments.metrics[mapping.template_metric] ||
mapping.user_expression ||
undefined
}
onChange={value =>
handleMetricChange(
mapping.template_metric,
value as string,
)
}
placeholder={t('Select expression')}
css={{ width: '100%' }}
options={[
...(mapping.user_expression
? [
{
label: mapping.user_expression,
value: mapping.user_expression,
},
]
: []),
...mapping.alternatives.map(alt => ({
label: alt,
value: alt,
})),
]}
/>
</SelectContainer>
<ConfidenceTag $level={mapping.confidence_level}>
{getConfidenceLabel(mapping.confidence_level)} (
{formatPercentage(mapping.confidence)})
</ConfidenceTag>
</MappingRow>
{mapping.match_reasons.length > 0 && (
<ReasonText>
{t('Reason')}: {mapping.match_reasons.join(', ')}
</ReasonText>
)}
</MappingCard>
))}
</Flex>
),
},
]}
/>
)}
<ButtonRow justify="center">
<Button onClick={onCancel} disabled={isSubmitting}>
{t('Cancel')}
</Button>
<Button
buttonStyle="primary"
onClick={handleApprove}
loading={isSubmitting}
>
{t('Approve & Generate')}
</Button>
</ButtonRow>
</PanelContainer>
);
}

View File

@@ -0,0 +1,226 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, SupersetClient } from '@superset-ui/core';
import { useState, useCallback } from 'react';
import { styled, css } from '@apache-superset/core/ui';
import {
Button,
Flex,
Space,
Typography,
} from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
interface PendingReviewPanelProps {
dashboardId: number;
datasetId: number | null;
failedMappings: Array<{
type: string;
id: string;
name: string;
error: string;
alternatives?: string[];
}>;
reviewReasons: string[];
onBack: () => void;
}
const Container = styled(Flex)`
${({ theme }) => css`
width: 100%;
max-width: 800px;
gap: ${theme.marginLG}px;
padding: ${theme.paddingLG}px;
`}
`;
const Card = styled.div`
${({ theme }) => css`
background: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingXL}px ${theme.paddingXL}px ${theme.paddingLG}px
${theme.paddingXL}px;
width: 100%;
`}
`;
const IssueCard = styled.div`
${({ theme }) => css`
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadiusSM}px;
padding: ${theme.paddingSM}px;
background: ${theme.colorBgLayout};
`}
`;
const WarningBox = styled.div`
${({ theme }) => css`
border: 1px solid ${theme.colorWarning};
background: ${theme.colorWarning}20;
color: ${theme.colorWarning};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingSM}px ${theme.paddingMD}px;
`}
`;
export default function PendingReviewPanel({
dashboardId,
datasetId,
failedMappings,
reviewReasons,
onBack,
}: PendingReviewPanelProps) {
const [issues, setIssues] = useState(failedMappings);
const { addSuccessToast, addDangerToast } = useToasts();
const removeIssue = useCallback((id: string) => {
setIssues(prev => prev.filter(item => item.id !== id));
}, []);
const deleteChart = useCallback(
async (chartId: string) => {
try {
await SupersetClient.delete({
endpoint: `/api/v1/chart/${chartId}`,
});
addSuccessToast(t('Chart %s deleted', chartId));
removeIssue(chartId);
} catch (err) {
addDangerToast(t('Failed to delete chart %s', chartId));
}
},
[addSuccessToast, addDangerToast, removeIssue],
);
const deleteFilter = useCallback(
async (filterId: string) => {
try {
const resp = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
});
const dashboard = resp.json?.result;
const metadata =
typeof dashboard?.json_metadata === 'string'
? JSON.parse(dashboard.json_metadata)
: dashboard?.json_metadata || {};
const filters = metadata.native_filter_configuration || [];
const nextFilters = filters.filter((f: any) => f?.id !== filterId);
metadata.native_filter_configuration = nextFilters;
await SupersetClient.put({
endpoint: `/api/v1/dashboard/${dashboardId}`,
jsonPayload: {
json_metadata: JSON.stringify(metadata),
},
});
addSuccessToast(t('Filter %s removed', filterId));
removeIssue(filterId);
} catch (err) {
addDangerToast(t('Failed to remove filter %s', filterId));
}
},
[addSuccessToast, addDangerToast, dashboardId, removeIssue],
);
return (
<Container vertical align="center">
<Typography.Title level={4}>
{t('Dashboard needs your review')}
</Typography.Title>
<Typography.Text type="secondary" css={{ textAlign: 'center' }}>
{t(
'Some mappings could not be auto-fixed. Use the links below to open the generated dashboard and dataset to correct them.',
)}
</Typography.Text>
<Card>
{reviewReasons.length > 0 && (
<WarningBox>
<Typography.Text strong>
{t('Why review is needed')}
</Typography.Text>
<div>{reviewReasons.join('; ')}</div>
</WarningBox>
)}
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Flex justify="space-between" align="center" style={{ marginTop: 8 }}>
<Typography.Text strong>
{t('Open generated assets')}
</Typography.Text>
<Space>
<Button
href={`/superset/dashboard/${dashboardId}/`}
buttonStyle="primary"
>
{t('Open dashboard')}
</Button>
{datasetId ? (
<Button href={`/dataset/${datasetId}`}>
{t('Open dataset')}
</Button>
) : null}
</Space>
</Flex>
<Typography.Text strong>{t('Issues to fix')}</Typography.Text>
<Flex vertical gap="small">
{issues.map(item => (
<IssueCard key={`${item.type}-${item.id}`}>
<Typography.Text strong>
{item.type}: {item.name}
</Typography.Text>
<div>{item.error}</div>
{item.alternatives && item.alternatives.length > 0 && (
<div>
{t('Alternatives')}: {item.alternatives.join(', ')}
</div>
)}
<Space style={{ marginTop: 8 }}>
<Button
size="small"
onClick={() =>
item.type === 'chart'
? deleteChart(item.id)
: deleteFilter(item.id)
}
>
{item.type === 'chart'
? t('Delete chart now')
: t('Delete filter now')}
</Button>
<Button
size="small"
buttonStyle="tertiary"
onClick={() => addSuccessToast(t('Keep and fix later'))}
>
{t('Fix later')}
</Button>
</Space>
</IssueCard>
))}
</Flex>
</Space>
</Card>
<Button onClick={onBack}>{t('Back')}</Button>
</Container>
);
}

View File

@@ -0,0 +1,337 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect } from 'react';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Flex, Icons, Typography } from '@superset-ui/core/components';
interface ReviewSchemaPanelProps {
databaseName: string | null;
schemaName: string | null;
onAnalysisComplete: (reportId: number) => void;
}
enum AnalysisStep {
CONNECTING = 0,
SCANNING = 1,
ANALYZING = 2,
AI_INTERPRETATION = 3,
COMPLETE = 4,
}
const analysisSteps = [
{
key: AnalysisStep.CONNECTING,
title: t('Connecting to database'),
description: t('Establishing secure connection'),
},
{
key: AnalysisStep.SCANNING,
title: t('Scanning schema'),
description: t('Discovering tables, views, and relationships'),
},
{
key: AnalysisStep.ANALYZING,
title: t('Analyzing structure'),
description: t('Identifying primary keys, foreign keys, and data types'),
},
{
key: AnalysisStep.AI_INTERPRETATION,
title: t('AI interpretation'),
description: t('Generating semantic descriptions for tables and columns'),
},
];
const Spinner = styled.div`
${({ theme }) => `
width: ${theme.sizeUnit * 20}px;
height: ${theme.sizeUnit * 20}px;
border: ${theme.sizeUnit}px solid ${theme.colorBgContainer};
border-top: ${theme.sizeUnit}px solid ${theme.colorPrimary};
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
`;
const Subtitle = styled(Typography.Text)`
${({ theme }) => `
display: block;
text-align: center;
.database-name {
color: ${theme.colorPrimary};
font-weight: ${theme.fontWeightStrong};
}
`}
`;
const ProgressBarContainer = styled.div`
${({ theme }) => `
width: 100%;
height: ${theme.sizeUnit * 1.5}px;
background-color: ${theme.colorBgContainer};
border-radius: ${theme.borderRadiusSM}px;
overflow: hidden;
`}
`;
const ProgressBar = styled.div<{ progress: number }>`
${({ theme, progress }) => `
height: 100%;
width: ${progress}%;
background: linear-gradient(90deg, ${theme.colorPrimary} 0%, ${theme.colorSuccess} 100%);
border-radius: ${theme.borderRadiusSM}px;
transition: width 0.5s ease-in-out;
`}
`;
const StepsContainer = styled.div`
${({ theme }) => `
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingLG}px;
`}
`;
const StepIcon = styled.div<{ isActive: boolean; isCompleted: boolean }>`
${({ theme, isActive, isCompleted }) => `
width: ${theme.sizeUnit * 6}px;
height: ${theme.sizeUnit * 6}px;
min-width: ${theme.sizeUnit * 6}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: ${theme.fontSizeSM}px;
${
isCompleted
? `
background-color: ${theme.colorSuccess};
color: white;
`
: isActive
? `
background-color: ${theme.colorPrimary};
color: white;
`
: `
background-color: ${theme.colorBgBase};
border: 1px solid ${theme.colorBorder};
color: ${theme.colorTextSecondary};
`
}
`}
`;
const StepTitle = styled.p<{ isActive: boolean; isCompleted: boolean }>`
${({ theme, isActive, isCompleted }) => `
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${isActive || isCompleted ? theme.colorText : theme.colorTextSecondary};
margin: 0 0 2px 0;
`}
`;
const StepDescription = styled.p`
${({ theme }) => `
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
margin: 0;
`}
`;
const InfoBanner = styled.div`
${({ theme }) => `
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
padding: ${theme.paddingMD}px ${theme.paddingLG}px;
`}
`;
const InfoIcon = styled.div`
margin-top: 2px;
flex-shrink: 0;
`;
const PanelContainer = styled(Flex)`
${({ theme }) => `
width: 100%;
max-width: 600px;
gap: ${theme.marginLG}px;
`}
`;
const TitleSection = styled(Flex)`
${({ theme }) => `
gap: ${theme.marginSM}px;
`}
`;
const StepsListContainer = styled(Flex)`
${({ theme }) => `
gap: ${theme.marginSM}px;
`}
`;
const StepRow = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingSM}px 0;
gap: ${theme.marginSM}px;
`}
`;
const InfoBannerContent = styled(Flex)`
${({ theme }) => `
gap: ${theme.marginSM}px;
`}
`;
const StepContent = styled(Flex)`
flex: 1;
`;
export default function ReviewSchemaPanel({
databaseName,
schemaName: _schemaName,
onAnalysisComplete,
}: ReviewSchemaPanelProps) {
const theme = useTheme();
const [currentStep, setCurrentStep] = useState<AnalysisStep>(
AnalysisStep.CONNECTING,
);
// Simulate the analysis progress
useEffect(() => {
const stepDurations = [1500, 2000, 2500, 3000]; // ms for each step
let timeoutId: NodeJS.Timeout;
let completionTimeoutId: NodeJS.Timeout;
const advanceStep = (step: AnalysisStep) => {
if (step < AnalysisStep.COMPLETE) {
timeoutId = setTimeout(() => {
const nextStep = step + 1;
setCurrentStep(nextStep);
if (nextStep < AnalysisStep.COMPLETE) {
advanceStep(nextStep);
} else {
// Analysis complete - trigger callback after a short delay
// TODO: In real implementation, get the reportId from the analysis API
// For now, use a placeholder reportId of 1
completionTimeoutId = setTimeout(() => {
onAnalysisComplete(1);
}, 1000);
}
}, stepDurations[step]);
}
};
advanceStep(AnalysisStep.CONNECTING);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (completionTimeoutId) {
clearTimeout(completionTimeoutId);
}
};
}, [onAnalysisComplete]);
const progress = ((currentStep + 1) / (analysisSteps.length + 1)) * 100;
return (
<PanelContainer vertical align="center">
<Spinner />
<TitleSection vertical align="center">
<Typography.Title css={{ margin: 0, textAlign: 'center' }} level={3}>
{t('Analyzing database schema')}
</Typography.Title>
<Subtitle type="secondary">
{t('Connected to')}{' '}
<span className="database-name">{databaseName || 'database'}</span>
</Subtitle>
</TitleSection>
<ProgressBarContainer>
<ProgressBar progress={progress} />
</ProgressBarContainer>
<StepsContainer>
<StepsListContainer vertical>
{analysisSteps.map(step => {
const isCompleted = currentStep > step.key;
const isActive = currentStep === step.key;
return (
<StepRow key={step.key} align="flex-start">
<StepIcon isActive={isActive} isCompleted={isCompleted}>
{isCompleted ? (
<Icons.CheckOutlined />
) : isActive ? (
<Icons.LoadingOutlined />
) : null}
</StepIcon>
<StepContent vertical>
<StepTitle isActive={isActive} isCompleted={isCompleted}>
{step.title}
</StepTitle>
<StepDescription>{step.description}</StepDescription>
</StepContent>
</StepRow>
);
})}
</StepsListContainer>
</StepsContainer>
<InfoBanner>
<InfoBannerContent align="flex-start">
<InfoIcon>
<Icons.InfoCircleOutlined
iconSize="m"
iconColor={theme.colorPrimary}
/>
</InfoIcon>
<StepContent vertical>
<Typography.Text
css={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
>
{t('This may take a while for large databases')}
</Typography.Text>
<Typography.Text type="secondary">
{t(
'AI is analyzing your schema to provide intelligent suggestions',
)}
</Typography.Text>
</StepContent>
</InfoBannerContent>
</InfoBanner>
</PanelContainer>
);
}

View File

@@ -0,0 +1,244 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Flex, Icons, Tag, Typography } from '@superset-ui/core/components';
import type { SchemaSelection } from '../types';
import EditableDescription from './EditableDescription';
interface SchemaDetailPanelProps {
selection: SchemaSelection;
onUpdateTableDescription: (
tableId: number,
description: string | null,
) => Promise<boolean>;
onUpdateColumnDescription: (
columnId: number,
description: string | null,
) => Promise<boolean>;
isUpdating: boolean;
}
const PanelContainer = styled.div`
${({ theme }) => `
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
overflow: hidden;
`}
`;
const HeaderContainer = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingSM}px ${theme.paddingMD}px;
border-bottom: 1px solid ${theme.colorBorder};
background-color: ${theme.colorBgLayout};
`}
`;
const ContentSection = styled.div`
${({ theme }) => `
padding: ${theme.paddingMD}px;
`}
`;
const ItemHeader = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingMD}px;
border-bottom: 1px solid ${theme.colorBorderSecondary};
`}
`;
const ConfidenceIndicator = styled(Flex)`
${({ theme }) => `
margin-top: ${theme.marginMD}px;
padding: ${theme.paddingSM}px;
background-color: ${theme.colorSuccessBg};
border-radius: ${theme.borderRadiusSM}px;
`}
`;
const EmptyState = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingXL}px;
color: ${theme.colorTextSecondary};
`}
`;
const TypeLabel = styled(Typography.Text)`
${({ theme }) => `
color: ${theme.colorPrimary};
font-weight: ${theme.fontWeightStrong};
`}
`;
export default function SchemaDetailPanel({
selection,
onUpdateTableDescription,
onUpdateColumnDescription,
isUpdating,
}: SchemaDetailPanelProps) {
const theme = useTheme();
const handleSaveTableDescription = useCallback(
async (description: string | null): Promise<boolean> => {
if (selection?.type !== 'table') return false;
return onUpdateTableDescription(selection.table.id, description);
},
[selection, onUpdateTableDescription],
);
const handleSaveColumnDescription = useCallback(
async (description: string | null): Promise<boolean> => {
if (selection?.type !== 'column') return false;
return onUpdateColumnDescription(selection.column.id, description);
},
[selection, onUpdateColumnDescription],
);
// Empty state
if (!selection) {
return (
<PanelContainer>
<HeaderContainer align="center" gap={8}>
<Icons.InfoCircleOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
<Typography.Text strong>
{t('AI Schema Understanding')}
</Typography.Text>
</HeaderContainer>
<EmptyState vertical align="center" justify="center">
<Icons.TableOutlined
iconSize="xl"
iconColor={theme.colorTextSecondary}
/>
<Typography.Text type="secondary" css={{ marginTop: theme.marginMD }}>
{t('Select a table or column from the schema tree to view details')}
</Typography.Text>
</EmptyState>
</PanelContainer>
);
}
// Column detail view
if (selection.type === 'column') {
const { column } = selection;
return (
<PanelContainer>
<HeaderContainer align="center" gap={8}>
<Icons.InfoCircleOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
<Typography.Text strong>
{t('AI Schema Understanding')}
</Typography.Text>
</HeaderContainer>
<ItemHeader vertical gap={8}>
<Flex align="center" gap={8}>
<Typography.Title level={5} css={{ margin: 0 }}>
{column.name}
</Typography.Title>
{column.is_primary_key && (
<Tag color="warning">{t('PRIMARY KEY')}</Tag>
)}
{column.is_foreign_key && (
<Tag color="processing">{t('FOREIGN KEY')}</Tag>
)}
</Flex>
<Flex align="center" gap={4}>
<Typography.Text type="secondary">{t('Type:')}</Typography.Text>
<TypeLabel>{column.type}</TypeLabel>
</Flex>
</ItemHeader>
<ContentSection>
<EditableDescription
key={`column-${column.id}`}
description={column.description}
placeholder={t('Enter a description for this column...')}
onSave={handleSaveColumnDescription}
isUpdating={isUpdating}
/>
<ConfidenceIndicator align="center" gap={8}>
<Icons.CheckCircleOutlined
iconSize="s"
iconColor={theme.colorSuccess}
/>
<Typography.Text css={{ color: theme.colorSuccess }}>
{t('AI Confidence: High')}
</Typography.Text>
</ConfidenceIndicator>
</ContentSection>
</PanelContainer>
);
}
// Table detail view
const { table } = selection;
return (
<PanelContainer>
<HeaderContainer align="center" gap={8}>
<Icons.InfoCircleOutlined iconSize="s" iconColor={theme.colorPrimary} />
<Typography.Text strong>{t('AI Schema Understanding')}</Typography.Text>
</HeaderContainer>
<ItemHeader align="center" gap={12}>
<Icons.CheckSquareOutlined
iconSize="m"
iconColor={theme.colorSuccess}
/>
<Flex vertical gap={2}>
<Typography.Title level={5} css={{ margin: 0 }}>
{table.name}
</Typography.Title>
<Tag color="default">{table.type}</Tag>
</Flex>
</ItemHeader>
<ContentSection>
<EditableDescription
key={`table-${table.id}`}
description={table.description}
placeholder={t('Enter a description for this table...')}
onSave={handleSaveTableDescription}
isUpdating={isUpdating}
/>
<ConfidenceIndicator align="center" gap={8}>
<Icons.CheckCircleOutlined
iconSize="s"
iconColor={theme.colorSuccess}
/>
<Typography.Text css={{ color: theme.colorSuccess }}>
{t('AI Confidence: High')}
</Typography.Text>
</ConfidenceIndicator>
</ContentSection>
</PanelContainer>
);
}

View File

@@ -0,0 +1,248 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, useState } from 'react';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Tree } from 'antd';
import type { DataNode as TreeDataNode } from 'antd/es/tree';
import { Flex, Icons, Typography } from '@superset-ui/core/components';
import type { AnalyzedTable, SchemaSelection } from '../types';
interface SchemaTreeViewProps {
tables: AnalyzedTable[];
selection: SchemaSelection;
onSelectionChange: (selection: SchemaSelection) => void;
schemaName: string | null;
}
const TreeContainer = styled.div`
${({ theme }) => `
width: 100%;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
overflow: hidden;
.ant-tree {
background: transparent;
padding: ${theme.paddingSM}px;
}
.ant-tree-treenode {
padding: ${theme.paddingXXS}px 0;
}
.ant-tree-node-content-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.ant-tree-title {
display: flex;
align-items: center;
gap: ${theme.marginXS}px;
width: 100%;
}
`}
`;
const HeaderContainer = styled(Flex)`
${({ theme }) => `
padding: ${theme.paddingSM}px ${theme.paddingMD}px;
border-bottom: 1px solid ${theme.colorBorder};
background-color: ${theme.colorBgLayout};
`}
`;
const TableIcon = styled.span`
${({ theme }) => `
display: flex;
align-items: center;
color: ${theme.colorPrimary};
`}
`;
const KeyIcon = styled.span`
${({ theme }) => `
display: flex;
align-items: center;
color: ${theme.colorError};
`}
`;
const ColumnIcon = styled.span`
${({ theme }) => `
display: flex;
align-items: center;
color: ${theme.colorTextSecondary};
`}
`;
const ColumnType = styled(Typography.Text)`
${({ theme }) => `
margin-left: auto;
padding-left: ${theme.paddingMD}px;
font-family: monospace;
font-size: ${theme.fontSizeSM}px;
`}
`;
const TreeNodeTitle = styled(Flex)`
width: 100%;
`;
export default function SchemaTreeView({
tables,
selection,
onSelectionChange,
schemaName,
}: SchemaTreeViewProps) {
const theme = useTheme();
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoCollapsed, setAutoCollapsed] = useState(false);
const treeData: TreeDataNode[] = useMemo(
() =>
tables.map(table => ({
key: `table-${table.id}`,
title: (
<TreeNodeTitle align="center" gap={4}>
<TableIcon>
<Icons.TableOutlined iconSize="s" />
</TableIcon>
<Typography.Text strong>{table.name}</Typography.Text>
</TreeNodeTitle>
),
children: table.columns.map(column => ({
key: `column-${table.id}-${column.id}`,
title: (
<TreeNodeTitle align="center" justify="space-between">
<Flex align="center" gap={4}>
{column.is_primary_key || column.is_foreign_key ? (
<KeyIcon>
<Icons.KeyOutlined iconSize="s" />
</KeyIcon>
) : (
<ColumnIcon>
<Icons.FieldNumberOutlined iconSize="s" />
</ColumnIcon>
)}
<Typography.Text>{column.name}</Typography.Text>
</Flex>
<ColumnType type="secondary">({column.type})</ColumnType>
</TreeNodeTitle>
),
isLeaf: true,
})),
})),
[tables],
);
const handleSelect = (
_selectedKeys: React.Key[],
info: { node: TreeDataNode },
) => {
const key = String(info.node.key);
if (key.startsWith('table-')) {
const tableId = parseInt(key.replace('table-', ''), 10);
const table = tables.find(t => t.id === tableId);
if (table) {
onSelectionChange({ type: 'table', table });
}
} else if (key.startsWith('column-')) {
// Format: column-{tableId}-{columnId}
const parts = key.split('-');
const tableId = parseInt(parts[1], 10);
const columnId = parseInt(parts[2], 10);
const table = tables.find(t => t.id === tableId);
const column = table?.columns.find(c => c.id === columnId);
if (table && column) {
onSelectionChange({ type: 'column', column, table });
}
}
};
// Compute selected key from selection
const selectedKeys = useMemo(() => {
if (!selection) return [];
if (selection.type === 'table') {
return [`table-${selection.table.id}`];
}
return [`column-${selection.table.id}-${selection.column.id}`];
}, [selection]);
// Collapse by default on first render to reduce vertical space.
// Users can expand as needed.
const initialExpandedKeys =
autoCollapsed && expandedKeys.length === 0
? []
: expandedKeys.length > 0
? expandedKeys
: tables.map(table => `table-${table.id}`);
// Once we compute collapsed state the first time, avoid reapplying.
useMemo(() => {
if (!autoCollapsed) {
setExpandedKeys([]);
setAutoCollapsed(true);
}
}, [autoCollapsed]);
return (
<TreeContainer>
<HeaderContainer align="center" gap={8}>
<Icons.DatabaseOutlined iconSize="s" iconColor={theme.colorPrimary} />
<Typography.Text strong>{t('Database Schema')}</Typography.Text>
</HeaderContainer>
{schemaName && (
<Flex
align="center"
gap={4}
css={{
padding: `${theme.paddingXS}px ${theme.paddingMD}px`,
borderBottom: `1px solid ${theme.colorBorderSecondary}`,
}}
>
<Typography.Text type="secondary">
{t('Connected to:')}
</Typography.Text>
<Typography.Text
css={{
color: theme.colorPrimary,
fontWeight: theme.fontWeightStrong,
}}
>
{schemaName}
</Typography.Text>
</Flex>
)}
<Tree
treeData={treeData}
selectedKeys={selectedKeys}
expandedKeys={initialExpandedKeys}
onExpand={keys => setExpandedKeys(keys)}
onSelect={handleSelect}
showIcon={false}
blockNode
/>
</TreeContainer>
);
}

View File

@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Set to true to use mock data for UI testing without backend.
* Set to false to use real API endpoints.
*/
export const USE_MOCK_DATA = false;

View File

@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback } from 'react';
/**
* Hook to manage database list refresh state.
* Used to trigger a refresh after a new database is added.
*/
export default function useDatabaseListRefresh() {
const [refreshKey, setRefreshKey] = useState(0);
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
return {
refreshKey,
triggerRefresh,
};
}

View File

@@ -0,0 +1,162 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useState } from 'react';
import { SupersetClient, logging } from '@superset-ui/core';
import type { GenerateDashboardResponse } from '../types';
import { USE_MOCK_DATA } from '../config';
interface MutationState {
loading: boolean;
error: string | null;
}
interface UseSchemaEditorMutationsReturn {
updateTableDescription: (
tableId: number,
description: string | null,
) => Promise<boolean>;
updateColumnDescription: (
columnId: number,
description: string | null,
) => Promise<boolean>;
generateDashboard: (
reportId: number,
dashboardId: number,
) => Promise<string | null>;
mutationState: MutationState;
}
export default function useSchemaEditorMutations(): UseSchemaEditorMutationsReturn {
const [mutationState, setMutationState] = useState<MutationState>({
loading: false,
error: null,
});
const updateTableDescription = useCallback(
async (tableId: number, description: string | null): Promise<boolean> => {
setMutationState({ loading: true, error: null });
// Use mock for testing
if (USE_MOCK_DATA) {
await new Promise(resolve => setTimeout(resolve, 300));
logging.info(
`Mock: Updated table ${tableId} description to:`,
description,
);
setMutationState({ loading: false, error: null });
return true;
}
try {
await SupersetClient.put({
endpoint: `/api/v1/datasource/analysis/table/${tableId}`,
jsonPayload: { description },
});
setMutationState({ loading: false, error: null });
return true;
} catch (err) {
logging.error('Error updating table description:', err);
const errorMessage =
err instanceof Error
? err.message
: 'Failed to update table description';
setMutationState({ loading: false, error: errorMessage });
return false;
}
},
[],
);
const updateColumnDescription = useCallback(
async (columnId: number, description: string | null): Promise<boolean> => {
setMutationState({ loading: true, error: null });
// Use mock for testing
if (USE_MOCK_DATA) {
await new Promise(resolve => setTimeout(resolve, 300));
logging.info(
`Mock: Updated column ${columnId} description to:`,
description,
);
setMutationState({ loading: false, error: null });
return true;
}
try {
await SupersetClient.put({
endpoint: `/api/v1/datasource/analysis/column/${columnId}`,
jsonPayload: { description },
});
setMutationState({ loading: false, error: null });
return true;
} catch (err) {
logging.error('Error updating column description:', err);
const errorMessage =
err instanceof Error
? err.message
: 'Failed to update column description';
setMutationState({ loading: false, error: errorMessage });
return false;
}
},
[],
);
const generateDashboard = useCallback(
async (reportId: number, dashboardId: number): Promise<string | null> => {
setMutationState({ loading: true, error: null });
// Use mock for testing
if (USE_MOCK_DATA) {
await new Promise(resolve => setTimeout(resolve, 500));
const mockRunId = `mock-run-${Date.now()}`;
logging.info(`Mock: Generated dashboard with run_id: ${mockRunId}`);
setMutationState({ loading: false, error: null });
return mockRunId;
}
try {
const response = await SupersetClient.post({
endpoint: '/api/v1/datasource/analysis/generate',
jsonPayload: {
report_id: reportId,
dashboard_id: dashboardId,
},
});
const data = response.json as GenerateDashboardResponse;
setMutationState({ loading: false, error: null });
return data.result.run_id;
} catch (err) {
logging.error('Error generating dashboard:', err);
const errorMessage =
err instanceof Error ? err.message : 'Failed to generate dashboard';
setMutationState({ loading: false, error: errorMessage });
return null;
}
},
[],
);
return {
updateTableDescription,
updateColumnDescription,
generateDashboard,
mutationState,
};
}

View File

@@ -0,0 +1,239 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useCallback } from 'react';
import { SupersetClient, logging } from '@superset-ui/core';
import type { SchemaReportResponse, DatabaseSchemaReport } from '../types';
import { USE_MOCK_DATA } from '../config';
import { JoinType, Cardinality } from '../../../components/DatabaseSchemaEditor';
// Mock data for testing UI without backend
const MOCK_REPORT: DatabaseSchemaReport = {
id: 1,
database_id: 1,
schema_name: 'postgres-prod',
status: 'completed',
created_at: new Date().toISOString(),
tables: [
{
id: 1,
name: 'orders',
type: 'table',
description:
'This table stores all customer orders including order details, timestamps, and status information.',
columns: [
{
id: 1,
name: 'order_id',
type: 'INTEGER',
position: 1,
description: 'Unique identifier for each order',
is_primary_key: true,
},
{
id: 2,
name: 'customer_id',
type: 'INTEGER',
position: 2,
description: 'Reference to the customer who placed the order',
is_foreign_key: true,
},
{
id: 3,
name: 'order_date',
type: 'TIMESTAMP',
position: 3,
description: null,
},
{
id: 4,
name: 'total_amount',
type: 'DECIMAL',
position: 4,
description: null,
},
{
id: 5,
name: 'status',
type: 'VARCHAR',
position: 5,
description: null,
},
],
},
{
id: 2,
name: 'customers',
type: 'table',
description:
'Customer master data including contact information and account details.',
columns: [
{
id: 6,
name: 'customer_id',
type: 'INTEGER',
position: 1,
description: 'Unique identifier for each customer',
is_primary_key: true,
},
{
id: 7,
name: 'email',
type: 'VARCHAR',
position: 2,
description: null,
},
{
id: 8,
name: 'created_at',
type: 'TIMESTAMP',
position: 3,
description: null,
},
{
id: 9,
name: 'country',
type: 'VARCHAR',
position: 4,
description: null,
},
],
},
{
id: 3,
name: 'products',
type: 'table',
description: 'Product catalog containing all available items for sale.',
columns: [
{
id: 10,
name: 'product_id',
type: 'INTEGER',
position: 1,
description: 'Unique identifier for each product',
is_primary_key: true,
},
{
id: 11,
name: 'name',
type: 'VARCHAR',
position: 2,
description: null,
},
{
id: 12,
name: 'price',
type: 'DECIMAL',
position: 3,
description: null,
},
{
id: 13,
name: 'category',
type: 'VARCHAR',
position: 4,
description: null,
},
],
},
],
joins: [
{
id: 1,
source_table: 'orders',
source_table_id: 1,
source_columns: ['customer_id'],
target_table: 'customers',
target_table_id: 2,
target_columns: ['customer_id'],
join_type: JoinType.LEFT,
cardinality: Cardinality.MANY_TO_ONE,
semantic_context: 'Orders are linked to customers through the customer_id field',
},
],
};
interface UseSchemaReportReturn {
report: DatabaseSchemaReport | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
export default function useSchemaReport(
reportId: number | null,
): UseSchemaReportReturn {
const [report, setReport] = useState<DatabaseSchemaReport | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fetchReport = useCallback(async () => {
if (!reportId) {
setReport(null);
return;
}
// Use mock data for testing
if (USE_MOCK_DATA) {
setLoading(true);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
setReport(MOCK_REPORT);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/datasource/analysis/report/${reportId}`,
});
const data = response.json as SchemaReportResponse;
setReport({
id: data.id,
database_id: data.database_id,
schema_name: data.schema_name,
status: data.status,
created_at: data.created_at,
tables: data.tables,
joins: data.joins || [],
});
} catch (err) {
logging.error('Error fetching schema report:', err);
setError(
err instanceof Error ? err.message : 'Failed to fetch schema report',
);
} finally {
setLoading(false);
}
}, [reportId]);
useEffect(() => {
fetchReport();
}, [fetchReport]);
return {
report,
loading,
error,
refetch: fetchReport,
};
}

View File

@@ -0,0 +1,583 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { SupersetClient, t, logging } from '@superset-ui/core';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import type { DatabaseObject } from 'src/components/DatabaseSelector/types';
import DatabaseModal from 'src/features/databases/DatabaseModal';
import ConnectorLayout from './components/ConnectorLayout';
import DataSourcePanel from './components/DataSourcePanel';
import DatasourceEditorPanel from './components/DatasourceEditorPanel';
import DatasourceAnalyzerPanel from './components/DatasourceAnalyzerPanel';
import DashboardGeneratorPanel from './components/DashboardGeneratorPanel';
import MappingReviewPanel from './components/MappingReviewPanel';
import useDatabaseListRefresh from './hooks/useDatabaseListRefresh';
import {
ConnectorStep,
DatasourceConnectorState,
MappingProposal,
MappingProposalResponse,
AdjustedMappings,
ExistingReportResponse,
ConfidenceLevel,
} from './types';
import PendingReviewPanel from './components/PendingReviewPanel';
interface TemplateDashboardInfo {
id: number;
dashboard_title: string;
is_template: boolean;
}
const INITIAL_STATE: DatasourceConnectorState = {
databaseId: null,
databaseName: null,
catalogName: null,
schemaName: null,
isSubmitting: false,
forceReanalyze: false,
};
interface AnalysisApiResponse {
run_id: string;
status: string;
}
interface GenerationApiResponse {
run_id: string;
status: string;
}
export default function DatasourceConnector() {
const history = useHistory();
const location = useLocation();
const { addDangerToast, addSuccessToast } = useToasts();
const { refreshKey, triggerRefresh } = useDatabaseListRefresh();
const [state, setState] = useState<DatasourceConnectorState>(INITIAL_STATE);
const [database, setDatabase] = useState<DatabaseObject | null>(null);
const [showDatabaseModal, setShowDatabaseModal] = useState(false);
const [currentStep, setCurrentStep] = useState<ConnectorStep>(
ConnectorStep.CONNECT_DATA_SOURCE,
);
const [templateInfo, setTemplateInfo] =
useState<TemplateDashboardInfo | null>(null);
const [databaseReportId, setDatabaseReportId] = useState<number | null>(null);
const [analysisRunId, setAnalysisRunId] = useState<string | null>(null);
const [generationRunId, setGenerationRunId] = useState<string | null>(null);
const [reportId, setReportId] = useState<number | null>(null);
const [mappingProposal, setMappingProposal] = useState<MappingProposal | null>(
null,
);
const [pendingReviewData, setPendingReviewData] = useState<{
dashboardId: number;
datasetId: number | null;
failedMappings: Array<{
type: string;
id: string;
name: string;
error: string;
alternatives?: string[];
}>;
reviewReasons: string[];
} | null>(null);
const [isProposing, setIsProposing] = useState(false);
const [existingReportInfo, setExistingReportInfo] = useState<{
exists: boolean;
reportId?: number;
tablesCount?: number;
createdAt?: string;
}>({ exists: false });
// Get dashboard_id from query params
const dashboardId = useMemo(() => {
const params = new URLSearchParams(location.search);
const id = params.get('dashboard_id');
return id ? parseInt(id, 10) : null;
}, [location.search]);
// Fetch dashboard info when dashboard_id is present
useEffect(() => {
if (!dashboardId) return;
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
})
.then(({ json }) => {
const dashboard = json.result;
// json_metadata is returned as a string from the API
let metadata: Record<string, unknown> = {};
if (dashboard?.json_metadata) {
try {
metadata =
typeof dashboard.json_metadata === 'string'
? JSON.parse(dashboard.json_metadata)
: dashboard.json_metadata;
} catch {
logging.error('Error parsing dashboard metadata');
}
}
const templateInfoMeta =
(metadata?.template_info as Record<string, unknown>) || {};
const isTemplate =
(templateInfoMeta?.is_template as boolean | undefined) ??
(metadata?.is_template as boolean | undefined) ??
true;
setTemplateInfo({
id: dashboard.id,
dashboard_title:
(templateInfoMeta?.dashboard_title as string | undefined) ||
dashboard.dashboard_title,
is_template: isTemplate,
});
})
.catch(error => {
logging.error('Error fetching dashboard info:', error);
});
}, [dashboardId]);
// Check for existing schema analysis report when database/schema are selected
useEffect(() => {
if (!state.databaseId || !state.schemaName) {
setExistingReportInfo({ exists: false });
return;
}
SupersetClient.get({
endpoint: `/api/v1/datasource/analysis/?database_id=${state.databaseId}&schema_name=${encodeURIComponent(state.schemaName)}`,
})
.then(({ json }) => {
const result = json as ExistingReportResponse;
if (result?.exists && result.report_id) {
setExistingReportInfo({
exists: true,
reportId: result.report_id,
tablesCount: result.tables_count,
createdAt: result.created_at ?? undefined,
});
} else {
setExistingReportInfo({ exists: false });
}
})
.catch(error => {
logging.error('Error checking for existing report:', error);
setExistingReportInfo({ exists: false });
});
}, [state.databaseId, state.schemaName]);
const handleDatabaseChange = useCallback((db: DatabaseObject | null) => {
setDatabase(db);
setState(prev => ({
...prev,
databaseId: db?.id ?? null,
databaseName: db?.database_name ?? null,
catalogName: null,
schemaName: null,
}));
}, []);
const handleCatalogChange = useCallback((catalogName: string | null) => {
setState(prev => ({
...prev,
catalogName,
schemaName: null,
}));
}, []);
const handleSchemaChange = useCallback((schemaName: string | null) => {
setState(prev => ({
...prev,
schemaName,
}));
}, []);
const handleForceReanalyzeChange = useCallback((checked: boolean) => {
setState(prev => ({
...prev,
forceReanalyze: checked,
}));
}, []);
const handleError = useCallback(
(msg: string) => {
addDangerToast(msg);
},
[addDangerToast],
);
const handleAddNewDatabase = useCallback(() => {
setShowDatabaseModal(true);
}, []);
const handleDatabaseModalHide = useCallback(() => {
setShowDatabaseModal(false);
}, []);
const handleDatabaseAdd = useCallback(
(newDb?: DatabaseObject) => {
setShowDatabaseModal(false);
triggerRefresh();
if (newDb) {
handleDatabaseChange(newDb);
addSuccessToast(t('Database connection added successfully'));
}
},
[triggerRefresh, handleDatabaseChange, addSuccessToast],
);
const handleCancel = useCallback(() => {
history.goBack();
}, [history]);
const handleAnalysisComplete = useCallback(
async (newReportId: number) => {
setReportId(newReportId);
// If we have a template, first attempt mapping proposal (may auto-start generation)
if (dashboardId && newReportId) {
setIsProposing(true);
setState(prev => ({ ...prev, isSubmitting: true }));
try {
// First get mapping proposal; backend will auto-start if high confidence
const response = await SupersetClient.post({
endpoint: '/api/v1/dashboard/generation/proposals',
jsonPayload: {
database_report_id: newReportId,
dashboard_id: dashboardId,
},
});
const result = response.json?.result as MappingProposalResponse;
if (result?.requires_review) {
// Show review step to the user
setMappingProposal({
proposal_id: result.proposal_id || '',
column_mappings: result.column_mappings || [],
metric_mappings: result.metric_mappings || [],
unmapped_columns: result.unmapped_columns || [],
unmapped_metrics: result.unmapped_metrics || [],
review_reasons: result.review_reasons || [],
overall_confidence: result.overall_confidence || 0,
});
setCurrentStep(ConnectorStep.REVIEW_MAPPINGS);
addSuccessToast(t('Mappings need review before generation'));
} else if (result?.run_id) {
setGenerationRunId(result.run_id);
addSuccessToast(
result.message || t('Dashboard generation started'),
);
setCurrentStep(ConnectorStep.GENERATE_DASHBOARD);
} else {
throw new Error('Invalid response from proposal/generation API');
}
} catch (error) {
logging.error('Error starting dashboard generation:', error);
addDangerToast(t('Failed to start dashboard generation'));
} finally {
setIsProposing(false);
setState(prev => ({ ...prev, isSubmitting: false }));
}
} else {
// No template selected - analysis complete but can't generate dashboard
addSuccessToast(t('Schema analysis complete'));
// Stay on the current step or redirect to a sensible place
history.push('/dashboard/templates/');
}
},
[dashboardId, addSuccessToast, addDangerToast, history],
);
const handleContinueToReview = useCallback(async () => {
if (!state.databaseId || !state.schemaName) {
addDangerToast(t('Please select a database and schema'));
return;
}
setState(prev => ({ ...prev, isSubmitting: true }));
try {
// Use existing report if available and force reanalyze is not enabled
if (
!state.forceReanalyze &&
existingReportInfo.exists &&
existingReportInfo.reportId
) {
logging.info(
`Using existing report ${existingReportInfo.reportId} with ${existingReportInfo.tablesCount} tables`,
);
addSuccessToast(
t(
'Using existing schema analysis (created %s)',
existingReportInfo.createdAt || 'previously',
),
);
// Directly proceed to analysis complete handler with existing report
setState(prev => ({ ...prev, isSubmitting: false }));
await handleAnalysisComplete(existingReportInfo.reportId);
return;
}
// No existing report or force reanalyze - start new analysis
const response = await SupersetClient.post({
endpoint: '/api/v1/datasource/analysis/',
jsonPayload: {
database_id: state.databaseId,
schema_name: state.schemaName,
catalog_name: state.catalogName,
force_reanalyze: state.forceReanalyze,
},
});
const result = response.json?.result as AnalysisApiResponse;
if (result?.run_id) {
setAnalysisRunId(result.run_id);
addSuccessToast(t('Analysis job initiated'));
setCurrentStep(ConnectorStep.REVIEW_SCHEMA);
} else {
throw new Error('No run_id returned from analysis API');
}
} catch (error) {
logging.error('Error starting analysis:', error);
addDangerToast(t('Failed to start analysis'));
} finally {
setState(prev => ({ ...prev, isSubmitting: false }));
}
}, [
state,
existingReportInfo,
addDangerToast,
addSuccessToast,
handleAnalysisComplete,
]);
// Handler when analysis completes - transition to schema editor
const handleAnalysisCompleteToEditor = useCallback(
(newReportId: number) => {
setDatabaseReportId(newReportId);
setReportId(newReportId);
addSuccessToast(t('Analysis complete! Review and edit your schema.'));
setCurrentStep(ConnectorStep.EDIT_SCHEMA);
},
[addSuccessToast],
);
const handleBackToReview = useCallback(() => {
setCurrentStep(ConnectorStep.REVIEW_SCHEMA);
}, []);
const handleConfirmAndGenerate = useCallback(
(runId: string) => {
setGenerationRunId(runId);
addSuccessToast(t('Dashboard generation started'));
setCurrentStep(ConnectorStep.GENERATE_DASHBOARD);
},
[addSuccessToast],
);
const handleMappingApprove = useCallback(
async (adjustments: AdjustedMappings) => {
if (!mappingProposal || !reportId || !dashboardId) {
addDangerToast(t('Missing proposal or report information'));
return;
}
setIsProposing(true);
try {
const response = await SupersetClient.post({
endpoint: '/api/v1/dashboard/generation/',
jsonPayload: {
proposal_id: mappingProposal.proposal_id,
database_report_id: reportId,
dashboard_id: dashboardId,
adjusted_mappings: adjustments,
},
});
const result = response.json?.result as GenerationApiResponse;
if (result?.run_id) {
setGenerationRunId(result.run_id);
addSuccessToast(t('Dashboard generation started'));
setCurrentStep(ConnectorStep.GENERATE_DASHBOARD);
} else {
throw new Error('No run_id returned from confirm API');
}
} catch (error) {
logging.error('Error confirming mappings:', error);
addDangerToast(t('Failed to start dashboard generation'));
} finally {
setIsProposing(false);
}
},
[mappingProposal, reportId, dashboardId, addSuccessToast, addDangerToast],
);
const handleMappingCancel = useCallback(() => {
setMappingProposal(null);
setCurrentStep(ConnectorStep.CONNECT_DATA_SOURCE);
}, []);
const handleAnalysisError = useCallback(
(error: string) => {
addDangerToast(error);
setCurrentStep(ConnectorStep.CONNECT_DATA_SOURCE);
},
[addDangerToast],
);
const handleGenerationComplete = useCallback(
(generatedDashboardId: number) => {
addSuccessToast(t('Dashboard generated successfully!'));
history.push(`/superset/dashboard/${generatedDashboardId}/`);
},
[addSuccessToast, history],
);
const handleGenerationError = useCallback(
(error: string) => {
addDangerToast(error);
},
[addDangerToast],
);
const renderCurrentStep = () => {
switch (currentStep) {
case ConnectorStep.CONNECT_DATA_SOURCE:
return (
<DataSourcePanel
key={refreshKey}
database={database}
catalog={state.catalogName}
schema={state.schemaName}
isSubmitting={state.isSubmitting}
forceReanalyze={state.forceReanalyze}
hasExistingReport={existingReportInfo.exists}
existingReportInfo={
existingReportInfo.exists
? t(
'Existing analysis found (%s tables, created %s)',
existingReportInfo.tablesCount ?? 0,
existingReportInfo.createdAt
? new Date(existingReportInfo.createdAt).toLocaleDateString(
undefined,
{
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
)
: 'previously',
)
: undefined
}
onDatabaseChange={handleDatabaseChange}
onCatalogChange={handleCatalogChange}
onSchemaChange={handleSchemaChange}
onForceReanalyzeChange={handleForceReanalyzeChange}
onError={handleError}
onAddNewDatabase={handleAddNewDatabase}
onCancel={handleCancel}
onContinue={handleContinueToReview}
/>
);
case ConnectorStep.REVIEW_SCHEMA:
return analysisRunId ? (
<DatasourceAnalyzerPanel
runId={analysisRunId}
databaseName={state.databaseName}
onComplete={handleAnalysisCompleteToEditor}
onError={handleAnalysisError}
/>
) : null;
case ConnectorStep.EDIT_SCHEMA:
return databaseReportId ? (
<DatasourceEditorPanel
reportId={databaseReportId}
dashboardId={dashboardId}
onBack={handleBackToReview}
onConfirm={handleConfirmAndGenerate}
/>
) : null;
case ConnectorStep.REVIEW_MAPPINGS:
return mappingProposal ? (
<MappingReviewPanel
proposal={mappingProposal}
onApprove={handleMappingApprove}
onCancel={handleMappingCancel}
isSubmitting={isProposing}
/>
) : null;
case ConnectorStep.GENERATE_DASHBOARD:
return generationRunId ? (
<DashboardGeneratorPanel
runId={generationRunId}
templateName={templateInfo?.dashboard_title ?? null}
onComplete={handleGenerationComplete}
onError={handleGenerationError}
onPendingReview={data => {
setPendingReviewData({
dashboardId: data.dashboardId,
datasetId: (data as any).datasetId || null,
failedMappings: data.failedMappings || [],
reviewReasons: data.reviewReasons || [],
});
setCurrentStep(ConnectorStep.REVIEW_PENDING);
}}
/>
) : null;
case ConnectorStep.REVIEW_PENDING:
return pendingReviewData ? (
<PendingReviewPanel
dashboardId={pendingReviewData.dashboardId}
datasetId={pendingReviewData.datasetId}
failedMappings={pendingReviewData.failedMappings}
reviewReasons={pendingReviewData.reviewReasons}
onBack={() => setCurrentStep(ConnectorStep.CONNECT_DATA_SOURCE)}
/>
) : null;
default:
return null;
}
};
return (
<>
<ConnectorLayout
currentStep={currentStep}
templateName={templateInfo?.dashboard_title}
databaseName={state.databaseName}
>
{renderCurrentStep()}
</ConnectorLayout>
<DatabaseModal
show={showDatabaseModal}
onHide={handleDatabaseModalHide}
onDatabaseAdd={handleDatabaseAdd}
databaseId={undefined}
dbEngine={undefined}
/>
</>
);
}

View File

@@ -0,0 +1,167 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Join } from '../../../components/DatabaseSchemaEditor';
export interface DatasourceConnectorState {
databaseId: number | null;
databaseName: string | null;
catalogName: string | null;
schemaName: string | null;
isSubmitting: boolean;
forceReanalyze: boolean;
}
export interface DatasourceAnalyzerPostPayload {
database_id: number;
schema_name: string;
catalog_name?: string | null;
force_reanalyze?: boolean;
}
export interface ExistingReportResponse {
exists: boolean;
report_id: number | null;
created_at: string | null;
tables_count: number;
}
export interface DatasourceAnalyzerResponse {
result: {
run_id: string;
};
}
export enum ConnectorStep {
CONNECT_DATA_SOURCE = 0,
REVIEW_SCHEMA = 1,
EDIT_SCHEMA = 2,
REVIEW_MAPPINGS = 3,
GENERATE_DASHBOARD = 4,
REVIEW_PENDING = 5,
}
// Schema Editor Types
export interface AnalyzedColumn {
id: number;
name: string;
type: string;
position: number;
description: string | null;
is_primary_key?: boolean;
is_foreign_key?: boolean;
}
export interface AnalyzedTable {
id: number;
name: string;
type: 'table' | 'view' | 'materialized_view';
description: string | null;
columns: AnalyzedColumn[];
}
// Selection types for the detail panel
export type SchemaSelection =
| { type: 'table'; table: AnalyzedTable }
| { type: 'column'; column: AnalyzedColumn; table: AnalyzedTable }
| null;
export interface DatabaseSchemaReport {
id: number;
database_id: number;
schema_name: string;
status: string;
created_at: string | null;
tables: AnalyzedTable[];
joins: Join[];
}
export interface SchemaReportResponse {
id: number;
database_id: number;
schema_name: string;
status: string;
created_at: string | null;
tables: AnalyzedTable[];
joins: Join[];
}
export interface GenerateDashboardPayload {
report_id: number;
dashboard_id: number;
}
export interface GenerateDashboardResponse {
result: {
run_id: string;
};
}
export type ConfidenceLevel = 'high' | 'medium' | 'low' | 'failed';
export interface ColumnMapping {
template_column: string;
user_column: string | null;
user_table: string | null;
confidence: number;
confidence_level: ConfidenceLevel;
match_reasons: string[];
alternatives: Array<{
column: string;
table: string;
confidence: number;
}>;
}
export interface MetricMapping {
template_metric: string;
user_expression: string | null;
confidence: number;
confidence_level: ConfidenceLevel;
match_reasons: string[];
alternatives: string[];
}
export interface MappingProposal {
proposal_id: string;
column_mappings: ColumnMapping[];
metric_mappings: MetricMapping[];
unmapped_columns: string[];
unmapped_metrics: string[];
review_reasons: string[];
overall_confidence: number;
}
export interface MappingProposalResponse {
requires_review: boolean;
proposal_id?: string;
run_id?: string;
message?: string;
column_mappings?: ColumnMapping[];
metric_mappings?: MetricMapping[];
unmapped_columns?: string[];
unmapped_metrics?: string[];
review_reasons?: string[];
overall_confidence?: number;
}
export interface AdjustedMappings {
columns: Record<string, { column: string; table: string }>;
metrics: Record<string, string>;
}

View File

@@ -73,6 +73,7 @@ export type Slice = {
form_data?: QueryFormData;
query_context?: object;
is_managed_externally: boolean;
is_template_chart?: boolean;
owners?: number[];
datasource?: string;
datasource_id?: number;

View File

@@ -34,6 +34,13 @@ const ChartCreation = lazy(
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
);
const DatasourceConnector = lazy(
() =>
import(
/* webpackChunkName: "DatasourceConnector" */ 'src/pages/DatasourceConnector'
),
);
const AnnotationLayerList = lazy(
() =>
import(
@@ -77,6 +84,13 @@ const Dashboard = lazy(
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
);
const DashboardTemplates = lazy(
() =>
import(
/* webpackChunkName: "DashboardTemplates" */ 'src/pages/DashboardTemplates'
),
);
const DatabaseList = lazy(
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
);
@@ -203,6 +217,10 @@ export const routes: Routes = [
path: '/dashboard/list/',
Component: DashboardList,
},
{
path: '/dashboard/templates/',
Component: DashboardTemplates,
},
{
path: '/superset/dashboard/:idOrSlug/',
Component: Dashboard,
@@ -215,6 +233,14 @@ export const routes: Routes = [
path: '/chart/list/',
Component: ChartList,
},
{
path: '/datasource-connector/',
Component: DatasourceConnector,
},
{
path: '/datasource-connector/loading/:runId',
Component: DatasourceConnector,
},
{
path: '/tablemodelview/list/',
Component: DatasetList,

View File

@@ -1670,6 +1670,11 @@ class ChartGetResponseSchema(Schema):
viz_type = fields.String()
query_context = fields.String()
is_managed_externally = fields.Boolean()
is_template_chart = fields.Boolean(
metadata={
"description": "Whether this chart belongs to a template dashboard"
}
)
tags = fields.Nested(TagSchema, many=True)
owners = fields.List(fields.Nested(UserSchema))
dashboards = fields.List(fields.Nested(DashboardSchema))

View File

@@ -27,6 +27,7 @@ from superset.commands.chart.exceptions import (
ChartDeleteFailedReportsExistError,
ChartForbiddenError,
ChartNotFoundError,
ChartTemplateDeleteForbiddenError,
)
from superset.daos.chart import ChartDAO
from superset.daos.report import ReportScheduleDAO
@@ -53,6 +54,12 @@ class DeleteChartCommand(BaseCommand):
self._models = ChartDAO.find_by_ids(self._model_ids)
if not self._models or len(self._models) != len(self._model_ids):
raise ChartNotFoundError()
# Charts belonging to template dashboards cannot be deleted
for model in self._models:
if model.is_template_chart is True:
raise ChartTemplateDeleteForbiddenError()
# Check there are no associated ReportSchedules
if reports := ReportScheduleDAO.find_by_chart_ids(self._model_ids):
report_names = [report.name for report in reports]

View File

@@ -162,3 +162,11 @@ class ChartFaveError(CommandException):
class ChartUnfaveError(CommandException):
message = _("Error unfaving chart")
class ChartTemplateUpdateForbiddenError(ForbiddenError):
message = _("Charts belonging to template dashboards cannot be modified.")
class ChartTemplateDeleteForbiddenError(ForbiddenError):
message = _("Charts belonging to template dashboards cannot be deleted.")

View File

@@ -29,6 +29,7 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
ChartTemplateUpdateForbiddenError,
ChartUpdateFailedError,
DashboardsNotFoundValidationError,
DatasourceTypeUpdateRequiredValidationError,
@@ -112,6 +113,10 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not self._model:
raise ChartNotFoundError()
# Charts belonging to template dashboards cannot be modified
if self._model.is_template_chart is True:
raise ChartTemplateUpdateForbiddenError()
# Check and update ownership; when only updating query context we ignore
# ownership so the update can be performed by report workers
if not is_query_context_update(self._properties):

View File

@@ -28,6 +28,7 @@ from superset.commands.dashboard.exceptions import (
DashboardDeleteFailedReportsExistError,
DashboardForbiddenError,
DashboardNotFoundError,
DashboardTemplateDeleteForbiddenError,
)
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
from superset.daos.report import ReportScheduleDAO
@@ -67,6 +68,12 @@ class DeleteDashboardCommand(BaseCommand):
self._models = DashboardDAO.find_by_ids(self._model_ids)
if not self._models or len(self._models) != len(self._model_ids):
raise DashboardNotFoundError()
# Templates cannot be deleted - reuse the security_manager helper
for model in self._models:
if security_manager._is_template_dashboard(model):
raise DashboardTemplateDeleteForbiddenError()
# Check there are no associated ReportSchedules
if reports := ReportScheduleDAO.find_by_dashboard_ids(self._model_ids):
report_names = [report.name for report in reports]

View File

@@ -100,3 +100,11 @@ class DashboardFaveError(CommandInvalidError):
class DashboardUnfaveError(CommandInvalidError):
message = _("Dashboard cannot be unfavorited.")
class DashboardTemplateUpdateForbiddenError(ForbiddenError):
message = _("Template dashboards cannot be modified.")
class DashboardTemplateDeleteForbiddenError(ForbiddenError):
message = _("Template dashboards cannot be deleted.")

View File

@@ -37,9 +37,10 @@ from superset.commands.dashboard.importers.v1.utils import (
from superset.commands.database.importers.v1.utils import import_database
from superset.commands.dataset.importers.v1.utils import import_dataset
from superset.commands.importers.v1 import ImportModelsCommand
from superset.commands.importers.v1.utils import import_tag
from superset.commands.importers.v1.utils import import_tag, load_yaml, METADATA_FILE_NAME
from superset.commands.theme.import_themes import import_theme
from superset.commands.utils import update_chart_config_dataset
from superset.connectors.sqla.models import SqlaTable
from superset.daos.dashboard import DashboardDAO
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
@@ -77,6 +78,24 @@ class ImportDashboardsCommand(ImportModelsCommand):
contents: dict[str, Any] | None = None,
) -> None:
contents = {} if contents is None else contents
# Extract template metadata from metadata.yaml if present
template_metadata: dict[str, Any] = {}
if METADATA_FILE_NAME in contents:
metadata = load_yaml(METADATA_FILE_NAME, contents[METADATA_FILE_NAME])
template_fields = [
"is_template",
"is_featured_template",
"template_category",
"template_thumbnail_url",
"template_description",
"template_tags",
"template_context",
]
template_metadata = {
k: v for k, v in metadata.items() if k in template_fields and v
}
# discover charts, datasets, and themes associated with dashboards
chart_uuids: set[str] = set()
dataset_uuids: set[str] = set()
@@ -175,6 +194,11 @@ class ImportDashboardsCommand(ImportModelsCommand):
# Theme not found, set to None for graceful fallback
config["theme_id"] = None
del config["theme_uuid"]
# Merge template metadata into dashboard's metadata
if template_metadata:
if "metadata" not in config:
config["metadata"] = {}
config["metadata"]["template_info"] = template_metadata
dashboard = import_dashboard(config, overwrite=overwrite)
dashboards.append(dashboard)
for uuid in find_chart_uuids(config["position"]):
@@ -207,6 +231,37 @@ class ImportDashboardsCommand(ImportModelsCommand):
for dashboard in dashboards:
migrate_dashboard(dashboard)
# Set is_template_chart and is_template_dataset flags for template dashboards.
# These flags prevent charts/datasets from being modified/deleted via the API.
if template_metadata.get("is_template"):
template_chart_ids: set[int] = set()
template_dataset_ids: set[int] = set()
# Collect all chart IDs from template dashboards
for file_name, config in configs.items():
if file_name.startswith("dashboards/"):
for chart_uuid in find_chart_uuids(config["position"]):
if chart_uuid in chart_ids:
template_chart_ids.add(chart_ids[chart_uuid])
# Set is_template_chart=True for all template charts
for chart in charts:
if chart.id in template_chart_ids:
chart.is_template_chart = True
# Collect dataset IDs from template charts
if chart.datasource_id:
template_dataset_ids.add(chart.datasource_id)
# Set is_template_dataset=True for all datasets used by template charts
if template_dataset_ids:
datasets = (
db.session.query(SqlaTable)
.filter(SqlaTable.id.in_(template_dataset_ids)) # type: ignore[attr-defined]
.all()
)
for dataset in datasets:
dataset.is_template_dataset = True
# Remove all obsolete filter-box charts.
for chart in charts:
if chart.viz_type == "filter_box":

View File

@@ -32,6 +32,7 @@ from superset.commands.dashboard.exceptions import (
DashboardNativeFiltersUpdateFailedError,
DashboardNotFoundError,
DashboardSlugExistsValidationError,
DashboardTemplateUpdateForbiddenError,
DashboardUpdateFailedError,
)
from superset.commands.utils import populate_roles, update_tags, validate_tags
@@ -83,6 +84,11 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
self._model = DashboardDAO.find_by_id(self._model_id)
if not self._model:
raise DashboardNotFoundError()
# Templates cannot be modified - reuse the security_manager helper
if security_manager._is_template_dashboard(self._model):
raise DashboardTemplateUpdateForbiddenError()
# Check ownership
try:
security_manager.raise_for_ownership(self._model)

View File

@@ -0,0 +1,87 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Dashboard Generator Commands.
This package provides AI-powered dashboard generation from templates.
Main components:
- DashboardGeneratorCommand: Main entry point for generation
- AgenticDashboardGenerator: LangGraph-based iterative generator
- TemplateAnalyzer: Extracts requirements from template dashboards
- MappingService: Rule-based column/metric mapping with confidence scoring
- DashboardGeneratorLLMService: LLM service for AI-powered tasks
"""
from superset.commands.dashboard_generator.agentic_generator import (
AgentPhase,
AgenticDashboardGenerator,
ChartValidationResult,
DatasetValidationResult,
FailedMapping,
FilterValidationResult,
)
from superset.commands.dashboard_generator.generate import DashboardGeneratorCommand
from superset.commands.dashboard_generator.llm_service import (
DashboardGeneratorLLMService,
)
from superset.commands.dashboard_generator.mapping_service import (
ColumnMapping,
ConfidenceLevel,
MappingProposal,
MappingService,
MetricMapping,
)
from superset.commands.dashboard_generator.template_analyzer import (
TemplateAnalyzer,
TemplateColumn,
TemplateMetric,
TemplateRequirements,
)
from superset.commands.dashboard_generator.utils import (
COLUMN_PARAMS,
METRIC_PARAMS,
prepare_database_report_data,
)
__all__ = [
# Main command
"DashboardGeneratorCommand",
# Agentic generator
"AgenticDashboardGenerator",
"AgentPhase",
"ChartValidationResult",
"FilterValidationResult",
"DatasetValidationResult",
"FailedMapping",
# Template analysis
"TemplateAnalyzer",
"TemplateRequirements",
"TemplateColumn",
"TemplateMetric",
# Mapping service
"MappingService",
"MappingProposal",
"ColumnMapping",
"MetricMapping",
"ConfidenceLevel",
# LLM service
"DashboardGeneratorLLMService",
# Utilities
"prepare_database_report_data",
"COLUMN_PARAMS",
"METRIC_PARAMS",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Dashboard Generator Command.
This module provides the main entry point for dashboard generation.
It delegates to AgenticDashboardGenerator for the actual implementation.
"""
from __future__ import annotations
import logging
from typing import Any
from superset import db
from superset.commands.base import BaseCommand
from superset.commands.dashboard_generator.agentic_generator import (
AgenticDashboardGenerator,
)
from superset.models.dashboard import Dashboard
from superset.models.dashboard_generator import DashboardGeneratorRun
from superset.models.database_analyzer import DatabaseSchemaReport
logger = logging.getLogger(__name__)
class DashboardGeneratorCommand(BaseCommand):
"""
Command to generate a dashboard from a template using AI-powered mapping.
This command delegates to AgenticDashboardGenerator, which implements
an iterative, self-correcting approach to dashboard generation using
LangGraph for orchestration.
The generation process:
1. Copies the template dashboard with all charts
2. Generates a virtual dataset SQL from the user's schema
3. Maps chart parameters to the new dataset
4. Configures native filters
5. Validates and refines iteratively until quality threshold is met
Usage:
command = DashboardGeneratorCommand(run_id=123)
result = command.run()
"""
def __init__(self, run_id: int):
"""
Initialize the command.
:param run_id: The DashboardGeneratorRun ID to execute
"""
self.run_id = run_id
self.generator_run: DashboardGeneratorRun | None = None
self.report: DatabaseSchemaReport | None = None
self.template_dashboard: Dashboard | None = None
def run(self) -> dict[str, Any]:
"""
Execute dashboard generation.
Validates inputs and delegates to AgenticDashboardGenerator.
:return: Result dictionary with status, dashboard_id, dataset_id, and metrics
:raises ValueError: If run, report, or template not found
"""
self.validate()
# Delegate to agentic generator
generator = AgenticDashboardGenerator()
return generator.generate(self.run_id)
def validate(self) -> None:
"""
Validate that all required resources exist.
:raises ValueError: If run, report, or template not found
"""
self.generator_run = db.session.query(DashboardGeneratorRun).get(self.run_id)
if not self.generator_run:
raise ValueError(f"Run with id {self.run_id} not found")
self.report = db.session.query(DatabaseSchemaReport).get(
self.generator_run.database_report_id
)
if not self.report:
raise ValueError(
f"Database report with id {self.generator_run.database_report_id} not found"
)
self.template_dashboard = db.session.query(Dashboard).get(
self.generator_run.template_dashboard_id
)
if not self.template_dashboard:
raise ValueError(
f"Template dashboard with id {self.generator_run.template_dashboard_id} not found"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,562 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Mapping Service for Dashboard Generation.
Provides confidence-scored column and metric mappings between
template requirements and user database schema.
Key features:
- Structural pre-matching (exact match, normalization, type checking)
- Confidence scoring to identify when review is needed
- LLM handles all semantic matching (synonyms, context, industry terms)
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from superset.commands.dashboard_generator.template_analyzer import (
TemplateColumn,
TemplateMetric,
TemplateRequirements,
)
from superset.models.database_analyzer import (
AnalyzedColumn,
AnalyzedTable,
DatabaseSchemaReport,
)
from superset.utils.core import GenericDataType
logger = logging.getLogger(__name__)
# Confidence thresholds
HIGH_CONFIDENCE_THRESHOLD = 0.85
MEDIUM_CONFIDENCE_THRESHOLD = 0.60
class ConfidenceLevel(str, Enum):
"""Confidence level for mapping quality."""
HIGH = "high" # ≥85% - Auto-approve
MEDIUM = "medium" # 60-84% - Warn but allow
LOW = "low" # <60% - Requires review
FAILED = "failed" # No match found
@dataclass
class ColumnMapping:
"""Represents a mapping from template column to user column."""
template_column: str
user_column: str | None
user_table: str | None = None
confidence: float = 0.0
confidence_level: ConfidenceLevel = ConfidenceLevel.FAILED
match_reasons: list[str] = field(default_factory=list)
alternatives: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class MetricMapping:
"""Represents a mapping for a template metric."""
template_metric: str
user_expression: str | None = None
confidence: float = 0.0
confidence_level: ConfidenceLevel = ConfidenceLevel.FAILED
match_reasons: list[str] = field(default_factory=list)
alternatives: list[str] = field(default_factory=list)
@dataclass
class MappingProposal:
"""Complete mapping proposal with confidence scores."""
proposal_id: str
column_mappings: list[ColumnMapping] = field(default_factory=list)
metric_mappings: list[MetricMapping] = field(default_factory=list)
unmapped_columns: list[str] = field(default_factory=list)
unmapped_metrics: list[str] = field(default_factory=list)
requires_review: bool = False
review_reasons: list[str] = field(default_factory=list)
overall_confidence: float = 0.0
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for API response."""
return {
"proposal_id": self.proposal_id,
"column_mappings": [
{
"template_column": m.template_column,
"user_column": m.user_column,
"user_table": m.user_table,
"confidence": m.confidence,
"confidence_level": m.confidence_level.value,
"match_reasons": m.match_reasons,
"alternatives": m.alternatives,
}
for m in self.column_mappings
],
"metric_mappings": [
{
"template_metric": m.template_metric,
"user_expression": m.user_expression,
"confidence": m.confidence,
"confidence_level": m.confidence_level.value,
"match_reasons": m.match_reasons,
"alternatives": m.alternatives,
}
for m in self.metric_mappings
],
"unmapped_columns": self.unmapped_columns,
"unmapped_metrics": self.unmapped_metrics,
"requires_review": self.requires_review,
"review_reasons": self.review_reasons,
"overall_confidence": self.overall_confidence,
}
class MappingService:
"""
Service for generating confidence-scored column/metric mappings.
Uses a hybrid approach:
1. Structural pre-matching (exact names, type compatibility)
2. Confidence scoring based on multiple factors
3. LLM handles semantic matching (synonyms, context-aware reasoning)
"""
def propose_mappings(
self,
template_requirements: TemplateRequirements,
database_report: DatabaseSchemaReport,
) -> MappingProposal:
"""
Generate mapping proposal with confidence scores.
:param template_requirements: Extracted template requirements
:param database_report: Analyzed user database schema
:return: MappingProposal with confidence-scored mappings
"""
logger.info("Generating mapping proposal")
proposal = MappingProposal(proposal_id=str(uuid.uuid4()))
# Build flat list of all columns from report
user_columns = self._extract_user_columns(database_report)
# Match columns
for template_col in template_requirements.columns:
mapping = self._match_column(template_col, user_columns)
proposal.column_mappings.append(mapping)
if mapping.confidence_level == ConfidenceLevel.FAILED:
proposal.unmapped_columns.append(template_col.name)
# Match metrics
for template_metric in template_requirements.metrics:
mapping = self._match_metric(template_metric, user_columns)
proposal.metric_mappings.append(mapping)
if mapping.confidence_level == ConfidenceLevel.FAILED:
proposal.unmapped_metrics.append(template_metric.name)
# Determine if review is needed
proposal.requires_review = self._check_requires_review(proposal)
proposal.review_reasons = self._get_review_reasons(proposal)
proposal.overall_confidence = self._calculate_overall_confidence(proposal)
logger.info(
"Mapping proposal generated: %d columns, %d metrics, requires_review=%s",
len(proposal.column_mappings),
len(proposal.metric_mappings),
proposal.requires_review,
)
return proposal
def _extract_user_columns(
self, report: DatabaseSchemaReport
) -> list[tuple[AnalyzedTable, AnalyzedColumn]]:
"""Extract all columns from database report with their tables."""
columns = []
for table in report.tables:
for col in table.columns:
columns.append((table, col))
return columns
def _match_column(
self,
template_col: TemplateColumn,
user_columns: list[tuple[AnalyzedTable, AnalyzedColumn]],
) -> ColumnMapping:
"""Match a template column to the best user column."""
candidates: list[tuple[float, list[str], AnalyzedTable, AnalyzedColumn]] = []
for table, col in user_columns:
score, reasons = self._calculate_column_score(template_col, col)
if score > 0.3: # Minimum threshold for candidates
candidates.append((score, reasons, table, col))
# Sort by score descending
candidates.sort(key=lambda x: x[0], reverse=True)
if not candidates:
return ColumnMapping(
template_column=template_col.name,
user_column=None,
confidence=0.0,
confidence_level=ConfidenceLevel.FAILED,
match_reasons=["No matching column found"],
)
# Best match
best_score, best_reasons, best_table, best_col = candidates[0]
confidence_level = self._score_to_confidence_level(best_score)
# Build alternatives from remaining candidates
alternatives = [
{
"column": c[3].column_name,
"table": c[2].table_name,
"confidence": c[0],
}
for c in candidates[1:5] # Top 4 alternatives
]
return ColumnMapping(
template_column=template_col.name,
user_column=best_col.column_name,
user_table=best_table.table_name,
confidence=best_score,
confidence_level=confidence_level,
match_reasons=best_reasons,
alternatives=alternatives,
)
def _calculate_column_score(
self,
template_col: TemplateColumn,
user_col: AnalyzedColumn,
) -> tuple[float, list[str]]:
"""
Calculate match score for a column pair.
Scoring factors (weighted):
- Name similarity (40%): Fuzzy string match
- Type compatibility (35%): Same GenericDataType
- Role match (15%): temporal/dimension/measure
- AI description match (10%): Semantic hints from AI descriptions
"""
score = 0.0
reasons: list[str] = []
# Name similarity (40%)
name_score = self._calculate_name_similarity(
template_col.name, user_col.column_name
)
score += name_score * 0.40
if name_score > 0.8:
reasons.append(f"Strong name match ({name_score:.0%})")
elif name_score > 0.5:
reasons.append(f"Partial name match ({name_score:.0%})")
# Type compatibility (35%)
type_score = self._calculate_type_compatibility(
template_col.type_generic, user_col.type_generic
)
score += type_score * 0.35
if type_score == 1.0:
reasons.append("Type compatible")
elif type_score > 0:
reasons.append("Type partially compatible")
# Role match (15%)
role_score = self._calculate_role_match(template_col, user_col)
score += role_score * 0.15
if role_score == 1.0:
reasons.append(f"Role matches ({template_col.role})")
# AI description match (10%)
semantic_score = self._calculate_semantic_match(
template_col.name, user_col.ai_description
)
score += semantic_score * 0.10
if semantic_score > 0.5:
reasons.append("Semantic match in AI description")
return score, reasons
def _calculate_name_similarity(self, name1: str, name2: str) -> float:
"""
Calculate basic name similarity using normalization.
Only performs simple structural matching:
- Exact match after normalization
- Common abbreviation patterns
Semantic matching (synonyms, fuzzy matching, industry terms) is
delegated to the LLM reasoning loop which has better cross-industry
coverage and handles context-aware matching.
"""
# Normalize: lowercase, replace separators with spaces
n1 = name1.lower().replace("_", " ").replace("-", " ").strip()
n2 = name2.lower().replace("_", " ").replace("-", " ").strip()
# Exact match after normalization
if n1 == n2:
return 1.0
# Also check without spaces (order_id vs orderid)
n1_compact = n1.replace(" ", "")
n2_compact = n2.replace(" ", "")
if n1_compact == n2_compact:
return 0.95
# Check if one is contained in the other (customer vs customer_id)
if n1_compact in n2_compact or n2_compact in n1_compact:
return 0.7
# Check prefix match (cust vs customer) - at least 3 chars
min_prefix = min(len(n1_compact), len(n2_compact), 3)
if min_prefix >= 3 and n1_compact[:min_prefix] == n2_compact[:min_prefix]:
return 0.5
# No structural match - LLM will handle semantic matching
return 0.0
def _calculate_type_compatibility(
self,
type1: GenericDataType | None,
type2: GenericDataType | None,
) -> float:
"""Check if types are compatible for mapping."""
if type1 is None or type2 is None:
return 0.5 # Unknown type - partial match
if type1 == type2:
return 1.0
# Partial compatibility
compatible_groups = [
{GenericDataType.NUMERIC},
{GenericDataType.STRING},
{GenericDataType.TEMPORAL},
{GenericDataType.BOOLEAN},
]
for group in compatible_groups:
if type1 in group and type2 in group:
return 0.8
return 0.3 # Different types - low score
def _calculate_role_match(
self,
template_col: TemplateColumn,
user_col: AnalyzedColumn,
) -> float:
"""Calculate role match score."""
# Infer user column role from type
user_role = "dimension"
if user_col.type_generic == GenericDataType.TEMPORAL:
user_role = "temporal"
elif user_col.type_generic == GenericDataType.NUMERIC:
user_role = "measure"
if template_col.role == user_role:
return 1.0
return 0.3
def _calculate_semantic_match(
self,
template_name: str,
ai_description: str | None,
) -> float:
"""Check if template column name appears in AI description."""
if not ai_description:
return 0.0
template_lower = template_name.lower().replace("_", " ")
desc_lower = ai_description.lower()
if template_lower in desc_lower:
return 1.0
# Check for semantic keywords
words = template_lower.split()
matches = sum(1 for w in words if w in desc_lower and len(w) > 2)
if words:
return matches / len(words) * 0.8
return 0.0
def _match_metric(
self,
template_metric: TemplateMetric,
user_columns: list[tuple[AnalyzedTable, AnalyzedColumn]],
) -> MetricMapping:
"""Match a template metric to a user expression."""
# For simple aggregates, try to find matching column
if template_metric.aggregate and template_metric.base_column:
# Look for the base column
best_match = None
best_score = 0.0
for table, col in user_columns:
score = self._calculate_name_similarity(
template_metric.base_column, col.column_name
)
# Prefer numeric columns for aggregates
if col.type_generic == GenericDataType.NUMERIC:
score *= 1.2
if score > best_score:
best_score = score
best_match = (table, col)
if best_match and best_score > 0.5:
table, col = best_match
expression = f"{template_metric.aggregate}({col.column_name})"
confidence = min(best_score, 1.0)
return MetricMapping(
template_metric=template_metric.name,
user_expression=expression,
confidence=confidence,
confidence_level=self._score_to_confidence_level(confidence),
match_reasons=[
f"Aggregate {template_metric.aggregate} on {col.column_name}"
],
)
# For COUNT(*), high confidence
if template_metric.expression and "COUNT(*)" in template_metric.expression.upper():
return MetricMapping(
template_metric=template_metric.name,
user_expression="COUNT(*)",
confidence=0.95,
confidence_level=ConfidenceLevel.HIGH,
match_reasons=["COUNT(*) is universal"],
)
# For named metrics, try to match by name
for table, col in user_columns:
name_score = self._calculate_name_similarity(
template_metric.name, col.column_name
)
if name_score > 0.7 and col.type_generic == GenericDataType.NUMERIC:
return MetricMapping(
template_metric=template_metric.name,
user_expression=f"SUM({col.column_name})",
confidence=name_score * 0.8,
confidence_level=self._score_to_confidence_level(name_score * 0.8),
match_reasons=[f"Name match with numeric column {col.column_name}"],
alternatives=[f"AVG({col.column_name})", f"MAX({col.column_name})"],
)
# No good match found
return MetricMapping(
template_metric=template_metric.name,
user_expression=None,
confidence=0.0,
confidence_level=ConfidenceLevel.FAILED,
match_reasons=["No matching metric found"],
)
def _score_to_confidence_level(self, score: float) -> ConfidenceLevel:
"""Convert numeric score to confidence level."""
if score >= HIGH_CONFIDENCE_THRESHOLD:
return ConfidenceLevel.HIGH
if score >= MEDIUM_CONFIDENCE_THRESHOLD:
return ConfidenceLevel.MEDIUM
if score > 0:
return ConfidenceLevel.LOW
return ConfidenceLevel.FAILED
def _check_requires_review(self, proposal: MappingProposal) -> bool:
"""Determine if proposal requires user review."""
# Review needed if any mapping is low confidence or failed
for mapping in proposal.column_mappings:
if mapping.confidence_level in (ConfidenceLevel.LOW, ConfidenceLevel.FAILED):
return True
for mapping in proposal.metric_mappings:
if mapping.confidence_level in (ConfidenceLevel.LOW, ConfidenceLevel.FAILED):
return True
# Review needed if there are unmapped items
if proposal.unmapped_columns or proposal.unmapped_metrics:
return True
return False
def _get_review_reasons(self, proposal: MappingProposal) -> list[str]:
"""Get reasons why review is needed."""
reasons = []
low_confidence_cols = [
m.template_column
for m in proposal.column_mappings
if m.confidence_level in (ConfidenceLevel.LOW, ConfidenceLevel.FAILED)
]
if low_confidence_cols:
reasons.append(
f"Low confidence column mappings: {', '.join(low_confidence_cols)}"
)
low_confidence_metrics = [
m.template_metric
for m in proposal.metric_mappings
if m.confidence_level in (ConfidenceLevel.LOW, ConfidenceLevel.FAILED)
]
if low_confidence_metrics:
reasons.append(
f"Low confidence metric mappings: {', '.join(low_confidence_metrics)}"
)
if proposal.unmapped_columns:
reasons.append(
f"Unmapped columns: {', '.join(proposal.unmapped_columns)}"
)
if proposal.unmapped_metrics:
reasons.append(
f"Unmapped metrics: {', '.join(proposal.unmapped_metrics)}"
)
return reasons
def _calculate_overall_confidence(self, proposal: MappingProposal) -> float:
"""Calculate overall confidence score for the proposal."""
all_confidences = []
for mapping in proposal.column_mappings:
all_confidences.append(mapping.confidence)
for mapping in proposal.metric_mappings:
all_confidences.append(mapping.confidence)
if not all_confidences:
return 0.0
return sum(all_confidences) / len(all_confidences)

View File

@@ -0,0 +1,362 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Template Analyzer for Dashboard Generation.
Extracts requirements from template dashboards including:
- Required columns per chart
- Required metrics with expressions
- Native filter configurations
- Dataset SQL if virtual
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from superset.connectors.sqla.models import (
COLUMN_FORM_DATA_PARAMS,
METRIC_FORM_DATA_PARAMS,
)
from superset.models.dashboard import Dashboard
from superset.utils import json
from superset.utils.core import (
as_list,
GenericDataType,
get_column_name,
get_metric_name,
is_adhoc_metric,
)
logger = logging.getLogger(__name__)
@dataclass
class TemplateColumn:
"""Represents a column required by the template."""
name: str
type_generic: GenericDataType | None = None
data_type: str | None = None
role: str = "dimension" # 'dimension', 'temporal', 'measure'
used_by_charts: list[int] = field(default_factory=list)
is_adhoc: bool = False
sql_expression: str | None = None
@dataclass
class TemplateMetric:
"""Represents a metric required by the template."""
name: str
expression: str | None = None
aggregate: str | None = None # SUM, COUNT, AVG, etc.
base_column: str | None = None
used_by_charts: list[int] = field(default_factory=list)
is_adhoc: bool = False
@dataclass
class TemplateFilter:
"""Represents a native filter in the template."""
filter_id: str
filter_type: str
target_column: str | None = None
default_value: Any = None
config: dict[str, Any] = field(default_factory=dict)
@dataclass
class TemplateRequirements:
"""Complete requirements extracted from a template dashboard."""
columns: list[TemplateColumn] = field(default_factory=list)
metrics: list[TemplateMetric] = field(default_factory=list)
filters: list[TemplateFilter] = field(default_factory=list)
dataset_sql: str | None = None
dataset_name: str | None = None
chart_count: int = 0
template_context: dict[str, Any] | None = None
class TemplateAnalyzer:
"""
Analyzes template dashboards to extract requirements.
Uses existing Superset utilities for column/metric extraction:
- METRIC_FORM_DATA_PARAMS, COLUMN_FORM_DATA_PARAMS for param keys
- get_metric_name, get_column_name for extracting names
- is_adhoc_metric for detecting computed metrics
"""
def analyze(self, dashboard: Dashboard) -> TemplateRequirements:
"""
Analyze a template dashboard and extract all requirements.
:param dashboard: The template dashboard to analyze
:return: TemplateRequirements with columns, metrics, filters
"""
logger.info("Analyzing template dashboard %s", dashboard.id)
requirements = TemplateRequirements(chart_count=len(dashboard.slices))
# Extract from charts
column_map: dict[str, TemplateColumn] = {}
metric_map: dict[str, TemplateMetric] = {}
for chart in dashboard.slices:
self._extract_chart_requirements(chart, column_map, metric_map)
requirements.columns = list(column_map.values())
requirements.metrics = list(metric_map.values())
# Extract native filters
requirements.filters = self._extract_native_filters(dashboard)
# Extract dataset info
requirements.dataset_sql, requirements.dataset_name = (
self._extract_dataset_info(dashboard)
)
# Extract template context if present
metadata = json.loads(dashboard.json_metadata or "{}")
template_info = metadata.get("template_info", {}) if isinstance(metadata, dict) else {}
if isinstance(template_info, dict) and "template_context" in template_info:
requirements.template_context = template_info.get("template_context")
logger.info(
"Template analysis complete: %d columns, %d metrics, %d filters",
len(requirements.columns),
len(requirements.metrics),
len(requirements.filters),
)
return requirements
def _extract_chart_requirements(
self,
chart: Any,
column_map: dict[str, TemplateColumn],
metric_map: dict[str, TemplateMetric],
) -> None:
"""Extract column and metric requirements from a chart."""
params = json.loads(chart.params or "{}")
chart_id = chart.id
datasource = chart.datasource
# Build verbose map for name resolution
verbose_map = {}
if datasource:
verbose_map = getattr(datasource, "verbose_map", {}) or {}
# Extract columns using existing params list
for param_key in COLUMN_FORM_DATA_PARAMS:
for column in as_list(params.get(param_key) or []):
col_name = get_column_name(column, verbose_map)
if not col_name:
continue
if col_name in column_map:
if chart_id not in column_map[col_name].used_by_charts:
column_map[col_name].used_by_charts.append(chart_id)
else:
template_col = self._create_template_column(
column, col_name, chart_id, datasource
)
column_map[col_name] = template_col
# Extract metrics using existing params list
for param_key in METRIC_FORM_DATA_PARAMS:
for metric in as_list(params.get(param_key) or []):
metric_name = get_metric_name(metric, verbose_map)
if not metric_name:
continue
if metric_name in metric_map:
if chart_id not in metric_map[metric_name].used_by_charts:
metric_map[metric_name].used_by_charts.append(chart_id)
else:
template_metric = self._create_template_metric(
metric, metric_name, chart_id, datasource
)
metric_map[metric_name] = template_metric
# Extract temporal column from x_axis if present
if x_axis := params.get("x_axis"):
col_name = get_column_name(x_axis, verbose_map)
if col_name:
if col_name in column_map:
column_map[col_name].role = "temporal"
else:
template_col = self._create_template_column(
x_axis, col_name, chart_id, datasource
)
template_col.role = "temporal"
column_map[col_name] = template_col
# Check granularity_sqla for temporal
if granularity := params.get("granularity_sqla"):
if isinstance(granularity, str) and granularity in column_map:
column_map[granularity].role = "temporal"
def _create_template_column(
self,
column: Any,
col_name: str,
chart_id: int,
datasource: Any,
) -> TemplateColumn:
"""Create a TemplateColumn from raw column data."""
template_col = TemplateColumn(
name=col_name,
used_by_charts=[chart_id],
)
# Check if adhoc column
if isinstance(column, dict):
if "sqlExpression" in column:
template_col.is_adhoc = True
template_col.sql_expression = column.get("sqlExpression")
# Get type info from datasource if available
if datasource:
for ds_col in getattr(datasource, "columns", []):
if ds_col.column_name == col_name:
template_col.data_type = ds_col.type
template_col.type_generic = getattr(
ds_col, "type_generic", None
)
# Infer role from type
if template_col.type_generic == GenericDataType.TEMPORAL:
template_col.role = "temporal"
elif template_col.type_generic == GenericDataType.NUMERIC:
template_col.role = "measure"
break
return template_col
def _create_template_metric(
self,
metric: Any,
metric_name: str,
chart_id: int,
datasource: Any,
) -> TemplateMetric:
"""Create a TemplateMetric from raw metric data."""
template_metric = TemplateMetric(
name=metric_name,
used_by_charts=[chart_id],
)
if is_adhoc_metric(metric):
template_metric.is_adhoc = True
expression_type = metric.get("expressionType")
if expression_type == "SQL":
template_metric.expression = metric.get("sqlExpression")
elif expression_type == "SIMPLE":
template_metric.aggregate = metric.get("aggregate")
column_info = metric.get("column", {})
if isinstance(column_info, dict):
template_metric.base_column = column_info.get("column_name")
else:
# Named metric - look up in datasource
if datasource:
for ds_metric in getattr(datasource, "metrics", []):
if ds_metric.metric_name == metric_name:
template_metric.expression = ds_metric.expression
# Try to parse aggregate from expression
template_metric.aggregate = self._parse_aggregate(
ds_metric.expression
)
break
return template_metric
def _parse_aggregate(self, expression: str | None) -> str | None:
"""Parse aggregate function from metric expression."""
if not expression:
return None
expression_upper = expression.upper().strip()
for agg in ["COUNT", "SUM", "AVG", "MIN", "MAX", "COUNT_DISTINCT"]:
if expression_upper.startswith(agg + "("):
return agg
return None
def _extract_native_filters(
self, dashboard: Dashboard
) -> list[TemplateFilter]:
"""Extract native filter configurations from dashboard metadata."""
metadata = json.loads(dashboard.json_metadata or "{}")
native_filter_config = metadata.get("native_filter_configuration", [])
filters = []
for f in native_filter_config:
template_filter = TemplateFilter(
filter_id=f.get("id", ""),
filter_type=f.get("filterType", ""),
config=f,
)
# Extract target column
targets = f.get("targets", [])
if targets and isinstance(targets, list):
first_target = targets[0]
if isinstance(first_target, dict):
column_info = first_target.get("column", {})
if isinstance(column_info, dict):
template_filter.target_column = column_info.get("name")
# Extract default value
template_filter.default_value = f.get("defaultDataMask", {}).get(
"filterState", {}
).get("value")
filters.append(template_filter)
return filters
def _extract_dataset_info(
self, dashboard: Dashboard
) -> tuple[str | None, str | None]:
"""Extract dataset SQL and name from first chart's datasource."""
if not dashboard.slices:
return None, None
first_slice = dashboard.slices[0]
datasource = first_slice.datasource
if not datasource:
return None, None
dataset_sql = getattr(datasource, "sql", None)
dataset_name = getattr(datasource, "name", None)
return dataset_sql, dataset_name
def analyze_template(dashboard: Dashboard) -> TemplateRequirements:
"""
Convenience function to analyze a template dashboard.
:param dashboard: The template dashboard to analyze
:return: TemplateRequirements with extracted requirements
"""
return TemplateAnalyzer().analyze(dashboard)

View File

@@ -0,0 +1,112 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Shared utilities for Dashboard Generator.
This module contains common helper functions and constants used across
the dashboard generation pipeline.
"""
from __future__ import annotations
from typing import Any
from superset.models.database_analyzer import DatabaseSchemaReport
from superset.utils import json
# Constants for chart params that contain column references
COLUMN_PARAMS = [
"groupby",
"columns",
"all_columns",
"all_columns_x",
"all_columns_y",
"order_by_cols",
"series",
"entity",
"x_axis",
"temporal_columns_lookup",
]
METRIC_PARAMS = [
"metric",
"metrics",
"metric_2",
"percent_metrics",
"secondary_metric",
"size",
"x",
"y",
]
def prepare_database_report_data(report: DatabaseSchemaReport) -> dict[str, Any]:
"""
Convert DatabaseSchemaReport to dictionary format for LLM prompts.
:param report: The database schema report
:return: Dictionary with tables, columns, and joins
"""
tables = []
for table in report.tables:
table_data = {
"name": table.table_name,
"type": table.table_type.value if table.table_type else "table",
"description": table.ai_description or table.db_comment,
"columns": [],
}
for col in table.columns:
col_data = {
"name": col.column_name,
"type": col.data_type,
"description": col.ai_description or col.db_comment,
}
table_data["columns"].append(col_data)
tables.append(table_data)
joins = []
for join in report.joins:
# Handle source_columns/target_columns which may be JSON strings
src_cols = (
json.loads(join.source_columns)
if isinstance(join.source_columns, str)
else join.source_columns
)
tgt_cols = (
json.loads(join.target_columns)
if isinstance(join.target_columns, str)
else join.target_columns
)
join_data = {
"source_table": join.source_table.table_name,
"source_columns": src_cols,
"target_table": join.target_table.table_name,
"target_columns": tgt_cols,
"join_type": join.join_type.value if join.join_type else "inner",
"cardinality": join.cardinality.value if join.cardinality else "N:1",
}
joins.append(join_data)
return {
"database_id": report.database_id,
"schema_name": report.schema_name,
"tables": tables,
"joins": joins,
}

View File

@@ -0,0 +1,314 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
SQL Validator for Dashboard Generation.
Validates LLM-generated SQL before creating datasets:
- Syntax validation using sqlglot
- Execution validation with LIMIT
- Column presence verification
- Row count sanity checks
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from superset.connectors.sqla.utils import get_columns_description
from superset.models.core import Database
from superset.sql.parse import SQLStatement
logger = logging.getLogger(__name__)
# Thresholds for validation
MAX_ROW_COUNT_WARNING = 10_000_000 # Warn if more than 10M rows
SAMPLE_LIMIT = 5 # Number of sample rows to fetch
@dataclass
class ValidationResult:
"""Result of SQL validation."""
success: bool
error_message: str | None = None
warning_message: str | None = None
actual_columns: list[str] = field(default_factory=list)
missing_columns: list[str] = field(default_factory=list)
extra_columns: list[str] = field(default_factory=list)
sample_rows: list[dict[str, Any]] = field(default_factory=list)
row_count_estimate: int | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for API response."""
return {
"success": self.success,
"error_message": self.error_message,
"warning_message": self.warning_message,
"actual_columns": self.actual_columns,
"missing_columns": self.missing_columns,
"extra_columns": self.extra_columns,
"sample_rows": self.sample_rows,
"row_count_estimate": self.row_count_estimate,
}
class SQLValidator:
"""
Validates SQL for dashboard generation.
Uses existing Superset utilities:
- SQLStatement for syntax parsing
- get_columns_description for column extraction
"""
def validate(
self,
sql: str,
expected_columns: list[str],
database: Database,
schema: str | None = None,
catalog: str | None = None,
) -> ValidationResult:
"""
Validate SQL against expected requirements.
:param sql: The SQL query to validate
:param expected_columns: List of column names expected in output
:param database: Database to validate against
:param schema: Schema name
:param catalog: Catalog name
:return: ValidationResult with details
"""
logger.info("Validating generated SQL")
# Step 1: Syntax check using sqlglot
syntax_result = self._validate_syntax(sql, database)
if not syntax_result.success:
return syntax_result
# Step 2: Check for mutating operations
mutating_result = self._check_no_mutations(sql, database)
if not mutating_result.success:
return mutating_result
# Step 3: Execute with LIMIT to get columns
execution_result = self._validate_execution(
sql, database, schema, catalog
)
if not execution_result.success:
return execution_result
# Step 4: Check expected columns
column_result = self._validate_columns(
execution_result.actual_columns, expected_columns
)
# Step 5: Get sample rows
sample_result = self._get_sample_data(sql, database, schema, catalog)
# Step 6: Estimate row count
row_count = self._estimate_row_count(sql, database, schema, catalog)
# Build final result
result = ValidationResult(
success=column_result.success,
error_message=column_result.error_message,
actual_columns=execution_result.actual_columns,
missing_columns=column_result.missing_columns,
extra_columns=column_result.extra_columns,
sample_rows=sample_result,
row_count_estimate=row_count,
)
# Add warnings
if row_count and row_count > MAX_ROW_COUNT_WARNING:
result.warning_message = (
f"Dataset has {row_count:,} rows which may impact performance"
)
elif row_count == 0:
result.warning_message = "Dataset returns no rows"
return result
def _validate_syntax(
self, sql: str, database: Database
) -> ValidationResult:
"""Validate SQL syntax using sqlglot."""
try:
engine = database.db_engine_spec.engine
stmt = SQLStatement(sql, engine=engine)
# Just parsing is enough - if it fails, we'll get an exception
_ = stmt
return ValidationResult(success=True)
except Exception as e:
logger.error("SQL syntax error: %s", str(e))
return ValidationResult(
success=False,
error_message=f"SQL syntax error: {str(e)}",
)
def _check_no_mutations(
self, sql: str, database: Database
) -> ValidationResult:
"""Ensure SQL doesn't contain mutating operations."""
try:
engine = database.db_engine_spec.engine
stmt = SQLStatement(sql, engine=engine)
if stmt.is_mutating():
return ValidationResult(
success=False,
error_message="SQL contains mutating operations (INSERT, UPDATE, DELETE, etc.)",
)
return ValidationResult(success=True)
except Exception as e:
logger.warning("Could not check mutations: %s", str(e))
# If we can't parse, allow it through - execution will catch issues
return ValidationResult(success=True)
def _validate_execution(
self,
sql: str,
database: Database,
schema: str | None,
catalog: str | None,
) -> ValidationResult:
"""Execute SQL with LIMIT to verify it runs and get columns."""
try:
# Wrap in subquery with LIMIT for safety
limited_sql = f"SELECT * FROM ({sql}) _validation_subquery LIMIT 0"
columns = get_columns_description(
database=database,
catalog=catalog,
schema=schema,
query=limited_sql,
)
actual_columns = [col["column_name"] for col in columns]
return ValidationResult(
success=True,
actual_columns=actual_columns,
)
except Exception as e:
logger.error("SQL execution error: %s", str(e))
return ValidationResult(
success=False,
error_message=f"SQL execution failed: {str(e)}",
)
def _validate_columns(
self,
actual_columns: list[str],
expected_columns: list[str],
) -> ValidationResult:
"""Check if expected columns are present in actual columns."""
actual_set = set(col.lower() for col in actual_columns)
expected_set = set(col.lower() for col in expected_columns)
missing = expected_set - actual_set
extra = actual_set - expected_set
# Allow case-insensitive matching for missing
missing_case_insensitive = []
for exp_col in missing:
if exp_col not in actual_set:
missing_case_insensitive.append(exp_col)
if missing_case_insensitive:
return ValidationResult(
success=False,
error_message=f"Missing columns: {', '.join(missing_case_insensitive)}",
missing_columns=list(missing_case_insensitive),
extra_columns=list(extra),
)
return ValidationResult(
success=True,
extra_columns=list(extra),
)
def _get_sample_data(
self,
sql: str,
database: Database,
schema: str | None,
catalog: str | None,
) -> list[dict[str, Any]]:
"""Get sample rows from the query."""
try:
limited_sql = f"SELECT * FROM ({sql}) _sample_subquery LIMIT {SAMPLE_LIMIT}"
with database.get_raw_connection(catalog=catalog, schema=schema) as conn:
cursor = conn.cursor()
cursor.execute(limited_sql)
columns = [desc[0] for desc in cursor.description or []]
rows = cursor.fetchall()
return [dict(zip(columns, row)) for row in rows]
except Exception as e:
logger.warning("Could not get sample data: %s", str(e))
return []
def _estimate_row_count(
self,
sql: str,
database: Database,
schema: str | None,
catalog: str | None,
) -> int | None:
"""Estimate row count for the query."""
try:
count_sql = f"SELECT COUNT(*) FROM ({sql}) _count_subquery"
with database.get_raw_connection(catalog=catalog, schema=schema) as conn:
cursor = conn.cursor()
cursor.execute(count_sql)
result = cursor.fetchone()
return result[0] if result else None
except Exception as e:
logger.warning("Could not estimate row count: %s", str(e))
return None
def validate_generated_sql(
sql: str,
expected_columns: list[str],
database: Database,
schema: str | None = None,
catalog: str | None = None,
) -> ValidationResult:
"""
Convenience function to validate generated SQL.
:param sql: The SQL query to validate
:param expected_columns: List of column names expected in output
:param database: Database to validate against
:param schema: Schema name
:param catalog: Catalog name
:return: ValidationResult with details
"""
return SQLValidator().validate(
sql=sql,
expected_columns=expected_columns,
database=database,
schema=schema,
catalog=catalog,
)

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -0,0 +1,610 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from concurrent.futures import as_completed, ThreadPoolExecutor
from typing import Any
from flask import current_app, Flask
from sqlalchemy import inspect, MetaData, text
from superset import db
from superset.commands.base import BaseCommand
from superset.commands.database_analyzer.llm_service import LLMService
from superset.models.core import Database
from superset.models.database_analyzer import (
AnalyzedColumn,
AnalyzedTable,
DatabaseSchemaReport,
InferredJoin,
TableType,
)
from superset.utils import json
logger = logging.getLogger(__name__)
class AnalyzeDatabaseSchemaCommand(BaseCommand):
"""Command to analyze database schema and generate metadata"""
def __init__(self, report_id: int):
self.report_id = report_id
self.report: DatabaseSchemaReport | None = None
self.database: Database | None = None
self.llm_service = LLMService()
def run(self) -> dict[str, Any]:
"""Execute the analysis"""
self.validate()
# After validate(), report and database are guaranteed to be set
assert self.report is not None
assert self.database is not None
# Extract schema information
tables_data = self._extract_schema_info()
# Store basic metadata
self._store_tables_and_columns(tables_data)
# Augment with AI descriptions (parallel processing)
self._augment_with_ai_descriptions()
# Infer joins using AI
self._infer_joins_with_ai()
# Validate the analysis confidence
self._validate_analysis_confidence()
return {
"tables_count": len(self.report.tables),
"joins_count": len(self.report.joins),
"confidence_score": self.report.confidence_score,
}
def validate(self) -> None:
"""Validate the command can be executed"""
self.report = db.session.query(DatabaseSchemaReport).get(self.report_id)
if not self.report:
raise ValueError(f"Report with id {self.report_id} not found")
self.database = self.report.database
if not self.database:
raise ValueError(f"Database with id {self.report.database_id} not found")
def _extract_schema_info(self) -> list[dict[str, Any]]:
"""Extract schema information from the database"""
assert self.report is not None
assert self.database is not None
logger.info(
"Extracting schema info for database %s schema %s",
self.database.id,
self.report.schema_name,
)
tables_data = []
with self.database.get_sqla_engine() as engine:
inspector = inspect(engine)
metadata = MetaData()
metadata.reflect(engine, schema=self.report.schema_name)
# Get all tables and views
table_names = inspector.get_table_names(schema=self.report.schema_name)
view_names = inspector.get_view_names(schema=self.report.schema_name)
# Process tables
for table_name in table_names:
table_info = self._extract_table_info(
inspector, engine, table_name, TableType.TABLE
)
tables_data.append(table_info)
# Process views
for view_name in view_names:
view_info = self._extract_table_info(
inspector, engine, view_name, TableType.VIEW
)
tables_data.append(view_info)
return tables_data
def _extract_table_info(
self,
inspector: Any,
engine: Any,
table_name: str,
table_type: TableType,
) -> dict[str, Any]:
"""Extract information for a single table/view"""
assert self.report is not None
logger.debug("Extracting info for %s: %s", table_type.value, table_name)
# Get columns
columns = inspector.get_columns(table_name, schema=self.report.schema_name)
# Get primary keys
pk_constraint = inspector.get_pk_constraint(
table_name, schema=self.report.schema_name
)
primary_keys = pk_constraint["constrained_columns"] if pk_constraint else []
# Get foreign keys
foreign_keys = inspector.get_foreign_keys(
table_name, schema=self.report.schema_name
)
fk_columns = set()
for fk in foreign_keys:
fk_columns.update(fk["constrained_columns"])
# Get table comment
table_comment = None
try:
schema = self.report.schema_name
comment_sql = ( # noqa: S608
f"SELECT obj_description('{schema}.{table_name}'::regclass, 'pg_class')"
)
result = engine.execute(text(comment_sql))
row = result.fetchone()
table_comment = row[0] if row else None
except Exception:
logger.debug("Could not fetch table comment for %s", table_name)
# Get sample data (3 random rows)
sample_rows = []
schema = self.report.schema_name
if table_type == TableType.TABLE:
try:
# Use ORDER BY RANDOM() for better sampling on small tables
sample_sql = (
f'SELECT * FROM "{schema}"."{table_name}" ' # noqa: S608
f"ORDER BY RANDOM() LIMIT 3"
)
result = engine.execute(text(sample_sql))
for row in result:
sample_rows.append(dict(row))
logger.debug(
"Fetched %d sample rows from %s", len(sample_rows), table_name
)
except Exception:
# Fallback to regular LIMIT if RANDOM() not supported
try:
fallback_sql = f'SELECT * FROM "{schema}"."{table_name}" LIMIT 3' # noqa: S608, E501
result = engine.execute(text(fallback_sql))
for row in result:
sample_rows.append(dict(row))
logger.debug(
"Fetched %d sample rows from %s (fallback)",
len(sample_rows),
table_name,
)
except Exception as e2:
logger.warning(
"Could not fetch sample data for %s: %s", table_name, str(e2)
)
# Get row count (try reltuples first, fallback to actual count)
row_count = None
try:
# Try reltuples first (faster for large tables)
count_sql = (
f"SELECT reltuples::BIGINT FROM pg_class " # noqa: S608
f"WHERE oid = '{schema}.{table_name}'::regclass"
)
result = engine.execute(text(count_sql))
row = result.fetchone()
row_count = row[0] if row and row[0] >= 0 else None
# If reltuples is -1 or None, get actual count for small tables
if row_count is None or row_count < 0:
actual_count_sql = f'SELECT COUNT(*) FROM "{schema}"."{table_name}"' # noqa: S608
result = engine.execute(text(actual_count_sql))
row_count = result.fetchone()[0]
logger.debug("Used actual count for %s: %d", table_name, row_count)
else:
logger.debug("Used reltuples for %s: %d", table_name, row_count)
except Exception as e:
logger.warning("Could not fetch row count for %s: %s", table_name, str(e))
# Process column information
columns_info = []
for idx, col in enumerate(columns, start=1):
col_info = {
"name": col["name"],
"type": str(col["type"]),
"position": idx,
"nullable": col.get("nullable", True),
"is_primary_key": col["name"] in primary_keys,
"is_foreign_key": col["name"] in fk_columns,
"comment": col.get("comment"),
}
columns_info.append(col_info)
return {
"name": table_name,
"type": table_type,
"comment": table_comment,
"columns": columns_info,
"sample_rows": sample_rows,
"row_count": row_count,
"foreign_keys": foreign_keys,
}
def _store_tables_and_columns(self, tables_data: list[dict[str, Any]]) -> None:
"""Store extracted table and column metadata"""
logger.info("Storing tables and columns metadata")
for table_data in tables_data:
# Create table record
table = AnalyzedTable(
report_id=self.report_id,
table_name=table_data["name"],
table_type=table_data["type"],
db_comment=table_data["comment"],
extra_json=json.dumps(
{
"row_count_estimate": table_data["row_count"],
"sample_rows": table_data["sample_rows"],
"foreign_keys": table_data["foreign_keys"],
}
),
)
db.session.add(table)
db.session.flush() # Get the table ID
# Create column records
for col_data in table_data["columns"]:
column = AnalyzedColumn(
table_id=table.id,
column_name=col_data["name"],
data_type=col_data["type"],
ordinal_position=col_data["position"],
is_primary_key=col_data["is_primary_key"],
is_foreign_key=col_data["is_foreign_key"],
db_comment=col_data["comment"],
extra_json=json.dumps(
{
"is_nullable": col_data["nullable"],
}
),
)
db.session.add(column)
db.session.commit() # pylint: disable=consider-using-transaction
def _augment_with_ai_descriptions(self) -> None:
"""Use LLM to generate AI descriptions for tables and columns"""
assert self.report is not None
logger.info("Generating AI descriptions for tables and columns")
if not self.llm_service.is_available():
logger.warning("LLM service not available, skipping AI augmentation")
return
# Process tables in parallel
tables = self.report.tables
if not tables:
logger.warning("No tables to augment with AI descriptions")
return
max_workers = min(10, len(tables))
# Capture the current Flask app context
app = current_app._get_current_object()
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_table = {
executor.submit(self._augment_table_with_ai_context, app, table): table
for table in tables
}
for future in as_completed(future_to_table):
table = future_to_table[future]
try:
future.result()
except Exception as e:
logger.error(
"Failed to generate AI description for table %s: %s",
table.table_name,
str(e),
)
def _augment_table_with_ai_context(self, app: Flask, table: AnalyzedTable) -> None:
"""Wrapper to provide Flask context to the AI description thread"""
with app.app_context():
self._augment_table_with_ai(table)
def _augment_table_with_ai(self, table: AnalyzedTable) -> None:
"""Generate AI descriptions for a single table and its columns"""
try:
# Prepare context for LLM
extra_json = json.loads(table.extra_json or "{}")
sample_rows = extra_json.get("sample_rows", [])
columns_info = []
for col in table.columns:
col_extra = json.loads(col.extra_json or "{}")
columns_info.append(
{
"name": col.column_name,
"type": col.data_type,
"nullable": col_extra.get("is_nullable", True),
"is_pk": col_extra.get("is_primary_key", False),
"is_fk": col_extra.get("is_foreign_key", False),
"comment": col.db_comment,
}
)
# Generate descriptions
result = self.llm_service.generate_table_descriptions(
table_name=table.table_name,
table_comment=table.db_comment,
columns=columns_info,
sample_data=sample_rows,
)
# Update table description
table.ai_description = result.get("table_description")
# Update column descriptions
col_descriptions = result.get("column_descriptions", {})
for col in table.columns:
if col.column_name in col_descriptions:
col.ai_description = col_descriptions[col.column_name]
db.session.commit() # pylint: disable=consider-using-transaction
except Exception as e:
logger.error(
"Error generating AI descriptions for table %s: %s",
table.table_name,
str(e),
)
db.session.rollback() # pylint: disable=consider-using-transaction
def _infer_joins_with_ai(self) -> None:
"""Use LLM to infer potential joins between tables"""
assert self.report is not None
logger.info("Inferring joins between tables using AI")
if not self.llm_service.is_available():
logger.warning("LLM service not available, skipping join inference")
return
tables = self.report.tables
if len(tables) < 2:
logger.info("Not enough tables to infer joins")
return
# Prepare schema context
schema_context = []
for table in tables:
table_info = {
"name": table.table_name,
"description": table.ai_description or table.db_comment,
"columns": [],
}
for col in table.columns:
col_extra = json.loads(col.extra_json or "{}")
table_info["columns"].append(
{
"name": col.column_name,
"type": col.data_type,
"description": col.ai_description or col.db_comment,
"is_pk": col_extra.get("is_primary_key", False),
"is_fk": col_extra.get("is_foreign_key", False),
}
)
schema_context.append(table_info)
# Get existing foreign key relationships
existing_fks = self._get_existing_foreign_keys()
# Use LLM to infer joins
try:
inferred_joins = self.llm_service.infer_joins(
schema_context=schema_context,
existing_foreign_keys=existing_fks,
)
# Store inferred joins
self._store_inferred_joins(inferred_joins)
except Exception as e:
logger.error("Error inferring joins with AI: %s", str(e))
def _get_existing_foreign_keys(self) -> list[dict[str, Any]]:
"""Get existing foreign key relationships from extracted metadata"""
assert self.report is not None
existing_fks = []
for table in self.report.tables:
extra_json = json.loads(table.extra_json or "{}")
foreign_keys = extra_json.get("foreign_keys", [])
for fk in foreign_keys:
existing_fks.append(
{
"source_table": table.table_name,
"source_columns": fk["constrained_columns"],
"target_table": fk["referred_table"],
"target_columns": fk["referred_columns"],
}
)
return existing_fks
def _store_inferred_joins(self, inferred_joins: list[dict[str, Any]]) -> None:
"""Store the inferred joins in the database"""
assert self.report is not None
logger.info("Storing %d inferred joins", len(inferred_joins))
# Create lookup for table IDs
table_lookup = {table.table_name: table.id for table in self.report.tables}
# Debug logging to see actual data being stored
for i, join_data in enumerate(inferred_joins):
logger.debug(
"Join %d data: join_type=%s, cardinality=%s",
i,
join_data.get("join_type"),
join_data.get("cardinality"),
)
for join_data in inferred_joins:
source_table_id = table_lookup.get(join_data["source_table"])
target_table_id = table_lookup.get(join_data["target_table"])
if not source_table_id or not target_table_id:
logger.warning(
"Skipping join %s -> %s: table not found",
join_data.get("source_table"),
join_data.get("target_table"),
)
continue
join = InferredJoin(
report_id=self.report_id,
source_table_id=source_table_id,
target_table_id=target_table_id,
source_columns=json.dumps(join_data["source_columns"]),
target_columns=json.dumps(join_data["target_columns"]),
join_type=join_data.get("join_type", "inner"),
cardinality=join_data.get("cardinality", "N:1"),
semantic_context=join_data.get("semantic_context"),
extra_json=json.dumps(
{
"confidence_score": join_data.get("confidence_score", 0.5),
"suggested_by": join_data.get("suggested_by", "ai_inference"),
}
),
)
db.session.add(join)
db.session.commit() # pylint: disable=consider-using-transaction
def _validate_analysis_confidence(self) -> None:
"""Use another LLM to validate the confidence of the analysis"""
assert self.report is not None
logger.info("Validating analysis confidence using LLM")
if not self.llm_service.is_available():
logger.warning("LLM service not available, skipping confidence validation")
return
try:
# Prepare data for validation
tables_data = []
for table in self.report.tables:
extra_json = json.loads(table.extra_json or "{}")
tables_data.append(
{
"name": table.table_name,
"type": table.table_type.value if table.table_type else "table",
"columns_count": len(table.columns),
"row_count": extra_json.get("row_count_estimate"),
"ai_description": table.ai_description,
"has_description": bool(
table.ai_description or table.db_comment
),
}
)
joins_data = []
for join in self.report.joins:
extra_json = json.loads(join.extra_json or "{}")
joins_data.append(
{
"source_table": join.source_table.table_name,
"source_columns": json.loads(join.source_columns),
"target_table": join.target_table.table_name,
"target_columns": json.loads(join.target_columns),
"join_type": join.join_type.value
if join.join_type
else "inner",
"cardinality": join.cardinality.value
if join.cardinality
else "N:1",
"confidence_score": extra_json.get("confidence_score", 0.5),
"semantic_context": join.semantic_context,
}
)
# Collect all AI descriptions
ai_descriptions = {
"tables": {
table.table_name: table.ai_description
for table in self.report.tables
if table.ai_description
},
"columns": {},
}
for table in self.report.tables:
for col in table.columns:
if col.ai_description:
key = f"{table.table_name}.{col.column_name}"
ai_descriptions["columns"][key] = col.ai_description
# Call validation service
validation_result = self.llm_service.validate_analysis_confidence(
schema_name=self.report.schema_name,
tables=tables_data,
joins=joins_data,
ai_descriptions=ai_descriptions,
)
# Store validation results
self.report.confidence_score = validation_result.get(
"overall_confidence", 0.5
)
self.report.confidence_breakdown = json.dumps(
validation_result.get("confidence_breakdown", {})
)
# Combine recommendations and potential issues
all_recommendations = []
all_recommendations.extend(validation_result.get("recommendations", []))
all_recommendations.extend(validation_result.get("potential_issues", []))
self.report.confidence_recommendations = json.dumps(all_recommendations)
self.report.confidence_validation_notes = validation_result.get(
"validation_notes", ""
)
db.session.commit() # pylint: disable=consider-using-transaction
logger.info(
"Confidence validation complete. Score: %.2f",
self.report.confidence_score or 0.5,
)
except Exception as e:
logger.error("Error validating analysis confidence: %s", str(e))
# Set default confidence if validation fails
self.report.confidence_score = 0.5
self.report.confidence_validation_notes = f"Validation error: {str(e)}"
db.session.commit() # pylint: disable=consider-using-transaction

View File

@@ -0,0 +1,462 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
LLM Service for Database Analyzer.
Provides AI-powered features for database schema analysis:
- Table and column description generation
- Join relationship inference
"""
from __future__ import annotations
import logging
from typing import Any
from superset.llm.base import BaseLLMClient, LLMConfig
from superset.utils import json
logger = logging.getLogger(__name__)
class LLMService(BaseLLMClient):
"""
LLM service for database analysis tasks.
Extends BaseLLMClient with domain-specific methods for:
- Generating table/column descriptions from schema and sample data
- Inferring join relationships between tables
- Validating analysis confidence
Uses the "database_analyzer" feature configuration, which defaults to
a large-context model (google/gemini-2.0-flash-001) optimized for
processing large database schemas.
"""
# Feature name for automatic configuration
feature_name = "database_analyzer"
def __init__(self, config: LLMConfig | None = None) -> None:
super().__init__(config)
def generate_table_descriptions(
self,
table_name: str,
table_comment: str | None,
columns: list[dict[str, Any]],
sample_data: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Generate AI descriptions for a table and its columns.
:param table_name: Name of the table
:param table_comment: Existing table comment
:param columns: List of column information
:param sample_data: Sample rows from the table
:return: Dict with table_description and column_descriptions
"""
if not self.is_available():
return {"table_description": None, "column_descriptions": {}}
prompt = self._build_table_description_prompt(
table_name, table_comment, columns, sample_data
)
response = self.chat_json(
prompt=prompt,
system_prompt=(
"You are a database documentation expert. Generate brief but "
"informative descriptions for database tables and columns."
),
)
if not response.success or not response.json_content:
logger.error(
"Error calling LLM for table descriptions: %s",
response.error or "No content",
)
return {"table_description": None, "column_descriptions": {}}
return {
"table_description": response.json_content.get("table_description"),
"column_descriptions": response.json_content.get(
"column_descriptions", {}
),
}
def infer_joins(
self,
schema_context: list[dict[str, Any]],
existing_foreign_keys: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Infer potential joins between tables using AI.
:param schema_context: List of tables with their columns and descriptions
:param existing_foreign_keys: Already known foreign key relationships
:return: List of inferred joins
"""
if not self.is_available():
return []
prompt = self._build_join_inference_prompt(schema_context, existing_foreign_keys)
response = self.chat_json(
prompt=prompt,
system_prompt=(
"You are a database architect expert. Analyze database schemas "
"to identify potential join relationships between tables."
),
)
if not response.success or not response.json_content:
logger.error(
"Error calling LLM for join inference: %s",
response.error or "No content",
)
return []
return self._parse_join_inference_response(response.json_content)
def _build_table_description_prompt(
self,
table_name: str,
table_comment: str | None,
columns: list[dict[str, Any]],
sample_data: list[dict[str, Any]],
) -> str:
"""Build prompt for generating table descriptions."""
prompt = f"""Analyze this database table and generate descriptions.
Table Name: {table_name}
Existing Comment: {table_comment or 'None'}
Columns:
"""
for col in columns:
prompt += f"- {col['name']} ({col['type']})"
if col.get("is_pk"):
prompt += " [PRIMARY KEY]"
if col.get("is_fk"):
prompt += " [FOREIGN KEY]"
if col.get("comment"):
prompt += f" - {col['comment']}"
prompt += "\n"
if sample_data:
prompt += f"\nSample Data (3 rows):\n{json.dumps(sample_data[:3], indent=2)}\n"
prompt += """
Based on the table name, column names, types, and sample data, provide:
1. A brief description of what this table represents (2-3 sentences)
2. Brief descriptions for each column explaining its purpose
Return as JSON:
{
"table_description": "Description of the table",
"column_descriptions": {
"column_name": "Description of this column",
...
}
}
"""
return prompt
def _build_join_inference_prompt(
self,
schema_context: list[dict[str, Any]],
existing_foreign_keys: list[dict[str, Any]],
) -> str:
"""Build prompt for inferring joins."""
prompt = """Analyze this database schema and identify potential join relationships.
Schema Information:
"""
for table in schema_context:
prompt += f"\nTable: {table['name']}\n"
if table.get("description"):
prompt += f"Description: {table['description']}\n"
prompt += "Columns:\n"
for col in table["columns"]:
prompt += f" - {col['name']} ({col['type']})"
if col.get("is_pk"):
prompt += " [PK]"
if col.get("is_fk"):
prompt += " [FK]"
if col.get("description"):
prompt += f" - {col['description']}"
prompt += "\n"
if existing_foreign_keys:
prompt += "\nExisting Foreign Keys:\n"
for fk in existing_foreign_keys:
src = f"{fk['source_table']}.{fk['source_columns']}"
tgt = f"{fk['target_table']}.{fk['target_columns']}"
prompt += f"- {src} -> {tgt}\n"
prompt += """
Identify potential join relationships based on:
1. Column name patterns (e.g., user_id, customer_id)
2. Data type compatibility
3. Semantic relationships
4. Common database patterns
Return ONLY joins not already covered by existing foreign keys.
Return as JSON array:
[
{
"source_table": "table1",
"source_columns": ["col1"],
"target_table": "table2",
"target_columns": ["col2"],
"join_type": "inner",
"cardinality": "N:1",
"semantic_context": "Explanation of the relationship",
"confidence_score": 0.85,
"suggested_by": "ai_inference"
}
]
"""
return prompt
def _parse_join_inference_response(
self, joins: dict[str, Any] | list[Any]
) -> list[dict[str, Any]]:
"""Parse and validate the join inference response."""
if not isinstance(joins, list):
logger.error("LLM response is not a list")
return []
valid_joins = []
for i, join in enumerate(joins):
logger.debug(
"Raw join %d: join_type=%s, cardinality=%s",
i,
join.get("join_type"),
join.get("cardinality"),
)
if self._validate_join(join):
logger.debug(
"Validated join %d: join_type=%s, cardinality=%s",
i,
join.get("join_type"),
join.get("cardinality"),
)
valid_joins.append(join)
else:
logger.warning("Join %d failed validation", i)
return valid_joins
def _validate_join(self, join: dict[str, Any]) -> bool:
"""Validate a join object has required fields."""
required_fields = [
"source_table",
"source_columns",
"target_table",
"target_columns",
]
for field in required_fields:
if field not in join:
logger.warning("Join missing required field: %s", field)
return False
# Ensure columns are lists
if not isinstance(join["source_columns"], list):
join["source_columns"] = [join["source_columns"]]
if not isinstance(join["target_columns"], list):
join["target_columns"] = [join["target_columns"]]
# Normalize join_type to lowercase
if "join_type" in join:
join["join_type"] = str(join["join_type"]).lower()
# Normalize cardinality to use enum values
if "cardinality" in join:
cardinality_map = {
"ONE_TO_ONE": "1:1",
"1:1": "1:1",
"ONE_TO_MANY": "1:N",
"1:N": "1:N",
"MANY_TO_ONE": "N:1",
"N:1": "N:1",
"MANY_TO_MANY": "N:M",
"N:M": "N:M",
}
raw_cardinality = str(join["cardinality"]).upper()
join["cardinality"] = cardinality_map.get(raw_cardinality, "N:1")
# Set defaults for optional fields
join.setdefault("join_type", "inner")
join.setdefault("cardinality", "N:1")
join.setdefault("confidence_score", 0.5)
join.setdefault("suggested_by", "ai_inference")
return True
def validate_analysis_confidence(
self,
schema_name: str,
tables: list[dict[str, Any]],
joins: list[dict[str, Any]],
ai_descriptions: dict[str, Any],
) -> dict[str, Any]:
"""
Validate the confidence of the analysis using another LLM.
:param schema_name: Name of the schema analyzed
:param tables: List of analyzed tables with metadata
:param joins: List of inferred joins
:param ai_descriptions: AI-generated descriptions
:return: Dict with confidence scores and recommendations
"""
if not self.is_available():
return {
"overall_confidence": 0.5,
"confidence_breakdown": {},
"recommendations": [],
"validation_notes": "LLM validation not available",
}
prompt = self._build_confidence_validation_prompt(
schema_name, tables, joins, ai_descriptions
)
response = self.chat_json(
prompt=prompt,
system_prompt=(
"You are a database analysis quality auditor. Review database "
"schema analyses and provide confidence scores."
),
)
if not response.success or not response.json_content:
logger.error(
"Error calling LLM for confidence validation: %s",
response.error or "No content",
)
return {
"overall_confidence": 0.5,
"confidence_breakdown": {},
"recommendations": [],
"validation_notes": f"Validation failed: {response.error or 'Unknown error'}",
}
return self._parse_confidence_validation_response(response.json_content)
def _build_confidence_validation_prompt(
self,
schema_name: str,
tables: list[dict[str, Any]],
joins: list[dict[str, Any]],
ai_descriptions: dict[str, Any],
) -> str:
"""Build prompt for validating analysis confidence"""
prompt = (
"You are a database analysis quality auditor. Review the following "
"database schema analysis and provide confidence scores.\n\n"
f"Schema: {schema_name}\n"
f"Number of tables: {len(tables)}\n"
f"Number of inferred joins: {len(joins)}\n\n"
"Analysis Summary:\n"
)
# Add table information
prompt += "\nTables analyzed:\n"
for table in tables[:10]: # Limit to first 10 tables for context
prompt += f"- {table.get('name', 'Unknown')}: "
prompt += f"{table.get('columns_count', 0)} columns, "
prompt += f"{table.get('row_count', 'unknown')} rows\n"
if table.get("ai_description"):
prompt += f" Description: {table['ai_description'][:100]}...\n"
# Add join information
if joins:
prompt += f"\nInferred joins ({len(joins)} total):\n"
for join in joins[:5]: # Show first 5 joins
prompt += (
f"- {join.get('source_table')}.{join.get('source_columns')} -> "
)
prompt += f"{join.get('target_table')}.{join.get('target_columns')}\n"
prompt += f" Type: {join.get('join_type', 'unknown')}, "
prompt += f"Cardinality: {join.get('cardinality', 'unknown')}, "
prompt += f"Confidence: {join.get('confidence_score', 0)}\n"
prompt += """
Please evaluate the quality and completeness of this analysis and provide:
1. overall_confidence: A score from 0.0 to 1.0 indicating overall confidence
2. confidence_breakdown: Individual confidence scores for different aspects:
- table_descriptions: How accurate/complete are the table descriptions
- column_descriptions: How accurate/complete are the column descriptions
- join_inference: How accurate are the inferred joins
- schema_coverage: How complete is the schema analysis
3. recommendations: List of specific recommendations to improve the analysis
4. potential_issues: Any red flags or concerns noticed
5. validation_notes: Additional context about the validation
Consider factors like:
- Consistency of descriptions
- Plausibility of inferred relationships
- Completeness of metadata
- Semantic accuracy
Return the response as JSON:
{
"overall_confidence": 0.75,
"confidence_breakdown": {
"table_descriptions": 0.8,
"column_descriptions": 0.7,
"join_inference": 0.65,
"schema_coverage": 0.9
},
"recommendations": [
"Review join between X and Y - seems unlikely",
"Table Z description needs more detail"
],
"potential_issues": [
"Missing relationships between related tables",
"Some descriptions are too generic"
],
"validation_notes": "Analysis appears mostly complete with minor gaps"
}
"""
return prompt
def _parse_confidence_validation_response(
self, result: dict[str, Any]
) -> dict[str, Any]:
"""Parse the LLM response for confidence validation"""
try:
# Ensure all required fields exist with defaults
return {
"overall_confidence": float(result.get("overall_confidence", 0.5)),
"confidence_breakdown": result.get("confidence_breakdown", {}),
"recommendations": result.get("recommendations", []),
"potential_issues": result.get("potential_issues", []),
"validation_notes": result.get("validation_notes", ""),
}
except (ValueError, TypeError) as e:
logger.error("Failed to parse confidence validation response: %s", str(e))
return {
"overall_confidence": 0.5,
"confidence_breakdown": {},
"recommendations": [],
"potential_issues": [],
"validation_notes": "Failed to parse validation response",
}

View File

@@ -24,6 +24,7 @@ from superset.commands.dataset.exceptions import (
DatasetDeleteFailedError,
DatasetForbiddenError,
DatasetNotFoundError,
DatasetTemplateDeleteForbiddenError,
)
from superset.connectors.sqla.models import SqlaTable
from superset.daos.dataset import DatasetDAO
@@ -49,6 +50,12 @@ class DeleteDatasetCommand(BaseCommand):
self._models = DatasetDAO.find_by_ids(self._model_ids)
if not self._models or len(self._models) != len(self._model_ids):
raise DatasetNotFoundError()
# Template datasets cannot be deleted
for model in self._models:
if security_manager._is_template_dataset(model):
raise DatasetTemplateDeleteForbiddenError()
# Check ownership
for model in self._models:
try:

View File

@@ -205,3 +205,11 @@ class DatasetForbiddenDataURI(ImportFailedError): # noqa: N818
class WarmUpCacheTableNotFoundError(CommandException):
status = 404
message = _("The provided table was not found in the provided database")
class DatasetTemplateUpdateForbiddenError(ForbiddenError):
message = _("Template datasets cannot be modified.")
class DatasetTemplateDeleteForbiddenError(ForbiddenError):
message = _("Template datasets cannot be deleted.")

View File

@@ -41,6 +41,7 @@ from superset.commands.dataset.exceptions import (
DatasetMetricsExistsValidationError,
DatasetMetricsNotFoundValidationError,
DatasetNotFoundError,
DatasetTemplateUpdateForbiddenError,
DatasetUpdateFailedError,
MultiCatalogDisabledValidationError,
)
@@ -92,6 +93,10 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
if not self._model:
raise DatasetNotFoundError()
# Template datasets cannot be modified
if security_manager._is_template_dataset(self._model):
raise DatasetTemplateUpdateForbiddenError()
# Check permission to update the dataset
try:
security_manager.raise_for_ownership(self._model)

View File

@@ -50,6 +50,14 @@ class MetadataSchema(Schema):
version = fields.String(required=True, validate=validate.Equal(IMPORT_VERSION))
type = fields.String(required=False)
timestamp = fields.DateTime()
# Template-related fields
is_template = fields.Boolean(required=False)
is_featured_template = fields.Boolean(required=False)
template_category = fields.String(required=False)
template_thumbnail_url = fields.String(required=False)
template_description = fields.String(required=False)
template_tags = fields.List(fields.String(), required=False)
template_context = fields.String(required=False)
def load_yaml(file_name: str, content: str) -> dict[str, Any]:

View File

@@ -1216,6 +1216,8 @@ class CeleryConfig: # pylint: disable=too-few-public-methods
"superset.tasks.thumbnails",
"superset.tasks.cache",
"superset.tasks.slack",
"superset.tasks.database_analyzer",
"superset.tasks.dashboard_generator",
)
result_backend = "db+sqlite:///celery_results.sqlite"
worker_prefetch_multiplier = 1

333
superset/config_llm.py Normal file
View File

@@ -0,0 +1,333 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
LLM Configuration for Apache Superset.
This module provides a flexible LLM configuration system that supports:
- Per-feature model configuration (different models for different use cases)
- Global defaults with feature-specific overrides
- Environment variable and Flask config support
=============================================================================
ARCHITECTURE: Per-Feature LLM Configuration
=============================================================================
Superset's AI features have different requirements:
Feature | Best Model Type | Why
-----------------------------|---------------------------|---------------------------
Database Analyzer | Large context, fast | Process large schemas
Dashboard Generator | Strong reasoning | Complex SQL generation
Text2SQL (future) | Fast, good at SQL | User-facing latency
Chart Suggestions (future) | Creative, fast | Many quick suggestions
Each feature can specify its preferred model while sharing a common API key.
=============================================================================
RECOMMENDED: OpenRouter (https://openrouter.ai)
=============================================================================
OpenRouter provides a unified API for multiple LLM providers through a single
endpoint. This allows switching between models without code changes.
Setup:
1. Get an API key from https://openrouter.ai/keys
2. Set environment variables:
export SUPERSET_LLM_API_KEY=sk-or-v1-...
=============================================================================
MODEL RECOMMENDATIONS (as of December 2025)
=============================================================================
For large database schemas (need large context window):
- google/gemini-2.0-flash-001 1M context, fast, cost-effective
- google/gemini-1.5-pro 2M context, excellent reasoning
- meta-llama/llama-4-scout 10M context, open source
For complex reasoning tasks (SQL generation, mappings):
- anthropic/claude-sonnet-4 200K context, excellent reasoning
- openai/gpt-4o 128K context, reliable
- anthropic/claude-3.5-sonnet 200K context, fast and capable
For fast, cost-effective tasks (descriptions, suggestions):
- anthropic/claude-3.5-haiku 200K context, very fast, cheap
- openai/gpt-4o-mini 128K context, fast and cheap
- google/gemini-2.0-flash-lite 1M context, very cheap
For advanced reasoning (complex multi-step tasks):
- openai/o1 200K context, reasoning model
- openai/o3-mini 200K context, fast reasoning
=============================================================================
ENVIRONMENT VARIABLES
=============================================================================
Global (shared across all features):
SUPERSET_LLM_API_KEY API key for the LLM provider (required)
SUPERSET_LLM_BASE_URL API endpoint (default: OpenRouter)
Per-feature model selection (optional, overrides global default):
SUPERSET_LLM_MODEL Global default model
SUPERSET_LLM_DATABASE_ANALYZER_MODEL Model for database analysis
SUPERSET_LLM_DASHBOARD_GENERATOR_MODEL Model for dashboard generation
Per-feature parameters (optional):
SUPERSET_LLM_TEMPERATURE Global temperature (default: 0.3)
SUPERSET_LLM_MAX_TOKENS Global max tokens (default: 4096)
SUPERSET_LLM_TIMEOUT Global timeout seconds (default: 120)
=============================================================================
FLASK CONFIG (superset_config.py)
=============================================================================
# Global defaults
LLM_API_KEY = "sk-or-v1-..."
LLM_BASE_URL = "https://openrouter.ai/api/v1"
LLM_MODEL = "anthropic/claude-3.5-sonnet" # Global default
# Per-feature model configuration
LLM_FEATURE_CONFIG = {
"database_analyzer": {
"model": "google/gemini-2.0-flash-001", # Large context for schemas
"temperature": 0.2,
"max_tokens": 8192,
"timeout": 180,
},
"dashboard_generator": {
"model": "anthropic/claude-sonnet-4", # Best reasoning for SQL
"temperature": 0.2,
"max_tokens": 8192,
"timeout": 180,
},
"text2sql": {
"model": "openai/gpt-4o-mini", # Fast for user-facing
"temperature": 0.1,
"max_tokens": 2048,
"timeout": 30,
},
}
Environment variables take precedence over Flask config.
"""
import os
from dataclasses import dataclass, field
from typing import Any
# =============================================================================
# Global LLM Configuration Defaults
# =============================================================================
# API key for the LLM provider (OpenRouter or direct)
LLM_API_KEY = os.environ.get("SUPERSET_LLM_API_KEY")
# API base URL - default to OpenRouter
LLM_BASE_URL = os.environ.get(
"SUPERSET_LLM_BASE_URL",
"https://openrouter.ai/api/v1",
)
# Global default model (used when feature-specific model not configured)
LLM_MODEL = os.environ.get("SUPERSET_LLM_MODEL", "anthropic/claude-3.5-sonnet")
# Global default parameters
LLM_TEMPERATURE = float(os.environ.get("SUPERSET_LLM_TEMPERATURE", "0.3"))
LLM_MAX_TOKENS = int(os.environ.get("SUPERSET_LLM_MAX_TOKENS", "4096"))
LLM_TIMEOUT = int(os.environ.get("SUPERSET_LLM_TIMEOUT", "120"))
# OpenRouter-specific settings
LLM_APP_NAME = "Apache Superset"
LLM_SITE_URL = "https://superset.apache.org"
# =============================================================================
# Per-Feature Configuration
# =============================================================================
@dataclass
class LLMFeatureConfig:
"""
Configuration for a specific LLM-powered feature.
Each feature can specify its own model and parameters while
sharing the global API key and base URL.
"""
model: str | None = None # None = use global default
temperature: float | None = None
max_tokens: int | None = None
timeout: int | None = None
extra: dict[str, Any] = field(default_factory=dict)
def get_model(self, global_default: str) -> str:
"""Get model, falling back to global default."""
return self.model or global_default
def get_temperature(self, global_default: float) -> float:
"""Get temperature, falling back to global default."""
return self.temperature if self.temperature is not None else global_default
def get_max_tokens(self, global_default: int) -> int:
"""Get max tokens, falling back to global default."""
return self.max_tokens if self.max_tokens is not None else global_default
def get_timeout(self, global_default: int) -> int:
"""Get timeout, falling back to global default."""
return self.timeout if self.timeout is not None else global_default
# Default per-feature configurations
# These can be overridden in superset_config.py via LLM_FEATURE_CONFIG
DEFAULT_FEATURE_CONFIGS: dict[str, LLMFeatureConfig] = {
# Database Analyzer: needs large context for schema analysis
"database_analyzer": LLMFeatureConfig(
model="google/gemini-2.0-flash-001", # 1M context, fast
temperature=0.2,
max_tokens=8192,
timeout=180,
),
# Dashboard Generator: needs strong reasoning for SQL generation
"dashboard_generator": LLMFeatureConfig(
model="anthropic/claude-3.5-sonnet", # 200K context, excellent reasoning
temperature=0.2,
max_tokens=8192,
timeout=180,
),
# Text2SQL (future): needs to be fast for user-facing
"text2sql": LLMFeatureConfig(
model="openai/gpt-4o-mini", # Fast and capable
temperature=0.1,
max_tokens=2048,
timeout=30,
),
# Chart Suggestions (future): creative suggestions
"chart_suggestions": LLMFeatureConfig(
model="anthropic/claude-3.5-haiku", # Fast and cheap
temperature=0.5,
max_tokens=1024,
timeout=15,
),
}
def get_feature_config(feature_name: str) -> LLMFeatureConfig:
"""
Get LLM configuration for a specific feature.
Priority order:
1. Environment variable (SUPERSET_LLM_{FEATURE}_MODEL)
2. Flask config (LLM_FEATURE_CONFIG[feature_name])
3. Default feature config (DEFAULT_FEATURE_CONFIGS)
4. Global defaults
:param feature_name: Name of the feature (e.g., "database_analyzer")
:return: LLMFeatureConfig for the feature
"""
# Start with default feature config or empty
config = DEFAULT_FEATURE_CONFIGS.get(feature_name, LLMFeatureConfig())
# Check for Flask config override
try:
from flask import current_app
flask_feature_configs = current_app.config.get("LLM_FEATURE_CONFIG", {})
if feature_name in flask_feature_configs:
flask_config = flask_feature_configs[feature_name]
config = LLMFeatureConfig(
model=flask_config.get("model", config.model),
temperature=flask_config.get("temperature", config.temperature),
max_tokens=flask_config.get("max_tokens", config.max_tokens),
timeout=flask_config.get("timeout", config.timeout),
extra=flask_config.get("extra", config.extra),
)
except RuntimeError:
pass # Outside Flask context
# Check for environment variable override (highest priority)
env_key = f"SUPERSET_LLM_{feature_name.upper()}_MODEL"
env_model = os.environ.get(env_key)
if env_model:
config = LLMFeatureConfig(
model=env_model,
temperature=config.temperature,
max_tokens=config.max_tokens,
timeout=config.timeout,
extra=config.extra,
)
return config
# =============================================================================
# Model Registry (for documentation and validation)
# =============================================================================
RECOMMENDED_MODELS = {
# Large context models
"google/gemini-2.0-flash-001": {
"context": 1_000_000,
"description": "Fast, 1M context, cost-effective for large schemas",
"best_for": ["database_analyzer"],
},
"google/gemini-1.5-pro": {
"context": 2_000_000,
"description": "2M context, excellent reasoning",
"best_for": ["database_analyzer", "dashboard_generator"],
},
"meta-llama/llama-4-scout": {
"context": 10_000_000,
"description": "10M context, open source",
"best_for": ["database_analyzer"],
},
# Reasoning models
"anthropic/claude-sonnet-4": {
"context": 200_000,
"description": "Excellent reasoning, balanced speed/quality",
"best_for": ["dashboard_generator", "text2sql"],
},
"anthropic/claude-3.5-sonnet": {
"context": 200_000,
"description": "Fast, capable, good all-rounder",
"best_for": ["dashboard_generator", "text2sql"],
},
"openai/gpt-4o": {
"context": 128_000,
"description": "Reliable, good reasoning",
"best_for": ["dashboard_generator", "text2sql"],
},
"openai/o1": {
"context": 200_000,
"description": "Advanced reasoning, slower",
"best_for": ["complex_tasks"],
},
# Fast/cheap models
"anthropic/claude-3.5-haiku": {
"context": 200_000,
"description": "Very fast, cheap, 200K context",
"best_for": ["chart_suggestions", "quick_tasks"],
},
"openai/gpt-4o-mini": {
"context": 128_000,
"description": "Fast and cheap, good for user-facing",
"best_for": ["text2sql", "chart_suggestions"],
},
"google/gemini-2.0-flash-lite": {
"context": 1_000_000,
"description": "Very cheap, 1M context",
"best_for": ["database_analyzer", "quick_tasks"],
},
}

View File

@@ -205,6 +205,7 @@ class BaseDatasource(
schema_perm = Column(String(1000))
catalog_perm = Column(String(1000), nullable=True, default=None)
is_managed_externally = Column(Boolean, nullable=False, default=False)
is_template_dataset = Column(Boolean, nullable=False, default=False)
external_url = Column(Text, nullable=True)
sql: str | None = None

View File

@@ -105,6 +105,60 @@ class DashboardDAO(BaseDAO[Dashboard]):
def get_charts_for_dashboard(id_or_slug: str) -> list[Slice]:
return DashboardDAO.get_by_id_or_slug(id_or_slug).slices
@staticmethod
def get_templates() -> list[Dashboard]:
"""
Get all dashboard templates accessible to current user.
Templates are dashboards with is_template=true in json_metadata.
Templates are "public" resources - any user with dashboard creation
permission can view and use them, regardless of ownership.
Returns templates ordered by: featured first, then by title.
Returns:
list[Dashboard]: List of template dashboards
Raises:
DashboardAccessDeniedError: If user lacks dashboard creation permission
"""
from sqlalchemy.orm import joinedload
# Templates are accessible to any user with dashboard creation permission
# This bypasses the normal ownership-based DashboardAccessFilter
if not security_manager.can_access("can_write", "Dashboard"):
raise DashboardAccessDeniedError()
query = db.session.query(Dashboard).options(
joinedload(Dashboard.tags),
joinedload(Dashboard.owners),
)
# Note: We intentionally skip DashboardAccessFilter here.
# Templates should be visible to all users with dashboard creation permission,
# not just dashboard owners.
# Filter to only templates and order
dashboards = query.all()
# Filter in Python (metadata filtering)
templates = []
for dashboard in dashboards:
metadata = json.loads(dashboard.json_metadata or "{}")
# Template metadata is stored in the nested template_info structure
template_info = metadata.get("template_info", {})
if template_info.get("is_template", False):
# Add sorting metadata as attributes for sorting
dashboard._is_featured_template = template_info.get(
"is_featured_template", False
)
templates.append(dashboard)
# Sort: featured first, then alphabetically by title
templates.sort(key=lambda d: (not d._is_featured_template, d.dashboard_title))
return templates
@staticmethod
def get_dashboard_changed_on(id_or_slug_or_dashboard: str | Dashboard) -> datetime:
"""
@@ -295,17 +349,38 @@ class DashboardDAO(BaseDAO[Dashboard]):
.all()
]
@classmethod
def _is_template_dashboard(cls, dashboard: Dashboard) -> bool:
"""Check if a dashboard is marked as a template."""
if not dashboard.json_metadata:
return False
try:
metadata = json.loads(dashboard.json_metadata)
return metadata.get("is_template", False)
except (json.JSONDecodeError, TypeError):
return False
@classmethod
def copy_dashboard(
cls, original_dash: Dashboard, data: dict[str, Any]
cls,
original_dash: Dashboard,
data: dict[str, Any],
owner: Any | None = None,
) -> Dashboard:
if is_feature_enabled("DASHBOARD_RBAC") and not security_manager.is_owner(
original_dash
# Skip RBAC check if dashboard is a template or no user context (Celery)
if (
is_feature_enabled("DASHBOARD_RBAC")
and hasattr(g, "user")
and not cls._is_template_dashboard(original_dash)
and not security_manager.is_owner(original_dash)
):
raise DashboardForbiddenError()
# Use provided owner, fall back to g.user for request context
effective_owner = owner if owner is not None else getattr(g, "user", None)
dash = Dashboard()
dash.owners = [g.user] if g.user else []
dash.owners = [effective_owner] if effective_owner else []
dash.dashboard_title = data["dashboard_title"]
dash.css = data.get("css")
@@ -315,7 +390,7 @@ class DashboardDAO(BaseDAO[Dashboard]):
# Duplicating slices as well, mapping old ids to new ones
for slc in original_dash.slices:
new_slice = slc.clone()
new_slice.owners = [g.user] if g.user else []
new_slice.owners = [effective_owner] if effective_owner else []
db.session.add(new_slice)
db.session.flush()
new_slice.dashboards.append(dash)

View File

@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -0,0 +1,406 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from typing import Any
from flask import jsonify, request, Response
from flask_appbuilder.api import expose, protect, safe
from marshmallow import ValidationError
# Custom permission map that includes our custom method names
# All methods map to "read" or "write" which Admin role has access to
GENERATOR_PERMISSION_MAP = {
"post": "write",
"get": "read",
"check_status": "read",
"create_proposal": "write",
}
from superset.dashboard_generator.exceptions import (
DatabaseReportNotFoundError,
TemplateDashboardNotFoundError,
)
from superset.commands.dashboard_generator.llm_service import (
DashboardGeneratorLLMService,
)
from superset.commands.dashboard_generator.mapping_service import MappingService
from superset.commands.dashboard_generator.template_analyzer import TemplateAnalyzer
from superset.dashboard_generator.schemas import (
DashboardGeneratorPostSchema,
DashboardGeneratorResponseSchema,
DashboardGeneratorStatusResponseSchema,
MappingProposalPostSchema,
MappingProposalResponseSchema,
)
from superset.extensions import cache_manager, db, event_logger
from superset.models.dashboard import Dashboard
from superset.models.database_analyzer import DatabaseSchemaReport
from superset.tasks.dashboard_generator import (
check_generation_status,
kickstart_generation,
)
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
# Cache key prefix for mapping proposals
PROPOSAL_CACHE_PREFIX = "dashboard_generator_proposal_"
PROPOSAL_CACHE_TIMEOUT = 3600 # 1 hour
logger = logging.getLogger(__name__)
class DashboardGeneratorRestApi(BaseSupersetApi):
"""API endpoints for dashboard generation from templates"""
route_base = "/api/v1/dashboard/generation"
resource_name = "dashboard_generation"
allow_browser_login = True
# Use existing "Dashboard" permission - Admin users can create/modify dashboards
class_permission_name = "Dashboard"
# Map custom methods to standard "read"/"write" permissions
method_permission_name = GENERATOR_PERMISSION_MAP
openapi_spec_tag = "Dashboard Generator"
openapi_spec_component_schemas = (
DashboardGeneratorPostSchema,
DashboardGeneratorResponseSchema,
DashboardGeneratorStatusResponseSchema,
MappingProposalPostSchema,
MappingProposalResponseSchema,
)
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
resp = jsonify(kwargs)
resp.status_code = status_code
return resp
def response_400(self, message: str = "Bad request") -> Response:
"""Helper method to create 400 responses."""
return self.response(400, message=message)
def response_404(self, message: str = "Not found") -> Response:
"""Helper method to create 404 responses."""
return self.response(404, message=message)
def response_409(self, message: str = "Conflict") -> Response:
"""Helper method to create 409 responses."""
return self.response(409, message=message)
def response_500(self, message: str = "Internal server error") -> Response:
"""Helper method to create 500 responses."""
return self.response(500, message=message)
@expose("/", methods=("POST",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=True,
)
def post(self) -> Response:
"""Initiate dashboard generation from template.
---
post:
summary: Start dashboard generation
description: >-
Initiates a background job to generate a dashboard from a template
using the analyzed database schema. Returns a run_id for polling.
Can also be used to confirm a reviewed proposal by providing proposal_id.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardGeneratorPostSchema'
responses:
200:
description: Generation job initiated successfully
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/DashboardGeneratorResponseSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
409:
description: Generation already in progress
500:
$ref: '#/components/responses/500'
"""
try:
# Parse request body
schema = DashboardGeneratorPostSchema()
data = schema.load(request.json)
# Handle proposal confirmation if proposal_id is provided
proposal_id = data.get("proposal_id")
if proposal_id:
# Retrieve and validate cached proposal
cache_key = f"{PROPOSAL_CACHE_PREFIX}{proposal_id}"
cached_data = cache_manager.cache.get(cache_key)
if not cached_data:
return self.response_404(
message="Proposal not found or expired. Please create a new proposal."
)
# Clear cached proposal after use
cache_manager.cache.delete(cache_key)
# TODO: Apply adjusted_mappings to the generation process
# adjusted_mappings = data.get("adjusted_mappings")
# Validate database report exists
report = db.session.query(DatabaseSchemaReport).get(
data["database_report_id"]
)
if not report:
raise DatabaseReportNotFoundError(data["database_report_id"])
# Validate template dashboard exists
dashboard = db.session.query(Dashboard).get(data["dashboard_id"])
if not dashboard:
raise TemplateDashboardNotFoundError(data["dashboard_id"])
# Start the generation
result = kickstart_generation(
database_report_id=data["database_report_id"],
template_dashboard_id=data["dashboard_id"],
)
return self.response(200, result=result)
except ValidationError as error:
return self.response_400(message=str(error.messages))
except DatabaseReportNotFoundError as e:
return self.response_404(message=str(e))
except TemplateDashboardNotFoundError as e:
return self.response_404(message=str(e))
except Exception as e:
logger.exception("Error starting dashboard generation")
return self.response_500(message=str(e))
@expose("/status/<string:run_id>", methods=("GET",))
@protect()
@safe
def check_status(self, run_id: str) -> Response:
"""
Check the status of a dashboard generation run.
---
get:
summary: Check generation status
description: >-
Poll the status of a dashboard generation job
parameters:
- in: path
name: run_id
required: true
schema:
type: string
description: The run ID returned from the generate endpoint
responses:
200:
description: Status retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardGeneratorStatusResponseSchema'
404:
description: Generation run not found
500:
description: Internal server error
"""
try:
result = check_generation_status(run_id)
if result["status"] == "not_found":
return self.response_404(
message=result.get("message", "Generation run not found")
)
return self.response(200, result=result)
except Exception as e:
logger.exception("Error checking generation status")
return self.response_500(message=str(e))
@expose("/proposals", methods=("POST",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.create_proposal",
log_to_statsd=True,
)
def create_proposal(self) -> Response:
"""Create mapping proposal and check if review is needed.
---
post:
summary: Propose column/metric mappings
description: >-
Analyzes the template and database schema to propose mappings.
If all mappings have high confidence, automatically starts generation.
If any mappings need review, returns the proposal for user approval.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MappingProposalPostSchema'
responses:
200:
description: Proposal generated
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/MappingProposalResponseSchema'
400:
$ref: '#/components/responses/400'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
try:
schema = MappingProposalPostSchema()
data = schema.load(request.json)
# Validate database report exists
report = db.session.query(DatabaseSchemaReport).get(
data["database_report_id"]
)
if not report:
raise DatabaseReportNotFoundError(data["database_report_id"])
# Validate template dashboard exists
dashboard = db.session.query(Dashboard).get(data["dashboard_id"])
if not dashboard:
raise TemplateDashboardNotFoundError(data["dashboard_id"])
# Step 1: Analyze template requirements
template_analyzer = TemplateAnalyzer()
template_requirements = template_analyzer.analyze(dashboard)
# Step 2: Generate initial proposal via rule-based matching
mapping_service = MappingService()
proposal = mapping_service.propose_mappings(
template_requirements, report
)
# Step 3: ALWAYS refine with LLM (required step, not optional)
# User review only happens if LLM also fails to find good mappings
llm_service = DashboardGeneratorLLMService()
if not llm_service.is_available():
# LLM is required for this flow - cannot proceed without it
return self.response_500(
message="LLM service is not available. Please configure LLM_API_KEY."
)
# LLM refinement - this is where the real mapping intelligence happens
proposal = llm_service.refine_mappings_with_context(
template_requirements, report, proposal
)
# Step 4: Check if review is needed (ONLY after LLM has tried)
# Review is required only when LLM couldn't find good mappings
if not proposal.requires_review:
# Auto-start generation
result = kickstart_generation(
database_report_id=data["database_report_id"],
template_dashboard_id=data["dashboard_id"],
)
return self.response(
200,
result={
"requires_review": False,
"run_id": result["run_id"],
"message": "High confidence mappings - generation started automatically",
},
)
# Cache proposal for later confirmation
cache_key = f"{PROPOSAL_CACHE_PREFIX}{proposal.proposal_id}"
cache_manager.cache.set(
cache_key,
{
"proposal": proposal.to_dict(),
"database_report_id": data["database_report_id"],
"dashboard_id": data["dashboard_id"],
},
timeout=PROPOSAL_CACHE_TIMEOUT,
)
# Return proposal for review
return self.response(
200,
result={
"requires_review": True,
"proposal_id": proposal.proposal_id,
"column_mappings": [
{
"template_column": m.template_column,
"user_column": m.user_column,
"user_table": m.user_table,
"confidence": m.confidence,
"confidence_level": m.confidence_level.value,
"match_reasons": m.match_reasons,
"alternatives": m.alternatives,
}
for m in proposal.column_mappings
],
"metric_mappings": [
{
"template_metric": m.template_metric,
"user_expression": m.user_expression,
"confidence": m.confidence,
"confidence_level": m.confidence_level.value,
"match_reasons": m.match_reasons,
"alternatives": m.alternatives,
}
for m in proposal.metric_mappings
],
"unmapped_columns": proposal.unmapped_columns,
"unmapped_metrics": proposal.unmapped_metrics,
"review_reasons": proposal.review_reasons,
"overall_confidence": proposal.overall_confidence,
},
)
except ValidationError as error:
return self.response_400(message=str(error.messages))
except DatabaseReportNotFoundError as e:
return self.response_404(message=str(e))
except TemplateDashboardNotFoundError as e:
return self.response_404(message=str(e))
except Exception as e:
logger.exception("Error generating mapping proposal")
return self.response_500(message=str(e))

View File

@@ -0,0 +1,109 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
ObjectNotFoundError,
)
class DashboardGeneratorException(CommandException):
"""Base exception for dashboard generator errors"""
pass
class DashboardGeneratorInvalidError(CommandInvalidError):
"""Invalid input for dashboard generation"""
message = _("Dashboard generator parameters are invalid")
class DashboardGeneratorNotFoundError(ObjectNotFoundError):
"""Dashboard generator run not found"""
def __init__(self, run_id: str | None = None) -> None:
super().__init__("Dashboard generator run", run_id)
class DatabaseReportNotFoundError(ObjectNotFoundError):
"""Database report not found"""
def __init__(self, report_id: int | str | None = None) -> None:
super().__init__("Database report", str(report_id) if report_id else None)
class TemplateDashboardNotFoundError(ObjectNotFoundError):
"""Template dashboard not found"""
def __init__(self, dashboard_id: int | str | None = None) -> None:
super().__init__(
"Template dashboard", str(dashboard_id) if dashboard_id else None
)
class DashboardGeneratorCreateError(CreateFailedError):
"""Failed to create dashboard generator run"""
message = _("Failed to create dashboard generator run")
class DashboardCopyError(DashboardGeneratorException):
"""Failed to copy dashboard from template"""
status = 500
message = _("Failed to copy dashboard from template")
class DatasetCreationError(DashboardGeneratorException):
"""Failed to create dataset"""
status = 500
message = _("Failed to create dataset for generated dashboard")
class ChartUpdateError(DashboardGeneratorException):
"""Failed to update chart"""
status = 500
message = _("Failed to update chart datasource")
class NativeFilterUpdateError(DashboardGeneratorException):
"""Failed to update native filters"""
status = 500
message = _("Failed to update native filters")
class LLMServiceError(DashboardGeneratorException):
"""Error calling LLM service"""
status = 500
message = _("Error calling LLM service for dashboard generation")
class DashboardGeneratorAlreadyRunningError(DashboardGeneratorException):
"""Dashboard generation already in progress"""
status = 409
message = _("Dashboard generation is already in progress for this template")

View File

@@ -0,0 +1,185 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from marshmallow import fields, Schema
class DashboardGeneratorPostSchema(Schema):
"""Schema for initiating dashboard generation"""
database_report_id = fields.Integer(
required=True,
metadata={
"description": "The ID of the database schema report to use for generation"
},
)
dashboard_id = fields.Integer(
required=True,
metadata={"description": "The ID of the template dashboard to generate from"},
)
proposal_id = fields.String(
required=False,
allow_none=True,
metadata={
"description": "Optional proposal ID when confirming a reviewed proposal"
},
)
adjusted_mappings = fields.Dict(
keys=fields.String(),
values=fields.Dict(),
required=False,
allow_none=True,
metadata={"description": "User-adjusted mappings when confirming a proposal"},
)
class DashboardGeneratorResponseSchema(Schema):
"""Schema for dashboard generator initiation response"""
run_id = fields.String(
required=True,
metadata={
"description": "The unique identifier (UUID) for this generation run"
},
)
class DashboardGeneratorProgressSchema(Schema):
"""Schema for progress tracking"""
charts_total = fields.Integer(allow_none=True)
charts_completed = fields.Integer(allow_none=True)
filters_total = fields.Integer(allow_none=True)
filters_completed = fields.Integer(allow_none=True)
class DashboardGeneratorStatusResponseSchema(Schema):
"""Schema for status polling response"""
run_id = fields.String(required=True)
status = fields.String(required=True)
current_phase = fields.String(allow_none=True)
progress = fields.Nested(DashboardGeneratorProgressSchema, allow_none=True)
started_at = fields.DateTime(allow_none=True)
completed_at = fields.DateTime(allow_none=True)
failed_at = fields.DateTime(allow_none=True)
error_message = fields.String(allow_none=True)
dashboard_id = fields.Integer(
allow_none=True, metadata={"description": "Generated dashboard ID"}
)
dashboard_url = fields.String(
allow_none=True, metadata={"description": "URL to the generated dashboard"}
)
dataset_id = fields.Integer(
allow_none=True, metadata={"description": "Generated dataset ID"}
)
failed_items = fields.Dict(
keys=fields.String(),
values=fields.List(fields.Dict()),
allow_none=True,
metadata={"description": "Failed chart/filter items with error details"},
)
class MappingProposalPostSchema(Schema):
"""Schema for requesting mapping proposal"""
database_report_id = fields.Integer(
required=True,
metadata={"description": "The ID of the database schema report"},
)
dashboard_id = fields.Integer(
required=True,
metadata={"description": "The ID of the template dashboard"},
)
class ColumnMappingSchema(Schema):
"""Schema for a column mapping"""
template_column = fields.String(required=True)
user_column = fields.String(allow_none=True)
user_table = fields.String(allow_none=True)
confidence = fields.Float(required=True)
confidence_level = fields.String(required=True)
match_reasons = fields.List(fields.String())
alternatives = fields.List(fields.Dict())
class MetricMappingSchema(Schema):
"""Schema for a metric mapping"""
template_metric = fields.String(required=True)
user_expression = fields.String(allow_none=True)
confidence = fields.Float(required=True)
confidence_level = fields.String(required=True)
match_reasons = fields.List(fields.String())
alternatives = fields.List(fields.String())
class MappingProposalResponseSchema(Schema):
"""Schema for mapping proposal response"""
requires_review = fields.Boolean(
required=True,
metadata={"description": "Whether user review is needed before generation"},
)
proposal_id = fields.String(
allow_none=True,
metadata={"description": "Proposal ID if review is needed"},
)
run_id = fields.String(
allow_none=True,
metadata={"description": "Run ID if generation started automatically"},
)
message = fields.String(allow_none=True)
column_mappings = fields.List(
fields.Nested(ColumnMappingSchema),
allow_none=True,
)
metric_mappings = fields.List(
fields.Nested(MetricMappingSchema),
allow_none=True,
)
unmapped_columns = fields.List(fields.String(), allow_none=True)
unmapped_metrics = fields.List(fields.String(), allow_none=True)
review_reasons = fields.List(fields.String(), allow_none=True)
overall_confidence = fields.Float(allow_none=True)
class MappingConfirmPostSchema(Schema):
"""Schema for confirming mappings and starting generation"""
proposal_id = fields.String(
required=True,
metadata={"description": "The proposal ID to confirm"},
)
database_report_id = fields.Integer(
required=True,
metadata={"description": "The ID of the database schema report"},
)
dashboard_id = fields.Integer(
required=True,
metadata={"description": "The ID of the template dashboard"},
)
adjusted_mappings = fields.Dict(
keys=fields.String(),
values=fields.Dict(),
allow_none=True,
metadata={"description": "User-adjusted mappings"},
)

View File

@@ -97,6 +97,7 @@ from superset.dashboards.schemas import (
DashboardPostSchema,
DashboardPutSchema,
DashboardScreenshotPostSchema,
DashboardTemplateSchema,
EmbeddedDashboardConfigSchema,
EmbeddedDashboardResponseSchema,
get_delete_ids_schema,
@@ -238,6 +239,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
"screenshot",
"put_filters",
"put_colors",
"templates",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -644,6 +646,84 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
except DashboardNotFoundError:
return self.response_404()
@expose("/templates", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.templates",
log_to_statsd=False,
)
@permission_name("read")
def templates(self) -> Response:
"""Get dashboard templates.
---
get:
summary: Get dashboard templates
description: >-
Returns a list of dashboard templates available for selection.
Templates are dashboards marked with is_template=true in their metadata.
responses:
200:
description: Dashboard templates list
content:
application/json:
schema:
type: object
properties:
result:
type: array
items:
$ref: '#/components/schemas/DashboardTemplateSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
try:
templates = DashboardDAO.get_templates()
return self.response(
200,
result=[
DashboardTemplateSchema().dump(
self._enrich_template_with_metadata(template)
)
for template in templates
],
)
except DashboardAccessDeniedError:
return self.response_403()
except Exception as ex: # pylint: disable=broad-except
logger.exception("Error fetching dashboard templates")
return self.response_500(message=str(ex))
def _enrich_template_with_metadata(self, dashboard: Dashboard) -> dict[str, Any]:
"""Extract template metadata from json_metadata into top-level fields.
Template metadata is stored in the nested "template_info" structure
within the dashboard's json_metadata.
"""
metadata = json.loads(dashboard.json_metadata or "{}")
template_info = metadata.get("template_info", {})
return {
"id": dashboard.id,
"uuid": str(dashboard.uuid),
"dashboard_title": dashboard.dashboard_title,
"slug": dashboard.slug,
"is_template": template_info.get("is_template", False),
"is_featured_template": template_info.get("is_featured_template", False),
"template_category": template_info.get("template_category"),
"template_description": template_info.get("template_description"),
"template_thumbnail_url": template_info.get("template_thumbnail_url"),
"template_tags": template_info.get("template_tags", []),
"template_context": template_info.get("template_context"),
}
@expose("/", methods=("POST",))
@protect()
@safe

View File

@@ -168,6 +168,8 @@ class DashboardJSONMetadataSchema(Schema):
remote_id = fields.Integer()
filter_bar_orientation = fields.Str(allow_none=True)
native_filter_migration = fields.Dict()
# Template metadata is stored in the nested template_info structure
template_info = fields.Dict(allow_none=True)
@pre_load
def remove_show_native_filters( # pylint: disable=unused-argument
@@ -187,6 +189,23 @@ class DashboardJSONMetadataSchema(Schema):
return data
class DashboardTemplateSchema(Schema):
"""Lightweight schema for dashboard template listing"""
id = fields.Integer()
uuid = fields.String()
dashboard_title = fields.String()
slug = fields.String()
# Metadata fields (extracted from json_metadata)
is_template = fields.Boolean()
is_featured_template = fields.Boolean()
template_category = fields.String()
template_description = fields.String()
template_thumbnail_url = fields.String()
template_tags = fields.List(fields.String())
template_context = fields.String()
class UserSchema(Schema):
id = fields.Int()
username = fields.String()

View File

@@ -0,0 +1,998 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from typing import Any
from flask import jsonify, request, Response
from flask_appbuilder.api import expose, protect, safe
from marshmallow import fields, Schema, ValidationError
# Custom permission map that includes our custom method names
# All methods map to "read" or "write" which Admin role has access to
ANALYZER_PERMISSION_MAP = {
"post": "write",
"get": "read",
"check_status": "read",
"get_report": "read",
"generate_dashboard": "write",
}
from superset.extensions import db, event_logger
from superset.models.database_analyzer import (
AnalyzedColumn,
AnalyzedTable,
DatabaseSchemaReport,
)
from superset.tasks.dashboard_generator import kickstart_generation
from superset.tasks.database_analyzer import (
check_analysis_status,
kickstart_analysis,
)
from superset.utils import json
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
logger = logging.getLogger(__name__)
class DatasourceAnalyzerPostSchema(Schema):
"""Schema for datasource analyzer request"""
database_id = fields.Integer(
required=True, metadata={"description": "The ID of the database connection"}
)
schema_name = fields.String(
required=True,
validate=lambda x: len(x) > 0,
metadata={"description": "The name of the schema to analyze"},
)
catalog_name = fields.String(
required=False,
allow_none=True,
metadata={"description": "The name of the catalog (optional)"},
)
force_reanalyze = fields.Boolean(
required=False,
load_default=False,
metadata={
"description": "Force re-analysis even if a completed report exists"
},
)
class DatasourceAnalyzerResponseSchema(Schema):
"""Schema for datasource analyzer response"""
run_id = fields.String(
required=True,
metadata={"description": "The unique identifier for this analysis run"},
)
class CheckStatusResponseSchema(Schema):
"""Schema for check status response"""
run_id = fields.String(required=True)
database_report_id = fields.Integer(allow_none=True)
status = fields.String(required=True)
database_id = fields.Integer(allow_none=True)
schema_name = fields.String(allow_none=True)
started_at = fields.DateTime(allow_none=True)
completed_at = fields.DateTime(allow_none=True)
failed_at = fields.DateTime(allow_none=True)
error_message = fields.String(allow_none=True)
tables_count = fields.Integer(allow_none=True)
joins_count = fields.Integer(allow_none=True)
confidence_score = fields.Float(allow_none=True)
confidence_validation_notes = fields.String(allow_none=True)
class TableDescriptionPutSchema(Schema):
"""Schema for updating table description"""
description = fields.String(
required=True,
allow_none=True,
metadata={"description": "The AI-generated description for the table"},
)
class ColumnDescriptionPutSchema(Schema):
"""Schema for updating column description"""
description = fields.String(
required=True,
allow_none=True,
metadata={"description": "The AI-generated description for the column"},
)
class GenerateDashboardPostSchema(Schema):
"""Schema for triggering dashboard generation"""
report_id = fields.Integer(
required=True,
metadata={"description": "The database schema report ID"},
)
dashboard_id = fields.Integer(
required=True,
metadata={"description": "The dashboard template ID to use for generation"},
)
class GenerateDashboardResponseSchema(Schema):
"""Schema for dashboard generation response"""
run_id = fields.String(
required=True,
metadata={"description": "The unique identifier for this generation run"},
)
class DatasourceAnalyzerRestApi(BaseSupersetApi):
"""API endpoints for database schema analyzer"""
route_base = "/api/v1/datasource/analysis"
resource_name = "datasource_analysis"
allow_browser_login = True
# Use existing "Database" permission - Admin users can access database features
class_permission_name = "Database"
# Map custom methods to standard "read"/"write" permissions
method_permission_name = ANALYZER_PERMISSION_MAP
openapi_spec_tag = "Datasource Analyzer"
openapi_spec_component_schemas = (
DatasourceAnalyzerPostSchema,
DatasourceAnalyzerResponseSchema,
)
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
resp = jsonify(kwargs)
resp.status_code = status_code
return resp
def response_400(self, message: str = "Bad request") -> Response:
"""Helper method to create 400 responses."""
return self.response(400, message=message)
def response_404(self, message: str = "Not found") -> Response:
"""Helper method to create 404 responses."""
return self.response(404, message=message)
def response_500(self, message: str = "Internal server error") -> Response:
"""Helper method to create 500 responses."""
return self.response(500, message=message)
@expose("/", methods=("POST",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=True,
)
def post(self) -> Response:
"""Initiate a datasource analysis job.
---
post:
summary: Initiate datasource analysis
description: >-
Initiates a background job to analyze a database schema.
Returns a run_id that can be used to track the job status.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DatasourceAnalyzerPostSchema'
responses:
200:
description: Analysis job initiated successfully
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/DatasourceAnalyzerResponseSchema'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
try:
# Parse request body
schema = DatasourceAnalyzerPostSchema()
data = schema.load(request.json)
# Start the analysis (catalog_name ignored for compatibility)
result = kickstart_analysis(
database_id=data["database_id"],
schema_name=data["schema_name"],
)
return self.response(200, result=result)
except ValidationError as error:
return self.response_400(message=error.messages)
except Exception as e:
logger.exception("Error starting database analysis")
return self.response_500(message=str(e))
@expose("/status/<string:run_id>", methods=("GET",))
@protect()
@safe
def check_status(self, run_id: str) -> Response:
"""
Check the status of a running analysis.
---
get:
description: >-
Poll the status of a database schema analysis job
parameters:
- in: path
name: run_id
required: true
schema:
type: string
description: The run ID returned from analyze endpoint
responses:
200:
description: Status retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/CheckStatusResponseSchema'
404:
description: Analysis not found
500:
description: Internal server error
"""
try:
result = check_analysis_status(run_id)
if result["status"] == "not_found":
return self.response_404(
message=result.get("message", "Analysis not found")
)
# Wrap in 'result' to match usePolling expectations
return self.response(200, result=result)
except Exception as e:
logger.exception("Error checking analysis status")
return self.response_500(message=str(e))
@expose("/report/<int:report_id>", methods=("GET",))
@protect()
@safe
def get_report(self, report_id: int) -> Response:
"""
Get the full analysis report.
---
get:
description: >-
Retrieve the complete analysis report with tables, columns, and joins
parameters:
- in: path
name: report_id
required: true
schema:
type: integer
description: The database_report_id
responses:
200:
description: Report retrieved
404:
description: Report not found
500:
description: Internal server error
"""
try:
report = db.session.query(DatabaseSchemaReport).get(report_id)
if not report:
return self.response_404(message="Report not found")
# Build the response
result = {
"id": report.id,
"database_id": report.database_id,
"schema_name": report.schema_name,
"status": report.status,
"created_at": report.created_on.isoformat()
if report.created_on
else None,
"confidence_score": report.confidence_score,
"confidence_breakdown": json.loads(report.confidence_breakdown or "{}"),
"confidence_recommendations": json.loads(
report.confidence_recommendations or "[]"
),
"confidence_validation_notes": report.confidence_validation_notes,
"tables": [],
"joins": [],
}
# Add tables and columns
for table in report.tables:
table_data = {
"id": table.id,
"name": table.table_name,
"type": table.table_type,
"description": table.ai_description or table.db_comment,
"columns": [],
}
for column in table.columns:
table_data["columns"].append(
{
"id": column.id,
"name": column.column_name,
"type": column.data_type,
"position": column.ordinal_position,
"description": column.ai_description or column.db_comment,
"is_primary_key": column.is_primary_key,
"is_foreign_key": column.is_foreign_key,
}
)
result["tables"].append(table_data)
# Add joins
for join in report.joins:
source_columns = (
json.loads(join.source_columns)
if isinstance(join.source_columns, str)
else join.source_columns
)
target_columns = (
json.loads(join.target_columns)
if isinstance(join.target_columns, str)
else join.target_columns
)
result["joins"].append(
{
"id": join.id,
"source_table": join.source_table.table_name,
"source_table_id": join.source_table_id,
"source_columns": source_columns,
"target_table": join.target_table.table_name,
"target_table_id": join.target_table_id,
"target_columns": target_columns,
"join_type": join.join_type,
"cardinality": join.cardinality,
"semantic_context": join.semantic_context,
}
)
return self.response(200, **result)
except Exception as e:
logger.exception("Error retrieving report")
return self.response_500(message=str(e))
@expose("/", methods=("GET",))
@protect()
@safe
def get(self) -> Response:
"""
Check if a completed report exists for a database/schema combination.
---
get:
description: >-
Check if a completed database schema analysis report already exists
for the given database and schema. This allows the frontend to skip
the analysis step if a report is already available.
parameters:
- in: query
name: database_id
required: true
schema:
type: integer
description: The database ID
- in: query
name: schema_name
required: true
schema:
type: string
description: The schema name
responses:
200:
description: Check completed
content:
application/json:
schema:
type: object
properties:
exists:
type: boolean
description: Whether a completed report exists
report_id:
type: integer
description: The report ID if exists
created_at:
type: string
description: When the report was created
tables_count:
type: integer
description: Number of tables in the report
400:
description: Missing required parameters
500:
description: Internal server error
"""
try:
database_id = request.args.get("database_id", type=int)
schema_name = request.args.get("schema_name", type=str)
if not database_id or not schema_name:
return self.response_400(
message="Both database_id and schema_name are required"
)
# Check for existing completed report
from superset.models.database_analyzer import AnalysisStatus
report = (
db.session.query(DatabaseSchemaReport)
.filter(
DatabaseSchemaReport.database_id == database_id,
DatabaseSchemaReport.schema_name == schema_name,
DatabaseSchemaReport.status == AnalysisStatus.COMPLETED,
)
.first()
)
if report:
return self.response(
200,
exists=True,
report_id=report.id,
created_at=report.created_on.isoformat()
if report.created_on
else None,
tables_count=len(report.tables) if report.tables else 0,
)
return self.response(200, exists=False, report_id=None)
except Exception as e:
logger.exception("Error checking for existing report")
return self.response_500(message=str(e))
@expose("/table/<int:table_id>", methods=("PUT",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_table",
log_to_statsd=True,
)
def update_table(self, table_id: int) -> Response:
"""
Update table description.
---
put:
summary: Update table AI description
description: >-
Updates the AI-generated description for an analyzed table
parameters:
- in: path
name: table_id
required: true
schema:
type: integer
description: The table ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TableDescriptionPutSchema'
responses:
200:
description: Table description updated successfully
400:
$ref: '#/components/responses/400'
404:
description: Table not found
500:
description: Internal server error
"""
try:
schema = TableDescriptionPutSchema()
data = schema.load(request.json)
table = db.session.query(AnalyzedTable).get(table_id)
if not table:
return self.response_404(message="Table not found")
table.ai_description = data["description"]
db.session.commit() # pylint: disable=consider-using-transaction
return self.response(
200,
id=table.id,
name=table.table_name,
description=table.ai_description,
)
except ValidationError as error:
return self.response_400(message=str(error.messages))
except Exception as e:
db.session.rollback() # pylint: disable=consider-using-transaction
logger.exception("Error updating table description")
return self.response_500(message=str(e))
@expose("/column/<int:column_id>", methods=("PUT",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_column",
log_to_statsd=True,
)
def update_column(self, column_id: int) -> Response:
"""
Update column description.
---
put:
summary: Update column AI description
description: >-
Updates the AI-generated description for an analyzed column
parameters:
- in: path
name: column_id
required: true
schema:
type: integer
description: The column ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ColumnDescriptionPutSchema'
responses:
200:
description: Column description updated successfully
400:
$ref: '#/components/responses/400'
404:
description: Column not found
500:
description: Internal server error
"""
try:
schema = ColumnDescriptionPutSchema()
data = schema.load(request.json)
column = db.session.query(AnalyzedColumn).get(column_id)
if not column:
return self.response_404(message="Column not found")
column.ai_description = data["description"]
db.session.commit() # pylint: disable=consider-using-transaction
return self.response(
200,
id=column.id,
name=column.column_name,
description=column.ai_description,
)
except ValidationError as error:
return self.response_400(message=str(error.messages))
except Exception as e:
db.session.rollback() # pylint: disable=consider-using-transaction
logger.exception("Error updating column description")
return self.response_500(message=str(e))
@expose("/report/<int:report_id>/join", methods=("POST",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.create_join",
log_to_statsd=True,
)
def create_join(self, report_id: int) -> Response:
"""Create a new join relationship.
---
post:
summary: Create join relationship
description: Create a new join relationship between tables
parameters:
- in: path
name: report_id
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [source_table_id, target_table_id, source_columns, target_columns, join_type, cardinality]
properties:
source_table_id:
type: integer
target_table_id:
type: integer
source_columns:
type: array
items:
type: string
target_columns:
type: array
items:
type: string
join_type:
type: string
enum: [inner, left, right, full, cross]
cardinality:
type: string
enum: ["1:1", "1:N", "N:1", "N:M"]
semantic_context:
type: string
responses:
201:
description: Join created successfully
400:
description: Bad request
404:
description: Report or table not found
500:
description: Internal server error
"""
try:
import json
from superset.models.database_analyzer import (
AnalyzedTable,
InferredJoin,
JoinType,
Cardinality,
)
data = request.json or {}
# Verify report exists
report = db.session.query(DatabaseSchemaReport).get(report_id)
if not report:
return self.response_404(message="Report not found")
# Verify tables belong to this report
source_table = (
db.session.query(AnalyzedTable)
.filter_by(id=data["source_table_id"], report_id=report_id)
.first()
)
target_table = (
db.session.query(AnalyzedTable)
.filter_by(id=data["target_table_id"], report_id=report_id)
.first()
)
if not source_table or not target_table:
return self.response_404(message="Table not found")
# Create join
join = InferredJoin(
report_id=report_id,
source_table_id=data["source_table_id"],
target_table_id=data["target_table_id"],
source_columns=json.dumps(data["source_columns"]),
target_columns=json.dumps(data["target_columns"]),
join_type=JoinType(data["join_type"]),
cardinality=Cardinality(data["cardinality"]),
semantic_context=data.get("semantic_context"),
)
db.session.add(join)
db.session.commit()
return self.response(
201,
id=join.id,
source_table=source_table.table_name,
source_columns=data["source_columns"],
target_table=target_table.table_name,
target_columns=data["target_columns"],
join_type=join.join_type.value,
cardinality=join.cardinality.value,
semantic_context=join.semantic_context,
)
except Exception as e:
logger.exception("Error creating join")
db.session.rollback()
return self.response_500(message=str(e))
@expose("/report/<int:report_id>/join/<int:join_id>", methods=("PUT",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_join",
log_to_statsd=True,
)
def update_join(self, report_id: int, join_id: int) -> Response:
"""Update a join relationship.
---
put:
summary: Update join relationship
description: Update an existing join relationship
parameters:
- in: path
name: report_id
required: true
schema:
type: integer
- in: path
name: join_id
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
source_table_id:
type: integer
target_table_id:
type: integer
source_columns:
type: array
items:
type: string
target_columns:
type: array
items:
type: string
join_type:
type: string
enum: [inner, left, right, full, cross]
cardinality:
type: string
enum: ["1:1", "1:N", "N:1", "N:M"]
semantic_context:
type: string
responses:
200:
description: Join updated successfully
404:
description: Join not found
500:
description: Internal server error
"""
try:
import json
from superset.models.database_analyzer import (
AnalyzedTable,
InferredJoin,
JoinType,
Cardinality,
)
data = request.json or {}
join = (
db.session.query(InferredJoin)
.filter_by(id=join_id, report_id=report_id)
.first()
)
if not join:
return self.response_404(message="Join not found")
# Update fields if provided
if "source_table_id" in data:
source_table = (
db.session.query(AnalyzedTable)
.filter_by(id=data["source_table_id"], report_id=report_id)
.first()
)
if not source_table:
return self.response_404(message="Source table not found")
join.source_table_id = data["source_table_id"]
if "target_table_id" in data:
target_table = (
db.session.query(AnalyzedTable)
.filter_by(id=data["target_table_id"], report_id=report_id)
.first()
)
if not target_table:
return self.response_404(message="Target table not found")
join.target_table_id = data["target_table_id"]
if "source_columns" in data:
join.source_columns = json.dumps(data["source_columns"])
if "target_columns" in data:
join.target_columns = json.dumps(data["target_columns"])
if "join_type" in data:
join.join_type = JoinType(data["join_type"])
if "cardinality" in data:
join.cardinality = Cardinality(data["cardinality"])
if "semantic_context" in data:
join.semantic_context = data["semantic_context"]
db.session.commit()
return self.response(
200,
id=join.id,
source_table=join.source_table.table_name,
source_columns=json.loads(join.source_columns),
target_table=join.target_table.table_name,
target_columns=json.loads(join.target_columns),
join_type=join.join_type.value,
cardinality=join.cardinality.value,
semantic_context=join.semantic_context,
)
except Exception as e:
logger.exception("Error updating join")
db.session.rollback()
return self.response_500(message=str(e))
@expose("/report/<int:report_id>/join/<int:join_id>", methods=("DELETE",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_join",
log_to_statsd=True,
)
def delete_join(self, report_id: int, join_id: int) -> Response:
"""Delete a join relationship.
---
delete:
summary: Delete join relationship
description: Delete an existing join relationship
parameters:
- in: path
name: report_id
required: true
schema:
type: integer
- in: path
name: join_id
required: true
schema:
type: integer
responses:
204:
description: Join deleted successfully
404:
description: Join not found
500:
description: Internal server error
"""
try:
from superset.models.database_analyzer import InferredJoin
join = (
db.session.query(InferredJoin)
.filter_by(id=join_id, report_id=report_id)
.first()
)
if not join:
return self.response_404(message="Join not found")
db.session.delete(join)
db.session.commit()
return self.response(204)
except Exception as e:
logger.exception("Error deleting join")
db.session.rollback()
return self.response_500(message=str(e))
@expose("/generate", methods=("POST",))
@protect()
@safe
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.generate",
log_to_statsd=True,
)
def generate_dashboard(self) -> Response:
"""
Trigger dashboard generation from schema report.
---
post:
summary: Generate dashboard from analyzed schema
description: >-
Triggers the dashboard generation Celery job using the analyzed
schema report and a dashboard template. Returns a run_id for
tracking the generation progress.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GenerateDashboardPostSchema'
responses:
200:
description: Dashboard generation initiated successfully
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/GenerateDashboardResponseSchema'
400:
$ref: '#/components/responses/400'
404:
description: Report or dashboard not found
500:
description: Internal server error
"""
try:
schema = GenerateDashboardPostSchema()
data = schema.load(request.json)
report_id = data["report_id"]
dashboard_id = data["dashboard_id"]
# Verify report exists
report = db.session.query(DatabaseSchemaReport).get(report_id)
if not report:
return self.response_404(message="Report not found")
result = kickstart_generation(
database_report_id=report_id,
template_dashboard_id=dashboard_id,
)
logger.info(
"Dashboard generation requested for report_id=%s, dashboard_id=%s -> run_id=%s",
report_id,
dashboard_id,
result.get("run_id"),
)
return self.response(200, result=result)
except ValidationError as error:
return self.response_400(message=str(error.messages))
except Exception as e:
logger.exception("Error initiating dashboard generation")
return self.response_500(message=str(e))

View File

@@ -144,6 +144,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"sql",
"table_name",
"uuid",
"is_template_dataset",
]
list_select_columns = list_columns + ["changed_on", "changed_by_fk"]
order_columns = [
@@ -227,6 +228,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"database.allow_multi_catalog",
"columns.advanced_data_type",
"is_managed_externally",
"is_template_dataset",
"uid",
"uuid",
"datasource_name",

View File

@@ -257,6 +257,7 @@ class ImportV1ColumnSchema(Schema):
expression = fields.String(allow_none=True)
description = fields.String(allow_none=True)
python_date_format = fields.String(allow_none=True)
datetime_format = fields.String(allow_none=True)
class ImportMetricCurrencySchema(Schema):

View File

@@ -158,9 +158,11 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.charts.api import ChartRestApi
from superset.charts.data.api import ChartDataRestApi
from superset.css_templates.api import CssTemplateRestApi
from superset.dashboard_generator.api import DashboardGeneratorRestApi
from superset.dashboards.api import DashboardRestApi
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
from superset.dashboards.permalink.api import DashboardPermalinkRestApi
from superset.databases.analyzer_api import DatasourceAnalyzerRestApi
from superset.databases.api import DatabaseRestApi
from superset.datasets.api import DatasetRestApi
from superset.datasets.columns.api import DatasetColumnsRestApi
@@ -200,6 +202,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
)
from superset.views.database.views import DatabaseView
from superset.views.datasource.views import DatasetEditor, Datasource
from superset.views.datasource_connector import DatasourceConnectorView
from superset.views.dynamic_plugins import DynamicPluginsView
from superset.views.error_handling import set_app_error_handlers
from superset.views.explore import ExplorePermalinkView, ExploreView
@@ -250,11 +253,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(DashboardFilterStateRestApi)
appbuilder.add_api(DashboardPermalinkRestApi)
appbuilder.add_api(DashboardRestApi)
appbuilder.add_api(DashboardGeneratorRestApi)
appbuilder.add_api(DatabaseRestApi)
appbuilder.add_api(DatasetRestApi)
appbuilder.add_api(DatasetColumnsRestApi)
appbuilder.add_api(DatasetMetricRestApi)
appbuilder.add_api(DatasourceRestApi)
appbuilder.add_api(DatasourceAnalyzerRestApi)
appbuilder.add_api(EmbeddedDashboardRestApi)
appbuilder.add_api(ExploreRestApi)
appbuilder.add_api(ExploreFormDataRestApi)
@@ -429,6 +434,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view_no_menu(ReportView)
appbuilder.add_view_no_menu(RoleRestAPI)
appbuilder.add_view_no_menu(UserInfoView)
appbuilder.add_view_no_menu(DatasourceConnectorView)
#
# Add links

62
superset/llm/__init__.py Normal file
View File

@@ -0,0 +1,62 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
LLM integration module for Superset.
This module provides a unified interface for LLM providers through OpenRouter
or direct API access, with support for per-feature model configuration.
Global Configuration (environment variables):
SUPERSET_LLM_API_KEY: API key for OpenRouter or direct provider (required)
SUPERSET_LLM_MODEL: Global default model (default: anthropic/claude-3.5-sonnet)
SUPERSET_LLM_BASE_URL: API endpoint (default: https://openrouter.ai/api/v1)
SUPERSET_LLM_TEMPERATURE: Response temperature (default: 0.3)
SUPERSET_LLM_MAX_TOKENS: Max response tokens (default: 4096)
SUPERSET_LLM_TIMEOUT: Request timeout in seconds (default: 120)
Per-Feature Configuration:
Different AI features have different requirements. Each feature can use
a different model optimized for its use case:
Feature | Default Model | Why
---------------------------|-------------------------|---------------------------
database_analyzer | gemini-2.0-flash-001 | 1M context for schemas
dashboard_generator | claude-3.5-sonnet | Strong reasoning for SQL
text2sql (future) | gpt-4o-mini | Fast user-facing queries
chart_suggestions (future) | claude-3.5-haiku | Creative, fast suggestions
Override per-feature models via environment variables:
SUPERSET_LLM_DATABASE_ANALYZER_MODEL=google/gemini-2.0-flash-001
SUPERSET_LLM_DASHBOARD_GENERATOR_MODEL=anthropic/claude-sonnet-4
Or via Flask config (superset_config.py):
LLM_FEATURE_CONFIG = {
"database_analyzer": {"model": "google/gemini-2.0-flash-001"},
"dashboard_generator": {"model": "anthropic/claude-sonnet-4"},
}
OpenRouter Usage:
OpenRouter provides a unified API for multiple LLM providers.
Set SUPERSET_LLM_BASE_URL=https://openrouter.ai/api/v1
Use model names like "openai/gpt-4o" or "anthropic/claude-3.5-sonnet"
Get your API key from https://openrouter.ai/keys
See superset/config_llm.py for detailed configuration options and model recommendations.
"""
from superset.llm.base import BaseLLMClient, LLMConfig, LLMResponse
__all__ = ["BaseLLMClient", "LLMConfig", "LLMResponse"]

572
superset/llm/base.py Normal file
View File

@@ -0,0 +1,572 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Base LLM client for Superset.
Provides a unified interface for LLM providers, with OpenRouter as the
recommended routing layer for provider flexibility.
OpenRouter (https://openrouter.ai) allows routing to multiple LLM providers
(OpenAI, Anthropic, Google, etc.) through a single API endpoint.
Per-Feature Configuration
=========================
Different Superset features have different LLM requirements:
- database_analyzer: Large context window for processing schemas
- dashboard_generator: Strong reasoning for SQL generation
- text2sql: Fast response for user-facing queries
- chart_suggestions: Creative, fast suggestions
Use feature_name parameter to get feature-specific configuration:
config = LLMConfig.from_env(feature_name="dashboard_generator")
client = BaseLLMClient(feature_name="dashboard_generator")
See superset/config_llm.py for detailed configuration options.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import requests
from flask import current_app
from langchain_core.utils.json import parse_json_markdown
from superset.utils import json
logger = logging.getLogger(__name__)
# Default to OpenRouter as the routing layer
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "anthropic/claude-3.5-sonnet"
DEFAULT_TEMPERATURE = 0.3
DEFAULT_MAX_TOKENS = 4096
DEFAULT_TIMEOUT = 120
@dataclass
class LLMConfig:
"""
Configuration for LLM service.
Attributes:
api_key: API key for the LLM provider (OpenRouter or direct)
model: Model identifier. For OpenRouter, use provider/model format
(e.g., "openai/gpt-4o", "anthropic/claude-3.5-sonnet")
base_url: API endpoint URL
temperature: Response randomness (0.0 = deterministic, 1.0 = creative)
max_tokens: Maximum tokens in response
timeout: Request timeout in seconds
app_name: Application name for OpenRouter tracking
site_url: Site URL for OpenRouter tracking
"""
api_key: str | None = None
model: str = DEFAULT_MODEL
base_url: str = DEFAULT_BASE_URL
temperature: float = DEFAULT_TEMPERATURE
max_tokens: int = DEFAULT_MAX_TOKENS
timeout: int = DEFAULT_TIMEOUT
app_name: str = "Apache Superset"
site_url: str = "https://superset.apache.org"
@classmethod
def from_env(cls, feature_name: str | None = None) -> LLMConfig:
"""
Load configuration from environment variables and Flask config.
If feature_name is provided, feature-specific configuration is loaded
from config_llm.py with the following priority order:
1. Environment variable (SUPERSET_LLM_{FEATURE}_MODEL)
2. Flask config (LLM_FEATURE_CONFIG[feature_name])
3. Default feature config (DEFAULT_FEATURE_CONFIGS)
4. Global defaults
:param feature_name: Optional feature name for feature-specific config
(e.g., "database_analyzer", "dashboard_generator")
Environment variables:
SUPERSET_LLM_API_KEY: API key (required for LLM features)
SUPERSET_LLM_MODEL: Global model name
SUPERSET_LLM_BASE_URL: API URL (default: https://openrouter.ai/api/v1)
SUPERSET_LLM_{FEATURE}_MODEL: Feature-specific model override
"""
# Try Flask config for base settings
try:
flask_config = current_app.config
except RuntimeError:
flask_config = {}
# Get API key and base URL (shared across all features)
# Check both SUPERSET_LLM_API_KEY and LLM_API_KEY for Flask config compatibility
api_key = (
os.environ.get("SUPERSET_LLM_API_KEY")
or flask_config.get("SUPERSET_LLM_API_KEY")
or flask_config.get("LLM_API_KEY")
)
base_url = os.environ.get("SUPERSET_LLM_BASE_URL") or flask_config.get(
"LLM_BASE_URL", DEFAULT_BASE_URL
)
app_name = flask_config.get("LLM_APP_NAME", "Apache Superset")
site_url = flask_config.get("LLM_SITE_URL", "https://superset.apache.org")
# Get global defaults
global_model = os.environ.get("SUPERSET_LLM_MODEL") or flask_config.get(
"LLM_MODEL", DEFAULT_MODEL
)
global_temperature = float(
os.environ.get("SUPERSET_LLM_TEMPERATURE")
or flask_config.get("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)
)
global_max_tokens = int(
os.environ.get("SUPERSET_LLM_MAX_TOKENS")
or flask_config.get("LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS)
)
global_timeout = int(
os.environ.get("SUPERSET_LLM_TIMEOUT")
or flask_config.get("LLM_TIMEOUT", DEFAULT_TIMEOUT)
)
# If feature_name is provided, get feature-specific configuration
if feature_name:
from superset.config_llm import get_feature_config
feature_config = get_feature_config(feature_name)
return cls(
api_key=api_key,
model=feature_config.get_model(global_model),
base_url=base_url,
temperature=feature_config.get_temperature(global_temperature),
max_tokens=feature_config.get_max_tokens(global_max_tokens),
timeout=feature_config.get_timeout(global_timeout),
app_name=app_name,
site_url=site_url,
)
# No feature specified - use global defaults
return cls(
api_key=api_key,
model=global_model,
base_url=base_url,
temperature=global_temperature,
max_tokens=global_max_tokens,
timeout=global_timeout,
app_name=app_name,
site_url=site_url,
)
@dataclass
class LLMResponse:
"""
Response from LLM API call.
Attributes:
content: Raw response content (may include markdown code blocks)
json_content: Parsed JSON content (if response is valid JSON)
success: Whether the call was successful
error: Error message if unsuccessful
model: Model that generated the response
usage: Token usage information
"""
content: str = ""
json_content: dict[str, Any] | list[Any] | None = None
success: bool = True
error: str | None = None
model: str | None = None
usage: dict[str, int] = field(default_factory=dict)
class BaseLLMClient:
"""
Base client for LLM API interactions.
Supports OpenRouter (recommended) and direct provider APIs.
OpenRouter provides unified access to multiple LLM providers.
Per-Feature Configuration:
Different features can use different models optimized for their needs.
Pass feature_name to use feature-specific configuration:
# Uses dashboard_generator model (claude-3.5-sonnet for reasoning)
client = BaseLLMClient(feature_name="dashboard_generator")
# Uses database_analyzer model (gemini-2.0-flash for large context)
client = BaseLLMClient(feature_name="database_analyzer")
Example usage with OpenRouter:
export SUPERSET_LLM_API_KEY=sk-or-v1-...
export SUPERSET_LLM_BASE_URL=https://openrouter.ai/api/v1
export SUPERSET_LLM_MODEL=anthropic/claude-3.5-sonnet
Example usage with direct OpenAI:
export SUPERSET_LLM_API_KEY=sk-...
export SUPERSET_LLM_BASE_URL=https://api.openai.com/v1
export SUPERSET_LLM_MODEL=gpt-4o
Feature-specific model override:
export SUPERSET_LLM_DATABASE_ANALYZER_MODEL=google/gemini-2.0-flash-001
export SUPERSET_LLM_DASHBOARD_GENERATOR_MODEL=anthropic/claude-sonnet-4
"""
# Feature name for this client (set by subclasses)
feature_name: str | None = None
def __init__(
self,
config: LLMConfig | None = None,
feature_name: str | None = None,
) -> None:
"""
Initialize the LLM client.
:param config: LLM configuration. If None, loads from environment.
:param feature_name: Optional feature name for feature-specific config.
If provided, overrides the class-level feature_name.
Examples: "database_analyzer", "dashboard_generator"
"""
# Use provided feature_name or fall back to class attribute
effective_feature = feature_name or self.feature_name
self.config = config or LLMConfig.from_env(feature_name=effective_feature)
def is_available(self) -> bool:
"""Check if LLM service is configured and available."""
return bool(self.config.api_key)
def chat(
self,
prompt: str,
system_prompt: str = "",
temperature: float | None = None,
max_tokens: int | None = None,
) -> LLMResponse:
"""
Send a chat completion request to the LLM.
:param prompt: User prompt/message
:param system_prompt: System prompt for context
:param temperature: Override default temperature
:param max_tokens: Override default max tokens
:return: LLMResponse with content and metadata
"""
if not self.is_available():
return LLMResponse(
success=False,
error="LLM service not configured. Set SUPERSET_LLM_API_KEY.",
)
headers = self._build_headers()
payload = self._build_payload(
prompt,
system_prompt,
temperature or self.config.temperature,
max_tokens or self.config.max_tokens,
)
try:
response = requests.post(
f"{self.config.base_url}/chat/completions",
headers=headers,
json=payload,
timeout=self.config.timeout,
)
if response.status_code != 200:
logger.error(
"LLM API error: %d - %s", response.status_code, response.text
)
return LLMResponse(
success=False,
error=f"API error: {response.status_code}",
)
return self._parse_response(response.json())
except requests.exceptions.Timeout:
logger.error("LLM API timeout after %d seconds", self.config.timeout)
return LLMResponse(success=False, error="Request timeout")
except requests.exceptions.RequestException as e:
logger.error("LLM API request error: %s", str(e))
return LLMResponse(success=False, error=str(e))
except Exception as e:
logger.error("Unexpected error in LLM call: %s", str(e))
return LLMResponse(success=False, error=str(e))
def chat_json(
self,
prompt: str,
system_prompt: str = "",
temperature: float | None = None,
max_tokens: int | None = None,
) -> LLMResponse:
"""
Send a chat request expecting JSON response.
Automatically cleans markdown code blocks from response.
:param prompt: User prompt/message
:param system_prompt: System prompt (will append JSON instruction)
:param temperature: Override default temperature
:param max_tokens: Override default max tokens
:return: LLMResponse with parsed json_content
"""
# Append JSON instruction to system prompt
json_system = system_prompt
if json_system:
json_system += " Respond only with valid JSON."
else:
json_system = "Respond only with valid JSON."
response = self.chat(
prompt=prompt,
system_prompt=json_system,
temperature=temperature,
max_tokens=max_tokens,
)
if response.success and response.content:
response.json_content = self._parse_json_content(response.content)
return response
def _build_headers(self) -> dict[str, str]:
"""Build request headers with provider-specific additions."""
headers = {
"Authorization": f"Bearer {self.config.api_key}",
"Content-Type": "application/json",
}
# OpenRouter-specific headers for tracking and routing
if self._is_openrouter():
headers["HTTP-Referer"] = self.config.site_url
headers["X-Title"] = self.config.app_name
return headers
def _build_payload(
self,
prompt: str,
system_prompt: str,
temperature: float,
max_tokens: int,
) -> dict[str, Any]:
"""Build the API request payload."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
return {
"model": self.config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
def _parse_response(self, response_data: dict[str, Any]) -> LLMResponse:
"""Parse the API response into LLMResponse."""
try:
choice = response_data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
model = response_data.get("model")
usage = response_data.get("usage", {})
return LLMResponse(
content=content,
success=True,
model=model,
usage=usage,
)
except (KeyError, IndexError) as e:
logger.error("Failed to parse LLM response: %s", str(e))
return LLMResponse(success=False, error="Invalid response format")
def _parse_json_content(self, content: str) -> dict[str, Any] | list[Any] | None:
"""
Parse JSON from LLM response, handling markdown code blocks and extra text.
Uses langchain-core's parse_json_markdown first, with custom fallback
handling for SQL strings with unescaped newlines.
Handles common LLM issues like:
- Markdown code blocks (```json ... ```)
- Unescaped newlines in string values (common in SQL)
- Extra explanatory text before/after JSON
:param content: Raw response content
:return: Parsed JSON or None if parsing fails
"""
if not content:
return None
# Try langchain-core's parser first (handles markdown well)
try:
result = parse_json_markdown(content)
if result is not None:
return result
except Exception:
# Fall through to custom parsing for edge cases
pass
# Custom parsing for edge cases (especially SQL with newlines)
return self._parse_json_fallback(content)
def _parse_json_fallback(self, content: str) -> dict[str, Any] | list[Any] | None:
"""
Custom JSON parsing fallback for cases langchain-core doesn't handle.
Specifically handles SQL strings with unescaped newlines, which is
common when LLMs generate SQL in JSON responses.
"""
# Clean up markdown code blocks
cleaned = content.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
elif cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
# Try direct parsing first
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Try fixing unescaped newlines in strings (common LLM issue with SQL)
fixed = self._fix_unescaped_newlines(cleaned)
if fixed != cleaned:
try:
return json.loads(fixed)
except json.JSONDecodeError:
pass
# If that fails, try to extract JSON object or array
# Handle cases where LLM adds explanatory text before/after JSON
extracted = self._extract_json_from_text(cleaned)
if extracted:
try:
return json.loads(extracted)
except json.JSONDecodeError:
pass
# Try fixing unescaped newlines in extracted JSON
fixed_extracted = self._fix_unescaped_newlines(extracted)
try:
return json.loads(fixed_extracted)
except json.JSONDecodeError as e:
logger.warning("Failed to parse extracted JSON: %s", str(e))
logger.warning("Could not extract JSON from LLM response")
return None
def _extract_json_from_text(self, text: str) -> str | None:
"""Extract JSON object or array from text with surrounding content."""
# Find JSON object
obj_start = text.find("{")
if obj_start >= 0:
brace_count = 0
for i, char in enumerate(text[obj_start:], obj_start):
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
if brace_count == 0:
return text[obj_start : i + 1]
# Try JSON array if no object found
arr_start = text.find("[")
if arr_start >= 0:
bracket_count = 0
for i, char in enumerate(text[arr_start:], arr_start):
if char == "[":
bracket_count += 1
elif char == "]":
bracket_count -= 1
if bracket_count == 0:
return text[arr_start : i + 1]
return None
def _fix_unescaped_newlines(self, content: str) -> str:
"""
Fix unescaped newlines inside JSON string values.
LLMs often return JSON with literal newlines inside strings (especially SQL),
which is invalid JSON. This method escapes them properly.
:param content: JSON string that may have unescaped newlines
:return: Fixed JSON string with properly escaped newlines in strings
"""
result = []
in_string = False
escape_next = False
i = 0
while i < len(content):
char = content[i]
if escape_next:
result.append(char)
escape_next = False
i += 1
continue
if char == "\\":
result.append(char)
escape_next = True
i += 1
continue
if char == '"':
in_string = not in_string
result.append(char)
i += 1
continue
if in_string and char == "\n":
# Escape the newline inside a string
result.append("\\n")
i += 1
continue
if in_string and char == "\r":
# Skip carriage returns or escape them
i += 1
continue
if in_string and char == "\t":
# Escape tabs inside strings
result.append("\\t")
i += 1
continue
result.append(char)
i += 1
return "".join(result)
def _is_openrouter(self) -> bool:
"""Check if using OpenRouter as the provider."""
return "openrouter" in self.config.base_url.lower()

View File

@@ -0,0 +1,62 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""add is_template flags for template dashboards
Revision ID: aaca38be72f2
Revises: a9c01ec10479
Create Date: 2025-12-16 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "aaca38be72f2"
down_revision = "a9c01ec10479"
def upgrade():
# Add is_template_chart to slices (charts)
with op.batch_alter_table("slices") as batch_op:
batch_op.add_column(
sa.Column(
"is_template_chart",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
# Add is_template_dataset to tables (datasets)
with op.batch_alter_table("tables") as batch_op:
batch_op.add_column(
sa.Column(
"is_template_dataset",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade():
with op.batch_alter_table("tables") as batch_op:
batch_op.drop_column("is_template_dataset")
with op.batch_alter_table("slices") as batch_op:
batch_op.drop_column("is_template_chart")

View File

@@ -0,0 +1,347 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Add database analyzer models
Revision ID: c95466b0
Revises:
Create Date: 2025-12-17 07:54:00.000000
"""
# revision identifiers, used by Alembic.
revision = "c95466b0"
down_revision = "a9c01ec10479"
import sqlalchemy as sa
from alembic import op
from sqlalchemy_utils import UUIDType
def upgrade():
# Create database_schema_report table
op.create_table(
"database_schema_report",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("database_id", sa.Integer(), nullable=False),
sa.Column("schema_name", sa.String(256), nullable=False),
sa.Column("celery_task_id", sa.String(256), nullable=True),
sa.Column("status", sa.String(50), server_default="reserved", nullable=False),
sa.Column("reserved_dttm", sa.DateTime(), nullable=True),
sa.Column("start_dttm", sa.DateTime(), nullable=True),
sa.Column("end_dttm", sa.DateTime(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("extra_json", sa.Text(), nullable=True),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_database_schema_report"),
sa.UniqueConstraint("uuid", name="uq_database_schema_report_uuid"),
sa.UniqueConstraint(
"database_id",
"schema_name",
name="uq_database_schema_report_database_schema",
),
sa.ForeignKeyConstraint(
["database_id"],
["dbs.id"],
name="fk_database_schema_report_database_id_dbs",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_fk"],
["ab_user.id"],
name="fk_database_schema_report_created_by_fk_ab_user",
),
sa.ForeignKeyConstraint(
["changed_by_fk"],
["ab_user.id"],
name="fk_database_schema_report_changed_by_fk_ab_user",
),
sa.CheckConstraint(
"status IN ('reserved', 'running', 'completed', 'failed')",
name="ck_database_schema_report_status",
),
)
# Create indexes for database_schema_report
op.create_index(
"ix_database_schema_report_database_id",
"database_schema_report",
["database_id"],
)
op.create_index(
"ix_database_schema_report_status",
"database_schema_report",
["status"],
)
op.create_index(
"ix_database_schema_report_celery_task_id",
"database_schema_report",
["celery_task_id"],
)
op.create_index(
"ix_database_schema_report_database_schema",
"database_schema_report",
["database_id", "schema_name"],
)
# Create analyzed_table table
op.create_table(
"analyzed_table",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("report_id", sa.Integer(), nullable=False),
sa.Column("table_name", sa.String(256), nullable=False),
sa.Column("table_type", sa.String(50), nullable=False),
sa.Column("db_comment", sa.Text(), nullable=True),
sa.Column("ai_description", sa.Text(), nullable=True),
sa.Column("extra_json", sa.Text(), nullable=True),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_analyzed_table"),
sa.UniqueConstraint("uuid", name="uq_analyzed_table_uuid"),
sa.UniqueConstraint(
"report_id",
"table_name",
name="uq_analyzed_table_report_table",
),
sa.ForeignKeyConstraint(
["report_id"],
["database_schema_report.id"],
name="fk_analyzed_table_report_id_database_schema_report",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_fk"],
["ab_user.id"],
name="fk_analyzed_table_created_by_fk_ab_user",
),
sa.ForeignKeyConstraint(
["changed_by_fk"],
["ab_user.id"],
name="fk_analyzed_table_changed_by_fk_ab_user",
),
sa.CheckConstraint(
"table_type IN ('table', 'view', 'materialized_view')",
name="ck_analyzed_table_table_type",
),
)
# Create indexes for analyzed_table
op.create_index(
"ix_analyzed_table_report_id",
"analyzed_table",
["report_id"],
)
op.create_index(
"ix_analyzed_table_table_type",
"analyzed_table",
["table_type"],
)
op.create_index(
"ix_analyzed_table_report_type",
"analyzed_table",
["report_id", "table_type"],
)
# Create analyzed_column table
op.create_table(
"analyzed_column",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("table_id", sa.Integer(), nullable=False),
sa.Column("column_name", sa.String(256), nullable=False),
sa.Column("data_type", sa.String(256), nullable=False),
sa.Column("ordinal_position", sa.Integer(), nullable=False),
sa.Column("db_comment", sa.Text(), nullable=True),
sa.Column("ai_description", sa.Text(), nullable=True),
sa.Column("extra_json", sa.Text(), nullable=True),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_analyzed_column"),
sa.UniqueConstraint("uuid", name="uq_analyzed_column_uuid"),
sa.UniqueConstraint(
"table_id",
"column_name",
name="uq_analyzed_column_table_column",
),
sa.ForeignKeyConstraint(
["table_id"],
["analyzed_table.id"],
name="fk_analyzed_column_table_id_analyzed_table",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_fk"],
["ab_user.id"],
name="fk_analyzed_column_created_by_fk_ab_user",
),
sa.ForeignKeyConstraint(
["changed_by_fk"],
["ab_user.id"],
name="fk_analyzed_column_changed_by_fk_ab_user",
),
sa.CheckConstraint(
"ordinal_position >= 1",
name="ck_analyzed_column_ordinal_position",
),
)
# Create indexes for analyzed_column
op.create_index(
"ix_analyzed_column_table_id",
"analyzed_column",
["table_id"],
)
op.create_index(
"ix_analyzed_column_data_type",
"analyzed_column",
["data_type"],
)
op.create_index(
"ix_analyzed_column_table_position",
"analyzed_column",
["table_id", "ordinal_position"],
)
# Create inferred_join table
op.create_table(
"inferred_join",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("report_id", sa.Integer(), nullable=False),
sa.Column("source_table_id", sa.Integer(), nullable=False),
sa.Column("target_table_id", sa.Integer(), nullable=False),
sa.Column("source_columns", sa.Text(), nullable=False),
sa.Column("target_columns", sa.Text(), nullable=False),
sa.Column("join_type", sa.String(50), server_default="inner", nullable=False),
sa.Column("cardinality", sa.String(50), nullable=False),
sa.Column("semantic_context", sa.Text(), nullable=True),
sa.Column("extra_json", sa.Text(), nullable=True),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_inferred_join"),
sa.UniqueConstraint("uuid", name="uq_inferred_join_uuid"),
sa.ForeignKeyConstraint(
["report_id"],
["database_schema_report.id"],
name="fk_inferred_join_report_id_database_schema_report",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_table_id"],
["analyzed_table.id"],
name="fk_inferred_join_source_table_id_analyzed_table",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["target_table_id"],
["analyzed_table.id"],
name="fk_inferred_join_target_table_id_analyzed_table",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_fk"],
["ab_user.id"],
name="fk_inferred_join_created_by_fk_ab_user",
),
sa.ForeignKeyConstraint(
["changed_by_fk"],
["ab_user.id"],
name="fk_inferred_join_changed_by_fk_ab_user",
),
sa.CheckConstraint(
"join_type IN ('inner', 'left', 'right', 'full', 'cross')",
name="ck_inferred_join_join_type",
),
sa.CheckConstraint(
"cardinality IN ('1:1', '1:N', 'N:1', 'N:M')",
name="ck_inferred_join_cardinality",
),
)
# Create indexes for inferred_join
op.create_index(
"ix_inferred_join_report_id",
"inferred_join",
["report_id"],
)
op.create_index(
"ix_inferred_join_source_table_id",
"inferred_join",
["source_table_id"],
)
op.create_index(
"ix_inferred_join_target_table_id",
"inferred_join",
["target_table_id"],
)
op.create_index(
"ix_inferred_join_source_target",
"inferred_join",
["source_table_id", "target_table_id"],
)
op.create_index(
"ix_inferred_join_join_type",
"inferred_join",
["join_type"],
)
def downgrade():
# Drop indexes for inferred_join
op.drop_index("ix_inferred_join_join_type", table_name="inferred_join")
op.drop_index("ix_inferred_join_source_target", table_name="inferred_join")
op.drop_index("ix_inferred_join_target_table_id", table_name="inferred_join")
op.drop_index("ix_inferred_join_source_table_id", table_name="inferred_join")
op.drop_index("ix_inferred_join_report_id", table_name="inferred_join")
# Drop inferred_join table
op.drop_table("inferred_join")
# Drop indexes for analyzed_column
op.drop_index("ix_analyzed_column_table_position", table_name="analyzed_column")
op.drop_index("ix_analyzed_column_data_type", table_name="analyzed_column")
op.drop_index("ix_analyzed_column_table_id", table_name="analyzed_column")
# Drop analyzed_column table
op.drop_table("analyzed_column")
# Drop indexes for analyzed_table
op.drop_index("ix_analyzed_table_report_type", table_name="analyzed_table")
op.drop_index("ix_analyzed_table_table_type", table_name="analyzed_table")
op.drop_index("ix_analyzed_table_report_id", table_name="analyzed_table")
# Drop analyzed_table table
op.drop_table("analyzed_table")
# Drop indexes for database_schema_report
op.drop_index("ix_database_schema_report_database_schema", table_name="database_schema_report")
op.drop_index("ix_database_schema_report_celery_task_id", table_name="database_schema_report")
op.drop_index("ix_database_schema_report_status", table_name="database_schema_report")
op.drop_index("ix_database_schema_report_database_id", table_name="database_schema_report")
# Drop database_schema_report table
op.drop_table("database_schema_report")

View File

@@ -0,0 +1,38 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Merge database analyzer and main branches
Revision ID: 4a032c8dbc11
Revises: ('aaca38be72f2', 'c95466b0')
Create Date: 2025-12-17 18:01:29.962499
"""
# revision identifiers, used by Alembic.
revision = '4a032c8dbc11'
down_revision = ('aaca38be72f2', 'c95466b0')
from alembic import op
import sqlalchemy as sa
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,164 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Add dashboard_generator_run table
Revision ID: b8f2a1c3d4e5
Revises: 4a032c8dbc11
Create Date: 2025-12-17 20:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "b8f2a1c3d4e5"
down_revision = "4a032c8dbc11"
import sqlalchemy as sa
from alembic import op
from sqlalchemy_utils import UUIDType
def upgrade():
# Create dashboard_generator_run table
op.create_table(
"dashboard_generator_run",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=False),
sa.Column("celery_task_id", sa.String(256), nullable=True),
# Input references
sa.Column("database_report_id", sa.Integer(), nullable=True),
sa.Column("template_dashboard_id", sa.Integer(), nullable=True),
# Output references
sa.Column("generated_dashboard_id", sa.Integer(), nullable=True),
sa.Column("generated_dataset_id", sa.Integer(), nullable=True),
# Status tracking
sa.Column(
"status", sa.String(50), server_default="reserved", nullable=False
),
sa.Column("current_phase", sa.String(50), nullable=True),
# Progress tracking
sa.Column("progress_json", sa.Text(), nullable=True),
# Mapping data
sa.Column("column_mappings_json", sa.Text(), nullable=True),
sa.Column("metric_mappings_json", sa.Text(), nullable=True),
# Timing
sa.Column("reserved_dttm", sa.DateTime(), nullable=True),
sa.Column("start_dttm", sa.DateTime(), nullable=True),
sa.Column("end_dttm", sa.DateTime(), nullable=True),
# Error tracking
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("failed_items_json", sa.Text(), nullable=True),
# Audit columns
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
# Constraints
sa.PrimaryKeyConstraint("id", name="pk_dashboard_generator_run"),
sa.UniqueConstraint("uuid", name="uq_dashboard_generator_run_uuid"),
sa.UniqueConstraint(
"celery_task_id", name="uq_dashboard_generator_run_celery_task_id"
),
sa.ForeignKeyConstraint(
["database_report_id"],
["database_schema_report.id"],
name="fk_dashboard_generator_run_database_report_id",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["template_dashboard_id"],
["dashboards.id"],
name="fk_dashboard_generator_run_template_dashboard_id",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["generated_dashboard_id"],
["dashboards.id"],
name="fk_dashboard_generator_run_generated_dashboard_id",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["generated_dataset_id"],
["tables.id"],
name="fk_dashboard_generator_run_generated_dataset_id",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["created_by_fk"],
["ab_user.id"],
name="fk_dashboard_generator_run_created_by_fk",
),
sa.ForeignKeyConstraint(
["changed_by_fk"],
["ab_user.id"],
name="fk_dashboard_generator_run_changed_by_fk",
),
sa.CheckConstraint(
"status IN ('reserved', 'running', 'completed', 'failed')",
name="ck_dashboard_generator_run_status",
),
sa.CheckConstraint(
"current_phase IN ('copy_dashboard', 'build_dataset_charts', "
"'build_dataset_filters', 'update_charts', 'update_filters', 'finalize') "
"OR current_phase IS NULL",
name="ck_dashboard_generator_run_phase",
),
)
# Create indexes
op.create_index(
"ix_dashboard_generator_run_celery_task_id",
"dashboard_generator_run",
["celery_task_id"],
)
op.create_index(
"ix_dashboard_generator_run_status",
"dashboard_generator_run",
["status"],
)
op.create_index(
"ix_dashboard_generator_run_database_report_id",
"dashboard_generator_run",
["database_report_id"],
)
op.create_index(
"ix_dashboard_generator_run_template_dashboard_id",
"dashboard_generator_run",
["template_dashboard_id"],
)
def downgrade():
# Drop indexes
op.drop_index(
"ix_dashboard_generator_run_template_dashboard_id",
table_name="dashboard_generator_run",
)
op.drop_index(
"ix_dashboard_generator_run_database_report_id",
table_name="dashboard_generator_run",
)
op.drop_index(
"ix_dashboard_generator_run_status",
table_name="dashboard_generator_run",
)
op.drop_index(
"ix_dashboard_generator_run_celery_task_id",
table_name="dashboard_generator_run",
)
# Drop table
op.drop_table("dashboard_generator_run")

Some files were not shown because too many files have changed in this diff Show More