mirror of
https://github.com/apache/superset.git
synced 2026-07-03 13:25:32 +00:00
Compare commits
3 Commits
chore/ci/s
...
supernauts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa6c8a2e3 | ||
|
|
f86511a956 | ||
|
|
b91ff3ab0d |
@@ -109,6 +109,10 @@ dependencies = [
|
|||||||
"wtforms>=2.3.3, <4",
|
"wtforms>=2.3.3, <4",
|
||||||
"wtforms-json",
|
"wtforms-json",
|
||||||
"xlsxwriter>=3.0.7, <3.1",
|
"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]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ amqp==5.3.1
|
|||||||
# via kombu
|
# via kombu
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
# via pydantic
|
# via pydantic
|
||||||
|
anyio==4.12.0
|
||||||
|
# via httpx
|
||||||
apispec==6.6.1
|
apispec==6.6.1
|
||||||
# via
|
# via
|
||||||
# -r requirements/base.in
|
# -r requirements/base.in
|
||||||
@@ -50,6 +52,8 @@ celery==5.5.2
|
|||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
certifi==2025.6.15
|
certifi==2025.6.15
|
||||||
# via
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
# requests
|
# requests
|
||||||
# selenium
|
# selenium
|
||||||
cffi==1.17.1
|
cffi==1.17.1
|
||||||
@@ -164,16 +168,26 @@ greenlet==3.1.1
|
|||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
# via wsproto
|
# via
|
||||||
|
# httpcore
|
||||||
|
# wsproto
|
||||||
hashids==1.3.1
|
hashids==1.3.1
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
holidays==0.82
|
holidays==0.82
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httpx==0.28.1
|
||||||
|
# via
|
||||||
|
# langgraph-sdk
|
||||||
|
# langsmith
|
||||||
humanize==4.12.3
|
humanize==4.12.3
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
idna==3.10
|
idna==3.10
|
||||||
# via
|
# via
|
||||||
|
# anyio
|
||||||
# email-validator
|
# email-validator
|
||||||
|
# httpx
|
||||||
# requests
|
# requests
|
||||||
# trio
|
# trio
|
||||||
# url-normalize
|
# url-normalize
|
||||||
@@ -187,8 +201,12 @@ jinja2==3.1.6
|
|||||||
# via
|
# via
|
||||||
# flask
|
# flask
|
||||||
# flask-babel
|
# flask-babel
|
||||||
|
jsonpatch==1.33
|
||||||
|
# via langchain-core
|
||||||
jsonpath-ng==1.7.0
|
jsonpath-ng==1.7.0
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
|
jsonpointer==3.0.0
|
||||||
|
# via jsonpatch
|
||||||
jsonschema==4.23.0
|
jsonschema==4.23.0
|
||||||
# via
|
# via
|
||||||
# flask-appbuilder
|
# flask-appbuilder
|
||||||
@@ -199,6 +217,24 @@ jsonschema-specifications==2025.4.1
|
|||||||
# openapi-schema-validator
|
# openapi-schema-validator
|
||||||
kombu==5.5.3
|
kombu==5.5.3
|
||||||
# via celery
|
# 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
|
limits==5.1.0
|
||||||
# via flask-limiter
|
# via flask-limiter
|
||||||
mako==1.3.10
|
mako==1.3.10
|
||||||
@@ -252,6 +288,12 @@ openpyxl==3.1.5
|
|||||||
# via pandas
|
# via pandas
|
||||||
ordered-set==4.1.0
|
ordered-set==4.1.0
|
||||||
# via flask-limiter
|
# via flask-limiter
|
||||||
|
orjson==3.11.5
|
||||||
|
# via
|
||||||
|
# langgraph-sdk
|
||||||
|
# langsmith
|
||||||
|
ormsgpack==1.12.1
|
||||||
|
# via langgraph-checkpoint
|
||||||
outcome==1.3.0.post0
|
outcome==1.3.0.post0
|
||||||
# via
|
# via
|
||||||
# trio
|
# trio
|
||||||
@@ -262,6 +304,8 @@ packaging==25.0
|
|||||||
# apispec
|
# apispec
|
||||||
# deprecation
|
# deprecation
|
||||||
# gunicorn
|
# gunicorn
|
||||||
|
# langchain-core
|
||||||
|
# langsmith
|
||||||
# limits
|
# limits
|
||||||
# marshmallow
|
# marshmallow
|
||||||
# shillelagh
|
# shillelagh
|
||||||
@@ -301,6 +345,9 @@ pydantic==2.11.7
|
|||||||
# via
|
# via
|
||||||
# apache-superset (pyproject.toml)
|
# apache-superset (pyproject.toml)
|
||||||
# apache-superset-core
|
# apache-superset-core
|
||||||
|
# langchain-core
|
||||||
|
# langgraph
|
||||||
|
# langsmith
|
||||||
pydantic-core==2.33.2
|
pydantic-core==2.33.2
|
||||||
# via pydantic
|
# via pydantic
|
||||||
pygments==2.19.1
|
pygments==2.19.1
|
||||||
@@ -343,6 +390,7 @@ pyyaml==6.0.2
|
|||||||
# via
|
# via
|
||||||
# apache-superset (pyproject.toml)
|
# apache-superset (pyproject.toml)
|
||||||
# apispec
|
# apispec
|
||||||
|
# langchain-core
|
||||||
redis==5.3.1
|
redis==5.3.1
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
referencing==0.36.2
|
referencing==0.36.2
|
||||||
@@ -351,10 +399,14 @@ referencing==0.36.2
|
|||||||
# jsonschema-specifications
|
# jsonschema-specifications
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
# via
|
# via
|
||||||
|
# langsmith
|
||||||
# requests-cache
|
# requests-cache
|
||||||
|
# requests-toolbelt
|
||||||
# shillelagh
|
# shillelagh
|
||||||
requests-cache==1.2.1
|
requests-cache==1.2.1
|
||||||
# via shillelagh
|
# via shillelagh
|
||||||
|
requests-toolbelt==1.0.0
|
||||||
|
# via langsmith
|
||||||
rfc3339-validator==0.1.4
|
rfc3339-validator==0.1.4
|
||||||
# via openapi-schema-validator
|
# via openapi-schema-validator
|
||||||
rich==13.9.4
|
rich==13.9.4
|
||||||
@@ -408,6 +460,8 @@ sshtunnel==0.4.0
|
|||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
tabulate==0.9.0
|
tabulate==0.9.0
|
||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
|
tenacity==9.1.2
|
||||||
|
# via langchain-core
|
||||||
trio==0.30.0
|
trio==0.30.0
|
||||||
# via
|
# via
|
||||||
# selenium
|
# selenium
|
||||||
@@ -418,8 +472,10 @@ typing-extensions==4.15.0
|
|||||||
# via
|
# via
|
||||||
# apache-superset (pyproject.toml)
|
# apache-superset (pyproject.toml)
|
||||||
# alembic
|
# alembic
|
||||||
|
# anyio
|
||||||
# apache-superset-core
|
# apache-superset-core
|
||||||
# cattrs
|
# cattrs
|
||||||
|
# langchain-core
|
||||||
# limits
|
# limits
|
||||||
# pydantic
|
# pydantic
|
||||||
# pydantic-core
|
# pydantic-core
|
||||||
@@ -442,6 +498,10 @@ urllib3==2.6.0
|
|||||||
# requests
|
# requests
|
||||||
# requests-cache
|
# requests-cache
|
||||||
# selenium
|
# selenium
|
||||||
|
uuid-utils==0.12.0
|
||||||
|
# via
|
||||||
|
# langchain-core
|
||||||
|
# langsmith
|
||||||
vine==5.1.0
|
vine==5.1.0
|
||||||
# via
|
# via
|
||||||
# amqp
|
# amqp
|
||||||
@@ -478,5 +538,9 @@ xlsxwriter==3.0.9
|
|||||||
# via
|
# via
|
||||||
# apache-superset (pyproject.toml)
|
# apache-superset (pyproject.toml)
|
||||||
# pandas
|
# pandas
|
||||||
|
xxhash==3.6.0
|
||||||
|
# via langgraph
|
||||||
zstandard==0.23.0
|
zstandard==0.23.0
|
||||||
# via flask-compress
|
# via
|
||||||
|
# flask-compress
|
||||||
|
# langsmith
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ annotated-types==0.7.0
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# pydantic
|
# pydantic
|
||||||
anyio==4.11.0
|
anyio==4.12.0
|
||||||
# via
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
# httpx
|
# httpx
|
||||||
# mcp
|
# mcp
|
||||||
# sse-starlette
|
# sse-starlette
|
||||||
@@ -401,10 +402,15 @@ holidays==0.82
|
|||||||
# apache-superset
|
# apache-superset
|
||||||
# prophet
|
# prophet
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
# via httpx
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# httpx
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
# fastmcp
|
# fastmcp
|
||||||
|
# langgraph-sdk
|
||||||
|
# langsmith
|
||||||
# mcp
|
# mcp
|
||||||
httpx-sse==0.4.1
|
httpx-sse==0.4.1
|
||||||
# via mcp
|
# via mcp
|
||||||
@@ -458,10 +464,18 @@ jinja2==3.1.6
|
|||||||
# apache-superset-extensions-cli
|
# apache-superset-extensions-cli
|
||||||
# flask
|
# flask
|
||||||
# flask-babel
|
# flask-babel
|
||||||
|
jsonpatch==1.33
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# langchain-core
|
||||||
jsonpath-ng==1.7.0
|
jsonpath-ng==1.7.0
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# apache-superset
|
# apache-superset
|
||||||
|
jsonpointer==3.0.0
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# jsonpatch
|
||||||
jsonschema==4.23.0
|
jsonschema==4.23.0
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
@@ -486,6 +500,34 @@ kombu==5.5.3
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# celery
|
# 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
|
lazy-object-proxy==1.10.0
|
||||||
# via openapi-spec-validator
|
# via openapi-spec-validator
|
||||||
limits==5.1.0
|
limits==5.1.0
|
||||||
@@ -606,6 +648,15 @@ ordered-set==4.1.0
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# flask-limiter
|
# 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
|
outcome==1.3.0.post0
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
@@ -622,6 +673,8 @@ packaging==25.0
|
|||||||
# duckdb-engine
|
# duckdb-engine
|
||||||
# google-cloud-bigquery
|
# google-cloud-bigquery
|
||||||
# gunicorn
|
# gunicorn
|
||||||
|
# langchain-core
|
||||||
|
# langsmith
|
||||||
# limits
|
# limits
|
||||||
# marshmallow
|
# marshmallow
|
||||||
# matplotlib
|
# matplotlib
|
||||||
@@ -747,6 +800,9 @@ pydantic==2.11.7
|
|||||||
# apache-superset
|
# apache-superset
|
||||||
# apache-superset-core
|
# apache-superset-core
|
||||||
# fastmcp
|
# fastmcp
|
||||||
|
# langchain-core
|
||||||
|
# langgraph
|
||||||
|
# langsmith
|
||||||
# mcp
|
# mcp
|
||||||
# openapi-pydantic
|
# openapi-pydantic
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
@@ -866,6 +922,7 @@ pyyaml==6.0.2
|
|||||||
# apache-superset
|
# apache-superset
|
||||||
# apispec
|
# apispec
|
||||||
# jsonschema-path
|
# jsonschema-path
|
||||||
|
# langchain-core
|
||||||
# pre-commit
|
# pre-commit
|
||||||
redis==5.3.1
|
redis==5.3.1
|
||||||
# via
|
# via
|
||||||
@@ -887,10 +944,12 @@ requests==2.32.4
|
|||||||
# google-api-core
|
# google-api-core
|
||||||
# google-cloud-bigquery
|
# google-cloud-bigquery
|
||||||
# jsonschema-path
|
# jsonschema-path
|
||||||
|
# langsmith
|
||||||
# pydruid
|
# pydruid
|
||||||
# pyhive
|
# pyhive
|
||||||
# requests-cache
|
# requests-cache
|
||||||
# requests-oauthlib
|
# requests-oauthlib
|
||||||
|
# requests-toolbelt
|
||||||
# shillelagh
|
# shillelagh
|
||||||
# trino
|
# trino
|
||||||
requests-cache==1.2.1
|
requests-cache==1.2.1
|
||||||
@@ -899,6 +958,10 @@ requests-cache==1.2.1
|
|||||||
# shillelagh
|
# shillelagh
|
||||||
requests-oauthlib==2.0.0
|
requests-oauthlib==2.0.0
|
||||||
# via google-auth-oauthlib
|
# via google-auth-oauthlib
|
||||||
|
requests-toolbelt==1.0.0
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# langsmith
|
||||||
rfc3339-validator==0.1.4
|
rfc3339-validator==0.1.4
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
@@ -965,7 +1028,6 @@ slack-sdk==3.35.0
|
|||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# anyio
|
|
||||||
# trio
|
# trio
|
||||||
sortedcontainers==2.4.0
|
sortedcontainers==2.4.0
|
||||||
# via
|
# via
|
||||||
@@ -1014,6 +1076,10 @@ tabulate==0.9.0
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# apache-superset
|
# apache-superset
|
||||||
|
tenacity==9.1.2
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# langchain-core
|
||||||
tomlkit==0.13.3
|
tomlkit==0.13.3
|
||||||
# via pylint
|
# via pylint
|
||||||
tqdm==4.67.1
|
tqdm==4.67.1
|
||||||
@@ -1042,6 +1108,7 @@ typing-extensions==4.15.0
|
|||||||
# apache-superset-core
|
# apache-superset-core
|
||||||
# cattrs
|
# cattrs
|
||||||
# exceptiongroup
|
# exceptiongroup
|
||||||
|
# langchain-core
|
||||||
# limits
|
# limits
|
||||||
# mcp
|
# mcp
|
||||||
# opentelemetry-api
|
# opentelemetry-api
|
||||||
@@ -1082,6 +1149,11 @@ urllib3==2.6.0
|
|||||||
# requests
|
# requests
|
||||||
# requests-cache
|
# requests-cache
|
||||||
# selenium
|
# selenium
|
||||||
|
uuid-utils==0.12.0
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# langchain-core
|
||||||
|
# langsmith
|
||||||
uvicorn==0.37.0
|
uvicorn==0.37.0
|
||||||
# via
|
# via
|
||||||
# fastmcp
|
# fastmcp
|
||||||
@@ -1144,6 +1216,10 @@ xlsxwriter==3.0.9
|
|||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# apache-superset
|
# apache-superset
|
||||||
# pandas
|
# pandas
|
||||||
|
xxhash==3.6.0
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# langgraph
|
||||||
zipp==3.23.0
|
zipp==3.23.0
|
||||||
# via importlib-metadata
|
# via importlib-metadata
|
||||||
zope-event==5.0
|
zope-event==5.0
|
||||||
@@ -1154,3 +1230,4 @@ zstandard==0.23.0
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# flask-compress
|
# 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 { Skeleton, type SkeletonProps } from './Skeleton';
|
||||||
|
|
||||||
|
export { Spin } from './Spin';
|
||||||
|
|
||||||
export { Switch, type SwitchProps } from './Switch';
|
export { Switch, type SwitchProps } from './Switch';
|
||||||
|
|
||||||
export { TreeSelect, type TreeSelectProps } from './TreeSelect';
|
export { TreeSelect, type TreeSelectProps } from './TreeSelect';
|
||||||
@@ -191,3 +193,9 @@ export {
|
|||||||
type CodeEditorTheme,
|
type CodeEditorTheme,
|
||||||
} from './CodeEditor';
|
} from './CodeEditor';
|
||||||
export { ActionButton, type ActionProps } from './ActionButton';
|
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;
|
const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS;
|
||||||
|
|
||||||
function changeDatabase(
|
function changeDatabase(
|
||||||
value: { label: string; value: number },
|
value: { label: string; value: number } | undefined,
|
||||||
database: DatabaseValue,
|
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
|
// the database id is actually stored in the value property; the ID is used
|
||||||
// for the DOM, so it can't be an integer
|
// for the DOM, so it can't be an integer
|
||||||
const databaseWithId = { ...database, id: database.value };
|
const databaseWithId = { ...database, id: database.value };
|
||||||
@@ -361,6 +378,7 @@ export function DatabaseSelector({
|
|||||||
disabled={!isDatabaseSelectEnabled || readOnly}
|
disabled={!isDatabaseSelectEnabled || readOnly}
|
||||||
options={loadDatabases}
|
options={loadDatabases}
|
||||||
sortComparator={sortComparator}
|
sortComparator={sortComparator}
|
||||||
|
allowClear
|
||||||
/>,
|
/>,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
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 buildPayload = (datasource: Record<string, any>) => {
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
table_name: datasource.table_name,
|
table_name: datasource.table_name,
|
||||||
@@ -345,17 +348,17 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
buttonStyle="primary"
|
buttonStyle="primary"
|
||||||
data-test="datasource-modal-save"
|
data-test="datasource-modal-save"
|
||||||
onClick={onClickSave}
|
onClick={onClickSave}
|
||||||
disabled={
|
disabled={isSaving || errors.length > 0 || isReadOnly}
|
||||||
isSaving ||
|
|
||||||
errors.length > 0 ||
|
|
||||||
currentDatasource.is_managed_externally
|
|
||||||
}
|
|
||||||
tooltip={
|
tooltip={
|
||||||
currentDatasource.is_managed_externally
|
isTemplateDataset
|
||||||
? t(
|
? 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')}
|
{t('Save')}
|
||||||
@@ -364,6 +367,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
}
|
}
|
||||||
responsive
|
responsive
|
||||||
>
|
>
|
||||||
|
{isTemplateDataset && (
|
||||||
|
<Alert type="info" banner closable={false}>
|
||||||
|
{t(
|
||||||
|
'This dataset belongs to a template dashboard and cannot be modified.',
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<DatasourceEditor
|
<DatasourceEditor
|
||||||
showLoadingForImport
|
showLoadingForImport
|
||||||
height={500}
|
height={500}
|
||||||
|
|||||||
@@ -299,7 +299,8 @@ export const hydrateDashboard =
|
|||||||
css: dashboard.css || '',
|
css: dashboard.css || '',
|
||||||
colorNamespace: metadata?.color_namespace || null,
|
colorNamespace: metadata?.color_namespace || null,
|
||||||
colorScheme: metadata?.color_scheme || 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,
|
isPublished: dashboard.published,
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
dashboardIsSaving: false,
|
dashboardIsSaving: false,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
|
|||||||
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
||||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||||
import DashboardHeader from 'src/dashboard/components/Header';
|
import DashboardHeader from 'src/dashboard/components/Header';
|
||||||
|
import TemplatePreviewHeader from 'src/dashboard/components/TemplatePreviewHeader';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import IconButton from 'src/dashboard/components/IconButton';
|
import IconButton from 'src/dashboard/components/IconButton';
|
||||||
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
|
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
} from 'src/dashboard/actions/dashboardLayout';
|
} from 'src/dashboard/actions/dashboardLayout';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_GRID_ID,
|
DASHBOARD_GRID_ID,
|
||||||
|
DASHBOARD_HEADER_ID,
|
||||||
DASHBOARD_ROOT_DEPTH,
|
DASHBOARD_ROOT_DEPTH,
|
||||||
DASHBOARD_ROOT_ID,
|
DASHBOARD_ROOT_ID,
|
||||||
DashboardStandaloneMode,
|
DashboardStandaloneMode,
|
||||||
@@ -70,6 +72,7 @@ import { getRootLevelTabsComponent, shouldFocusTabs } from './utils';
|
|||||||
import DashboardContainer from './DashboardContainer';
|
import DashboardContainer from './DashboardContainer';
|
||||||
import { useNativeFilters } from './state';
|
import { useNativeFilters } from './state';
|
||||||
import DashboardWrapper from './DashboardWrapper';
|
import DashboardWrapper from './DashboardWrapper';
|
||||||
|
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
|
||||||
|
|
||||||
// @z-index-above-dashboard-charts + 1 = 11
|
// @z-index-above-dashboard-charts + 1 = 11
|
||||||
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
|
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
|
||||||
@@ -386,6 +389,17 @@ const DashboardBuilder = () => {
|
|||||||
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
|
({ 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(
|
const handleChangeTab = useCallback(
|
||||||
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
|
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
|
||||||
dispatch(setDirectPathToChild(pathToTabIndex));
|
dispatch(setDirectPathToChild(pathToTabIndex));
|
||||||
@@ -510,7 +524,15 @@ const DashboardBuilder = () => {
|
|||||||
const renderDraggableContent = useCallback(
|
const renderDraggableContent = useCallback(
|
||||||
({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
|
({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
|
||||||
<div>
|
<div>
|
||||||
{!hideDashboardHeader && <DashboardHeader />}
|
{!hideDashboardHeader &&
|
||||||
|
(isTemplate ? (
|
||||||
|
<TemplatePreviewHeader
|
||||||
|
dashboardTitle={dashboardTitle}
|
||||||
|
dashboardId={dashboardNumericId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DashboardHeader />
|
||||||
|
))}
|
||||||
{showFilterBar &&
|
{showFilterBar &&
|
||||||
filterBarOrientation === FilterBarOrientation.Horizontal && (
|
filterBarOrientation === FilterBarOrientation.Horizontal && (
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -519,7 +541,8 @@ const DashboardBuilder = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
||||||
{!isReport && topLevelTabs && !uiConfig.hideNav && (
|
{/* Hide tabs editing controls for templates */}
|
||||||
|
{!isReport && topLevelTabs && !uiConfig.hideNav && !isTemplate && (
|
||||||
<WithPopoverMenu
|
<WithPopoverMenu
|
||||||
shouldFocus={shouldFocusTabs}
|
shouldFocus={shouldFocusTabs}
|
||||||
menuItems={[
|
menuItems={[
|
||||||
@@ -544,16 +567,31 @@ const DashboardBuilder = () => {
|
|||||||
/>
|
/>
|
||||||
</WithPopoverMenu>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
nativeFiltersEnabled,
|
showFilterBar,
|
||||||
filterBarOrientation,
|
filterBarOrientation,
|
||||||
editMode,
|
editMode,
|
||||||
handleChangeTab,
|
handleChangeTab,
|
||||||
handleDeleteTopLevelTabs,
|
handleDeleteTopLevelTabs,
|
||||||
hideDashboardHeader,
|
hideDashboardHeader,
|
||||||
isReport,
|
isReport,
|
||||||
|
isTemplate,
|
||||||
|
dashboardTitle,
|
||||||
|
dashboardNumericId,
|
||||||
topLevelTabs,
|
topLevelTabs,
|
||||||
uiConfig.hideNav,
|
uiConfig.hideNav,
|
||||||
],
|
],
|
||||||
@@ -646,8 +684,10 @@ const DashboardBuilder = () => {
|
|||||||
</Droppable>
|
</Droppable>
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
<StyledContent fullSizeChartId={fullSizeChartId}>
|
<StyledContent fullSizeChartId={fullSizeChartId}>
|
||||||
|
{/* Don't show empty state with edit button for templates (they can't be edited) */}
|
||||||
{!editMode &&
|
{!editMode &&
|
||||||
!topLevelTabs &&
|
!topLevelTabs &&
|
||||||
|
!isTemplate &&
|
||||||
dashboardLayout[DASHBOARD_GRID_ID]?.children?.length === 0 && (
|
dashboardLayout[DASHBOARD_GRID_ID]?.children?.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('There are no charts added to this dashboard')}
|
title={t('There are no charts added to this dashboard')}
|
||||||
|
|||||||
@@ -522,10 +522,15 @@ const Header = () => {
|
|||||||
|
|
||||||
const metadataBar = useDashboardMetadataBar(dashboardInfo);
|
const metadataBar = useDashboardMetadataBar(dashboardInfo);
|
||||||
|
|
||||||
|
// Templates cannot be edited - block edit permission
|
||||||
|
const isTemplate = !!dashboardInfo.metadata?.is_template;
|
||||||
const userCanEdit =
|
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 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 =
|
const userCanCurate =
|
||||||
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
|
isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
|
||||||
findPermission('can_set_embedded', 'Dashboard', user.roles);
|
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 { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||||
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
||||||
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
|
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
|
||||||
|
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
|
||||||
|
|
||||||
const RefreshTooltip = styled.div`
|
const RefreshTooltip = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
@@ -168,14 +169,18 @@ const SliceHeaderControls = (
|
|||||||
);
|
);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const isTemplate = useSelector(selectIsTemplateDashboard);
|
||||||
|
// Templates are read-only - disable editing capabilities
|
||||||
const canEditCrossFilters =
|
const canEditCrossFilters =
|
||||||
|
!isTemplate &&
|
||||||
useSelector<RootState, boolean>(
|
useSelector<RootState, boolean>(
|
||||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||||
) &&
|
) &&
|
||||||
getChartMetadataRegistry()
|
getChartMetadataRegistry()
|
||||||
.get(props.slice.viz_type)
|
.get(props.slice.viz_type)
|
||||||
?.behaviors?.includes(Behavior.InteractiveChart);
|
?.behaviors?.includes(Behavior.InteractiveChart);
|
||||||
const canExplore = props.supersetCanExplore;
|
// Hide "Edit chart" for templates
|
||||||
|
const canExplore = !isTemplate && props.supersetCanExplore;
|
||||||
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
|
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
|
||||||
|
|
||||||
const datasetResource = useDatasetDrillInfo(
|
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 ChartCustomizationModal from '../../ChartCustomization/ChartCustomizationModal';
|
||||||
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
||||||
import FilterConfigurationLink from '../FilterConfigurationLink';
|
import FilterConfigurationLink from '../FilterConfigurationLink';
|
||||||
|
import { selectIsTemplateDashboard } from 'src/dashboard/selectors';
|
||||||
|
|
||||||
type SelectedKey = FilterBarOrientation | string | number;
|
type SelectedKey = FilterBarOrientation | string | number;
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ const FilterBarSettings = () => {
|
|||||||
const canEdit = useSelector<RootState, boolean>(
|
const canEdit = useSelector<RootState, boolean>(
|
||||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||||
);
|
);
|
||||||
|
const isTemplate = useSelector(selectIsTemplateDashboard);
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
const filterValues = useMemo(() => Object.values(filters), [filters]);
|
const filterValues = useMemo(() => Object.values(filters), [filters]);
|
||||||
const dashboardId = useSelector<RootState, number>(
|
const dashboardId = useSelector<RootState, number>(
|
||||||
@@ -258,7 +260,8 @@ const FilterBarSettings = () => {
|
|||||||
filterValues,
|
filterValues,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!menuItems.length || !canEdit) {
|
// Hide settings for templates - templates are read-only
|
||||||
|
if (!menuItems.length || !canEdit || isTemplate) {
|
||||||
return null;
|
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>;
|
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 = {
|
export type DashboardInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
common: {
|
common: {
|
||||||
@@ -150,6 +165,8 @@ export type DashboardInfo = {
|
|||||||
map_label_colors: JsonObject;
|
map_label_colors: JsonObject;
|
||||||
cross_filters_enabled: boolean;
|
cross_filters_enabled: boolean;
|
||||||
chart_customization_config?: ChartCustomizationItem[];
|
chart_customization_config?: ChartCustomizationItem[];
|
||||||
|
// Template metadata is stored in the nested template_info structure
|
||||||
|
template_info?: TemplateInfo;
|
||||||
};
|
};
|
||||||
crossFiltersEnabled: boolean;
|
crossFiltersEnabled: boolean;
|
||||||
filterBarOrientation: FilterBarOrientation;
|
filterBarOrientation: FilterBarOrientation;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { AlteredSliceTag } from 'src/components';
|
import { AlteredSliceTag } from 'src/components';
|
||||||
import { logging, SupersetClient, t } from '@superset-ui/core';
|
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 { chartPropShape } from 'src/dashboard/util/propShapes';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||||
@@ -102,6 +102,9 @@ export const ExploreChartHeader = ({
|
|||||||
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
|
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
|
||||||
const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
|
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 updateCategoricalNamespace = useCallback(async () => {
|
||||||
const { dashboards } = metadata || {};
|
const { dashboards } = metadata || {};
|
||||||
const dashboard =
|
const dashboard =
|
||||||
@@ -185,6 +188,7 @@ export const ExploreChartHeader = ({
|
|||||||
metadata?.dashboards,
|
metadata?.dashboards,
|
||||||
showReportModal,
|
showReportModal,
|
||||||
setCurrentReportDeleting,
|
setCurrentReportDeleting,
|
||||||
|
isTemplateChart,
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadataBar = useExploreMetadataBar(metadata, slice);
|
const metadataBar = useExploreMetadataBar(metadata, slice);
|
||||||
@@ -237,13 +241,22 @@ export const ExploreChartHeader = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{isTemplateChart && (
|
||||||
|
<Alert type="info" banner closable={false}>
|
||||||
|
{t(
|
||||||
|
'This chart belongs to a template dashboard and cannot be modified.',
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<PageHeaderWithActions
|
<PageHeaderWithActions
|
||||||
editableTitleProps={{
|
editableTitleProps={{
|
||||||
title: sliceName ?? '',
|
title: sliceName ?? '',
|
||||||
|
// Disable title editing for template charts
|
||||||
canEdit:
|
canEdit:
|
||||||
!slice ||
|
!isTemplateChart &&
|
||||||
canOverwrite ||
|
(!slice ||
|
||||||
(slice?.owners || []).includes(user?.userId),
|
canOverwrite ||
|
||||||
|
(slice?.owners || []).includes(user?.userId)),
|
||||||
onSave: actions.updateChartTitle,
|
onSave: actions.updateChartTitle,
|
||||||
placeholder: t('Add the name of the chart'),
|
placeholder: t('Add the name of the chart'),
|
||||||
label: t('Chart title'),
|
label: t('Chart title'),
|
||||||
@@ -275,27 +288,30 @@ export const ExploreChartHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
rightPanelAdditionalItems={
|
rightPanelAdditionalItems={
|
||||||
<Tooltip
|
// Hide save button for template charts
|
||||||
title={
|
isTemplateChart ? null : (
|
||||||
saveDisabled
|
<Tooltip
|
||||||
? t('Add required control values to save chart')
|
title={
|
||||||
: null
|
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
|
{/* needed to wrap button in a div - antd tooltip doesn't work with disabled button */}
|
||||||
buttonStyle="secondary"
|
<div>
|
||||||
onClick={showModal}
|
<Button
|
||||||
disabled={saveDisabled}
|
buttonStyle="secondary"
|
||||||
data-test="query-save-button"
|
onClick={showModal}
|
||||||
css={saveButtonStyles}
|
disabled={saveDisabled}
|
||||||
icon={<Icons.SaveOutlined />}
|
data-test="query-save-button"
|
||||||
>
|
css={saveButtonStyles}
|
||||||
{t('Save')}
|
icon={<Icons.SaveOutlined />}
|
||||||
</Button>
|
>
|
||||||
</div>
|
{t('Save')}
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
additionalActionsMenu={menu}
|
additionalActionsMenu={menu}
|
||||||
menuDropdownProps={{
|
menuDropdownProps={{
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
dashboards,
|
dashboards,
|
||||||
showReportModal,
|
showReportModal,
|
||||||
setCurrentReportDeleting,
|
setCurrentReportDeleting,
|
||||||
...rest
|
isTemplateChart = false,
|
||||||
) => {
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { addDangerToast, addSuccessToast } = useToasts();
|
const { addDangerToast, addSuccessToast } = useToasts();
|
||||||
@@ -435,8 +435,8 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const menuItems = [];
|
const menuItems = [];
|
||||||
|
|
||||||
// Edit chart properties
|
// Edit chart properties - hidden for template charts
|
||||||
if (slice) {
|
if (slice && !isTemplateChart) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||||
label: t('Edit chart properties'),
|
label: t('Edit chart properties'),
|
||||||
@@ -820,8 +820,8 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
// Divider
|
// Divider
|
||||||
menuItems.push({ type: 'divider' });
|
menuItems.push({ type: 'divider' });
|
||||||
|
|
||||||
// Report menu item
|
// Report menu item - hidden for template charts
|
||||||
if (reportMenuItem) {
|
if (reportMenuItem && !isTemplateChart) {
|
||||||
menuItems.push(reportMenuItem);
|
menuItems.push(reportMenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,7 +857,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Menu selectable={false} items={menuItems} {...rest} />;
|
return <Menu selectable={false} items={menuItems} />;
|
||||||
}, [
|
}, [
|
||||||
addDangerToast,
|
addDangerToast,
|
||||||
canDownloadCSV,
|
canDownloadCSV,
|
||||||
@@ -882,6 +882,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
theme.sizeUnit,
|
theme.sizeUnit,
|
||||||
ownState,
|
ownState,
|
||||||
hasExportCurrentView,
|
hasExportCurrentView,
|
||||||
|
isTemplateChart,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Return streaming modal state and handlers for parent to render
|
// Return streaming modal state and handlers for parent to render
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export type DatasetObject = {
|
|||||||
metrics: MetricObject[];
|
metrics: MetricObject[];
|
||||||
extra?: string;
|
extra?: string;
|
||||||
is_managed_externally: boolean;
|
is_managed_externally: boolean;
|
||||||
|
is_template_dataset: boolean;
|
||||||
normalize_columns: boolean;
|
normalize_columns: boolean;
|
||||||
always_filter_main_dttm: boolean;
|
always_filter_main_dttm: boolean;
|
||||||
type: DatasourceType;
|
type: DatasourceType;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import { DeleteModal, Loading } from '@superset-ui/core/components';
|
|||||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||||
import DashboardCard from 'src/features/dashboards/DashboardCard';
|
import DashboardCard from 'src/features/dashboards/DashboardCard';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import { navigateTo } from 'src/utils/navigationUtils';
|
|
||||||
import EmptyState from './EmptyState';
|
import EmptyState from './EmptyState';
|
||||||
import SubMenu from './SubMenu';
|
import SubMenu from './SubMenu';
|
||||||
import { WelcomeTable } from './types';
|
import { WelcomeTable } from './types';
|
||||||
@@ -200,7 +199,7 @@ function DashboardTable({
|
|||||||
name: t('Dashboard'),
|
name: t('Dashboard'),
|
||||||
buttonStyle: 'secondary',
|
buttonStyle: 'secondary',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigateTo('/dashboard/new', { assign: true });
|
history.push('/dashboard/templates/');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { TableTab } from 'src/views/CRUD/types';
|
import { TableTab } from 'src/views/CRUD/types';
|
||||||
import { t } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
import { styled } from '@apache-superset/core/ui';
|
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 { makeUrl } from 'src/utils/pathUtils';
|
||||||
import { WelcomeTable } from './types';
|
import { WelcomeTable } from './types';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ const LABELS = {
|
|||||||
const REDIRECTS = {
|
const REDIRECTS = {
|
||||||
create: {
|
create: {
|
||||||
[WelcomeTable.Charts]: '/chart/add',
|
[WelcomeTable.Charts]: '/chart/add',
|
||||||
[WelcomeTable.Dashboards]: '/dashboard/new',
|
[WelcomeTable.Dashboards]: '/dashboard/templates/',
|
||||||
[WelcomeTable.SavedQueries]: makeUrl('/sqllab?new=true'),
|
[WelcomeTable.SavedQueries]: makeUrl('/sqllab?new=true'),
|
||||||
},
|
},
|
||||||
viewAll: {
|
viewAll: {
|
||||||
@@ -75,6 +75,8 @@ export interface EmptyStateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
|
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const getActionButton = () => {
|
const getActionButton = () => {
|
||||||
if (tableName === WelcomeTable.Recents) {
|
if (tableName === WelcomeTable.Recents) {
|
||||||
return null;
|
return null;
|
||||||
@@ -93,7 +95,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) {
|
|||||||
<Button
|
<Button
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigateTo(url);
|
history.push(url);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFavorite
|
{isFavorite
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const RightMenu = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('Dashboard'),
|
label: t('Dashboard'),
|
||||||
url: '/dashboard/new',
|
url: '/dashboard/templates/',
|
||||||
icon: (
|
icon: (
|
||||||
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
|
<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 { styled } from '@apache-superset/core/ui';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import {
|
import {
|
||||||
createFetchRelated,
|
createFetchRelated,
|
||||||
@@ -73,7 +73,6 @@ import DashboardCard from 'src/features/dashboards/DashboardCard';
|
|||||||
import { DashboardStatus } from 'src/features/dashboards/types';
|
import { DashboardStatus } from 'src/features/dashboards/types';
|
||||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||||
import { findPermission } from 'src/utils/findPermission';
|
import { findPermission } from 'src/utils/findPermission';
|
||||||
import { navigateTo } from 'src/utils/navigationUtils';
|
|
||||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
@@ -147,6 +146,7 @@ function DashboardList(props: DashboardListProps) {
|
|||||||
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
|
||||||
state => state.user,
|
state => state.user,
|
||||||
);
|
);
|
||||||
|
const history = useHistory();
|
||||||
const canReadTag = findPermission('can_read', 'Tag', roles);
|
const canReadTag = findPermission('can_read', 'Tag', roles);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -722,7 +722,7 @@ function DashboardList(props: DashboardListProps) {
|
|||||||
name: t('Dashboard'),
|
name: t('Dashboard'),
|
||||||
buttonStyle: 'primary',
|
buttonStyle: 'primary',
|
||||||
onClick: () => {
|
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>;
|
owners: Array<Owner>;
|
||||||
schema: string;
|
schema: string;
|
||||||
table_name: string;
|
table_name: string;
|
||||||
|
is_template_dataset?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VirtualDataset extends Dataset {
|
interface VirtualDataset extends Dataset {
|
||||||
@@ -416,9 +417,37 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||||||
{
|
{
|
||||||
Cell: ({ row: { original } }: any) => {
|
Cell: ({ row: { original } }: any) => {
|
||||||
// Verify owner or isAdmin
|
// Verify owner or isAdmin
|
||||||
const allowEdit =
|
const isOwnerOrAdmin =
|
||||||
original.owners.map((o: Owner) => o.id).includes(user.userId) ||
|
original.owners.map((o: Owner) => o.id).includes(user.userId) ||
|
||||||
isUserAdmin(user);
|
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 handleEdit = () => openDatasetEditModal(original);
|
||||||
const handleDelete = () => openDatasetDeleteModal(original);
|
const handleDelete = () => openDatasetDeleteModal(original);
|
||||||
@@ -432,14 +461,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
id="delete-action-tooltip"
|
id="delete-action-tooltip"
|
||||||
title={t('Delete')}
|
title={getDeleteTooltip()}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="action-button"
|
className={`action-button ${allowDelete ? '' : 'disabled'}`}
|
||||||
onClick={handleDelete}
|
onClick={allowDelete ? handleDelete : undefined}
|
||||||
>
|
>
|
||||||
<Icons.DeleteOutlined iconSize="l" />
|
<Icons.DeleteOutlined iconSize="l" />
|
||||||
</span>
|
</span>
|
||||||
@@ -464,13 +493,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
id="edit-action-tooltip"
|
id="edit-action-tooltip"
|
||||||
title={
|
title={getEditTooltip()}
|
||||||
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.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<span
|
<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;
|
form_data?: QueryFormData;
|
||||||
query_context?: object;
|
query_context?: object;
|
||||||
is_managed_externally: boolean;
|
is_managed_externally: boolean;
|
||||||
|
is_template_chart?: boolean;
|
||||||
owners?: number[];
|
owners?: number[];
|
||||||
datasource?: string;
|
datasource?: string;
|
||||||
datasource_id?: number;
|
datasource_id?: number;
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const ChartCreation = lazy(
|
|||||||
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
|
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DatasourceConnector = lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "DatasourceConnector" */ 'src/pages/DatasourceConnector'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const AnnotationLayerList = lazy(
|
const AnnotationLayerList = lazy(
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
@@ -77,6 +84,13 @@ const Dashboard = lazy(
|
|||||||
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
|
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DashboardTemplates = lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "DashboardTemplates" */ 'src/pages/DashboardTemplates'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const DatabaseList = lazy(
|
const DatabaseList = lazy(
|
||||||
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
|
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
|
||||||
);
|
);
|
||||||
@@ -203,6 +217,10 @@ export const routes: Routes = [
|
|||||||
path: '/dashboard/list/',
|
path: '/dashboard/list/',
|
||||||
Component: DashboardList,
|
Component: DashboardList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard/templates/',
|
||||||
|
Component: DashboardTemplates,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/superset/dashboard/:idOrSlug/',
|
path: '/superset/dashboard/:idOrSlug/',
|
||||||
Component: Dashboard,
|
Component: Dashboard,
|
||||||
@@ -215,6 +233,14 @@ export const routes: Routes = [
|
|||||||
path: '/chart/list/',
|
path: '/chart/list/',
|
||||||
Component: ChartList,
|
Component: ChartList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/datasource-connector/',
|
||||||
|
Component: DatasourceConnector,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/datasource-connector/loading/:runId',
|
||||||
|
Component: DatasourceConnector,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/tablemodelview/list/',
|
path: '/tablemodelview/list/',
|
||||||
Component: DatasetList,
|
Component: DatasetList,
|
||||||
|
|||||||
@@ -1670,6 +1670,11 @@ class ChartGetResponseSchema(Schema):
|
|||||||
viz_type = fields.String()
|
viz_type = fields.String()
|
||||||
query_context = fields.String()
|
query_context = fields.String()
|
||||||
is_managed_externally = fields.Boolean()
|
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)
|
tags = fields.Nested(TagSchema, many=True)
|
||||||
owners = fields.List(fields.Nested(UserSchema))
|
owners = fields.List(fields.Nested(UserSchema))
|
||||||
dashboards = fields.List(fields.Nested(DashboardSchema))
|
dashboards = fields.List(fields.Nested(DashboardSchema))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from superset.commands.chart.exceptions import (
|
|||||||
ChartDeleteFailedReportsExistError,
|
ChartDeleteFailedReportsExistError,
|
||||||
ChartForbiddenError,
|
ChartForbiddenError,
|
||||||
ChartNotFoundError,
|
ChartNotFoundError,
|
||||||
|
ChartTemplateDeleteForbiddenError,
|
||||||
)
|
)
|
||||||
from superset.daos.chart import ChartDAO
|
from superset.daos.chart import ChartDAO
|
||||||
from superset.daos.report import ReportScheduleDAO
|
from superset.daos.report import ReportScheduleDAO
|
||||||
@@ -53,6 +54,12 @@ class DeleteChartCommand(BaseCommand):
|
|||||||
self._models = ChartDAO.find_by_ids(self._model_ids)
|
self._models = ChartDAO.find_by_ids(self._model_ids)
|
||||||
if not self._models or len(self._models) != len(self._model_ids):
|
if not self._models or len(self._models) != len(self._model_ids):
|
||||||
raise ChartNotFoundError()
|
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
|
# Check there are no associated ReportSchedules
|
||||||
if reports := ReportScheduleDAO.find_by_chart_ids(self._model_ids):
|
if reports := ReportScheduleDAO.find_by_chart_ids(self._model_ids):
|
||||||
report_names = [report.name for report in reports]
|
report_names = [report.name for report in reports]
|
||||||
|
|||||||
@@ -162,3 +162,11 @@ class ChartFaveError(CommandException):
|
|||||||
|
|
||||||
class ChartUnfaveError(CommandException):
|
class ChartUnfaveError(CommandException):
|
||||||
message = _("Error unfaving chart")
|
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,
|
ChartForbiddenError,
|
||||||
ChartInvalidError,
|
ChartInvalidError,
|
||||||
ChartNotFoundError,
|
ChartNotFoundError,
|
||||||
|
ChartTemplateUpdateForbiddenError,
|
||||||
ChartUpdateFailedError,
|
ChartUpdateFailedError,
|
||||||
DashboardsNotFoundValidationError,
|
DashboardsNotFoundValidationError,
|
||||||
DatasourceTypeUpdateRequiredValidationError,
|
DatasourceTypeUpdateRequiredValidationError,
|
||||||
@@ -112,6 +113,10 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
|||||||
if not self._model:
|
if not self._model:
|
||||||
raise ChartNotFoundError()
|
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
|
# Check and update ownership; when only updating query context we ignore
|
||||||
# ownership so the update can be performed by report workers
|
# ownership so the update can be performed by report workers
|
||||||
if not is_query_context_update(self._properties):
|
if not is_query_context_update(self._properties):
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from superset.commands.dashboard.exceptions import (
|
|||||||
DashboardDeleteFailedReportsExistError,
|
DashboardDeleteFailedReportsExistError,
|
||||||
DashboardForbiddenError,
|
DashboardForbiddenError,
|
||||||
DashboardNotFoundError,
|
DashboardNotFoundError,
|
||||||
|
DashboardTemplateDeleteForbiddenError,
|
||||||
)
|
)
|
||||||
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
|
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
|
||||||
from superset.daos.report import ReportScheduleDAO
|
from superset.daos.report import ReportScheduleDAO
|
||||||
@@ -67,6 +68,12 @@ class DeleteDashboardCommand(BaseCommand):
|
|||||||
self._models = DashboardDAO.find_by_ids(self._model_ids)
|
self._models = DashboardDAO.find_by_ids(self._model_ids)
|
||||||
if not self._models or len(self._models) != len(self._model_ids):
|
if not self._models or len(self._models) != len(self._model_ids):
|
||||||
raise DashboardNotFoundError()
|
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
|
# Check there are no associated ReportSchedules
|
||||||
if reports := ReportScheduleDAO.find_by_dashboard_ids(self._model_ids):
|
if reports := ReportScheduleDAO.find_by_dashboard_ids(self._model_ids):
|
||||||
report_names = [report.name for report in reports]
|
report_names = [report.name for report in reports]
|
||||||
|
|||||||
@@ -100,3 +100,11 @@ class DashboardFaveError(CommandInvalidError):
|
|||||||
|
|
||||||
class DashboardUnfaveError(CommandInvalidError):
|
class DashboardUnfaveError(CommandInvalidError):
|
||||||
message = _("Dashboard cannot be unfavorited.")
|
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.database.importers.v1.utils import import_database
|
||||||
from superset.commands.dataset.importers.v1.utils import import_dataset
|
from superset.commands.dataset.importers.v1.utils import import_dataset
|
||||||
from superset.commands.importers.v1 import ImportModelsCommand
|
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.theme.import_themes import import_theme
|
||||||
from superset.commands.utils import update_chart_config_dataset
|
from superset.commands.utils import update_chart_config_dataset
|
||||||
|
from superset.connectors.sqla.models import SqlaTable
|
||||||
from superset.daos.dashboard import DashboardDAO
|
from superset.daos.dashboard import DashboardDAO
|
||||||
from superset.dashboards.schemas import ImportV1DashboardSchema
|
from superset.dashboards.schemas import ImportV1DashboardSchema
|
||||||
from superset.databases.schemas import ImportV1DatabaseSchema
|
from superset.databases.schemas import ImportV1DatabaseSchema
|
||||||
@@ -77,6 +78,24 @@ class ImportDashboardsCommand(ImportModelsCommand):
|
|||||||
contents: dict[str, Any] | None = None,
|
contents: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
contents = {} if contents is None else contents
|
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
|
# discover charts, datasets, and themes associated with dashboards
|
||||||
chart_uuids: set[str] = set()
|
chart_uuids: set[str] = set()
|
||||||
dataset_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
|
# Theme not found, set to None for graceful fallback
|
||||||
config["theme_id"] = None
|
config["theme_id"] = None
|
||||||
del config["theme_uuid"]
|
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)
|
dashboard = import_dashboard(config, overwrite=overwrite)
|
||||||
dashboards.append(dashboard)
|
dashboards.append(dashboard)
|
||||||
for uuid in find_chart_uuids(config["position"]):
|
for uuid in find_chart_uuids(config["position"]):
|
||||||
@@ -207,6 +231,37 @@ class ImportDashboardsCommand(ImportModelsCommand):
|
|||||||
for dashboard in dashboards:
|
for dashboard in dashboards:
|
||||||
migrate_dashboard(dashboard)
|
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.
|
# Remove all obsolete filter-box charts.
|
||||||
for chart in charts:
|
for chart in charts:
|
||||||
if chart.viz_type == "filter_box":
|
if chart.viz_type == "filter_box":
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from superset.commands.dashboard.exceptions import (
|
|||||||
DashboardNativeFiltersUpdateFailedError,
|
DashboardNativeFiltersUpdateFailedError,
|
||||||
DashboardNotFoundError,
|
DashboardNotFoundError,
|
||||||
DashboardSlugExistsValidationError,
|
DashboardSlugExistsValidationError,
|
||||||
|
DashboardTemplateUpdateForbiddenError,
|
||||||
DashboardUpdateFailedError,
|
DashboardUpdateFailedError,
|
||||||
)
|
)
|
||||||
from superset.commands.utils import populate_roles, update_tags, validate_tags
|
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)
|
self._model = DashboardDAO.find_by_id(self._model_id)
|
||||||
if not self._model:
|
if not self._model:
|
||||||
raise DashboardNotFoundError()
|
raise DashboardNotFoundError()
|
||||||
|
|
||||||
|
# Templates cannot be modified - reuse the security_manager helper
|
||||||
|
if security_manager._is_template_dashboard(self._model):
|
||||||
|
raise DashboardTemplateUpdateForbiddenError()
|
||||||
|
|
||||||
# Check ownership
|
# Check ownership
|
||||||
try:
|
try:
|
||||||
security_manager.raise_for_ownership(self._model)
|
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,
|
DatasetDeleteFailedError,
|
||||||
DatasetForbiddenError,
|
DatasetForbiddenError,
|
||||||
DatasetNotFoundError,
|
DatasetNotFoundError,
|
||||||
|
DatasetTemplateDeleteForbiddenError,
|
||||||
)
|
)
|
||||||
from superset.connectors.sqla.models import SqlaTable
|
from superset.connectors.sqla.models import SqlaTable
|
||||||
from superset.daos.dataset import DatasetDAO
|
from superset.daos.dataset import DatasetDAO
|
||||||
@@ -49,6 +50,12 @@ class DeleteDatasetCommand(BaseCommand):
|
|||||||
self._models = DatasetDAO.find_by_ids(self._model_ids)
|
self._models = DatasetDAO.find_by_ids(self._model_ids)
|
||||||
if not self._models or len(self._models) != len(self._model_ids):
|
if not self._models or len(self._models) != len(self._model_ids):
|
||||||
raise DatasetNotFoundError()
|
raise DatasetNotFoundError()
|
||||||
|
|
||||||
|
# Template datasets cannot be deleted
|
||||||
|
for model in self._models:
|
||||||
|
if security_manager._is_template_dataset(model):
|
||||||
|
raise DatasetTemplateDeleteForbiddenError()
|
||||||
|
|
||||||
# Check ownership
|
# Check ownership
|
||||||
for model in self._models:
|
for model in self._models:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -205,3 +205,11 @@ class DatasetForbiddenDataURI(ImportFailedError): # noqa: N818
|
|||||||
class WarmUpCacheTableNotFoundError(CommandException):
|
class WarmUpCacheTableNotFoundError(CommandException):
|
||||||
status = 404
|
status = 404
|
||||||
message = _("The provided table was not found in the provided database")
|
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,
|
DatasetMetricsExistsValidationError,
|
||||||
DatasetMetricsNotFoundValidationError,
|
DatasetMetricsNotFoundValidationError,
|
||||||
DatasetNotFoundError,
|
DatasetNotFoundError,
|
||||||
|
DatasetTemplateUpdateForbiddenError,
|
||||||
DatasetUpdateFailedError,
|
DatasetUpdateFailedError,
|
||||||
MultiCatalogDisabledValidationError,
|
MultiCatalogDisabledValidationError,
|
||||||
)
|
)
|
||||||
@@ -92,6 +93,10 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
|||||||
if not self._model:
|
if not self._model:
|
||||||
raise DatasetNotFoundError()
|
raise DatasetNotFoundError()
|
||||||
|
|
||||||
|
# Template datasets cannot be modified
|
||||||
|
if security_manager._is_template_dataset(self._model):
|
||||||
|
raise DatasetTemplateUpdateForbiddenError()
|
||||||
|
|
||||||
# Check permission to update the dataset
|
# Check permission to update the dataset
|
||||||
try:
|
try:
|
||||||
security_manager.raise_for_ownership(self._model)
|
security_manager.raise_for_ownership(self._model)
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ class MetadataSchema(Schema):
|
|||||||
version = fields.String(required=True, validate=validate.Equal(IMPORT_VERSION))
|
version = fields.String(required=True, validate=validate.Equal(IMPORT_VERSION))
|
||||||
type = fields.String(required=False)
|
type = fields.String(required=False)
|
||||||
timestamp = fields.DateTime()
|
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]:
|
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.thumbnails",
|
||||||
"superset.tasks.cache",
|
"superset.tasks.cache",
|
||||||
"superset.tasks.slack",
|
"superset.tasks.slack",
|
||||||
|
"superset.tasks.database_analyzer",
|
||||||
|
"superset.tasks.dashboard_generator",
|
||||||
)
|
)
|
||||||
result_backend = "db+sqlite:///celery_results.sqlite"
|
result_backend = "db+sqlite:///celery_results.sqlite"
|
||||||
worker_prefetch_multiplier = 1
|
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))
|
schema_perm = Column(String(1000))
|
||||||
catalog_perm = Column(String(1000), nullable=True, default=None)
|
catalog_perm = Column(String(1000), nullable=True, default=None)
|
||||||
is_managed_externally = Column(Boolean, nullable=False, default=False)
|
is_managed_externally = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_template_dataset = Column(Boolean, nullable=False, default=False)
|
||||||
external_url = Column(Text, nullable=True)
|
external_url = Column(Text, nullable=True)
|
||||||
|
|
||||||
sql: str | None = None
|
sql: str | None = None
|
||||||
|
|||||||
@@ -105,6 +105,60 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
|||||||
def get_charts_for_dashboard(id_or_slug: str) -> list[Slice]:
|
def get_charts_for_dashboard(id_or_slug: str) -> list[Slice]:
|
||||||
return DashboardDAO.get_by_id_or_slug(id_or_slug).slices
|
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
|
@staticmethod
|
||||||
def get_dashboard_changed_on(id_or_slug_or_dashboard: str | Dashboard) -> datetime:
|
def get_dashboard_changed_on(id_or_slug_or_dashboard: str | Dashboard) -> datetime:
|
||||||
"""
|
"""
|
||||||
@@ -295,17 +349,38 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
|||||||
.all()
|
.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
|
@classmethod
|
||||||
def copy_dashboard(
|
def copy_dashboard(
|
||||||
cls, original_dash: Dashboard, data: dict[str, Any]
|
cls,
|
||||||
|
original_dash: Dashboard,
|
||||||
|
data: dict[str, Any],
|
||||||
|
owner: Any | None = None,
|
||||||
) -> Dashboard:
|
) -> Dashboard:
|
||||||
if is_feature_enabled("DASHBOARD_RBAC") and not security_manager.is_owner(
|
# Skip RBAC check if dashboard is a template or no user context (Celery)
|
||||||
original_dash
|
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()
|
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 = Dashboard()
|
||||||
dash.owners = [g.user] if g.user else []
|
dash.owners = [effective_owner] if effective_owner else []
|
||||||
dash.dashboard_title = data["dashboard_title"]
|
dash.dashboard_title = data["dashboard_title"]
|
||||||
dash.css = data.get("css")
|
dash.css = data.get("css")
|
||||||
|
|
||||||
@@ -315,7 +390,7 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
|||||||
# Duplicating slices as well, mapping old ids to new ones
|
# Duplicating slices as well, mapping old ids to new ones
|
||||||
for slc in original_dash.slices:
|
for slc in original_dash.slices:
|
||||||
new_slice = slc.clone()
|
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.add(new_slice)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
new_slice.dashboards.append(dash)
|
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,
|
DashboardPostSchema,
|
||||||
DashboardPutSchema,
|
DashboardPutSchema,
|
||||||
DashboardScreenshotPostSchema,
|
DashboardScreenshotPostSchema,
|
||||||
|
DashboardTemplateSchema,
|
||||||
EmbeddedDashboardConfigSchema,
|
EmbeddedDashboardConfigSchema,
|
||||||
EmbeddedDashboardResponseSchema,
|
EmbeddedDashboardResponseSchema,
|
||||||
get_delete_ids_schema,
|
get_delete_ids_schema,
|
||||||
@@ -238,6 +239,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
|||||||
"screenshot",
|
"screenshot",
|
||||||
"put_filters",
|
"put_filters",
|
||||||
"put_colors",
|
"put_colors",
|
||||||
|
"templates",
|
||||||
}
|
}
|
||||||
resource_name = "dashboard"
|
resource_name = "dashboard"
|
||||||
allow_browser_login = True
|
allow_browser_login = True
|
||||||
@@ -644,6 +646,84 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
|||||||
except DashboardNotFoundError:
|
except DashboardNotFoundError:
|
||||||
return self.response_404()
|
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",))
|
@expose("/", methods=("POST",))
|
||||||
@protect()
|
@protect()
|
||||||
@safe
|
@safe
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ class DashboardJSONMetadataSchema(Schema):
|
|||||||
remote_id = fields.Integer()
|
remote_id = fields.Integer()
|
||||||
filter_bar_orientation = fields.Str(allow_none=True)
|
filter_bar_orientation = fields.Str(allow_none=True)
|
||||||
native_filter_migration = fields.Dict()
|
native_filter_migration = fields.Dict()
|
||||||
|
# Template metadata is stored in the nested template_info structure
|
||||||
|
template_info = fields.Dict(allow_none=True)
|
||||||
|
|
||||||
@pre_load
|
@pre_load
|
||||||
def remove_show_native_filters( # pylint: disable=unused-argument
|
def remove_show_native_filters( # pylint: disable=unused-argument
|
||||||
@@ -187,6 +189,23 @@ class DashboardJSONMetadataSchema(Schema):
|
|||||||
return data
|
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):
|
class UserSchema(Schema):
|
||||||
id = fields.Int()
|
id = fields.Int()
|
||||||
username = fields.String()
|
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",
|
"sql",
|
||||||
"table_name",
|
"table_name",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"is_template_dataset",
|
||||||
]
|
]
|
||||||
list_select_columns = list_columns + ["changed_on", "changed_by_fk"]
|
list_select_columns = list_columns + ["changed_on", "changed_by_fk"]
|
||||||
order_columns = [
|
order_columns = [
|
||||||
@@ -227,6 +228,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"database.allow_multi_catalog",
|
"database.allow_multi_catalog",
|
||||||
"columns.advanced_data_type",
|
"columns.advanced_data_type",
|
||||||
"is_managed_externally",
|
"is_managed_externally",
|
||||||
|
"is_template_dataset",
|
||||||
"uid",
|
"uid",
|
||||||
"uuid",
|
"uuid",
|
||||||
"datasource_name",
|
"datasource_name",
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ class ImportV1ColumnSchema(Schema):
|
|||||||
expression = fields.String(allow_none=True)
|
expression = fields.String(allow_none=True)
|
||||||
description = fields.String(allow_none=True)
|
description = fields.String(allow_none=True)
|
||||||
python_date_format = fields.String(allow_none=True)
|
python_date_format = fields.String(allow_none=True)
|
||||||
|
datetime_format = fields.String(allow_none=True)
|
||||||
|
|
||||||
|
|
||||||
class ImportMetricCurrencySchema(Schema):
|
class ImportMetricCurrencySchema(Schema):
|
||||||
|
|||||||
@@ -158,9 +158,11 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||||||
from superset.charts.api import ChartRestApi
|
from superset.charts.api import ChartRestApi
|
||||||
from superset.charts.data.api import ChartDataRestApi
|
from superset.charts.data.api import ChartDataRestApi
|
||||||
from superset.css_templates.api import CssTemplateRestApi
|
from superset.css_templates.api import CssTemplateRestApi
|
||||||
|
from superset.dashboard_generator.api import DashboardGeneratorRestApi
|
||||||
from superset.dashboards.api import DashboardRestApi
|
from superset.dashboards.api import DashboardRestApi
|
||||||
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
|
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
|
||||||
from superset.dashboards.permalink.api import DashboardPermalinkRestApi
|
from superset.dashboards.permalink.api import DashboardPermalinkRestApi
|
||||||
|
from superset.databases.analyzer_api import DatasourceAnalyzerRestApi
|
||||||
from superset.databases.api import DatabaseRestApi
|
from superset.databases.api import DatabaseRestApi
|
||||||
from superset.datasets.api import DatasetRestApi
|
from superset.datasets.api import DatasetRestApi
|
||||||
from superset.datasets.columns.api import DatasetColumnsRestApi
|
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.database.views import DatabaseView
|
||||||
from superset.views.datasource.views import DatasetEditor, Datasource
|
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.dynamic_plugins import DynamicPluginsView
|
||||||
from superset.views.error_handling import set_app_error_handlers
|
from superset.views.error_handling import set_app_error_handlers
|
||||||
from superset.views.explore import ExplorePermalinkView, ExploreView
|
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(DashboardFilterStateRestApi)
|
||||||
appbuilder.add_api(DashboardPermalinkRestApi)
|
appbuilder.add_api(DashboardPermalinkRestApi)
|
||||||
appbuilder.add_api(DashboardRestApi)
|
appbuilder.add_api(DashboardRestApi)
|
||||||
|
appbuilder.add_api(DashboardGeneratorRestApi)
|
||||||
appbuilder.add_api(DatabaseRestApi)
|
appbuilder.add_api(DatabaseRestApi)
|
||||||
appbuilder.add_api(DatasetRestApi)
|
appbuilder.add_api(DatasetRestApi)
|
||||||
appbuilder.add_api(DatasetColumnsRestApi)
|
appbuilder.add_api(DatasetColumnsRestApi)
|
||||||
appbuilder.add_api(DatasetMetricRestApi)
|
appbuilder.add_api(DatasetMetricRestApi)
|
||||||
appbuilder.add_api(DatasourceRestApi)
|
appbuilder.add_api(DatasourceRestApi)
|
||||||
|
appbuilder.add_api(DatasourceAnalyzerRestApi)
|
||||||
appbuilder.add_api(EmbeddedDashboardRestApi)
|
appbuilder.add_api(EmbeddedDashboardRestApi)
|
||||||
appbuilder.add_api(ExploreRestApi)
|
appbuilder.add_api(ExploreRestApi)
|
||||||
appbuilder.add_api(ExploreFormDataRestApi)
|
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(ReportView)
|
||||||
appbuilder.add_view_no_menu(RoleRestAPI)
|
appbuilder.add_view_no_menu(RoleRestAPI)
|
||||||
appbuilder.add_view_no_menu(UserInfoView)
|
appbuilder.add_view_no_menu(UserInfoView)
|
||||||
|
appbuilder.add_view_no_menu(DatasourceConnectorView)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Add links
|
# 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