gaard-api 0.2.11__tar.gz → 0.2.13__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 (78) hide show
  1. {gaard_api-0.2.11/src/gaard_api.egg-info → gaard_api-0.2.13}/PKG-INFO +1 -1
  2. {gaard_api-0.2.11 → gaard_api-0.2.13}/pyproject.toml +1 -1
  3. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/services.py +1 -0
  4. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/main.js +446 -22
  5. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/styles.css +22 -0
  6. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/admin.py +703 -1
  7. {gaard_api-0.2.11 → gaard_api-0.2.13/src/gaard_api.egg-info}/PKG-INFO +1 -1
  8. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_admin_api.py +160 -0
  9. {gaard_api-0.2.11 → gaard_api-0.2.13}/MANIFEST.in +0 -0
  10. {gaard_api-0.2.11 → gaard_api-0.2.13}/README.md +0 -0
  11. {gaard_api-0.2.11 → gaard_api-0.2.13}/setup.cfg +0 -0
  12. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/__init__.py +0 -0
  13. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/__init__.py +0 -0
  14. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/database.py +0 -0
  15. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/database_initial.sql +0 -0
  16. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
  17. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/database_updates.sql +0 -0
  18. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/defaults.py +0 -0
  19. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/migration_runner.py +0 -0
  20. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/models.py +0 -0
  21. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/prompt_runtime.py +0 -0
  22. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin/security.py +0 -0
  23. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  24. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/identity.js +0 -0
  25. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  26. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  27. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/index.html +0 -0
  28. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/admin-web/package.json +0 -0
  29. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/__init__.py +0 -0
  30. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/__init__.py +0 -0
  31. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/analysis.py +0 -0
  32. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/dashboards.py +0 -0
  33. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/prompts.py +0 -0
  34. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/query.py +0 -0
  35. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api/v1/schema.py +0 -0
  36. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/api_registry.py +0 -0
  37. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/auth_dependencies.py +0 -0
  38. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/auth_hooks.py +0 -0
  39. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/cli.py +0 -0
  40. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/cli_commands.py +0 -0
  41. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/conversations.py +0 -0
  42. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/core/__init__.py +0 -0
  43. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/core/error_handlers.py +0 -0
  44. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/core/schema_cache.py +0 -0
  45. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/core/settings.py +0 -0
  46. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/example_data/__init__.py +0 -0
  47. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  48. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  49. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  50. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/example_database.py +0 -0
  51. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/extension_services.py +0 -0
  52. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/extensions.py +0 -0
  53. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/license.py +0 -0
  54. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/main.py +0 -0
  55. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/package_updates.py +0 -0
  56. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/py.typed +0 -0
  57. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/query_hooks.py +0 -0
  58. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/server_cli.py +0 -0
  59. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/siem.py +0 -0
  60. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api/tls_http.py +0 -0
  61. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  62. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  63. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api.egg-info/entry_points.txt +0 -0
  64. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api.egg-info/requires.txt +0 -0
  65. {gaard_api-0.2.11 → gaard_api-0.2.13}/src/gaard_api.egg-info/top_level.txt +0 -0
  66. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_analysis_api.py +0 -0
  67. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_api_cli.py +0 -0
  68. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_api_registry.py +0 -0
  69. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_conversation_api.py +0 -0
  70. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_database_migrations.py +0 -0
  71. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_error_handlers.py +0 -0
  72. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_license_api.py +0 -0
  73. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_license_service.py +0 -0
  74. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_prompt_runtime.py +0 -0
  75. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_query_error_messages.py +0 -0
  76. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_server_cli.py +0 -0
  77. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_settings.py +0 -0
  78. {gaard_api-0.2.11 → gaard_api-0.2.13}/tests/test_tls_http.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.11"
