mirror of
https://github.com/apache/superset.git
synced 2026-06-10 10:09:14 +00:00
Compare commits
3 Commits
ci/cypress
...
supernauts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa6c8a2e3 | ||
|
|
f86511a956 | ||
|
|
b91ff3ab0d |
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
29
superset-frontend/src/dashboard/selectors.ts
Normal file
29
superset-frontend/src/dashboard/selectors.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/');
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,7 +217,7 @@ const RightMenu = ({
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/templates/',
|
||||
icon: (
|
||||
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
|
||||
),
|
||||
|
||||
163
superset-frontend/src/hooks/usePolling.ts
Normal file
163
superset-frontend/src/hooks/usePolling.ts
Normal 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;
|
||||
@@ -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/');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
37
superset-frontend/src/pages/DashboardTemplates/constants.ts
Normal file
37
superset-frontend/src/pages/DashboardTemplates/constants.ts
Normal 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');
|
||||
110
superset-frontend/src/pages/DashboardTemplates/index.tsx
Normal file
110
superset-frontend/src/pages/DashboardTemplates/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
superset-frontend/src/pages/DashboardTemplates/types.ts
Normal file
38
superset-frontend/src/pages/DashboardTemplates/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
superset-frontend/src/pages/DatasourceConnector/config.ts
Normal file
24
superset-frontend/src/pages/DatasourceConnector/config.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
583
superset-frontend/src/pages/DatasourceConnector/index.tsx
Normal file
583
superset-frontend/src/pages/DatasourceConnector/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
superset-frontend/src/pages/DatasourceConnector/types.ts
Normal file
167
superset-frontend/src/pages/DatasourceConnector/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
87
superset/commands/dashboard_generator/__init__.py
Normal file
87
superset/commands/dashboard_generator/__init__.py
Normal 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",
|
||||
]
|
||||
2475
superset/commands/dashboard_generator/agentic_generator.py
Normal file
2475
superset/commands/dashboard_generator/agentic_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
110
superset/commands/dashboard_generator/generate.py
Normal file
110
superset/commands/dashboard_generator/generate.py
Normal 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"
|
||||
)
|
||||
1073
superset/commands/dashboard_generator/llm_service.py
Normal file
1073
superset/commands/dashboard_generator/llm_service.py
Normal file
File diff suppressed because it is too large
Load Diff
562
superset/commands/dashboard_generator/mapping_service.py
Normal file
562
superset/commands/dashboard_generator/mapping_service.py
Normal 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)
|
||||
362
superset/commands/dashboard_generator/template_analyzer.py
Normal file
362
superset/commands/dashboard_generator/template_analyzer.py
Normal 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)
|
||||
112
superset/commands/dashboard_generator/utils.py
Normal file
112
superset/commands/dashboard_generator/utils.py
Normal 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,
|
||||
}
|
||||
314
superset/commands/dashboard_generator/validator.py
Normal file
314
superset/commands/dashboard_generator/validator.py
Normal 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,
|
||||
)
|
||||
16
superset/commands/database_analyzer/__init__.py
Normal file
16
superset/commands/database_analyzer/__init__.py
Normal 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.
|
||||
610
superset/commands/database_analyzer/analyze.py
Normal file
610
superset/commands/database_analyzer/analyze.py
Normal 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
|
||||
462
superset/commands/database_analyzer/llm_service.py
Normal file
462
superset/commands/database_analyzer/llm_service.py
Normal 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",
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
333
superset/config_llm.py
Normal 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"],
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
superset/dashboard_generator/__init__.py
Normal file
16
superset/dashboard_generator/__init__.py
Normal 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.
|
||||
406
superset/dashboard_generator/api.py
Normal file
406
superset/dashboard_generator/api.py
Normal 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))
|
||||
109
superset/dashboard_generator/exceptions.py
Normal file
109
superset/dashboard_generator/exceptions.py
Normal 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")
|
||||
185
superset/dashboard_generator/schemas.py
Normal file
185
superset/dashboard_generator/schemas.py
Normal 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"},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
998
superset/databases/analyzer_api.py
Normal file
998
superset/databases/analyzer_api.py
Normal 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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
62
superset/llm/__init__.py
Normal 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
572
superset/llm/base.py
Normal 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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user