Skip to content

Commit e3985f4

Browse files
committed
add 'load what you can' button to failed tables; decode keys in json
1 parent f42fa3d commit e3985f4

File tree

7 files changed

+188
-5
lines changed

7 files changed

+188
-5
lines changed

src/components/ErrorViewer.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface ErrorViewerProps {
55
tabId: string;
66
error: string;
77
sourceFile: string;
8+
sourceFileSize?: number;
89
tableName: string;
910
fullTableName?: string;
1011
isPreLoadError?: boolean;
@@ -16,6 +17,7 @@ function ErrorViewer({
1617
tabId,
1718
error,
1819
sourceFile,
20+
sourceFileSize,
1921
tableName,
2022
fullTableName,
2123
isPreLoadError,
@@ -84,6 +86,52 @@ function ErrorViewer({
8486
}
8587
};
8688

89+
const handleLoadPartialData = async () => {
90+
if (!state.workerManager) {
91+
return;
92+
}
93+
94+
const tableNameForLoading = fullTableName || tableName;
95+
// Get the source file path (non-.err file)
96+
const sourceFilePath = sourceFile.replace(/\.err\.txt$/, '');
97+
98+
setIsLoading(true);
99+
setLoadError(null);
100+
try {
101+
// Load the table with ignore_errors flag
102+
await state.workerManager.loadSingleTable({
103+
name: tableNameForLoading,
104+
path: sourceFilePath,
105+
size: sourceFileSize || 0,
106+
originalName: tableName,
107+
isError: false,
108+
ignoreErrors: true, // NEW: Tell the loader to use relaxed mode
109+
});
110+
111+
// Close this error tab and open the SQL tab for the loaded table
112+
dispatch({ type: "CLOSE_TAB", id: tabId });
113+
setTimeout(() => {
114+
dispatch({
115+
type: "OPEN_TAB",
116+
tab: {
117+
kind: "sql",
118+
id: `sql-${tableNameForLoading}`,
119+
title: tableNameForLoading,
120+
query: `SELECT * FROM "${tableNameForLoading}" LIMIT 100`,
121+
sourceTable: tableNameForLoading,
122+
isCustomQuery: false,
123+
},
124+
});
125+
}, 100);
126+
} catch (err) {
127+
console.error("Failed to load partial data:", err);
128+
const errorMessage = err instanceof Error ? err.message : String(err);
129+
setLoadError(errorMessage);
130+
} finally {
131+
setIsLoading(false);
132+
}
133+
};
134+
87135
// Parse the error message to extract useful information
88136
const parseError = (errorMsg: string) => {
89137
// Look for line number in error
@@ -103,6 +151,7 @@ function ErrorViewer({
103151
if (isPreLoadError) {
104152
const isSingleFile = !errorFiles || errorFiles.length === 1;
105153
const hasAvailableFiles = availableFiles && availableFiles.length > 0;
154+
const hasPartialData = isSingleFile && sourceFileSize && sourceFileSize > 0;
106155

107156
// Helper to format file sizes
108157
const formatSize = (bytes?: number) => {
@@ -149,6 +198,33 @@ function ErrorViewer({
149198
</ul>
150199
</div>
151200

201+
{hasPartialData && !hasAvailableFiles && (
202+
<div className="error-section">
203+
<h3>Partial Data Available</h3>
204+
<p style={{ marginBottom: "12px" }}>
205+
The source file contains {formatSize(sourceFileSize)} of data. You can attempt to load what can be parsed with relaxed error handling.
206+
</p>
207+
<p style={{ marginBottom: "12px", color: "var(--text-muted)" }}>
208+
<strong>Note:</strong> This will skip malformed rows and may result in incomplete data.
209+
</p>
210+
<button
211+
className="btn btn-primary"
212+
onClick={handleLoadPartialData}
213+
disabled={isLoading}
214+
style={{ marginTop: "16px" }}
215+
>
216+
{isLoading ? "Loading..." : `Load Partial Data (${formatSize(sourceFileSize)})`}
217+
</button>
218+
219+
{loadError && (
220+
<div style={{ marginTop: "16px", padding: "12px", backgroundColor: "var(--error-bg, #3c1f1f)", border: "1px solid var(--error-border, #f44336)", borderRadius: "4px" }}>
221+
<h4 style={{ margin: "0 0 8px 0", color: "var(--error-color, #f44336)" }}>Failed to Load Partial Data</h4>
222+
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: "0.9em" }}>{loadError}</pre>
223+
</div>
224+
)}
225+
</div>
226+
)}
227+
152228
{hasAvailableFiles && (
153229
<div className="error-section">
154230
<h3>Available Files ({availableFiles.length} nodes)</h3>

src/components/MainPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ function MainPanel({ style }: { style?: React.CSSProperties }) {
113113
tabId={tab.id}
114114
error={tab.error}
115115
sourceFile={tab.sourceFile}
116+
sourceFileSize={tab.sourceFileSize}
116117
tableName={tab.tableName}
117118
fullTableName={tab.fullTableName}
118119
isPreLoadError={tab.isPreLoadError}

src/components/sidebar/TablesView.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,15 @@ function TablesView() {
415415
[{ path: table.sourceFile, nodeId: table.nodeId ?? 0, size: table.size ?? 0, isError: true }];
416416
const availableFiles = table.nodeFiles?.filter(f => !f.isError) || [];
417417

418+
// For single-file errors, try to find the source (non-.err) file and its size
419+
let sourceFileSize: number | undefined;
420+
if (!table.nodeFiles || table.nodeFiles.length <= 1) {
421+
// Single file error - try to find the corresponding non-.err file
422+
const sourceFilePath = table.sourceFile.replace(/\.err\.txt$/, '');
423+
const sourceFileEntry = state.filesIndex[sourceFilePath];
424+
sourceFileSize = sourceFileEntry?.size;
425+
}
426+
418427
dispatch({
419428
type: "OPEN_TAB",
420429
tab: {
@@ -423,6 +432,7 @@ function TablesView() {
423432
title: `Error: ${baseName}`,
424433
error: "", // Will be populated by ErrorViewer
425434
sourceFile: table.sourceFile,
435+
sourceFileSize,
426436
tableName: baseName,
427437
fullTableName: table.name, // Full name with _by_node suffix if applicable
428438
isPreLoadError: true,

src/crdb/batchProcessor.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface BatchProcessOptions {
2121
sourcePath?: string;
2222
nodeId?: number;
2323
forceInsert?: boolean; // Force INSERT mode (table already exists)
24+
ignoreErrors?: boolean; // Use relaxed CSV parsing to load partial data
2425
}
2526

2627
const BATCH_SIZE_ROWS = 10000; // Max rows per batch
@@ -129,6 +130,7 @@ function tryReplaceWithPrettyKey(value: string): string {
129130
return value;
130131
}
131132

133+
// Handle hex keys (start with \x)
132134
if (value === "\\x" || value.startsWith("\\x")) {
133135
try {
134136
const decoded = prettyKey(value);
@@ -138,16 +140,74 @@ function tryReplaceWithPrettyKey(value: string): string {
138140
}
139141
}
140142

143+
// Handle base64-encoded keys (like oA==, oQ==, etc.)
144+
// Check if it looks like base64: only contains base64 chars and correct padding
145+
if (/^[A-Za-z0-9+/]*(=|==)?$/.test(value) && value.length % 4 === 0 && value.length > 0) {
146+
try {
147+
// Convert base64 to hex
148+
const binaryString = atob(value);
149+
const bytes = new Uint8Array(binaryString.length);
150+
for (let i = 0; i < binaryString.length; i++) {
151+
bytes[i] = binaryString.charCodeAt(i);
152+
}
153+
const hexStr = Array.from(bytes)
154+
.map((b) => b.toString(16).padStart(2, "0"))
155+
.join("");
156+
157+
const decoded = prettyKey(hexStr);
158+
// Only use the decoded version if it's different from the hex
159+
if (decoded.pretty !== hexStr) {
160+
return decoded.pretty;
161+
}
162+
} catch {
163+
// If decoding fails, return original value
164+
}
165+
}
166+
141167
return value;
142168
}
143169

170+
// Recursively decode base64 keys in JSON objects
171+
function decodeKeysInJson(obj: unknown): unknown {
172+
if (typeof obj === "string") {
173+
// If it's a string that looks like base64, try to decode it as a key
174+
return tryReplaceWithPrettyKey(obj);
175+
} else if (Array.isArray(obj)) {
176+
return obj.map(decodeKeysInJson);
177+
} else if (obj !== null && typeof obj === "object") {
178+
const result: Record<string, unknown> = {};
179+
for (const [key, value] of Object.entries(obj)) {
180+
// Decode values in key-related fields
181+
if (key.toLowerCase().includes("key") || key === "start" || key === "end") {
182+
result[key] = typeof value === "string" ? tryReplaceWithPrettyKey(value) : decodeKeysInJson(value);
183+
} else {
184+
result[key] = decodeKeysInJson(value);
185+
}
186+
}
187+
return result;
188+
}
189+
return obj;
190+
}
191+
192+
function decodeJsonKeys(jsonString: string): string {
193+
try {
194+
const parsed = JSON.parse(jsonString);
195+
const decoded = decodeKeysInJson(parsed);
196+
return JSON.stringify(decoded);
197+
} catch {
198+
// If parsing fails, return original
199+
return jsonString;
200+
}
201+
}
202+
144203
async function processRow(
145204
row: string[],
146205
headers: string[],
147206
rowIndex: number,
148207
options: {
149208
tableName: string;
150209
keyColumns: Set<number>;
210+
jsonKeyColumns: Set<number>;
151211
protoColumns: Map<number, string | null>;
152212
infoKeyColumnIndex: number;
153213
decodeKeys: boolean;
@@ -157,6 +217,18 @@ async function processRow(
157217
): Promise<string[]> {
158218
const processedRow = await Promise.all(
159219
row.map(async (value, colIndex) => {
220+
// Transform JSON columns that contain base64 keys
221+
if (options.jsonKeyColumns.has(colIndex) && options.decodeKeys) {
222+
if (value === "\\N" || value === "NULL" || !value) {
223+
return value;
224+
}
225+
// Check if it looks like JSON
226+
if (value.startsWith("{") || value.startsWith("[")) {
227+
return decodeJsonKeys(value);
228+
}
229+
return value;
230+
}
231+
160232
// Transform key columns
161233
if (options.keyColumns.has(colIndex) && options.decodeKeys) {
162234
if (value === "\\N" || value === "NULL") {
@@ -242,7 +314,7 @@ export async function preprocessAndLoadInBatches(
242314
content: string,
243315
options: BatchProcessOptions
244316
): Promise<number> {
245-
const { conn, db, tableName, delimiter, sourcePath, nodeId } = options;
317+
const { conn, db, tableName, delimiter, sourcePath, nodeId, ignoreErrors } = options;
246318

247319
// Process content in a streaming fashion
248320
let position = 0;
@@ -266,6 +338,7 @@ export async function preprocessAndLoadInBatches(
266338
// Identify columns that need special processing
267339
const keyColumns = new Set<number>();
268340
const protoColumns = new Map<number, string | null>();
341+
const jsonKeyColumns = new Set<number>(); // JSON columns that may contain base64 keys
269342
let infoKeyColumnIndex = -1;
270343

271344
headers.forEach((header, index) => {
@@ -276,7 +349,14 @@ export async function preprocessAndLoadInBatches(
276349
keyColumns.add(index);
277350
}
278351

279-
// Check for proto columns
352+
// Check for JSON columns that may contain encoded keys (e.g., rangelog.info)
353+
// These are already JSON from CRDB, not protos that need decoding
354+
if (options.decodeKeys && columnName === "info" &&
355+
(tableName.includes("rangelog") || tableName.includes("range_log"))) {
356+
jsonKeyColumns.add(index);
357+
}
358+
359+
// Check for proto columns (hex-encoded that need proto->JSON conversion)
280360
if (options.decodeProtos && options.protoDecoder) {
281361
if (columnName === "config" || columnName === "descriptor" ||
282362
columnName === "payload" || columnName === "progress" || columnName === "value") {
@@ -347,6 +427,7 @@ export async function preprocessAndLoadInBatches(
347427
const processedRow = await processRow(row, headers, lineNumber, {
348428
tableName,
349429
keyColumns,
430+
jsonKeyColumns,
350431
protoColumns,
351432
infoKeyColumnIndex,
352433
decodeKeys: options.decodeKeys,
@@ -383,6 +464,7 @@ export async function preprocessAndLoadInBatches(
383464
nodeId, // Always pass nodeId for both CREATE and INSERT
384465
typeHints,
385466
headers: !tableCreated ? headers : undefined,
467+
ignoreErrors,
386468
});
387469

388470
await conn.query(sql);
@@ -422,6 +504,7 @@ export async function preprocessAndLoadInBatches(
422504
nodeId, // Always pass nodeId for both CREATE and INSERT
423505
typeHints,
424506
headers: !tableCreated ? headers : undefined,
507+
ignoreErrors,
425508
});
426509

427510
await conn.query(sql);

src/crdb/csvUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ export interface CsvReadOptions {
99
nodeId?: number;
1010
typeHints?: Map<string, string>;
1111
headers?: string[];
12+
ignoreErrors?: boolean; // Use relaxed CSV parsing to skip malformed rows
1213
}
1314

1415
export function generateCsvReadSql(options: CsvReadOptions): string {
15-
const { fileName, tableName, operation, nodeId, typeHints, headers } = options;
16+
const { fileName, tableName, operation, nodeId, typeHints, headers, ignoreErrors } = options;
1617
const quotedTableName = `"${tableName}"`;
1718

1819
// Build column specification if we have type hints and headers
@@ -35,7 +36,7 @@ export function generateCsvReadSql(options: CsvReadOptions): string {
3536
escape = '"',
3637
header = true,
3738
nullstr = ['NULL', '\\N'],
38-
max_line_size = 33554432${columnsClause}
39+
max_line_size = 33554432${ignoreErrors ? ',\n ignore_errors = true' : ''}${columnsClause}
3940
`;
4041

4142
if (operation === 'create') {

src/state/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type ViewerTab =
3737
title: string;
3838
error: string;
3939
sourceFile: ZipEntryId;
40+
sourceFileSize?: number; // Size of the non-.err source file (for partial loading)
4041
tableName: string; // Display name (e.g., "crdb_internal.node_build_info")
4142
fullTableName?: string; // Full table name for loading (e.g., "crdb_internal.node_build_info_by_node")
4243
isPreLoadError?: boolean; // true for .err.txt files, undefined for DuckDB load errors
@@ -169,6 +170,7 @@ export interface TableData {
169170
nodeId?: number;
170171
originalName?: string;
171172
isError?: boolean;
173+
ignoreErrors?: boolean; // Use relaxed CSV parsing to load partial data
172174
loaded?: boolean;
173175
loading?: boolean;
174176
sourceFile?: string;

0 commit comments

Comments
 (0)