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
@@ -0,0 +1,240 @@
1
+ export const SYNTHETIC_GENERATOR_TYPES = [
2
+ { value: 'skip', label: 'Skip' },
3
+ { value: 'static', label: 'Static Value' },
4
+ { value: 'randomText', label: 'Random Text' },
5
+ { value: 'name', label: 'Name' },
6
+ { value: 'firstName', label: 'First Name' },
7
+ { value: 'lastName', label: 'Last Name' },
8
+ { value: 'email', label: 'Email' },
9
+ { value: 'username', label: 'Username' },
10
+ { value: 'title', label: 'Title' },
11
+ { value: 'slug', label: 'Slug' },
12
+ { value: 'url', label: 'URL' },
13
+ { value: 'randomInteger', label: 'Random Integer' },
14
+ { value: 'randomDecimal', label: 'Random Decimal' },
15
+ { value: 'boolean', label: 'Boolean' },
16
+ { value: 'timestamp', label: 'Timestamp' },
17
+ { value: 'uuid', label: 'UUID' },
18
+ { value: 'oneOf', label: 'One Of' },
19
+ { value: 'existingForeignKey', label: 'Existing FK' },
20
+ ];
21
+
22
+ const GENERATOR_VALUES = new Set(SYNTHETIC_GENERATOR_TYPES.map(type => type.value));
23
+
24
+ function normalizeColumnName(value) {
25
+ return String(value ?? '')
26
+ .trim()
27
+ .toLowerCase();
28
+ }
29
+
30
+ function hasBooleanAllowedValues(column = {}) {
31
+ const values = (column.allowedValues ?? []).map(value => String(value));
32
+ const uniqueValues = new Set(values);
33
+
34
+ return uniqueValues.size === 2 && uniqueValues.has('0') && uniqueValues.has('1');
35
+ }
36
+
37
+ function hasBooleanIntegerRange(column = {}) {
38
+ return Number(column.integerRange?.min) === 0 && Number(column.integerRange?.max) === 1;
39
+ }
40
+
41
+ function isBooleanLikeColumn(column = {}, normalizedName = '') {
42
+ return (
43
+ /(^is_|^has_|enabled$|active$|archived$|published$|deleted$|visible$|boolean|bool)/.test(normalizedName) ||
44
+ hasBooleanAllowedValues(column) ||
45
+ hasBooleanIntegerRange(column)
46
+ );
47
+ }
48
+
49
+ function getForeignKeyInfo(foreignKeys = [], columnName) {
50
+ const singleColumnForeignKey = foreignKeys.find(
51
+ foreignKey =>
52
+ (foreignKey.mappings?.length ?? 0) === 1 && foreignKey.mappings?.[0]?.from === columnName,
53
+ );
54
+
55
+ if (singleColumnForeignKey) {
56
+ return {
57
+ kind: 'single',
58
+ foreignKey: singleColumnForeignKey,
59
+ mapping: singleColumnForeignKey.mappings[0],
60
+ };
61
+ }
62
+
63
+ const compositeForeignKey = foreignKeys.find(foreignKey =>
64
+ (foreignKey.mappings ?? []).some(mapping => mapping.from === columnName),
65
+ );
66
+
67
+ if (compositeForeignKey) {
68
+ return {
69
+ kind: 'composite',
70
+ foreignKey: compositeForeignKey,
71
+ mapping: compositeForeignKey.mappings.find(mapping => mapping.from === columnName) ?? null,
72
+ };
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ export function isIntegerPrimaryKeyColumn(column = {}) {
79
+ return (
80
+ Number(column.primaryKeyPosition ?? 0) > 0 &&
81
+ (column.affinity === 'INTEGER' || /\bINT\b/i.test(column.declaredType ?? ''))
82
+ );
83
+ }
84
+
85
+ export function normalizeSyntheticGeneratorType(value, fallback = 'skip') {
86
+ const text = String(value ?? '');
87
+
88
+ return GENERATOR_VALUES.has(text) ? text : fallback;
89
+ }
90
+
91
+ export function getSyntheticGeneratorLabel(value) {
92
+ return SYNTHETIC_GENERATOR_TYPES.find(type => type.value === value)?.label ?? 'Skip';
93
+ }
94
+
95
+ function normalizeIntegerRange(column = {}) {
96
+ const min = Number(column.integerRange?.min);
97
+ const max = Number(column.integerRange?.max);
98
+
99
+ return {
100
+ min: Number.isSafeInteger(min) ? min : null,
101
+ max: Number.isSafeInteger(max) ? max : null,
102
+ };
103
+ }
104
+
105
+ function getDefaultIntegerOptions(column = {}) {
106
+ const range = normalizeIntegerRange(column);
107
+ const min = range.min ?? (range.max !== null && range.max < 1 ? range.max - 999 : 1);
108
+ const max = range.max ?? (range.min !== null && range.min > 1000 ? range.min + 999 : 1000);
109
+
110
+ return { min, max };
111
+ }
112
+
113
+ export function getDefaultSyntheticOptions(generator, column = {}) {
114
+ switch (generator) {
115
+ case 'static':
116
+ return { value: '' };
117
+ case 'randomInteger':
118
+ return getDefaultIntegerOptions(column);
119
+ case 'randomDecimal':
120
+ return { min: 0, max: 1000, decimals: 2 };
121
+ case 'boolean':
122
+ return { trueProbability: 50 };
123
+ case 'timestamp':
124
+ return { range: 'last30', from: '', to: '' };
125
+ case 'oneOf':
126
+ return { values: (column.allowedValues ?? []).join(', ') };
127
+ default:
128
+ return {};
129
+ }
130
+ }
131
+
132
+ export function suggestSyntheticGeneratorForColumn(column = {}, foreignKeys = []) {
133
+ const name = normalizeColumnName(column.name);
134
+ const declaredType = String(column.declaredType ?? '').toUpperCase();
135
+ const affinity = String(column.affinity ?? '').toUpperCase();
136
+
137
+ if (!column.visible || column.generated || affinity === 'BLOB') {
138
+ return 'skip';
139
+ }
140
+
141
+ if (isIntegerPrimaryKeyColumn(column)) {
142
+ return 'skip';
143
+ }
144
+
145
+ const foreignKeyInfo = getForeignKeyInfo(foreignKeys, column.name);
146
+
147
+ if (foreignKeyInfo?.kind === 'single') {
148
+ return 'existingForeignKey';
149
+ }
150
+
151
+ if (foreignKeyInfo?.kind === 'composite') {
152
+ return 'skip';
153
+ }
154
+
155
+ if (/(^|_)email$/.test(name) || name.includes('email_address')) {
156
+ return 'email';
157
+ }
158
+
159
+ if (/^(first_name|firstname|given_name)$/.test(name)) {
160
+ return 'firstName';
161
+ }
162
+
163
+ if (/^(last_name|lastname|family_name|surname)$/.test(name)) {
164
+ return 'lastName';
165
+ }
166
+
167
+ if (/^(name|full_name|display_name|contact_name)$/.test(name)) {
168
+ return 'name';
169
+ }
170
+
171
+ if (/^(username|user_name|login)$/.test(name)) {
172
+ return 'username';
173
+ }
174
+
175
+ if (/(^|_)(title|headline|subject)$/.test(name)) {
176
+ return 'title';
177
+ }
178
+
179
+ if (/(^|_)slug$/.test(name)) {
180
+ return 'slug';
181
+ }
182
+
183
+ if (/(^|_)(url|website|site|homepage)$/.test(name)) {
184
+ return 'url';
185
+ }
186
+
187
+ if (/(^|_)(uuid|guid)$/.test(name)) {
188
+ return 'uuid';
189
+ }
190
+
191
+ if (isBooleanLikeColumn(column, name)) {
192
+ return 'boolean';
193
+ }
194
+
195
+ if ((column.allowedValues ?? []).length) {
196
+ return 'oneOf';
197
+ }
198
+
199
+ if (/(date|time|created_at|updated_at|timestamp)/.test(name) || /DATE|TIME/.test(declaredType)) {
200
+ return 'timestamp';
201
+ }
202
+
203
+ if (affinity === 'INTEGER') {
204
+ return 'randomInteger';
205
+ }
206
+
207
+ if (affinity === 'REAL' || affinity === 'NUMERIC' || /DECIMAL|NUMERIC/.test(declaredType)) {
208
+ return 'randomDecimal';
209
+ }
210
+
211
+ if (affinity === 'TEXT') {
212
+ return 'randomText';
213
+ }
214
+
215
+ return 'skip';
216
+ }
217
+
218
+ export function buildSyntheticDataMappings(columns = [], foreignKeys = []) {
219
+ return columns
220
+ .filter(column => column.visible && !column.generated)
221
+ .map(column => {
222
+ const foreignKeyInfo = getForeignKeyInfo(foreignKeys, column.name);
223
+ const generator = suggestSyntheticGeneratorForColumn(column, foreignKeys);
224
+ const note =
225
+ foreignKeyInfo?.kind === 'single'
226
+ ? `Existing ${foreignKeyInfo.foreignKey.referencedTable}.${foreignKeyInfo.mapping.to}`
227
+ : foreignKeyInfo?.kind === 'composite'
228
+ ? 'Composite FK unsupported'
229
+ : isIntegerPrimaryKeyColumn(column)
230
+ ? 'Auto increment / skipped'
231
+ : '';
232
+
233
+ return {
234
+ columnName: column.name,
235
+ generator,
236
+ options: getDefaultSyntheticOptions(generator, column),
237
+ note,
238
+ };
239
+ });
240
+ }
@@ -73,6 +73,57 @@ function renderBackupMetadata(backup) {
73
73
  `;
74
74
  }
75
75
 
76
+ function getBackupsUsageSummary(items = []) {
77
+ return items.reduce(
78
+ (summary, backup) => {
79
+ const sizeBytes = Number(backup.sizeBytes ?? 0);
80
+ const fileExists = backup.fileExists !== false;
81
+
82
+ summary.backupCount += 1;
83
+
84
+ if (fileExists) {
85
+ summary.availableCount += 1;
86
+ summary.totalSizeBytes += Number.isFinite(sizeBytes) && sizeBytes > 0 ? sizeBytes : 0;
87
+ } else {
88
+ summary.missingCount += 1;
89
+ }
90
+
91
+ return summary;
92
+ },
93
+ {
94
+ totalSizeBytes: 0,
95
+ backupCount: 0,
96
+ availableCount: 0,
97
+ missingCount: 0,
98
+ },
99
+ );
100
+ }
101
+
102
+ function renderBackupUsageSummary(state) {
103
+ const summary = getBackupsUsageSummary(state.backups.items);
104
+ const details = [
105
+ `${formatNumber(summary.availableCount)} available`,
106
+ summary.missingCount ? `${formatNumber(summary.missingCount)} missing` : '',
107
+ ]
108
+ .filter(Boolean)
109
+ .join(' // ');
110
+
111
+ return `
112
+ <div class="mb-4 grid gap-3 border border-outline-variant/10 bg-surface-container-low px-4 py-4 md:grid-cols-[minmax(14rem,1fr)_minmax(10rem,0.45fr)]">
113
+ <div class="min-w-0">
114
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">Total backup usage</div>
115
+ <div class="mt-1 font-body text-2xl font-black uppercase text-primary-container">${escapeHtml(formatBytes(summary.totalSizeBytes))}</div>
116
+ </div>
117
+ <div class="min-w-0 border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
118
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Backups</div>
119
+ <div class="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">
120
+ ${escapeHtml(formatNumber(summary.backupCount))}${details ? ` // ${escapeHtml(details)}` : ''}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ `;
125
+ }
126
+
76
127
  function renderBackupRows(state) {
77
128
  return state.backups.items
78
129
  .map(backup => {
@@ -186,6 +237,7 @@ function renderBackupsBody(state) {
186
237
  }
187
238
 
188
239
  return `
240
+ ${renderBackupUsageSummary(state)}
189
241
  <div class="overflow-auto border border-outline-variant/10 bg-surface-container-low">
190
242
  <div class="grid min-w-[76rem] grid-cols-[minmax(18rem,1.2fr)_minmax(17rem,0.85fr)_minmax(18rem,1fr)_14rem] gap-5 border-b border-outline-variant/10 bg-surface-container px-4 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
191
243
  <div>Backup</div>
@@ -137,6 +137,16 @@ function renderWorkspaceHeader(state) {
137
137
  }
138
138
  </div>
139
139
  <div class="flex flex-wrap items-center justify-end gap-3">
140
+ <button
141
+ class="standard-button"
142
+ data-action="open-generate-data-modal"
143
+ title="${table ? 'Generate synthetic test rows for this table' : 'Select a table before generating rows'}"
144
+ type="button"
145
+ ${table ? '' : 'disabled aria-disabled="true"'}
146
+ >
147
+ <span class="material-symbols-outlined text-sm">auto_awesome</span>
148
+ Generate
149
+ </button>
140
150
  ${
141
151
  table
142
152
  ? renderWorkspaceOpenDropdown({
@@ -154,6 +164,12 @@ function renderWorkspaceHeader(state) {
154
164
  label: 'Table Designer',
155
165
  target: tableName => `/table-designer/${encodeURIComponent(tableName)}`,
156
166
  },
167
+ {
168
+ icon: 'troubleshoot',
169
+ key: 'table-advisor',
170
+ label: 'Table Advisor',
171
+ target: tableName => `/table-advisor/${encodeURIComponent(tableName)}`,
172
+ },
157
173
  {
158
174
  key: 'sql-editor',
159
175
  },
@@ -0,0 +1,339 @@
1
+ import { renderStatusBadge } from '../components/badges.js';
2
+ import { renderTextInput } from '../components/formControls.js';
3
+ import { escapeHtml, formatCompactDateTime, formatDurationMs, formatNumber, truncateMiddle } from '../utils/format.js';
4
+
5
+ const FILTERS = {
6
+ range: [
7
+ ['1h', '1H'],
8
+ ['24h', '24H'],
9
+ ['7d', '7D'],
10
+ ['30d', '30D'],
11
+ ['all', 'ALL'],
12
+ ],
13
+ kind: [
14
+ ['all', 'All'],
15
+ ['query', 'Query'],
16
+ ['access', 'Access'],
17
+ ],
18
+ actor: [
19
+ ['all', 'All'],
20
+ ['user', 'User'],
21
+ ['cli', 'CLI'],
22
+ ['api', 'API'],
23
+ ['mcp', 'MCP'],
24
+ ],
25
+ status: [
26
+ ['all', 'All'],
27
+ ['success', 'Success'],
28
+ ['error', 'Error'],
29
+ ],
30
+ queryType: [
31
+ ['all', 'All'],
32
+ ['select', 'Select'],
33
+ ['insert', 'Insert'],
34
+ ['update', 'Update'],
35
+ ['delete', 'Delete'],
36
+ ['pragma', 'Pragma'],
37
+ ['create', 'Create'],
38
+ ['alter', 'Alter'],
39
+ ['drop', 'Drop'],
40
+ ['other', 'Other'],
41
+ ],
42
+ };
43
+
44
+ function renderFilterGroup({ label, field, value, items, className = '' }) {
45
+ return `
46
+ <div class="${escapeHtml(['min-w-0', className].filter(Boolean).join(' '))}">
47
+ <div class="mb-2 font-mono text-[9px] font-bold uppercase tracking-[0.18em] text-on-surface-variant/55">
48
+ ${escapeHtml(label)}
49
+ </div>
50
+ <div class="custom-scrollbar max-w-full overflow-x-auto pb-1">
51
+ <div class="charts-height-toggle" role="group" aria-label="${escapeHtml(label)}">
52
+ ${items
53
+ .map(
54
+ ([itemValue, itemLabel]) => `
55
+ <button
56
+ class="standard-button charts-height-toggle__button ${value === itemValue ? 'is-active' : ''}"
57
+ aria-pressed="${value === itemValue ? 'true' : 'false'}"
58
+ data-action="set-log-filter"
59
+ data-field="${escapeHtml(field)}"
60
+ data-value="${escapeHtml(itemValue)}"
61
+ type="button"
62
+ >
63
+ ${escapeHtml(itemLabel)}
64
+ </button>
65
+ `,
66
+ )
67
+ .join('')}
68
+ </div>
69
+ </div>
70
+ </div>
71
+ `;
72
+ }
73
+
74
+ function renderLogMetaStrip(logs) {
75
+ const total = formatNumber(logs.total ?? 0);
76
+ const visible = formatNumber((logs.items ?? []).length);
77
+ const activeDatabase = logs.metadata?.activeDatabase?.label ?? null;
78
+ const scope = activeDatabase || 'Active Database';
79
+ const items = [
80
+ ['Visible', visible],
81
+ ['Matched', total],
82
+ ['Scope', scope],
83
+ ];
84
+
85
+ return `
86
+ <div class="flex flex-wrap items-center gap-x-8 gap-y-3 border-t border-outline-variant/10 pt-4">
87
+ ${items
88
+ .map(
89
+ ([label, value]) => `
90
+ <div class="min-w-0">
91
+ <div class="font-mono text-[9px] font-bold uppercase tracking-[0.18em] text-on-surface-variant/50">
92
+ ${escapeHtml(label)}
93
+ </div>
94
+ <div class="mt-1 max-w-[18rem] truncate font-mono text-[11px] font-bold uppercase tracking-[0.12em] text-on-surface" data-logs-meta="${escapeHtml(
95
+ label.toLowerCase(),
96
+ )}" title="${escapeHtml(value)}">
97
+ ${escapeHtml(value)}
98
+ </div>
99
+ </div>
100
+ `,
101
+ )
102
+ .join('')}
103
+ </div>
104
+ `;
105
+ }
106
+
107
+ function renderLogFilters(logs) {
108
+ const filters = logs.filters ?? {};
109
+
110
+ return `
111
+ <section class="shell-section p-5">
112
+ <div class="flex flex-col gap-5">
113
+ <form class="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_auto]" data-form="logs-search">
114
+ ${renderTextInput({
115
+ dataAttributes: { bind: 'logs-search' },
116
+ name: 'search',
117
+ placeholder: 'Search action, target, SQL, table, error...',
118
+ value: filters.searchInput ?? '',
119
+ })}
120
+ <button class="standard-button justify-center" type="submit">
121
+ <span class="material-symbols-outlined text-sm">search</span>
122
+ Apply
123
+ </button>
124
+ </form>
125
+ ${renderLogMetaStrip(logs)}
126
+ <div class="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
127
+ ${renderFilterGroup({
128
+ label: 'Time Window',
129
+ field: 'range',
130
+ value: filters.range ?? 'all',
131
+ items: FILTERS.range,
132
+ })}
133
+ ${renderFilterGroup({
134
+ label: 'Log Type',
135
+ field: 'kind',
136
+ value: filters.kind ?? 'all',
137
+ items: FILTERS.kind,
138
+ })}
139
+ ${renderFilterGroup({
140
+ label: 'Executed By',
141
+ field: 'actor',
142
+ value: filters.actor ?? 'all',
143
+ items: FILTERS.actor,
144
+ })}
145
+ ${renderFilterGroup({
146
+ label: 'Status',
147
+ field: 'status',
148
+ value: filters.status ?? 'all',
149
+ items: FILTERS.status,
150
+ })}
151
+ ${renderFilterGroup({
152
+ label: 'Query Type',
153
+ field: 'queryType',
154
+ value: filters.queryType ?? 'all',
155
+ items: FILTERS.queryType,
156
+ className: 'xl:col-span-2',
157
+ })}
158
+ </div>
159
+ </div>
160
+ </section>
161
+ `;
162
+ }
163
+
164
+ function renderLogPreview(item) {
165
+ if (item.kind === 'query') {
166
+ return `
167
+ <div class="mt-2 max-w-2xl truncate font-mono text-[11px] text-on-surface-variant/60" title="${escapeHtml(
168
+ item.rawSql ?? '',
169
+ )}">
170
+ ${escapeHtml(item.preview || item.rawSql || 'SQL query')}
171
+ </div>
172
+ `;
173
+ }
174
+
175
+ const metadata = item.metadata ?? {};
176
+ const method = metadata.method ? `${metadata.method} ` : '';
177
+ const path = metadata.path ?? '';
178
+
179
+ return `
180
+ <div class="mt-2 max-w-4xl truncate font-mono text-[11px] text-on-surface-variant/60" title="${escapeHtml(path)}">
181
+ ${escapeHtml(`${method}${path || item.action || 'access'}`)}
182
+ </div>
183
+ `;
184
+ }
185
+
186
+ function getActorTextClass(actor) {
187
+ return ['api', 'cli', 'mcp'].includes(
188
+ String(actor ?? '')
189
+ .trim()
190
+ .toLowerCase(),
191
+ )
192
+ ? 'text-primary-container'
193
+ : 'text-on-surface-variant/70';
194
+ }
195
+
196
+ function renderLogRow(item) {
197
+ const actor = item.executedBy || item.source || 'n/a';
198
+ const source = item.kind || 'log';
199
+ const status = item.status || 'unknown';
200
+ const target = [item.targetType, item.targetName].filter(Boolean).join(' // ') || 'n/a';
201
+ const detailBadges = [
202
+ item.queryType && renderStatusBadge(item.queryType, 'muted'),
203
+ item.destructive === true && renderStatusBadge('destructive', 'alert'),
204
+ item.kind === 'query' && item.rowCount !== null && item.rowCount !== undefined
205
+ ? renderStatusBadge(`${formatNumber(item.rowCount)} rows`, 'muted')
206
+ : '',
207
+ item.kind === 'query' && item.affectedRows
208
+ ? renderStatusBadge(`${formatNumber(item.affectedRows)} affected`, 'muted')
209
+ : '',
210
+ ]
211
+ .filter(Boolean)
212
+ .join('');
213
+
214
+ return `
215
+ <tr class="border-b border-outline-variant/5 bg-surface-container-lowest/60 align-top">
216
+ <td class="px-4 py-4 font-mono text-[11px] text-on-surface-variant/75">
217
+ ${escapeHtml(formatCompactDateTime(item.occurredAt))}
218
+ </td>
219
+ <td class="px-4 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.12em] text-on-surface-variant/70">
220
+ ${escapeHtml(source)}
221
+ </td>
222
+ <td class="px-4 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.12em] ${getActorTextClass(actor)}">
223
+ ${escapeHtml(actor)}
224
+ </td>
225
+ <td class="px-4 py-4">
226
+ <div class="font-body text-sm font-black uppercase text-on-surface">
227
+ ${escapeHtml(truncateMiddle(item.action ?? 'log', 44))}
228
+ </div>
229
+ ${renderLogPreview(item)}
230
+ ${
231
+ detailBadges
232
+ ? `<div class="query-history-badge-row query-history-badge-row--compact logs-detail-badge-row mt-3">${detailBadges}</div>`
233
+ : ''
234
+ }
235
+ ${item.errorMessage ? `<div class="mt-2 text-xs text-error">${escapeHtml(item.errorMessage)}</div>` : ''}
236
+ </td>
237
+ <td class="px-4 py-4 font-mono text-[11px] text-on-surface-variant/70" title="${escapeHtml(target)}">
238
+ ${escapeHtml(truncateMiddle(target, 42))}
239
+ </td>
240
+ <td class="px-4 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.12em] ${
241
+ status === 'error' ? 'text-error' : 'text-on-surface-variant/70'
242
+ }">
243
+ ${escapeHtml(status)}
244
+ </td>
245
+ <td class="px-4 py-4 font-mono text-[11px] text-on-surface-variant/70">
246
+ ${escapeHtml(formatDurationMs(item.durationMs))}
247
+ </td>
248
+ </tr>
249
+ `;
250
+ }
251
+
252
+ export function renderLogTable(logs) {
253
+ if (logs.loading && !(logs.items ?? []).length) {
254
+ return `
255
+ <section class="shell-section flex min-h-0 flex-1 items-center justify-center overflow-hidden" data-logs-table>
256
+ <div class="text-center text-on-surface-variant/45">
257
+ <span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
258
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em]">Loading Logs</div>
259
+ </div>
260
+ </section>
261
+ `;
262
+ }
263
+
264
+ if (logs.error) {
265
+ return `
266
+ <section class="shell-section custom-scrollbar min-h-0 flex-1 overflow-auto border-error/20 bg-error-container/10 px-6 py-5" data-logs-table>
267
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
268
+ ${escapeHtml(logs.error.code)}
269
+ </div>
270
+ <div class="mt-2 text-sm text-on-surface">${escapeHtml(logs.error.message)}</div>
271
+ </section>
272
+ `;
273
+ }
274
+
275
+ if (!(logs.items ?? []).length) {
276
+ return `
277
+ <section class="shell-section flex min-h-0 flex-1 items-center justify-center overflow-hidden" data-logs-table>
278
+ <div class="text-center text-on-surface-variant/45">
279
+ <span class="material-symbols-outlined mb-3 text-5xl">receipt_long</span>
280
+ <div class="font-body text-xl font-black uppercase text-on-surface">No Logs Found</div>
281
+ <div class="mt-2 font-mono text-[10px] uppercase tracking-[0.18em]">Adjust filters or time window</div>
282
+ </div>
283
+ </section>
284
+ `;
285
+ }
286
+
287
+ return `
288
+ <section class="shell-section flex min-h-0 flex-1 flex-col overflow-hidden" data-logs-table>
289
+ <div class="custom-scrollbar min-h-0 flex-1 overflow-auto" data-logs-table-scroll>
290
+ <table class="w-full min-w-[1080px] text-left">
291
+ <thead class="sticky top-0 z-10 bg-surface-container-highest">
292
+ <tr class="border-b border-outline-variant/10 font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/60">
293
+ <th class="px-4 py-3 font-normal">Time</th>
294
+ <th class="px-4 py-3 font-normal">Source</th>
295
+ <th class="px-4 py-3 font-normal">Executed By</th>
296
+ <th class="px-4 py-3 font-normal">Action</th>
297
+ <th class="px-4 py-3 font-normal">Target</th>
298
+ <th class="px-4 py-3 font-normal">Status</th>
299
+ <th class="px-4 py-3 font-normal">Duration</th>
300
+ </tr>
301
+ </thead>
302
+ <tbody>
303
+ ${(logs.items ?? []).map(renderLogRow).join('')}
304
+ </tbody>
305
+ </table>
306
+ </div>
307
+ ${
308
+ logs.hasMore
309
+ ? `
310
+ <div class="border-t border-outline-variant/10 px-4 py-3">
311
+ <button class="standard-button" data-action="load-more-logs" type="button" ${
312
+ logs.loadingMore ? 'disabled' : ''
313
+ }>
314
+ ${logs.loadingMore ? 'Loading...' : 'Load More'}
315
+ </button>
316
+ </div>
317
+ `
318
+ : ''
319
+ }
320
+ </section>
321
+ `;
322
+ }
323
+
324
+ export function renderLogsView(state) {
325
+ const logs = state.logs ?? {};
326
+ const activeDatabaseId = state.connections?.active?.id ?? logs.metadata?.activeDatabase?.id ?? '';
327
+
328
+ return {
329
+ main: `
330
+ <main class="view-surface flex h-full min-h-0 flex-col" data-logs-view data-logs-active-database-id="${escapeHtml(activeDatabaseId)}">
331
+ <div class="view-frame flex h-full min-h-0 flex-col gap-6">
332
+ ${renderLogFilters(logs)}
333
+ ${renderLogTable(logs)}
334
+ </div>
335
+ </main>
336
+ `,
337
+ panel: '',
338
+ };
339
+ }