gaard-api 0.1.0__tar.gz → 0.1.2__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 (55) hide show
  1. gaard_api-0.1.2/MANIFEST.in +2 -0
  2. {gaard_api-0.1.0 → gaard_api-0.1.2}/PKG-INFO +13 -4
  3. {gaard_api-0.1.0 → gaard_api-0.1.2}/README.md +10 -2
  4. {gaard_api-0.1.0 → gaard_api-0.1.2}/pyproject.toml +12 -3
  5. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/database.py +0 -29
  6. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/services.py +15 -30
  7. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin-web/assets/main.js +74 -19
  8. gaard_api-0.1.2/src/gaard_api/admin-web/package.json +10 -0
  9. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin-web/src/main.ts +110 -15
  10. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/v1/admin.py +38 -13
  11. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/v1/prompts.py +4 -4
  12. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/v1/query.py +15 -11
  13. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/v1/schema.py +4 -4
  14. gaard_api-0.1.2/src/gaard_api/example_data/__init__.py +1 -0
  15. gaard_api-0.1.2/src/gaard_api/example_data/medical_poc/__init__.py +1 -0
  16. gaard_api-0.1.2/src/gaard_api/example_data/medical_poc/schema.sql +140 -0
  17. gaard_api-0.1.2/src/gaard_api/example_data/medical_poc/seed.sql +635 -0
  18. gaard_api-0.1.2/src/gaard_api/example_database.py +135 -0
  19. gaard_api-0.1.2/src/gaard_api/extensions.py +18 -0
  20. gaard_api-0.1.2/src/gaard_api/server_cli.py +62 -0
  21. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/PKG-INFO +13 -4
  22. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/SOURCES.txt +8 -3
  23. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/requires.txt +2 -1
  24. {gaard_api-0.1.0 → gaard_api-0.1.2}/tests/test_admin_api.py +48 -15
  25. gaard_api-0.1.2/tests/test_server_cli.py +111 -0
  26. gaard_api-0.1.0/src/gaard_api/dependencies/__init__.py +0 -0
  27. gaard_api-0.1.0/src/gaard_api/schemas/__init__.py +0 -0
  28. gaard_api-0.1.0/src/gaard_api/server_cli.py +0 -25
  29. gaard_api-0.1.0/src/gaard_api/services/__init__.py +0 -0
  30. gaard_api-0.1.0/tests/test_server_cli.py +0 -20
  31. {gaard_api-0.1.0 → gaard_api-0.1.2}/setup.cfg +0 -0
  32. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/__init__.py +0 -0
  33. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/__init__.py +0 -0
  34. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/defaults.py +0 -0
  35. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/models.py +0 -0
  36. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/prompt_runtime.py +0 -0
  37. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin/security.py +0 -0
  38. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin-web/assets/styles.css +0 -0
  39. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/admin-web/index.html +0 -0
  40. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/__init__.py +0 -0
  41. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/api/v1/__init__.py +0 -0
  42. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/cli.py +0 -0
  43. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/cli_commands.py +0 -0
  44. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/core/__init__.py +0 -0
  45. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/core/error_handlers.py +0 -0
  46. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/core/schema_cache.py +0 -0
  47. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/core/settings.py +0 -0
  48. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api/main.py +0 -0
  49. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  50. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/entry_points.txt +0 -0
  51. {gaard_api-0.1.0 → gaard_api-0.1.2}/src/gaard_api.egg-info/top_level.txt +0 -0
  52. {gaard_api-0.1.0 → gaard_api-0.1.2}/tests/test_error_handlers.py +0 -0
  53. {gaard_api-0.1.0 → gaard_api-0.1.2}/tests/test_investigation_error_messages.py +0 -0
  54. {gaard_api-0.1.0 → gaard_api-0.1.2}/tests/test_prompt_runtime.py +0 -0
  55. {gaard_api-0.1.0 → gaard_api-0.1.2}/tests/test_settings.py +0 -0
