From d23168b971b1f44a43d276e9b7ae7389a2fd2701 Mon Sep 17 00:00:00 2001 From: rapturate Date: Thu, 11 Jun 2026 15:19:52 -0400 Subject: [PATCH] 1) Added clickable column names for sorting (asc/desc) 2) Added Date/Time column 3) Fixed some spacing issues on standard column 4) Changed .env to lumberjack.config 5) Fixed the issue where the .env/lumberjack.config file was trunctated with bad file locations on start. 6) Updated env.hpp/.cpp to be config.hpp/.cpp and the parent directory to be config referencing. --- CMakeLists.txt | 2 +- README.md | 6 +- .../env.cpp => config_reader/config.cpp | 34 +-- config_reader/config.hpp | 37 +++ env_reader/env.hpp | 37 --- lumberjack.config | 8 + main.cpp | 282 +++++++++++++----- 7 files changed, 275 insertions(+), 131 deletions(-) rename env_reader/env.cpp => config_reader/config.cpp (66%) create mode 100644 config_reader/config.hpp delete mode 100644 env_reader/env.hpp create mode 100644 lumberjack.config diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bd77a3..f1377e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ include(FetchContent) add_library(LumberJack_core log_parsing/log_parsing.cpp ip_to_geo/ip_to_geo.cpp - env_reader/env.cpp + config_reader/config.cpp third_party/src/GeoLite2PP.cpp third_party/src/GeoLite2PP_error_category.cpp ) diff --git a/README.md b/README.md index d4a2de6..2957c27 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,10 @@ All Other Users: - I have packaged executables into the released .zip files NOTES: -- The TUI will ask for the access.log file location and the GeoLite2-City.mmdb file location on startup. It will then create local .env file with the locations you provided. If you mess up, just change them in the file for now. +- The TUI will ask for the access.log file location and the GeoLite2-City.mmdb file location on startup. It will then create local lumberjack.config file with the locations you provided. If you mess up, just change them in the file for now. * I have not tried using the GeoLite2-Country.mmdb file and do not know if it will crash the application... FUTURE CHANGES: -- Better filtering functionality - Ban IP functionality -- Access Date/Time column -- Sorting by column (asc/desc) \ No newline at end of file +- Live .log file changing \ No newline at end of file diff --git a/env_reader/env.cpp b/config_reader/config.cpp similarity index 66% rename from env_reader/env.cpp rename to config_reader/config.cpp index a112df5..a9092c3 100644 --- a/env_reader/env.cpp +++ b/config_reader/config.cpp @@ -1,14 +1,14 @@ /** - * @file env.cpp + * @file config.cpp * @author Lewis Price (lewis.e.price@outlook.com) - * @brief Implementation for environment utilities and cross-platform .env loader + * @brief Implementation for environment utilities and cross-platform config loader * @version 1.0.0 * @date 2026-06-09 * * @copyright Copyright (c) 2026 * */ -#include "env.hpp" +#include "config.hpp" #include #include #include @@ -25,21 +25,21 @@ static int set_env_variable(const std::string& key, const std::string& value) { #endif } -bool check_for_env() { - const std::string env_path = ".env"; - // Returns true only if .env exists on disk and is a file (not a directory) - return std::filesystem::exists(env_path) && std::filesystem::is_regular_file(env_path); +bool check_for_config() { + const std::string config_path = "lumberjack.config"; + // Returns true only if lumberjack.config exists on disk and is a file (not a directory) + return std::filesystem::exists(config_path) && std::filesystem::is_regular_file(config_path); } -void make_env() { - const std::string env_path = ".env"; - if (std::filesystem::exists(env_path)) { +void make_config() { + const std::string config_path = "lumberjack.config"; + if (std::filesystem::exists(config_path)) { return; } - std::ofstream file(env_path); + std::ofstream file(config_path); if (!file.is_open()) { - std::cerr << "Error: Could not generate a default " << env_path << " configuration template file." << std::endl; + std::cerr << "Error: Could not generate a default " << config_path << " configuration template file." << std::endl; return; } @@ -47,17 +47,17 @@ void make_env() { file << "# ParseLogCLI Global Environment Configuration\n"; file << "# Generated on 2026-06-09\n\n"; file << "# Path to the MaxMind GeoLite2 City Database binary\n"; - file << "DB_PATH=data/GeoLite2-City.mmdb\n\n"; + file << "DB_PATH=./data/GeoLite2-City.mmdb\n\n"; file << "# Absolute or relative path to the server access logs target file\n"; - file << "LOG_PATH=test_logs/access.log.txt\n"; + file << "LOG_PATH=./access.log.txt\n"; file.close(); } -void load_env_file(const std::string& env_path) { - std::ifstream file(env_path); +void load_config_file(const std::string& config_path) { + std::ifstream file(config_path); if (!file.is_open()) { - std::cerr << "Warning: Could not open " << env_path << " file." << std::endl; + std::cerr << "Warning: Could not open " << config_path << " file." << std::endl; return; } diff --git a/config_reader/config.hpp b/config_reader/config.hpp new file mode 100644 index 0000000..1bab9b8 --- /dev/null +++ b/config_reader/config.hpp @@ -0,0 +1,37 @@ +/** + * @file config.hpp + * @author Lewis Price (lewis.e.price@outlook.com) + * @brief A set of functions for reading/creating the .config file + * @version 1.0.1 + * @date 2026-06-09 + * + * @copyright Copyright (c) 2026 + * + */ + +#pragma once + +#include + + +/** + * @brief Checks for an already existing lumberjack.config file. + * + * @return true + * @return false + */ +bool check_for_config(); + +/** + * @brief Creates a new lumberjack.config file for the user. + * + */ +void make_config(); + +/** + * @brief Loads the lumberjack.config file information + * + * @param config_path + */ +void load_config_file(const std::string& config_path); + diff --git a/env_reader/env.hpp b/env_reader/env.hpp deleted file mode 100644 index ccd91c3..0000000 --- a/env_reader/env.hpp +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file env.hpp - * @author Lewis Price (lewis.e.price@outlook.com) - * @brief A set of functions for reading/creating the .env file - * @version 1.0.0 - * @date 2026-06-09 - * - * @copyright Copyright (c) 2026 - * - */ - -#pragma once - -#include - - -/** - * @brief Checks for an already existing .env file. - * - * @return true - * @return false - */ -bool check_for_env(); - -/** - * @brief Creates a new .env file for the user. - * - */ -void make_env(); - -/** - * @brief Loads the .env file information - * - * @param env_path - */ -void load_env_file(const std::string& env_path); - diff --git a/lumberjack.config b/lumberjack.config new file mode 100644 index 0000000..241a9e0 --- /dev/null +++ b/lumberjack.config @@ -0,0 +1,8 @@ +# ParseLogCLI Global Environment Configuration +# Generated on 2026-06-09 + +# Path to the MaxMind GeoLite2 City Database binary +DB_PATH=./data/GeoLite2-City.mmdb + +# Absolute or relative path to the server access logs target file +LOG_PATH=./test_logs/access.log diff --git a/main.cpp b/main.cpp index c92c745..01eeddf 100644 --- a/main.cpp +++ b/main.cpp @@ -2,7 +2,7 @@ * @file main.cpp * @author Lewis Price (lewis.e.price@outlook.com) * @brief The main run file for LumberJack TUI - * @version 1.0.0 + * @version 1.0.1 * @date 2026-06-09 * * @copyright Copyright (c) 2026 @@ -13,13 +13,15 @@ #include #include #include +#include +#include #include #include #include #include "log_parsing/log_parsing.hpp" -#include "env_reader/env.hpp" +#include "config_reader/config.hpp" int main() { using namespace ftxui; @@ -27,45 +29,125 @@ int main() { // ── 1. App State ───────────────────────────────────────────────────────── bool setup_complete = false; std::string log_file_path = ""; - std::string mmdb_path = "ip_data/GeoLite2-City.mmdb"; + std::string mmdb_path = ""; std::string search_query = ""; - if (check_for_env()) { - load_env_file(".env"); + if (check_for_config()) { + load_config_file("lumberjack.config"); // Safely extract environment strings - char* env_log = std::getenv("LOG_PATH"); - char* env_db = std::getenv("DB_PATH"); - if (env_log) log_file_path = env_log; - if (env_db) mmdb_path = env_db; + 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 | STATUS | USER-AGENT - std::array col_widths = {20, 35, 8, 26}; + // 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. - // -1 means none highlighted. 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 (search_query.empty() || - log.ip.find(search_query) != std::string::npos || - log.status.find(search_query) != std::string::npos || - log.request.find(search_query) != std::string::npos || - geo.find(search_query) != std::string::npos) { + 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; @@ -75,17 +157,18 @@ int main() { auto run_log_parsing = [&]() { setup_complete = true; - //.env file check - if (!check_for_env()) { - // Update process environment variables first so make_env() saves them correctly - #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 - make_env(); + //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); @@ -116,11 +199,15 @@ int main() { } }; - //Checks for log file and mmdb path - if (!log_file_path.empty() && !mmdb_path.empty()) { + // 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..."); @@ -141,7 +228,7 @@ int main() { if (!setup_complete) { // ── VIEW 1: Configuration ───────────────────────────────────────── return vbox({ - text(" ParseLogCLI Configuration ") | bold | color(Color::Blue) | border, + text(" LumberJack Configuration ") | bold | color(Color::Blue) | border, separator(), vbox({ hbox({ text(" Log File Path: ") | bold | color(Color::Cyan), input_log->Render() }), @@ -157,32 +244,65 @@ int main() { auto filtered_logs = get_filtered(); std::vector> grid_matrix; - // Column headers + // Interactive sorting headers auto header_cell = [&](int col_idx, const std::string& label) -> Element { - bool active = (col_idx == resize_col); - std::string display = active ? label + " ↔" : label; - Element e = text(display) - | bold - | color(active ? Color::Yellow : Color::Cyan) - | size(WIDTH, EQUAL, col_widths[col_idx]); - if (active) e = e | underlined; - return e; + 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, "STATUS"), - header_cell(3, "CLIENT USER-AGENT"), + 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 = log.location.city.empty() - ? log.location.country - : log.location.city + ", " + log.location.country; + 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); @@ -193,22 +313,24 @@ int main() { 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 status_cell = text(log.status) | size(WIDTH, EQUAL, col_widths[2]) - | color(sel ? Color::Cyan : status_color); - auto agent_cell = text(log.browser + " (" + log.os + ")") | size(WIDTH, EQUAL, col_widths[3]); - auto request_cell = text(log.request) | flex; + 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, status_cell, agent_cell, request_cell}); + grid_matrix.push_back({ip_cell, geo_cell, time_cell, status_cell, agent_cell, request_cell}); } if (filtered_logs.empty()) { @@ -221,13 +343,14 @@ int main() { auto box_layout = vbox({ table_grid }) | vscroll_indicator | frame | flex; std::string resize_hint = - " Resize columns: Shift+← / Shift+→ " - "│ Switch column: ← / → " - "│ Scroll: ↑↓ or wheel " - "│ q / esc = quit "; + " Resize columns: Shift+← / Shift+→ " + "│ Switch Column Focus: ← / → " + "│ Click Header label to sort " + "│ Filter Syntax: date:09/Jun hour:12 " + "│ q = quit "; return vbox({ - text(" ParseLogCLI Live Monitor ") | bold | color(Color::Blue) | border, + text(" LumberJack Live Monitor ") | bold | color(Color::Blue) | border, vbox({ hbox({ text(" DATA SOURCE: ") | bold | color(Color::Green), text(log_file_path) }), @@ -261,21 +384,36 @@ int main() { if (!setup_complete) return false; - // Mouse wheel scrolling + // Mouse event handler for Scrolling & Column Header Clicking if (event.is_mouse()) { auto& m = event.mouse(); - auto filtered_size = [&]() { - int n = 0; - for (const auto& log : active_logs) { - std::string geo = log.location.city + ", " + log.location.country; - if (search_query.empty() || - log.ip.find(search_query) != std::string::npos || - log.status.find(search_query) != std::string::npos || - log.request.find(search_query) != std::string::npos || - geo.find(search_query) != std::string::npos) - ++n; + + // 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; + } + } } - return n; + } + + auto filtered_size = [&]() { + return (int)get_filtered().size(); }; if (m.button == Mouse::WheelDown) { @@ -304,11 +442,11 @@ int main() { } // Column resizing - if (event == Event::Special("\033[1;2C")) { + 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")) { + if (event == Event::Special("\033[1;2D")) { col_widths[resize_col] = std::max(col_widths[resize_col] - 2, COL_MIN_WIDTH); return true; } @@ -328,4 +466,4 @@ int main() { screen.Loop(global_events); return 0; -} \ No newline at end of file +}