/** * @file main.cpp * @author Lewis Price (lewis.e.price@outlook.com) * @brief The main run file for LumberJack TUI * @version 1.0.1 * @date 2026-06-09 * * @copyright Copyright (c) 2026 * */ #include #include #include #include #include #include #include #include #include #include "log_parsing/log_parsing.hpp" #include "config_reader/config.hpp" int main() { using namespace ftxui; // ── 1. App State ───────────────────────────────────────────────────────── bool setup_complete = false; std::string log_file_path = ""; std::string mmdb_path = ""; std::string search_query = ""; if (check_for_config()) { load_config_file("lumberjack.config"); // Safely extract environment strings char* config_log = std::getenv("LOG_PATH"); char* config_db = std::getenv("DB_PATH"); if (config_log) log_file_path = config_log; if (config_db) mmdb_path = config_db; } std::vector active_logs; int selected_row = 0; // Order: IP | GEOLOCATION | DATE/TIME | STATUS | USER-AGENT std::array col_widths = {18, 38, 30, 8, 40}; constexpr int COL_MIN_WIDTH = 6; constexpr int COL_MAX_WIDTH = 60; // Which column is currently targeted by Shift+Arrow resize. int resize_col = 0; // Sorting System State // -1 = none, 0 = IP, 1 = Geo, 2 = Date/Time, 3 = Status, 4 = User-Agent int sort_column = -1; enum class SortOrder { NONE, ASC, DESC }; SortOrder current_sort = SortOrder::NONE; // Dynamically tracked Y position of the header row, updated every render frame. // The dashboard layout is: // row 0 : "ParseLogCLI Live Monitor" title border (1 row) // rows 1-2 : data source box with borderLight (2 data rows + 2 border = 4 rows total, but // borderLight is single-line so: top border + 2 content + bottom border = 4) // rows 5-7 : filter box (top border + content + bottom border = 3) // row 8 : resize hint dim text // row 9 : separator // row 10 : ← header row (gridbox first row) // Rather than hardcoding, we compute it from the actual rendered terminal dimensions // each frame so it stays correct if the layout ever changes. int header_y = 0; // ── 2. Filtering helper ─────────────────────────────────────────────────── // Returns the filtered subset and resets selected_row if out-of-bounds. auto get_filtered = [&]() -> std::vector { std::vector out; // Check for special prefix searches in the query box std::string date_filter = ""; std::string hour_filter = ""; std::string clean_search = search_query; // Check for "date:pattern" (e.g., date:09/Jun or date:2026-06-09) std::regex date_regex(R"(date:(\d+))"); std::smatch date_match; if (std::regex_search(search_query, date_match, date_regex)) { date_filter = date_match[1].str(); clean_search = std::regex_replace(clean_search, date_regex, ""); } // Check for "hour:HH" prefix (matches exactly 2 digits) std::regex hour_regex(R"(hour:(\d{2}))"); std::smatch hour_match; if (std::regex_search(search_query, hour_match, hour_regex)) { hour_filter = hour_match[1].str(); clean_search = std::regex_replace(clean_search, hour_regex, ""); } // Trim whitespace left over after prefix stripping clean_search.erase(0, clean_search.find_first_not_of(" \t")); clean_search.erase(clean_search.find_last_not_of(" \t") + 1); for (const auto& log : active_logs) { // Advanced Time & Date Specific Filters if (!date_filter.empty() && log.timestamp.find(date_filter) == std::string::npos) { continue; } if (!hour_filter.empty()) { // Find hour component (after first colon in Common Log Format or 'T' in ISO formats) std::size_t colon_pos = log.timestamp.find(':'); if (colon_pos != std::string::npos && colon_pos + 3 <= log.timestamp.size()) { std::string log_hour = log.timestamp.substr(colon_pos + 1, 2); if (log_hour != hour_filter) continue; } else if (log.timestamp.find("T" + hour_filter) == std::string::npos) { continue; } } // Standard Text Filtering Fallback std::string geo = log.location.city + ", " + log.location.country; if (clean_search.empty() || log.ip.find(clean_search) != std::string::npos || log.status.find(clean_search) != std::string::npos || log.request.find(clean_search) != std::string::npos || log.timestamp.find(clean_search) != std::string::npos || geo.find(clean_search) != std::string::npos) { out.push_back(log); } } // Apply dynamic sorting if a column header is active if (sort_column != -1 && current_sort != SortOrder::NONE) { std::sort(out.begin(), out.end(), [&](const Entry& a, const Entry& b) { std::string val_a, val_b; switch (sort_column) { case 0: val_a = a.ip; val_b = b.ip; break; case 1: val_a = a.location.city + a.location.country; val_b = b.location.city + b.location.country; break; case 2: val_a = a.timestamp; val_b = b.timestamp; break; case 3: val_a = a.status; val_b = b.status; break; case 4: val_a = a.browser + a.os; val_b = b.browser + b.os; break; default: return false; } return (current_sort == SortOrder::ASC) ? (val_a < val_b) : (val_a > val_b); }); } if (!out.empty() && selected_row >= (int)out.size()) selected_row = (int)out.size() - 1; return out; }; // ── 3. Log parsing lambda ───────────────────────────────────────────────── auto run_log_parsing = [&]() { setup_complete = true; //lumberjack.config file check #if defined(_WIN32) _putenv_s("LOG_PATH", log_file_path.c_str()); _putenv_s("DB_PATH", mmdb_path.c_str()); #else setenv("LOG_PATH", log_file_path.c_str(), 1); setenv("DB_PATH", mmdb_path.c_str(), 1); #endif // Generate config if it was missing completely if (!check_for_config()) { make_config(); } p_logs parsed_logs(log_file_path); active_logs = parsed_logs.get_all_logs(); if (active_logs.empty()) { loc_data mock_loc{"United States", "North Carolina", "Charlotte", "35.227", "-80.843"}; active_logs.push_back({ "192.168.1.50", mock_loc, "2026-06-09T12:00:00", "GET /api/v1/status HTTP/1.1", "200", "512", "https://github.com", "Linux", "Firefox" }); active_logs.push_back({ "8.8.8.8", {"United States", "California", "Mountain View", "37.386", "-122.083"}, "2026-06-09T12:01:15", "POST /login HTTP/1.1", "401", "1024", "direct", "Android", "Chrome" }); for (int i = 1; i <= 40; ++i) { active_logs.push_back({ "10.0.0." + std::to_string(i), {"Canada", "Ontario", "Toronto", "43.653", "-79.383"}, "2026-06-09T12:05:00", "GET /static/style.css HTTP/1.1", "200", "2048", "https://github.com", "macOS", "Safari" }); } } }; // Replace the old if statement on line 105 with this safety check: if (check_for_config() && std::filesystem::exists(log_file_path) && std::filesystem::exists(mmdb_path)) { run_log_parsing(); } else { // Force setup_complete to false so the user can verify their paths in View 1 setup_complete = false; } // ── 4. Setup-screen components ──────────────────────────────────────────── Component input_log = Input(&log_file_path, "Enter path to log file..."); Component input_mmdb = Input(&mmdb_path, "Enter path to MaxMind MMDB..."); Component submit_button = Button(" Start Log Monitor ", run_log_parsing, ButtonOption::Animated()); auto setup_container = Container::Vertical({input_log, input_mmdb, submit_button}); // ── 5. Search input (dashboard) ─────────────────────────────────────────── Component search_input = Input(&search_query, "Type to filter records (IP, request, status, etc.)..."); // ── 6. Main renderer ────────────────────────────────────────────────────── auto main_components = Container::Vertical({ setup_container, search_input }); auto main_renderer = Renderer(main_components, [&]() { if (!setup_complete) { // ── VIEW 1: Configuration ───────────────────────────────────────── return vbox({ text(" LumberJack Configuration ") | bold | color(Color::Blue) | border, separator(), vbox({ hbox({ text(" Log File Path: ") | bold | color(Color::Cyan), input_log->Render() }), vbox() | size(HEIGHT, EQUAL, 1), hbox({ text(" MaxMind DB Path: ") | bold | color(Color::Cyan), input_mmdb->Render() }), }) | borderLight, separator(), center(submit_button->Render()), }); } // ── VIEW 2: Dashboard ───────────────────────────────────────────────── auto filtered_logs = get_filtered(); std::vector> grid_matrix; // Interactive sorting headers auto header_cell = [&](int col_idx, const std::string& label) -> Element { bool is_resize_target = (col_idx == resize_col); bool is_sort_target = (col_idx == sort_column); std::string indicator = " "; if (is_sort_target) { indicator = (current_sort == SortOrder::ASC) ? " ▲" : " ▼"; } else if (is_resize_target) { indicator = " ↔"; } std::string display = label + indicator; auto cell_element = text(display) | bold | color(is_resize_target ? Color::Yellow : (is_sort_target ? Color::Green : Color::Cyan)) | size(WIDTH, EQUAL, col_widths[col_idx]); if (is_resize_target) { cell_element = cell_element | underlined; } return cell_element; }; // Count rows above the table so we know where the header lands. // Layout (0-indexed terminal rows): // 0 : title border top // 1 : title text ← "LumberJack Live Monitor" // 2 : title border bottom // 3 : data source border top // 4 : DATA SOURCE line // 5 : DATABASE line // 6 : data source border bottom // 7 : filter border top // 8 : FILTER line // 9 : filter border bottom // 10 : resize hint // 11 : separator // 12 : ← header row (gridbox row 0) // We update header_y here so the mouse handler always has the right value. header_y = 12; grid_matrix.push_back({ header_cell(0, " IP ADDRESS"), header_cell(1, "GEOLOCATION"), header_cell(2, "DATE/TIME"), header_cell(3, "STATUS"), header_cell(4, "CLIENT USER-AGENT"), text("HTTP REQUEST") | bold | color(Color::Cyan) | flex, }); // Data rows for (int i = 0; i < (int)filtered_logs.size(); ++i) { const auto& log = filtered_logs[i]; std::string geo = ""; if (!log.location.city.empty()) geo += log.location.city + ", "; if (!log.location.subdivision.empty()) geo += log.location.subdivision + ", "; geo += log.location.country; bool sel = (i == selected_row); Color status_color = Color::Red; if (!log.status.empty()) { char first = log.status[0]; if (first == '2') status_color = Color::Green; else if (first == '3') status_color = Color::GrayLight; } auto ip_cell = text(" " + log.ip) | size(WIDTH, EQUAL, col_widths[0]); auto geo_cell = text(geo) | size(WIDTH, EQUAL, col_widths[1]); auto time_cell = text(log.timestamp) | size(WIDTH, EQUAL, col_widths[2]); auto status_cell = text(log.status) | size(WIDTH, EQUAL, col_widths[3]) | color(sel ? Color::Cyan : status_color); auto agent_cell = text(log.browser + " (" + log.os + ")") | size(WIDTH, EQUAL, col_widths[4]); auto request_cell = text(log.request) | flex; if (sel) { ip_cell = ip_cell | inverted | color(Color::Cyan) | focus; geo_cell = geo_cell | inverted | color(Color::Cyan); time_cell = time_cell | inverted | color(Color::Cyan); status_cell = status_cell | inverted | color(Color::Cyan); agent_cell = agent_cell | inverted | color(Color::Cyan); request_cell = request_cell | inverted | color(Color::Cyan); } grid_matrix.push_back({ip_cell, geo_cell, time_cell, status_cell, agent_cell, request_cell}); } if (filtered_logs.empty()) { grid_matrix.push_back({ text(" No records matching filter criteria found. ") | dim }); } auto table_grid = gridbox(std::move(grid_matrix)); auto box_layout = vbox({ table_grid }) | vscroll_indicator | frame | flex; std::string resize_hint = " Resize columns: Shift+← / Shift+→ " "│ Switch Column Focus: ← / → " "│ Click Header label to sort " "│ Filter Syntax: date:09/Jun hour:12 " "│ q = quit "; return vbox({ text(" LumberJack Live Monitor ") | bold | color(Color::Blue) | border, vbox({ hbox({ text(" DATA SOURCE: ") | bold | color(Color::Green), text(log_file_path) }), hbox({ text(" DATABASE: ") | bold | color(Color::Green), text(mmdb_path) }), }) | borderLight, hbox({ text(" FILTER: ") | bold | color(Color::Yellow), search_input->Render() | flex, }) | borderLight, text(resize_hint) | dim, separator(), box_layout | flex, }); }); // ── 7. Event loop ───────────────────────────────────────────────────────── auto screen = ScreenInteractive::Fullscreen(); auto global_events = CatchEvent(main_renderer, [&](Event event) { if (event == Event::Character('q') || event == Event::Escape) { screen.Exit(); return true; } if (event == Event::Return && !setup_complete) { run_log_parsing(); return true; } if (!setup_complete) return false; // Mouse event handler for Scrolling & Column Header Clicking if (event.is_mouse()) { auto& m = event.mouse(); // Interactive Column Header Mouse Sorting Trigger // header_y is computed in the renderer each frame, so this always // targets the actual rendered row regardless of layout changes. if (m.button == Mouse::Left && m.motion == Mouse::Pressed) { if (m.y == header_y) { int current_x_bound = 0; for (int col = 0; col < (int)col_widths.size(); ++col) { current_x_bound += col_widths[col]; if (m.x < current_x_bound) { if (sort_column == col) { // Cycle: Unsorted -> Ascending -> Descending -> Unsorted if (current_sort == SortOrder::NONE) current_sort = SortOrder::ASC; else if (current_sort == SortOrder::ASC) current_sort = SortOrder::DESC; else { current_sort = SortOrder::NONE; sort_column = -1; } } else { sort_column = col; current_sort = SortOrder::ASC; } return true; } } } } auto filtered_size = [&]() { return (int)get_filtered().size(); }; if (m.button == Mouse::WheelDown) { int sz = filtered_size(); if (sz > 0) selected_row = std::min(selected_row + 3, sz - 1); return true; } if (m.button == Mouse::WheelUp) { selected_row = std::max(selected_row - 3, 0); return true; } return false; } // Keyboard row navigation if (!active_logs.empty()) { if (event == Event::ArrowDown) { auto fl = get_filtered(); if (!fl.empty()) selected_row = std::min(selected_row + 1, (int)fl.size() - 1); return true; } if (event == Event::ArrowUp) { selected_row = std::max(selected_row - 1, 0); return true; } } // Column resizing if (event == Event::Special("\033[1;2C")) { col_widths[resize_col] = std::min(col_widths[resize_col] + 2, COL_MAX_WIDTH); return true; } if (event == Event::Special("\033[1;2D")) { col_widths[resize_col] = std::max(col_widths[resize_col] - 2, COL_MIN_WIDTH); return true; } // Target columns selection if (event == Event::Special("\033[1;3C") || event == Event::ArrowRight) { resize_col = (resize_col + 1) % (int)col_widths.size(); return true; } if (event == Event::Special("\033[1;3D") || event == Event::ArrowLeft) { resize_col = (resize_col - 1 + (int)col_widths.size()) % (int)col_widths.size(); return true; } return false; }); screen.Loop(global_events); return 0; }