@@ -0,0 +1,2 @@
1
+ global-exclude *.py[cod]
2
+ global-exclude __pycache__/*
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: gaard-core==0.1.0
8
- Requires-Dist: gaard-connectors==0.1.0
8
+ Requires-Dist: gaard-connectors==0.1.1
9
9
  Requires-Dist: gaard-llm==0.1.0
10
+ Requires-Dist: gaard-plugin-api<0.2.0,>=0.1.0
10
11
  Requires-Dist: fastapi>=0.111.0
11
12
  Requires-Dist: uvicorn[standard]>=0.30.0
12
13
  Requires-Dist: pydantic>=2.7.0
@@ -34,11 +35,19 @@ For more informacion see https://github.com/pkroliszewski/gaard
34
35
  After installation, start it with:
35
36
 
36
37
  ```bash
38
+ gaard-core install-example-database
37
39
  gaard-core start
38
40
  ```
39
41
 
40
- The command accepts `--host`, `--port`, and `--reload`. By default the API is
41
- available at `http://localhost:8000` and the admin application at
42
+ `gaard-core install-example-database` creates the bundled Medical POC SQLite
43
+ database at `examples/medical-poc/demo.db` in the current working directory,
44
+ registers it as the active `default` datasource in `metadata.db`, and matches
45
+ the default datasource URL. Use
46
+ `gaard-core install-example-database --output /path/to/demo.db` to place it
47
+ elsewhere; the command prints the SQLite datasource URL saved in metadata.
48
+
49
+ `gaard-core start` accepts `--host`, `--port`, and `--reload`. By default the
50
+ API is available at `http://localhost:8000` and the admin application at
42
51
  `http://localhost:8000/admin`.
43
52
 
44
53
  `gaard-api start` is an alias. `gaard admin` remains available for compatibility.
@@ -13,11 +13,19 @@ For more informacion see https://github.com/pkroliszewski/gaard
13
13
  After installation, start it with:
14
14
 
15
15
  ```bash
16
+ gaard-core install-example-database
16
17
  gaard-core start
17
18
  ```
18
19
 
19
- The command accepts `--host`, `--port`, and `--reload`. By default the API is
20
- available at `http://localhost:8000` and the admin application at
20
+ `gaard-core install-example-database` creates the bundled Medical POC SQLite
21
+ database at `examples/medical-poc/demo.db` in the current working directory,
22
+ registers it as the active `default` datasource in `metadata.db`, and matches
23
+ the default datasource URL. Use
24
+ `gaard-core install-example-database --output /path/to/demo.db` to place it
25
+ elsewhere; the command prints the SQLite datasource URL saved in metadata.
26
+
27
+ `gaard-core start` accepts `--host`, `--port`, and `--reload`. By default the
28
+ API is available at `http://localhost:8000` and the admin application at
21
29
  `http://localhost:8000/admin`.
22
30
 
23
31
  `gaard-api start` is an alias. `gaard admin` remains available for compatibility.
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.1.0"
7
+ version = "0.1.2"
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
13
  "gaard-core==0.1.0",
14
- "gaard-connectors==0.1.0",
14
+ "gaard-connectors==0.1.1",
15
15
  "gaard-llm==0.1.0",
16
+ "gaard-plugin-api>=0.1.0,<0.2.0",
16
17
  "fastapi>=0.111.0",
17
18
  "uvicorn[standard]>=0.30.0",
18
19
  "pydantic>=2.7.0",
@@ -44,7 +45,7 @@ gaard-core = "gaard_api.server_cli:main"
44
45
  admin = "gaard_api.cli_commands:register"
45
46
 
46
47
  [tool.setuptools]
47
- include-package-data = true
48
+ include-package-data = false
48
49
 
49
50
  [tool.setuptools.packages.find]
50
51
  where = ["src"]
@@ -52,6 +53,14 @@ where = ["src"]
52
53
  [tool.setuptools.package-data]
53
54
  gaard_api = [
54
55
  "admin-web/**/*",
56
+ "example_data/medical_poc/*.sql",
57
+ ]
58
+
59
+ [tool.setuptools.exclude-package-data]
60
+ gaard_api = [
61
+ "**/__pycache__/*",
62
+ "**/*.pyc",
63
+ "**/*.pyo",
55
64
  ]
