sqlite-hub 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +9 -0
  2. package/bin/sqlite-hub.js +555 -122
  3. package/docs/API.md +30 -0
  4. package/docs/CLI.md +22 -0
  5. package/docs/CLI_API_PARITY.md +61 -0
  6. package/docs/changelog.md +9 -1
  7. package/docs/guidelines/AGENTS.md +32 -0
  8. package/docs/{DESIGN_GUIDELINES.md → guidelines/DESIGN.md} +10 -0
  9. package/docs/todo.md +1 -13
  10. package/frontend/js/api.js +45 -0
  11. package/frontend/js/app.js +192 -2
  12. package/frontend/js/components/connectionCard.js +3 -1
  13. package/frontend/js/components/modal.js +356 -15
  14. package/frontend/js/components/sidebar.js +41 -7
  15. package/frontend/js/components/topNav.js +2 -14
  16. package/frontend/js/router.js +10 -0
  17. package/frontend/js/store.js +448 -0
  18. package/frontend/js/utils/inputClear.js +36 -0
  19. package/frontend/js/utils/syntheticData.js +240 -0
  20. package/frontend/js/views/backups.js +52 -0
  21. package/frontend/js/views/data.js +16 -0
  22. package/frontend/js/views/logs.js +339 -0
  23. package/frontend/js/views/settings.js +77 -27
  24. package/frontend/js/views/structure.js +6 -0
  25. package/frontend/js/views/tableAdvisor.js +385 -0
  26. package/frontend/js/views/tableDesigner.js +6 -0
  27. package/frontend/styles/components.css +149 -0
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/views.css +8 -0
  30. package/package.json +1 -1
  31. package/server/routes/data.js +45 -0
  32. package/server/routes/externalApi.js +285 -2
  33. package/server/routes/logs.js +127 -0
  34. package/server/server.js +3 -0
  35. package/server/services/databaseCommandService.js +45 -0
  36. package/server/services/sqlite/dataBrowserService.js +36 -0
  37. package/server/services/sqlite/introspection.js +226 -22
  38. package/server/services/sqlite/syntheticDataGenerator.js +728 -0
  39. package/server/services/sqlite/tableAdvisor.js +790 -0
  40. package/server/services/storage/appStateStore.js +773 -169
@@ -1,6 +1,6 @@
1
1
  import { renderPageHeader } from '../components/pageHeader.js';
2
2
  import { renderTextInput } from '../components/formControls.js';
3
- import { escapeHtml } from '../utils/format.js';
3
+ import { escapeHtml, formatCompactDateTime, formatNumber } from '../utils/format.js';
4
4
 
