llm_logs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +148 -0
  3. data/Rakefile +11 -0
  4. data/app/controllers/llm_logs/application_controller.rb +5 -0
  5. data/app/controllers/llm_logs/prompt_versions_controller.rb +66 -0
  6. data/app/controllers/llm_logs/prompts_controller.rb +91 -0
  7. data/app/controllers/llm_logs/spans_controller.rb +8 -0
  8. data/app/controllers/llm_logs/traces_controller.rb +19 -0
  9. data/app/helpers/llm_logs/formatting_helper.rb +34 -0
  10. data/app/models/llm_logs/application_record.rb +5 -0
  11. data/app/models/llm_logs/prompt.rb +54 -0
  12. data/app/models/llm_logs/prompt_version.rb +31 -0
  13. data/app/models/llm_logs/span.rb +38 -0
  14. data/app/models/llm_logs/trace.rb +46 -0
  15. data/app/views/kaminari/tailwind/_first_page.html.erb +5 -0
  16. data/app/views/kaminari/tailwind/_gap.html.erb +3 -0
  17. data/app/views/kaminari/tailwind/_last_page.html.erb +5 -0
  18. data/app/views/kaminari/tailwind/_next_page.html.erb +5 -0
  19. data/app/views/kaminari/tailwind/_page.html.erb +9 -0
  20. data/app/views/kaminari/tailwind/_paginator.html.erb +17 -0
  21. data/app/views/kaminari/tailwind/_prev_page.html.erb +5 -0
  22. data/app/views/layouts/llm_logs/application.html.erb +52 -0
  23. data/app/views/llm_logs/prompt_versions/compare.html.erb +41 -0
  24. data/app/views/llm_logs/prompt_versions/index.html.erb +92 -0
  25. data/app/views/llm_logs/prompt_versions/show.html.erb +48 -0
  26. data/app/views/llm_logs/prompts/_form.html.erb +94 -0
  27. data/app/views/llm_logs/prompts/edit.html.erb +6 -0
  28. data/app/views/llm_logs/prompts/index.html.erb +43 -0
  29. data/app/views/llm_logs/prompts/new.html.erb +5 -0
  30. data/app/views/llm_logs/prompts/show.html.erb +107 -0
  31. data/app/views/llm_logs/spans/show.html.erb +99 -0
  32. data/app/views/llm_logs/traces/_span_node.html.erb +66 -0
  33. data/app/views/llm_logs/traces/index.html.erb +78 -0
  34. data/app/views/llm_logs/traces/show.html.erb +79 -0
  35. data/config/routes.rb +18 -0
  36. data/db/migrate/001_create_llm_logs_traces.rb +22 -0
  37. data/db/migrate/002_create_llm_logs_spans.rb +31 -0
  38. data/db/migrate/003_create_llm_logs_prompts.rb +13 -0
  39. data/db/migrate/004_create_llm_logs_prompt_versions.rb +18 -0
  40. data/db/migrate/005_add_prompt_version_to_traces.rb +7 -0
  41. data/lib/generators/llm_logs/install_generator.rb +17 -0
  42. data/lib/generators/llm_logs/templates/initializer.rb +10 -0
  43. data/lib/llm_logs/engine.rb +14 -0
  44. data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +107 -0
  45. data/lib/llm_logs/prompt_renderer.rb +17 -0
  46. data/lib/llm_logs/tracer.rb +67 -0
  47. data/lib/llm_logs/version.rb +3 -0
  48. data/lib/llm_logs.rb +23 -0
  49. metadata +145 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25fbd9f60530d337d2cfaa0bc4cec00afc6a3c1c802c771d4109460fac64dfea
