gaard-api 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gaard_api-0.2.0/src/gaard_api.egg-info → gaard_api-0.2.1}/PKG-INFO +4 -4
- {gaard_api-0.2.0 → gaard_api-0.2.1}/pyproject.toml +4 -4
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/database.py +9 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/models.py +1 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/services.py +208 -8
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/assets/main.js +138 -39
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/assets/styles.css +9 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/admin.py +160 -45
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/analysis.py +6 -3
- gaard_api-0.2.1/src/gaard_api/api/v1/query.py +1225 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.0 → gaard_api-0.2.1/src/gaard_api.egg-info}/PKG-INFO +4 -4
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/SOURCES.txt +0 -1
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/requires.txt +3 -3
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_admin_api.py +785 -1
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_error_handlers.py +25 -0
- gaard_api-0.2.0/src/gaard_api/admin-web/src/main.ts +0 -2755
- gaard_api-0.2.0/src/gaard_api/api/v1/query.py +0 -592
- {gaard_api-0.2.0 → gaard_api-0.2.1}/MANIFEST.in +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/README.md +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/setup.cfg +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/defaults.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/prompt_runtime.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_analysis_api.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_settings.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gaard-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: GAARD backend web services providing admin interface
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: gaard-core==0.2.
|
|
8
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
7
|
+
Requires-Dist: gaard-core==0.2.1
|
|
8
|
+
Requires-Dist: gaard-connectors==0.2.1
|
|
9
|
+
Requires-Dist: gaard-llm==0.2.1
|
|
10
10
|
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.0
|
|
11
11
|
Requires-Dist: fastapi>=0.111.0
|
|
12
12
|
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gaard-api"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "GAARD backend web services providing admin interface"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
|
|
12
12
|
dependencies = [
|
|
13
|
-
"gaard-core==0.2.
|
|
14
|
-
"gaard-connectors==0.2.
|
|
15
|
-
"gaard-llm==0.2.
|
|
13
|
+
"gaard-core==0.2.1",
|
|
14
|
+
"gaard-connectors==0.2.1",
|
|
15
|
+
"gaard-llm==0.2.1",
|
|
16
16
|
"gaard-plugin-api>=0.2.0,<0.3.0",
|
|
17
17
|
"fastapi>=0.111.0",
|
|
18
18
|
"uvicorn[standard]>=0.30.0",
|
|
@@ -406,6 +406,15 @@ def ensure_data_query_audit_schema(engine: Engine) -> None:
|
|
|
406
406
|
)
|
|
407
407
|
)
|
|
408
408
|
|
|
409
|
+
if "llm_sql_language" not in columns:
|
|
410
|
+
with engine.begin() as connection:
|
|
411
|
+
connection.execute(
|
|
412
|
+
text(
|
|
413
|
+
"ALTER TABLE data_query_audit_logs "
|
|
414
|
+
"ADD COLUMN llm_sql_language VARCHAR(50) DEFAULT ''"
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
409
418
|
with engine.begin() as connection:
|
|
410
419
|
for index in DataQueryAuditLog.__table__.indexes:
|
|
411
420
|
index.create(bind=connection, checkfirst=True)
|
|
@@ -89,6 +89,7 @@ class DataQueryAuditLog(Base):
|
|
|
89
89
|
question: Mapped[str] = mapped_column(Text)
|
|
90
90
|
answer: Mapped[str] = mapped_column(Text)
|
|
91
91
|
sql: Mapped[str] = mapped_column(Text)
|
|
92
|
+
llm_sql_language: Mapped[str] = mapped_column(String(50), default="")
|
|
92
93
|
output_classification: Mapped[OutputClassification] = mapped_column(
|
|
93
94
|
SAEnum(
|
|
94
95
|
OutputClassification,
|
|
@@ -2,8 +2,10 @@ import json
|
|
|
2
2
|
import re
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
8
|
+
from sqlalchemy.engine import URL
|
|
7
9
|
from sqlalchemy import delete, desc, select
|
|
8
10
|
from sqlalchemy.exc import SQLAlchemyError
|
|
9
11
|
from sqlalchemy.orm import Session
|
|
@@ -53,6 +55,137 @@ def validate_datasource_configuration(
|
|
|
53
55
|
get_connector_registry().validate(database_type, database_url, sql_dialect)
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class NormalizedDatasourceConfiguration:
|
|
60
|
+
database_type: str
|
|
61
|
+
database_url: str
|
|
62
|
+
sql_dialect: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_datasource_configuration(
|
|
66
|
+
database_type: str,
|
|
67
|
+
connection_config: dict[str, Any] | None = None,
|
|
68
|
+
database_path: str | None = None,
|
|
69
|
+
database_url: str | None = None,
|
|
70
|
+
sql_dialect: str | None = None,
|
|
71
|
+
) -> NormalizedDatasourceConfiguration:
|
|
72
|
+
registry = get_connector_registry()
|
|
73
|
+
normalized_url = (database_url or "").strip()
|
|
74
|
+
normalized_path = (database_path or "").strip()
|
|
75
|
+
|
|
76
|
+
if not normalized_url and connection_config:
|
|
77
|
+
normalized_url = build_sqlalchemy_url_from_connection_config(
|
|
78
|
+
database_type,
|
|
79
|
+
connection_config,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not normalized_url and normalized_path:
|
|
83
|
+
normalized_url = build_sqlalchemy_url_from_path(database_type, normalized_path)
|
|
84
|
+
|
|
85
|
+
if not normalized_url:
|
|
86
|
+
raise ValueError("Datasource database path is required.")
|
|
87
|
+
|
|
88
|
+
if database_type:
|
|
89
|
+
definition = registry.get(database_type)
|
|
90
|
+
else:
|
|
91
|
+
definition = registry.detect_from_database_url(normalized_url)
|
|
92
|
+
|
|
93
|
+
resolved_dialect = (sql_dialect or "").strip() or definition.default_sql_dialect
|
|
94
|
+
registry.validate(definition.type_key, normalized_url, resolved_dialect)
|
|
95
|
+
|
|
96
|
+
return NormalizedDatasourceConfiguration(
|
|
97
|
+
database_type=definition.type_key,
|
|
98
|
+
database_url=normalized_url,
|
|
99
|
+
sql_dialect=resolved_dialect,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_sqlalchemy_url_from_path(database_type: str, database_path: str) -> str:
|
|
104
|
+
if "://" in database_path:
|
|
105
|
+
return database_path
|
|
106
|
+
|
|
107
|
+
if database_type == "sqlite":
|
|
108
|
+
return str(URL.create("sqlite", database=str(Path(database_path).expanduser())))
|
|
109
|
+
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Only SQLite datasources can be created from a filesystem path. "
|
|
112
|
+
"Use a SQLAlchemy URL for this datasource type."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_sqlalchemy_url_from_connection_config(
|
|
117
|
+
database_type: str,
|
|
118
|
+
connection_config: dict[str, Any],
|
|
119
|
+
) -> str:
|
|
120
|
+
if database_type == "sqlite":
|
|
121
|
+
database_path = str(connection_config.get("database_path") or "").strip()
|
|
122
|
+
if not database_path:
|
|
123
|
+
raise ValueError("SQLite datasource requires a database path.")
|
|
124
|
+
return build_sqlalchemy_url_from_path(database_type, database_path)
|
|
125
|
+
|
|
126
|
+
if database_type == "postgresql":
|
|
127
|
+
return build_server_database_url(
|
|
128
|
+
drivername="postgresql+psycopg",
|
|
129
|
+
connection_config=connection_config,
|
|
130
|
+
default_port=5432,
|
|
131
|
+
query_keys=("sslmode",),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if database_type == "mysql":
|
|
135
|
+
return build_server_database_url(
|
|
136
|
+
drivername="mysql+pymysql",
|
|
137
|
+
connection_config=connection_config,
|
|
138
|
+
default_port=3306,
|
|
139
|
+
query_keys=("charset",),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
raise ValueError(f"Datasource type {database_type!r} does not support generated URLs.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_server_database_url(
|
|
146
|
+
drivername: str,
|
|
147
|
+
connection_config: dict[str, Any],
|
|
148
|
+
default_port: int,
|
|
149
|
+
query_keys: tuple[str, ...],
|
|
150
|
+
) -> str:
|
|
151
|
+
host = str(connection_config.get("host") or "").strip()
|
|
152
|
+
database = str(connection_config.get("database") or "").strip()
|
|
153
|
+
username = str(connection_config.get("username") or "").strip()
|
|
154
|
+
password = str(connection_config.get("password") or "")
|
|
155
|
+
port_value = connection_config.get("port") or default_port
|
|
156
|
+
|
|
157
|
+
if not host:
|
|
158
|
+
raise ValueError("Datasource host is required.")
|
|
159
|
+
if not database:
|
|
160
|
+
raise ValueError("Datasource database name is required.")
|
|
161
|
+
if not username:
|
|
162
|
+
raise ValueError("Datasource username is required.")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
port = int(port_value)
|
|
166
|
+
except (TypeError, ValueError) as exc:
|
|
167
|
+
raise ValueError("Datasource port must be a number.") from exc
|
|
168
|
+
|
|
169
|
+
query = {
|
|
170
|
+
key: str(connection_config.get(key)).strip()
|
|
171
|
+
for key in query_keys
|
|
172
|
+
if connection_config.get(key) not in (None, "")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
URL.create(
|
|
177
|
+
drivername=drivername,
|
|
178
|
+
username=username,
|
|
179
|
+
password=password or None,
|
|
180
|
+
host=host,
|
|
181
|
+
port=port,
|
|
182
|
+
database=database,
|
|
183
|
+
query=query,
|
|
184
|
+
)
|
|
185
|
+
.render_as_string(hide_password=False)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
56
189
|
def mask_database_url(database_url: str) -> str:
|
|
57
190
|
if "://" not in database_url or "@" not in database_url:
|
|
58
191
|
return database_url
|
|
@@ -280,6 +413,7 @@ def _record_data_query_audit(
|
|
|
280
413
|
audit_metadata = dict(metadata)
|
|
281
414
|
audit_metadata.pop("audit_type", None)
|
|
282
415
|
audit_metadata.pop("output_classification", None)
|
|
416
|
+
llm_sql_language = str(audit_metadata.get("llm_sql_language") or "")
|
|
283
417
|
log = DataQueryAuditLog(
|
|
284
418
|
type=coerce_data_query_audit_type(audit_type),
|
|
285
419
|
output_classification=coerce_output_classification(output_classification),
|
|
@@ -288,6 +422,7 @@ def _record_data_query_audit(
|
|
|
288
422
|
question=request.question,
|
|
289
423
|
answer=answer,
|
|
290
424
|
sql=sql,
|
|
425
|
+
llm_sql_language=llm_sql_language,
|
|
291
426
|
metadata_json=json_dumps(audit_metadata),
|
|
292
427
|
)
|
|
293
428
|
session.add(log)
|
|
@@ -1029,10 +1164,20 @@ def list_business_logic_suggestions(
|
|
|
1029
1164
|
session: Session,
|
|
1030
1165
|
connector_id: int,
|
|
1031
1166
|
) -> list[BusinessLogicSuggestion]:
|
|
1167
|
+
return list_business_logic_suggestions_for_connectors(session, [connector_id])
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def list_business_logic_suggestions_for_connectors(
|
|
1171
|
+
session: Session,
|
|
1172
|
+
connector_ids: list[int],
|
|
1173
|
+
) -> list[BusinessLogicSuggestion]:
|
|
1174
|
+
if not connector_ids:
|
|
1175
|
+
return []
|
|
1176
|
+
|
|
1032
1177
|
return list(
|
|
1033
1178
|
session.scalars(
|
|
1034
1179
|
select(BusinessLogicSuggestion)
|
|
1035
|
-
.where(BusinessLogicSuggestion.connector_id
|
|
1180
|
+
.where(BusinessLogicSuggestion.connector_id.in_(connector_ids))
|
|
1036
1181
|
.order_by(
|
|
1037
1182
|
BusinessLogicSuggestion.enabled.desc(),
|
|
1038
1183
|
desc(BusinessLogicSuggestion.updated_at),
|
|
@@ -1840,6 +1985,16 @@ def get_active_datasource_connector(session: Session) -> DatasourceConnector | N
|
|
|
1840
1985
|
return session.scalar(select(DatasourceConnector).where(DatasourceConnector.active.is_(True)))
|
|
1841
1986
|
|
|
1842
1987
|
|
|
1988
|
+
def get_active_datasource_connectors(session: Session) -> list[DatasourceConnector]:
|
|
1989
|
+
return list(
|
|
1990
|
+
session.scalars(
|
|
1991
|
+
select(DatasourceConnector)
|
|
1992
|
+
.where(DatasourceConnector.active.is_(True))
|
|
1993
|
+
.order_by(DatasourceConnector.name, DatasourceConnector.id)
|
|
1994
|
+
)
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
|
|
1843
1998
|
def get_active_datasource_connector_safe() -> DatasourceConnector | None:
|
|
1844
1999
|
try:
|
|
1845
2000
|
session = create_session()
|
|
@@ -1859,13 +2014,17 @@ def set_active_datasource_connector(
|
|
|
1859
2014
|
connector: DatasourceConnector,
|
|
1860
2015
|
actor: str,
|
|
1861
2016
|
) -> None:
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
continue
|
|
2017
|
+
if is_system_datasource_connector(connector):
|
|
2018
|
+
connector.active = False
|
|
2019
|
+
return
|
|
1866
2020
|
|
|
1867
|
-
|
|
1868
|
-
|
|
2021
|
+
if connector.connector_key != "default":
|
|
2022
|
+
for item in list_datasource_connectors(session):
|
|
2023
|
+
if item.connector_key == "default":
|
|
2024
|
+
item.active = False
|
|
2025
|
+
|
|
2026
|
+
connector.active = True
|
|
2027
|
+
connector.updated_by = actor
|
|
1869
2028
|
|
|
1870
2029
|
|
|
1871
2030
|
def test_datasource_connection(connector: DatasourceConnector) -> None:
|
|
@@ -2085,7 +2244,6 @@ def get_datasource_schema_context_safe() -> (
|
|
|
2085
2244
|
session = create_session()
|
|
2086
2245
|
except SQLAlchemyError:
|
|
2087
2246
|
return None
|
|
2088
|
-
|
|
2089
2247
|
try:
|
|
2090
2248
|
connector = get_active_datasource_connector(session)
|
|
2091
2249
|
|
|
@@ -2104,3 +2262,45 @@ def get_datasource_schema_context_safe() -> (
|
|
|
2104
2262
|
return None
|
|
2105
2263
|
finally:
|
|
2106
2264
|
session.close()
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def get_datasource_schema_contexts_safe(
|
|
2268
|
+
datasource_ids: list[str] | None = None,
|
|
2269
|
+
) -> list[tuple[DatasourceConnector, DatasourceSchemaCache]]:
|
|
2270
|
+
try:
|
|
2271
|
+
session = create_session()
|
|
2272
|
+
except SQLAlchemyError:
|
|
2273
|
+
return []
|
|
2274
|
+
|
|
2275
|
+
try:
|
|
2276
|
+
if datasource_ids:
|
|
2277
|
+
connectors = [
|
|
2278
|
+
connector
|
|
2279
|
+
for datasource_id in datasource_ids
|
|
2280
|
+
if (connector := get_datasource_connector_by_key(session, datasource_id))
|
|
2281
|
+
is not None
|
|
2282
|
+
and not is_system_datasource_connector(connector)
|
|
2283
|
+
]
|
|
2284
|
+
else:
|
|
2285
|
+
connectors = [
|
|
2286
|
+
connector
|
|
2287
|
+
for connector in get_active_datasource_connectors(session)
|
|
2288
|
+
if not is_system_datasource_connector(connector)
|
|
2289
|
+
]
|
|
2290
|
+
|
|
2291
|
+
contexts: list[tuple[DatasourceConnector, DatasourceSchemaCache]] = []
|
|
2292
|
+
for connector in connectors:
|
|
2293
|
+
cache = get_datasource_schema_cache(session, connector.id)
|
|
2294
|
+
|
|
2295
|
+
if cache is None:
|
|
2296
|
+
cache = introspect_datasource_connector(session, connector, "system")
|
|
2297
|
+
session.commit()
|
|
2298
|
+
|
|
2299
|
+
contexts.append((connector, cache))
|
|
2300
|
+
|
|
2301
|
+
return contexts
|
|
2302
|
+
except SQLAlchemyError:
|
|
2303
|
+
session.rollback()
|
|
2304
|
+
return []
|
|
2305
|
+
finally:
|
|
2306
|
+
session.close()
|
|
@@ -44,6 +44,7 @@ var state = {
|
|
|
44
44
|
schemaCache: null,
|
|
45
45
|
businessLogic: [],
|
|
46
46
|
businessLogicDatasource: null,
|
|
47
|
+
businessLogicDatasources: [],
|
|
47
48
|
businessLogicEditorId: null,
|
|
48
49
|
llmConfig: null,
|
|
49
50
|
reasoningConfig: null,
|
|
@@ -145,6 +146,21 @@ function isMenuGroupActive(group) {
|
|
|
145
146
|
function renderNavigation() {
|
|
146
147
|
return getMenuGroups().map((group) => renderMenuGroup(group)).join("");
|
|
147
148
|
}
|
|
149
|
+
function renderSidebar() {
|
|
150
|
+
return `
|
|
151
|
+
<aside class="sidebar${state.mobileMenuOpen ? " menu-open" : ""}">
|
|
152
|
+
<div class="sidebar-header">
|
|
153
|
+
<div class="brand"><strong>GAARD Admin Console</strong><span>Community edition</span></div>
|
|
154
|
+
<button class="menu-toggle" id="mobile-menu-button" type="button" aria-label="${state.mobileMenuOpen ? "Close navigation" : "Open navigation"}" aria-expanded="${state.mobileMenuOpen}" aria-controls="admin-navigation">
|
|
155
|
+
<span></span><span></span><span></span>
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
<nav class="nav" id="admin-navigation">
|
|
159
|
+
${renderNavigation()}
|
|
160
|
+
</nav>
|
|
161
|
+
<div class="sidebar-footer"><span>${escapeHtml(state.username)}</span><button id="logout-button">Sign out</button></div>
|
|
162
|
+
</aside>`;
|
|
163
|
+
}
|
|
148
164
|
function renderMenuGroup(group) {
|
|
149
165
|
const isOpen = Boolean(state.openMenuGroups[group.key]);
|
|
150
166
|
const isActive = isMenuGroupActive(group);
|
|
@@ -392,18 +408,7 @@ function renderShell() {
|
|
|
392
408
|
const activeLabel = getSectionLabel(state.section);
|
|
393
409
|
app.innerHTML = `
|
|
394
410
|
<div class="app-shell">
|
|
395
|
-
|
|
396
|
-
<div class="sidebar-header">
|
|
397
|
-
<div class="brand"><strong>GAARD Admin Console</strong><span>Community edition</span></div>
|
|
398
|
-
<button class="menu-toggle" id="mobile-menu-button" type="button" aria-label="${state.mobileMenuOpen ? "Close navigation" : "Open navigation"}" aria-expanded="${state.mobileMenuOpen}" aria-controls="admin-navigation">
|
|
399
|
-
<span></span><span></span><span></span>
|
|
400
|
-
</button>
|
|
401
|
-
</div>
|
|
402
|
-
<nav class="nav" id="admin-navigation">
|
|
403
|
-
${renderNavigation()}
|
|
404
|
-
</nav>
|
|
405
|
-
<div class="sidebar-footer"><span>${escapeHtml(state.username)}</span><button id="logout-button">Sign out</button></div>
|
|
406
|
-
</aside>
|
|
411
|
+
${renderSidebar()}
|
|
407
412
|
<main class="main">
|
|
408
413
|
<header class="topbar">
|
|
409
414
|
<h1>${escapeHtml(activeLabel || "Admin")}</h1>
|
|
@@ -416,12 +421,16 @@ function renderShell() {
|
|
|
416
421
|
</main>
|
|
417
422
|
</div>
|
|
418
423
|
${renderOverviewWidgetModal()}`;
|
|
424
|
+
attachShellHandlers();
|
|
425
|
+
resizeExtensionFrames();
|
|
426
|
+
}
|
|
427
|
+
function attachShellHandlers() {
|
|
419
428
|
document.querySelectorAll("[data-menu-group]").forEach((button) => {
|
|
420
429
|
button.addEventListener("click", () => {
|
|
421
430
|
const groupKey = button.dataset.menuGroup;
|
|
422
431
|
if (!groupKey) return;
|
|
423
432
|
state.openMenuGroups[groupKey] = !state.openMenuGroups[groupKey];
|
|
424
|
-
|
|
433
|
+
updateSidebar();
|
|
425
434
|
});
|
|
426
435
|
});
|
|
427
436
|
document.querySelectorAll("[data-section]").forEach((button) => {
|
|
@@ -437,12 +446,17 @@ function renderShell() {
|
|
|
437
446
|
});
|
|
438
447
|
document.querySelector("#mobile-menu-button")?.addEventListener("click", () => {
|
|
439
448
|
state.mobileMenuOpen = !state.mobileMenuOpen;
|
|
440
|
-
|
|
449
|
+
updateSidebar();
|
|
441
450
|
});
|
|
442
451
|
document.querySelector("#logout-button")?.addEventListener("click", logout);
|
|
443
452
|
document.querySelector("#top-logout-button")?.addEventListener("click", logout);
|
|
444
453
|
attachSectionHandlers();
|
|
445
|
-
|
|
454
|
+
}
|
|
455
|
+
function updateSidebar() {
|
|
456
|
+
const sidebarHost = document.querySelector(".app-shell > .sidebar");
|
|
457
|
+
if (!sidebarHost) return render();
|
|
458
|
+
sidebarHost.outerHTML = renderSidebar();
|
|
459
|
+
attachShellHandlers();
|
|
446
460
|
}
|
|
447
461
|
function renderSection() {
|
|
448
462
|
if (state.section === "overview") return renderOverview();
|
|
@@ -991,7 +1005,7 @@ function renderDatasources() {
|
|
|
991
1005
|
<div class="split">
|
|
992
1006
|
<section class="panel">
|
|
993
1007
|
<div class="panel-header"><h2>Datasources</h2><button id="new-datasource">New</button></div>
|
|
994
|
-
<div class="panel-body list">${state.datasources.map((connector) => `<button data-datasource="${connector.id}" class="${selected?.id === connector.id ? "active" : ""}"><strong>${escapeHtml(connector.name)}</strong><br /><span>${escapeHtml(connector.database_type)} ${connector.active ? "active" : ""}</span></button>`).join("")}</div>
|
|
1008
|
+
<div class="panel-body list datasource-list">${state.datasources.map((connector) => `<button data-datasource="${connector.id}" class="${selected?.id === connector.id ? "active" : ""}"><strong>${escapeHtml(connector.name)}</strong><br /><span>${escapeHtml(connector.database_type)} ${connector.active ? "active" : ""}</span></button>`).join("")}</div>
|
|
995
1009
|
</section>
|
|
996
1010
|
<section class="panel">
|
|
997
1011
|
<div class="panel-header"><h2>${selected ? escapeHtml(selected.name) : "New datasource"}</h2></div>
|
|
@@ -1008,12 +1022,11 @@ function renderDatasourceForm(connector) {
|
|
|
1008
1022
|
const systemManaged = connector?.system_managed === true;
|
|
1009
1023
|
const selectedTypeKey = connector?.database_type || state.datasourceTypes[0]?.type_key || "";
|
|
1010
1024
|
const selectedType = getDatasourceType(selectedTypeKey);
|
|
1011
|
-
const databaseUrlSchema = selectedType?.config_schema?.properties?.database_url;
|
|
1012
1025
|
const selectedSqlDialect = connector?.sql_dialect || selectedType?.default_sql_dialect || "";
|
|
1013
|
-
const databaseUrl = connector?.database_url || String(databaseUrlSchema?.default || "");
|
|
1014
1026
|
const unavailableType = Boolean(selectedTypeKey && !selectedType);
|
|
1015
1027
|
const disabled = systemManaged || unavailableType || !selectedType ? "disabled" : "";
|
|
1016
1028
|
const connectorDescription = selectedType?.description || (unavailableType ? `Connector type '${selectedTypeKey}' is unavailable. Install or enable its plugin before editing this datasource.` : "No connector types are available. Install or enable a connector plugin.");
|
|
1029
|
+
const connectionValues = datasourceConnectionConfigDefaults(selectedType, connector);
|
|
1017
1030
|
return `
|
|
1018
1031
|
<form id="datasource-form" class="form-grid">
|
|
1019
1032
|
<input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
|
|
@@ -1022,15 +1035,18 @@ function renderDatasourceForm(connector) {
|
|
|
1022
1035
|
<label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
|
|
1023
1036
|
<div class="subgrid">
|
|
1024
1037
|
<label>Connector type<select id="datasource-type" name="database_type" ${disabled}>${renderDatasourceTypeOptions(selectedTypeKey)}</select></label>
|
|
1025
|
-
<label>SQL dialect<
|
|
1038
|
+
<label>SQL dialect<input id="datasource-sql-dialect" readonly ${disabled} value="${escapeHtml(selectedSqlDialect)}" /></label>
|
|
1026
1039
|
</div>
|
|
1027
1040
|
<p id="datasource-type-description" class="muted">${escapeHtml(connectorDescription)}</p>
|
|
1028
|
-
<
|
|
1041
|
+
<div id="datasource-connection-fields" class="subgrid">
|
|
1042
|
+
${renderDatasourceConnectionFields(selectedType, connectionValues, disabled)}
|
|
1043
|
+
</div>
|
|
1029
1044
|
<label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
|
|
1030
1045
|
<div class="button-row">
|
|
1031
1046
|
<button type="button" id="test-datasource" ${disabled}>Test</button>
|
|
1032
1047
|
<button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
|
|
1033
1048
|
<button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
|
|
1049
|
+
${connector && !systemManaged ? `<button type="button" class="danger" id="delete-datasource">Delete</button>` : ""}
|
|
1034
1050
|
<button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
|
|
1035
1051
|
</div>
|
|
1036
1052
|
</form>`;
|
|
@@ -1065,28 +1081,84 @@ function renderSqlDialectOptions(datasourceType, selected) {
|
|
|
1065
1081
|
}
|
|
1066
1082
|
return dialects.map((value) => `<option value="${escapeHtml(value)}" ${selected === value ? "selected" : ""}>${escapeHtml(value)}</option>`).join("");
|
|
1067
1083
|
}
|
|
1084
|
+
function datasourceConnectionConfigDefaults(datasourceType, connector = null) {
|
|
1085
|
+
const values = {};
|
|
1086
|
+
const properties = datasourceType?.config_schema?.properties || {};
|
|
1087
|
+
const usesGeneratedFields = datasourceUsesGeneratedConnectionFields(datasourceType);
|
|
1088
|
+
Object.entries(properties).forEach(([key, schema]) => {
|
|
1089
|
+
if (key === "database_url" && usesGeneratedFields) return;
|
|
1090
|
+
values[key] = schema?.default ?? "";
|
|
1091
|
+
});
|
|
1092
|
+
if (!connector?.database_url) return values;
|
|
1093
|
+
if (!usesGeneratedFields) {
|
|
1094
|
+
return { ...values, database_url: connector.database_url };
|
|
1095
|
+
}
|
|
1096
|
+
return { ...values, ...parseDatasourceUrl(connector.database_type, connector.database_url) };
|
|
1097
|
+
}
|
|
1098
|
+
function datasourceUsesGeneratedConnectionFields(datasourceType) {
|
|
1099
|
+
const required = datasourceType?.config_schema?.required || [];
|
|
1100
|
+
return required.some((key) => key !== "database_url");
|
|
1101
|
+
}
|
|
1102
|
+
function parseDatasourceUrl(databaseType, databaseUrl) {
|
|
1103
|
+
if (!databaseUrl) return {};
|
|
1104
|
+
if (databaseType === "sqlite") {
|
|
1105
|
+
return { database_path: databaseUrl.replace(/^sqlite:\/\/\/?/, "") };
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const parsed = new URL(databaseUrl);
|
|
1109
|
+
const query = Object.fromEntries(parsed.searchParams.entries());
|
|
1110
|
+
return {
|
|
1111
|
+
host: parsed.hostname || "",
|
|
1112
|
+
port: parsed.port || "",
|
|
1113
|
+
database: decodeURIComponent(parsed.pathname.replace(/^\//, "")),
|
|
1114
|
+
username: decodeURIComponent(parsed.username || ""),
|
|
1115
|
+
password: decodeURIComponent(parsed.password || ""),
|
|
1116
|
+
...query
|
|
1117
|
+
};
|
|
1118
|
+
} catch {
|
|
1119
|
+
return {};
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function renderDatasourceConnectionFields(datasourceType, values = {}, disabled = "") {
|
|
1123
|
+
const properties = datasourceType?.config_schema?.properties || {};
|
|
1124
|
+
const usesGeneratedFields = datasourceUsesGeneratedConnectionFields(datasourceType);
|
|
1125
|
+
return Object.entries(properties)
|
|
1126
|
+
.filter(([key]) => key !== "database_url" || !usesGeneratedFields)
|
|
1127
|
+
.map(([key, schema]) => {
|
|
1128
|
+
const type = schema?.format === "password" ? "password" : schema?.type === "integer" ? "number" : "text";
|
|
1129
|
+
const value = values[key] ?? schema?.default ?? "";
|
|
1130
|
+
const placeholder = schema?.description || "";
|
|
1131
|
+
const inputName = key === "database_url" ? "database_url" : `connection_${key}`;
|
|
1132
|
+
const dataAttribute = key === "database_url" ? "" : `data-connection-field="${escapeHtml(key)}"`;
|
|
1133
|
+
return `<label>${escapeHtml(schema?.title || key)}<input ${dataAttribute} name="${escapeHtml(inputName)}" type="${type}" ${disabled} placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(value)}" /></label>`;
|
|
1134
|
+
})
|
|
1135
|
+
.join("");
|
|
1136
|
+
}
|
|
1137
|
+
function collectDatasourceConnectionConfig(form) {
|
|
1138
|
+
const config = {};
|
|
1139
|
+
form.querySelectorAll("[data-connection-field]").forEach((input) => {
|
|
1140
|
+
config[input.dataset.connectionField] = input.value;
|
|
1141
|
+
});
|
|
1142
|
+
return config;
|
|
1143
|
+
}
|
|
1068
1144
|
function syncDatasourceTypeFields(event) {
|
|
1069
1145
|
const typeKey = event.currentTarget.value;
|
|
1070
1146
|
const datasourceType = getDatasourceType(typeKey);
|
|
1071
1147
|
const sqlDialect = document.querySelector("#datasource-sql-dialect");
|
|
1072
1148
|
const description = document.querySelector("#datasource-type-description");
|
|
1073
|
-
const
|
|
1074
|
-
const urlInput = document.querySelector("#datasource-url");
|
|
1075
|
-
const databaseUrlSchema = datasourceType?.config_schema?.properties?.database_url;
|
|
1149
|
+
const connectionFields = document.querySelector("#datasource-connection-fields");
|
|
1076
1150
|
if (sqlDialect) {
|
|
1077
|
-
sqlDialect.
|
|
1078
|
-
datasourceType,
|
|
1079
|
-
datasourceType?.default_sql_dialect || ""
|
|
1080
|
-
);
|
|
1151
|
+
sqlDialect.value = datasourceType?.default_sql_dialect || "";
|
|
1081
1152
|
}
|
|
1082
1153
|
if (description) {
|
|
1083
1154
|
description.textContent = datasourceType?.description || "Connector type is unavailable.";
|
|
1084
1155
|
}
|
|
1085
|
-
if (
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1156
|
+
if (connectionFields) {
|
|
1157
|
+
connectionFields.innerHTML = renderDatasourceConnectionFields(
|
|
1158
|
+
datasourceType,
|
|
1159
|
+
datasourceConnectionConfigDefaults(datasourceType),
|
|
1160
|
+
""
|
|
1161
|
+
);
|
|
1090
1162
|
}
|
|
1091
1163
|
}
|
|
1092
1164
|
function renderModeOptions(selected, values) {
|
|
@@ -1095,10 +1167,11 @@ function renderModeOptions(selected, values) {
|
|
|
1095
1167
|
function renderDatasourceSchema() {
|
|
1096
1168
|
const schema = state.datasourceSchema?.item;
|
|
1097
1169
|
const rawTables = schema?.raw_schema?.tables || [];
|
|
1170
|
+
const displayTables = [...rawTables].sort((a, b) => a.name.localeCompare(b.name));
|
|
1098
1171
|
const tableSettings = schema?.table_settings?.tables || {};
|
|
1099
1172
|
const draftTables = schema ? getDatasourceSchemaDraftTables(rawTables, tableSettings) : {};
|
|
1100
|
-
const visibleTables = state.datasourceSchemaShowEnabledOnly ?
|
|
1101
|
-
const selectedTable = schema ? getSelectedDatasourceSchemaObject(
|
|
1173
|
+
const visibleTables = state.datasourceSchemaShowEnabledOnly ? displayTables.filter((table) => draftTables[table.name]?.selected !== false) : displayTables;
|
|
1174
|
+
const selectedTable = schema ? getSelectedDatasourceSchemaObject(displayTables, visibleTables) : null;
|
|
1102
1175
|
const selectedSettings = selectedTable ? draftTables[selectedTable.name] || {} : {};
|
|
1103
1176
|
return `
|
|
1104
1177
|
<section class="panel">
|
|
@@ -1149,11 +1222,15 @@ function getSelectedDatasourceSchemaObject(rawTables, visibleTables) {
|
|
|
1149
1222
|
}
|
|
1150
1223
|
function renderBusinessLogicSuggestions() {
|
|
1151
1224
|
const datasource = state.businessLogicDatasource;
|
|
1225
|
+
const datasources = state.businessLogicDatasources || (datasource ? [datasource] : []);
|
|
1226
|
+
const datasourceLabel = datasources.length
|
|
1227
|
+
? datasources.map((item) => item.connector_key).join(", ")
|
|
1228
|
+
: "no active datasource";
|
|
1152
1229
|
return `
|
|
1153
1230
|
<section class="panel">
|
|
1154
1231
|
<div class="panel-header">
|
|
1155
1232
|
<h2>Business logic suggestions</h2>
|
|
1156
|
-
<span class="badge">${escapeHtml(
|
|
1233
|
+
<span class="badge">${escapeHtml(datasourceLabel)}</span>
|
|
1157
1234
|
</div>
|
|
1158
1235
|
<div class="table-wrap"><table>
|
|
1159
1236
|
<thead><tr><th>Use</th><th>Status</th><th>Safety</th><th>Rule</th><th>Error</th><th>Confidence</th><th>Actions</th></tr></thead>
|
|
@@ -1173,7 +1250,7 @@ function renderBusinessLogicSuggestions() {
|
|
|
1173
1250
|
</td>
|
|
1174
1251
|
</tr>`).join("")}</tbody>
|
|
1175
1252
|
</table></div>
|
|
1176
|
-
${state.businessLogic.length ? "" : `<div class="panel-body"><p class="muted">No suggestions for the active
|
|
1253
|
+
${state.businessLogic.length ? "" : `<div class="panel-body"><p class="muted">No suggestions for the active datasources.</p></div>`}
|
|
1177
1254
|
</section>
|
|
1178
1255
|
${renderBusinessLogicEditorModal()}`;
|
|
1179
1256
|
}
|
|
@@ -1455,6 +1532,7 @@ function attachSectionHandlers() {
|
|
|
1455
1532
|
document.querySelector("#test-datasource")?.addEventListener("click", testDatasource);
|
|
1456
1533
|
document.querySelector("#introspect-datasource")?.addEventListener("click", introspectDatasource);
|
|
1457
1534
|
document.querySelector("#activate-datasource")?.addEventListener("click", activateDatasource);
|
|
1535
|
+
document.querySelector("#delete-datasource")?.addEventListener("click", deleteDatasource);
|
|
1458
1536
|
document.querySelectorAll("[data-open-business-logic]").forEach((button) => {
|
|
1459
1537
|
button.addEventListener("click", async () => {
|
|
1460
1538
|
state.section = "business-logic";
|
|
@@ -1875,8 +1953,8 @@ async function saveDatasource(event) {
|
|
|
1875
1953
|
connector_key: form.get("connector_key"),
|
|
1876
1954
|
name: form.get("name"),
|
|
1877
1955
|
database_type: form.get("database_type"),
|
|
1878
|
-
|
|
1879
|
-
|
|
1956
|
+
connection_config: collectDatasourceConnectionConfig(event.currentTarget),
|
|
1957
|
+
database_url: form.get("database_url") || null,
|
|
1880
1958
|
active: form.get("active") === "on"
|
|
1881
1959
|
};
|
|
1882
1960
|
try {
|
|
@@ -1903,7 +1981,8 @@ async function testDatasource() {
|
|
|
1903
1981
|
method: "POST",
|
|
1904
1982
|
body: JSON.stringify({
|
|
1905
1983
|
database_type: formData.get("database_type"),
|
|
1906
|
-
|
|
1984
|
+
connection_config: collectDatasourceConnectionConfig(form),
|
|
1985
|
+
database_url: formData.get("database_url") || null
|
|
1907
1986
|
})
|
|
1908
1987
|
});
|
|
1909
1988
|
} else {
|
|
@@ -1946,6 +2025,25 @@ async function activateDatasource() {
|
|
|
1946
2025
|
render();
|
|
1947
2026
|
}
|
|
1948
2027
|
}
|
|
2028
|
+
async function deleteDatasource() {
|
|
2029
|
+
const selected = getSelectedDatasource();
|
|
2030
|
+
if (!selected || selected.system_managed) return;
|
|
2031
|
+
if (!confirm(`Delete datasource "${selected.name}"?`)) return;
|
|
2032
|
+
try {
|
|
2033
|
+
await api(`/api/v1/admin/datasources/${selected.id}`, { method: "DELETE" });
|
|
2034
|
+
state.selectedDatasourceId = null;
|
|
2035
|
+
state.datasourceSchema = null;
|
|
2036
|
+
state.datasourceSchemaLoading = false;
|
|
2037
|
+
state.datasourceSchemaError = "";
|
|
2038
|
+
state.datasourceSchemaSelectedObjectName = "";
|
|
2039
|
+
state.datasourceSchemaDraftTables = null;
|
|
2040
|
+
setMessage("success", "Datasource deleted.");
|
|
2041
|
+
await loadDatasources();
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
setMessage("error", error.message);
|
|
2044
|
+
render();
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1949
2047
|
function syncDatasourceSchemaDraftFromForm() {
|
|
1950
2048
|
const form = document.querySelector("#datasource-schema-form");
|
|
1951
2049
|
const rawTables = state.datasourceSchema?.item?.raw_schema?.tables || [];
|
|
@@ -2169,6 +2267,7 @@ async function loadBusinessLogicSuggestions() {
|
|
|
2169
2267
|
const result = await api("/api/v1/admin/business-logic-suggestions");
|
|
2170
2268
|
state.businessLogic = result.items || [];
|
|
2171
2269
|
state.businessLogicDatasource = result.datasource || null;
|
|
2270
|
+
state.businessLogicDatasources = result.datasources || [];
|
|
2172
2271
|
render();
|
|
2173
2272
|
}
|
|
2174
2273
|
async function loadLlmConfig() {
|