gaard-api 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,2343 @@
1
+ type Section =
2
+ | "overview"
3
+ | "widgets"
4
+ | "data-audit"
5
+ | "prompts"
6
+ | "schema-cache"
7
+ | "business-logic"
8
+ | "llm-config"
9
+ | "governance-policy"
10
+ | "identity"
11
+ | "datasources"
12
+ | "license"
13
+ | "admin-audit";
14
+
15
+ type PromptTemplate = {
16
+ prompt_key: string;
17
+ name: string;
18
+ description: string;
19
+ system_prompt: string;
20
+ user_prompt_template: string;
21
+ version: number;
22
+ active: boolean;
23
+ };
24
+
25
+ type DatasourceConnector = {
26
+ id: number;
27
+ connector_key: string;
28
+ name: string;
29
+ database_type: "sqlite" | "postgresql" | "mysql";
30
+ database_url: string;
31
+ masked_database_url: string;
32
+ sql_dialect: "sqlite" | "postgres" | "mysql";
33
+ active: boolean;
34
+ system_managed: boolean;
35
+ };
36
+
37
+ type OverviewDatasource = {
38
+ connector_key: string;
39
+ name: string;
40
+ database_type: string;
41
+ masked_database_url: string;
42
+ active: boolean;
43
+ };
44
+
45
+ type OverviewWidgetResult = {
46
+ status: "ok" | "error";
47
+ value?: unknown;
48
+ columns?: string[];
49
+ rows?: Record<string, unknown>[];
50
+ answer?: string;
51
+ interpretation?: string;
52
+ result_mode?: "data" | "interpretation";
53
+ sql?: string;
54
+ error?: string;
55
+ };
56
+
57
+ type OverviewWidget = {
58
+ widget_key: string;
59
+ label: string;
60
+ widget_type: "scalar" | "timeseries" | "table";
61
+ datasource_key: string;
62
+ question: string;
63
+ sql: string;
64
+ result_mode: "data" | "interpretation";
65
+ position: number;
66
+ grid_width: number;
67
+ active: boolean;
68
+ result?: OverviewWidgetResult;
69
+ };
70
+
71
+ type OverviewState = {
72
+ datasources: OverviewDatasource[];
73
+ info_widgets: OverviewWidget[];
74
+ runtime_widget: OverviewWidget | null;
75
+ table_widgets: OverviewWidget[];
76
+ widgets: OverviewWidget[];
77
+ };
78
+
79
+ type State = {
80
+ token: string | null;
81
+ username: string;
82
+ mustChangePassword: boolean;
83
+ mobileMenuOpen: boolean;
84
+ section: Section;
85
+ error: string;
86
+ success: string;
87
+ overview: OverviewState | null;
88
+ overviewWidgetConfigs: OverviewWidget[];
89
+ overviewWidgetDatasources: OverviewDatasource[];
90
+ selectedOverviewWidgetKey: string;
91
+ overviewEditorWidgetKey: string | null;
92
+ overviewPlacementSlot: number | null;
93
+ overviewLoading: boolean;
94
+ overviewRefreshing: boolean;
95
+ overviewTablePages: Record<string, number>;
96
+ dataAudit: any[];
97
+ dataAuditType: string;
98
+ dataAuditOutputClassification: string;
99
+ dataAuditSqlContains: string;
100
+ adminAudit: any[];
101
+ auditSettings: any | null;
102
+ prompts: PromptTemplate[];
103
+ selectedPromptKey: string;
104
+ schemaCache: any | null;
105
+ businessLogic: any[];
106
+ businessLogicDatasource: any | null;
107
+ businessLogicEditorId: number | null;
108
+ llmConfig: any | null;
109
+ governancePolicy: any | null;
110
+ datasources: DatasourceConnector[];
111
+ selectedDatasourceId: number | "new" | null;
112
+ datasourceSchema: any | null;
113
+ datasourceSchemaLoading: boolean;
114
+ datasourceSchemaError: string;
115
+ datasourceSchemaSelectedObjectName: string;
116
+ datasourceSchemaShowEnabledOnly: boolean;
117
+ datasourceSchemaDraftTables: Record<string, any> | null;
118
+ license: any | null;
119
+ };
120
+
121
+ const app = document.querySelector<HTMLDivElement>("#app");
122
+
123
+ const sections: Array<{ key: Section; label: string }> = [
124
+ { key: "overview", label: "Overview" },
125
+ { key: "widgets", label: "Widgets" },
126
+ { key: "data-audit", label: "Data audit" },
127
+ { key: "prompts", label: "Prompts" },
128
+ { key: "schema-cache", label: "Schema cache" },
129
+ { key: "business-logic", label: "Business logic suggestions" },
130
+ { key: "llm-config", label: "LLM configuration" },
131
+ { key: "governance-policy", label: "Governance policy" },
132
+ { key: "identity", label: "Identity connector" },
133
+ { key: "datasources", label: "Datasource connector" },
134
+ { key: "license", label: "License" },
135
+ { key: "admin-audit", label: "Admin audit" },
136
+ ];
137
+
138
+ const state: State = {
139
+ token: localStorage.getItem("gaard_admin_token"),
140
+ username: localStorage.getItem("gaard_admin_username") || "",
141
+ mustChangePassword: localStorage.getItem("gaard_admin_must_change") === "true",
142
+ mobileMenuOpen: false,
143
+ section: "overview",
144
+ error: "",
145
+ success: "",
146
+ overview: null,
147
+ overviewWidgetConfigs: [],
148
+ overviewWidgetDatasources: [],
149
+ selectedOverviewWidgetKey: "",
150
+ overviewEditorWidgetKey: null,
151
+ overviewPlacementSlot: null,
152
+ overviewLoading: Boolean(localStorage.getItem("gaard_admin_token") && localStorage.getItem("gaard_admin_must_change") !== "true"),
153
+ overviewRefreshing: false,
154
+ overviewTablePages: {},
155
+ dataAudit: [],
156
+ dataAuditType: "",
157
+ dataAuditOutputClassification: "",
158
+ dataAuditSqlContains: "",
159
+ adminAudit: [],
160
+ auditSettings: null,
161
+ prompts: [],
162
+ selectedPromptKey: "",
163
+ schemaCache: null,
164
+ businessLogic: [],
165
+ businessLogicDatasource: null,
166
+ businessLogicEditorId: null,
167
+ llmConfig: null,
168
+ governancePolicy: null,
169
+ datasources: [],
170
+ selectedDatasourceId: null,
171
+ datasourceSchema: null,
172
+ datasourceSchemaLoading: false,
173
+ datasourceSchemaError: "",
174
+ datasourceSchemaSelectedObjectName: "",
175
+ datasourceSchemaShowEnabledOnly: false,
176
+ datasourceSchemaDraftTables: null,
177
+ license: null,
178
+ };
179
+
180
+ const dataAuditTypes = [
181
+ { value: "", label: "All types" },
182
+ { value: "info", label: "Info" },
183
+ { value: "sql_error", label: "SQL error" },
184
+ { value: "access_error", label: "Access error" },
185
+ ];
186
+
187
+ const outputClassifications = [
188
+ { value: "", label: "All classifications" },
189
+ { value: "personal_data", label: "Personal data" },
190
+ { value: "sensitive_data", label: "Sensitive data" },
191
+ { value: "technical_data", label: "Technical data" },
192
+ { value: "neutral_data", label: "Neutral data" },
193
+ { value: "unknown", label: "Unknown" },
194
+ ];
195
+
196
+ const OVERVIEW_TABLE_PAGE_SIZE = 10;
197
+ const OVERVIEW_GRID_COLUMNS = 4;
198
+ const OVERVIEW_MIN_GRID_SLOTS = 8;
199
+ const ALLOWED_WIDGET_HTML_TAGS = new Set(["A", "B", "I", "UL", "LI"]);
200
+ const DROPPED_WIDGET_HTML_TAGS = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "TEMPLATE", "SVG", "MATH"]);
201
+
202
+ function escapeHtml(value: unknown): string {
203
+ return String(value ?? "")
204
+ .replaceAll("&", "&amp;")
205
+ .replaceAll("<", "&lt;")
206
+ .replaceAll(">", "&gt;")
207
+ .replaceAll('"', "&quot;")
208
+ .replaceAll("'", "&#039;");
209
+ }
210
+
211
+ function renderWidgetContent(value: unknown): string {
212
+ const documentFragment = new DOMParser().parseFromString(String(value ?? ""), "text/html");
213
+
214
+ return Array.from(documentFragment.body.childNodes)
215
+ .map(renderWidgetContentNode)
216
+ .join("");
217
+ }
218
+
219
+ function renderWidgetContentNode(node: ChildNode): string {
220
+ if (node.nodeType === Node.TEXT_NODE) {
221
+ return escapeHtml(node.textContent || "");
222
+ }
223
+
224
+ if (node.nodeType !== Node.ELEMENT_NODE) {
225
+ return "";
226
+ }
227
+
228
+ const element = node as HTMLElement;
229
+ const tagName = element.tagName.toUpperCase();
230
+
231
+ if (DROPPED_WIDGET_HTML_TAGS.has(tagName)) {
232
+ return "";
233
+ }
234
+
235
+ const children = Array.from(element.childNodes)
236
+ .map(renderWidgetContentNode)
237
+ .join("");
238
+
239
+ if (!ALLOWED_WIDGET_HTML_TAGS.has(tagName)) {
240
+ return children;
241
+ }
242
+
243
+ const tag = tagName.toLowerCase();
244
+
245
+ if (tag === "a") {
246
+ const href = sanitizeWidgetHref(element.getAttribute("href"));
247
+ return `<a${href ? ` href="${escapeHtml(href)}"` : ""}>${children}</a>`;
248
+ }
249
+
250
+ return `<${tag}>${children}</${tag}>`;
251
+ }
252
+
253
+ function sanitizeWidgetHref(value: string | null): string {
254
+ const href = String(value || "")
255
+ .trim()
256
+ .replace(/[\u0000-\u001F\u007F\s]+/g, "");
257
+
258
+ if (!href) {
259
+ return "";
260
+ }
261
+
262
+ try {
263
+ const parsed = new URL(href, window.location.origin);
264
+
265
+ if (["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol)) {
266
+ return href;
267
+ }
268
+ } catch {
269
+ return "";
270
+ }
271
+
272
+ return "";
273
+ }
274
+
275
+ function formatAuditTime(value: unknown): string {
276
+ const raw = String(value ?? "");
277
+ const match = raw.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?/);
278
+
279
+ if (match) {
280
+ const millis = (match[5] || "000").slice(0, 3).padEnd(3, "0");
281
+ return `${match[1]} ${match[2]}:${match[3]}:${match[4]}:${millis}`;
282
+ }
283
+
284
+ return raw;
285
+ }
286
+
287
+ function extractErrorMessage(value: unknown): string {
288
+ if (typeof value === "string") {
289
+ const trimmed = value.trim();
290
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
291
+ try {
292
+ return extractErrorMessage(JSON.parse(trimmed));
293
+ } catch {
294
+ return value;
295
+ }
296
+ }
297
+ return value;
298
+ }
299
+
300
+ if (value && typeof value === "object") {
301
+ const record = value as Record<string, unknown>;
302
+
303
+ if (typeof record.message === "string") return record.message;
304
+ if (typeof record.detail === "string") return record.detail;
305
+ if (record.error) return extractErrorMessage(record.error);
306
+ if (Array.isArray(record.detail) && record.detail.length) {
307
+ return extractErrorMessage(record.detail[0]);
308
+ }
309
+ return JSON.stringify(value);
310
+ }
311
+
312
+ return String(value ?? "");
313
+ }
314
+
315
+ async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
316
+ const headers = new Headers(options.headers || {});
317
+ headers.set("Content-Type", "application/json");
318
+
319
+ if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
320
+
321
+ const response = await fetch(path, { ...options, headers });
322
+ const payload = await response.json().catch(() => ({}));
323
+
324
+ if (response.status === 401) {
325
+ logout();
326
+ throw new Error("Session expired.");
327
+ }
328
+
329
+ if (!response.ok) {
330
+ throw new Error(extractErrorMessage(payload.detail ?? payload.error ?? payload.message ?? "Request failed."));
331
+ }
332
+
333
+ return payload as T;
334
+ }
335
+
336
+ function setMessage(type: "error" | "success", value: string): void {
337
+ state.error = type === "error" ? value : "";
338
+ state.success = type === "success" ? value : "";
339
+ const region = document.querySelector<HTMLElement>("#message-region");
340
+
341
+ if (region) {
342
+ region.innerHTML = renderMessages();
343
+ }
344
+ }
345
+
346
+ function renderMessages(): string {
347
+ return `
348
+ ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ""}
349
+ ${state.success ? `<div class="success">${escapeHtml(state.success)}</div>` : ""}`;
350
+ }
351
+
352
+ function persistAuth(token: string, username: string, mustChangePassword: boolean): void {
353
+ state.token = token;
354
+ state.username = username;
355
+ state.mustChangePassword = mustChangePassword;
356
+ localStorage.setItem("gaard_admin_token", token);
357
+ localStorage.setItem("gaard_admin_username", username);
358
+ localStorage.setItem("gaard_admin_must_change", String(mustChangePassword));
359
+ }
360
+
361
+ function logout(): void {
362
+ state.token = null;
363
+ state.username = "";
364
+ state.mustChangePassword = false;
365
+ state.overviewLoading = false;
366
+ state.overviewRefreshing = false;
367
+ state.overviewEditorWidgetKey = null;
368
+ localStorage.removeItem("gaard_admin_token");
369
+ localStorage.removeItem("gaard_admin_username");
370
+ localStorage.removeItem("gaard_admin_must_change");
371
+ render();
372
+ }
373
+
374
+ function render(): void {
375
+ if (!app) return;
376
+ if (!state.token) return renderLogin();
377
+ if (state.mustChangePassword) return renderPasswordChange();
378
+ renderShell();
379
+ }
380
+
381
+ function renderLogin(): void {
382
+ app!.innerHTML = `
383
+ <main class="login-shell">
384
+ <section class="login-panel">
385
+ <h1>GAARD Admin Console</h1>
386
+ <p>Sign in with an administrator account.</p>
387
+ <form id="login-form" class="form-grid">
388
+ <label>Username<input name="username" autocomplete="username" value="admin" /></label>
389
+ <label>Password<input name="password" type="password" autocomplete="current-password" value="admin" /></label>
390
+ ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ""}
391
+ <div class="form-actions"><button class="primary" type="submit">Sign in</button></div>
392
+ </form>
393
+ </section>
394
+ </main>`;
395
+
396
+ document.querySelector<HTMLFormElement>("#login-form")?.addEventListener("submit", async event => {
397
+ event.preventDefault();
398
+ const form = new FormData(event.currentTarget);
399
+ try {
400
+ const result = await api<any>("/api/v1/admin/auth/login", {
401
+ method: "POST",
402
+ body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
403
+ });
404
+ persistAuth(result.token, result.username, result.must_change_password);
405
+ state.overviewLoading = !result.must_change_password && state.section === "overview";
406
+ setMessage("success", "");
407
+ render();
408
+ if (!result.must_change_password) await loadCurrentSection();
409
+ } catch (error) {
410
+ setMessage("error", (error as Error).message);
411
+ render();
412
+ }
413
+ });
414
+ }
415
+
416
+ function renderPasswordChange(): void {
417
+ app!.innerHTML = `
418
+ <main class="login-shell">
419
+ <section class="login-panel">
420
+ <h1>Change password</h1>
421
+ <p>The default administrator password must be changed.</p>
422
+ <form id="password-form" class="form-grid">
423
+ <label>Current password<input name="current_password" type="password" /></label>
424
+ <label>New password<input name="new_password" type="password" /></label>
425
+ ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ""}
426
+ <div class="form-actions">
427
+ <button type="button" id="logout-button">Sign out</button>
428
+ <button class="primary" type="submit">Save password</button>
429
+ </div>
430
+ </form>
431
+ </section>
432
+ </main>`;
433
+
434
+ document.querySelector("#logout-button")?.addEventListener("click", logout);
435
+ document.querySelector<HTMLFormElement>("#password-form")?.addEventListener("submit", async event => {
436
+ event.preventDefault();
437
+ const form = new FormData(event.currentTarget);
438
+ try {
439
+ const result = await api<any>("/api/v1/admin/auth/change-password", {
440
+ method: "POST",
441
+ body: JSON.stringify({
442
+ current_password: form.get("current_password"),
443
+ new_password: form.get("new_password"),
444
+ }),
445
+ });
446
+ state.mustChangePassword = result.must_change_password;
447
+ state.overviewLoading = !state.mustChangePassword && state.section === "overview";
448
+ localStorage.setItem("gaard_admin_must_change", String(result.must_change_password));
449
+ setMessage("success", "Password changed.");
450
+ render();
451
+ await loadCurrentSection();
452
+ } catch (error) {
453
+ setMessage("error", (error as Error).message);
454
+ render();
455
+ }
456
+ });
457
+ }
458
+
459
+ function renderShell(): void {
460
+ const active = sections.find(section => section.key === state.section);
461
+ app!.innerHTML = `
462
+ <div class="app-shell">
463
+ <aside class="sidebar${state.mobileMenuOpen ? " menu-open" : ""}">
464
+ <div class="sidebar-header">
465
+ <div class="brand"><strong>GAARD Admin Console</strong><span>Community edition</span></div>
466
+ <button class="menu-toggle" id="mobile-menu-button" type="button" aria-label="${state.mobileMenuOpen ? "Close navigation" : "Open navigation"}" aria-expanded="${state.mobileMenuOpen}" aria-controls="admin-navigation">
467
+ <span></span><span></span><span></span>
468
+ </button>
469
+ </div>
470
+ <nav class="nav" id="admin-navigation">
471
+ ${sections.map(section => `<button data-section="${section.key}" class="${section.key === state.section ? "active" : ""}">${section.label}</button>`).join("")}
472
+ </nav>
473
+ <div class="sidebar-footer"><span>${escapeHtml(state.username)}</span><button id="logout-button">Sign out</button></div>
474
+ </aside>
475
+ <main class="main">
476
+ <header class="topbar">
477
+ <h1>${escapeHtml(active?.label || "Admin")}</h1>
478
+ <div class="topbar-actions"><span>${escapeHtml(state.username)}</span><button id="top-logout-button">Sign out</button></div>
479
+ </header>
480
+ <section class="content">
481
+ <div id="message-region">${renderMessages()}</div>
482
+ ${renderSection()}
483
+ </section>
484
+ </main>
485
+ </div>
486
+ ${renderOverviewWidgetModal()}`;
487
+
488
+ document.querySelectorAll<HTMLButtonElement>("[data-section]").forEach(button => {
489
+ button.addEventListener("click", async () => {
490
+ state.section = button.dataset.section as Section;
491
+ state.mobileMenuOpen = false;
492
+ state.overviewEditorWidgetKey = null;
493
+ state.overviewLoading = state.section === "overview";
494
+ setMessage("success", "");
495
+ render();
496
+ await loadCurrentSection();
497
+ });
498
+ });
499
+ document.querySelector("#mobile-menu-button")?.addEventListener("click", () => {
500
+ state.mobileMenuOpen = !state.mobileMenuOpen;
501
+ render();
502
+ });
503
+ document.querySelector("#logout-button")?.addEventListener("click", logout);
504
+ document.querySelector("#top-logout-button")?.addEventListener("click", logout);
505
+ attachSectionHandlers();
506
+ }
507
+
508
+ function renderSection(): string {
509
+ if (state.section === "overview") return renderOverview();
510
+ if (state.section === "widgets") return renderWidgets();
511
+ if (state.section === "data-audit") return renderDataAudit();
512
+ if (state.section === "prompts") return renderPrompts();
513
+ if (state.section === "schema-cache") return renderSchemaCache();
514
+ if (state.section === "business-logic") return renderBusinessLogicSuggestions();
515
+ if (state.section === "llm-config") return renderLlmConfig();
516
+ if (state.section === "governance-policy") return renderGovernancePolicy();
517
+ if (state.section === "identity") return renderStub("Identity connector", "FreeIPA connector configuration is planned.");
518
+ if (state.section === "datasources") return renderDatasources();
519
+ if (state.section === "license") return renderLicense();
520
+ if (state.section === "admin-audit") return renderAdminAudit();
521
+ return "";
522
+ }
523
+
524
+ function renderOverview(): string {
525
+ const overview = state.overview;
526
+ const widgets = overview?.widgets || [];
527
+ const isLoading = state.overviewLoading || state.overviewRefreshing;
528
+ const showInitialLoader = isLoading && !overview;
529
+
530
+ return `
531
+ <div class="toolbar overview-toolbar">
532
+ <div class="refresh-status" aria-live="polite">
533
+ ${isLoading ? `<span class="spinner" aria-hidden="true"></span><span>Refreshing</span>` : ""}
534
+ </div>
535
+ <button class="primary" type="button" id="overview-refresh" ${isLoading ? "disabled" : ""}>Refresh</button>
536
+ </div>
537
+ <div class="overview-grid">
538
+ ${showInitialLoader ? renderOverviewLoading() : renderOverviewGrid(widgets)}
539
+ </div>`;
540
+ }
541
+
542
+ function renderOverviewLoading(): string {
543
+ return `
544
+ <section class="overview-loading" aria-live="polite">
545
+ <span class="spinner" aria-hidden="true"></span>
546
+ <span>Refreshing</span>
547
+ </section>`;
548
+ }
549
+
550
+ function renderOverviewGrid(widgets: OverviewWidget[]): string {
551
+ const layout = buildOverviewLayout(widgets);
552
+ const occupiedSlots = Array.from(layout.occupiedSlots);
553
+ const slotCount = Math.max(
554
+ OVERVIEW_MIN_GRID_SLOTS,
555
+ occupiedSlots.length ? Math.max(...occupiedSlots) + 1 : 0
556
+ );
557
+
558
+ return Array.from({ length: slotCount }, (_, slot) => {
559
+ const widget = layout.widgetSlots.get(slot);
560
+
561
+ if (widget) {
562
+ return renderOverviewGridWidget(widget);
563
+ }
564
+
565
+ if (layout.occupiedSlots.has(slot)) {
566
+ return "";
567
+ }
568
+
569
+ return renderOverviewEmptySlot(slot);
570
+ }).join("");
571
+ }
572
+
573
+ function buildOverviewLayout(widgets: OverviewWidget[]): {
574
+ widgetSlots: Map<number, OverviewWidget>;
575
+ occupiedSlots: Set<number>;
576
+ } {
577
+ const widgetSlots = new Map<number, OverviewWidget>();
578
+ const occupiedSlots = new Set<number>();
579
+
580
+ widgets
581
+ .filter(widget => widget.active !== false)
582
+ .sort((left, right) => (left.position || 0) - (right.position || 0))
583
+ .forEach(widget => {
584
+ const width = getOverviewWidgetGridWidth(widget);
585
+ let slot = overviewSlotFromPosition(widget.position);
586
+
587
+ while (!canPlaceOverviewWidget(slot, width, occupiedSlots)) {
588
+ slot += 1;
589
+ }
590
+
591
+ widgetSlots.set(slot, widget);
592
+
593
+ for (let offset = 0; offset < width; offset += 1) {
594
+ occupiedSlots.add(slot + offset);
595
+ }
596
+ });
597
+
598
+ return { widgetSlots, occupiedSlots };
599
+ }
600
+
601
+ function canPlaceOverviewWidget(slot: number, width: number, occupiedSlots: Set<number>): boolean {
602
+ if (slot < 0) {
603
+ return false;
604
+ }
605
+
606
+ const column = slot % OVERVIEW_GRID_COLUMNS;
607
+
608
+ if (column + width > OVERVIEW_GRID_COLUMNS) {
609
+ return false;
610
+ }
611
+
612
+ for (let offset = 0; offset < width; offset += 1) {
613
+ if (occupiedSlots.has(slot + offset)) {
614
+ return false;
615
+ }
616
+ }
617
+
618
+ return true;
619
+ }
620
+
621
+ function findAvailableOverviewSlot(slot: number, width: number, occupiedSlots: Set<number>): number {
622
+ let candidate = Math.max(0, slot);
623
+
624
+ while (!canPlaceOverviewWidget(candidate, width, occupiedSlots)) {
625
+ candidate += 1;
626
+ }
627
+
628
+ return candidate;
629
+ }
630
+
631
+ function overviewSlotFromPosition(position: unknown): number {
632
+ const numeric = Number(position);
633
+
634
+ if (!Number.isFinite(numeric) || numeric < 10) {
635
+ return 0;
636
+ }
637
+
638
+ return Math.max(0, Math.floor(numeric / 10) - 1);
639
+ }
640
+
641
+ function overviewPositionFromSlot(slot: number): number {
642
+ return (slot + 1) * 10;
643
+ }
644
+
645
+ function getOverviewWidgetGridWidth(widget: Pick<OverviewWidget, "widget_type" | "grid_width">): number {
646
+ const fallback = widget.widget_type === "scalar" ? 1 : OVERVIEW_GRID_COLUMNS;
647
+ const width = Number(widget.grid_width || fallback);
648
+
649
+ return Math.max(1, Math.min(OVERVIEW_GRID_COLUMNS, Number.isFinite(width) ? Math.floor(width) : fallback));
650
+ }
651
+
652
+ function renderOverviewGridWidget(widget: OverviewWidget): string {
653
+ const result = widget.result;
654
+ const width = getOverviewWidgetGridWidth(widget);
655
+
656
+ return `
657
+ <section class="widget-card overview-widget-slot overview-widget-${escapeHtml(widget.widget_type)}" style="grid-column: span ${escapeHtml(width)};">
658
+ <div class="widget-card-header">
659
+ <div>
660
+ <span>${escapeHtml(widget.datasource_key)}</span>
661
+ <strong>${escapeHtml(widget.label)}</strong>
662
+ </div>
663
+ ${renderEditWidgetButton(widget.widget_key)}
664
+ </div>
665
+ <div class="widget-card-main">
666
+ ${renderOverviewWidgetBody(widget, result)}
667
+ </div>
668
+ </section>`;
669
+ }
670
+
671
+ function renderOverviewWidgetBody(widget: OverviewWidget, result?: OverviewWidgetResult): string {
672
+ if (!result) {
673
+ return `<div class="empty-state">No data yet.</div>`;
674
+ }
675
+
676
+ if (result.status !== "ok") {
677
+ return `<div class="error">${escapeHtml(result.error || "Widget query failed.")}</div>`;
678
+ }
679
+
680
+ if ((result.result_mode || widget.result_mode) === "interpretation") {
681
+ const interpretation = result.interpretation || result.answer || result.value || "-";
682
+ return `<div class="widget-card-value widget-card-interpretation">${renderWidgetContent(interpretation)}</div>`;
683
+ }
684
+
685
+ if (widget.widget_type === "scalar") {
686
+ return `<div class="widget-card-value">${renderWidgetContent(result.value ?? "-")}</div>`;
687
+ }
688
+
689
+ if (widget.widget_type === "table") {
690
+ return renderOverviewTable(widget.widget_key, result);
691
+ }
692
+
693
+ return renderTimeSeriesChart(result);
694
+ }
695
+
696
+ function renderOverviewEmptySlot(slot: number): string {
697
+ return `
698
+ <section class="overview-empty-slot">
699
+ <button type="button" data-overview-empty-slot="${escapeHtml(slot)}" aria-label="Add widget to slot ${escapeHtml(slot + 1)}">+</button>
700
+ ${state.overviewPlacementSlot === slot ? renderOverviewPlacementPanel(slot) : ""}
701
+ </section>`;
702
+ }
703
+
704
+ function renderOverviewPlacementPanel(slot: number): string {
705
+ const availableWidgets = state.overviewWidgetConfigs.filter(widget => widget.active === false);
706
+
707
+ return `
708
+ <div class="overview-placement-panel">
709
+ <label>Widget
710
+ <select data-overview-placement-select="${escapeHtml(slot)}" ${availableWidgets.length ? "" : "disabled"}>
711
+ ${availableWidgets.length
712
+ ? availableWidgets.map(widget => `<option value="${escapeHtml(widget.widget_key)}">${escapeHtml(widget.label)} (${escapeHtml(widget.widget_key)}, ${escapeHtml(getOverviewWidgetGridWidth(widget))} cols)</option>`).join("")
713
+ : `<option>No inactive widgets</option>`}
714
+ </select>
715
+ </label>
716
+ <div class="button-row">
717
+ <button type="button" data-overview-place-widget="${escapeHtml(slot)}" ${availableWidgets.length ? "" : "disabled"}>Add selected</button>
718
+ <button type="button" class="primary" data-overview-new-widget="${escapeHtml(slot)}">New widget</button>
719
+ </div>
720
+ </div>`;
721
+ }
722
+
723
+ function renderEditWidgetButton(widgetKey: string): string {
724
+ return `
725
+ <button class="icon-button" type="button" data-edit-overview-widget="${escapeHtml(widgetKey)}" aria-label="Edit widget source" title="Edit source">
726
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
727
+ <path d="M12 20h9" />
728
+ <path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
729
+ </svg>
730
+ </button>`;
731
+ }
732
+
733
+ function renderOverviewWidgetModal(): string {
734
+ const widget = getOverviewEditorWidget();
735
+
736
+ if (!widget) return "";
737
+
738
+ return `
739
+ <div class="modal-backdrop" data-overview-widget-backdrop>
740
+ <section class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="overview-widget-modal-title">
741
+ <div class="modal-header">
742
+ <div>
743
+ <h2 id="overview-widget-modal-title">Edit widget source</h2>
744
+ <p>${escapeHtml(widget.label)}</p>
745
+ </div>
746
+ <button type="button" data-close-overview-widget>Close</button>
747
+ </div>
748
+ <form class="form-grid" data-overview-widget-form="${escapeHtml(widget.widget_key)}">
749
+ <input type="hidden" name="position" value="${escapeHtml(widget.position || 100)}" />
750
+ <input type="hidden" name="grid_width" value="${escapeHtml(getOverviewWidgetGridWidth(widget))}" />
751
+ <input type="hidden" name="active" value="${escapeHtml(widget.active !== false ? "true" : "false")}" />
752
+ <label>Label<input name="label" value="${escapeHtml(widget.label)}" /></label>
753
+ <div class="subgrid">
754
+ <label>Type<select name="widget_type">${renderWidgetTypeOptions(widget.widget_type)}</select></label>
755
+ <label>Datasource<select name="datasource_key">${renderOverviewDatasourceOptions(widget.datasource_key)}</select></label>
756
+ </div>
757
+ <label>Result mode<select name="result_mode">${renderOverviewWidgetResultModeOptions(widget.result_mode)}</select></label>
758
+ <label>Question<textarea name="question">${escapeHtml(widget.question)}</textarea></label>
759
+ <label>Generated SQL<textarea class="textarea-small" readonly>${escapeHtml(widget.result?.sql || widget.sql || "")}</textarea></label>
760
+ <div class="form-actions">
761
+ <button type="button" data-close-overview-widget>Cancel</button>
762
+ <button class="primary" type="submit">Save and refresh</button>
763
+ </div>
764
+ </form>
765
+ </section>
766
+ </div>`;
767
+ }
768
+
769
+ function getOverviewEditorWidget(): OverviewWidget | null {
770
+ if (!state.overviewEditorWidgetKey) return null;
771
+
772
+ return (state.overview?.widgets || []).find(
773
+ widget => widget.widget_key === state.overviewEditorWidgetKey
774
+ ) || null;
775
+ }
776
+
777
+ function renderWidgetTypeOptions(selected: string): string {
778
+ return ["scalar", "timeseries", "table"]
779
+ .map(value => `<option value="${value}" ${value === selected ? "selected" : ""}>${value}</option>`)
780
+ .join("");
781
+ }
782
+
783
+ function renderOverviewDatasourceOptions(selected: string): string {
784
+ const datasources = state.overviewWidgetDatasources.length
785
+ ? state.overviewWidgetDatasources
786
+ : state.overview?.datasources || [];
787
+
788
+ return datasources
789
+ .map(item => `<option value="${escapeHtml(item.connector_key)}" ${item.connector_key === selected ? "selected" : ""}>${escapeHtml(item.name)} (${escapeHtml(item.connector_key)})</option>`)
790
+ .join("");
791
+ }
792
+
793
+ function renderTimeSeriesChart(result: OverviewWidgetResult): string {
794
+ const points = normalizeChartPoints(result);
795
+
796
+ if (!points.length) {
797
+ return `<div class="empty-state">No data yet.</div>`;
798
+ }
799
+
800
+ const max = Math.max(...points.map(point => point.value), 1);
801
+ const dates = Array.from(new Set(points.map(point => point.date)));
802
+ const series = Array.from(new Set(points.map(point => point.series)));
803
+
804
+ return `
805
+ <div class="chart">
806
+ ${dates.map(date => {
807
+ const datePoints = points.filter(point => point.date === date);
808
+ return `<div class="chart-row">
809
+ <div class="chart-date">${escapeHtml(date)}</div>
810
+ <div class="chart-bars">
811
+ ${datePoints.map(point => `<div class="chart-bar" title="${escapeHtml(`${point.series}: ${point.value}`)}" style="width: ${Math.max(4, (point.value / max) * 100)}%"><span>${escapeHtml(point.series)}: ${escapeHtml(point.value)}</span></div>`).join("")}
812
+ </div>
813
+ </div>`;
814
+ }).join("")}
815
+ </div>
816
+ <div class="chart-legend">${series.map(item => `<span>${escapeHtml(item)}</span>`).join("")}</div>`;
817
+ }
818
+
819
+ function normalizeChartPoints(result: OverviewWidgetResult): Array<{ date: string; series: string; value: number }> {
820
+ const rows = result.rows || [];
821
+ const columns = result.columns || Object.keys(rows[0] || {});
822
+
823
+ if (!rows.length || columns.length < 2) {
824
+ return [];
825
+ }
826
+
827
+ const dateColumn = columns[0];
828
+
829
+ if (columns.length === 3 && rows.some(row => !isNumeric(row[columns[1]]) && isNumeric(row[columns[2]]))) {
830
+ return rows
831
+ .filter(row => isNumeric(row[columns[2]]))
832
+ .map(row => ({
833
+ date: formatChartDate(row[dateColumn]),
834
+ series: String(row[columns[1]] ?? "series"),
835
+ value: Number(row[columns[2]]),
836
+ }));
837
+ }
838
+
839
+ return rows.flatMap(row =>
840
+ columns.slice(1)
841
+ .filter(column => isNumeric(row[column]))
842
+ .map(column => ({
843
+ date: formatChartDate(row[dateColumn]),
844
+ series: column,
845
+ value: Number(row[column]),
846
+ }))
847
+ );
848
+ }
849
+
850
+ function formatChartDate(value: unknown): string {
851
+ return String(value ?? "").slice(0, 10);
852
+ }
853
+
854
+ function isNumeric(value: unknown): boolean {
855
+ return value !== null && value !== "" && !Array.isArray(value) && Number.isFinite(Number(value));
856
+ }
857
+
858
+ function renderOverviewTable(widgetKey: string, result: OverviewWidgetResult): string {
859
+ const rows = result.rows || [];
860
+ const columns = result.columns?.length ? result.columns : Object.keys(rows[0] || {});
861
+
862
+ if (!columns.length) {
863
+ return `<div class="empty-state">No data yet.</div>`;
864
+ }
865
+
866
+ const totalPages = Math.max(1, Math.ceil(rows.length / OVERVIEW_TABLE_PAGE_SIZE));
867
+ const currentPage = Math.min(
868
+ Math.max(state.overviewTablePages[widgetKey] || 0, 0),
869
+ totalPages - 1
870
+ );
871
+ const start = currentPage * OVERVIEW_TABLE_PAGE_SIZE;
872
+ const pageRows = rows.slice(start, start + OVERVIEW_TABLE_PAGE_SIZE);
873
+
874
+ if (state.overviewTablePages[widgetKey] !== currentPage) {
875
+ state.overviewTablePages[widgetKey] = currentPage;
876
+ }
877
+
878
+ return `
879
+ <div class="table-wrap overview-table-wrap">
880
+ <table>
881
+ <thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead>
882
+ <tbody>
883
+ ${pageRows.length ? pageRows.map(row => `<tr>${columns.map(column => `<td>${formatOverviewTableCell(row[column])}</td>`).join("")}</tr>`).join("") : `<tr><td colspan="${escapeHtml(columns.length)}" class="empty-state">No rows.</td></tr>`}
884
+ </tbody>
885
+ </table>
886
+ </div>
887
+ <div class="table-pagination" aria-label="${escapeHtml(`${widgetKey} pagination`)}">
888
+ <span class="table-pagination-info">${escapeHtml(rows.length ? `${start + 1}-${Math.min(start + OVERVIEW_TABLE_PAGE_SIZE, rows.length)} of ${rows.length}` : "0 rows")}</span>
889
+ <div class="button-row">
890
+ <button type="button" data-overview-table-page="${escapeHtml(widgetKey)}" data-page="${escapeHtml(currentPage - 1)}" ${currentPage === 0 ? "disabled" : ""}>Previous</button>
891
+ <span class="badge">Page ${escapeHtml(currentPage + 1)} / ${escapeHtml(totalPages)}</span>
892
+ <button type="button" data-overview-table-page="${escapeHtml(widgetKey)}" data-page="${escapeHtml(currentPage + 1)}" ${currentPage >= totalPages - 1 ? "disabled" : ""}>Next</button>
893
+ </div>
894
+ </div>`;
895
+ }
896
+
897
+ function formatOverviewTableCell(value: unknown): string {
898
+ if (value === null || value === undefined || value === "") {
899
+ return `<span class="muted">-</span>`;
900
+ }
901
+
902
+ if (typeof value === "object") {
903
+ return `<code>${escapeHtml(JSON.stringify(value))}</code>`;
904
+ }
905
+
906
+ return renderWidgetContent(value);
907
+ }
908
+
909
+ function renderDataAudit(): string {
910
+ return `
911
+ <section class="panel">
912
+ <div class="panel-header">
913
+ <h2>Data query audit</h2>
914
+ <div class="audit-controls">
915
+ <form id="data-audit-filter-form" class="form-actions">
916
+ <label>Type<select id="data-audit-type">${renderDataAuditTypeOptions()}</select></label>
917
+ <label>Output classification<select id="data-audit-output-classification">${renderOutputClassificationOptions()}</select></label>
918
+ <label>SQL contains<input id="data-audit-sql-contains" name="sql_contains" value="${escapeHtml(state.dataAuditSqlContains)}" /></label>
919
+ <button type="submit">Apply</button>
920
+ </form>
921
+ <form id="retention-form" class="form-actions">
922
+ <label>Retention days<input name="retention" type="number" min="1" max="3650" value="${escapeHtml(state.auditSettings?.data_query_retention_days ?? 90)}" /></label>
923
+ <button class="primary" type="submit">Save</button>
924
+ </form>
925
+ </div>
926
+ </div>
927
+ <div class="table-wrap"><table>
928
+ <thead><tr><th>Time</th><th>Type</th><th>Output classification</th><th>Learning</th><th>User</th><th>Datasource</th><th>Question</th><th>Answer</th><th>SQL</th><th>Metadata</th></tr></thead>
929
+ <tbody>${state.dataAudit.map(item => `<tr><td>${escapeHtml(formatAuditTime(item.occurred_at))}</td><td>${escapeHtml(item.audit_type || "info")}</td><td>${escapeHtml(item.output_classification || "unknown")}</td><td>${renderAuditLearning(item)}</td><td>${escapeHtml(item.user_id)}</td><td>${escapeHtml(item.datasource_id)}</td><td>${escapeHtml(item.question)}</td><td>${escapeHtml(item.answer)}</td><td><code>${escapeHtml(item.sql)}</code></td><td>${renderAuditMetadata(item)}</td></tr>`).join("")}</tbody>
930
+ </table></div>
931
+ </section>`;
932
+ }
933
+
934
+ function renderAuditMetadata(item: any): string {
935
+ const metadata = item.metadata || {};
936
+
937
+ if (!Object.keys(metadata).length) return "";
938
+
939
+ return `<pre class="metadata-json">${escapeHtml(JSON.stringify(metadata, null, 2))}</pre>`;
940
+ }
941
+
942
+ function renderAuditLearning(item: any): string {
943
+ const learning = item.metadata?.business_logic_learning;
944
+
945
+ if (!learning) return "";
946
+
947
+ return `
948
+ <span>${escapeHtml(learning.message || "")}</span>
949
+ ${learning.suggestion_id ? `<button type="button" data-open-business-logic>Open suggestions</button>` : ""}`;
950
+ }
951
+
952
+ function renderDataAuditTypeOptions(): string {
953
+ return dataAuditTypes
954
+ .map(type => `<option value="${escapeHtml(type.value)}" ${state.dataAuditType === type.value ? "selected" : ""}>${escapeHtml(type.label)}</option>`)
955
+ .join("");
956
+ }
957
+
958
+ function renderOutputClassificationOptions(): string {
959
+ return outputClassifications
960
+ .map(item => `<option value="${escapeHtml(item.value)}" ${state.dataAuditOutputClassification === item.value ? "selected" : ""}>${escapeHtml(item.label)}</option>`)
961
+ .join("");
962
+ }
963
+
964
+ function renderWidgets(): string {
965
+ const selectedWidget = getSelectedOverviewWidgetConfig();
966
+ const creating = state.selectedOverviewWidgetKey === "__new__";
967
+
968
+ return `
969
+ <div class="split widgets-editor">
970
+ <section class="panel">
971
+ <div class="panel-header">
972
+ <h2>Widgets</h2>
973
+ <button type="button" id="new-overview-widget">New</button>
974
+ </div>
975
+ <div class="panel-body list widget-config-list">
976
+ ${state.overviewWidgetConfigs.length
977
+ ? state.overviewWidgetConfigs.map(widget => renderWidgetConfigListItem(widget, selectedWidget?.widget_key === widget.widget_key && !creating)).join("")
978
+ : `<p class="muted">No widgets defined.</p>`}
979
+ </div>
980
+ </section>
981
+ <section class="panel">
982
+ <div class="panel-header">
983
+ <h2>${creating ? "New widget" : escapeHtml(selectedWidget?.label || "Widget settings")}</h2>
984
+ </div>
985
+ <div class="panel-body">
986
+ ${creating || selectedWidget ? renderOverviewWidgetSettingsForm(selectedWidget) : `<p class="muted">Select a widget to edit its settings.</p>`}
987
+ </div>
988
+ </section>
989
+ </div>`;
990
+ }
991
+
992
+ function renderWidgetConfigListItem(widget: OverviewWidget, active: boolean): string {
993
+ return `
994
+ <div class="widget-config-row ${active ? "active" : ""}">
995
+ <input type="checkbox" data-overview-widget-active="${escapeHtml(widget.widget_key)}" aria-label="Enable ${escapeHtml(widget.label)}" ${widget.active ? "checked" : ""} />
996
+ <button class="widget-config-select" type="button" data-overview-widget-select="${escapeHtml(widget.widget_key)}">
997
+ <strong>${escapeHtml(widget.label)}</strong>
998
+ <span>${escapeHtml(widget.widget_key)} · ${escapeHtml(widget.widget_type)} · ${escapeHtml(formatOverviewWidgetResultMode(widget.result_mode))} · ${escapeHtml(formatOverviewWidgetSize(widget))} · slot ${escapeHtml(overviewSlotFromPosition(widget.position) + 1)}</span>
999
+ </button>
1000
+ <button class="icon-button danger widget-config-delete" type="button" data-overview-widget-delete="${escapeHtml(widget.widget_key)}" aria-label="Delete ${escapeHtml(widget.label)}" title="Delete widget">
1001
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
1002
+ <path d="M3 6h18" />
1003
+ <path d="M8 6V4h8v2" />
1004
+ <path d="M19 6l-1 14H6L5 6" />
1005
+ <path d="M10 11v5" />
1006
+ <path d="M14 11v5" />
1007
+ </svg>
1008
+ </button>
1009
+ </div>`;
1010
+ }
1011
+
1012
+ function getSelectedOverviewWidgetConfig(): OverviewWidget | null {
1013
+ if (state.selectedOverviewWidgetKey === "__new__") {
1014
+ return null;
1015
+ }
1016
+
1017
+ return state.overviewWidgetConfigs.find(widget => widget.widget_key === state.selectedOverviewWidgetKey)
1018
+ || state.overviewWidgetConfigs[0]
1019
+ || null;
1020
+ }
1021
+
1022
+ function getOverviewWidgetFormPosition(widget: OverviewWidget | null): number {
1023
+ if (widget) {
1024
+ return widget.position || 100;
1025
+ }
1026
+
1027
+ return overviewPositionFromSlot(state.overviewPlacementSlot ?? 0);
1028
+ }
1029
+
1030
+ function formatOverviewWidgetSize(widget: Pick<OverviewWidget, "widget_type" | "grid_width">): string {
1031
+ return `${getOverviewWidgetGridWidth(widget)}/${OVERVIEW_GRID_COLUMNS}`;
1032
+ }
1033
+
1034
+ function getDefaultOverviewWidgetGridWidth(widgetType: string): number {
1035
+ return widgetType === "scalar" ? 1 : OVERVIEW_GRID_COLUMNS;
1036
+ }
1037
+
1038
+ function renderOverviewWidgetSizeOptions(selected: number): string {
1039
+ const options = [
1040
+ { value: 1, label: "Small (1 col)" },
1041
+ { value: 2, label: "Medium (2 cols)" },
1042
+ { value: 3, label: "Wide (3 cols)" },
1043
+ { value: 4, label: "Full (4 cols)" },
1044
+ ];
1045
+
1046
+ return options
1047
+ .map(option => `<option value="${escapeHtml(option.value)}" ${option.value === selected ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
1048
+ .join("");
1049
+ }
1050
+
1051
+ function renderOverviewWidgetResultModeOptions(selected: string = "data"): string {
1052
+ const options = [
1053
+ { value: "data", label: "Zwróć dane" },
1054
+ { value: "interpretation", label: "Interpretuj dane" },
1055
+ ];
1056
+
1057
+ return options
1058
+ .map(option => `<option value="${escapeHtml(option.value)}" ${option.value === selected ? "selected" : ""}>${escapeHtml(option.label)}</option>`)
1059
+ .join("");
1060
+ }
1061
+
1062
+ function formatOverviewWidgetResultMode(value: string = "data"): string {
1063
+ return value === "interpretation" ? "interpretation" : "data";
1064
+ }
1065
+
1066
+ function renderOverviewWidgetSettingsForm(widget: OverviewWidget | null): string {
1067
+ const creating = widget === null;
1068
+ const position = getOverviewWidgetFormPosition(widget);
1069
+ const widgetType = widget?.widget_type || "scalar";
1070
+ const gridWidth = widget ? getOverviewWidgetGridWidth(widget) : getDefaultOverviewWidgetGridWidth(widgetType);
1071
+ const resultMode = widget?.result_mode || "data";
1072
+
1073
+ return `
1074
+ <form class="form-grid" id="overview-widget-settings-form" data-widget-mode="${creating ? "create" : "update"}" data-widget-key="${escapeHtml(widget?.widget_key || "")}">
1075
+ ${creating ? `<label>Widget key<input name="widget_key" value="" placeholder="custom_widget_key" /></label>` : `<input type="hidden" name="widget_key" value="${escapeHtml(widget?.widget_key || "")}" />`}
1076
+ <label>Label<input name="label" value="${escapeHtml(widget?.label || "")}" /></label>
1077
+ <div class="subgrid">
1078
+ <label>Type<select name="widget_type">${renderWidgetTypeOptions(widget?.widget_type || "scalar")}</select></label>
1079
+ <label>Datasource<select name="datasource_key">${renderOverviewDatasourceOptions(widget?.datasource_key || "metadata-db")}</select></label>
1080
+ </div>
1081
+ <div class="subgrid">
1082
+ <label>Position<input name="position" type="number" min="10" step="10" value="${escapeHtml(position)}" /></label>
1083
+ <label>Size<select name="grid_width">${renderOverviewWidgetSizeOptions(gridWidth)}</select></label>
1084
+ </div>
1085
+ <label>Result mode<select name="result_mode">${renderOverviewWidgetResultModeOptions(resultMode)}</select></label>
1086
+ <label class="inline-check"><input name="active" type="checkbox" ${widget?.active || creating ? "checked" : ""} /> Enabled</label>
1087
+ <label>Question<textarea name="question">${escapeHtml(widget?.question || "")}</textarea></label>
1088
+ <label>Generated SQL<textarea class="textarea-small" readonly>${escapeHtml(widget?.sql || "")}</textarea></label>
1089
+ <div class="form-actions">
1090
+ <button class="primary" type="submit">${creating ? "Create and refresh" : "Save and refresh"}</button>
1091
+ </div>
1092
+ </form>`;
1093
+ }
1094
+
1095
+ function renderPrompts(): string {
1096
+ const selected = state.prompts.find(prompt => prompt.prompt_key === state.selectedPromptKey) || state.prompts[0];
1097
+ return `
1098
+ <div class="split">
1099
+ <section class="panel">
1100
+ <div class="panel-header"><h2>Prompt templates</h2></div>
1101
+ <div class="panel-body list">${state.prompts.map(prompt => `<button data-prompt="${prompt.prompt_key}" class="${selected?.prompt_key === prompt.prompt_key ? "active" : ""}"><strong>${escapeHtml(prompt.name)}</strong><br /><span>v${escapeHtml(prompt.version)} ${prompt.active ? "active" : "inactive"}</span></button>`).join("")}</div>
1102
+ </section>
1103
+ <section class="panel">
1104
+ <div class="panel-header"><h2>${escapeHtml(selected?.name || "Prompt")}</h2></div>
1105
+ <div class="panel-body">${selected ? renderPromptForm(selected) : ""}</div>
1106
+ </section>
1107
+ </div>`;
1108
+ }
1109
+
1110
+ function renderPromptForm(prompt: PromptTemplate): string {
1111
+ return `
1112
+ <form id="prompt-form" class="form-grid">
1113
+ <input type="hidden" name="prompt_key" value="${escapeHtml(prompt.prompt_key)}" />
1114
+ <label>Name<input name="name" value="${escapeHtml(prompt.name)}" /></label>
1115
+ <label>Description<input name="description" value="${escapeHtml(prompt.description)}" /></label>
1116
+ <label>System prompt<textarea name="system_prompt">${escapeHtml(prompt.system_prompt)}</textarea></label>
1117
+ <label>User prompt template<textarea name="user_prompt_template">${escapeHtml(prompt.user_prompt_template)}</textarea></label>
1118
+ <label class="inline-check"><input name="active" type="checkbox" ${prompt.active ? "checked" : ""} /> Active</label>
1119
+ <div class="form-actions"><button class="primary" type="submit">Save prompt</button></div>
1120
+ </form>`;
1121
+ }
1122
+
1123
+ function renderDatasources(): string {
1124
+ const selected = getSelectedDatasource();
1125
+ return `
1126
+ <div class="split">
1127
+ <section class="panel">
1128
+ <div class="panel-header"><h2>Datasources</h2><button id="new-datasource">New</button></div>
1129
+ <div class="panel-body list">${state.datasources.map(connector => `<button data-datasource="${connector.id}" class="${selected?.id === connector.id ? "active" : ""}"><strong>${escapeHtml(connector.name)}</strong><br /><span>${escapeHtml(connector.database_type)} ${connector.active ? "active" : ""}</span></button>`).join("")}</div>
1130
+ </section>
1131
+ <section class="panel">
1132
+ <div class="panel-header"><h2>${selected ? escapeHtml(selected.name) : "New datasource"}</h2></div>
1133
+ <div class="panel-body">${renderDatasourceForm(selected)}</div>
1134
+ </section>
1135
+ </div>
1136
+ ${selected ? renderDatasourceSchema() : ""}`;
1137
+ }
1138
+
1139
+ function getSelectedDatasource(): DatasourceConnector | null {
1140
+ if (state.selectedDatasourceId === "new") return null;
1141
+ return state.datasources.find(item => item.id === state.selectedDatasourceId) || state.datasources[0] || null;
1142
+ }
1143
+
1144
+ function renderDatasourceForm(connector: DatasourceConnector | null): string {
1145
+ const systemManaged = connector?.system_managed === true;
1146
+ const disabled = systemManaged ? "disabled" : "";
1147
+ return `
1148
+ <form id="datasource-form" class="form-grid">
1149
+ <input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
1150
+ ${systemManaged ? `<div class="badge">System managed</div>` : ""}
1151
+ <label>Connector key<input name="connector_key" ${connector || systemManaged ? "readonly" : ""} ${disabled} value="${escapeHtml(connector?.connector_key || "")}" /></label>
1152
+ <label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
1153
+ <div class="subgrid">
1154
+ <label>Database type<select name="database_type" ${disabled}>${renderDatabaseTypeOptions(connector?.database_type || "sqlite")}</select></label>
1155
+ <label>SQL dialect<select name="sql_dialect" ${disabled}>${renderSqlDialectOptions(connector?.sql_dialect || "sqlite")}</select></label>
1156
+ </div>
1157
+ <label>Database URL<input name="database_url" ${disabled} value="${escapeHtml(connector?.database_url || "sqlite:///./examples/medical-poc/demo.db")}" /></label>
1158
+ <label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
1159
+ <div class="button-row">
1160
+ <button type="button" id="test-datasource">Test</button>
1161
+ <button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
1162
+ <button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
1163
+ <button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
1164
+ </div>
1165
+ </form>`;
1166
+ }
1167
+
1168
+ function renderDatabaseTypeOptions(selected: string): string {
1169
+ return ["sqlite", "postgresql", "mysql"]
1170
+ .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1171
+ .join("");
1172
+ }
1173
+
1174
+ function renderSqlDialectOptions(selected: string): string {
1175
+ return ["sqlite", "postgres", "mysql"]
1176
+ .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1177
+ .join("");
1178
+ }
1179
+
1180
+ function renderModeOptions(selected: string, values: string[]): string {
1181
+ return values
1182
+ .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1183
+ .join("");
1184
+ }
1185
+
1186
+ function renderDatasourceSchema(): string {
1187
+ const schema = state.datasourceSchema?.item;
1188
+ const rawTables = schema?.raw_schema?.tables || [];
1189
+ const tableSettings = schema?.table_settings?.tables || {};
1190
+ const draftTables = schema ? getDatasourceSchemaDraftTables(rawTables, tableSettings) : {};
1191
+ const visibleTables = state.datasourceSchemaShowEnabledOnly
1192
+ ? rawTables.filter((table: any) => draftTables[table.name]?.selected !== false)
1193
+ : rawTables;
1194
+ const selectedTable = schema ? getSelectedDatasourceSchemaObject(rawTables, visibleTables) : null;
1195
+ const selectedSettings = selectedTable ? draftTables[selectedTable.name] || {} : {};
1196
+ return `
1197
+ <section class="panel">
1198
+ <div class="panel-header"><h2>Schema introspection</h2><span class="badge">${escapeHtml(schema?.introspected_at || "not cached")}</span></div>
1199
+ <div class="panel-body">
1200
+ ${state.datasourceSchemaLoading ? `<p class="muted">loading schema</p>` : state.datasourceSchemaError ? `<p class="error">${escapeHtml(state.datasourceSchemaError)}</p>` : schema ? `
1201
+ <form id="datasource-schema-form" class="schema-editor">
1202
+ <section class="schema-object-list">
1203
+ <div class="schema-object-list-header">
1204
+ <label class="inline-check"><input id="schema-show-enabled-only" type="checkbox" ${state.datasourceSchemaShowEnabledOnly ? "checked" : ""} /> Show enabled objects only</label>
1205
+ </div>
1206
+ <div class="schema-object-list-body">
1207
+ ${visibleTables.length ? visibleTables.map((table: any) => renderDatasourceObjectListItem(table, draftTables[table.name] || {}, selectedTable?.name === table.name)).join("") : `<p class="muted schema-object-empty">No enabled objects.</p>`}
1208
+ </div>
1209
+ </section>
1210
+ <section class="schema-object-details">
1211
+ ${selectedTable ? renderDatasourceObjectDetails(selectedTable, selectedSettings) : `<p class="muted">Select a table or view to edit its guidance.</p>`}
1212
+ <div class="form-actions"><button class="primary" type="submit">Save schema settings</button></div>
1213
+ </section>
1214
+ </form>` : `<p class="muted">Run schema introspection to cache tables, views, keys and relationships.</p>`}
1215
+ </div>
1216
+ </section>`;
1217
+ }
1218
+
1219
+ function getDatasourceSchemaDraftTables(rawTables: any[], tableSettings: Record<string, any>): Record<string, any> {
1220
+ if (!state.datasourceSchemaDraftTables) {
1221
+ state.datasourceSchemaDraftTables = {};
1222
+ }
1223
+
1224
+ for (const table of rawTables) {
1225
+ if (state.datasourceSchemaDraftTables[table.name]) continue;
1226
+
1227
+ const settings = tableSettings[table.name] || {};
1228
+ state.datasourceSchemaDraftTables[table.name] = {
1229
+ selected: settings.selected !== false,
1230
+ description: settings.description || "",
1231
+ primary_key_prompt: settings.primary_key_prompt || "",
1232
+ foreign_key_prompt: settings.foreign_key_prompt || "",
1233
+ join_logic: settings.join_logic || "",
1234
+ };
1235
+ }
1236
+
1237
+ return state.datasourceSchemaDraftTables;
1238
+ }
1239
+
1240
+ function getSelectedDatasourceSchemaObject(rawTables: any[], visibleTables: any[]): any | null {
1241
+ const current = rawTables.find((table: any) => table.name === state.datasourceSchemaSelectedObjectName);
1242
+ const currentIsVisible = visibleTables.some((table: any) => table.name === current?.name);
1243
+
1244
+ if (current && currentIsVisible) return current;
1245
+
1246
+ const fallback = visibleTables[0] || null;
1247
+ state.datasourceSchemaSelectedObjectName = fallback?.name || "";
1248
+ return fallback;
1249
+ }
1250
+
1251
+ function renderBusinessLogicSuggestions(): string {
1252
+ const datasource = state.businessLogicDatasource;
1253
+ return `
1254
+ <section class="panel">
1255
+ <div class="panel-header">
1256
+ <h2>Business logic suggestions</h2>
1257
+ <span class="badge">${escapeHtml(datasource?.connector_key || "no active datasource")}</span>
1258
+ </div>
1259
+ <div class="table-wrap"><table>
1260
+ <thead><tr><th>Use</th><th>Status</th><th>Safety</th><th>Rule</th><th>Error</th><th>Confidence</th><th>Actions</th></tr></thead>
1261
+ <tbody>${state.businessLogic.map(item => `
1262
+ <tr>
1263
+ <td><input type="checkbox" data-business-logic-toggle="${escapeHtml(item.id)}" ${item.enabled ? "checked" : ""} /></td>
1264
+ <td>${escapeHtml(item.status)}</td>
1265
+ <td>${escapeHtml(item.safety)}</td>
1266
+ <td><strong>${escapeHtml(item.title)}</strong><br /><span class="muted">${escapeHtml(item.rule_text)}</span></td>
1267
+ <td>${escapeHtml(item.error_category)}</td>
1268
+ <td>${escapeHtml(Math.round(Number(item.confidence || 0) * 100))}%</td>
1269
+ <td>
1270
+ <div class="button-row">
1271
+ <button type="button" data-business-logic-edit="${escapeHtml(item.id)}">Edit</button>
1272
+ <button type="button" class="danger" data-business-logic-delete="${escapeHtml(item.id)}">Delete</button>
1273
+ </div>
1274
+ </td>
1275
+ </tr>`).join("")}</tbody>
1276
+ </table></div>
1277
+ ${state.businessLogic.length ? "" : `<div class="panel-body"><p class="muted">No suggestions for the active datasource.</p></div>`}
1278
+ </section>
1279
+ ${renderBusinessLogicEditorModal()}`;
1280
+ }
1281
+
1282
+ function renderBusinessLogicEditorModal(): string {
1283
+ const suggestion = getBusinessLogicEditorSuggestion();
1284
+
1285
+ if (!suggestion) return "";
1286
+
1287
+ return `
1288
+ <div class="modal-backdrop" data-business-logic-backdrop>
1289
+ <section class="modal-panel modal-panel-small" role="dialog" aria-modal="true" aria-labelledby="business-logic-modal-title">
1290
+ <div class="modal-header">
1291
+ <div>
1292
+ <h2 id="business-logic-modal-title">Edit business logic</h2>
1293
+ <p>${escapeHtml(suggestion.error_category || "business logic suggestion")}</p>
1294
+ </div>
1295
+ <button type="button" data-close-business-logic-editor>Close</button>
1296
+ </div>
1297
+ <form class="form-grid" data-business-logic-form="${escapeHtml(suggestion.id)}">
1298
+ <label>Title<input name="title" value="${escapeHtml(suggestion.title || "")}" /></label>
1299
+ <label>Rule text<textarea class="textarea-small" name="rule_text">${escapeHtml(suggestion.rule_text || "")}</textarea></label>
1300
+ <div class="form-actions">
1301
+ <button type="button" data-close-business-logic-editor>Cancel</button>
1302
+ <button class="primary" type="submit">Save</button>
1303
+ </div>
1304
+ </form>
1305
+ </section>
1306
+ </div>`;
1307
+ }
1308
+
1309
+ function getBusinessLogicEditorSuggestion(): any | null {
1310
+ if (state.businessLogicEditorId === null) return null;
1311
+
1312
+ return state.businessLogic.find(item => Number(item.id) === state.businessLogicEditorId) || null;
1313
+ }
1314
+
1315
+ function renderLlmConfig(): string {
1316
+ const config = state.llmConfig || {};
1317
+ const apiKeyStatus = config.api_key_configured
1318
+ ? `Configured (${escapeHtml(config.api_key_preview || "hidden")})`
1319
+ : "Not configured";
1320
+ const apiKeyPlaceholder = config.api_key_configured
1321
+ ? "Leave blank to keep current key"
1322
+ : "Enter API key";
1323
+ return `
1324
+ <section class="panel">
1325
+ <div class="panel-header"><h2>LLM configuration</h2></div>
1326
+ <div class="panel-body">
1327
+ <form id="llm-config-form" class="form-grid">
1328
+ <label>Provider<input name="provider" value="${escapeHtml(config.provider || "openai-compatible")}" /></label>
1329
+ <label>Base URL<input name="base_url" value="${escapeHtml(config.base_url || "")}" /></label>
1330
+ <label>API key <span class="muted">${apiKeyStatus}</span><input name="api_key" type="password" value="" placeholder="${apiKeyPlaceholder}" autocomplete="new-password" /></label>
1331
+ <label class="checkbox-row"><input name="clear_api_key" type="checkbox" /> Clear API key</label>
1332
+ <label>Model<input name="model" value="${escapeHtml(config.model || "")}" /></label>
1333
+ <label>LLM timeout seconds<input name="timeout_seconds" type="number" min="1" max="600" value="${escapeHtml(config.timeout_seconds || 60)}" /></label>
1334
+ <div class="subgrid">
1335
+ <label>Intent mode<select name="intent_classification_mode">${renderModeOptions(config.intent_classification_mode || "auto", ["auto", "llm"])}</select></label>
1336
+ <label>SQL generation<select name="sql_generation_mode">${renderModeOptions(config.sql_generation_mode || "llm", ["llm"])}</select></label>
1337
+ </div>
1338
+ <div class="subgrid">
1339
+ <label>Result interpretation<select name="result_interpretation_mode">${renderModeOptions(config.result_interpretation_mode || "llm", ["llm"])}</select></label>
1340
+ <label>Output classification<select name="output_classification_mode">${renderModeOptions(config.output_classification_mode || "auto", ["auto", "llm"])}</select></label>
1341
+ </div>
1342
+ <div class="subgrid">
1343
+ <label>Investigation mode<select name="investigation_mode">${renderModeOptions(config.investigation_mode || "llm", ["llm"])}</select></label>
1344
+ <label>Ambiguity handling<select name="investigation_ambiguity_mode">${renderModeOptions(config.investigation_ambiguity_mode || "clarify", ["clarify", "safe_aggregate"])}</select></label>
1345
+ </div>
1346
+ <div class="subgrid">
1347
+ <label>Query max rows<input name="query_max_rows" type="number" min="1" max="100000" value="${escapeHtml(config.query_max_rows || 100)}" /></label>
1348
+ <label>Query timeout seconds<input name="query_timeout_seconds" type="number" min="1" max="3600" value="${escapeHtml(config.query_timeout_seconds || 30)}" /></label>
1349
+ </div>
1350
+ <label>Extra body JSON<textarea name="extra_body">${escapeHtml(config.extra_body_json || "{}")}</textarea></label>
1351
+ <div class="mono muted">${escapeHtml(JSON.stringify(config.sources || {}, null, 2))}</div>
1352
+ <div class="form-actions"><button class="primary" type="submit">Save LLM configuration</button></div>
1353
+ </form>
1354
+ </div>
1355
+ </section>`;
1356
+ }
1357
+
1358
+ function renderGovernancePolicy(): string {
1359
+ const config = state.governancePolicy || {};
1360
+ const finalAnswer = config.final_answer || {};
1361
+ const sql = config.sql || {};
1362
+ const privacy = config.privacy || {};
1363
+
1364
+ return `
1365
+ <section class="panel">
1366
+ <div class="panel-header"><h2>Governance policy</h2></div>
1367
+ <div class="panel-body">
1368
+ <form id="governance-policy-form" class="form-grid">
1369
+ <div class="subgrid">
1370
+ <label class="inline-check"><input name="record_level_pii_allowed" type="checkbox" ${finalAnswer.record_level_pii_allowed ? "checked" : ""} /> Record-level PII allowed</label>
1371
+ <label class="inline-check"><input name="prefer_aggregates_for_sensitive_domains" type="checkbox" ${finalAnswer.prefer_aggregates_for_sensitive_domains !== false ? "checked" : ""} /> Prefer aggregates for sensitive domains</label>
1372
+ </div>
1373
+ <div class="subgrid">
1374
+ <label class="inline-check"><input name="sql_read_only" type="checkbox" ${sql.read_only !== false ? "checked" : ""} /> Read-only SQL</label>
1375
+ <label class="inline-check"><input name="select_star_allowed" type="checkbox" ${sql.select_star_allowed ? "checked" : ""} /> SELECT * allowed</label>
1376
+ </div>
1377
+ <div class="subgrid">
1378
+ <label class="inline-check"><input name="tenant_filter_required" type="checkbox" ${sql.tenant_filter_required ? "checked" : ""} /> Tenant filter required</label>
1379
+ <label>Tenant column<input name="tenant_column" value="${escapeHtml(sql.tenant_column || "")}" /></label>
1380
+ </div>
1381
+ <label class="inline-check"><input name="record_level_forbidden" type="checkbox" ${privacy.record_level_forbidden ? "checked" : ""} /> Record-level output forbidden</label>
1382
+ <label>Explicit forbidden columns JSON<textarea name="forbidden_columns">${escapeHtml(JSON.stringify(privacy.forbidden_columns || {}, null, 2))}</textarea></label>
1383
+ <label>PII column names JSON<textarea name="pii_column_names">${escapeHtml(JSON.stringify(config.pii_column_names || {}, null, 2))}</textarea></label>
1384
+ <div class="mono muted">${escapeHtml(JSON.stringify(config.sources || {}, null, 2))}</div>
1385
+ <div class="form-actions"><button class="primary" type="submit">Save governance policy</button></div>
1386
+ </form>
1387
+ </div>
1388
+ </section>`;
1389
+ }
1390
+
1391
+ function renderDatasourceObjectListItem(table: any, settings: any, active: boolean): string {
1392
+ const selected = settings.selected !== false;
1393
+ const objectType = table.object_type || "table";
1394
+ return `
1395
+ <div class="schema-object-row ${active ? "active" : ""}" data-schema-object-row="${escapeHtml(table.name)}">
1396
+ <input name="${escapeHtml(table.name)}__selected" data-schema-object-enabled="${escapeHtml(table.name)}" aria-label="Use ${escapeHtml(table.name)}" type="checkbox" ${selected ? "checked" : ""} />
1397
+ <button type="button" data-schema-object="${escapeHtml(table.name)}">
1398
+ <span class="schema-object-name">${escapeHtml(table.name)}</span>
1399
+ <span class="badge">${escapeHtml(objectType)}</span>
1400
+ </button>
1401
+ </div>`;
1402
+ }
1403
+
1404
+ function renderDatasourceObjectDetails(table: any, settings: any): string {
1405
+ const objectType = table.object_type || "table";
1406
+ return `
1407
+ <div class="schema-object-detail-header">
1408
+ <div>
1409
+ <h3>${escapeHtml(table.name)}</h3>
1410
+ <span class="badge">${escapeHtml(objectType)}</span>
1411
+ </div>
1412
+ </div>
1413
+ <div class="schema-object-columns">${escapeHtml((table.columns || []).map((column: any) => `${column.name}:${column.type}${column.primary_key ? " pk" : ""}`).join(", ") || "No columns available.")}</div>
1414
+ <label>Description<input data-schema-detail="description" name="${escapeHtml(table.name)}__description" value="${escapeHtml(settings.description || "")}" /></label>
1415
+ <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>
1416
+ <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>
1417
+ <label>Join logic<textarea data-schema-detail="join_logic" class="textarea-small" name="${escapeHtml(table.name)}__join_logic">${escapeHtml(settings.join_logic || "")}</textarea></label>`;
1418
+ }
1419
+
1420
+ function renderSchemaCache(): string {
1421
+ return `
1422
+ <section class="panel"><div class="panel-header"><h2>Schema cache</h2></div>
1423
+ <div class="panel-body"><form id="schema-cache-form" class="form-grid">
1424
+ <label>TTL seconds<input name="ttl_seconds" type="number" min="1" max="86400" value="${escapeHtml(state.schemaCache?.ttl_seconds ?? 300)}" /></label>
1425
+ <div><span class="badge">Runtime ${escapeHtml(state.schemaCache?.runtime_ttl_seconds ?? "-")}s</span></div>
1426
+ <div><code>${escapeHtml(state.schemaCache?.cache_key ?? "")}</code></div>
1427
+ <div class="form-actions"><button type="button" id="invalidate-schema-cache" class="danger">Invalidate cache</button><button class="primary" type="submit">Save TTL</button></div>
1428
+ </form></div>
1429
+ </section>`;
1430
+ }
1431
+
1432
+ function renderStub(title: string, text: string): string {
1433
+ return `<section class="panel"><div class="panel-header"><h2>${escapeHtml(title)}</h2><span class="badge planned">planned</span></div><div class="panel-body"><p class="muted">${escapeHtml(text)}</p></div></section>`;
1434
+ }
1435
+
1436
+ function renderLicense(): string {
1437
+ return `<section class="panel"><div class="panel-header"><h2>License</h2></div><div class="panel-body mono">${escapeHtml(JSON.stringify(state.license || {}, null, 2))}</div></section>`;
1438
+ }
1439
+
1440
+ function renderAdminAudit(): string {
1441
+ return `
1442
+ <section class="panel"><div class="panel-header"><h2>Admin audit</h2></div>
1443
+ <div class="table-wrap"><table>
1444
+ <thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>Details</th></tr></thead>
1445
+ <tbody>${state.adminAudit.map(item => `<tr><td>${escapeHtml(item.occurred_at)}</td><td>${escapeHtml(item.actor)}</td><td>${escapeHtml(item.action)}</td><td>${escapeHtml(item.resource_type)}:${escapeHtml(item.resource_id)}</td><td><code>${escapeHtml(JSON.stringify(item.details))}</code></td></tr>`).join("")}</tbody>
1446
+ </table></div>
1447
+ </section>`;
1448
+ }
1449
+
1450
+ function attachSectionHandlers(): void {
1451
+ document.querySelector<HTMLButtonElement>("#overview-refresh")?.addEventListener("click", refreshOverview);
1452
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-empty-slot]").forEach(button => {
1453
+ button.addEventListener("click", async () => {
1454
+ const slot = Number(button.dataset.overviewEmptySlot || 0);
1455
+
1456
+ state.overviewPlacementSlot = state.overviewPlacementSlot === slot ? null : slot;
1457
+
1458
+ if (!state.overviewWidgetConfigs.length) {
1459
+ await loadOverviewWidgetConfigs(false);
1460
+ }
1461
+
1462
+ render();
1463
+ });
1464
+ });
1465
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-place-widget]").forEach(button => {
1466
+ button.addEventListener("click", placeOverviewWidget);
1467
+ });
1468
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-new-widget]").forEach(button => {
1469
+ button.addEventListener("click", () => {
1470
+ state.overviewPlacementSlot = Number(button.dataset.overviewNewWidget || 0);
1471
+ state.selectedOverviewWidgetKey = "__new__";
1472
+ state.section = "widgets";
1473
+ render();
1474
+ });
1475
+ });
1476
+ document.querySelector("#new-overview-widget")?.addEventListener("click", () => {
1477
+ state.selectedOverviewWidgetKey = "__new__";
1478
+ state.overviewPlacementSlot = null;
1479
+ render();
1480
+ });
1481
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-widget-select]").forEach(button => {
1482
+ button.addEventListener("click", () => {
1483
+ state.selectedOverviewWidgetKey = button.dataset.overviewWidgetSelect || "";
1484
+ state.overviewPlacementSlot = null;
1485
+ render();
1486
+ });
1487
+ });
1488
+ document.querySelectorAll<HTMLInputElement>("[data-overview-widget-active]").forEach(input => {
1489
+ input.addEventListener("change", updateOverviewWidgetActive);
1490
+ });
1491
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-widget-delete]").forEach(button => {
1492
+ button.addEventListener("click", deleteOverviewWidget);
1493
+ });
1494
+ document.querySelector<HTMLFormElement>("#overview-widget-settings-form")?.addEventListener("submit", saveOverviewWidgetSettings);
1495
+ document.querySelectorAll<HTMLButtonElement>("[data-edit-overview-widget]").forEach(button => {
1496
+ button.addEventListener("click", () => {
1497
+ state.overviewEditorWidgetKey = button.dataset.editOverviewWidget || null;
1498
+ render();
1499
+ });
1500
+ });
1501
+ document.querySelectorAll<HTMLButtonElement>("[data-close-overview-widget]").forEach(button => {
1502
+ button.addEventListener("click", () => {
1503
+ state.overviewEditorWidgetKey = null;
1504
+ render();
1505
+ });
1506
+ });
1507
+ document.querySelector<HTMLElement>("[data-overview-widget-backdrop]")?.addEventListener("click", event => {
1508
+ if (event.target === event.currentTarget) {
1509
+ state.overviewEditorWidgetKey = null;
1510
+ render();
1511
+ }
1512
+ });
1513
+ document.querySelectorAll<HTMLFormElement>("[data-overview-widget-form]").forEach(form => {
1514
+ form.addEventListener("submit", saveOverviewWidget);
1515
+ });
1516
+ document.querySelectorAll<HTMLButtonElement>("[data-overview-table-page]").forEach(button => {
1517
+ button.addEventListener("click", () => {
1518
+ const widgetKey = button.dataset.overviewTablePage;
1519
+ const page = Number(button.dataset.page || 0);
1520
+
1521
+ if (!widgetKey || !Number.isFinite(page)) return;
1522
+
1523
+ state.overviewTablePages[widgetKey] = page;
1524
+ render();
1525
+ });
1526
+ });
1527
+ document.querySelector<HTMLFormElement>("#data-audit-filter-form")?.addEventListener("submit", loadDataAuditForFilters);
1528
+ document.querySelector<HTMLFormElement>("#retention-form")?.addEventListener("submit", saveRetention);
1529
+ document.querySelector<HTMLSelectElement>("#data-audit-type")?.addEventListener("change", loadDataAuditForFilters);
1530
+ document.querySelector<HTMLSelectElement>("#data-audit-output-classification")?.addEventListener("change", loadDataAuditForFilters);
1531
+ document.querySelector<HTMLFormElement>("#prompt-form")?.addEventListener("submit", savePrompt);
1532
+ document.querySelector<HTMLFormElement>("#schema-cache-form")?.addEventListener("submit", saveSchemaCacheTtl);
1533
+ document.querySelector<HTMLFormElement>("#llm-config-form")?.addEventListener("submit", saveLlmConfig);
1534
+ document.querySelector<HTMLFormElement>("#governance-policy-form")?.addEventListener("submit", saveGovernancePolicy);
1535
+ document.querySelector<HTMLFormElement>("#datasource-form")?.addEventListener("submit", saveDatasource);
1536
+ document.querySelector<HTMLFormElement>("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
1537
+ document.querySelector<HTMLInputElement>("#schema-show-enabled-only")?.addEventListener("change", event => {
1538
+ syncDatasourceSchemaDraftFromForm();
1539
+ state.datasourceSchemaShowEnabledOnly = (event.currentTarget as HTMLInputElement).checked;
1540
+ render();
1541
+ });
1542
+ document.querySelectorAll<HTMLInputElement>("[data-schema-object-enabled]").forEach(input => {
1543
+ input.addEventListener("change", () => {
1544
+ if (!state.datasourceSchemaShowEnabledOnly) return;
1545
+
1546
+ syncDatasourceSchemaDraftFromForm();
1547
+ render();
1548
+ });
1549
+ });
1550
+ document.querySelectorAll<HTMLButtonElement>("[data-schema-object]").forEach(button => {
1551
+ button.addEventListener("click", () => {
1552
+ syncDatasourceSchemaDraftFromForm();
1553
+ state.datasourceSchemaSelectedObjectName = button.dataset.schemaObject || "";
1554
+ render();
1555
+ });
1556
+ });
1557
+ document.querySelector("#invalidate-schema-cache")?.addEventListener("click", invalidateSchemaCache);
1558
+ document.querySelector("#test-datasource")?.addEventListener("click", testDatasource);
1559
+ document.querySelector("#introspect-datasource")?.addEventListener("click", introspectDatasource);
1560
+ document.querySelector("#activate-datasource")?.addEventListener("click", activateDatasource);
1561
+ document.querySelectorAll<HTMLButtonElement>("[data-open-business-logic]").forEach(button => {
1562
+ button.addEventListener("click", async () => {
1563
+ state.section = "business-logic";
1564
+ render();
1565
+ await loadBusinessLogicSuggestions();
1566
+ });
1567
+ });
1568
+ document.querySelectorAll<HTMLInputElement>("[data-business-logic-toggle]").forEach(input => {
1569
+ input.addEventListener("change", updateBusinessLogicSuggestion);
1570
+ });
1571
+ document.querySelectorAll<HTMLButtonElement>("[data-business-logic-edit]").forEach(button => {
1572
+ button.addEventListener("click", () => {
1573
+ state.businessLogicEditorId = Number(button.dataset.businessLogicEdit);
1574
+ render();
1575
+ });
1576
+ });
1577
+ document.querySelectorAll<HTMLButtonElement>("[data-close-business-logic-editor]").forEach(button => {
1578
+ button.addEventListener("click", () => {
1579
+ state.businessLogicEditorId = null;
1580
+ render();
1581
+ });
1582
+ });
1583
+ document.querySelector<HTMLElement>("[data-business-logic-backdrop]")?.addEventListener("click", event => {
1584
+ if (event.target === event.currentTarget) {
1585
+ state.businessLogicEditorId = null;
1586
+ render();
1587
+ }
1588
+ });
1589
+ document.querySelectorAll<HTMLFormElement>("[data-business-logic-form]").forEach(form => {
1590
+ form.addEventListener("submit", saveBusinessLogicSuggestion);
1591
+ });
1592
+ document.querySelectorAll<HTMLButtonElement>("[data-business-logic-delete]").forEach(button => {
1593
+ button.addEventListener("click", deleteBusinessLogicSuggestion);
1594
+ });
1595
+ document.querySelector("#new-datasource")?.addEventListener("click", () => {
1596
+ state.selectedDatasourceId = "new";
1597
+ state.datasourceSchema = null;
1598
+ state.datasourceSchemaLoading = false;
1599
+ state.datasourceSchemaError = "";
1600
+ state.datasourceSchemaSelectedObjectName = "";
1601
+ state.datasourceSchemaDraftTables = null;
1602
+ render();
1603
+ });
1604
+ document.querySelectorAll<HTMLButtonElement>("[data-prompt]").forEach(button => {
1605
+ button.addEventListener("click", () => {
1606
+ state.selectedPromptKey = button.dataset.prompt || "";
1607
+ render();
1608
+ });
1609
+ });
1610
+ document.querySelectorAll<HTMLButtonElement>("[data-datasource]").forEach(button => {
1611
+ button.addEventListener("click", async () => {
1612
+ state.selectedDatasourceId = Number(button.dataset.datasource);
1613
+ await loadDatasourceSchema();
1614
+ });
1615
+ });
1616
+ }
1617
+
1618
+ async function loadDataAuditForFilters(event: Event): Promise<void> {
1619
+ event.preventDefault();
1620
+ state.dataAuditType = document.querySelector<HTMLSelectElement>("#data-audit-type")?.value || "";
1621
+ state.dataAuditOutputClassification = document.querySelector<HTMLSelectElement>("#data-audit-output-classification")?.value || "";
1622
+ state.dataAuditSqlContains = document.querySelector<HTMLInputElement>("#data-audit-sql-contains")?.value || "";
1623
+ await loadDataAudit();
1624
+ }
1625
+
1626
+ async function saveOverviewWidget(event: Event): Promise<void> {
1627
+ event.preventDefault();
1628
+ const formElement = event.currentTarget as HTMLFormElement;
1629
+ const widgetKey = formElement.dataset.overviewWidgetForm;
1630
+
1631
+ if (!widgetKey) return;
1632
+
1633
+ const form = new FormData(formElement);
1634
+
1635
+ try {
1636
+ await api(`/api/v1/admin/overview/widgets/${encodeURIComponent(widgetKey)}`, {
1637
+ method: "PUT",
1638
+ body: JSON.stringify({
1639
+ label: form.get("label"),
1640
+ widget_type: form.get("widget_type"),
1641
+ datasource_key: form.get("datasource_key"),
1642
+ question: form.get("question"),
1643
+ result_mode: form.get("result_mode") || "data",
1644
+ position: Number(form.get("position") || 100),
1645
+ grid_width: Number(form.get("grid_width") || 1),
1646
+ active: form.get("active") !== "false",
1647
+ }),
1648
+ });
1649
+ setMessage("success", "Overview widget saved.");
1650
+ state.overviewEditorWidgetKey = null;
1651
+ await loadOverview();
1652
+ } catch (error) {
1653
+ setMessage("error", (error as Error).message);
1654
+ }
1655
+ }
1656
+
1657
+ async function placeOverviewWidget(event: Event): Promise<void> {
1658
+ const button = event.currentTarget as HTMLButtonElement;
1659
+ const slot = Number(button.dataset.overviewPlaceWidget || 0);
1660
+ const select = document.querySelector<HTMLSelectElement>(`[data-overview-placement-select="${slot}"]`);
1661
+ const widgetKey = select?.value || "";
1662
+ const widget = state.overviewWidgetConfigs.find(item => item.widget_key === widgetKey);
1663
+
1664
+ if (!widgetKey || !widget) return;
1665
+
1666
+ try {
1667
+ const width = getOverviewWidgetGridWidth(widget);
1668
+ const layout = buildOverviewLayout(state.overview?.widgets || []);
1669
+ const actualSlot = findAvailableOverviewSlot(slot, width, layout.occupiedSlots);
1670
+
1671
+ await updateOverviewWidgetState(widgetKey, true, overviewPositionFromSlot(actualSlot), width);
1672
+ state.overviewPlacementSlot = null;
1673
+ setMessage("success", "Widget added to overview.");
1674
+ await loadOverview();
1675
+ } catch (error) {
1676
+ setMessage("error", (error as Error).message);
1677
+ render();
1678
+ }
1679
+ }
1680
+
1681
+ async function updateOverviewWidgetActive(event: Event): Promise<void> {
1682
+ const input = event.currentTarget as HTMLInputElement;
1683
+ const widgetKey = input.dataset.overviewWidgetActive || "";
1684
+ const widget = state.overviewWidgetConfigs.find(item => item.widget_key === widgetKey);
1685
+
1686
+ if (!widget) return;
1687
+
1688
+ try {
1689
+ await updateOverviewWidgetState(widgetKey, input.checked, widget.position, getOverviewWidgetGridWidth(widget));
1690
+ setMessage("success", input.checked ? "Widget enabled." : "Widget disabled.");
1691
+ await loadOverviewWidgetConfigs(false);
1692
+ if (state.section === "overview") {
1693
+ await loadOverview();
1694
+ } else {
1695
+ render();
1696
+ }
1697
+ } catch (error) {
1698
+ setMessage("error", (error as Error).message);
1699
+ await loadOverviewWidgetConfigs();
1700
+ }
1701
+ }
1702
+
1703
+ async function deleteOverviewWidget(event: Event): Promise<void> {
1704
+ const button = event.currentTarget as HTMLButtonElement;
1705
+ const widgetKey = button.dataset.overviewWidgetDelete || "";
1706
+ const widget = state.overviewWidgetConfigs.find(item => item.widget_key === widgetKey);
1707
+
1708
+ if (!widgetKey || !widget) return;
1709
+
1710
+ if (!window.confirm(`Delete widget "${widget.label}"?`)) {
1711
+ return;
1712
+ }
1713
+
1714
+ try {
1715
+ await api(`/api/v1/admin/overview/widgets/${encodeURIComponent(widgetKey)}`, {
1716
+ method: "DELETE",
1717
+ });
1718
+ setMessage("success", "Widget deleted.");
1719
+
1720
+ if (state.selectedOverviewWidgetKey === widgetKey) {
1721
+ state.selectedOverviewWidgetKey = "";
1722
+ }
1723
+
1724
+ if (state.overviewEditorWidgetKey === widgetKey) {
1725
+ state.overviewEditorWidgetKey = null;
1726
+ }
1727
+
1728
+ await loadOverviewWidgetConfigs(false);
1729
+ await loadOverview();
1730
+
1731
+ if (state.section !== "overview") {
1732
+ render();
1733
+ }
1734
+ } catch (error) {
1735
+ setMessage("error", (error as Error).message);
1736
+ render();
1737
+ }
1738
+ }
1739
+
1740
+ async function updateOverviewWidgetState(
1741
+ widgetKey: string,
1742
+ active: boolean,
1743
+ position: number,
1744
+ gridWidth?: number,
1745
+ ): Promise<void> {
1746
+ await api(`/api/v1/admin/overview/widgets/${encodeURIComponent(widgetKey)}/state`, {
1747
+ method: "PATCH",
1748
+ body: JSON.stringify({
1749
+ active,
1750
+ position,
1751
+ grid_width: gridWidth,
1752
+ }),
1753
+ });
1754
+ }
1755
+
1756
+ async function saveOverviewWidgetSettings(event: Event): Promise<void> {
1757
+ event.preventDefault();
1758
+ const formElement = event.currentTarget as HTMLFormElement;
1759
+ const mode = formElement.dataset.widgetMode || "update";
1760
+ const form = new FormData(formElement);
1761
+ const widgetKey = String(form.get("widget_key") || "").trim();
1762
+
1763
+ if (!widgetKey) return;
1764
+
1765
+ const payload = {
1766
+ widget_key: widgetKey,
1767
+ label: form.get("label"),
1768
+ widget_type: form.get("widget_type"),
1769
+ datasource_key: form.get("datasource_key"),
1770
+ question: form.get("question"),
1771
+ result_mode: form.get("result_mode") || "data",
1772
+ position: Number(form.get("position") || 100),
1773
+ grid_width: Number(form.get("grid_width") || 1),
1774
+ active: form.get("active") === "on",
1775
+ };
1776
+
1777
+ try {
1778
+ if (mode === "create") {
1779
+ await api("/api/v1/admin/overview/widgets", {
1780
+ method: "POST",
1781
+ body: JSON.stringify(payload),
1782
+ });
1783
+ state.selectedOverviewWidgetKey = widgetKey;
1784
+ state.overviewPlacementSlot = null;
1785
+ setMessage("success", "Widget created.");
1786
+ } else {
1787
+ await api(`/api/v1/admin/overview/widgets/${encodeURIComponent(widgetKey)}`, {
1788
+ method: "PUT",
1789
+ body: JSON.stringify(payload),
1790
+ });
1791
+ setMessage("success", "Widget saved.");
1792
+ }
1793
+
1794
+ await loadOverviewWidgetConfigs(false);
1795
+ await loadOverview();
1796
+
1797
+ if (state.section !== "overview") {
1798
+ render();
1799
+ }
1800
+ } catch (error) {
1801
+ setMessage("error", (error as Error).message);
1802
+ }
1803
+ }
1804
+
1805
+ async function updateBusinessLogicSuggestion(event: Event): Promise<void> {
1806
+ const input = event.currentTarget as HTMLInputElement;
1807
+ const id = input.dataset.businessLogicToggle;
1808
+
1809
+ if (!id) return;
1810
+
1811
+ try {
1812
+ await api(`/api/v1/admin/business-logic-suggestions/${encodeURIComponent(id)}`, {
1813
+ method: "PUT",
1814
+ body: JSON.stringify({ enabled: input.checked }),
1815
+ });
1816
+ setMessage("success", input.checked ? "Business logic enabled." : "Business logic disabled.");
1817
+ await loadBusinessLogicSuggestions();
1818
+ } catch (error) {
1819
+ setMessage("error", (error as Error).message);
1820
+ await loadBusinessLogicSuggestions();
1821
+ }
1822
+ }
1823
+
1824
+ async function saveBusinessLogicSuggestion(event: Event): Promise<void> {
1825
+ event.preventDefault();
1826
+ const formElement = event.currentTarget as HTMLFormElement;
1827
+ const id = formElement.dataset.businessLogicForm;
1828
+
1829
+ if (!id) return;
1830
+
1831
+ const form = new FormData(formElement);
1832
+
1833
+ try {
1834
+ await api(`/api/v1/admin/business-logic-suggestions/${encodeURIComponent(id)}`, {
1835
+ method: "PUT",
1836
+ body: JSON.stringify({
1837
+ title: form.get("title"),
1838
+ rule_text: form.get("rule_text"),
1839
+ }),
1840
+ });
1841
+ setMessage("success", "Business logic suggestion updated.");
1842
+ state.businessLogicEditorId = null;
1843
+ await loadBusinessLogicSuggestions();
1844
+ } catch (error) {
1845
+ setMessage("error", (error as Error).message);
1846
+ render();
1847
+ }
1848
+ }
1849
+
1850
+ async function deleteBusinessLogicSuggestion(event: Event): Promise<void> {
1851
+ const button = event.currentTarget as HTMLButtonElement;
1852
+ const id = button.dataset.businessLogicDelete;
1853
+
1854
+ if (!id) return;
1855
+
1856
+ try {
1857
+ await api(`/api/v1/admin/business-logic-suggestions/${encodeURIComponent(id)}`, {
1858
+ method: "DELETE",
1859
+ });
1860
+ setMessage("success", "Business logic suggestion deleted.");
1861
+ await loadBusinessLogicSuggestions();
1862
+ } catch (error) {
1863
+ setMessage("error", (error as Error).message);
1864
+ render();
1865
+ }
1866
+ }
1867
+
1868
+ async function saveLlmConfig(event: Event): Promise<void> {
1869
+ event.preventDefault();
1870
+ const form = new FormData(event.currentTarget as HTMLFormElement);
1871
+
1872
+ try {
1873
+ const extraBody = JSON.parse(String(form.get("extra_body") || "{}"));
1874
+ if (extraBody === null || Array.isArray(extraBody) || typeof extraBody !== "object") {
1875
+ throw new Error("Extra body JSON must be an object.");
1876
+ }
1877
+
1878
+ const result = await api<any>("/api/v1/admin/llm-config", {
1879
+ method: "PUT",
1880
+ body: JSON.stringify({
1881
+ provider: form.get("provider"),
1882
+ base_url: form.get("base_url"),
1883
+ api_key: form.get("api_key"),
1884
+ clear_api_key: form.get("clear_api_key") === "on",
1885
+ model: form.get("model"),
1886
+ timeout_seconds: Number(form.get("timeout_seconds") || 60),
1887
+ intent_classification_mode: form.get("intent_classification_mode"),
1888
+ sql_generation_mode: form.get("sql_generation_mode"),
1889
+ result_interpretation_mode: form.get("result_interpretation_mode"),
1890
+ output_classification_mode: form.get("output_classification_mode"),
1891
+ investigation_mode: form.get("investigation_mode"),
1892
+ investigation_ambiguity_mode: form.get("investigation_ambiguity_mode"),
1893
+ query_max_rows: Number(form.get("query_max_rows") || 100),
1894
+ query_timeout_seconds: Number(form.get("query_timeout_seconds") || 30),
1895
+ extra_body: extraBody,
1896
+ }),
1897
+ });
1898
+ state.llmConfig = result.item;
1899
+ setMessage("success", "LLM configuration saved.");
1900
+ render();
1901
+ } catch (error) {
1902
+ setMessage("error", (error as Error).message);
1903
+ render();
1904
+ }
1905
+ }
1906
+
1907
+ async function saveGovernancePolicy(event: Event): Promise<void> {
1908
+ event.preventDefault();
1909
+ const form = new FormData(event.currentTarget as HTMLFormElement);
1910
+
1911
+ try {
1912
+ const forbiddenColumns = JSON.parse(String(form.get("forbidden_columns") || "{}"));
1913
+ const piiColumnNames = JSON.parse(String(form.get("pii_column_names") || "{}"));
1914
+ if (forbiddenColumns === null || Array.isArray(forbiddenColumns) || typeof forbiddenColumns !== "object") {
1915
+ throw new Error("Forbidden columns JSON must be an object.");
1916
+ }
1917
+ if (piiColumnNames === null || typeof piiColumnNames !== "object") {
1918
+ throw new Error("PII column names JSON must be an object or list.");
1919
+ }
1920
+
1921
+ const result = await api<any>("/api/v1/admin/governance-policy", {
1922
+ method: "PUT",
1923
+ body: JSON.stringify({
1924
+ final_answer: {
1925
+ record_level_pii_allowed: form.get("record_level_pii_allowed") === "on",
1926
+ prefer_aggregates_for_sensitive_domains: form.get("prefer_aggregates_for_sensitive_domains") === "on",
1927
+ },
1928
+ sql: {
1929
+ read_only: form.get("sql_read_only") === "on",
1930
+ select_star_allowed: form.get("select_star_allowed") === "on",
1931
+ tenant_filter_required: form.get("tenant_filter_required") === "on",
1932
+ tenant_column: String(form.get("tenant_column") || "").trim() || null,
1933
+ },
1934
+ privacy: {
1935
+ record_level_forbidden: form.get("record_level_forbidden") === "on",
1936
+ forbidden_columns: forbiddenColumns,
1937
+ },
1938
+ pii_column_names: piiColumnNames,
1939
+ }),
1940
+ });
1941
+ state.governancePolicy = result.item;
1942
+ setMessage("success", "Governance policy saved.");
1943
+ render();
1944
+ } catch (error) {
1945
+ setMessage("error", (error as Error).message);
1946
+ render();
1947
+ }
1948
+ }
1949
+
1950
+ async function saveRetention(event: Event): Promise<void> {
1951
+ event.preventDefault();
1952
+ const form = new FormData(event.currentTarget as HTMLFormElement);
1953
+ try {
1954
+ await api("/api/v1/admin/audit/settings", { method: "PUT", body: JSON.stringify({ data_query_retention_days: Number(form.get("retention")) }) });
1955
+ setMessage("success", "Audit retention saved.");
1956
+ await loadDataAudit();
1957
+ } catch (error) {
1958
+ setMessage("error", (error as Error).message);
1959
+ render();
1960
+ }
1961
+ }
1962
+
1963
+ async function savePrompt(event: Event): Promise<void> {
1964
+ event.preventDefault();
1965
+ const form = new FormData(event.currentTarget as HTMLFormElement);
1966
+ const promptKey = String(form.get("prompt_key"));
1967
+ try {
1968
+ await api(`/api/v1/admin/prompts/${encodeURIComponent(promptKey)}`, {
1969
+ method: "PUT",
1970
+ body: JSON.stringify({
1971
+ name: form.get("name"),
1972
+ description: form.get("description"),
1973
+ system_prompt: form.get("system_prompt"),
1974
+ user_prompt_template: form.get("user_prompt_template"),
1975
+ active: form.get("active") === "on",
1976
+ }),
1977
+ });
1978
+ setMessage("success", "Prompt saved.");
1979
+ await loadPrompts();
1980
+ } catch (error) {
1981
+ setMessage("error", (error as Error).message);
1982
+ render();
1983
+ }
1984
+ }
1985
+
1986
+ async function saveDatasource(event: Event): Promise<void> {
1987
+ event.preventDefault();
1988
+ const selected = getSelectedDatasource();
1989
+ if (selected?.system_managed) return;
1990
+
1991
+ const form = new FormData(event.currentTarget as HTMLFormElement);
1992
+ const id = String(form.get("id") || "");
1993
+ const payload = {
1994
+ connector_key: form.get("connector_key"),
1995
+ name: form.get("name"),
1996
+ database_type: form.get("database_type"),
1997
+ database_url: form.get("database_url"),
1998
+ sql_dialect: form.get("sql_dialect"),
1999
+ active: form.get("active") === "on",
2000
+ };
2001
+ try {
2002
+ const result = await api<any>(id ? `/api/v1/admin/datasources/${id}` : "/api/v1/admin/datasources", {
2003
+ method: id ? "PUT" : "POST",
2004
+ body: JSON.stringify(id ? { ...payload, connector_key: undefined } : payload),
2005
+ });
2006
+ state.selectedDatasourceId = result.item.id;
2007
+ setMessage("success", "Datasource saved.");
2008
+ await loadDatasources();
2009
+ } catch (error) {
2010
+ setMessage("error", (error as Error).message);
2011
+ }
2012
+ }
2013
+
2014
+ async function testDatasource(): Promise<void> {
2015
+ const selected = getSelectedDatasource();
2016
+ const form = document.querySelector<HTMLFormElement>("#datasource-form");
2017
+ const formData = form ? new FormData(form) : null;
2018
+
2019
+ try {
2020
+ if (selected) {
2021
+ await api(`/api/v1/admin/datasources/${selected.id}/test`, { method: "POST" });
2022
+ } else if (formData) {
2023
+ await api("/api/v1/admin/datasources/test", {
2024
+ method: "POST",
2025
+ body: JSON.stringify({
2026
+ database_type: formData.get("database_type"),
2027
+ database_url: formData.get("database_url"),
2028
+ }),
2029
+ });
2030
+ } else {
2031
+ return;
2032
+ }
2033
+ setMessage("success", "Connection test succeeded.");
2034
+ } catch (error) {
2035
+ setMessage("error", extractErrorMessage(error));
2036
+ }
2037
+ }
2038
+
2039
+ async function introspectDatasource(): Promise<void> {
2040
+ const selected = getSelectedDatasource();
2041
+ if (!selected) return;
2042
+ try {
2043
+ state.datasourceSchemaLoading = true;
2044
+ state.datasourceSchemaError = "";
2045
+ render();
2046
+ state.datasourceSchema = await api(`/api/v1/admin/datasources/${selected.id}/introspect`, { method: "POST" });
2047
+ state.datasourceSchemaSelectedObjectName = "";
2048
+ state.datasourceSchemaDraftTables = null;
2049
+ state.datasourceSchemaLoading = false;
2050
+ setMessage("success", "Schema introspection completed.");
2051
+ render();
2052
+ } catch (error) {
2053
+ state.datasourceSchemaLoading = false;
2054
+ state.datasourceSchemaError = (error as Error).message;
2055
+ setMessage("error", (error as Error).message);
2056
+ render();
2057
+ }
2058
+ }
2059
+
2060
+ async function activateDatasource(): Promise<void> {
2061
+ const selected = getSelectedDatasource();
2062
+ if (!selected) return;
2063
+ try {
2064
+ await api(`/api/v1/admin/datasources/${selected.id}/activate`, { method: "POST" });
2065
+ setMessage("success", "Datasource activated.");
2066
+ await loadDatasources();
2067
+ } catch (error) {
2068
+ setMessage("error", (error as Error).message);
2069
+ render();
2070
+ }
2071
+ }
2072
+
2073
+ function syncDatasourceSchemaDraftFromForm(): void {
2074
+ const form = document.querySelector<HTMLFormElement>("#datasource-schema-form");
2075
+ const rawTables = state.datasourceSchema?.item?.raw_schema?.tables || [];
2076
+ const tableSettings = state.datasourceSchema?.item?.table_settings?.tables || {};
2077
+
2078
+ if (!form || !rawTables.length) return;
2079
+
2080
+ const draftTables = getDatasourceSchemaDraftTables(rawTables, tableSettings);
2081
+
2082
+ form.querySelectorAll<HTMLInputElement>("[data-schema-object-enabled]").forEach(input => {
2083
+ const name = input.dataset.schemaObjectEnabled;
2084
+ if (!name || !draftTables[name]) return;
2085
+
2086
+ draftTables[name].selected = input.checked;
2087
+ });
2088
+
2089
+ const selectedName = state.datasourceSchemaSelectedObjectName;
2090
+ const selectedSettings = selectedName ? draftTables[selectedName] : null;
2091
+
2092
+ if (!selectedSettings) return;
2093
+
2094
+ selectedSettings.description = form.querySelector<HTMLInputElement>("[data-schema-detail='description']")?.value || "";
2095
+ selectedSettings.primary_key_prompt = form.querySelector<HTMLInputElement>("[data-schema-detail='primary_key_prompt']")?.value || "";
2096
+ selectedSettings.foreign_key_prompt = form.querySelector<HTMLInputElement>("[data-schema-detail='foreign_key_prompt']")?.value || "";
2097
+ selectedSettings.join_logic = form.querySelector<HTMLTextAreaElement>("[data-schema-detail='join_logic']")?.value || "";
2098
+ }
2099
+
2100
+ async function saveDatasourceSchema(event: Event): Promise<void> {
2101
+ event.preventDefault();
2102
+ const selected = getSelectedDatasource();
2103
+ if (!selected || !state.datasourceSchema?.item) return;
2104
+ const rawTables = state.datasourceSchema.item.raw_schema.tables || [];
2105
+ const tableSettings = state.datasourceSchema.item.table_settings?.tables || {};
2106
+ syncDatasourceSchemaDraftFromForm();
2107
+ const draftTables = getDatasourceSchemaDraftTables(rawTables, tableSettings);
2108
+ const tables: Record<string, any> = {};
2109
+ for (const table of rawTables) {
2110
+ const draft = draftTables[table.name] || {};
2111
+ tables[table.name] = {
2112
+ selected: draft.selected !== false,
2113
+ description: draft.description || "",
2114
+ primary_key_prompt: draft.primary_key_prompt || "",
2115
+ foreign_key_prompt: draft.foreign_key_prompt || "",
2116
+ join_logic: draft.join_logic || "",
2117
+ };
2118
+ }
2119
+ try {
2120
+ state.datasourceSchema = await api(`/api/v1/admin/datasources/${selected.id}/schema/tables`, {
2121
+ method: "PUT",
2122
+ body: JSON.stringify({ tables }),
2123
+ });
2124
+ setMessage("success", "Schema settings saved.");
2125
+ state.datasourceSchemaSelectedObjectName = "";
2126
+ state.datasourceSchemaDraftTables = null;
2127
+ render();
2128
+ } catch (error) {
2129
+ setMessage("error", (error as Error).message);
2130
+ render();
2131
+ }
2132
+ }
2133
+
2134
+ async function saveSchemaCacheTtl(event: Event): Promise<void> {
2135
+ event.preventDefault();
2136
+ const form = new FormData(event.currentTarget as HTMLFormElement);
2137
+ try {
2138
+ await api("/api/v1/admin/schema-cache", { method: "PUT", body: JSON.stringify({ ttl_seconds: Number(form.get("ttl_seconds")) }) });
2139
+ setMessage("success", "Schema cache TTL saved.");
2140
+ await loadSchemaCache();
2141
+ } catch (error) {
2142
+ setMessage("error", (error as Error).message);
2143
+ render();
2144
+ }
2145
+ }
2146
+
2147
+ async function invalidateSchemaCache(): Promise<void> {
2148
+ try {
2149
+ await api("/api/v1/admin/schema-cache/invalidate", { method: "POST" });
2150
+ setMessage("success", "Schema cache invalidated.");
2151
+ await loadSchemaCache();
2152
+ } catch (error) {
2153
+ setMessage("error", (error as Error).message);
2154
+ render();
2155
+ }
2156
+ }
2157
+
2158
+ async function loadCurrentSection(): Promise<void> {
2159
+ if (!state.token || state.mustChangePassword) return;
2160
+ if (state.section === "overview") await loadOverview();
2161
+ if (state.section === "widgets") await loadOverviewWidgetConfigs();
2162
+ if (state.section === "data-audit") await loadDataAudit();
2163
+ if (state.section === "prompts") await loadPrompts();
2164
+ if (state.section === "schema-cache") await loadSchemaCache();
2165
+ if (state.section === "business-logic") await loadBusinessLogicSuggestions();
2166
+ if (state.section === "llm-config") await loadLlmConfig();
2167
+ if (state.section === "governance-policy") await loadGovernancePolicy();
2168
+ if (state.section === "datasources") await loadDatasources();
2169
+ if (state.section === "license") await loadLicense();
2170
+ if (state.section === "admin-audit") await loadAdminAudit();
2171
+ }
2172
+
2173
+ async function loadOverview(): Promise<void> {
2174
+ state.overviewLoading = true;
2175
+ if (state.section === "overview") {
2176
+ render();
2177
+ }
2178
+
2179
+ try {
2180
+ const [overview, widgetConfig] = await Promise.all([
2181
+ api<OverviewState>("/api/v1/admin/overview"),
2182
+ api<{ items: OverviewWidget[]; datasources: OverviewDatasource[] }>("/api/v1/admin/overview/widgets"),
2183
+ ]);
2184
+ state.overview = overview;
2185
+ state.overviewWidgetConfigs = widgetConfig.items || [];
2186
+ state.overviewWidgetDatasources = widgetConfig.datasources || [];
2187
+ } finally {
2188
+ state.overviewLoading = false;
2189
+ if (state.section === "overview") {
2190
+ render();
2191
+ }
2192
+ }
2193
+ }
2194
+
2195
+ async function loadOverviewWidgetConfigs(shouldRender = true): Promise<void> {
2196
+ const payload = await api<{ items: OverviewWidget[]; datasources: OverviewDatasource[] }>("/api/v1/admin/overview/widgets");
2197
+ state.overviewWidgetConfigs = payload.items || [];
2198
+ state.overviewWidgetDatasources = payload.datasources || [];
2199
+
2200
+ if (
2201
+ state.selectedOverviewWidgetKey !== "__new__" &&
2202
+ !state.overviewWidgetConfigs.some(widget => widget.widget_key === state.selectedOverviewWidgetKey)
2203
+ ) {
2204
+ state.selectedOverviewWidgetKey = state.overviewWidgetConfigs[0]?.widget_key || "";
2205
+ }
2206
+
2207
+ if (shouldRender) {
2208
+ render();
2209
+ }
2210
+ }
2211
+
2212
+ async function refreshOverview(): Promise<void> {
2213
+ if (state.overviewRefreshing || state.overviewLoading) return;
2214
+
2215
+ state.overviewRefreshing = true;
2216
+ setMessage("success", "");
2217
+ render();
2218
+
2219
+ try {
2220
+ state.overview = await api("/api/v1/admin/overview");
2221
+ } catch (error) {
2222
+ setMessage("error", (error as Error).message);
2223
+ } finally {
2224
+ state.overviewRefreshing = false;
2225
+ render();
2226
+ }
2227
+ }
2228
+
2229
+ async function loadDataAudit(): Promise<void> {
2230
+ state.auditSettings = await api("/api/v1/admin/audit/settings");
2231
+ const params = new URLSearchParams();
2232
+
2233
+ if (state.dataAuditType) params.set("audit_type", state.dataAuditType);
2234
+ if (state.dataAuditOutputClassification) {
2235
+ params.set("output_classification", state.dataAuditOutputClassification);
2236
+ }
2237
+ if (state.dataAuditSqlContains.trim()) {
2238
+ params.set("sql_contains", state.dataAuditSqlContains.trim());
2239
+ }
2240
+
2241
+ const query = params.toString() ? `?${params.toString()}` : "";
2242
+ const logs = await api<any>(`/api/v1/admin/audit/data-queries${query}`);
2243
+ state.dataAudit = logs.items || [];
2244
+ render();
2245
+ }
2246
+
2247
+ async function loadPrompts(): Promise<void> {
2248
+ const result = await api<any>("/api/v1/admin/prompts");
2249
+ state.prompts = result.items || [];
2250
+ state.selectedPromptKey = state.selectedPromptKey || state.prompts[0]?.prompt_key || "";
2251
+ render();
2252
+ }
2253
+
2254
+ async function loadDatasources(): Promise<void> {
2255
+ const result = await api<any>("/api/v1/admin/datasources");
2256
+ state.datasources = result.items || [];
2257
+ if (!state.selectedDatasourceId || state.selectedDatasourceId === "new") {
2258
+ state.selectedDatasourceId = state.datasources[0]?.id || null;
2259
+ }
2260
+ render();
2261
+ void loadDatasourceSchema();
2262
+ }
2263
+
2264
+ async function loadDatasourceSchema(): Promise<void> {
2265
+ const selected = getSelectedDatasource();
2266
+ if (!selected) {
2267
+ state.datasourceSchema = null;
2268
+ state.datasourceSchemaSelectedObjectName = "";
2269
+ state.datasourceSchemaDraftTables = null;
2270
+ state.datasourceSchemaLoading = false;
2271
+ state.datasourceSchemaError = "";
2272
+ render();
2273
+ return;
2274
+ }
2275
+ state.datasourceSchemaLoading = true;
2276
+ state.datasourceSchemaError = "";
2277
+ render();
2278
+ try {
2279
+ state.datasourceSchema = await api(`/api/v1/admin/datasources/${selected.id}/schema`);
2280
+ state.datasourceSchemaSelectedObjectName = "";
2281
+ state.datasourceSchemaDraftTables = null;
2282
+ } catch (error) {
2283
+ state.datasourceSchema = null;
2284
+ state.datasourceSchemaError = (error as Error).message;
2285
+ } finally {
2286
+ state.datasourceSchemaLoading = false;
2287
+ render();
2288
+ }
2289
+ }
2290
+
2291
+ async function loadSchemaCache(): Promise<void> {
2292
+ state.schemaCache = await api("/api/v1/admin/schema-cache");
2293
+ render();
2294
+ }
2295
+
2296
+ async function loadBusinessLogicSuggestions(): Promise<void> {
2297
+ const result = await api<any>("/api/v1/admin/business-logic-suggestions");
2298
+ state.businessLogic = result.items || [];
2299
+ state.businessLogicDatasource = result.datasource || null;
2300
+ render();
2301
+ }
2302
+
2303
+ async function loadLlmConfig(): Promise<void> {
2304
+ const result = await api<any>("/api/v1/admin/llm-config");
2305
+ state.llmConfig = result.item || null;
2306
+ render();
2307
+ }
2308
+
2309
+ async function loadGovernancePolicy(): Promise<void> {
2310
+ const result = await api<any>("/api/v1/admin/governance-policy");
2311
+ state.governancePolicy = result.item || null;
2312
+ render();
2313
+ }
2314
+
2315
+ async function loadLicense(): Promise<void> {
2316
+ state.license = await api("/api/v1/admin/license");
2317
+ render();
2318
+ }
2319
+
2320
+ async function loadAdminAudit(): Promise<void> {
2321
+ const result = await api<any>("/api/v1/admin/audit/admin-events");
2322
+ state.adminAudit = result.items || [];
2323
+ render();
2324
+ }
2325
+
2326
+ async function bootstrap(): Promise<void> {
2327
+ render();
2328
+ if (!state.token) return;
2329
+ try {
2330
+ const me = await api<any>("/api/v1/admin/me");
2331
+ state.username = me.username;
2332
+ state.mustChangePassword = me.must_change_password;
2333
+ localStorage.setItem("gaard_admin_must_change", String(state.mustChangePassword));
2334
+ render();
2335
+ await loadCurrentSection();
2336
+ } catch (error) {
2337
+ state.overviewLoading = false;
2338
+ setMessage("error", (error as Error).message);
2339
+ render();
2340
+ }
2341
+ }
2342
+
2343
+ void bootstrap();