onlylogs 0.1.2

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +311 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/config/onlylogs_manifest.js +2 -0
  5. data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
  6. data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
  7. data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
  8. data/app/assets/images/onlylogs/favicon/favicon.svg +3 -0
  9. data/app/assets/images/onlylogs/favicon/site.webmanifest.erb +21 -0
  10. data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
  11. data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
  12. data/app/assets/images/onlylogs/logo.png +0 -0
  13. data/app/channels/onlylogs/application_cable/channel.rb +11 -0
  14. data/app/channels/onlylogs/logs_channel.rb +181 -0
  15. data/app/controllers/onlylogs/application_controller.rb +22 -0
  16. data/app/controllers/onlylogs/logs_controller.rb +23 -0
  17. data/app/helpers/onlylogs/application_helper.rb +4 -0
  18. data/app/javascript/onlylogs/application.js +1 -0
  19. data/app/javascript/onlylogs/controllers/application.js +9 -0
  20. data/app/javascript/onlylogs/controllers/index.js +11 -0
  21. data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +46 -0
  22. data/app/javascript/onlylogs/controllers/log_streamer_controller.js +432 -0
  23. data/app/javascript/onlylogs/controllers/text_selection_controller.js +90 -0
  24. data/app/jobs/onlylogs/application_job.rb +4 -0
  25. data/app/models/onlylogs/ansi_color_parser.rb +78 -0
  26. data/app/models/onlylogs/application_record.rb +5 -0
  27. data/app/models/onlylogs/batch_sender.rb +61 -0
  28. data/app/models/onlylogs/file.rb +151 -0
  29. data/app/models/onlylogs/file_path_parser.rb +118 -0
  30. data/app/models/onlylogs/grep.rb +54 -0
  31. data/app/models/onlylogs/log_line.rb +24 -0
  32. data/app/models/onlylogs/secure_file_path.rb +31 -0
  33. data/app/views/home/show.html.erb +10 -0
  34. data/app/views/layouts/onlylogs/application.html.erb +27 -0
  35. data/app/views/onlylogs/logs/index.html.erb +49 -0
  36. data/app/views/onlylogs/shared/_log_container.html.erb +106 -0
  37. data/app/views/onlylogs/shared/_log_container_styles.html.erb +228 -0
  38. data/config/importmap.rb +6 -0
  39. data/config/puma_plugins/vector.rb +94 -0
  40. data/config/routes.rb +4 -0
  41. data/config/udp_logger.rb +40 -0
  42. data/config/vector.toml +32 -0
  43. data/db/migrate/20250902112548_create_books.rb +9 -0
  44. data/lib/onlylogs/configuration.rb +133 -0
  45. data/lib/onlylogs/engine.rb +39 -0
  46. data/lib/onlylogs/formatter.rb +14 -0
  47. data/lib/onlylogs/log_silencer_middleware.rb +26 -0
  48. data/lib/onlylogs/logger.rb +10 -0
  49. data/lib/onlylogs/socket_logger.rb +71 -0
  50. data/lib/onlylogs/version.rb +3 -0
  51. data/lib/onlylogs.rb +17 -0
  52. data/lib/puma/plugin/onlylogs_sidecar.rb +113 -0
  53. data/lib/tasks/onlylogs_tasks.rake +4 -0
  54. metadata +110 -0
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ class AnsiColorParser
5
+ ANSI_COLORS = {
6
+ "1" => "fw-bold",
7
+ "30" => "log-black",
8
+ "31" => "log-red",
9
+ "32" => "log-green",
10
+ "33" => "log-yellow",
11
+ "34" => "log-blue",
12
+ "35" => "log-magenta",
13
+ "36" => "log-cyan",
14
+ "37" => "log-white",
15
+ "39" => "", # Default foreground color (reset)
16
+ "0" => "" # Reset (no color)
17
+ }.freeze
18
+
19
+ # Pre-compiled regex for better performance
20
+ ANSI_REGEX = /\x1b\[(\d+)m/.freeze
21
+
22
+ # Pre-built HTML templates to avoid string interpolation (frozen for better performance)
23
+ HTML_TEMPLATES = {
24
+ "1" => '<span class="fw-bold">'.freeze,
25
+ "30" => '<span class="log-black">'.freeze,
26
+ "31" => '<span class="log-red">'.freeze,
27
+ "32" => '<span class="log-green">'.freeze,
28
+ "33" => '<span class="log-yellow">'.freeze,
29
+ "34" => '<span class="log-blue">'.freeze,
30
+ "35" => '<span class="log-magenta">'.freeze,
31
+ "36" => '<span class="log-cyan">'.freeze,
32
+ "37" => '<span class="log-white">'.freeze,
33
+ "39" => '<span class="">'.freeze,
34
+ "0" => "".freeze # Reset (no color)
35
+ }.freeze
36
+
37
+ # Pre-built closing span (frozen for better performance)
38
+ CLOSING_SPAN = '</span>'.freeze
39
+
40
+ def self.parse(string)
41
+ return string if string.blank?
42
+
43
+ # Early return if no ANSI codes present
44
+ return string unless string.include?("\x1b[")
45
+
46
+ result = string
47
+ stack = []
48
+
49
+ # Replace ANSI color codes with HTML spans
50
+ result = result.gsub(ANSI_REGEX) do |_match|
51
+ code = ::Regexp.last_match(1)
52
+
53
+ if code == "0"
54
+ # Reset - close all open spans
55
+ if stack.empty?
56
+ ""
57
+ else
58
+ spans = CLOSING_SPAN * stack.length
59
+ stack.clear
60
+ spans
61
+ end
62
+ elsif (template = HTML_TEMPLATES[code])
63
+ # Add span for this color/attribute
64
+ stack.push(code)
65
+ template
66
+ else
67
+ # Unknown code, ignore
68
+ ""
69
+ end
70
+ end
71
+
72
+ # Close any remaining open spans using string multiplication (faster than map/join)
73
+ result += CLOSING_SPAN * stack.length if stack.any?
74
+
75
+ result.html_safe
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ module Onlylogs
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ class BatchSender
5
+ def initialize(channel, interval: 0.05)
6
+ @channel = channel
7
+ @interval = interval
8
+ @buffer = []
9
+ @mutex = Mutex.new
10
+ @running = false
11
+ @sender_thread = nil
12
+ end
13
+
14
+ def start
15
+ return if @running
16
+
17
+ @running = true
18
+ @sender_thread = Thread.new do
19
+ while @running
20
+ send_batch
21
+ sleep(@interval)
22
+ end
23
+ end
24
+ end
25
+
26
+ def stop
27
+ return unless @running
28
+
29
+ @running = false
30
+ @sender_thread&.join(0.1)
31
+
32
+ # Send any remaining lines
33
+ send_batch
34
+ end
35
+
36
+ def add_line(line_data)
37
+ @mutex.synchronize do
38
+ @buffer << line_data
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def send_batch
45
+ lines_to_send = nil
46
+
47
+ @mutex.synchronize do
48
+ return if @buffer.empty?
49
+ lines_to_send = @buffer.dup
50
+ @buffer.clear
51
+ end
52
+
53
+ return if lines_to_send.empty?
54
+
55
+ @channel.send(:transmit, {
56
+ action: "append_logs",
57
+ lines: lines_to_send
58
+ })
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,151 @@
1
+ module Onlylogs
2
+ class Error < StandardError; end
3
+
4
+ class File
5
+ attr_reader :path, :last_position, :last_line_number
6
+
7
+ def initialize(path, last_position: 0)
8
+ self.path = path
9
+ self.last_position = last_position
10
+ self.last_line_number = 0
11
+ validate!
12
+ end
13
+
14
+ def go_to_position(position)
15
+ return if position < 0
16
+
17
+ self.last_position = position
18
+ self.last_line_number = 0
19
+ end
20
+
21
+ def watch(&block)
22
+ # return enum_for(:watch) unless block
23
+
24
+ loop do
25
+ sleep 0.5
26
+
27
+ new_lines = read_new_lines
28
+ next if new_lines.empty?
29
+
30
+ yield new_lines
31
+ end
32
+ end
33
+
34
+ def size
35
+ ::File.size(path)
36
+ end
37
+
38
+ def exist?
39
+ ::File.exist?(path)
40
+ end
41
+
42
+ def text_file?
43
+ self.class.text_file?(path)
44
+ end
45
+
46
+ def self.text_file?(path)
47
+ return false unless ::File.exist?(path)
48
+ return false if ::File.zero?(path)
49
+
50
+ # Read first chunk and check for null bytes (binary indicator)
51
+ ::File.open(path, "rb") do |file|
52
+ chunk = file.read(8192) || ""
53
+ # If it contains null bytes, it's likely binary
54
+ return false if chunk.include?("\x00")
55
+ end
56
+
57
+ true
58
+ end
59
+
60
+ def grep(filter, regexp_mode: false, start_position: 0, end_position: nil, &block)
61
+ Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |line_number, content|
62
+ yield Onlylogs::LogLine.new(line_number, content)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ attr_writer :path, :last_position, :last_line_number
69
+
70
+ def read_new_lines
71
+ return [] unless exist?
72
+
73
+ current_size = ::File.size(path)
74
+ return [] if current_size <= last_position
75
+
76
+ # Read new content from last_position to end of file
77
+ new_content = ""
78
+ ::File.open(path, "rb") do |file|
79
+ file.seek(last_position)
80
+ new_content = file.read
81
+ end
82
+
83
+ return [] if new_content.empty?
84
+
85
+ # Split into lines, handling incomplete lines
86
+ lines = new_content.lines(chomp: true)
87
+
88
+ # If we're not at the beginning of the file, check if we're at a line boundary
89
+ first_line_removed = false
90
+ if last_position > 0
91
+ # Read one character before to see if it was a newline
92
+ ::File.open(path, "rb") do |file|
93
+ file.seek(last_position - 1)
94
+ char_before = file.read(1)
95
+ # If the character before wasn't a newline, we're in the middle of a line
96
+ if char_before != "\n" && lines.any?
97
+ # Remove the first line as it's incomplete
98
+ lines.shift
99
+ first_line_removed = true
100
+ end
101
+ end
102
+ end
103
+
104
+ # Check if the last line is complete (ends with newline)
105
+ last_line_incomplete = lines.any? && !new_content.end_with?("\n")
106
+ if last_line_incomplete
107
+ # Remove the last line as it's incomplete
108
+ lines.pop
109
+ end
110
+
111
+ # Update position to end of last complete line
112
+ if lines.any?
113
+ # Find the position after the last complete line
114
+ ::File.open(path, "rb") do |file|
115
+ file.seek(last_position)
116
+ # Read and count newlines to find where complete lines end
117
+ newline_count = 0
118
+ # If we removed the first line, we need to count one extra newline
119
+ # to account for the incomplete first line
120
+ target_newlines = lines.length + (first_line_removed ? 1 : 0)
121
+ while newline_count < target_newlines
122
+ char = file.read(1)
123
+ break unless char
124
+
125
+ newline_count += 1 if char == "\n"
126
+ end
127
+ self.last_position = file.tell
128
+ end
129
+ elsif last_line_incomplete
130
+ # If we had lines but removed the last incomplete one,
131
+ # position should be at the start of the incomplete line
132
+ self.last_position = current_size - new_content.lines.last.length
133
+ elsif first_line_removed
134
+ # If we removed the first line but have no complete lines,
135
+ # position should be at the end of the file since we consumed all content
136
+ self.last_position = current_size
137
+ else
138
+ # No lines at all, position at end of file
139
+ self.last_position = current_size
140
+ end
141
+
142
+ lines = lines.map.with_index { |line, index| Onlylogs::LogLine.new(self.last_line_number + index, line) }
143
+ self.last_line_number += lines.length
144
+ lines
145
+ end
146
+
147
+ def validate!
148
+ raise Error, "File not found: #{path}" unless exist?
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ # kudos to https://github.com/BetterErrors/better_errors for this code!
6
+ module Onlylogs
7
+ class FilePathParser
8
+ KNOWN_EDITORS = [
9
+ { symbols: [ :atom ], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" },
10
+ { symbols: [ :emacs, :emacsclient ], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" },
11
+ { symbols: [ :idea ], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" },
12
+ { symbols: [ :macvim, :mvim ], sniff: /vim/i, url: "mvim://open?url=file://%{file_unencoded}&line=%{line}" },
13
+ { symbols: [ :rubymine ], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" },
14
+ { symbols: [ :sublime, :subl, :st ], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" },
15
+ { symbols: [ :textmate, :txmt, :tm ], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" },
16
+ { symbols: [ :vscode, :code ], sniff: /code/i, url: "vscode://file/%{file}:%{line}" },
17
+ { symbols: [ :vscodium, :codium ], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" }
18
+ ].freeze
19
+
20
+ # Pre-compiled regex for better performance
21
+ FILE_PATH_PATTERN = %r{
22
+ (?<![a-zA-Z0-9_/]) # Negative lookbehind - not preceded by word chars or /
23
+ (?:\./)? # Optional relative path indicator
24
+ (?:/[a-zA-Z0-9_\-\.\s]+)+ # File path with allowed characters
25
+ (?:\.rb|\.js|\.ts|\.tsx|\.jsx|\.py|\.java|\.go|\.rs|\.php|\.html|\.erb|\.haml|\.slim|\.css|\.scss|\.sass|\.less|\.xml|\.json|\.yml|\.yaml|\.md|\.txt|\.log) # File extensions
26
+ (?::\d+)? # Optional line number
27
+ (?![a-zA-Z0-9_/]) # Negative lookahead - not followed by word chars or /
28
+ }x.freeze
29
+
30
+ # Pre-built HTML template to avoid string interpolation
31
+ HTML_TEMPLATE = '<a href="%{url}" class="file-link">%{match}</a>'.freeze
32
+
33
+ def self.parse(string)
34
+ return string if string.blank?
35
+
36
+ # Early return if no file paths present
37
+ return string unless string.match?(FILE_PATH_PATTERN)
38
+
39
+ string.gsub(FILE_PATH_PATTERN) do |match|
40
+ file_path = extract_file_path(match)
41
+ line_number = extract_line_number(match)
42
+ url = cached_editor_instance.url(file_path, line_number)
43
+ HTML_TEMPLATE % { url: url, match: match }
44
+ end
45
+ end
46
+
47
+ def self.for_formatting_string(formatting_string)
48
+ new proc { |file, line|
49
+ formatting_string % { file: URI.encode_www_form_component(file), file_unencoded: file, line: line }
50
+ }
51
+ end
52
+
53
+ def self.for_proc(url_proc)
54
+ new url_proc
55
+ end
56
+
57
+ # Cache for the editor instance
58
+ @cached_editor_instance = nil
59
+
60
+ def self.cached_editor_instance
61
+ return @cached_editor_instance if @cached_editor_instance
62
+ @cached_editor_instance = editor_from_symbol(Onlylogs.editor)
63
+ end
64
+
65
+
66
+ def self.editor_from_symbol(symbol)
67
+ KNOWN_EDITORS.each do |preset|
68
+ return for_formatting_string(preset[:url]) if preset[:symbols].include?(symbol)
69
+ end
70
+ editor_from_symbol(:vscode)
71
+ end
72
+
73
+ def initialize(url_proc)
74
+ @url_proc = url_proc
75
+ end
76
+
77
+ def url(raw_path, line)
78
+ if virtual_path && raw_path.start_with?(virtual_path)
79
+ if host_path
80
+ file = raw_path.sub(%r{\A#{virtual_path}}, host_path)
81
+ else
82
+ file = raw_path.sub(%r{\A#{virtual_path}/}, "")
83
+ end
84
+ else
85
+ file = raw_path
86
+ end
87
+
88
+ url_proc.call(file, line)
89
+ end
90
+
91
+ def scheme
92
+ url("/fake", 42).sub(/:.*/, ":")
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :url_proc
98
+
99
+ def virtual_path
100
+ @virtual_path ||= ENV["ONLYLOGS_VIRTUAL_PATH"]
101
+ end
102
+
103
+ def host_path
104
+ @host_path ||= ENV["ONLYLOGS_HOST_PATH"]
105
+ end
106
+
107
+ def self.extract_file_path(match)
108
+ # Remove line number if present
109
+ match.sub(/:\d+$/, "")
110
+ end
111
+
112
+ def self.extract_line_number(match)
113
+ # Extract line number or default to 1
114
+ line_match = match.match(/:(\d+)$/)
115
+ line_match ? line_match[1].to_i : 1
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,54 @@
1
+ module Onlylogs
2
+ class Grep
3
+ def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false, &block)
4
+ # Use the appropriate script based on configuration
5
+ script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
6
+ super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
7
+
8
+ command_args = [ super_grep_path ]
9
+ command_args += [ "--max-matches", Onlylogs.max_line_matches.to_s ] if Onlylogs.max_line_matches.present?
10
+ command_args << "--regexp" if regexp_mode
11
+
12
+ # Add byte range parameters if specified
13
+ if start_position > 0 || end_position
14
+ command_args << "--start-position" << start_position.to_s
15
+ command_args << "--end-position" << end_position.to_s if end_position
16
+ end
17
+
18
+ command_args += [ pattern, file_path ]
19
+
20
+ results = []
21
+
22
+ IO.popen(command_args, err: "/dev/null") do |io|
23
+ io.each_line do |line|
24
+ # Parse each line as it comes in - super_grep returns grep output with line numbers (format: line_number:content)
25
+ if match = line.strip.match(/^(\d+):(.*)/)
26
+ line_number = match[1].to_i
27
+ content = match[2]
28
+
29
+ if block_given?
30
+ yield line_number, content
31
+ else
32
+ results << [ line_number, content ]
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ block_given? ? nil : results
39
+ end
40
+
41
+ def self.match_line?(line, string, regexp_mode: false)
42
+ # Strip ANSI color codes from the line before matching
43
+ stripped_line = line.gsub(/\e\[[0-9;]*m/, "")
44
+ # Normalize multiple spaces to single spaces
45
+ normalized_line = stripped_line.gsub(/\s+/, " ")
46
+
47
+ if regexp_mode
48
+ normalized_line.match?(string)
49
+ else
50
+ normalized_line.match?(Regexp.escape(string))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ class LogLine
5
+ attr_reader :number, :text
6
+
7
+ def initialize(number, text)
8
+ @number = number
9
+ @text = text
10
+ end
11
+
12
+ def parsed_number
13
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1'").reverse.rjust(7)
14
+ end
15
+
16
+ def parsed_text
17
+ FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(text)))
18
+ end
19
+
20
+ def to_a
21
+ [number, text]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onlylogs
4
+ class SecureFilePath
5
+ class SecurityError < StandardError; end
6
+
7
+ def self.encrypt(file_path)
8
+ encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
9
+ encrypted = encryptor.encrypt_and_sign(file_path.to_s)
10
+ Base64.urlsafe_encode64(encrypted).tr("=", "")
11
+ rescue => e
12
+ Rails.logger.error "Onlylogs: Encryption failed for #{file_path}: #{e.message}"
13
+ raise SecurityError, "Encryption failed"
14
+ end
15
+
16
+ def self.decrypt(encrypted_path)
17
+ decoded = Base64.urlsafe_decode64(encrypted_path)
18
+ encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
19
+ encryptor.decrypt_and_verify(decoded)
20
+ rescue => e
21
+ Rails.logger.error "Onlylogs: Decryption failed: #{e.message}"
22
+ raise SecurityError, "Invalid encrypted file path"
23
+ end
24
+
25
+ private
26
+
27
+ def self.encryption_key
28
+ Rails.application.secret_key_base[0..31]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ <% content_for :head do %>
2
+ <meta http-equiv="refresh" content="5">
3
+ <% end %>
4
+
5
+ <p>
6
+ <%= link_to "Check logs in Real Time", onlylogs.root_path, target: :_blank %>
7
+ </p>
8
+
9
+ <p>When loading this page, some lines are added to the logs, for testing purposes.</p>
10
+ <p><em>This page refreshes automatically every 5 seconds.</em></p>
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Onlylogs</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= javascript_importmap_tags "application", importmap: Onlylogs.importmap %>
11
+
12
+ <style>
13
+ html, body {
14
+ margin: 0;
15
+ padding: 0;
16
+ height: 100%;
17
+ width: 100%;
18
+ overflow: hidden;
19
+ }
20
+ </style>
21
+ </head>
22
+ <body>
23
+
24
+ <%= yield %>
25
+
26
+ </body>
27
+ </html>
@@ -0,0 +1,49 @@
1
+ <div class="sidebar-layout" data-controller="toggle-class" data-toggle-class-toggle-class="closed">
2
+ <aside id="sidebar">
3
+ <div class="sidebar-menu">
4
+ <%= link_to onlylogs.root_path, class: "btn sidebar-menu__button" do %>
5
+ <%= image_tag "onlylogs/logo.png" %>
6
+ <% end %>
7
+ <div class="sidebar-menu__content">
8
+ <div class="sidebar-menu__items">
9
+ <div class="sidebar-menu__group">
10
+ <div class="sidebar-menu__group-label">Projects</div>
11
+ <nav class="sidebar-menu__items">
12
+ <%= link_to "All Projects", root_path, class: "btn sidebar-menu__button" %>
13
+
14
+ <nav class="sidebar-menu__sub">
15
+ <nav class="sidebar-menu__sub text-sm">
16
+ <div>
17
+ <% if Onlylogs::File.text_file?(drain_file) %>
18
+ <%= link_to drain_file.basename, project_path(@project, file_path: Onlylogs::SecureFilePath.encrypt(drain_file.to_s)) %>
19
+ <% else %>
20
+ <%= drain_file.basename %>
21
+ <% end %>
22
+ <small>(<%= number_to_human_size(File.size(drain_file)) %>)</small>
23
+ <%= link_to project_drain_file_path(@project, Onlylogs::SecureFilePath.encrypt(drain_file.to_s)),
24
+ title: t('drain_files.show.title'),
25
+ class: 'download-link' do %>
26
+ <i class="fa-regular fa-download" aria-label="<%= t('drain_files.show.title') %>"></i>
27
+ <% end %>
28
+ </div>
29
+ </nav>
30
+ </nav>
31
+ </nav>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ </aside>
38
+ <main id="main">
39
+ <header class="flex items-center gap show@md">
40
+ <button type="button" class="btn btn--borderless p-1" data-action="toggle-class#toggle">
41
+ <i class="fa-regular fa-sidebar"></i>
42
+ <span class="sr-only">Toggle Sidebar</span>
43
+ </button>
44
+ <div class="separator-vertical mi-1" style="--sep-size: 1rem"></div>
45
+ </header>
46
+
47
+ <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll } %>
48
+ </main>
49
+ </div>