56
65
 
57
66
  [tool.ruff]
@@ -190,22 +190,6 @@ def seed_prompts(session: Session) -> None:
190
190
  def seed_datasource_connectors(session: Session) -> None:
191
191
  migrate_postgres_sql_dialect(session)
192
192
 
193
- default_connector = session.scalar(
194
- select(DatasourceConnector).where(DatasourceConnector.connector_key == "default")
195
- )
196
-
197
- if default_connector is None:
198
- session.add(
199
- DatasourceConnector(
200
- connector_key="default",
201
- name="Medical POC SQLite",
202
- database_type="sqlite",
203
- database_url=settings.gaard_datasource_url,
204
- sql_dialect=settings.gaard_sql_dialect,
205
- active=True,
206
- )
207
- )
208
-
209
193
  metadata_connector = session.scalar(
210
194
  select(DatasourceConnector).where(DatasourceConnector.connector_key == "metadata-db")
211
195
  )
@@ -230,19 +214,6 @@ def seed_datasource_connectors(session: Session) -> None:
230
214
  metadata_connector.active = False
231
215
  metadata_connector.updated_by = "system"
232
216
 
233
- active_user_connector = session.scalar(
234
- select(DatasourceConnector).where(
235
- DatasourceConnector.connector_key != "metadata-db",
236
- DatasourceConnector.active.is_(True),
237
- )
238
- )
239
- default_connector = session.scalar(
240
- select(DatasourceConnector).where(DatasourceConnector.connector_key == "default")
241
- )
242
-
243
- if active_user_connector is None and default_connector is not None:
244
- default_connector.active = True
245
-
246
217
 
247
218
  def infer_datasource_type(database_url: str) -> tuple[str, str]:
248
219
  if database_url.startswith("sqlite"):
@@ -5,12 +5,10 @@ from dataclasses import dataclass
5
5
  from datetime import UTC, datetime, timedelta
6
6
  from typing import Any
7
7
 
8
- from sqlalchemy import create_engine, text
9
8
  from sqlalchemy import delete, desc, select
10
9
  from sqlalchemy.exc import SQLAlchemyError
11
10
  from sqlalchemy.orm import Session
12
11
 
13
- from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
14
12
  from gaard_core.errors import LlmProviderError
15
13
  from gaard_core.llm_output import remove_thinking_blocks
16
14
  from gaard_core.query_pipeline.models import OutputClassification, QueryRequest, QueryResponse
@@ -33,6 +31,7 @@ from gaard_api.admin.models import (
33
31
  PromptTemplate,
34
32
  )
35
33
  from gaard_api.core.settings import settings
34
+ from gaard_api.extensions import get_connector_registry
36
35
 
37
36
 
38
37
  def json_dumps(value: Any) -> str:
@@ -43,24 +42,16 @@ def json_loads(value: str) -> Any:
43
42
  return json.loads(value or "{}")
44
43
 
45
44
 
46
- SUPPORTED_DATABASE_TYPES = {
47
- "sqlite": ("sqlite://", "sqlite"),
48
- "postgresql": ("postgresql://", "postgresql+psycopg://"),
49
- "mysql": ("mysql://", "mysql+pymysql://"),
50
- }
51
-
52
-
53
45
  def validate_datasource_url(database_type: str, database_url: str) -> None:
54
- prefixes = SUPPORTED_DATABASE_TYPES.get(database_type)
46
+ get_connector_registry().get(database_type).validate_database_url(database_url)
55
47
 
56
- if prefixes is None:
57
- raise ValueError("Unsupported datasource type.")
58
48
 
59
- if not database_url.startswith(prefixes):
60
- raise ValueError(
61
- f"Datasource URL for {database_type} must start with one of: "
62
- f"{', '.join(prefixes)}"
63
- )
49
+ def validate_datasource_configuration(
50
+ database_type: str,
51
+ database_url: str,
52
+ sql_dialect: str,
53
+ ) -> None:
54
+ get_connector_registry().validate(database_type, database_url, sql_dialect)
64
55
 
