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.
This commit is contained in:
rapturate
2026-06-11 15:19:52 -04:00
parent aa11268522
commit d23168b971
7 changed files with 275 additions and 131 deletions

View File

@@ -14,7 +14,7 @@ include(FetchContent)
add_library(LumberJack_core add_library(LumberJack_core
log_parsing/log_parsing.cpp log_parsing/log_parsing.cpp
ip_to_geo/ip_to_geo.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.cpp
third_party/src/GeoLite2PP_error_category.cpp third_party/src/GeoLite2PP_error_category.cpp
) )

View File

@@ -7,12 +7,10 @@ All Other Users:
- I have packaged executables into the released .zip files - I have packaged executables into the released .zip files
NOTES: 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... * I have not tried using the GeoLite2-Country.mmdb file and do not know if it will crash the application...
FUTURE CHANGES: FUTURE CHANGES:
- Better filtering functionality
- Ban IP functionality - Ban IP functionality
- Access Date/Time column - Live .log file changing
- Sorting by column (asc/desc)

View File

@@ -1,14 +1,14 @@
/** /**
* @file env.cpp * @file config.cpp
* @author Lewis Price (lewis.e.price@outlook.com) * @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 * @version 1.0.0
* @date 2026-06-09 * @date 2026-06-09
* *
* @copyright Copyright (c) 2026 * @copyright Copyright (c) 2026
* *
*/ */
#include "env.hpp" #include "config.hpp"
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#include <cstdlib> #include <cstdlib>
@@ -25,21 +25,21 @@ static int set_env_variable(const std::string& key, const std::string& value) {
#endif #endif
} }
bool check_for_env() { bool check_for_config() {
const std::string env_path = ".env"; const std::string config_path = "lumberjack.config";
// Returns true only if .env exists on disk and is a file (not a directory) // Returns true only if lumberjack.config exists on disk and is a file (not a directory)
return std::filesystem::exists(env_path) && std::filesystem::is_regular_file(env_path); return std::filesystem::exists(config_path) && std::filesystem::is_regular_file(config_path);
} }
void make_env() { void make_config() {
const std::string env_path = ".env"; const std::string config_path = "lumberjack.config";
if (std::filesystem::exists(env_path)) { if (std::filesystem::exists(config_path)) {
return; return;
} }
std::ofstream file(env_path); std::ofstream file(config_path);
if (!file.is_open()) { 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; return;
} }
@@ -47,17 +47,17 @@ void make_env() {
file << "# ParseLogCLI Global Environment Configuration\n"; file << "# ParseLogCLI Global Environment Configuration\n";
file << "# Generated on 2026-06-09\n\n"; file << "# Generated on 2026-06-09\n\n";
file << "# Path to the MaxMind GeoLite2 City Database binary\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 << "# 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(); file.close();
} }
void load_env_file(const std::string& env_path) { void load_config_file(const std::string& config_path) {
std::ifstream file(env_path); std::ifstream file(config_path);
if (!file.is_open()) { 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; return;
} }

37
config_reader/config.hpp Normal file
View File

@@ -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 <string>
/**
* @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);

View File

@@ -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 <string>
/**
* @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);

8
lumberjack.config Normal file
View File

@@ -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

248
main.cpp
View File

@@ -2,7 +2,7 @@
* @file main.cpp * @file main.cpp
* @author Lewis Price (lewis.e.price@outlook.com) * @author Lewis Price (lewis.e.price@outlook.com)
* @brief The main run file for LumberJack TUI * @brief The main run file for LumberJack TUI
* @version 1.0.0 * @version 1.0.1
* @date 2026-06-09 * @date 2026-06-09
* *
* @copyright Copyright (c) 2026 * @copyright Copyright (c) 2026
@@ -13,13 +13,15 @@
#include <string> #include <string>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <filesystem>
#include <regex>
#include <ftxui/component/component.hpp> #include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp> #include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp> #include <ftxui/dom/elements.hpp>
#include "log_parsing/log_parsing.hpp" #include "log_parsing/log_parsing.hpp"
#include "env_reader/env.hpp" #include "config_reader/config.hpp"
int main() { int main() {
using namespace ftxui; using namespace ftxui;
@@ -27,45 +29,125 @@ int main() {
// ── 1. App State ───────────────────────────────────────────────────────── // ── 1. App State ─────────────────────────────────────────────────────────
bool setup_complete = false; bool setup_complete = false;
std::string log_file_path = ""; std::string log_file_path = "";
std::string mmdb_path = "ip_data/GeoLite2-City.mmdb"; std::string mmdb_path = "";
std::string search_query = ""; std::string search_query = "";
if (check_for_env()) { if (check_for_config()) {
load_env_file(".env"); load_config_file("lumberjack.config");
// Safely extract environment strings // Safely extract environment strings
char* env_log = std::getenv("LOG_PATH"); char* config_log = std::getenv("LOG_PATH");
char* env_db = std::getenv("DB_PATH"); char* config_db = std::getenv("DB_PATH");
if (env_log) log_file_path = env_log; if (config_log) log_file_path = config_log;
if (env_db) mmdb_path = env_db; if (config_db) mmdb_path = config_db;
} }
std::vector<Entry> active_logs; std::vector<Entry> active_logs;
int selected_row = 0; int selected_row = 0;
// Order: IP | GEOLOCATION | STATUS | USER-AGENT // Order: IP | GEOLOCATION | DATE/TIME | STATUS | USER-AGENT
std::array<int, 4> col_widths = {20, 35, 8, 26}; std::array<int, 5> col_widths = {18, 38, 30, 8, 40};
constexpr int COL_MIN_WIDTH = 6; constexpr int COL_MIN_WIDTH = 6;
constexpr int COL_MAX_WIDTH = 60; constexpr int COL_MAX_WIDTH = 60;
// Which column is currently targeted by Shift+Arrow resize. // Which column is currently targeted by Shift+Arrow resize.
// -1 means none highlighted.
int resize_col = 0; 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 ─────────────────────────────────────────────────── // ── 2. Filtering helper ───────────────────────────────────────────────────
// Returns the filtered subset and resets selected_row if out-of-bounds. // Returns the filtered subset and resets selected_row if out-of-bounds.
auto get_filtered = [&]() -> std::vector<Entry> { auto get_filtered = [&]() -> std::vector<Entry> {
std::vector<Entry> out; std::vector<Entry> 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) { 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; std::string geo = log.location.city + ", " + log.location.country;
if (search_query.empty() || if (clean_search.empty() ||
log.ip.find(search_query) != std::string::npos || log.ip.find(clean_search) != std::string::npos ||
log.status.find(search_query) != std::string::npos || log.status.find(clean_search) != std::string::npos ||
log.request.find(search_query) != std::string::npos || log.request.find(clean_search) != std::string::npos ||
geo.find(search_query) != std::string::npos) { log.timestamp.find(clean_search) != std::string::npos ||
geo.find(clean_search) != std::string::npos) {
out.push_back(log); 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()) if (!out.empty() && selected_row >= (int)out.size())
selected_row = (int)out.size() - 1; selected_row = (int)out.size() - 1;
return out; return out;
@@ -75,9 +157,7 @@ int main() {
auto run_log_parsing = [&]() { auto run_log_parsing = [&]() {
setup_complete = true; setup_complete = true;
//.env file check //lumberjack.config file check
if (!check_for_env()) {
// Update process environment variables first so make_env() saves them correctly
#if defined(_WIN32) #if defined(_WIN32)
_putenv_s("LOG_PATH", log_file_path.c_str()); _putenv_s("LOG_PATH", log_file_path.c_str());
_putenv_s("DB_PATH", mmdb_path.c_str()); _putenv_s("DB_PATH", mmdb_path.c_str());
@@ -85,7 +165,10 @@ int main() {
setenv("LOG_PATH", log_file_path.c_str(), 1); setenv("LOG_PATH", log_file_path.c_str(), 1);
setenv("DB_PATH", mmdb_path.c_str(), 1); setenv("DB_PATH", mmdb_path.c_str(), 1);
#endif #endif
make_env();
// Generate config if it was missing completely
if (!check_for_config()) {
make_config();
} }
p_logs parsed_logs(log_file_path); p_logs parsed_logs(log_file_path);
@@ -116,11 +199,15 @@ int main() {
} }
}; };
//Checks for log file and mmdb path // Replace the old if statement on line 105 with this safety check:
if (!log_file_path.empty() && !mmdb_path.empty()) { if (check_for_config() && std::filesystem::exists(log_file_path) && std::filesystem::exists(mmdb_path)) {
run_log_parsing(); 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 ──────────────────────────────────────────── // ── 4. Setup-screen components ────────────────────────────────────────────
Component input_log = Input(&log_file_path, "Enter path to log file..."); Component input_log = Input(&log_file_path, "Enter path to log file...");
Component input_mmdb = Input(&mmdb_path, "Enter path to MaxMind MMDB..."); Component input_mmdb = Input(&mmdb_path, "Enter path to MaxMind MMDB...");
@@ -141,7 +228,7 @@ int main() {
if (!setup_complete) { if (!setup_complete) {
// ── VIEW 1: Configuration ───────────────────────────────────────── // ── VIEW 1: Configuration ─────────────────────────────────────────
return vbox({ return vbox({
text(" ParseLogCLI Configuration ") | bold | color(Color::Blue) | border, text(" LumberJack Configuration ") | bold | color(Color::Blue) | border,
separator(), separator(),
vbox({ vbox({
hbox({ text(" Log File Path: ") | bold | color(Color::Cyan), input_log->Render() }), hbox({ text(" Log File Path: ") | bold | color(Color::Cyan), input_log->Render() }),
@@ -157,32 +244,65 @@ int main() {
auto filtered_logs = get_filtered(); auto filtered_logs = get_filtered();
std::vector<std::vector<Element>> grid_matrix; std::vector<std::vector<Element>> grid_matrix;
// Column headers // Interactive sorting headers
auto header_cell = [&](int col_idx, const std::string& label) -> Element { auto header_cell = [&](int col_idx, const std::string& label) -> Element {
bool active = (col_idx == resize_col); bool is_resize_target = (col_idx == resize_col);
std::string display = active ? label + "" : label; bool is_sort_target = (col_idx == sort_column);
Element e = text(display)
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 | bold
| color(active ? Color::Yellow : Color::Cyan) | color(is_resize_target ? Color::Yellow : (is_sort_target ? Color::Green : Color::Cyan))
| size(WIDTH, EQUAL, col_widths[col_idx]); | size(WIDTH, EQUAL, col_widths[col_idx]);
if (active) e = e | underlined;
return e; 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({ grid_matrix.push_back({
header_cell(0, " IP ADDRESS"), header_cell(0, " IP ADDRESS"),
header_cell(1, "GEOLOCATION"), header_cell(1, "GEOLOCATION"),
header_cell(2, "STATUS"), header_cell(2, "DATE/TIME"),
header_cell(3, "CLIENT USER-AGENT"), header_cell(3, "STATUS"),
header_cell(4, "CLIENT USER-AGENT"),
text("HTTP REQUEST") | bold | color(Color::Cyan) | flex, text("HTTP REQUEST") | bold | color(Color::Cyan) | flex,
}); });
// Data rows // Data rows
for (int i = 0; i < (int)filtered_logs.size(); ++i) { for (int i = 0; i < (int)filtered_logs.size(); ++i) {
const auto& log = filtered_logs[i]; const auto& log = filtered_logs[i];
std::string geo = log.location.city.empty() std::string geo = "";
? log.location.country if (!log.location.city.empty()) geo += log.location.city + ", ";
: log.location.city + ", " + log.location.country; if (!log.location.subdivision.empty()) geo += log.location.subdivision + ", ";
geo += log.location.country;
bool sel = (i == selected_row); bool sel = (i == selected_row);
@@ -195,20 +315,22 @@ int main() {
auto ip_cell = text(" " + log.ip) | size(WIDTH, EQUAL, col_widths[0]); auto ip_cell = text(" " + log.ip) | size(WIDTH, EQUAL, col_widths[0]);
auto geo_cell = text(geo) | size(WIDTH, EQUAL, col_widths[1]); auto geo_cell = text(geo) | size(WIDTH, EQUAL, col_widths[1]);
auto status_cell = text(log.status) | size(WIDTH, EQUAL, col_widths[2]) 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); | color(sel ? Color::Cyan : status_color);
auto agent_cell = text(log.browser + " (" + log.os + ")") | size(WIDTH, EQUAL, col_widths[3]); auto agent_cell = text(log.browser + " (" + log.os + ")") | size(WIDTH, EQUAL, col_widths[4]);
auto request_cell = text(log.request) | flex; auto request_cell = text(log.request) | flex;
if (sel) { if (sel) {
ip_cell = ip_cell | inverted | color(Color::Cyan) | focus; ip_cell = ip_cell | inverted | color(Color::Cyan) | focus;
geo_cell = geo_cell | inverted | color(Color::Cyan); geo_cell = geo_cell | inverted | color(Color::Cyan);
time_cell = time_cell | inverted | color(Color::Cyan);
status_cell = status_cell | inverted | color(Color::Cyan); status_cell = status_cell | inverted | color(Color::Cyan);
agent_cell = agent_cell | inverted | color(Color::Cyan); agent_cell = agent_cell | inverted | color(Color::Cyan);
request_cell = request_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()) { if (filtered_logs.empty()) {
@@ -222,12 +344,13 @@ int main() {
std::string resize_hint = std::string resize_hint =
" Resize columns: Shift+← / Shift+→ " " Resize columns: Shift+← / Shift+→ "
" Switch column: ← / → " "│ Switch Column Focus: ← / → "
" Scroll: ↑↓ or wheel " "Click Header label to sort "
" q / esc = quit "; "Filter Syntax: date:09/Jun hour:12 "
"│ q = quit ";
return vbox({ return vbox({
text(" ParseLogCLI Live Monitor ") | bold | color(Color::Blue) | border, text(" LumberJack Live Monitor ") | bold | color(Color::Blue) | border,
vbox({ vbox({
hbox({ text(" DATA SOURCE: ") | bold | color(Color::Green), text(log_file_path) }), hbox({ text(" DATA SOURCE: ") | bold | color(Color::Green), text(log_file_path) }),
@@ -261,21 +384,36 @@ int main() {
if (!setup_complete) return false; if (!setup_complete) return false;
// Mouse wheel scrolling // Mouse event handler for Scrolling & Column Header Clicking
if (event.is_mouse()) { if (event.is_mouse()) {
auto& m = event.mouse(); auto& m = event.mouse();
auto filtered_size = [&]() {
int n = 0; // Interactive Column Header Mouse Sorting Trigger
for (const auto& log : active_logs) { // header_y is computed in the renderer each frame, so this always
std::string geo = log.location.city + ", " + log.location.country; // targets the actual rendered row regardless of layout changes.
if (search_query.empty() || if (m.button == Mouse::Left && m.motion == Mouse::Pressed) {
log.ip.find(search_query) != std::string::npos || if (m.y == header_y) {
log.status.find(search_query) != std::string::npos || int current_x_bound = 0;
log.request.find(search_query) != std::string::npos || for (int col = 0; col < (int)col_widths.size(); ++col) {
geo.find(search_query) != std::string::npos) current_x_bound += col_widths[col];
++n; 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 n; return true;
}
}
}
}
auto filtered_size = [&]() {
return (int)get_filtered().size();
}; };
if (m.button == Mouse::WheelDown) { if (m.button == Mouse::WheelDown) {