diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9593a44..364a486 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,8 @@ "Read(//tmp/**)", "Bash(git add:*)", "Bash(git commit:*)", - "Read(//usr/lib/crystal/**)" + "Read(//usr/lib/crystal/**)", + "WebFetch(domain:github.com)" ], "deny": [], "ask": [] diff --git a/README.md b/README.md index 5414c1d..ea49afb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Key features include: * Real-time log viewing (with an optional auto-refresh). * Filtering by unit, tag, time range, and a general search query. +* **Configurable timezone support** - Display timestamps in your local timezone or any timezone you prefer. * **AI-powered log explanations** - Get intelligent analysis of log errors using LLMs (requires API key). * A dynamic user interface powered by HTMX for a smooth experience. * Embedded assets (HTML, favicon) for easy deployment as a single binary. @@ -34,6 +35,37 @@ Grafito includes an optional AI feature that provides intelligent explanations f The AI feature sends ±5 lines of log context around the selected entry to analyze patterns, suggest solutions, and explain complex errors. The feature is completely optional - Grafito works perfectly without it, and the AI button only appears when the API key is configured. +### Timezone Configuration + +Grafito displays timestamps in your local timezone by default, but you can configure it to use any timezone you prefer. This solves the issue of having to mentally convert UTC timestamps to your local time. + +**To configure timezone:** + +1. **Command line option**: + ```bash + ./bin/grafito --timezone America/New_York + ./bin/grafito --timezone Europe/London + ./bin/grafito --timezone GMT+5 + ./bin/grafito --timezone local # Default behavior + ``` + +2. **Environment variable**: + ```bash + export GRAFITO_TIMEZONE="America/New_York" + ./bin/grafito + ``` + +3. **In systemd service**: + ```ini + Environment="GRAFITO_TIMEZONE=America/New_York" + ``` + +**Supported timezone formats:** +- **IANA timezone names**: `America/New_York`, `Europe/London`, `Asia/Tokyo`, etc. +- **GMT offsets**: `GMT+5`, `GMT-3`, `GMT+5:30` (supports hours and minutes) +- **Special values**: `local` (system timezone), `utc` (UTC timezone) + +By default, Grafito uses your system's local timezone, so most users won't need to configure anything unless they want to use a different timezone. ![image](https://github.com/user-attachments/assets/1042269f-3c34-46d3-ad45-c9a0ee250c82) diff --git a/spec/timezone_spec.cr b/spec/timezone_spec.cr new file mode 100644 index 0000000..27db697 --- /dev/null +++ b/spec/timezone_spec.cr @@ -0,0 +1,127 @@ +require "../src/journalctl" +require "../src/grafito" +require "spec" + +describe "Timezone Support" do + describe "LogEntry#formatted_timestamp_with_timezone" do + it "formats timestamp with local timezone by default" do + # Create a time in UTC for consistent testing + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set timezone to local + Grafito.timezone = "local" + + # Should return a formatted timestamp (exact value depends on system timezone) + result = entry.formatted_timestamp_with_timezone + result.should match(/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) # MM-DD HH:MM:SS format + end + + it "formats timestamp with UTC timezone" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set timezone to UTC + Grafito.timezone = "utc" + + result = entry.formatted_timestamp_with_timezone + result.should eq("11-17 14:30:00") # Should match UTC time + end + + it "formats timestamp with GMT offset" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set timezone to GMT+5 (5 hours ahead of UTC) + Grafito.timezone = "GMT+5" + + result = entry.formatted_timestamp_with_timezone + result.should eq("11-17 19:30:00") # UTC + 5 hours = 19:30 + end + + it "formats timestamp with negative GMT offset" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set timezone to GMT-3 (3 hours behind UTC) + Grafito.timezone = "GMT-3" + + result = entry.formatted_timestamp_with_timezone + result.should eq("11-17 11:30:00") # UTC - 3 hours = 11:30 + end + + it "formats timestamp with custom format" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + Grafito.timezone = "utc" + + # Test custom format + result = entry.formatted_timestamp_with_timezone("%Y-%m-%d %H:%M:%S") + result.should eq("2023-11-17 14:30:00") + end + + it "handles invalid timezone gracefully" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set invalid timezone + Grafito.timezone = "Invalid/Timezone" + + # Should fall back to local time without crashing + result = entry.formatted_timestamp_with_timezone + result.should match(/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + end + + it "handles GMT offset with minutes" do + utc_time = Time.utc(2023, 11, 17, 14, 30, 0) + entry = Journalctl::LogEntry.new( + timestamp: utc_time, + message_raw: "Test message", + raw_priority_val: "6", + internal_unit_name: "test.service" + ) + + # Set timezone to GMT+5:30 (5 hours 30 minutes ahead of UTC) + Grafito.timezone = "GMT+5:30" + + result = entry.formatted_timestamp_with_timezone + result.should eq("11-17 20:00:00") # UTC + 5:30 hours = 20:00 + end + end + + # Reset timezone after tests + after_all do + Grafito.timezone = "local" + end +end diff --git a/src/grafito.cr b/src/grafito.cr index 3329cb7..2637e56 100644 --- a/src/grafito.cr +++ b/src/grafito.cr @@ -30,6 +30,9 @@ module Grafito # AI feature flag - when true, AI features are enabled class_property? ai_enabled : Bool = false + # Timezone configuration for timestamp display + class_property timezone : String = "local" + # ## The `/logs` endpoint # # Exposes the Journalctl wrapper via a REST API. diff --git a/src/grafito_helpers.cr b/src/grafito_helpers.cr index e93b909..116e26c 100644 --- a/src/grafito_helpers.cr +++ b/src/grafito_helpers.cr @@ -163,8 +163,8 @@ module Grafito tr(class: row_classes.join(" ")) do if show_timestamp td(style: "white-space: nowrap; min-width: 14ch;") do - # Using a more compact timestamp format: MM-DD HH:MM:SS - text entry.timestamp.to_s("%m-%d %H:%M:%S") + # Using timezone-aware timestamp format: MM-DD HH:MM:SS + text entry.formatted_timestamp_with_timezone("%m-%d %H:%M:%S") end end if show_hostname diff --git a/src/journalctl.cr b/src/journalctl.cr index abbb4b5..c0300ad 100644 --- a/src/journalctl.cr +++ b/src/journalctl.cr @@ -152,6 +152,46 @@ class Journalctl @timestamp.to_s(format) # @timestamp is now a Time object end + # Converts the timestamp to a formatted string using the configured timezone + def formatted_timestamp_with_timezone(format = "%m-%d %H:%M:%S") : String + time_in_timezone = convert_to_timezone(@timestamp) + time_in_timezone.to_s(format) + end + + # Converts a Time object to the configured timezone + private def convert_to_timezone(time : Time) : Time + timezone_config = Grafito.timezone + + case timezone_config.downcase + when "local" + time.to_local + when "utc" + time.to_utc + else + # Try to parse as timezone name (IANA) or GMT offset + begin + # Try IANA timezone name first + Time.local(time.year, time.month, time.day, time.hour, time.minute, time.second, nanosecond: time.nanosecond, location: Time::Location.load(timezone_config)) + rescue ex + # Try GMT offset format (e.g., GMT+5, GMT-3:30) + if timezone_config.match(/^GMT([+-]\d+)(?::(\d+))?$/i) + sign = $1[0] + hours = $1[1..].to_i + minutes = $2?.try(&.to_i) || 0 + + offset_seconds = (hours * 3600 + minutes * 60) + offset_seconds = -offset_seconds if sign == '-' + + time + offset_seconds.seconds + else + # Fallback to local time if timezone is invalid + Grafito::Log.warn { "Invalid timezone '#{timezone_config}', falling back to local time" } + time.to_local + end + end + end + end + # Converts the numeric priority string to its textual representation. def formatted_priority : String case self.priority # Use the getter to ensure defaulting/cleaning diff --git a/src/main.cr b/src/main.cr index 80e8da0..3c4ee7c 100644 --- a/src/main.cr +++ b/src/main.cr @@ -65,6 +65,7 @@ Options: -b ADDRESS, --bind=ADDRESS Address to bind to [default: 127.0.0.1]. -U UNITS, --units=UNITS Comma-separated list of systemd units to show (restricts access). --log-level=LEVEL Set log level (debug, info, warn, error, fatal) [default: info]. + -t TIMEZONE, --timezone=TIMEZONE Timezone for timestamps (e.g., America/New_York, Europe/London, GMT+5, local) [default: local]. -h --help Show this screen. --version Show version. @@ -72,6 +73,7 @@ Environment variables: GRAFITO_AUTH_USER Username for basic authentication (if set, GRAFITO_AUTH_PASS must also be set). GRAFITO_AUTH_PASS Password for basic authentication (if set, GRAFITO_AUTH_USER must also be set). LOG_LEVEL Log level (debug, info, warn, error, fatal) [default: info]. + GRAFITO_TIMEZONE Timezone for timestamps (e.g., America/New_York, Europe/London, GMT+5, local) [default: local]. DOCOPT # ## The Assets class @@ -121,6 +123,11 @@ def main Grafito::Log.info { "Restricting to units: #{units.join(", ")}" } end + # Parse timezone configuration + timezone = args["--timezone"]?.as(String?) || ENV["GRAFITO_TIMEZONE"]? || "local" + Grafito.timezone = timezone + Grafito::Log.info { "Using timezone: #{timezone}" } + # Log at debug level. Probably worth making it configurable. Log.setup(:debug) # Or use Log.setup_from_env for more flexibility