diff --git a/src/cli/app.rs b/src/cli/app.rs index de15526..21fca88 100644 --- a/src/cli/app.rs +++ b/src/cli/app.rs @@ -1129,6 +1129,10 @@ pub enum Commands { /// Output in JSON format #[arg(long)] json: bool, + + /// Path to .ibd tablespace for page correlation + #[arg(long)] + correlate: Option, }, /// Analyze undo tablespace files (.ibu or .ibd) diff --git a/src/cli/binlog.rs b/src/cli/binlog.rs index ebcb757..b049abf 100644 --- a/src/cli/binlog.rs +++ b/src/cli/binlog.rs @@ -2,9 +2,13 @@ //! //! Parses MySQL binary log files and displays event summaries, format //! description info, table map details, and row-based event statistics. +//! With `--correlate`, maps row events to tablespace pages via B+Tree lookup. +use std::collections::HashMap; use std::io::Write; +use serde::Serialize; + use crate::cli::{csv_escape, wprintln}; use crate::IdbError; @@ -22,10 +26,24 @@ pub struct BinlogOptions { pub json: bool, /// Output as CSV. pub csv: bool, + /// Path to .ibd tablespace for page correlation. + pub correlate: Option, +} + +/// Combined analysis with correlated events for JSON output. +#[derive(Serialize)] +struct CorrelatedBinlogAnalysis { + #[serde(flatten)] + analysis: crate::binlog::BinlogAnalysis, + correlated_events: Vec, } /// Analyze a binary log file and display results. pub fn execute(opts: &BinlogOptions, writer: &mut dyn Write) -> Result<(), IdbError> { + if opts.correlate.is_some() { + return execute_correlated(opts, writer); + } + let file = std::fs::File::open(&opts.file) .map_err(|e| IdbError::Io(format!("{}: {}", opts.file, e)))?; @@ -46,6 +64,214 @@ pub fn execute(opts: &BinlogOptions, writer: &mut dyn Write) -> Result<(), IdbEr write_text(&analysis, opts, writer) } +/// Execute with page correlation: maps row events to tablespace pages. +fn execute_correlated(opts: &BinlogOptions, writer: &mut dyn Write) -> Result<(), IdbError> { + let ts_path = opts.correlate.as_ref().unwrap(); + + // Run correlation + let mut binlog = crate::binlog::BinlogFile::open(&opts.file)?; + let mut ts = crate::cli::open_tablespace(ts_path, None, false)?; + let correlated = crate::binlog::correlate_events(&mut binlog, &mut ts)?; + + // Also run standard analysis for event context + let file = std::fs::File::open(&opts.file) + .map_err(|e| IdbError::Io(format!("{}: {}", opts.file, e)))?; + let reader = std::io::BufReader::new(file); + let analysis = crate::binlog::analyze_binlog(reader)?; + + if opts.json { + let combined = CorrelatedBinlogAnalysis { + analysis, + correlated_events: correlated, + }; + let json = + serde_json::to_string_pretty(&combined).map_err(|e| IdbError::Parse(e.to_string()))?; + wprintln!(writer, "{}", json)?; + return Ok(()); + } + + // Build lookup by binlog position + let correlated_map: HashMap = + correlated.iter().map(|e| (e.binlog_pos, e)).collect(); + + if opts.csv { + return write_correlated_csv(&analysis, &correlated_map, opts, writer); + } + + write_correlated_text(&analysis, &correlated_map, opts, writer) +} + +/// Write text output for correlated binlog analysis. +fn write_correlated_text( + analysis: &crate::binlog::BinlogAnalysis, + correlated: &HashMap, + opts: &BinlogOptions, + writer: &mut dyn Write, +) -> Result<(), IdbError> { + // Format description header + wprintln!(writer, "Binary Log: {}", opts.file)?; + wprintln!( + writer, + " Server Version: {}", + analysis.format_description.server_version + )?; + wprintln!( + writer, + " Binlog Version: {}", + analysis.format_description.binlog_version + )?; + wprintln!( + writer, + " Checksum Algorithm: {}", + analysis.format_description.checksum_alg + )?; + wprintln!( + writer, + " Correlated Events: {} (tablespace: {})", + correlated.len(), + opts.correlate.as_deref().unwrap_or("--") + )?; + wprintln!(writer)?; + + // Event type summary + wprintln!( + writer, + "Event Type Summary ({} total):", + analysis.event_count + )?; + let mut type_counts: Vec<_> = analysis.event_type_counts.iter().collect(); + type_counts.sort_by(|a, b| b.1.cmp(a.1)); + for (name, count) in &type_counts { + wprintln!(writer, " {:<30} {:>6}", name, count)?; + } + wprintln!(writer)?; + + // Table maps + if !analysis.table_maps.is_empty() { + wprintln!(writer, "Table Maps ({}):", analysis.table_maps.len())?; + for tm in &analysis.table_maps { + wprintln!( + writer, + " table_id={} {}.{} ({} columns)", + tm.table_id, + tm.database_name, + tm.table_name, + tm.column_count + )?; + if opts.verbose { + wprintln!(writer, " Column types: {:?}", &tm.column_types)?; + } + } + wprintln!(writer)?; + } + + // Event listing with page correlation columns + let events = filter_events(&analysis.events, opts); + let limit = opts.limit.unwrap_or(events.len()); + let display_events = &events[..limit.min(events.len())]; + + if !display_events.is_empty() { + wprintln!( + writer, + "{:<12} {:<30} {:<10} {:<12} {:<8} {}", + "Position", + "Type", + "Size", + "Timestamp", + "Page", + "PK" + )?; + wprintln!(writer, "{}", "-".repeat(90))?; + for evt in display_events { + if let Some(ce) = correlated.get(&evt.offset) { + let pk_display = if ce.pk_values.is_empty() { + "--".to_string() + } else { + format!("({})", ce.pk_values.join(", ")) + }; + wprintln!( + writer, + "{:<12} {:<30} {:<10} {:<12} {:<8} {}", + evt.offset, + evt.event_type, + evt.event_length, + evt.timestamp, + ce.page_no, + pk_display + )?; + } else { + wprintln!( + writer, + "{:<12} {:<30} {:<10} {:<12} {:<8} {}", + evt.offset, + evt.event_type, + evt.event_length, + evt.timestamp, + "--", + "--" + )?; + } + } + } + + if events.len() > limit { + wprintln!( + writer, + "\n... {} more events (use --limit to show more)", + events.len() - limit + )?; + } + + Ok(()) +} + +/// Write CSV output for correlated binlog events. +fn write_correlated_csv( + analysis: &crate::binlog::BinlogAnalysis, + correlated: &HashMap, + opts: &BinlogOptions, + writer: &mut dyn Write, +) -> Result<(), IdbError> { + wprintln!( + writer, + "position,type,size,timestamp,server_id,page_no,space_id,pk_values" + )?; + + let events = filter_events(&analysis.events, opts); + let limit = opts.limit.unwrap_or(events.len()); + let display_events = &events[..limit.min(events.len())]; + + for evt in display_events { + if let Some(ce) = correlated.get(&evt.offset) { + let pk_str = ce.pk_values.join(";"); + wprintln!( + writer, + "{},{},{},{},{},{},{},{}", + evt.offset, + csv_escape(&evt.event_type), + evt.event_length, + evt.timestamp, + evt.server_id, + ce.page_no, + ce.space_id, + csv_escape(&pk_str) + )?; + } else { + wprintln!( + writer, + "{},{},{},{},{},,,", + evt.offset, + csv_escape(&evt.event_type), + evt.event_length, + evt.timestamp, + evt.server_id + )?; + } + } + + Ok(()) +} + /// Write text output for binlog analysis. fn write_text( analysis: &crate::binlog::BinlogAnalysis, @@ -189,7 +415,6 @@ fn filter_events<'a>( mod tests { use super::*; use crate::binlog::{BinlogAnalysis, BinlogEventSummary, FormatDescriptionEvent}; - use std::collections::HashMap; fn sample_analysis() -> BinlogAnalysis { let mut event_type_counts = HashMap::new(); @@ -238,6 +463,7 @@ mod tests { verbose: false, json: false, csv: false, + correlate: None, }; let mut buf = Vec::new(); @@ -259,6 +485,7 @@ mod tests { verbose: false, json: false, csv: true, + correlate: None, }; let mut buf = Vec::new(); @@ -278,6 +505,7 @@ mod tests { verbose: false, json: false, csv: false, + correlate: None, }; let filtered = filter_events(&analysis.events, &opts); @@ -292,4 +520,117 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["event_count"], 7); } + + #[test] + fn test_correlated_text_output() { + let analysis = sample_analysis(); + let ce = crate::binlog::CorrelatedEvent { + binlog_pos: 123, + event_type: crate::binlog::RowEventType::Insert, + database: "test".to_string(), + table: "users".to_string(), + page_no: 42, + space_id: 5, + page_lsn: 999, + pk_values: vec!["1".to_string(), "alice".to_string()], + timestamp: 1700000001, + }; + let correlated: HashMap = + [(123, &ce)].into_iter().collect(); + let opts = BinlogOptions { + file: "test-bin.000001".to_string(), + limit: None, + filter_type: None, + verbose: false, + json: false, + csv: false, + correlate: Some("/tmp/users.ibd".to_string()), + }; + + let mut buf = Vec::new(); + write_correlated_text(&analysis, &correlated, &opts, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("Correlated Events: 1")); + assert!(output.contains("Page")); + assert!(output.contains("PK")); + // The correlated row event should show page 42 + assert!(output.contains("42")); + assert!(output.contains("(1, alice)")); + // Non-correlated event should show -- + let lines: Vec<&str> = output.lines().collect(); + let fde_line = lines + .iter() + .find(|l| l.contains("FORMAT_DESCRIPTION")) + .unwrap(); + assert!(fde_line.contains("--")); + } + + #[test] + fn test_correlated_csv_output() { + let analysis = sample_analysis(); + let ce = crate::binlog::CorrelatedEvent { + binlog_pos: 123, + event_type: crate::binlog::RowEventType::Insert, + database: "test".to_string(), + table: "users".to_string(), + page_no: 42, + space_id: 5, + page_lsn: 999, + pk_values: vec!["1".to_string()], + timestamp: 1700000001, + }; + let correlated: HashMap = + [(123, &ce)].into_iter().collect(); + let opts = BinlogOptions { + file: "test-bin.000001".to_string(), + limit: None, + filter_type: None, + verbose: false, + json: false, + csv: true, + correlate: Some("/tmp/users.ibd".to_string()), + }; + + let mut buf = Vec::new(); + write_correlated_csv(&analysis, &correlated, &opts, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!( + output.starts_with("position,type,size,timestamp,server_id,page_no,space_id,pk_values") + ); + // Correlated event should have page_no=42, space_id=5 + assert!(output.contains(",42,5,")); + // Non-correlated event should have empty page/space columns + let fde_line = output + .lines() + .find(|l| l.contains("FORMAT_DESCRIPTION")) + .unwrap(); + assert!(fde_line.ends_with(",,,")); + } + + #[test] + fn test_correlated_json_output() { + let analysis = sample_analysis(); + let correlated = vec![crate::binlog::CorrelatedEvent { + binlog_pos: 123, + event_type: crate::binlog::RowEventType::Insert, + database: "test".to_string(), + table: "users".to_string(), + page_no: 42, + space_id: 5, + page_lsn: 999, + pk_values: vec!["1".to_string()], + timestamp: 1700000001, + }]; + + let combined = CorrelatedBinlogAnalysis { + analysis, + correlated_events: correlated, + }; + let json = serde_json::to_string_pretty(&combined).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event_count"], 7); + assert_eq!(parsed["correlated_events"][0]["page_no"], 42); + assert_eq!(parsed["correlated_events"][0]["space_id"], 5); + assert_eq!(parsed["correlated_events"][0]["event_type"], "Insert"); + } } diff --git a/src/main.rs b/src/main.rs index e10c857..3b92f9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,7 @@ fn main() { filter_type, verbose, json, + correlate, } => cli::binlog::execute( &cli::binlog::BinlogOptions { file, @@ -661,6 +662,7 @@ fn main() { verbose, json: json || global_format == OutputFormat::Json, csv: global_format == OutputFormat::Csv, + correlate, }, &mut writer, ), diff --git a/web/src/components/binlog.js b/web/src/components/binlog.js index 4c68d3b..f229ce4 100644 --- a/web/src/components/binlog.js +++ b/web/src/components/binlog.js @@ -10,9 +10,12 @@ const PAGE_SIZE = 100; /** * Create the binlog tab for a binary log file. * @param {HTMLElement} container - * @param {Uint8Array} fileData + * @param {Uint8Array} fileData — raw binlog bytes + * @param {{ name: string, data: Uint8Array }|null} correlationTs — optional tablespace for page correlation + * @param {((name: string, data: Uint8Array) => void)|null} onCorrelateFile — callback when .ibd dropped + * @param {((pageNo: number) => void)|null} onPageClick — callback when a correlated page number is clicked */ -export function createBinlog(container, fileData) { +export function createBinlog(container, fileData, correlationTs, onCorrelateFile, onPageClick) { const wasm = getWasm(); let result; try { @@ -22,12 +25,27 @@ export function createBinlog(container, fileData) { return; } + // Run correlation if tablespace is available + let correlatedMap = null; // Map + let correlatedCount = 0; + if (correlationTs) { + try { + const correlated = JSON.parse(wasm.correlate_binlog_events(fileData, correlationTs.data)); + correlatedMap = new Map(correlated.map((e) => [e.binlog_pos, e])); + correlatedCount = correlated.length; + trackFeatureUse('binlog_correlate', { event_count: correlatedCount }); + } catch (e) { + // Show error but continue with uncorrelated view + correlatedMap = null; + console.warn('Binlog correlation failed:', e); + } + } + const fd = result.format_description || {}; const events = result.events || []; const tableMaps = result.table_maps || []; const typeCounts = result.event_type_counts || {}; let currentPage = 0; - const totalPages = Math.max(1, Math.ceil(events.length / PAGE_SIZE)); // Sort type counts by count descending const sortedTypes = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]); @@ -53,9 +71,26 @@ export function createBinlog(container, fileData) { ${statCard('Total Events', result.event_count || 0)} ${statCard('Event Types', sortedTypes.length)} ${statCard('Table Maps', tableMaps.length)} - ${statCard('Server ID', events.length > 0 ? events[0].server_id : '\u2014')} + ${correlatedMap + ? statCard('Correlated', correlatedCount, 'text-innodb-green') + : statCard('Server ID', events.length > 0 ? events[0].server_id : '\u2014')} + ${!correlationTs ? ` +
+
Drop a .ibd tablespace file here to correlate row events with pages
+
Maps each INSERT/UPDATE/DELETE to the specific B+Tree leaf page it affected
+ +
+ ` : ` +
+ Correlated + Tablespace: ${esc(correlationTs.name)} + ${correlatedCount} row events mapped to pages + +
+ `} + ${sortedTypes.length > 0 ? `

Event Type Distribution

@@ -86,7 +121,44 @@ export function createBinlog(container, fileData) { // Export bar const exportSlot = container.querySelector('#binlog-export'); if (exportSlot) { - exportSlot.appendChild(createExportBar(() => result, 'binlog')); + const exportData = correlatedMap + ? () => ({ ...result, correlated_events: [...correlatedMap.values()] }) + : () => result; + exportSlot.appendChild(createExportBar(exportData, 'binlog')); + } + + // Correlation dropzone (when no tablespace yet) + const dropzone = container.querySelector('#binlog-correlate-zone'); + if (dropzone && onCorrelateFile) { + const fileInput = container.querySelector('#binlog-correlate-input'); + + dropzone.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) readFile(file, onCorrelateFile); + }); + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('border-innodb-cyan/50', 'bg-innodb-cyan/5'); + }); + dropzone.addEventListener('dragleave', () => { + dropzone.classList.remove('border-innodb-cyan/50', 'bg-innodb-cyan/5'); + }); + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('border-innodb-cyan/50', 'bg-innodb-cyan/5'); + const file = e.dataTransfer.files[0]; + if (file) readFile(file, onCorrelateFile); + }); + } + + // Clear correlation button + const clearBtn = container.querySelector('#binlog-clear-correlate'); + if (clearBtn && onCorrelateFile) { + clearBtn.addEventListener('click', () => { + // Pass null to clear; main.js handles reset + onCorrelateFile(null, null); + }); } // Event listing with filter and pagination @@ -109,7 +181,18 @@ export function createBinlog(container, fileData) { const pageEvents = filteredEvents.slice(start, start + PAGE_SIZE); const wrap = container.querySelector('#binlog-events-wrap'); - wrap.innerHTML = renderEventsTable(pageEvents); + wrap.innerHTML = renderEventsTable(pageEvents, correlatedMap); + + // Wire up page click handlers + if (correlatedMap && onPageClick) { + wrap.querySelectorAll('[data-goto-page]').forEach((el) => { + el.addEventListener('click', (e) => { + e.preventDefault(); + const pageNo = parseInt(el.dataset.gotoPage, 10); + onPageClick(pageNo); + }); + }); + } const pag = container.querySelector('#binlog-pagination'); pag.innerHTML = ` @@ -131,6 +214,13 @@ export function createBinlog(container, fileData) { } } +/** Read a dropped/selected file as Uint8Array. */ +function readFile(file, callback) { + const reader = new FileReader(); + reader.onload = () => callback(file.name, new Uint8Array(reader.result)); + reader.readAsArrayBuffer(file); +} + function renderTypeDistribution(sortedTypes, total) { return ` @@ -186,7 +276,13 @@ function renderTableMaps(tableMaps) {
`; } -function renderEventsTable(events) { +/** + * Render the events table, optionally with correlation columns. + * @param {Array} events + * @param {Map|null} correlatedMap + */ +function renderEventsTable(events, correlatedMap) { + const hasCorrelation = !!correlatedMap; return ` @@ -196,18 +292,29 @@ function renderEventsTable(events) { + ${hasCorrelation ? '' : ''} - ${events.map((evt) => ` - - - - - - - - `).join('')} + ${events.map((evt) => { + const ce = hasCorrelation ? correlatedMap.get(evt.offset) : null; + const rowCls = ce + ? 'border-b border-gray-800/30 hover:bg-surface-2/50 bg-innodb-green/5' + : 'border-b border-gray-800/30 hover:bg-surface-2/50'; + return ` + + + + + + + ${hasCorrelation ? (ce + ? ` + ` + : '' + ) : ''} + `; + }).join('')}
Size Timestamp Server IDPagePK
${evt.offset}${esc(evt.event_type)}${evt.event_length}${formatTimestamp(evt.timestamp)}${evt.server_id}
${evt.offset}${esc(evt.event_type)}${evt.event_length}${formatTimestamp(evt.timestamp)}${evt.server_id}${ce.page_no}${esc(ce.pk_values.length ? '(' + ce.pk_values.join(', ') + ')' : '--')}----
`; } diff --git a/web/src/main.js b/web/src/main.js index ad91aaa..7b4baaa 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -44,6 +44,7 @@ let isBinlog = false; let decryptedData = null; let encryptionInfo = null; let auditFiles = null; +let binlogCorrelationTs = null; // { name, data } for binlog-to-page correlation // ── Bootstrap ──────────────────────────────────────────────────────── initTheme(); @@ -163,6 +164,8 @@ function onFile(name, data) { decryptedData = null; encryptionInfo = null; auditFiles = null; + binlogCorrelationTs = null; + savedBinlogState = null; const wasm = getWasm(); @@ -270,6 +273,49 @@ function onDecrypt(kData) { } } +/** Called when a .ibd file is dropped on the binlog correlation dropzone (null to clear). */ +function onBinlogCorrelate(name, data) { + binlogCorrelationTs = name ? { name, data } : null; + trackFeatureUse('binlog_correlate_drop'); + renderTab(); // Re-render binlog tab with correlation data +} + +/** Called when a correlated page number is clicked in the binlog tab. */ +let savedBinlogState = null; + +function onBinlogPageClick(pageNo) { + if (!binlogCorrelationTs) return; + trackFeatureUse('binlog_page_nav', { page_no: pageNo }); + // Save current binlog state + savedBinlogState = { fileData, fileName, isBinlog: true, binlogCorrelationTs }; + // Load tablespace as primary file + fileData = binlogCorrelationTs.data; + fileName = binlogCorrelationTs.name; + isBinlog = false; + try { + const info = JSON.parse(getWasm().get_tablespace_info(fileData)); + pageCount = info.page_count; + } catch { + pageCount = 0; + } + renderAnalyzer(); + requestPage(pageNo); + navigateToTab('pages'); +} + +/** Restore binlog state after navigating to a tablespace page. */ +function restoreBinlogState() { + if (!savedBinlogState) return; + fileData = savedBinlogState.fileData; + fileName = savedBinlogState.fileName; + isBinlog = savedBinlogState.isBinlog; + binlogCorrelationTs = savedBinlogState.binlogCorrelationTs; + savedBinlogState = null; + pageCount = 0; + currentTab = 0; + renderAnalyzer(); +} + function renderAnalyzer() { app.innerHTML = ''; // Skip-to-content link @@ -328,6 +374,8 @@ function renderAnalyzer() { decryptedData = null; encryptionInfo = null; auditFiles = null; + binlogCorrelationTs = null; + savedBinlogState = null; showDropzone(); }); @@ -357,6 +405,20 @@ function renderAnalyzer() { }); } + // "Back to Binlog" banner when navigating from correlated binlog view + if (savedBinlogState) { + const backBanner = document.createElement('div'); + backBanner.className = 'px-6 py-2 bg-innodb-cyan/10 border-b border-innodb-cyan/30 flex items-center gap-3'; + backBanner.innerHTML = ` + + Viewing tablespace from binlog correlation + `; + app.appendChild(backBanner); + backBanner.querySelector('#back-to-binlog').addEventListener('click', restoreBinlogState); + } + // Tabs const tabNav = createTabs(switchTab, opts); app.appendChild(tabNav); @@ -461,7 +523,7 @@ function renderTab() { createUndo(content, data); break; case 'binlog': - createBinlog(content, data); + createBinlog(content, data, binlogCorrelationTs, onBinlogCorrelate, onBinlogPageClick); break; case 'spatial': createSpatial(content, data); diff --git a/web/src/utils/help.js b/web/src/utils/help.js index 2848194..402d19d 100644 --- a/web/src/utils/help.js +++ b/web/src/utils/help.js @@ -204,8 +204,13 @@ export const TAB_DESCRIPTIONS = { tips: ['Blocks are 512-byte units within the redo log', 'Empty blocks indicate unused log space'], }, binlog: { - description: 'Binary log event listing — transactions, row changes, DDL statements, and replication metadata.', - tips: ['Events are shown in file order with timestamps', 'CRC-32C checksum validation per event'], + description: 'Binary log event listing — transactions, row changes, DDL statements, and replication metadata. Drop a .ibd tablespace to correlate row events with specific pages.', + tips: [ + 'Events are shown in file order with timestamps', + 'CRC-32C checksum validation per event', + 'Drop a tablespace (.ibd) file to see which page each row event affects', + 'Click a page number to navigate to that page in the Pages tab', + ], }, diff: { description: 'Side-by-side comparison of two tablespace files. Shows page-level differences in headers, checksums, and content.',