require "kemal" # For HTTP::Server::Context and HTML
require "json"
require "./journalctl"
require "./timeline"
require "html_builder"
Regex is part of Crystal core, no explicit require needed for it.
module Grafito
Helper to get an optional query parameter, treating empty strings as nil.
private def optional_query_param(env : HTTP::Server::Context, key : String) : String?
param = env.params.query[key]?
param.nil? || param.strip.empty? ? nil : param
end
Generates attributes for sortable table headers. Returns a NamedTuple with text, hx_vals (JSON string), and key_name.
private def _generate_header_attributes(
column_key_name : String,
display_text : String,
current_sort_by : String?,
current_sort_order : String?,
) : NamedTuple(text: String, hx_vals: String, key_name: String)
sort_indicator = ""
next_sort_order_for_click = "asc" # Default next sort is ascending
if current_sort_by == column_key_name # This column is currently being sorted
case current_sort_order
when "asc" # Up arrow for ascending
sort_indicator = %q( <span class="material-icons" aria-hidden="true" style="font-size: inherit; vertical-align: middle;">arrow_upward</span>)
next_sort_order_for_click = "desc" # Next click will be descending
when "desc" # Down arrow for descending
sort_indicator = %q( <span class="material-icons" aria-hidden="true" style="font-size: inherit; vertical-align: middle;">arrow_downward</span>)
next_sort_order_for_click = "asc" # Next click will be ascending
else # current_sort_order is nil or unexpected, default to ascending for next click
next_sort_order_for_click = "asc"
end
elsif current_sort_by.nil? && column_key_name == "timestamp"
No specific sort requested by user, and this is the timestamp column. Default sort is by timestamp, descending.
sort_indicator = %q( <span class="material-icons" aria-hidden="true" style="font-size: inherit; vertical-align: middle;">arrow_downward</span>)
If user clicks on timestamp, the next sort should be ascending.
next_sort_order_for_click = "asc"
end
vals_json = %({"sort_by": "#{column_key_name}", "sort_order": "#{next_sort_order_for_click}"})
{
text: display_text + sort_indicator,
hx_vals: vals_json,
key_name: column_key_name,
}
end
Generates a plain text representation of log entries.
def _generate_text_log_output(logs : Array(Journalctl::LogEntry)) : String
String.build do |str|
if logs.empty?
str << "No log entries found.\n"
else
logs.each do |entry|
str << entry.formatted_timestamp
str << " [#{entry.hostname}]" # Add hostname to text output
str << " [#{entry.unit}]" # Always include unit in text output
str << " (#{entry.formatted_priority}) "
str << entry.message << '\n'
end
end
end
end
Generates an HTML representation of log entries. ameba:disable Metrics/CyclomaticComplexity
private def html_log_output(
logs : Array(Journalctl::LogEntry),
current_sort_by : String?,
current_sort_order : String?,
search_query : String?,
chart : Bool = true,
highlight_cursor : String? = nil,
Column visibility flags - determined by the route handler from query parameters
show_timestamp : Bool = true,
show_hostname : Bool = true,
show_unit : Bool = true,
show_priority : Bool = true,
show_message : Bool = true,
) : String
HTML.build do
if chart
Generate and add the timeline SVG only if there are logs
if !logs.empty?
timeline_data = Timeline.generate_frequency_timeline(logs)
svg_timeline_html = Timeline.generate_svg_timeline(timeline_data)
div(style: "margin-bottom: 1em;") do
html svg_timeline_html
end
end
end
Display results count
count_message_inner_text = if logs.size == 5000
"showing first 5000 entries"
elsif logs.size == 1
"showing 1 entry"
else
"showing #{logs.size} entries" # Handles 0 and other counts
end
Prepare the styled count message for the header
styled_count_span = %Q(<span style="font-style: italic; font-size: 0.9em; color: var(--pico-muted-color); margin-left: 0.5em;">(#{count_message_inner_text})</span>)
message_header_text = "Message #{styled_count_span}"
headers_to_display = [] of NamedTuple(text: String, hx_vals: String, key_name: String)
if show_timestamp
headers_to_display << _generate_header_attributes("timestamp", "Timestamp", current_sort_by, current_sort_order)
end
if show_hostname
headers_to_display << _generate_header_attributes("hostname", "Hostname", current_sort_by, current_sort_order)
end
if show_unit
headers_to_display << _generate_header_attributes("unit", "Unit", current_sort_by, current_sort_order)
end
if show_priority
headers_to_display << _generate_header_attributes("priority", "Priority", current_sort_by, current_sort_order)
end
if show_message
headers_to_display << _generate_header_attributes("message", message_header_text, current_sort_by, current_sort_order)
end
table(class: "striped") do
thead do
tr do
headers_to_display.each do |header|
All remaining headers are sortable and will use this block
th({
"style" => "cursor: pointer; vertical-align: middle;",
"hx-get" => "/logs",
"hx-vals" => header[:hx_vals],
"hx-include" => "#search-box, #unit-filter, #tag-filter, #priority-filter, #time-range-filter, #live-view",
"hx-target" => "#results",
"hx-indicator" => "#loading-spinner",
}) do
html header[:text]
end
end
end
end
tbody do
if logs.empty?
tr do
td(colspan: Math.max(1, headers_to_display.size).to_s, style: "text-align: center; padding: 1em;") do
text "No log entries found."
end
end
else
logs.each do |entry|
row_classes = ["log-row-hover-actions", "priority-#{entry.priority.to_i}"]
entry_cursor = entry.data["__CURSOR"]?
if highlight_cursor && entry_cursor == highlight_cursor
row_classes << "highlighted-row"
end
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")
end
end
if show_hostname
td do
Make the hostname clickable to set the filter
display_hostname = HTML.escape(entry.hostname)
js_arg_hostname = entry.hostname.to_json # Ensures proper JS string escaping
a(href: "#", onclick: "return setHostnameFilterAndTrigger(#{js_arg_hostname});") do
text display_hostname
end
end
end
if show_unit
td(class: "log-unit-cell") do
Make the unit name clickable to set the filter
display_unit_name = HTML.escape(entry.unit)
JSON.generate creates a valid JavaScript string literal, e.g., ""my-unit""
js_arg_unit_name = entry.unit.to_json
a(href: "#", onclick: "return setUnitFilterAndTrigger(#{js_arg_unit_name});") do
text display_unit_name
end
end
end
if show_priority
td do
text HTML.escape(entry.formatted_priority)
end
end
if show_message
escaped_message = HTML.escape(entry.message)
highlighted_message = if search_query && !search_query.strip.empty?
pattern = Regex.escape(search_query)
escaped_message.gsub(/#{pattern}/i, "<mark>\\0</mark>")
else
escaped_message
end
td(class: "log-message-cell") do
html highlighted_message
end
end
if entry_cursor
Details button
td(class: "hover-action-cell", style: "width: 1%; white-space: nowrap; text-align: center; padding: 0.1em;") do
button({
"class" => "round-button",
"title" => "View full details for this log entry",
"hx-get" => "details?#{URI::Params.encode({"cursor" => entry_cursor})}",
"hx-target" => "#details-dialog-content", # Target the content area within the modal
"hx-swap" => "innerHTML",
"hx-on:htmx:before-request" => "document.getElementById('details-dialog-content').innerHTML = document.getElementById('details-dialog-loading-spinner-template').innerHTML;",
"hx-on:htmx:after-request" => "if(event.detail.successful) { document.getElementById('details-dialog').showModal(); } else { document.getElementById('details-dialog-content').innerHTML = '<p class=\\'error\\'>Failed to load details. Status: ' + event.detail.xhr.status + ' ' + event.detail.xhr.statusText + '</p>'; document.getElementById('details-dialog').showModal(); }",
}) do
span(class: "material-icons", style: "vertical-align: middle;") do
text "search"
end
end
end
Context button
td(class: "hover-action-cell", style: "width: 1%; white-space: nowrap; text-align: center; padding: 0.1em;") do
button({
"class" => "round-button",
"title" => "View context for this log entry (e.g., 5 before & 5 after)",
"hx-get" => "context?#{URI::Params.encode({"cursor" => entry_cursor})}",
"hx-target" => "#details-dialog-content", # Target the content area within the modal
"hx-swap" => "innerHTML",
"hx-on:htmx:before-request" => "document.getElementById('details-dialog-content').innerHTML = document.getElementById('details-dialog-loading-spinner-template').innerHTML;",
"hx-on:htmx:after-request" => "if(event.detail.successful) { document.getElementById('details-dialog').showModal(); } else { document.getElementById('details-dialog-content').innerHTML = '<p class=\\'error\\'>Failed to load details. Status: ' + event.detail.xhr.status + ' ' + event.detail.xhr.statusText + '</p>'; document.getElementById('details-dialog').showModal(); }",
}) do
span(class: "material-icons", style: "vertical-align: middle;") do
text "history"
end
end
end
end
end
end
end
end
end
end
end
end