gaard-api 0.2.11__tar.gz → 0.2.12__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.12}/PKG-INFO +1 -1
  2. {gaard_api-0.2.11 → gaard_api-0.2.12}/pyproject.toml +1 -1
  3. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/main.js +445 -22
  4. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/styles.css +22 -0
  5. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/admin.py +662 -1
  6. {gaard_api-0.2.11 → gaard_api-0.2.12/src/gaard_api.egg-info}/PKG-INFO +1 -1
  7. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_admin_api.py +127 -0
  8. {gaard_api-0.2.11 → gaard_api-0.2.12}/MANIFEST.in +0 -0
  9. {gaard_api-0.2.11 → gaard_api-0.2.12}/README.md +0 -0
  10. {gaard_api-0.2.11 → gaard_api-0.2.12}/setup.cfg +0 -0
  11. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/__init__.py +0 -0
  12. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/__init__.py +0 -0
  13. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database.py +0 -0
  14. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_initial.sql +0 -0
  15. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
  16. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_updates.sql +0 -0
  17. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/defaults.py +0 -0
  18. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/migration_runner.py +0 -0
  19. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/models.py +0 -0
  20. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/prompt_runtime.py +0 -0
  21. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/security.py +0 -0
  22. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/services.py +0 -0
  23. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  24. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/identity.js +0 -0
  25. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  26. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  27. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/index.html +0 -0
  28. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/package.json +0 -0
  29. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/__init__.py +0 -0
  30. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/__init__.py +0 -0
  31. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/analysis.py +0 -0
  32. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/dashboards.py +0 -0
  33. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/prompts.py +0 -0
  34. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/query.py +0 -0
  35. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/schema.py +0 -0
  36. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api_registry.py +0 -0
  37. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/auth_dependencies.py +0 -0
  38. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/auth_hooks.py +0 -0
  39. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/cli.py +0 -0
  40. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/cli_commands.py +0 -0
  41. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/conversations.py +0 -0
  42. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/__init__.py +0 -0
  43. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/error_handlers.py +0 -0
  44. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/schema_cache.py +0 -0
  45. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/settings.py +0 -0
  46. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/__init__.py +0 -0
  47. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  48. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  49. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  50. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_database.py +0 -0
  51. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/extension_services.py +0 -0
  52. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/extensions.py +0 -0
  53. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/license.py +0 -0
  54. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/main.py +0 -0
  55. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/package_updates.py +0 -0
  56. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/py.typed +0 -0
  57. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/query_hooks.py +0 -0
  58. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/server_cli.py +0 -0
  59. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/siem.py +0 -0
  60. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/tls_http.py +0 -0
  61. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  62. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  63. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/entry_points.txt +0 -0
  64. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/requires.txt +0 -0
  65. {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/top_level.txt +0 -0
  66. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_analysis_api.py +0 -0
  67. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_api_cli.py +0 -0
  68. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_api_registry.py +0 -0
  69. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_conversation_api.py +0 -0
  70. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_database_migrations.py +0 -0
  71. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_error_handlers.py +0 -0
  72. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_license_api.py +0 -0
  73. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_license_service.py +0 -0
  74. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_prompt_runtime.py +0 -0
  75. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_query_error_messages.py +0 -0
  76. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_server_cli.py +0 -0
  77. {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_settings.py +0 -0
  78. {gaard_api-0.2.11 → gaard_api-0.2.12}/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.12
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.12"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
+ 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="description">${escapeHtml(dialog.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";
@@ -2035,11 +2171,10 @@ function renderDatasourceObjectDetails(table, settings) {
2035
2171
  <h3>${escapeHtml(table.name)}</h3>
2036
2172
  <span class="badge">${escapeHtml(objectType)}</span>
2037
2173
  </div>
2174
+ ${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
2175
  </div>
2039
2176
  <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>
2177
+ <label>Datasource logic<textarea data-schema-detail="description" class="textarea-small" name="${escapeHtml(table.name)}__description">${escapeHtml(settings.description || "")}</textarea></label>
2043
2178
  <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
2179
  }
2045
2180
  function renderSchemaCache() {
@@ -2263,6 +2398,16 @@ function attachSectionHandlers() {
2263
2398
  document.querySelector("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
2264
2399
  document.querySelector("[data-connection-field='connection_mode']")?.addEventListener("change", syncOdbcConnectionModeFields);
2265
2400
  document.querySelector("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
2401
+ document.querySelector("#create-datasource-view")?.addEventListener("click", openDatasourceViewDialog);
2402
+ document.querySelector("#datasource-view-form")?.addEventListener("submit", saveDatasourceView);
2403
+ document.querySelector("[data-datasource-view-generate]")?.addEventListener("click", generateDatasourceViewSql);
2404
+ document.querySelector("[data-datasource-view-execute]")?.addEventListener("click", executeDatasourceViewSql);
2405
+ document.querySelector("[data-datasource-view-cancel]")?.addEventListener("click", closeDatasourceViewDialog);
2406
+ document.querySelector("[data-datasource-view-backdrop]")?.addEventListener("click", (event) => {
2407
+ if (event.target === event.currentTarget && !state.datasourceViewDialog?.busy) {
2408
+ closeDatasourceViewDialog();
2409
+ }
2410
+ });
2266
2411
  document.querySelectorAll("[data-datasource-detail-tab]").forEach((button) => button.addEventListener("click", () => {
2267
2412
  state.datasourceDetailTab = button.dataset.datasourceDetailTab || "config";
2268
2413
  render();
@@ -2294,6 +2439,12 @@ function attachSectionHandlers() {
2294
2439
  render();
2295
2440
  });
2296
2441
  });
2442
+ document.querySelectorAll("[data-delete-datasource-view]").forEach((button) => {
2443
+ button.addEventListener("click", deleteDatasourceView);
2444
+ });
2445
+ document.querySelectorAll("[data-edit-datasource-view]").forEach((button) => {
2446
+ button.addEventListener("click", openEditDatasourceViewDialog);
2447
+ });
2297
2448
  document.querySelector("#invalidate-schema-cache")?.addEventListener("click", invalidateSchemaCache);
2298
2449
  document.querySelector("#test-datasource")?.addEventListener("click", testDatasource);
2299
2450
  document.querySelector("#introspect-datasource")?.addEventListener("click", introspectDatasource);
@@ -2344,6 +2495,7 @@ function attachSectionHandlers() {
2344
2495
  state.datasourceSchemaError = "";
2345
2496
  state.datasourceSchemaSelectedObjectName = "";
2346
2497
  state.datasourceSchemaDraftTables = null;
2498
+ resetDatasourceViewDialog();
2347
2499
  render();
2348
2500
  });
2349
2501
  document.querySelectorAll("[data-prompt]").forEach((button) => {
@@ -2355,6 +2507,7 @@ function attachSectionHandlers() {
2355
2507
  document.querySelectorAll("[data-datasource]").forEach((button) => {
2356
2508
  button.addEventListener("click", async () => {
2357
2509
  state.selectedDatasourceId = Number(button.dataset.datasource);
2510
+ resetDatasourceViewDialog();
2358
2511
  await loadDatasourceExtensions();
2359
2512
  await loadDatasourceSchema();
2360
2513
  });
@@ -3179,6 +3332,270 @@ async function testDatasource() {
3179
3332
  setMessage("error", extractErrorMessage(error));
3180
3333
  }
3181
3334
  }
3335
+ function defaultDatasourceViewDialog() {
3336
+ return {
3337
+ open: false,
3338
+ mode: "create",
3339
+ originalName: "",
3340
+ loadedName: "",
3341
+ loadedSql: "",
3342
+ name: "",
3343
+ description: "",
3344
+ sql: "",
3345
+ error: "",
3346
+ success: "",
3347
+ busy: "",
3348
+ executed: false
3349
+ };
3350
+ }
3351
+ function resetDatasourceViewDialog() {
3352
+ state.datasourceViewDialog = defaultDatasourceViewDialog();
3353
+ }
3354
+ function openDatasourceViewDialog() {
3355
+ state.datasourceViewDialog = {
3356
+ ...defaultDatasourceViewDialog(),
3357
+ mode: "create",
3358
+ open: true
3359
+ };
3360
+ render();
3361
+ }
3362
+ async function openEditDatasourceViewDialog(event) {
3363
+ const selected = getSelectedDatasource();
3364
+ const viewName = event.currentTarget?.dataset?.editDatasourceView || "";
3365
+ if (!selected || !viewName) return;
3366
+ syncDatasourceSchemaDraftFromForm();
3367
+ const rawTables = state.datasourceSchema?.item?.raw_schema?.tables || [];
3368
+ const tableSettings = state.datasourceSchema?.item?.table_settings?.tables || {};
3369
+ const draftSettings = getDatasourceSchemaDraftTables(rawTables, tableSettings)[viewName] || {};
3370
+ const hasDraftDescription = Object.prototype.hasOwnProperty.call(draftSettings, "description");
3371
+ const draftDescription = hasDraftDescription ? draftSettings.description || "" : "";
3372
+ state.datasourceViewDialog = {
3373
+ ...defaultDatasourceViewDialog(),
3374
+ open: true,
3375
+ mode: "edit",
3376
+ originalName: viewName,
3377
+ loadedName: viewName,
3378
+ name: viewName,
3379
+ description: draftDescription,
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 description = hasDraftDescription ? draftDescription : 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
+ description,
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
+ description: String(dialog.description || ""),
3424
+ sql: String(dialog.sql || "")
3425
+ };
3426
+ }
3427
+ const data = new FormData(form);
3428
+ return {
3429
+ name: String(data.get("name") || "").trim(),
3430
+ description: String(data.get("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, description: payload.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({ description: payload.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();
@@ -3488,6 +3910,7 @@ async function loadDatasourceSchema() {
3488
3910
  state.datasourceSchemaDraftTables = null;
3489
3911
  state.datasourceSchemaLoading = false;
3490
3912
  state.datasourceSchemaError = "";
3913
+ resetDatasourceViewDialog();
3491
3914
  render();
3492
3915
  return;
3493
3916
  }
@@ -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;