5
5
  function renderSettingsNavigation(activeSection) {
6
6
  const items = [
@@ -90,6 +90,81 @@ function renderVersionCheckStatus(settings) {
90
90
  `;
91
91
  }
92
92
 
93
+ function renderApiTokenRow(token, tokenSaving) {
94
+ const callCount = Number(token.callCount ?? 0);
95
+ const createdAt = token.createdAt ? formatCompactDateTime(token.createdAt) : 'Unknown';
96
+ const lastCallAt = token.lastCallAt ? formatCompactDateTime(token.lastCallAt) : 'Never';
97
+
98
+ return `
99
+ <div class="grid grid-cols-1 gap-4 border-t border-outline-variant/10 bg-surface-container-high px-4 py-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(8rem,0.75fr)_7rem_minmax(8rem,0.85fr)_8rem] xl:items-center">
100
+ <div class="min-w-0">
101
+ <div class="truncate text-sm font-semibold text-on-surface" title="${escapeHtml(token.name)}">
102
+ ${escapeHtml(token.name)}
103
+ </div>
104
+ <div
105
+ class="mt-2 inline-flex max-w-full border border-outline-variant/10 bg-surface-container-lowest px-3 py-1 font-mono text-[11px] text-primary-container"
106
+ title="${escapeHtml(token.tokenPrefix ?? '')}"
107
+ >
108
+ <span class="truncate">${escapeHtml(token.tokenPrefix)}...</span>
109
+ </div>
110
+ </div>
111
+ <div>
112
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Created</div>
113
+ <div class="mt-1 font-mono text-[11px] text-on-surface-variant/75 xl:mt-0" title="${escapeHtml(token.createdAt ?? 'unknown')}">
114
+ ${escapeHtml(createdAt)}
115
+ </div>
116
+ </div>
117
+ <div>
118
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Calls</div>
119
+ <div class="mt-1 font-mono text-lg font-bold text-on-surface xl:mt-0">
120
+ ${escapeHtml(formatNumber(callCount))}
121
+ </div>
122
+ </div>
123
+ <div>
124
+ <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/45 xl:hidden">Last Call</div>
125
+ <div class="mt-1 font-mono text-[11px] text-on-surface-variant/75 xl:mt-0" title="${escapeHtml(token.lastCallAt ?? 'never')}">
126
+ ${escapeHtml(lastCallAt)}
127
+ </div>
128
+ </div>
129
+ <div class="flex xl:justify-end">
130
+ <button
131
+ class="delete-button"
132
+ data-action="open-delete-api-token-modal"
133
+ data-token-id="${escapeHtml(token.id)}"
134
+ type="button"
135
+ ${tokenSaving ? 'disabled' : ''}
136
+ >
137
+ <span class="material-symbols-outlined text-sm">delete</span>
138
+ Delete
139
+ </button>
140
+ </div>
141
+ </div>
142
+ `;
143
+ }
144
+
145
+ function renderApiTokenRows(apiTokens, tokenSaving) {
146
+ if (!apiTokens.length) {
147
+ return `
148
+ <div class="border border-outline-variant/10 bg-surface-container-high px-4 py-5 text-sm text-on-surface-variant">
149
+ No API tokens exist for this database.
150
+ </div>
151
+ `;
152
+ }
153
+
154
+ return `
155
+ <div class="overflow-hidden border border-outline-variant/10">
156
+ <div class="hidden bg-surface-container-highest px-4 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55 xl:grid xl:grid-cols-[minmax(0,1.35fr)_minmax(8rem,0.75fr)_7rem_minmax(8rem,0.85fr)_8rem]">
157
+ <div>Token</div>
158
+ <div>Created</div>
159
+ <div>Calls</div>
160
+ <div>Last Call</div>
161
+ <div class="text-right">Actions</div>
162
+ </div>
163
+ ${apiTokens.map(token => renderApiTokenRow(token, tokenSaving)).join('')}
164
+ </div>
165
+ `;
166
+ }
167
+
93
168
  function renderSettingsContent(state) {
94
169
  if (state.settings.loading && !state.settings.appVersion) {
95
170
  return `
@@ -121,32 +196,7 @@ function renderSettingsContent(state) {
121
196
  const apiTokens = state.settings.apiTokens ?? [];
122
197
  const createdApiToken = state.settings.createdApiToken;
123
198
  const tokenSaving = Boolean(state.settings.tokenSaving);
124
- const tokenItems = apiTokens.length
125
- ? apiTokens
126
- .map(
127
- token => `
128
- <div class="flex flex-col gap-3 border border-outline-variant/10 bg-surface-container-high px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
129
- <div class="min-w-0">
130
- <div class="truncate text-sm font-semibold text-on-surface">${escapeHtml(token.name)}</div>
131
- <div class="mt-1 font-mono text-[10px] text-on-surface-variant/60">
132
- ${escapeHtml(token.tokenPrefix)}... · Created ${escapeHtml(token.createdAt ?? 'unknown')}
133
- </div>
134
- </div>
135
- <button
136
- class="delete-button"
137
- data-action="open-delete-api-token-modal"
138
- data-token-id="${escapeHtml(token.id)}"
139
- type="button"
140
- ${tokenSaving ? 'disabled' : ''}
141
- >
142
- <span class="material-symbols-outlined text-sm">delete</span>
143
- Delete
144
- </button>
145
- </div>
146
- `,
147
- )
148
- .join('')
149
- : '<div class="text-sm text-on-surface-variant">No API tokens exist for this database.</div>';
199
+ const tokenItems = renderApiTokenRows(apiTokens, tokenSaving);
150
200
  const tokenSection = tokenDatabase
151
201
  ? `
152
202
  <div class="space-y-5">
@@ -248,6 +248,12 @@ function renderGraphSurface(structure, selectedName, detail, detailLoading, tabl
248
248
  label: 'Table Designer',
249
249
  target: tableName => `/table-designer/${encodeURIComponent(tableName)}`,
250
250
  },
251
+ {
252
+ icon: 'troubleshoot',
253
+ key: 'table-advisor',
254
+ label: 'Table Advisor',
255
+ target: tableName => `/table-advisor/${encodeURIComponent(tableName)}`,
256
+ },
251
257
  {
252
258
  key: 'sql-editor',
253
259
  },
@@ -0,0 +1,385 @@
1
+ import {
2
+ escapeHtml,
3
+ formatDateTime,
4
+ formatNumber,
5
+ highlightSql,
6
+ truncateMiddle,
7
+ } from '../utils/format.js';
8
+ import { renderWorkspaceOpenDropdown } from '../components/workspaceOpenDropdown.js';
9
+
10
+ const CATEGORY_LABELS = {
11
+ schema: 'Schema',
12
+ constraints: 'Constraints',
13
+ performance: 'Performance',
14
+ 'data-quality': 'Data Quality',
15
+ documentation: 'Documentation',
16
+ };
17
+
18
+ const CATEGORY_ORDER = ['schema', 'constraints', 'performance', 'data-quality', 'documentation'];
19
+
20
+ function getScoreClass(score) {
21
+ if (score >= 80) {
22
+ return 'text-primary-container';
23
+ }
24
+
25
+ if (score >= 60) {
26
+ return 'text-on-surface';
27
+ }
28
+
29
+ return 'text-error';
30
+ }
31
+
32
+ function renderError(error) {
33
+ if (!error) {
34
+ return '';
35
+ }
36
+
37
+ return `
38
+ <div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
39
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
40
+ ${escapeHtml(error.code ?? 'REQUEST_FAILED')}
41
+ </div>
42
+ <div class="mt-2">${escapeHtml(error.message ?? 'Request failed.')}</div>
43
+ </div>
44
+ `;
45
+ }
46
+
47
+ function renderSeverityBadge(severity = 'info') {
48
+ const normalized = String(severity ?? 'info').toLowerCase();
49
+ const className =
50
+ normalized === 'critical'
51
+ ? 'border-error/35 bg-error-container/25 text-error'
52
+ : normalized === 'warning'
53
+ ? 'border-primary-container/35 bg-primary-container/15 text-primary-container'
54
+ : 'border-outline-variant/20 bg-surface-container-lowest text-on-surface-variant';
55
+
56
+ return `<span class="inline-flex h-6 items-center border px-2 font-mono text-[10px] font-bold uppercase tracking-[0.16em] ${className}">${escapeHtml(
57
+ normalized,
58
+ )}</span>`;
59
+ }
60
+
61
+ function renderRiskBadge(risk = 'low') {
62
+ const normalized = String(risk ?? 'low').toLowerCase();
63
+ const className =
64
+ normalized === 'high'
65
+ ? 'border-error/35 text-error'
66
+ : normalized === 'medium'
67
+ ? 'border-primary-container/35 text-primary-container'
68
+ : 'border-outline-variant/20 text-on-surface-variant/70';
69
+
70
+ return `<span class="inline-flex h-6 items-center border bg-surface-container-lowest px-2 font-mono text-[10px] font-bold uppercase tracking-[0.16em] ${className}">Risk ${escapeHtml(
71
+ normalized,
72
+ )}</span>`;
73
+ }
74
+
75
+ function renderTableList(state) {
76
+ const { tables = [], selectedTableName } = state.tableAdvisor;
77
+
78
+ if (state.tableAdvisor.loading && !tables.length) {
79
+ return `
80
+ <div class="px-4 py-6 text-sm text-on-surface-variant/45">
81
+ Loading tables...
82
+ </div>
83
+ `;
84
+ }
85
+
86
+ if (!tables.length) {
87
+ return `
88
+ <div class="px-4 py-6 text-sm text-on-surface-variant/45">
89
+ No tables found.
90
+ </div>
91
+ `;
92
+ }
93
+
94
+ return `
95
+ <div class="subnavi-list custom-scrollbar">
96
+ ${tables
97
+ .map(table => {
98
+ const isActive = table.name === selectedTableName;
99
+
100
+ return `
101
+ <a
102
+ class="table-designer-sidebar__item subnavi-item block border px-4 py-3 text-left transition-colors ${
103
+ isActive
104
+ ? 'is-active border-primary-container/30 bg-surface-container-high'
105
+ : 'border-outline-variant/10 bg-surface-container-lowest hover:bg-surface-container-high'
106
+ }"
107
+ href="#/table-advisor/${encodeURIComponent(table.name)}"
108
+ title="${escapeHtml(table.name)}"
109
+ >
110
+ <div class="table-designer-sidebar__item-name ${isActive ? 'is-active' : ''}">
111
+ ${escapeHtml(table.name)}
112
+ </div>
113
+ <div class="table-designer-sidebar__item-meta">
114
+ ${escapeHtml(formatNumber(table.columnCount ?? 0))} columns
115
+ </div>
116
+ </a>
117
+ `;
118
+ })
119
+ .join('')}
120
+ </div>
121
+ `;
122
+ }
123
+
124
+ function renderSummary(result = {}) {
125
+ const score = Number(result.score ?? 0);
126
+
127
+ return `
128
+ <div class="grid grid-cols-1 gap-3 md:grid-cols-4">
129
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
130
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Table</div>
131
+ <div class="mt-2 min-w-0 truncate text-lg font-black text-on-surface" title="${escapeHtml(
132
+ result.tableName ?? '',
133
+ )}">${escapeHtml(result.tableName ?? 'Unknown')}</div>
134
+ </div>
135
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
136
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Score</div>
137
+ <div class="mt-2 text-3xl font-black ${getScoreClass(score)}">${escapeHtml(formatNumber(score))}</div>
138
+ </div>
139
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
140
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Issues</div>
141
+ <div class="mt-2 text-3xl font-black text-on-surface">${escapeHtml(formatNumber(result.issueCount ?? 0))}</div>
142
+ </div>
143
+ <div class="border border-outline-variant/10 bg-surface-container-low px-4 py-3">
144
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/55">Rows</div>
145
+ <div class="mt-2 text-3xl font-black text-on-surface">${escapeHtml(formatNumber(result.rowCount ?? 0))}</div>
146
+ <div class="mt-1 truncate font-mono text-[10px] uppercase tracking-[0.14em] text-on-surface-variant/45">
147
+ ${escapeHtml(formatDateTime(result.analyzedAt))}
148
+ </div>
149
+ </div>
150
+ </div>
151
+ `;
152
+ }
153
+
154
+ function renderIssue(issue = {}) {
155
+ const sql = String(issue.sql ?? '').trim();
156
+
157
+ return `
158
+ <article class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
159
+ <div class="flex flex-wrap items-start justify-between gap-3">
160
+ <div class="min-w-0">
161
+ <div class="flex flex-wrap items-center gap-2">
162
+ ${renderSeverityBadge(issue.severity)}
163
+ ${renderRiskBadge(issue.risk)}
164
+ </div>
165
+ <h4 class="mt-3 text-lg font-black text-on-surface">${escapeHtml(issue.title ?? 'Advisor issue')}</h4>
166
+ </div>
167
+ <div class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45" title="${escapeHtml(
168
+ issue.id ?? '',
169
+ )}">
170
+ ${escapeHtml(truncateMiddle(issue.id ?? '', 42))}
171
+ </div>
172
+ </div>
173
+ <div class="mt-3 grid grid-cols-1 gap-3 text-sm leading-6 text-on-surface-variant/75 lg:grid-cols-3">
174
+ <div>
175
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Explanation</div>
176
+ <p class="mt-1">${escapeHtml(issue.explanation ?? '')}</p>
177
+ </div>
178
+ <div>
179
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Evidence</div>
180
+ <p class="mt-1">${escapeHtml(issue.evidence ?? '')}</p>
181
+ </div>
182
+ <div>
183
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Recommendation</div>
184
+ <p class="mt-1">${escapeHtml(issue.recommendation ?? '')}</p>
185
+ </div>
186
+ </div>
187
+ ${
188
+ issue.risk === 'high'
189
+ ? `<div class="mt-3 border border-error/20 bg-error-container/15 px-3 py-2 text-xs leading-5 text-error">
190
+ This change may require rebuilding the table. Create a backup before applying it.
191
+ </div>`
192
+ : ''
193
+ }
194
+ ${
195
+ sql
196
+ ? `<div class="mt-4 border border-outline-variant/10 bg-surface-container-low">
197
+ <div class="flex items-center justify-between gap-3 border-b border-outline-variant/10 px-3 py-2">
198
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container">SQL</div>
199
+ <button
200
+ class="standard-button min-h-8 px-3 py-1 text-[10px]"
201
+ data-action="copy-table-advisor-sql"
202
+ data-issue-id="${escapeHtml(issue.id ?? '')}"
203
+ type="button"
204
+ >
205
+ <span class="material-symbols-outlined text-sm">content_copy</span>
206
+ Copy SQL
207
+ </button>
208
+ </div>
209
+ <pre class="custom-scrollbar max-h-48 overflow-auto whitespace-pre-wrap px-3 py-3 text-xs leading-6 text-on-surface"><code>${highlightSql(
210
+ sql,
211
+ )}</code></pre>
212
+ </div>`
213
+ : ''
214
+ }
215
+ </article>
216
+ `;
217
+ }
218
+
219
+ function renderIssues(result = {}) {
220
+ const issues = result.issues ?? [];
221
+
222
+ if (!issues.length) {
223
+ return `
224
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-6 text-sm leading-6 text-on-surface-variant/70">
225
+ No table advisor issues found for this table.
226
+ </div>
227
+ `;
228
+ }
229
+
230
+ return CATEGORY_ORDER.map(category => {
231
+ const categoryIssues = issues.filter(issue => issue.category === category);
232
+
233
+ if (!categoryIssues.length) {
234
+ return '';
235
+ }
236
+
237
+ return `
238
+ <section class="space-y-3">
239
+ <div class="flex items-center justify-between gap-3 border-b border-outline-variant/10 pb-2">
240
+ <h3 class="font-mono text-[11px] uppercase tracking-[0.22em] text-primary-container">
241
+ ${escapeHtml(CATEGORY_LABELS[category] ?? category)}
242
+ </h3>
243
+ <div class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/45">
244
+ ${escapeHtml(formatNumber(categoryIssues.length))}
245
+ </div>
246
+ </div>
247
+ ${categoryIssues.map(issue => renderIssue(issue)).join('')}
248
+ </section>
249
+ `;
250
+ }).join('');
251
+ }
252
+
253
+ function renderAdvisorBody(state) {
254
+ const advisor = state.tableAdvisor;
255
+
256
+ if (advisor.error) {
257
+ return renderError(advisor.error);
258
+ }
259
+
260
+ if (advisor.loading && !advisor.result) {
261
+ return `
262
+ <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
263
+ <div class="text-center text-on-surface-variant/40">
264
+ <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
265
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">LOADING_TABLES</p>
266
+ </div>
267
+ </div>
268
+ `;
269
+ }
270
+
271
+ if (!advisor.selectedTableName) {
272
+ return `
273
+ <div class="border border-dashed border-outline-variant/20 bg-surface-container-low px-8 py-10 text-center">
274
+ <span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">troubleshoot</span>
275
+ <p class="font-body text-xl font-black uppercase tracking-tight text-primary-container">
276
+ No table selected
277
+ </p>
278
+ </div>
279
+ `;
280
+ }
281
+
282
+ if (advisor.analysisLoading) {
283
+ return `
284
+ <div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
285
+ <div class="text-center text-on-surface-variant/40">
286
+ <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
287
+ <p class="font-mono text-[10px] uppercase tracking-[0.22em]">ANALYZING_TABLE</p>
288
+ </div>
289
+ </div>
290
+ `;
291
+ }
292
+
293
+ if (advisor.analysisError) {
294
+ return renderError(advisor.analysisError);
295
+ }
296
+
297
+ if (!advisor.result) {
298
+ return '';
299
+ }
300
+
301
+ return `
302
+ <div class="space-y-6">
303
+ ${renderSummary(advisor.result)}
304
+ ${renderIssues(advisor.result)}
305
+ </div>
306
+ `;
307
+ }
308
+
309
+ function renderToolbar(state) {
310
+ const tableName = state.tableAdvisor.selectedTableName;
311
+
312
+ return `
313
+ <header class="workspace-header">
314
+ <div class="min-w-0">
315
+ <div class="font-mono text-[10px] uppercase tracking-[0.26em] text-primary-container/70">
316
+ Local analysis
317
+ </div>
318
+ <h1 class="mt-1 truncate font-body text-3xl font-black uppercase tracking-tight text-primary-container">
319
+ Table Advisor
320
+ </h1>
321
+ </div>
322
+ <div class="ml-auto flex flex-wrap items-center justify-end gap-3">
323
+ ${renderWorkspaceOpenDropdown({
324
+ tableName: tableName ?? '',
325
+ destinations: [
326
+ {
327
+ icon: 'table_rows',
328
+ key: 'data',
329
+ label: 'Data',
330
+ target: name => `/data/${encodeURIComponent(name)}`,
331
+ },
332
+ {
333
+ icon: 'account_tree',
334
+ key: 'structure',
335
+ label: 'Structure',
336
+ target: name => `/structure/${encodeURIComponent(name)}`,
337
+ },
338
+ {
339
+ icon: 'table_chart',
340
+ key: 'table-designer',
341
+ label: 'Table Designer',
342
+ target: name => `/table-designer/${encodeURIComponent(name)}`,
343
+ },
344
+ {
345
+ key: 'sql-editor',
346
+ },
347
+ ],
348
+ })}
349
+ <button class="standard-button" data-action="refresh-view" type="button">
350
+ <span class="material-symbols-outlined text-sm">refresh</span>
351
+ Refresh
352
+ </button>
353
+ </div>
354
+ </header>
355
+ `;
356
+ }
357
+
358
+ export function renderTableAdvisorView(state) {
359
+ return {
360
+ main: `
361
+ <section class="view-surface flex h-full min-h-full min-h-0 flex-col overflow-hidden bg-surface-container">
362
+ <div class="data-view-grid data-view-grid--with-subnavi">
363
+ <aside class="subnavi-panel border-r border-outline-variant/10 bg-surface-low">
364
+ <div class="subnavi-header">
365
+ <div>
366
+ <div class="subnavi-header-title">Tables</div>
367
+ <div class="subnavi-header-details">
368
+ total ${escapeHtml(formatNumber(state.tableAdvisor.tables.length))}
369
+ </div>
370
+ </div>
371
+ </div>
372
+ ${renderTableList(state)}
373
+ </aside>
374
+ <section class="flex min-h-0 flex-col overflow-hidden">
375
+ ${renderToolbar(state)}
376
+ <div class="custom-scrollbar min-h-0 flex-1 overflow-auto p-6">
377
+ ${renderAdvisorBody(state)}
378
+ </div>
379
+ </section>
380
+ </div>
381
+ </section>
382
+ `,
383
+ panel: '',
384
+ };
385
+ }
@@ -51,6 +51,12 @@ function renderWorkspaceToolbar(state) {
51
51
  label: "Structure",
52
52
  target: name => `/structure/${encodeURIComponent(name)}`,
53
53
  },
54
+ {
55
+ icon: "troubleshoot",
56
+ key: "table-advisor",
57
+ label: "Table Advisor",
58
+ target: name => `/table-advisor/${encodeURIComponent(name)}`,
59
+ },
54
60
  {
55
61
  key: "sql-editor",
56
62
  },