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.
- package/README.md +9 -0
- package/bin/sqlite-hub.js +555 -122
- package/docs/API.md +30 -0
- package/docs/CLI.md +22 -0
- package/docs/CLI_API_PARITY.md +61 -0
- package/docs/changelog.md +9 -1
- package/docs/guidelines/AGENTS.md +32 -0
- package/docs/{DESIGN_GUIDELINES.md → guidelines/DESIGN.md} +10 -0
- package/docs/todo.md +1 -13
- package/frontend/js/api.js +45 -0
- package/frontend/js/app.js +192 -2
- package/frontend/js/components/connectionCard.js +3 -1
- package/frontend/js/components/modal.js +356 -15
- package/frontend/js/components/sidebar.js +41 -7
- package/frontend/js/components/topNav.js +2 -14
- package/frontend/js/router.js +10 -0
- package/frontend/js/store.js +448 -0
- package/frontend/js/utils/inputClear.js +36 -0
- package/frontend/js/utils/syntheticData.js +240 -0
- package/frontend/js/views/backups.js +52 -0
- package/frontend/js/views/data.js +16 -0
- package/frontend/js/views/logs.js +339 -0
- package/frontend/js/views/settings.js +77 -27
- package/frontend/js/views/structure.js +6 -0
- package/frontend/js/views/tableAdvisor.js +385 -0
- package/frontend/js/views/tableDesigner.js +6 -0
- package/frontend/styles/components.css +149 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +8 -0
- package/package.json +1 -1
- package/server/routes/data.js +45 -0
- package/server/routes/externalApi.js +285 -2
- package/server/routes/logs.js +127 -0
- package/server/server.js +3 -0
- package/server/services/databaseCommandService.js +45 -0
- package/server/services/sqlite/dataBrowserService.js +36 -0
- package/server/services/sqlite/introspection.js +226 -22
- package/server/services/sqlite/syntheticDataGenerator.js +728 -0
- package/server/services/sqlite/tableAdvisor.js +790 -0
- 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
|
+
}
|