Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down
127 changes: 127 additions & 0 deletions spec/timezone_spec.cr
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/grafito.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/grafito_helpers.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/journalctl.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/main.cr
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,15 @@ 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.

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
Expand Down Expand Up @@ -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
Expand Down