65
56
 
66
57
  def mask_database_url(database_url: str) -> str:
@@ -1896,18 +1887,7 @@ def set_active_datasource_connector(
1896
1887
 
1897
1888
 
1898
1889
  def test_datasource_connection(connector: DatasourceConnector) -> None:
1899
- connect_args = (
1900
- {"check_same_thread": False}
1901
- if connector.database_url.startswith("sqlite")
1902
- else {}
1903
- )
1904
- engine = create_engine(connector.database_url, connect_args=connect_args)
1905
-
1906
- try:
1907
- with engine.connect() as connection:
1908
- connection.execute(text("SELECT 1"))
1909
- finally:
1910
- engine.dispose()
1890
+ get_connector_registry().get(connector.database_type).connection_tester(connector.database_url)
1911
1891
 
1912
1892
 
1913
1893
  def get_datasource_schema_cache(
@@ -1935,7 +1915,12 @@ def introspect_datasource_connector(
1935
1915
  connector: DatasourceConnector,
1936
1916
  actor: str,
1937
1917
  ) -> DatasourceSchemaCache:
1938
- schema = SQLAlchemySchemaIntrospector(connector.database_url).introspect()
1918
+ schema = (
1919
+ get_connector_registry()
1920
+ .get(connector.database_type)
1921
+ .introspector_factory(connector.database_url)
1922
+ .introspect()
1923
+ )
1939
1924
  existing = get_datasource_schema_cache(session, connector.id)
1940
1925
  existing_settings = (
1941
1926
  json_loads(existing.table_settings_json) if existing is not None else {}
@@ -81,6 +81,7 @@ const state = {
81
81
  llmConfig: null,
82
82
  governancePolicy: null,
83
83
  datasources: [],
84
+ datasourceTypes: [],
84
85
  selectedDatasourceId: null,
85
86
  datasourceSchema: null,
86
87
  datasourceSchemaLoading: false,
@@ -946,7 +947,14 @@ function getSelectedDatasource() {
946
947
  }
947
948
  function renderDatasourceForm(connector) {
948
949
  const systemManaged = connector?.system_managed === true;
949
- const disabled = systemManaged ? "disabled" : "";
950
+ const selectedTypeKey = connector?.database_type || state.datasourceTypes[0]?.type_key || "";
951
+ const selectedType = getDatasourceType(selectedTypeKey);
952
+ const databaseUrlSchema = selectedType?.config_schema?.properties?.database_url;
953
+ const selectedSqlDialect = connector?.sql_dialect || selectedType?.default_sql_dialect || "";
954
+ const databaseUrl = connector?.database_url || String(databaseUrlSchema?.default || "");
955
+ const unavailableType = Boolean(selectedTypeKey && !selectedType);
956
+ const disabled = systemManaged || unavailableType || !selectedType ? "disabled" : "";
957
+ 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.");
950
958
  return `
951
959
  <form id="datasource-form" class="form-grid">
952
960
  <input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
@@ -954,32 +962,74 @@ function renderDatasourceForm(connector) {
954
962
  <label>Connector key<input name="connector_key" ${connector || systemManaged ? "readonly" : ""} ${disabled} value="${escapeHtml(connector?.connector_key || "")}" /></label>
955
963
  <label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
956
964
  <div class="subgrid">
957
- <label>Database type<select name="database_type" ${disabled}>${renderDatabaseTypeOptions(connector?.database_type || "sqlite")}</select></label>
958
- <label>SQL dialect<select name="sql_dialect" ${disabled}>${renderSqlDialectOptions(connector?.sql_dialect || "sqlite")}</select></label>
965
+ <label>Connector type<select id="datasource-type" name="database_type" ${disabled}>${renderDatasourceTypeOptions(selectedTypeKey)}</select></label>
966
+ <label>SQL dialect<select id="datasource-sql-dialect" name="sql_dialect" ${disabled}>${renderSqlDialectOptions(selectedType, selectedSqlDialect)}</select></label>
959
967
  </div>
960
- <label>Database URL<input name="database_url" ${disabled} value="${escapeHtml(connector?.database_url || "sqlite:///./examples/medical-poc/demo.db")}" /></label>
968
+ <p id="datasource-type-description" class="muted">${escapeHtml(connectorDescription)}</p>
969
+ <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>
961
970
  <label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
962
971
  <div class="button-row">
963
- <button type="button" id="test-datasource">Test</button>
972
+ <button type="button" id="test-datasource" ${disabled}>Test</button>
964
973
  <button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
965
974
  <button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
966
975
  <button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
967
976
  </div>
968
977
  </form>`;
969
978
  }
970
- function renderDatabaseTypeOptions(selected) {
971
- return [
972
- "sqlite",
973
- "postgresql",
974
- "mysql"
975
- ].map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
979
+ function getDatasourceType(typeKey) {
980
+ return state.datasourceTypes.find((item)=>item.type_key === typeKey) || null;
976
981
  }
977
- function renderSqlDialectOptions(selected) {
978
- return [
979
- "sqlite",
980
- "postgres",
981
- "mysql"
982
- ].map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
982
+ function renderDatasourceTypeOptions(selected) {
983
+ const datasourceTypes = [
984
+ ...state.datasourceTypes
985
+ ];
986
+ if (selected && !getDatasourceType(selected)) {
987
+ datasourceTypes.unshift({
988
+ type_key: selected,
989
+ label: `${selected} (plugin unavailable)`,
990
+ description: "",
991
+ sql_dialects: [],
992
+ default_sql_dialect: "",
993
+ config_schema: {}
994
+ });
995
+ }
996
+ if (!datasourceTypes.length) {
997
+ return `<option value="" selected>No connector types available</option>`;
998
+ }
999
+ return datasourceTypes.map((item)=>`<option value="${escapeHtml(item.type_key)}" ${item.type_key === selected ? "selected" : ""}>${escapeHtml(item.label)}</option>`).join("");
1000
+ }
1001
+ function renderSqlDialectOptions(datasourceType, selected) {
1002
+ const dialects = [
1003
+ ...datasourceType?.sql_dialects || []
1004
+ ];
1005
+ if (selected && !dialects.includes(selected)) {
1006
+ dialects.unshift(selected);
1007
+ }
1008
+ if (!dialects.length) {
1009
+ return `<option value="" selected>No SQL dialect available</option>`;
1010
+ }
1011
+ return dialects.map((value)=>`<option value="${escapeHtml(value)}" ${selected === value ? "selected" : ""}>${escapeHtml(value)}</option>`).join("");
1012
+ }
1013
+ function syncDatasourceTypeFields(event) {
1014
+ const typeKey = event.currentTarget.value;
1015
+ const datasourceType = getDatasourceType(typeKey);
1016
+ const sqlDialect = document.querySelector("#datasource-sql-dialect");
1017
+ const description = document.querySelector("#datasource-type-description");
1018
+ const urlLabel = document.querySelector("#datasource-url-label");
1019
+ const urlInput = document.querySelector("#datasource-url");
1020
+ const databaseUrlSchema = datasourceType?.config_schema?.properties?.database_url;
1021
+ if (sqlDialect) {
1022
+ sqlDialect.innerHTML = renderSqlDialectOptions(datasourceType, datasourceType?.default_sql_dialect || "");
1023
+ }
1024
+ if (description) {
1025
+ description.textContent = datasourceType?.description || "Connector type is unavailable.";
1026
+ }
1027
+ if (urlLabel) {
1028
+ urlLabel.textContent = databaseUrlSchema?.title || "Database URL";
1029
+ }
1030
+ if (urlInput) {
1031
+ urlInput.placeholder = databaseUrlSchema?.description || "";
1032
+ }
983
1033
  }
984
1034
  function renderModeOptions(selected, values) {
985
1035
  return values.map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
@@ -1315,6 +1365,7 @@ function attachSectionHandlers() {
1315
1365
  document.querySelector("#llm-config-form")?.addEventListener("submit", saveLlmConfig);
1316
1366
  document.querySelector("#governance-policy-form")?.addEventListener("submit", saveGovernancePolicy);
1317
1367
  document.querySelector("#datasource-form")?.addEventListener("submit", saveDatasource);
1368
+ document.querySelector("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
1318
1369
  document.querySelector("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
1319
1370
  document.querySelector("#schema-show-enabled-only")?.addEventListener("change", (event)=>{
1320
1371
  syncDatasourceSchemaDraftFromForm();
@@ -1974,8 +2025,12 @@ async function loadPrompts() {
1974
2025
  render();
1975
2026
  }
1976
2027
  async function loadDatasources() {
1977
- const result = await api("/api/v1/admin/datasources");
1978
- state.datasources = result.items || [];
2028
+ const [datasources, datasourceTypes] = await Promise.all([
2029
+ api("/api/v1/admin/datasources"),
2030
+ api("/api/v1/admin/datasource-types")
2031
+ ]);
2032
+ state.datasources = datasources.items || [];
2033
+ state.datasourceTypes = datasourceTypes.items || [];
1979
2034
  if (!state.selectedDatasourceId || state.selectedDatasourceId === "new") {
1980
2035
  state.selectedDatasourceId = state.datasources[0]?.id || null;
1981
2036
  }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "gaard-admin-web",
3
+ "private": true,
4
+ "scripts": {
5
+ "build": "esbuild src/main.ts --bundle --format=esm --target=es2020 --outfile=assets/main.js"
6
+ },
7
+ "devDependencies": {
8
+ "esbuild": "^0.25.0"
9
+ }
10
+ }
@@ -26,14 +26,31 @@ type DatasourceConnector = {
26
26
  id: number;
27
27
  connector_key: string;
28
28
  name: string;
29
- database_type: "sqlite" | "postgresql" | "mysql";
29
+ database_type: string;
30
30
  database_url: string;
31
31
  masked_database_url: string;
32
- sql_dialect: "sqlite" | "postgres" | "mysql";
32
+ sql_dialect: string;
33
33
  active: boolean;
34
34
  system_managed: boolean;
35
35
  };
36
36
 
37
+ type DatasourceTypeConfigSchema = {
38
+ properties?: Record<string, {
39
+ title?: string;
40
+ description?: string;
41
+ default?: unknown;
42
+ }>;
43
+ };
44
+
45
+ type DatasourceType = {
46
+ type_key: string;
47
+ label: string;
48
+ description: string;
49
+ sql_dialects: string[];
50
+ default_sql_dialect: string;
51
+ config_schema: DatasourceTypeConfigSchema;
52
+ };
53
+
37
54
  type OverviewDatasource = {
38
55
  connector_key: string;
39
56
  name: string;
@@ -108,6 +125,7 @@ type State = {
108
125
  llmConfig: any | null;
109
126
  governancePolicy: any | null;
110
127
  datasources: DatasourceConnector[];
128
+ datasourceTypes: DatasourceType[];
111
129
  selectedDatasourceId: number | "new" | null;
112
130
  datasourceSchema: any | null;
113
131
  datasourceSchemaLoading: boolean;
@@ -167,6 +185,7 @@ const state: State = {
167
185
  llmConfig: null,
168
186
  governancePolicy: null,
169
187
  datasources: [],
188
+ datasourceTypes: [],
170
189
  selectedDatasourceId: null,
171
190
  datasourceSchema: null,
172
191
  datasourceSchemaLoading: false,
@@ -1143,7 +1162,17 @@ function getSelectedDatasource(): DatasourceConnector | null {
1143
1162
 
1144
1163
  function renderDatasourceForm(connector: DatasourceConnector | null): string {
1145
1164
  const systemManaged = connector?.system_managed === true;
1146
- const disabled = systemManaged ? "disabled" : "";
1165
+ const selectedTypeKey = connector?.database_type || state.datasourceTypes[0]?.type_key || "";
1166
+ const selectedType = getDatasourceType(selectedTypeKey);
1167
+ const databaseUrlSchema = selectedType?.config_schema?.properties?.database_url;
1168
+ const selectedSqlDialect = connector?.sql_dialect || selectedType?.default_sql_dialect || "";
1169
+ const databaseUrl = connector?.database_url || String(databaseUrlSchema?.default || "");
1170
+ const unavailableType = Boolean(selectedTypeKey && !selectedType);
1171
+ const disabled = systemManaged || unavailableType || !selectedType ? "disabled" : "";
1172
+ const connectorDescription = selectedType?.description
1173
+ || (unavailableType
1174
+ ? `Connector type '${selectedTypeKey}' is unavailable. Install or enable its plugin before editing this datasource.`
1175
+ : "No connector types are available. Install or enable a connector plugin.");
1147
1176
  return `
1148
1177
  <form id="datasource-form" class="form-grid">
1149
1178
  <input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
@@ -1151,13 +1180,14 @@ function renderDatasourceForm(connector: DatasourceConnector | null): string {
1151
1180
  <label>Connector key<input name="connector_key" ${connector || systemManaged ? "readonly" : ""} ${disabled} value="${escapeHtml(connector?.connector_key || "")}" /></label>
1152
1181
  <label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
1153
1182
  <div class="subgrid">
1154
- <label>Database type<select name="database_type" ${disabled}>${renderDatabaseTypeOptions(connector?.database_type || "sqlite")}</select></label>
1155
- <label>SQL dialect<select name="sql_dialect" ${disabled}>${renderSqlDialectOptions(connector?.sql_dialect || "sqlite")}</select></label>
1183
+ <label>Connector type<select id="datasource-type" name="database_type" ${disabled}>${renderDatasourceTypeOptions(selectedTypeKey)}</select></label>
1184
+ <label>SQL dialect<select id="datasource-sql-dialect" name="sql_dialect" ${disabled}>${renderSqlDialectOptions(selectedType, selectedSqlDialect)}</select></label>
1156
1185
  </div>
1157
- <label>Database URL<input name="database_url" ${disabled} value="${escapeHtml(connector?.database_url || "sqlite:///./examples/medical-poc/demo.db")}" /></label>
1186
+ <p id="datasource-type-description" class="muted">${escapeHtml(connectorDescription)}</p>
1187
+ <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>
1158
1188
  <label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
1159
1189
  <div class="button-row">
1160
- <button type="button" id="test-datasource">Test</button>
1190
+ <button type="button" id="test-datasource" ${disabled}>Test</button>
1161
1191
  <button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
1162
1192
  <button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
1163
1193
  <button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
@@ -1165,18 +1195,78 @@ function renderDatasourceForm(connector: DatasourceConnector | null): string {
1165
1195
  </form>`;
1166
1196
  }
1167
1197
 
1168
- function renderDatabaseTypeOptions(selected: string): string {
1169
- return ["sqlite", "postgresql", "mysql"]
1170
- .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1198
+ function getDatasourceType(typeKey: string): DatasourceType | null {
1199
+ return state.datasourceTypes.find(item => item.type_key === typeKey) || null;
1200
+ }
1201
+
1202
+ function renderDatasourceTypeOptions(selected: string): string {
1203
+ const datasourceTypes = [...state.datasourceTypes];
1204
+
1205
+ if (selected && !getDatasourceType(selected)) {
1206
+ datasourceTypes.unshift({
1207
+ type_key: selected,
1208
+ label: `${selected} (plugin unavailable)`,
1209
+ description: "",
1210
+ sql_dialects: [],
1211
+ default_sql_dialect: "",
1212
+ config_schema: {},
1213
+ });
1214
+ }
1215
+
1216
+ if (!datasourceTypes.length) {
1217
+ return `<option value="" selected>No connector types available</option>`;
1218
+ }
1219
+
1220
+ return datasourceTypes
1221
+ .map(item => `<option value="${escapeHtml(item.type_key)}" ${item.type_key === selected ? "selected" : ""}>${escapeHtml(item.label)}</option>`)
1171
1222
  .join("");
1172
1223
  }
1173
1224
 
1174
- function renderSqlDialectOptions(selected: string): string {
1175
- return ["sqlite", "postgres", "mysql"]
1176
- .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1225
+ function renderSqlDialectOptions(datasourceType: DatasourceType | null, selected: string): string {
1226
+ const dialects = [...(datasourceType?.sql_dialects || [])];
1227
+
1228
+ if (selected && !dialects.includes(selected)) {
1229
+ dialects.unshift(selected);
1230
+ }
1231
+
1232
+ if (!dialects.length) {
1233
+ return `<option value="" selected>No SQL dialect available</option>`;
1234
+ }
1235
+
1236
+ return dialects
1237
+ .map(value => `<option value="${escapeHtml(value)}" ${selected === value ? "selected" : ""}>${escapeHtml(value)}</option>`)
1177
1238
  .join("");
1178
1239
  }
1179
1240
 
1241
+ function syncDatasourceTypeFields(event: Event): void {
1242
+ const typeKey = (event.currentTarget as HTMLSelectElement).value;
1243
+ const datasourceType = getDatasourceType(typeKey);
1244
+ const sqlDialect = document.querySelector<HTMLSelectElement>("#datasource-sql-dialect");
1245
+ const description = document.querySelector<HTMLElement>("#datasource-type-description");
1246
+ const urlLabel = document.querySelector<HTMLElement>("#datasource-url-label");
1247
+ const urlInput = document.querySelector<HTMLInputElement>("#datasource-url");
1248
+ const databaseUrlSchema = datasourceType?.config_schema?.properties?.database_url;
1249
+
1250
+ if (sqlDialect) {
1251
+ sqlDialect.innerHTML = renderSqlDialectOptions(
1252
+ datasourceType,
1253
+ datasourceType?.default_sql_dialect || "",
1254
+ );
1255
+ }
1256
+
1257
+ if (description) {
1258
+ description.textContent = datasourceType?.description || "Connector type is unavailable.";
1259
+ }
1260
+
1261
+ if (urlLabel) {
1262
+ urlLabel.textContent = databaseUrlSchema?.title || "Database URL";
1263
+ }
1264
+
1265
+ if (urlInput) {
1266
+ urlInput.placeholder = databaseUrlSchema?.description || "";
1267
+ }
1268
+ }
1269
+
1180
1270
  function renderModeOptions(selected: string, values: string[]): string {
1181
1271
  return values
1182
1272
  .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
@@ -1533,6 +1623,7 @@ function attachSectionHandlers(): void {
1533
1623
  document.querySelector<HTMLFormElement>("#llm-config-form")?.addEventListener("submit", saveLlmConfig);
1534
1624
  document.querySelector<HTMLFormElement>("#governance-policy-form")?.addEventListener("submit", saveGovernancePolicy);
1535
1625
  document.querySelector<HTMLFormElement>("#datasource-form")?.addEventListener("submit", saveDatasource);
1626
+ document.querySelector<HTMLSelectElement>("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
1536
1627
  document.querySelector<HTMLFormElement>("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
1537
1628
  document.querySelector<HTMLInputElement>("#schema-show-enabled-only")?.addEventListener("change", event => {
1538
1629
  syncDatasourceSchemaDraftFromForm();
@@ -2252,8 +2343,12 @@ async function loadPrompts(): Promise<void> {
2252
2343
  }
2253
2344
 
2254
2345
  async function loadDatasources(): Promise<void> {
2255
- const result = await api<any>("/api/v1/admin/datasources");
2256
- state.datasources = result.items || [];
2346
+ const [datasources, datasourceTypes] = await Promise.all([
2347
+ api<{ items: DatasourceConnector[] }>("/api/v1/admin/datasources"),
2348
+ api<{ items: DatasourceType[] }>("/api/v1/admin/datasource-types"),
2349
+ ]);
2350
+ state.datasources = datasources.items || [];
2351
+ state.datasourceTypes = datasourceTypes.items || [];
2257
2352
  if (!state.selectedDatasourceId || state.selectedDatasourceId === "new") {
2258
2353
  state.selectedDatasourceId = state.datasources[0]?.id || null;
2259
2354
  }