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.
- {gaard_api-0.2.11/src/gaard_api.egg-info → gaard_api-0.2.12}/PKG-INFO +1 -1
- {gaard_api-0.2.11 → gaard_api-0.2.12}/pyproject.toml +1 -1
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/main.js +445 -22
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/styles.css +22 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/admin.py +662 -1
- {gaard_api-0.2.11 → gaard_api-0.2.12/src/gaard_api.egg-info}/PKG-INFO +1 -1
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_admin_api.py +127 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/MANIFEST.in +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/README.md +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/setup.cfg +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_initial.sql +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/database_updates.sql +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/defaults.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/migration_runner.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/models.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/prompt_runtime.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin/services.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/identity.js +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/analysis.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/dashboards.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/query.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/auth_dependencies.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/auth_hooks.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/conversations.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/main.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/package_updates.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/requires.txt +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_analysis_api.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_api_cli.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_conversation_api.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_database_migrations.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_license_api.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_settings.py +0 -0
- {gaard_api-0.2.11 → gaard_api-0.2.12}/tests/test_tls_http.py +0 -0
|
@@ -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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
if (
|
|
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"
|
|
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>
|
|
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
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
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;
|