sqlite-hub 1.1.1 → 1.2.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 +17 -196
- package/bin/sqlite-hub.js +7 -2
- package/frontend/js/api.js +60 -0
- package/frontend/js/app.js +201 -5
- package/frontend/js/components/dropdownButton.js +92 -0
- package/frontend/js/components/modal.js +991 -876
- package/frontend/js/components/queryEditor.js +24 -15
- package/frontend/js/components/queryHistoryDetail.js +20 -1
- package/frontend/js/components/queryHistoryHeader.js +3 -2
- package/frontend/js/components/queryResults.js +1 -1
- package/frontend/js/components/rowEditorPanel.js +53 -13
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/components/topNav.js +1 -4
- package/frontend/js/components/workspaceOpenDropdown.js +52 -0
- package/frontend/js/router.js +2 -0
- package/frontend/js/store.js +450 -38
- package/frontend/js/utils/emailPreview.js +28 -0
- package/frontend/js/utils/markdownDocuments.js +17 -1
- package/frontend/js/utils/riskySql.js +165 -0
- package/frontend/js/views/backups.js +204 -0
- package/frontend/js/views/charts.js +1 -1
- package/frontend/js/views/data.js +42 -16
- package/frontend/js/views/documents.js +184 -154
- package/frontend/js/views/settings.js +1 -0
- package/frontend/js/views/structure.js +47 -33
- package/frontend/js/views/tableDesigner.js +23 -0
- package/frontend/styles/components.css +133 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +1 -0
- package/frontend/styles/views.css +33 -21
- package/package.json +2 -1
- package/server/routes/backups.js +120 -0
- package/server/routes/connections.js +26 -3
- package/server/routes/externalApi.js +6 -2
- package/server/server.js +3 -1
- package/server/services/databaseCommandService.js +4 -3
- package/server/services/sqlite/backupService.js +497 -66
- package/server/services/sqlite/connectionManager.js +2 -2
- package/server/services/sqlite/importService.js +25 -0
- package/server/services/sqlite/sqlExecutor.js +2 -0
- package/server/services/storage/appStateStore.js +379 -88
- package/tests/api-token-auth.test.js +45 -2
- package/tests/backup-manager.test.js +140 -0
- package/tests/backups-view.test.js +70 -0
- package/tests/charts-height-preset-storage.test.js +60 -0
- package/tests/charts-route-state.test.js +144 -0
- package/tests/cli-service-delegation.test.js +97 -0
- package/tests/connection-removal.test.js +52 -0
- package/tests/database-command-service.test.js +37 -2
- package/tests/documents-view.test.js +132 -0
- package/tests/dropdown-button.test.js +101 -0
- package/tests/email-preview.test.js +89 -0
- package/tests/markdown-documents.test.js +24 -0
- package/tests/query-editor.test.js +28 -0
- package/tests/query-history-detail.test.js +37 -0
- package/tests/query-history-header.test.js +30 -0
- package/tests/risky-sql.test.js +30 -0
- package/tests/row-editor-null-values.test.js +53 -0
- package/tests/settings-view.test.js +1 -0
- package/tests/structure-view.test.js +60 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const EMAIL_PATTERN = /^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/;
|
|
2
|
+
|
|
3
|
+
export function detectEmailValue(value) {
|
|
4
|
+
if (typeof value !== "string") {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const text = value.trim();
|
|
9
|
+
|
|
10
|
+
if (!text || text.length > 320 || !EMAIL_PATTERN.test(text)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const atIndex = text.lastIndexOf("@");
|
|
15
|
+
const localPart = text.slice(0, atIndex);
|
|
16
|
+
const domain = text.slice(atIndex + 1);
|
|
17
|
+
|
|
18
|
+
if (!localPart || !domain || domain.startsWith(".") || domain.endsWith(".")) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
type: "email",
|
|
24
|
+
value: text,
|
|
25
|
+
localPart,
|
|
26
|
+
domain,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -83,8 +83,24 @@ function renderFallbackMarkdown(markdown = '') {
|
|
|
83
83
|
return html.join('\n');
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function escapeMarkdownHtmlOutsideFences(markdown = '') {
|
|
87
|
+
const lines = String(markdown ?? '').split('\n');
|
|
88
|
+
let inFence = false;
|
|
89
|
+
|
|
90
|
+
return lines
|
|
91
|
+
.map(line => {
|
|
92
|
+
if (FENCED_CODE_PATTERN.test(line)) {
|
|
93
|
+
inFence = !inFence;
|
|
94
|
+
return line;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return inFence ? line : escapeHtml(line);
|
|
98
|
+
})
|
|
99
|
+
.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
function renderMarkdownBlock(markdown = '') {
|
|
87
|
-
const source =
|
|
103
|
+
const source = escapeMarkdownHtmlOutsideFences(markdown);
|
|
88
104
|
const markedRuntime = globalThis.marked;
|
|
89
105
|
const parser = typeof markedRuntime?.parse === 'function' ? markedRuntime.parse.bind(markedRuntime) : null;
|
|
90
106
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const RISKY_PATTERNS = [
|
|
2
|
+
{ type: 'drop_table', pattern: /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([^\s;]+))/i },
|
|
3
|
+
{ type: 'schema_change', pattern: /^\s*ALTER\s+TABLE\b/i },
|
|
4
|
+
{ type: 'schema_change', pattern: /^\s*CREATE\s+(?:TABLE|INDEX|VIEW|TRIGGER)\b/i },
|
|
5
|
+
{ type: 'schema_change', pattern: /^\s*DROP\s+(?:INDEX|VIEW|TRIGGER)\b/i },
|
|
6
|
+
{ type: 'schema_change', pattern: /^\s*REINDEX\b/i },
|
|
7
|
+
{ type: 'schema_change', pattern: /^\s*VACUUM\b/i },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function stripSqlComments(sql = '') {
|
|
11
|
+
const text = String(sql ?? '');
|
|
12
|
+
let output = '';
|
|
13
|
+
let index = 0;
|
|
14
|
+
let quote = null;
|
|
15
|
+
|
|
16
|
+
while (index < text.length) {
|
|
17
|
+
const char = text[index];
|
|
18
|
+
const next = text[index + 1];
|
|
19
|
+
|
|
20
|
+
if (quote) {
|
|
21
|
+
output += char;
|
|
22
|
+
if (char === quote) {
|
|
23
|
+
if (text[index + 1] === quote) {
|
|
24
|
+
output += text[index + 1];
|
|
25
|
+
index += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
quote = null;
|
|
29
|
+
}
|
|
30
|
+
index += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
35
|
+
quote = char;
|
|
36
|
+
output += char;
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (char === '-' && next === '-') {
|
|
42
|
+
while (index < text.length && text[index] !== '\n') {
|
|
43
|
+
index += 1;
|
|
44
|
+
}
|
|
45
|
+
output += '\n';
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (char === '/' && next === '*') {
|
|
50
|
+
index += 2;
|
|
51
|
+
while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
|
52
|
+
index += 1;
|
|
53
|
+
}
|
|
54
|
+
index += 2;
|
|
55
|
+
output += ' ';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
output += char;
|
|
60
|
+
index += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return output;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function splitSqlStatements(sql = '') {
|
|
67
|
+
const statements = [];
|
|
68
|
+
let current = '';
|
|
69
|
+
let quote = null;
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
72
|
+
const char = sql[index];
|
|
73
|
+
|
|
74
|
+
if (quote) {
|
|
75
|
+
current += char;
|
|
76
|
+
if (char === quote) {
|
|
77
|
+
if (sql[index + 1] === quote) {
|
|
78
|
+
current += sql[index + 1];
|
|
79
|
+
index += 1;
|
|
80
|
+
} else {
|
|
81
|
+
quote = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
88
|
+
quote = char;
|
|
89
|
+
current += char;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (char === ';') {
|
|
94
|
+
if (current.trim()) {
|
|
95
|
+
statements.push(current.trim());
|
|
96
|
+
}
|
|
97
|
+
current = '';
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
current += char;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (current.trim()) {
|
|
105
|
+
statements.push(current.trim());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return statements;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractDropTableName(match) {
|
|
112
|
+
return [match?.[1], match?.[2], match?.[3], match?.[4]]
|
|
113
|
+
.map(value => String(value ?? '').trim())
|
|
114
|
+
.find(Boolean) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function detectRiskySqlOperations(sql = '') {
|
|
118
|
+
const statements = splitSqlStatements(stripSqlComments(sql));
|
|
119
|
+
const operations = [];
|
|
120
|
+
|
|
121
|
+
statements.forEach((statement, index) => {
|
|
122
|
+
for (const rule of RISKY_PATTERNS) {
|
|
123
|
+
const match = statement.match(rule.pattern);
|
|
124
|
+
if (!match) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
operations.push({
|
|
129
|
+
type: statements.length > 1 && rule.type !== 'drop_table' ? 'migration' : rule.type,
|
|
130
|
+
statement,
|
|
131
|
+
statementIndex: index,
|
|
132
|
+
tableName: rule.type === 'drop_table' ? extractDropTableName(match) : null,
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return operations;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildRiskySqlBackupContext(operations = []) {
|
|
142
|
+
const dropTable = operations.find(operation => operation.type === 'drop_table');
|
|
143
|
+
|
|
144
|
+
if (dropTable) {
|
|
145
|
+
return {
|
|
146
|
+
type: 'pre_schema_change',
|
|
147
|
+
name: `Before dropping table ${dropTable.tableName ?? ''}`.trim(),
|
|
148
|
+
description: `DROP TABLE may remove data permanently. ${dropTable.tableName ? `Target: ${dropTable.tableName}.` : ''}`.trim(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (operations.some(operation => operation.type === 'migration') || operations.length > 1) {
|
|
153
|
+
return {
|
|
154
|
+
type: 'pre_migration',
|
|
155
|
+
name: 'Before migration',
|
|
156
|
+
description: 'Multiple schema-affecting statements were detected.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
type: 'pre_schema_change',
|
|
162
|
+
name: 'Before schema change',
|
|
163
|
+
description: 'This SQL can change database schema objects.',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { renderStatusBadge } from '../components/badges.js';
|
|
2
|
+
import { renderPageHeader } from '../components/pageHeader.js';
|
|
3
|
+
import { escapeHtml, formatBytes, formatCompactDateTime, truncateMiddle } from '../utils/format.js';
|
|
4
|
+
|
|
5
|
+
const STATUS_TONE = {
|
|
6
|
+
creating: 'muted',
|
|
7
|
+
verifying: 'muted',
|
|
8
|
+
verified: 'success',
|
|
9
|
+
failed: 'alert',
|
|
10
|
+
restoring: 'muted',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function formatStatus(value) {
|
|
14
|
+
return String(value ?? 'unknown')
|
|
15
|
+
.replace(/_/g, ' ')
|
|
16
|
+
.replace(/\b\w/g, letter => letter.toUpperCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderBackupStatus(backup) {
|
|
20
|
+
if (!backup.fileExists) {
|
|
21
|
+
return renderStatusBadge('File Missing', 'alert');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return renderStatusBadge(formatStatus(backup.status), STATUS_TONE[backup.status] ?? 'muted');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isBackupBusy(backup, state) {
|
|
28
|
+
return Boolean(state.backups.operationLoading) || ['creating', 'verifying', 'restoring'].includes(backup.status);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderBackupMetadataItem(label, value, extraClass = '') {
|
|
32
|
+
return `
|
|
33
|
+
<div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2 ${extraClass}">
|
|
34
|
+
<div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">${escapeHtml(label)}</div>
|
|
35
|
+
<div class="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">${escapeHtml(value)}</div>
|
|
36
|
+
</div>
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderBackupMetadata(backup) {
|
|
41
|
+
return `
|
|
42
|
+
<div class="grid min-w-[24rem] grid-cols-2 gap-2">
|
|
43
|
+
${renderBackupMetadataItem('Size', formatBytes(backup.sizeBytes))}
|
|
44
|
+
<div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
|
|
45
|
+
<div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Status</div>
|
|
46
|
+
<div class="mt-1">${renderBackupStatus(backup)}</div>
|
|
47
|
+
</div>
|
|
48
|
+
${renderBackupMetadataItem('SQLite Hub', backup.sqliteHubVersion ? `v${backup.sqliteHubVersion}` : 'n/a')}
|
|
49
|
+
${renderBackupMetadataItem('SQLite', backup.sqliteVersion ? `v${backup.sqliteVersion}` : 'n/a')}
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderBackupRows(state) {
|
|
55
|
+
return state.backups.items
|
|
56
|
+
.map(backup => {
|
|
57
|
+
const busy = isBackupBusy(backup, state);
|
|
58
|
+
const canRestore = backup.status === 'verified' && backup.fileExists && !busy && !state.connections.active?.readOnly;
|
|
59
|
+
const canDownload = backup.fileExists && !busy;
|
|
60
|
+
const canEditNotes = !busy;
|
|
61
|
+
const canDelete = !busy;
|
|
62
|
+
|
|
63
|
+
return `
|
|
64
|
+
<tr class="border-b border-outline-variant/10 align-top">
|
|
65
|
+
<td class="min-w-[20rem] px-4 py-5">
|
|
66
|
+
<div class="space-y-2">
|
|
67
|
+
<div class="font-headline text-sm font-black uppercase text-on-surface">${escapeHtml(backup.name)}</div>
|
|
68
|
+
<div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/45" title="${escapeHtml(backup.path)}">
|
|
69
|
+
${escapeHtml(truncateMiddle(backup.fileName || backup.path, 48))}
|
|
70
|
+
</div>
|
|
71
|
+
<div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/55">
|
|
72
|
+
Created // ${escapeHtml(formatCompactDateTime(backup.createdAt))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</td>
|
|
76
|
+
<td class="px-4 py-5">
|
|
77
|
+
${renderBackupMetadata(backup)}
|
|
78
|
+
</td>
|
|
79
|
+
<td class="min-w-[18rem] max-w-[24rem] px-4 py-5">
|
|
80
|
+
<div class="flex h-full flex-col items-start gap-3">
|
|
81
|
+
<div class="text-xs leading-6 text-on-surface-variant/70">
|
|
82
|
+
${backup.notes ? escapeHtml(backup.notes) : '<span class="text-on-surface-variant/35">No notes</span>'}
|
|
83
|
+
${backup.errorMessage ? `<div class="mt-2 text-error">${escapeHtml(backup.errorMessage)}</div>` : ''}
|
|
84
|
+
</div>
|
|
85
|
+
<button class="standard-button" data-action="open-edit-backup-notes-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canEditNotes ? '' : 'disabled'}>
|
|
86
|
+
<span class="material-symbols-outlined text-sm">edit_note</span>
|
|
87
|
+
Edit Notes
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</td>
|
|
91
|
+
<td class="px-4 py-5">
|
|
92
|
+
<div class="flex min-w-[10rem] flex-col items-stretch gap-2">
|
|
93
|
+
<button class="standard-button" data-action="open-restore-backup-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canRestore ? '' : 'disabled'}>
|
|
94
|
+
<span class="material-symbols-outlined text-sm">restore</span>
|
|
95
|
+
Restore
|
|
96
|
+
</button>
|
|
97
|
+
<button class="standard-button" data-action="download-backup" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canDownload ? '' : 'disabled'}>
|
|
98
|
+
<span class="material-symbols-outlined text-sm">download</span>
|
|
99
|
+
Download
|
|
100
|
+
</button>
|
|
101
|
+
<button class="delete-button" data-action="open-delete-backup-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canDelete ? '' : 'disabled'}>
|
|
102
|
+
<span class="material-symbols-outlined text-sm">delete</span>
|
|
103
|
+
Delete
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
`;
|
|
109
|
+
})
|
|
110
|
+
.join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderBackupsBody(state) {
|
|
114
|
+
if (state.backups.loading) {
|
|
115
|
+
return `
|
|
116
|
+
<div class="flex min-h-[280px] items-center justify-center border border-outline-variant/10 bg-surface-container-low">
|
|
117
|
+
<div class="text-center text-on-surface-variant/40">
|
|
118
|
+
<span class="material-symbols-outlined mb-3 text-4xl">progress_activity</span>
|
|
119
|
+
<p class="font-mono text-[10px] uppercase tracking-[0.22em]">LOADING_BACKUPS</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (state.backups.error) {
|
|
126
|
+
return `
|
|
127
|
+
<div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
|
|
128
|
+
<div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
|
|
129
|
+
${escapeHtml(state.backups.error.code)}
|
|
130
|
+
</div>
|
|
131
|
+
<div class="mt-2">${escapeHtml(state.backups.error.message)}</div>
|
|
132
|
+
</div>
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!state.backups.items.length) {
|
|
137
|
+
return `
|
|
138
|
+
<div class="border border-dashed border-outline-variant/20 bg-surface-container-low px-8 py-10 text-center">
|
|
139
|
+
<span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">inventory_2</span>
|
|
140
|
+
<p class="font-headline text-xl font-black uppercase tracking-tight text-primary-container">
|
|
141
|
+
No backups yet
|
|
142
|
+
</p>
|
|
143
|
+
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-on-surface-variant/65">
|
|
144
|
+
Create a backup to protect your database before making significant changes.
|
|
145
|
+
</p>
|
|
146
|
+
<div class="mt-6 flex items-center justify-center">
|
|
147
|
+
<button class="signature-button" data-action="open-create-backup-modal" type="button">
|
|
148
|
+
<span class="material-symbols-outlined text-sm">inventory_2</span>
|
|
149
|
+
Create backup
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return `
|
|
157
|
+
<div class="overflow-auto border border-outline-variant/10 bg-surface-container-low">
|
|
158
|
+
<table class="min-w-full border-collapse text-left">
|
|
159
|
+
<thead class="border-b border-outline-variant/10 bg-surface-container">
|
|
160
|
+
<tr class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
|
|
161
|
+
<th class="px-4 py-3 font-normal">Backup</th>
|
|
162
|
+
<th class="px-4 py-3 font-normal">Metadata</th>
|
|
163
|
+
<th class="px-4 py-3 font-normal">Note</th>
|
|
164
|
+
<th class="px-4 py-3 text-right font-normal">Actions</th>
|
|
165
|
+
</tr>
|
|
166
|
+
</thead>
|
|
167
|
+
<tbody>
|
|
168
|
+
${renderBackupRows(state)}
|
|
169
|
+
</tbody>
|
|
170
|
+
</table>
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderBackupsView(state) {
|
|
176
|
+
const activeLabel = state.connections.active?.label ?? 'No active database';
|
|
177
|
+
const actions = `
|
|
178
|
+
<button class="standard-button" data-action="refresh-backups" type="button" ${state.backups.loading ? 'disabled' : ''}>
|
|
179
|
+
<span class="material-symbols-outlined text-sm">refresh</span>
|
|
180
|
+
Refresh
|
|
181
|
+
</button>
|
|
182
|
+
<button class="signature-button" data-action="open-create-backup-modal" type="button" ${state.backups.operationLoading ? 'disabled' : ''}>
|
|
183
|
+
<span class="material-symbols-outlined text-sm">inventory_2</span>
|
|
184
|
+
Create backup
|
|
185
|
+
</button>
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
main: `
|
|
190
|
+
<section class="view-surface relative min-h-full overflow-hidden">
|
|
191
|
+
<div class="data-grid-texture pointer-events-none absolute inset-0"></div>
|
|
192
|
+
<div class="view-frame relative z-10">
|
|
193
|
+
${renderPageHeader({
|
|
194
|
+
title: 'Backups',
|
|
195
|
+
subtitle: `Active // ${activeLabel}`,
|
|
196
|
+
actions,
|
|
197
|
+
})}
|
|
198
|
+
${renderBackupsBody(state)}
|
|
199
|
+
</div>
|
|
200
|
+
</section>
|
|
201
|
+
`,
|
|
202
|
+
panel: '',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -557,7 +557,7 @@ function renderChartCard(chart, state, analysis) {
|
|
|
557
557
|
'" type="button">Edit</button>',
|
|
558
558
|
'<button class="delete-button" data-action="open-delete-query-chart-modal" data-chart-id="',
|
|
559
559
|
escapeHtml(chart.id),
|
|
560
|
-
'" type="button">Delete</button>',
|
|
560
|
+
'" type="button"><span class="material-symbols-outlined">delete</span> Delete</button>',
|
|
561
561
|
'<button class="standard-button" data-action="export-query-chart-png" data-chart-id="',
|
|
562
562
|
escapeHtml(chart.id),
|
|
563
563
|
'" type="button">Export PNG</button>',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { renderDataGrid } from '../components/dataGrid.js';
|
|
2
2
|
import { renderRowEditorPanel } from '../components/rowEditorPanel.js';
|
|
3
|
+
import { renderWorkspaceOpenDropdown } from '../components/workspaceOpenDropdown.js';
|
|
3
4
|
import { escapeHtml, formatCellValue, formatNumber, isBlobPreview, truncateMiddle } from '../utils/format.js';
|
|
5
|
+
import { detectEmailValue } from '../utils/emailPreview.js';
|
|
4
6
|
import { compactPathForDisplay, detectFilePathValue } from '../utils/filePathPreview.js';
|
|
5
7
|
|
|
6
8
|
function getSelectedRow(state) {
|
|
@@ -135,6 +137,30 @@ function renderWorkspaceHeader(state) {
|
|
|
135
137
|
}
|
|
136
138
|
</div>
|
|
137
139
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
|
140
|
+
${
|
|
141
|
+
table
|
|
142
|
+
? renderWorkspaceOpenDropdown({
|
|
143
|
+
tableName: table.name,
|
|
144
|
+
destinations: [
|
|
145
|
+
{
|
|
146
|
+
icon: 'account_tree',
|
|
147
|
+
key: 'structure',
|
|
148
|
+
label: 'Structure',
|
|
149
|
+
target: tableName => `/structure/${encodeURIComponent(tableName)}`,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
icon: 'table_chart',
|
|
153
|
+
key: 'table-designer',
|
|
154
|
+
label: 'Table Designer',
|
|
155
|
+
target: tableName => `/table-designer/${encodeURIComponent(tableName)}`,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
key: 'sql-editor',
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
})
|
|
162
|
+
: ''
|
|
163
|
+
}
|
|
138
164
|
${
|
|
139
165
|
table
|
|
140
166
|
? `<button
|
|
@@ -154,21 +180,6 @@ function renderWorkspaceHeader(state) {
|
|
|
154
180
|
>
|
|
155
181
|
Reload Data
|
|
156
182
|
</button>
|
|
157
|
-
${
|
|
158
|
-
table
|
|
159
|
-
? `
|
|
160
|
-
<button
|
|
161
|
-
class="standard-button"
|
|
162
|
-
data-action="navigate"
|
|
163
|
-
data-to="/structure/${encodeURIComponent(table.name)}"
|
|
164
|
-
type="button"
|
|
165
|
-
>
|
|
166
|
-
<span class="material-symbols-outlined">account_tree</span>
|
|
167
|
-
Open Structure
|
|
168
|
-
</button>
|
|
169
|
-
`
|
|
170
|
-
: ''
|
|
171
|
-
}
|
|
172
183
|
</div>
|
|
173
184
|
</div>
|
|
174
185
|
</header>
|
|
@@ -370,11 +381,26 @@ function renderTableSurface(state) {
|
|
|
370
381
|
cellClassName: 'px-4 py-2 align-top text-[11px] text-on-surface',
|
|
371
382
|
render: row => {
|
|
372
383
|
const rawValue = row[columnName];
|
|
384
|
+
const email = detectEmailValue(rawValue);
|
|
373
385
|
const filePath = detectFilePathValue(rawValue, columnName, tableMeta);
|
|
374
386
|
const value = formatCellValue(rawValue);
|
|
375
387
|
const isNull = value === 'NULL';
|
|
376
388
|
const widthClass = getCellWidthClass(columnName);
|
|
377
389
|
|
|
390
|
+
if (email) {
|
|
391
|
+
return `
|
|
392
|
+
<span
|
|
393
|
+
class="inline-flex ${widthClass} items-center gap-2 overflow-hidden whitespace-nowrap text-on-surface"
|
|
394
|
+
title="${escapeHtml(email.value)}"
|
|
395
|
+
>
|
|
396
|
+
<span class="material-symbols-outlined text-sm text-primary-container/75">alternate_email</span>
|
|
397
|
+
<span class="min-w-0 overflow-hidden text-ellipsis">${escapeHtml(
|
|
398
|
+
truncateMiddle(email.value, 48),
|
|
399
|
+
)}</span>
|
|
400
|
+
</span>
|
|
401
|
+
`;
|
|
402
|
+
}
|
|
403
|
+
|
|
378
404
|
if (filePath) {
|
|
379
405
|
return `
|
|
380
406
|
<span
|
|
@@ -406,7 +432,7 @@ function renderTableSurface(state) {
|
|
|
406
432
|
const gridMarkup = renderDataGrid({
|
|
407
433
|
columns,
|
|
408
434
|
rows: indexedRows.map(({ row }) => row),
|
|
409
|
-
tableClass: 'min-w-full border-collapse text-left font-mono text-xs',
|
|
435
|
+
tableClass: 'data-table min-w-full border-collapse text-left font-mono text-xs',
|
|
410
436
|
theadClass: 'sticky top-0 z-10 bg-surface-container-highest',
|
|
411
437
|
tbodyClass: 'divide-y divide-outline-variant/5',
|
|
412
438
|
getRowClass: (_, rowIndexOnPage) => {
|