7
+ version = "0.2.13"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2229,6 +2229,7 @@ def build_table_settings(
2229
2229
  tables[table.name] = {
2230
2230
  "selected": bool(existing.get("selected", True)),
2231
2231
  "description": str(existing.get("description", "")),
2232
+ "view_description": str(existing.get("view_description", "")),
2232
2233
  "primary_key_prompt": str(existing.get("primary_key_prompt", "")),
2233
2234
  "foreign_key_prompt": str(existing.get("foreign_key_prompt", "")),
2234
2235
  "join_logic": str(existing.get("join_logic", "")),
@@ -1,6 +1,8 @@
1
1
  import { createIdentityModule } from "./identity.js";
2
2
 
3
3
  var app = document.querySelector("#app");
4
+ var renderedSection = "";
5
+ var scrollRestoreGeneration = 0;
4
6
  var licensePackageUpdatePollTimer = null;
5
7
  var overviewGridStack = null;
6
8
  var overviewGridSaveTimer = null;
@@ -91,6 +93,20 @@ var state = {
91
93
  datasourceSchemaSelectedObjectName: "",
92
94
  datasourceSchemaShowEnabledOnly: false,
93
95
  datasourceSchemaDraftTables: null,
96
+ datasourceViewDialog: {
97
+ open: false,
98
+ mode: "create",
99
+ originalName: "",
100
+ loadedName: "",
101
+ loadedSql: "",
102
+ name: "",
103
+ view_description: "",
104
+ sql: "",
105
+ error: "",
106
+ success: "",
107
+ busy: "",
108
+ executed: false
109
+ },
94
110
  datasourceDetailTab: "config",
95
111
  datasourceSchemaContentTab: "details",
96
112
  datasourceSchemaEditorTab: "tables",
@@ -420,24 +436,100 @@ function logout() {
420
436
  }
421
437
  function render() {
422
438
  if (!app) return;
423
- if (!state.token) return renderLogin();
424
- if (state.mustChangePassword) return renderPasswordChange();
425
- renderShell();
439
+ const scrollSnapshot = captureScrollSnapshot();
440
+ const restoreGeneration = ++scrollRestoreGeneration;
441
+ if (!state.token) {
442
+ renderLogin();
443
+ } else if (state.mustChangePassword) {
444
+ renderPasswordChange();
445
+ } else {
446
+ renderShell();
447
+ }
448
+ restoreScrollSnapshot(scrollSnapshot, restoreGeneration);
426
449
  }
427
450
  function renderDatasourceSchemaPreservingScroll() {
428
- const content = document.querySelector(".content");
429
- const schemaObjectListBody = document.querySelector(".schema-object-list-body");
430
- const contentScrollTop = content?.scrollTop || 0;
431
- const schemaObjectListScrollTop = schemaObjectListBody?.scrollTop || 0;
432
-
433
451
  render();
434
-
435
- const nextContent = document.querySelector(".content");
436
- const nextSchemaObjectListBody = document.querySelector(".schema-object-list-body");
437
- if (nextContent) nextContent.scrollTop = contentScrollTop;
438
- if (nextSchemaObjectListBody) nextSchemaObjectListBody.scrollTop = schemaObjectListScrollTop;
452
+ }
453
+ function collectScrollableElements() {
454
+ const targets = [];
455
+ const rootScroller = document.scrollingElement || document.documentElement;
456
+ if (rootScroller) targets.push(rootScroller);
457
+ if (!app) return targets;
458
+ app.querySelectorAll("*").forEach((element) => {
459
+ if (!(element instanceof HTMLElement)) return;
460
+ const styles = window.getComputedStyle(element);
461
+ const canScrollY = ["auto", "scroll", "overlay"].includes(styles.overflowY) && element.scrollHeight > element.clientHeight + 1;
462
+ const canScrollX = ["auto", "scroll", "overlay"].includes(styles.overflowX) && element.scrollWidth > element.clientWidth + 1;
463
+ if (canScrollY || canScrollX) targets.push(element);
464
+ });
465
+ return targets;
466
+ }
467
+ function getScrollElementKey(element) {
468
+ const rootScroller = document.scrollingElement || document.documentElement;
469
+ if (element === rootScroller) return "__document__";
470
+ const parts = [];
471
+ let current = element;
472
+ while (current && current !== app && current instanceof HTMLElement) {
473
+ let part = current.tagName.toLowerCase();
474
+ if (current.id) {
475
+ part += `#${current.id}`;
476
+ } else {
477
+ const dataEntry = Object.entries(current.dataset || {}).find(([, value]) => value);
478
+ if (dataEntry) part += `[data-${dataEntry[0]}="${dataEntry[1]}"]`;
479
+ const classNames = Array.from(current.classList || []).sort();
480
+ if (classNames.length) part += `.${classNames.join(".")}`;
481
+ const parent = current.parentElement;
482
+ if (parent) {
483
+ const siblings = Array.from(parent.children).filter((sibling) => sibling.tagName === current.tagName);
484
+ if (siblings.length > 1) part += `:nth(${siblings.indexOf(current)})`;
485
+ }
486
+ }
487
+ parts.unshift(part);
488
+ current = current.parentElement;
489
+ }
490
+ return parts.join(">");
491
+ }
492
+ function getScrollPosition(element) {
493
+ const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
494
+ const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
495
+ return {
496
+ top: element.scrollTop,
497
+ left: element.scrollLeft,
498
+ atBottom: maxTop > 0 && maxTop - element.scrollTop <= 2,
499
+ atRight: maxLeft > 0 && maxLeft - element.scrollLeft <= 2
500
+ };
501
+ }
502
+ function captureScrollSnapshot() {
503
+ if (!app || !renderedSection) return null;
504
+ const positions = new Map();
505
+ collectScrollableElements().forEach((element) => {
506
+ positions.set(getScrollElementKey(element), getScrollPosition(element));
507
+ });
508
+ return { section: renderedSection, positions };
509
+ }
510
+ function restoreScrollSnapshot(snapshot, restoreGeneration) {
511
+ if (!snapshot || snapshot.section !== renderedSection || !snapshot.positions.size) return;
512
+ window.requestAnimationFrame(() => {
513
+ if (restoreGeneration !== scrollRestoreGeneration) return;
514
+ window.requestAnimationFrame(() => {
515
+ if (restoreGeneration !== scrollRestoreGeneration) return;
516
+ const targets = new Map();
517
+ collectScrollableElements().forEach((element) => {
518
+ targets.set(getScrollElementKey(element), element);
519
+ });
520
+ snapshot.positions.forEach((position, key) => {
521
+ const element = targets.get(key);
522
+ if (!element) return;
523
+ const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
524
+ const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
525
+ element.scrollTop = position.atBottom ? maxTop : Math.min(position.top, maxTop);
526
+ element.scrollLeft = position.atRight ? maxLeft : Math.min(position.left, maxLeft);
527
+ });
528
+ });
529
+ });
439
530
  }
440
531
  function renderLogin() {
532
+ renderedSection = "login";
441
533
  app.innerHTML = `
442
534
  <main class="login-shell">
443
535
  <section class="login-panel">
@@ -476,6 +568,7 @@ function renderLogin() {
476
568
  });
477
569
  }
478
570
  function renderPasswordChange() {
571
+ renderedSection = "password-change";
479
572
  app.innerHTML = `
480
573
  <main class="login-shell">
481
574
  <section class="login-panel">
@@ -519,6 +612,7 @@ function renderPasswordChange() {
519
612
  }
520
613
  function renderShell() {
521
614
  const activeLabel = getSectionLabel(state.section);
615
+ renderedSection = state.section;
522
616
  app.innerHTML = `
523
617
  <div class="app-shell">
524
618
  ${renderSidebar()}
@@ -1441,7 +1535,8 @@ function renderDatasources() {
1441
1535
  <div class="panel-body datasource-detail-grid">${renderDatasourceForm(selected)}</div>
1442
1536
  </section>
1443
1537
  </div>
1444
- ${selected ? renderDatasourceSchema() : ""}`;
1538
+ ${selected ? renderDatasourceSchema() : ""}
1539
+ ${renderDatasourceViewDialog()}`;
1445
1540
  }
1446
1541
  function getSelectedDatasource() {
1447
1542
  if (state.selectedDatasourceId === "new") return null;
@@ -1782,7 +1877,13 @@ function renderDatasourceSchema() {
1782
1877
  const activeContentTab = getDatasourceExtensionTab("schema", extensionPanels);
1783
1878
  return `
1784
1879
  <section class="panel">
1785
- <div class="panel-header"><h2>Schema introspection</h2><span class="badge">${escapeHtml(schema?.introspected_at || "not cached")}</span></div>
1880
+ <div class="panel-header">
1881
+ <h2>Schema introspection</h2>
1882
+ <div class="panel-header-actions">
1883
+ <button type="button" id="create-datasource-view">Create view</button>
1884
+ <span class="badge">${escapeHtml(schema?.introspected_at || "not cached")}</span>
1885
+ </div>
1886
+ </div>
1786
1887
  <div class="panel-body datasource-schema-layout">
1787
1888
  ${state.datasourceSchemaLoading ? `<p class="muted">loading schema</p>` : state.datasourceSchemaError ? `<p class="error">${escapeHtml(state.datasourceSchemaError)}</p>` : schema ? `
1788
1889
  <form id="datasource-schema-form" class="schema-editor mobile-active">
@@ -1808,6 +1909,41 @@ function renderDatasourceSchema() {
1808
1909
  </div>
1809
1910
  </section>`;
1810
1911
  }
1912
+ function renderDatasourceViewDialog() {
1913
+ const dialog = state.datasourceViewDialog || {};
1914
+ if (!dialog.open) return "";
1915
+ const busy = dialog.busy || "";
1916
+ const isBusy = Boolean(busy);
1917
+ const isEdit = dialog.mode === "edit";
1918
+ const title = isEdit ? "Edit view" : "Create view";
1919
+ return `
1920
+ <div class="modal-backdrop" data-datasource-view-backdrop>
1921
+ <section class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="datasource-view-dialog-title">
1922
+ <div class="modal-header">
1923
+ <div>
1924
+ <h2 id="datasource-view-dialog-title">${escapeHtml(title)}</h2>
1925
+ </div>
1926
+ </div>
1927
+ <form id="datasource-view-form" class="form-grid">
1928
+ <label>View name<input name="name" value="${escapeHtml(dialog.name || "")}" autocomplete="off" /></label>
1929
+ <label>View description<textarea name="view_description">${escapeHtml(dialog.view_description || "")}</textarea></label>
1930
+ <label>SQL<textarea name="sql" class="textarea-small" spellcheck="false">${escapeHtml(dialog.sql || "")}</textarea></label>
1931
+ ${busy === "load" ? `<p class="muted"><span class="spinner" aria-hidden="true"></span> Loading view definition.</p>` : ""}
1932
+ ${dialog.error ? `<p class="error">${escapeHtml(dialog.error)}</p>` : ""}
1933
+ ${dialog.success ? `<p class="success">${escapeHtml(dialog.success)}</p>` : ""}
1934
+ <div class="form-actions modal-actions">
1935
+ <button type="button" data-datasource-view-cancel ${isBusy ? "disabled" : ""}>Cancel</button>
1936
+ <button type="button" data-datasource-view-generate ${isBusy ? "disabled" : ""}>${renderBusyButtonLabel(busy, "generate", "Generate SQL")}</button>
1937
+ <button type="button" data-datasource-view-execute ${isBusy ? "disabled" : ""}>${renderBusyButtonLabel(busy, "execute", "Execute SQL")}</button>
1938
+ <button type="submit" class="primary" ${isBusy ? "disabled" : ""}>${renderBusyButtonLabel(busy, "save", "Save View")}</button>
1939
+ </div>
1940
+ </form>
1941
+ </section>
1942
+ </div>`;
1943
+ }
1944
+ function renderBusyButtonLabel(activeBusy, key, label) {
1945
+ return activeBusy === key ? `<span class="spinner" aria-hidden="true"></span><span>${escapeHtml(label)}</span>` : escapeHtml(label);
1946
+ }
1811
1947
  function getDatasourceExtensionPanels(slot, arguments_) {
1812
1948
  const renderer = slot === "detail" ? "renderDetail" : "renderSchema";
1813
1949
  const label = slot === "detail" ? "detailLabel" : "schemaLabel";
@@ -1840,6 +1976,7 @@ function getDatasourceSchemaDraftTables(rawTables, tableSettings) {
1840
1976
  state.datasourceSchemaDraftTables[table.name] = {
1841
1977
  selected: settings.selected !== false,
1842
1978
  description: settings.description || "",
1979
+ view_description: settings.view_description || "",
1843
1980
  primary_key_prompt: settings.primary_key_prompt || "",
1844
1981
  foreign_key_prompt: settings.foreign_key_prompt || "",
1845
1982
  join_logic: settings.join_logic || ""
@@ -2035,11 +2172,10 @@ function renderDatasourceObjectDetails(table, settings) {
2035
2172
  <h3>${escapeHtml(table.name)}</h3>
2036
2173
  <span class="badge">${escapeHtml(objectType)}</span>
2037
2174
  </div>
2175
+ ${objectType === "view" ? `<div class="schema-object-actions"><button type="button" data-edit-datasource-view="${escapeHtml(table.name)}">Edit view</button><button type="button" class="danger" data-delete-datasource-view="${escapeHtml(table.name)}">Delete view</button></div>` : ""}
2038
2176
  </div>
2039
2177
  <div class="schema-object-columns">${escapeHtml((table.columns || []).map((column) => `${column.name}:${column.type}${column.primary_key ? " pk" : ""}`).join(", ") || "No columns available.")}</div>
2040
- <label>Description<input data-schema-detail="description" name="${escapeHtml(table.name)}__description" value="${escapeHtml(settings.description || "")}" /></label>
2041
- <label>Primary key guidance<input data-schema-detail="primary_key_prompt" name="${escapeHtml(table.name)}__primary_key_prompt" value="${escapeHtml(settings.primary_key_prompt || "")}" /></label>
2042
- <label>Foreign key guidance<input data-schema-detail="foreign_key_prompt" name="${escapeHtml(table.name)}__foreign_key_prompt" value="${escapeHtml(settings.foreign_key_prompt || "")}" /></label>
2178
+ <label>Datasource logic<textarea data-schema-detail="description" class="textarea-small" name="${escapeHtml(table.name)}__description">${escapeHtml(settings.description || "")}</textarea></label>
2043
2179
  <label>Join logic<textarea data-schema-detail="join_logic" class="textarea-small" name="${escapeHtml(table.name)}__join_logic">${escapeHtml(settings.join_logic || "")}</textarea></label>`;
2044
2180
  }
2045
2181
  function renderSchemaCache() {
@@ -2263,6 +2399,16 @@ function attachSectionHandlers() {
2263
2399
  document.querySelector("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
2264
2400
  document.querySelector("[data-connection-field='connection_mode']")?.addEventListener("change", syncOdbcConnectionModeFields);
2265
2401
  document.querySelector("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
2402
+ document.querySelector("#create-datasource-view")?.addEventListener("click", openDatasourceViewDialog);
2403
+ document.querySelector("#datasource-view-form")?.addEventListener("submit", saveDatasourceView);
2404
+ document.querySelector("[data-datasource-view-generate]")?.addEventListener("click", generateDatasourceViewSql);
2405
+ document.querySelector("[data-datasource-view-execute]")?.addEventListener("click", executeDatasourceViewSql);
2406
+ document.querySelector("[data-datasource-view-cancel]")?.addEventListener("click", closeDatasourceViewDialog);
2407
+ document.querySelector("[data-datasource-view-backdrop]")?.addEventListener("click", (event) => {
2408
+ if (event.target === event.currentTarget && !state.datasourceViewDialog?.busy) {
2409
+ closeDatasourceViewDialog();
2410
+ }
2411
+ });
2266
2412
  document.querySelectorAll("[data-datasource-detail-tab]").forEach((button) => button.addEventListener("click", () => {
2267
2413
  state.datasourceDetailTab = button.dataset.datasourceDetailTab || "config";
2268
2414
  render();
@@ -2294,6 +2440,12 @@ function attachSectionHandlers() {
2294
2440
  render();
2295
2441
  });
2296
2442
  });
2443
+ document.querySelectorAll("[data-delete-datasource-view]").forEach((button) => {
2444
+ button.addEventListener("click", deleteDatasourceView);
2445
+ });
2446
+ document.querySelectorAll("[data-edit-datasource-view]").forEach((button) => {
2447
+ button.addEventListener("click", openEditDatasourceViewDialog);
2448
+ });
2297
2449
  document.querySelector("#invalidate-schema-cache")?.addEventListener("click", invalidateSchemaCache);
2298
2450
  document.querySelector("#test-datasource")?.addEventListener("click", testDatasource);
2299
2451
  document.querySelector("#introspect-datasource")?.addEventListener("click", introspectDatasource);
@@ -2344,6 +2496,7 @@ function attachSectionHandlers() {
2344
2496
  state.datasourceSchemaError = "";
2345
2497
  state.datasourceSchemaSelectedObjectName = "";
2346
2498
  state.datasourceSchemaDraftTables = null;
2499
+ resetDatasourceViewDialog();
2347
2500
  render();
2348
2501
  });
2349
2502
  document.querySelectorAll("[data-prompt]").forEach((button) => {
@@ -2355,6 +2508,7 @@ function attachSectionHandlers() {
2355
2508
  document.querySelectorAll("[data-datasource]").forEach((button) => {
2356
2509
  button.addEventListener("click", async () => {
2357
2510
  state.selectedDatasourceId = Number(button.dataset.datasource);
2511
+ resetDatasourceViewDialog();
2358
2512
  await loadDatasourceExtensions();
2359
2513
  await loadDatasourceSchema();
2360
2514
  });
@@ -3179,6 +3333,269 @@ async function testDatasource() {
3179
3333
  setMessage("error", extractErrorMessage(error));
3180
3334
  }
3181
3335
  }
3336
+ function defaultDatasourceViewDialog() {
3337
+ return {
3338
+ open: false,
3339
+ mode: "create",
3340
+ originalName: "",
3341
+ loadedName: "",
3342
+ loadedSql: "",
3343
+ name: "",
3344
+ view_description: "",
3345
+ sql: "",
3346
+ error: "",
3347
+ success: "",
3348
+ busy: "",
3349
+ executed: false
3350
+ };
3351
+ }
3352
+ function resetDatasourceViewDialog() {
3353
+ state.datasourceViewDialog = defaultDatasourceViewDialog();
3354
+ }
3355
+ function openDatasourceViewDialog() {
3356
+ state.datasourceViewDialog = {
3357
+ ...defaultDatasourceViewDialog(),
3358
+ mode: "create",
3359
+ open: true
3360
+ };
3361
+ render();
3362
+ }
3363
+ async function openEditDatasourceViewDialog(event) {
3364
+ const selected = getSelectedDatasource();
3365
+ const viewName = event.currentTarget?.dataset?.editDatasourceView || "";
3366
+ if (!selected || !viewName) return;
3367
+ syncDatasourceSchemaDraftFromForm();
3368
+ const rawTables = state.datasourceSchema?.item?.raw_schema?.tables || [];
3369
+ const tableSettings = state.datasourceSchema?.item?.table_settings?.tables || {};
3370
+ const draftSettings = getDatasourceSchemaDraftTables(rawTables, tableSettings)[viewName] || {};
3371
+ const draftViewDescription = draftSettings.view_description || "";
3372
+ state.datasourceViewDialog = {
3373
+ ...defaultDatasourceViewDialog(),
3374
+ open: true,
3375
+ mode: "edit",
3376
+ originalName: viewName,
3377
+ loadedName: viewName,
3378
+ name: viewName,
3379
+ view_description: draftViewDescription,
3380
+ busy: "load"
3381
+ };
3382
+ render();
3383
+ try {
3384
+ const result = await api(`/api/v1/admin/datasources/${selected.id}/schema/views/${encodeURIComponent(viewName)}`);
3385
+ const item = result.item || {};
3386
+ const viewDescription = draftViewDescription || item.view_description || item.description || "";
3387
+ state.datasourceViewDialog = {
3388
+ ...state.datasourceViewDialog,
3389
+ mode: "edit",
3390
+ originalName: item.name || viewName,
3391
+ loadedName: item.name || viewName,
3392
+ loadedSql: item.sql || "",
3393
+ name: item.name || viewName,
3394
+ view_description: viewDescription,
3395
+ sql: item.sql || "",
3396
+ busy: "",
3397
+ error: "",
3398
+ success: "",
3399
+ executed: true
3400
+ };
3401
+ render();
3402
+ } catch (error) {
3403
+ state.datasourceViewDialog = {
3404
+ ...state.datasourceViewDialog,
3405
+ busy: "",
3406
+ error: error.message,
3407
+ success: ""
3408
+ };
3409
+ render();
3410
+ }
3411
+ }
3412
+ function closeDatasourceViewDialog() {
3413
+ if (state.datasourceViewDialog?.busy) return;
3414
+ resetDatasourceViewDialog();
3415
+ render();
3416
+ }
3417
+ function readDatasourceViewDialogPayload() {
3418
+ const form = document.querySelector("#datasource-view-form");
3419
+ if (!form) {
3420
+ const dialog = state.datasourceViewDialog || {};
3421
+ return {
3422
+ name: String(dialog.name || "").trim(),
3423
+ view_description: String(dialog.view_description || ""),
3424
+ sql: String(dialog.sql || "")
3425
+ };
3426
+ }
3427
+ const data = new FormData(form);
3428
+ return {
3429
+ name: String(data.get("name") || "").trim(),
3430
+ view_description: String(data.get("view_description") || ""),
3431
+ sql: String(data.get("sql") || "")
3432
+ };
3433
+ }
3434
+ function setDatasourceViewDialogBusy(payload, busy) {
3435
+ state.datasourceViewDialog = {
3436
+ ...state.datasourceViewDialog,
3437
+ ...payload,
3438
+ busy,
3439
+ error: "",
3440
+ success: ""
3441
+ };
3442
+ render();
3443
+ }
3444
+ function validateDatasourceViewPayload(payload, requireSql = false) {
3445
+ if (!payload.name) return "View name is required.";
3446
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(payload.name)) {
3447
+ return "View name must start with a letter or underscore and contain only letters, numbers and underscores.";
3448
+ }
3449
+ if (requireSql && !payload.sql.trim()) return "SQL is required.";
3450
+ return "";
3451
+ }
3452
+ async function generateDatasourceViewSql() {
3453
+ const selected = getSelectedDatasource();
3454
+ if (!selected) return;
3455
+ const dialog = state.datasourceViewDialog || {};
3456
+ const payload = readDatasourceViewDialogPayload();
3457
+ const validationError = validateDatasourceViewPayload(payload);
3458
+ if (validationError) {
3459
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, error: validationError, success: "" };
3460
+ render();
3461
+ return;
3462
+ }
3463
+ setDatasourceViewDialogBusy(payload, "generate");
3464
+ try {
3465
+ const result = await api(`/api/v1/admin/datasources/${selected.id}/schema/views/generate-sql`, {
3466
+ method: "POST",
3467
+ body: JSON.stringify({ name: payload.name, view_description: payload.view_description })
3468
+ });
3469
+ state.datasourceViewDialog = {
3470
+ ...state.datasourceViewDialog,
3471
+ ...payload,
3472
+ mode: dialog.mode || "create",
3473
+ sql: result.sql || "",
3474
+ busy: "",
3475
+ error: "",
3476
+ success: "SQL generated.",
3477
+ executed: false
3478
+ };
3479
+ render();
3480
+ } catch (error) {
3481
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, busy: "", error: error.message, success: "" };
3482
+ render();
3483
+ }
3484
+ }
3485
+ async function executeDatasourceViewSql() {
3486
+ const selected = getSelectedDatasource();
3487
+ if (!selected) return;
3488
+ const dialog = state.datasourceViewDialog || {};
3489
+ const payload = readDatasourceViewDialogPayload();
3490
+ const validationError = validateDatasourceViewPayload(payload, true);
3491
+ if (validationError) {
3492
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, error: validationError, success: "" };
3493
+ render();
3494
+ return;
3495
+ }
3496
+ setDatasourceViewDialogBusy(payload, "execute");
3497
+ try {
3498
+ const result = dialog.mode === "edit"
3499
+ ? await api(`/api/v1/admin/datasources/${selected.id}/schema/views/${encodeURIComponent(dialog.originalName || payload.name)}/execute`, {
3500
+ method: "PUT",
3501
+ body: JSON.stringify(payload)
3502
+ })
3503
+ : await api(`/api/v1/admin/datasources/${selected.id}/schema/views/execute`, {
3504
+ method: "POST",
3505
+ body: JSON.stringify(payload)
3506
+ });
3507
+ state.datasourceSchema = result;
3508
+ state.datasourceSchemaSelectedObjectName = payload.name;
3509
+ state.datasourceSchemaDraftTables = null;
3510
+ state.datasourceViewDialog = {
3511
+ ...state.datasourceViewDialog,
3512
+ ...payload,
3513
+ originalName: payload.name,
3514
+ loadedName: payload.name,
3515
+ loadedSql: payload.sql,
3516
+ busy: "",
3517
+ error: "",
3518
+ success: "View executed and schema introspection refreshed.",
3519
+ executed: true
3520
+ };
3521
+ render();
3522
+ } catch (error) {
3523
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, busy: "", error: error.message, success: "" };
3524
+ render();
3525
+ }
3526
+ }
3527
+ async function saveDatasourceView(event) {
3528
+ event.preventDefault();
3529
+ const selected = getSelectedDatasource();
3530
+ if (!selected) return;
3531
+ const dialog = state.datasourceViewDialog || {};
3532
+ const payload = readDatasourceViewDialogPayload();
3533
+ const canSaveSettingsOnly = (
3534
+ dialog.executed
3535
+ && (dialog.loadedName || dialog.name) === payload.name
3536
+ && (dialog.loadedSql || dialog.sql) === payload.sql
3537
+ );
3538
+ const validationError = validateDatasourceViewPayload(payload, !canSaveSettingsOnly);
3539
+ if (validationError) {
3540
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, error: validationError, success: "" };
3541
+ render();
3542
+ return;
3543
+ }
3544
+ setDatasourceViewDialogBusy(payload, "save");
3545
+ try {
3546
+ const result = canSaveSettingsOnly
3547
+ ? await api(`/api/v1/admin/datasources/${selected.id}/schema/views/${encodeURIComponent(dialog.originalName || payload.name)}`, {
3548
+ method: "PUT",
3549
+ body: JSON.stringify({ view_description: payload.view_description })
3550
+ })
3551
+ : dialog.mode === "edit"
3552
+ ? await api(`/api/v1/admin/datasources/${selected.id}/schema/views/${encodeURIComponent(dialog.originalName || payload.name)}/execute`, {
3553
+ method: "PUT",
3554
+ body: JSON.stringify(payload)
3555
+ })
3556
+ : await api(`/api/v1/admin/datasources/${selected.id}/schema/views/execute`, {
3557
+ method: "POST",
3558
+ body: JSON.stringify(payload)
3559
+ });
3560
+ state.datasourceSchema = result;
3561
+ state.datasourceSchemaSelectedObjectName = payload.name;
3562
+ state.datasourceSchemaDraftTables = null;
3563
+ resetDatasourceViewDialog();
3564
+ setMessage("success", "View saved.");
3565
+ render();
3566
+ } catch (error) {
3567
+ state.datasourceViewDialog = { ...state.datasourceViewDialog, ...payload, busy: "", error: error.message, success: "" };
3568
+ render();
3569
+ }
3570
+ }
3571
+ async function deleteDatasourceView(event) {
3572
+ const selected = getSelectedDatasource();
3573
+ const viewName = event.currentTarget?.dataset?.deleteDatasourceView || "";
3574
+ if (!selected || !viewName) return;
3575
+ if (!await requestConfirmation({
3576
+ title: "Delete view",
3577
+ message: `Delete view "${viewName}"?`,
3578
+ confirmLabel: "Delete"
3579
+ })) return;
3580
+ try {
3581
+ state.datasourceSchemaLoading = true;
3582
+ state.datasourceSchemaError = "";
3583
+ render();
3584
+ state.datasourceSchema = await api(`/api/v1/admin/datasources/${selected.id}/schema/views/${encodeURIComponent(viewName)}`, {
3585
+ method: "DELETE"
3586
+ });
3587
+ state.datasourceSchemaSelectedObjectName = "";
3588
+ state.datasourceSchemaDraftTables = null;
3589
+ state.datasourceSchemaLoading = false;
3590
+ setMessage("success", "View deleted.");
3591
+ render();
3592
+ } catch (error) {
3593
+ state.datasourceSchemaLoading = false;
3594
+ state.datasourceSchemaError = error.message;
3595
+ setMessage("error", error.message);
3596
+ render();
3597
+ }
3598
+ }
3182
3599
  async function introspectDatasource() {
3183
3600
  const selected = getSelectedDatasource();
3184
3601
  if (!selected) return;
@@ -3227,6 +3644,7 @@ async function deleteDatasource() {
3227
3644
  state.datasourceSchemaError = "";
3228
3645
  state.datasourceSchemaSelectedObjectName = "";
3229
3646
  state.datasourceSchemaDraftTables = null;
3647
+ resetDatasourceViewDialog();
3230
3648
  setMessage("success", "Datasource deleted.");
3231
3649
  await loadDatasources();
3232
3650
  } catch (error) {
@@ -3248,10 +3666,14 @@ function syncDatasourceSchemaDraftFromForm() {
3248
3666
  const selectedName = state.datasourceSchemaSelectedObjectName;
3249
3667
  const selectedSettings = selectedName ? draftTables[selectedName] : null;
3250
3668
  if (!selectedSettings) return;
3251
- selectedSettings.description = form.querySelector("[data-schema-detail='description']")?.value || "";
3252
- selectedSettings.primary_key_prompt = form.querySelector("[data-schema-detail='primary_key_prompt']")?.value || "";
3253
- selectedSettings.foreign_key_prompt = form.querySelector("[data-schema-detail='foreign_key_prompt']")?.value || "";
3254
- selectedSettings.join_logic = form.querySelector("[data-schema-detail='join_logic']")?.value || "";
3669
+ const descriptionInput = form.querySelector("[data-schema-detail='description']");
3670
+ const primaryKeyPromptInput = form.querySelector("[data-schema-detail='primary_key_prompt']");
3671
+ const foreignKeyPromptInput = form.querySelector("[data-schema-detail='foreign_key_prompt']");
3672
+ const joinLogicInput = form.querySelector("[data-schema-detail='join_logic']");
3673
+ if (descriptionInput) selectedSettings.description = descriptionInput.value || "";
3674
+ if (primaryKeyPromptInput) selectedSettings.primary_key_prompt = primaryKeyPromptInput.value || "";
3675
+ if (foreignKeyPromptInput) selectedSettings.foreign_key_prompt = foreignKeyPromptInput.value || "";
3676
+ if (joinLogicInput) selectedSettings.join_logic = joinLogicInput.value || "";
3255
3677
  }
3256
3678
  async function saveDatasourceSchema(event) {
3257
3679
  event.preventDefault();
@@ -3267,6 +3689,7 @@ async function saveDatasourceSchema(event) {
3267
3689
  tables[table.name] = {
3268
3690
  selected: draft.selected !== false,
3269
3691
  description: draft.description || "",
3692
+ view_description: draft.view_description || "",
3270
3693
  primary_key_prompt: draft.primary_key_prompt || "",
3271
3694
  foreign_key_prompt: draft.foreign_key_prompt || "",
3272
3695
  join_logic: draft.join_logic || ""
@@ -3488,6 +3911,7 @@ async function loadDatasourceSchema() {
3488
3911
  state.datasourceSchemaDraftTables = null;
3489
3912
  state.datasourceSchemaLoading = false;
3490
3913
  state.datasourceSchemaError = "";
3914
+ resetDatasourceViewDialog();
3491
3915
  render();
3492
3916
  return;
3493
3917
  }
@@ -447,6 +447,14 @@ label {
447
447
  font-size: 16px;
448
448
  }
449
449
 
450
+ .schema-object-actions {
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: flex-end;
454
+ gap: 8px;
455
+ flex-wrap: wrap;
456
+ }
457
+
450
458
  .schema-object-columns {
451
459
  padding: 10px;
452
460
  border: 1px solid var(--line);
@@ -724,6 +732,12 @@ label {
724
732
  animation: spin 0.8s linear infinite;
725
733
  }
726
734
 
735
+ button .spinner {
736
+ display: inline-block;
737
+ margin-right: 6px;
738
+ vertical-align: -2px;
739
+ }
740
+
727
741
  @keyframes spin {
728
742
  to {
729
743
  transform: rotate(360deg);
@@ -763,6 +777,14 @@ label {
763
777
  font-size: 16px;
764
778
  }
765
779
 
780
+ .panel-header-actions {
781
+ display: flex;
782
+ align-items: center;
783
+ justify-content: flex-end;
784
+ gap: 10px;
785
+ flex-wrap: wrap;
786
+ }
787
+
766
788
  .audit-controls {
767
789
  display: flex;
768
790
  justify-content: flex-end;