4
+ data.tar.gz: 5e406b0764ebe2575e5b19da0b5d73934f46e63aff3f1572f385aa48d0839b02
5
+ SHA512:
6
+ metadata.gz: 3ad9d74c2d111ad34e4d132d9fc6ab1c818f9c59bc7143bfee9c68a69e4f89349e48c1710412ac54e004aec8dca544abe1fa99fc9f40c31a82c524a452f1c73b
7
+ data.tar.gz: '0854b4994c2156b431f71fbe08b7f36032e53452962c88ad3467c6fed3c165b37adb47111196c2268d2696f827499ad1fa35c3e69695ea04530f05a98ed0edca'
data/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # LlmLogs
2
+
3
+ Mountable Rails engine for LLM call tracing and prompt management. Auto-instruments [ruby_llm](https://github.com/crmne/ruby_llm) to capture every LLM call as a span within a trace, and provides a web UI for browsing logs and editing prompts.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "llm_logs", github: "tonic20/llm_logs"
11
+ ```
12
+
13
+ Run the install generator:
14
+
15
+ ```sh
16
+ bin/rails generate llm_logs:install
17
+ bin/rails db:migrate
18
+ ```
19
+
20
+ Or manually:
21
+
22
+ ```ruby
23
+ # config/routes.rb
24
+ mount LlmLogs::Engine, at: "/llm_logs"
25
+ ```
26
+
27
+ ```ruby
28
+ # config/initializers/llm_logs.rb
29
+ LlmLogs.setup do |config|
30
+ config.enabled = true
31
+ config.auto_instrument = true
32
+ end
33
+ ```
34
+
35
+ ## Tracing
36
+
37
+ Wrap operations in a trace block. All `ruby_llm` calls inside become child spans automatically.
38
+
39
+ ```ruby
40
+ LlmLogs.trace("strategy_analysis", metadata: { strategy_id: 42 }) do
41
+ chat = RubyLLM.chat(model: "anthropic/claude-sonnet-4")
42
+ chat.ask("Analyze this strategy...")
43
+ end
44
+ ```
45
+
46
+ Nested traces are supported:
47
+
48
+ ```ruby
49
+ LlmLogs.trace("full_pipeline") do
50
+ chat.ask("Step 1...")
51
+
52
+ LlmLogs.trace("risk_assessment") do
53
+ chat.ask("What are the risks?")
54
+ end
55
+ end
56
+ ```
57
+
58
+ LLM calls made outside an explicit trace are auto-wrapped in one.
59
+
60
+ ### What Gets Captured
61
+
62
+ Each span records:
63
+
64
+ - Model and provider
65
+ - Input messages and output content
66
+ - Input, output, and cached token counts
67
+ - Duration in milliseconds
68
+ - Error messages (on failure)
69
+ - Custom metadata
70
+
71
+ Tool calls are captured as child spans with tool name and arguments.
72
+
73
+ ## Prompt Management
74
+
75
+ Create prompts with [Mustache](https://mustache.github.io/) templates and auto-versioning.
76
+
77
+ ### Create a Prompt
78
+
79
+ ```ruby
80
+ prompt = LlmLogs::Prompt.create!(
81
+ slug: "strategy-analysis",
82
+ name: "Strategy Analysis"
83
+ )
84
+
85
+ prompt.update_content!(
86
+ messages: [
87
+ { "role" => "system", "content" => "You analyze trading strategies for {{app_name}}." },
88
+ { "role" => "user", "content" => "Analyze {{strategy_name}} on the {{timeframe}} timeframe." }
89
+ ],
90
+ model: "claude-sonnet-4",
91
+ model_params: { "temperature" => 0.3, "max_tokens" => 2048 }
92
+ )
93
+ ```
94
+
95
+ ### Load and Render
96
+
97
+ ```ruby
98
+ prompt = LlmLogs::Prompt.load("strategy-analysis")
99
+ params = prompt.build(
100
+ app_name: "Tradebot",
101
+ strategy_name: "Momentum Alpha",
102
+ timeframe: "4h"
103
+ )
104
+ # => { model: "claude-sonnet-4", messages: [...], temperature: 0.3, max_tokens: 2048 }
105
+ ```
106
+
107
+ ### Versioning
108
+
109
+ Every save creates a new version. Previous versions are never modified.
110
+
111
+ ```ruby
112
+ prompt.update_content!(
113
+ messages: [{ "role" => "user", "content" => "Updated prompt" }],
114
+ changelog: "Simplified the prompt"
115
+ )
116
+
117
+ prompt.current_version # latest
118
+ prompt.version(1) # specific version
119
+ prompt.rollback_to!(1) # creates new version from v1 content
120
+ ```
121
+
122
+ ## Web UI
123
+
124
+ Browse traces and manage prompts at `/llm_logs`.
125
+
126
+ **Traces** — list with filtering by status, drill into hierarchical span trees with collapsible input/output.
127
+
128
+ **Prompts** — CRUD with Mustache template editor, model configuration, and version history.
129
+
130
+ ## Configuration
131
+
132
+ ```ruby
133
+ LlmLogs.setup do |config|
134
+ config.enabled = true # master switch for logging
135
+ config.auto_instrument = true # auto-prepend on RubyLLM::Chat
136
+ config.retention_days = 30 # for future cleanup job
137
+ end
138
+ ```
139
+
140
+ ## Requirements
141
+
142
+ - Rails 8.0+
143
+ - Ruby 3.3+
144
+ - PostgreSQL
145
+
146
+ ## License
147
+
148
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require "rspec/core/rake_task"
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ module LlmLogs
2
+ class ApplicationController < ::ActionController::Base
3
+ layout "llm_logs/application"
4
+ end
5
+ end
@@ -0,0 +1,66 @@
1
+ require "diffy"
2
+
3
+ module LlmLogs
4
+ class PromptVersionsController < ApplicationController
5
+ before_action :set_prompt
6
+
7
+ def index
8
+ @versions = @prompt.versions.order(version_number: :desc)
9
+ end
10
+
11
+ def show
12
+ @version = @prompt.versions.find(params[:id])
13
+ end
14
+
15
+ def restore
16
+ version = @prompt.versions.find(params[:id])
17
+ @prompt.rollback_to!(version.version_number)
18
+ redirect_to prompt_path(@prompt), notice: "Restored to version #{version.version_number}."
19
+ end
20
+
21
+ def destroy
22
+ version = @prompt.versions.find(params[:id])
23
+
24
+ if version == @prompt.current_version
25
+ redirect_to prompt_versions_path(@prompt), alert: "Cannot delete the current active version."
26
+ return
27
+ end
28
+
29
+ version.destroy!
30
+ redirect_to prompt_versions_path(@prompt), notice: "Version #{version.version_number} deleted."
31
+ end
32
+
33
+ def compare
34
+
35
+ if params[:a].blank? || params[:b].blank? || params[:a] == params[:b]
36
+ redirect_to prompt_versions_path(@prompt), alert: "Select two different versions to compare."
37
+ return
38
+ end
39
+
40
+ @version_a = @prompt.versions.find_by(version_number: params[:a])
41
+ @version_b = @prompt.versions.find_by(version_number: params[:b])
42
+
43
+ unless @version_a && @version_b
44
+ redirect_to prompt_versions_path(@prompt), alert: "Version not found."
45
+ return
46
+ end
47
+
48
+ max_messages = [@version_a.messages.size, @version_b.messages.size].max
49
+ @diffs = (0...max_messages).map do |i|
50
+ msg_a = @version_a.messages[i]
51
+ msg_b = @version_b.messages[i]
52
+ role = (msg_a || msg_b)["role"]
53
+ content_a = (msg_a&.dig("content") || "").gsub("\r\n", "\n")
54
+ content_b = (msg_b&.dig("content") || "").gsub("\r\n", "\n")
55
+ diff_html = Diffy::SplitDiff.new(content_a, content_b, format: :html_simple)
56
+ { role: role, left: diff_html.left, right: diff_html.right }
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def set_prompt
63
+ @prompt = Prompt.find(params[:prompt_id])
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,91 @@
1
+ module LlmLogs
2
+ class PromptsController < ApplicationController
3
+ def index
4
+ @prompts = Prompt.order(:name).includes(:versions).page(params[:page]).per(25)
5
+ end
6
+
7
+ def show
8
+ @prompt = Prompt.find(params[:id])
9
+ @current_version = @prompt.current_version
10
+ @versions = @prompt.versions.order(version_number: :desc).limit(5)
11
+ end
12
+
13
+ def new
14
+ @prompt = Prompt.new
15
+ end
16
+
17
+ def create
18
+ @prompt = Prompt.new(prompt_params)
19
+
20
+ if @prompt.save
21
+ if version_params[:messages].present?
22
+ @prompt.update_content!(**version_params)
23
+ end
24
+ redirect_to prompt_path(@prompt), notice: "Prompt created."
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit
31
+ @prompt = Prompt.find(params[:id])
32
+ @current_version = @prompt.current_version
33
+ end
34
+
35
+ def update
36
+ @prompt = Prompt.find(params[:id])
37
+
38
+ if @prompt.update(prompt_params)
39
+ if version_params[:messages].present?
40
+ @prompt.update_content!(**version_params)
41
+ end
42
+ redirect_to prompt_path(@prompt), notice: "Prompt updated."
43
+ else
44
+ render :edit, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ def destroy
49
+ @prompt = Prompt.find(params[:id])
50
+ @prompt.destroy
51
+ redirect_to prompts_path, notice: "Prompt deleted."
52
+ end
53
+
54
+ private
55
+
56
+ def prompt_params
57
+ params.require(:prompt).permit(:slug, :name, :description)
58
+ end
59
+
60
+ def version_params
61
+ raw = params.require(:prompt).permit(:model, :changelog, model_params: {})
62
+ messages = parse_messages
63
+ {
64
+ messages: messages,
65
+ model: raw[:model],
66
+ model_params: coerce_model_params(raw[:model_params]&.to_h || {}),
67
+ changelog: raw[:changelog]
68
+ }.compact_blank
69
+ end
70
+
71
+ def coerce_model_params(params_hash)
72
+ params_hash.each_with_object({}) do |(key, value), result|
73
+ next if value.blank?
74
+
75
+ result[key] = case value
76
+ when /\A\d+\z/ then value.to_i
77
+ when /\A\d+\.\d+\z/ then value.to_f
78
+ else value
79
+ end
80
+ end
81
+ end
82
+
83
+ def parse_messages
84
+ return [] unless params[:prompt][:messages].present?
85
+
86
+ params[:prompt][:messages].values.map do |msg|
87
+ { "role" => msg[:role], "content" => msg[:content] }
88
+ end.reject { |m| m["content"].blank? }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,8 @@
1
+ module LlmLogs
2
+ class SpansController < ApplicationController
3
+ def show
4
+ @trace = Trace.find(params[:trace_id])
5
+ @span = @trace.spans.find(params[:id])
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ module LlmLogs
2
+ class TracesController < ApplicationController
3
+ def index
4
+ @traces = Trace.recent
5
+ @traces = @traces.by_status(params[:status]) if params[:status].present?
6
+ if params[:prompt_version_id].present?
7
+ @traces = @traces.where(prompt_version_id: params[:prompt_version_id])
8
+ @filter_version = PromptVersion.find_by(id: params[:prompt_version_id])
9
+ end
10
+ @traces = @traces.page(params[:page]).per(50)
11
+ end
12
+
13
+ def show
14
+ @trace = Trace.includes(prompt_version: :prompt).find(params[:id])
15
+ @root_spans = @trace.root_spans
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ module LlmLogs
2
+ module FormattingHelper
3
+ def format_duration_ms(ms)
4
+ return "—" if ms.nil?
5
+
6
+ "#{number_with_delimiter(sprintf('%.0f', ms))} ms"
7
+ end
8
+
9
+ def pretty_json(data)
10
+ JSON.pretty_generate(deep_parse_json(data))
11
+ rescue
12
+ data.to_s
13
+ end
14
+
15
+ private
16
+
17
+ def deep_parse_json(obj)
18
+ case obj
19
+ when Hash
20
+ obj.transform_values { |v| deep_parse_json(v) }
21
+ when Array
22
+ obj.map { |v| deep_parse_json(v) }
23
+ when String
24
+ begin
25
+ deep_parse_json(JSON.parse(obj))
26
+ rescue JSON::ParserError
27
+ obj
28
+ end
29
+ else
30
+ obj
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ module LlmLogs
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,54 @@
1
+ module LlmLogs
2
+ class Prompt < ApplicationRecord
3
+ has_many :versions, class_name: "LlmLogs::PromptVersion", dependent: :destroy
4
+
5
+ validates :slug, presence: true, uniqueness: true
6
+ validates :name, presence: true
7
+
8
+ def self.load(slug)
9
+ find_by!(slug: slug)
10
+ end
11
+
12
+ def current_version
13
+ versions.order(version_number: :desc).first
14
+ end
15
+
16
+ def version(number)
17
+ versions.find_by!(version_number: number)
18
+ end
19
+
20
+ def build(variables = {})
21
+ ver = current_version
22
+ raise "No versions exist for prompt '#{slug}'" unless ver
23
+
24
+ trace = LlmLogs::Tracer.current_trace
25
+ if trace && trace.prompt_version_id.nil?
26
+ trace.update_column(:prompt_version_id, ver.id)
27
+ end
28
+
29
+ ver.render(variables)
30
+ end
31
+
32
+ def update_content!(messages:, model: nil, model_params: {}, changelog: nil)
33
+ next_number = (versions.maximum(:version_number) || 0) + 1
34
+
35
+ versions.create!(
36
+ version_number: next_number,
37
+ messages: messages,
38
+ model: model,
39
+ model_params: model_params,
40
+ changelog: changelog
41
+ )
42
+ end
43
+
44
+ def rollback_to!(version_number)
45
+ source = version(version_number)
46
+ update_content!(
47
+ messages: source.messages,
48
+ model: source.model,
49
+ model_params: source.model_params,
50
+ changelog: "Rollback to version #{version_number}"
51
+ )
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,31 @@
1
+ require "mustache"
2
+
3
+ module LlmLogs
4
+ class PromptVersion < ApplicationRecord
5
+ belongs_to :prompt
6
+ has_many :traces, class_name: "LlmLogs::Trace", dependent: :nullify
7
+
8
+ validates :version_number, presence: true, uniqueness: { scope: :prompt_id }
9
+ validates :messages, presence: true
10
+
11
+ def variables
12
+ messages.flat_map { |msg| msg["content"].to_s.scan(/\{\{[#^]?([^\/}]+)\}\}/) }.flatten.uniq.sort
13
+ end
14
+
15
+ def render(variables = {})
16
+ merged = (default_variables || {}).merge(variables.stringify_keys)
17
+
18
+ rendered_messages = messages.map do |msg|
19
+ {
20
+ role: msg["role"],
21
+ content: LlmLogs::PromptRenderer.render(msg["content"], merged)
22
+ }
23
+ end
24
+
25
+ params = { messages: rendered_messages }
26
+ params[:model] = model if model.present?
27
+ params.merge!(model_params.symbolize_keys) if model_params.present?
28
+ params
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ module LlmLogs
2
+ class Span < ApplicationRecord
3
+ belongs_to :trace, counter_cache: true
4
+ belongs_to :parent_span, class_name: "LlmLogs::Span", optional: true
5
+ has_many :child_spans, class_name: "LlmLogs::Span", foreign_key: :parent_span_id, dependent: :nullify
6
+
7
+ validates :name, presence: true
8
+ validates :span_type, presence: true, inclusion: { in: %w[llm tool custom] }
9
+ validates :status, presence: true, inclusion: { in: %w[ok error] }
10
+ validates :started_at, presence: true
11
+
12
+ def finish
13
+ update!(
14
+ completed_at: Time.current,
15
+ duration_ms: (Time.current - started_at) * 1000
16
+ )
17
+
18
+ # Restore parent span as current
19
+ Thread.current[:llm_logs_span] = parent_span
20
+ end
21
+
22
+ def record_response(message)
23
+ self.output = { content: message.content.to_s }
24
+ self.input_tokens = message.input_tokens
25
+ self.output_tokens = message.output_tokens
26
+ self.cached_tokens = message.cached_tokens
27
+ end
28
+
29
+ def record_error(exception)
30
+ self.status = "error"
31
+ self.error_message = "#{exception.class}: #{exception.message}"
32
+ end
33
+
34
+ def set_attribute(key, value)
35
+ self.metadata = (metadata || {}).merge(key => value)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ module LlmLogs
2
+ class Trace < ApplicationRecord
3
+ has_many :spans, dependent: :destroy
4
+ belongs_to :prompt_version, class_name: "LlmLogs::PromptVersion", optional: true
5
+
6
+ validates :name, presence: true
7
+ validates :status, presence: true, inclusion: { in: %w[running completed error] }
8
+ validates :started_at, presence: true
9
+
10
+ scope :recent, -> { order(started_at: :desc) }
11
+ scope :by_status, ->(status) { where(status: status) if status.present? }
12
+
13
+ def complete!
14
+ return if status == "completed"
15
+
16
+ rollup_stats!
17
+ update!(
18
+ status: "completed",
19
+ completed_at: Time.current,
20
+ duration_ms: (Time.current - started_at) * 1000
21
+ )
22
+ end
23
+
24
+ def fail!
25
+ rollup_stats!
26
+ update!(
27
+ status: "error",
28
+ completed_at: Time.current,
29
+ duration_ms: (Time.current - started_at) * 1000
30
+ )
31
+ end
32
+
33
+ def root_spans
34
+ spans.where(parent_span_id: nil).order(:started_at)
35
+ end
36
+
37
+ private
38
+
39
+ def rollup_stats!
40
+ self.total_input_tokens = spans.sum(:input_tokens)
41
+ self.total_output_tokens = spans.sum(:output_tokens)
42
+ self.total_cached_tokens = spans.sum(:cached_tokens)
43
+ self.total_cost = spans.sum(:cost)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ <li>
2
+ <%= link_to url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote do %>
3
+ &laquo;
4
+ <% end %>
5
+ </li>
@@ -0,0 +1,3 @@
1
+ <li>
2
+ <span class="inline-flex items-center justify-center w-8 h-8 text-sm text-gray-500">&hellip;</span>
3
+ </li>
@@ -0,0 +1,5 @@
1
+ <li>
2
+ <%= link_to url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote do %>
3
+ &raquo;
4
+ <% end %>
5
+ </li>
@@ -0,0 +1,5 @@
1
+ <li>
2
+ <%= link_to url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote do %>
3
+ &rsaquo;
4
+ <% end %>
5
+ </li>
@@ -0,0 +1,9 @@
1
+ <% if page.current? %>
2
+ <li>
3
+ <span class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium text-white bg-indigo-600 rounded-md"><%= page %></span>
4
+ </li>
5
+ <% else %>
6
+ <li>
7
+ <%= link_to page, url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote %>
8
+ </li>
9
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%= paginator.render do -%>
2
+ <nav class="flex items-center justify-center mt-6" aria-label="Pagination">
3
+ <ul class="flex items-center space-x-1">
4
+ <%= first_page_tag unless current_page.first? %>
5
+ <%= prev_page_tag unless current_page.first? %>
6
+ <% each_page do |page| -%>
7
+ <% if page.left_outer? || page.right_outer? || page.inside_window? -%>
8
+ <%= page_tag page %>
9
+ <% elsif !page.was_truncated? -%>
10
+ <%= gap_tag %>
11
+ <% end -%>
12
+ <% end -%>
13
+ <%= next_page_tag unless current_page.last? %>
14
+ <%= last_page_tag unless current_page.last? %>
15
+ </ul>
16
+ </nav>
17
+ <% end -%>
@@ -0,0 +1,5 @@
1
+ <li>
2
+ <%= link_to url, class: "inline-flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50", remote: remote do %>
3
+ &lsaquo;
4
+ <% end %>
5
+ </li>