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.
Files changed (56) hide show
  1. {gaard_api-0.2.0/src/gaard_api.egg-info → gaard_api-0.2.1}/PKG-INFO +4 -4
  2. {gaard_api-0.2.0 → gaard_api-0.2.1}/pyproject.toml +4 -4
  3. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/database.py +9 -0
  4. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/models.py +1 -0
  5. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/services.py +208 -8
  6. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/assets/main.js +138 -39
  7. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/assets/styles.css +9 -0
  8. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/admin.py +160 -45
  9. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/analysis.py +6 -3
  10. gaard_api-0.2.1/src/gaard_api/api/v1/query.py +1225 -0
  11. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/main.py +1 -1
  12. {gaard_api-0.2.0 → gaard_api-0.2.1/src/gaard_api.egg-info}/PKG-INFO +4 -4
  13. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/SOURCES.txt +0 -1
  14. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/requires.txt +3 -3
  15. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_admin_api.py +785 -1
  16. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_error_handlers.py +25 -0
  17. gaard_api-0.2.0/src/gaard_api/admin-web/src/main.ts +0 -2755
  18. gaard_api-0.2.0/src/gaard_api/api/v1/query.py +0 -592
  19. {gaard_api-0.2.0 → gaard_api-0.2.1}/MANIFEST.in +0 -0
  20. {gaard_api-0.2.0 → gaard_api-0.2.1}/README.md +0 -0
  21. {gaard_api-0.2.0 → gaard_api-0.2.1}/setup.cfg +0 -0
  22. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/__init__.py +0 -0
  23. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/__init__.py +0 -0
  24. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/defaults.py +0 -0
  25. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/prompt_runtime.py +0 -0
  26. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin/security.py +0 -0
  27. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/index.html +0 -0
  28. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/admin-web/package.json +0 -0
  29. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/__init__.py +0 -0
  30. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/__init__.py +0 -0
  31. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/prompts.py +0 -0
  32. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api/v1/schema.py +0 -0
  33. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/api_registry.py +0 -0
  34. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/cli.py +0 -0
  35. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/cli_commands.py +0 -0
  36. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/__init__.py +0 -0
  37. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/error_handlers.py +0 -0
  38. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/schema_cache.py +0 -0
  39. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/core/settings.py +0 -0
  40. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/__init__.py +0 -0
  41. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  42. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  43. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  44. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/example_database.py +0 -0
  45. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/extension_services.py +0 -0
  46. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/extensions.py +0 -0
  47. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api/server_cli.py +0 -0
  48. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  49. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/entry_points.txt +0 -0
  50. {gaard_api-0.2.0 → gaard_api-0.2.1}/src/gaard_api.egg-info/top_level.txt +0 -0
  51. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_analysis_api.py +0 -0
  52. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_api_registry.py +0 -0
  53. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_prompt_runtime.py +0 -0
  54. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_query_error_messages.py +0 -0
  55. {gaard_api-0.2.0 → gaard_api-0.2.1}/tests/test_server_cli.py +0 -0
  56. {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.0
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.0
8
- Requires-Dist: gaard-connectors==0.2.0
9
- Requires-Dist: gaard-llm==0.2.0
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.0"
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.0",
14
- "gaard-connectors==0.2.0",
15
- "gaard-llm==0.2.0",
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 == 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
- for item in list_datasource_connectors(session):
1863
- if is_system_datasource_connector(item):
1864
- item.active = False
1865
- continue
2017
+ if is_system_datasource_connector(connector):
2018
+ connector.active = False
2019
+ return
1866
2020
 
1867
- item.active = item.id == connector.id
1868
- item.updated_by = actor if item.id == connector.id else item.updated_by
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
- <aside class="sidebar${state.mobileMenuOpen ? " menu-open" : ""}">
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
- render();
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
- render();
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
- resizeExtensionFrames();
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<select id="datasource-sql-dialect" name="sql_dialect" ${disabled}>${renderSqlDialectOptions(selectedType, selectedSqlDialect)}</select></label>
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
- <label><span id="datasource-url-label">${escapeHtml(databaseUrlSchema?.title || "Database URL")}</span><input id="datasource-url" name="database_url" ${disabled} placeholder="${escapeHtml(databaseUrlSchema?.description || "")}" value="${escapeHtml(databaseUrl)}" /></label>
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 urlLabel = document.querySelector("#datasource-url-label");
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.innerHTML = renderSqlDialectOptions(
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 (urlLabel) {
1086
- urlLabel.textContent = databaseUrlSchema?.title || "Database URL";
1087
- }
1088
- if (urlInput) {
1089
- urlInput.placeholder = databaseUrlSchema?.description || "";
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 ? rawTables.filter((table) => draftTables[table.name]?.selected !== false) : rawTables;
1101
- const selectedTable = schema ? getSelectedDatasourceSchemaObject(rawTables, visibleTables) : null;
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(datasource?.connector_key || "no active datasource")}</span>
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 datasource.</p></div>`}
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
- database_url: form.get("database_url"),
1879
- sql_dialect: form.get("sql_dialect"),
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
- database_url: formData.get("database_url")
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() {