rails_error_dashboard 0.1.28 → 0.1.30

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -6
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -0
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +79 -7
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +149 -0
  6. data/app/models/rails_error_dashboard/application.rb +1 -1
  7. data/app/models/rails_error_dashboard/error_log.rb +44 -16
  8. data/app/views/layouts/rails_error_dashboard.html.erb +71 -1237
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -2
  10. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +76 -0
  11. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +18 -82
  12. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +70 -0
  13. data/app/views/rails_error_dashboard/errors/analytics.html.erb +9 -37
  14. data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -37
  15. data/app/views/rails_error_dashboard/errors/index.html.erb +64 -31
  16. data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
  17. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
  18. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
  19. data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
  20. data/app/views/rails_error_dashboard/errors/show.html.erb +102 -76
  21. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
  22. data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
  23. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
  24. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
  25. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
  26. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
  27. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
  28. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
  29. data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
  30. data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
  31. data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
  32. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
  33. data/db/migrate/20251226020100_create_error_comments.rb +3 -0
  34. data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
  35. data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
  36. data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
  37. data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
  38. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  39. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
  40. data/lib/rails_error_dashboard/configuration.rb +160 -3
  41. data/lib/rails_error_dashboard/configuration_error.rb +24 -0
  42. data/lib/rails_error_dashboard/engine.rb +17 -0
  43. data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
  44. data/lib/rails_error_dashboard/queries/analytics_stats.rb +1 -2
  45. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
  46. data/lib/rails_error_dashboard/queries/errors_list.rb +27 -8
  47. data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
  48. data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
  49. data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
  50. data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
  51. data/lib/rails_error_dashboard/version.rb +1 -1
  52. data/lib/rails_error_dashboard.rb +6 -0
  53. metadata +14 -10
  54. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
  55. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
  56. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
  57. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
  58. data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
  59. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
  60. data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
  61. data/app/views/layouts/rails_error_dashboard/application.html.erb +0 -55
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Smart error message normalization for better error grouping
6
+ #
7
+ # Replaces dynamic values (IDs, UUIDs, timestamps, etc.) with placeholders
8
+ # while preserving semantic meaning. This improves error deduplication accuracy
9
+ # compared to naive "replace all numbers" approach.
10
+ #
11
+ # @example
12
+ # ErrorNormalizer.normalize("User 123 not found")
13
+ # # => "User :id not found"
14
+ #
15
+ # ErrorNormalizer.normalize("Expected 2 arguments, got 5")
16
+ # # => "Expected 2 arguments, got 5" (preserves meaningful numbers)
17
+ #
18
+ class ErrorNormalizer
19
+ # Patterns for smart normalization
20
+ # Order matters: more specific patterns should come first
21
+ PATTERNS = {
22
+ # UUIDs (e.g., "550e8400-e29b-41d4-a716-446655440000")
23
+ uuid: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i,
24
+
25
+ # Memory addresses (e.g., "<User:0x00007f8b1a2b3c4d>", "0x00007f8b1a2b3c4d")
26
+ # MUST come before hash_id to match memory addresses first
27
+ memory_address: /#?<[^>]+:0x[0-9a-f]+>/i,
28
+ hex_address: /\b0x[0-9a-f]{8,16}\b/i,
29
+
30
+ # Timestamps (ISO8601 and common formats)
31
+ # Remove timezone offset separately to avoid leaving it behind
32
+ timestamp_iso: /\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/,
33
+ timestamp_unix: /\btimestamp[:\s]+\d{10,13}\b/i,
34
+
35
+ # Tokens and API keys (long alphanumeric strings)
36
+ # MUST come before large_number to match long tokens first
37
+ token: /\b[a-z0-9]{32,}\b/i,
38
+
39
+ # Object IDs and database IDs (e.g., "User #123", "id: 456", "ID=789")
40
+ # MUST come before hash_id to match specific ID patterns first
41
+ object_id: /(?:#|(?:id|ID)(?:\s*[=:#]\s*|\s+))\d+\b/,
42
+ # Ruby-style object references (e.g., "User:123", "#<User:123>")
43
+ hash_id: /#?<?[A-Z]\w*:\d+>?/,
44
+
45
+ # File paths with dynamic components (but check for UUIDs in path first)
46
+ # More specific pattern: match /tmp/ followed by UUID-like or hash-like segment
47
+ temp_path: %r{/(?:tmp|var/tmp|private/tmp)/(?:[a-z0-9_-]+/)*[a-z0-9_-]+(?:\.[a-z0-9]+)?},
48
+
49
+ # Numbered URL paths - MUST come before large_number
50
+ # Capture the leading slash with the number, and optional trailing slash
51
+ numbered_path: %r{/\d+(?=/|$)}, # e.g., "/api/users/123/posts" → "/api/users:numbered_path/posts"
52
+
53
+ # Email addresses (preserve domain, replace local part)
54
+ email: /\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/i,
55
+
56
+ # IP addresses
57
+ ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
58
+ ipv6: /\b(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\b/i,
59
+
60
+ # Hexadecimal values (but not in memory addresses - already handled)
61
+ hex_value: /\b0x[0-9a-f]+\b/i,
62
+
63
+ # Standalone large numbers (likely IDs, but preserve small numbers < 1000)
64
+ # MUST come last to avoid matching parts of other patterns
65
+ large_number: /\b\d{4,}\b/
66
+ }.freeze
67
+
68
+ class << self
69
+ # Normalize an error message by replacing dynamic values with placeholders
70
+ #
71
+ # @param message [String] the error message to normalize
72
+ # @return [String] the normalized message
73
+ def normalize(message)
74
+ return "" if message.nil?
75
+ return message if message.strip.empty? # Preserve whitespace-only strings
76
+
77
+ normalized = message.dup
78
+
79
+ # Apply each pattern in order
80
+ PATTERNS.each do |type, pattern|
81
+ normalized.gsub!(pattern, ":#{type}")
82
+ end
83
+
84
+ # Clean up leftover timezone offsets that weren't caught by timestamp pattern
85
+ normalized.gsub!(/\s+[+-]\d{2}:\d{2}$/, "")
86
+
87
+ normalized
88
+ end
89
+
90
+ # Extract significant backtrace frames, skipping gem/vendor code
91
+ #
92
+ # @param backtrace [String] the full backtrace string
93
+ # @param count [Integer] number of frames to extract (default: 3)
94
+ # @return [String, nil] the significant frames joined with "|"
95
+ def extract_significant_frames(backtrace, count: 3)
96
+ return nil if backtrace.blank?
97
+
98
+ frames = backtrace.split("\n")
99
+ .map(&:strip)
100
+ .reject { |line| gem_or_vendor_code?(line) }
101
+ .reject { |line| ruby_stdlib_code?(line) }
102
+ .first(count)
103
+ .map { |line| extract_file_and_method(line) }
104
+ .compact
105
+
106
+ frames.empty? ? nil : frames.join("|")
107
+ end
108
+
109
+ private
110
+
111
+ # Check if a backtrace line is from gem/vendor code
112
+ def gem_or_vendor_code?(line)
113
+ line.include?("vendor/bundle") ||
114
+ line.include?("gems/") ||
115
+ line.include?(".gem/ruby")
116
+ end
117
+
118
+ # Check if a backtrace line is from Ruby standard library
119
+ def ruby_stdlib_code?(line)
120
+ line.include?("/ruby/") ||
121
+ line.include?("/lib/ruby/") ||
122
+ line.match?(%r{ruby-\d+\.\d+\.\d+/lib})
123
+ end
124
+
125
+ # Extract file path and method name from a backtrace line
126
+ # Example: "/app/models/user.rb:10:in `name'" => "/app/models/user.rb:name"
127
+ def extract_file_and_method(line)
128
+ # Match pattern: file.rb:line:in `method'
129
+ match = line.match(%r{^(.+\.rb):(\d+)(?::in `(.+)')?})
130
+ return nil unless match
131
+
132
+ file = match[1]
133
+ method = match[3]
134
+
135
+ # Remove absolute path prefix for consistency
136
+ file = file.sub(%r{.*/(?=app/)}, "")
137
+
138
+ method ? "#{file}:#{method}" : file
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module RailsErrorDashboard
7
+ module Services
8
+ # Reads git blame information for specific file lines
9
+ # Executes git blame command and parses porcelain output
10
+ #
11
+ # @example
12
+ # reader = GitBlameReader.new("/path/to/file.rb", 42)
13
+ # blame = reader.read_blame
14
+ # # => { author: "John Doe", email: "john@example.com", date: Time, sha: "abc123", line: "code" }
15
+ class GitBlameReader
16
+ COMMAND_TIMEOUT = 5 # seconds
17
+ PORCELAIN_FIELDS = %w[
18
+ author
19
+ author-mail
20
+ author-time
21
+ author-tz
22
+ committer
23
+ committer-mail
24
+ committer-time
25
+ committer-tz
26
+ summary
27
+ filename
28
+ ].freeze
29
+
30
+ attr_reader :file_path, :line_number, :error
31
+
32
+ # Initialize a new git blame reader
33
+ #
34
+ # @param file_path [String] Path to the source file
35
+ # @param line_number [Integer] Target line number
36
+ def initialize(file_path, line_number)
37
+ @file_path = file_path
38
+ @line_number = line_number.to_i
39
+ @error = nil
40
+ end
41
+
42
+ # Read git blame information for the target line
43
+ #
44
+ # @return [Hash, nil] Blame data hash or nil if unavailable
45
+ def read_blame
46
+ unless git_available?
47
+ @error = "Git not available"
48
+ return nil
49
+ end
50
+
51
+ unless File.exist?(file_path)
52
+ @error = "File not found"
53
+ return nil
54
+ end
55
+
56
+ # Execute git blame command
57
+ output = execute_git_blame
58
+ return nil unless output
59
+
60
+ # Parse porcelain format output
61
+ parse_blame_output(output)
62
+ rescue StandardError => e
63
+ @error = "Error reading git blame: #{e.message}"
64
+ RailsErrorDashboard::Logger.error("GitBlameReader error for #{file_path}:#{line_number} - #{e.message}")
65
+ nil
66
+ end
67
+
68
+ # Check if git is available on the system
69
+ #
70
+ # @return [Boolean]
71
+ def git_available?
72
+ @git_available ||= begin
73
+ _stdout, _stderr, status = Open3.capture3("git", "--version")
74
+ status.success?
75
+ rescue StandardError
76
+ false
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Execute git blame command for the specific line
83
+ #
84
+ # @return [String, nil] Command output or nil if failed
85
+ def execute_git_blame
86
+ # Build command array (prevents command injection)
87
+ cmd = [
88
+ "git",
89
+ "blame",
90
+ "-L", "#{line_number},#{line_number}",
91
+ "--porcelain",
92
+ "--", # Separator
93
+ file_path
94
+ ]
95
+
96
+ # Execute with timeout
97
+ stdout, stderr, status = Timeout.timeout(COMMAND_TIMEOUT) do
98
+ Open3.capture3(*cmd, chdir: Rails.root)
99
+ end
100
+
101
+ unless status.success?
102
+ @error = "Git blame failed: #{stderr}"
103
+ RailsErrorDashboard::Logger.debug("Git blame failed for #{file_path}:#{line_number} - #{stderr}")
104
+ return nil
105
+ end
106
+
107
+ stdout
108
+ rescue Timeout::Error
109
+ @error = "Git blame timeout"
110
+ RailsErrorDashboard::Logger.warn("Git blame timeout for #{file_path}:#{line_number}")
111
+ nil
112
+ rescue StandardError => e
113
+ @error = "Git blame execution error: #{e.message}"
114
+ RailsErrorDashboard::Logger.error("Git blame execution error for #{file_path}:#{line_number} - #{e.message}")
115
+ nil
116
+ end
117
+
118
+ # Parse git blame porcelain format output
119
+ #
120
+ # Git blame --porcelain format:
121
+ # <sha> <line_number> <final_line_number> <num_lines>
122
+ # author <author_name>
123
+ # author-mail <<author_email>>
124
+ # author-time <unix_timestamp>
125
+ # author-tz <timezone>
126
+ # committer <committer_name>
127
+ # committer-mail <<committer_email>>
128
+ # committer-time <unix_timestamp>
129
+ # committer-tz <timezone>
130
+ # summary <commit_message_summary>
131
+ # filename <filename>
132
+ # <TAB><line_content>
133
+ #
134
+ # @param output [String] Git blame porcelain output
135
+ # @return [Hash, nil] Parsed blame data
136
+ def parse_blame_output(output)
137
+ return nil if output.blank?
138
+
139
+ lines = output.split("\n")
140
+ return nil if lines.empty?
141
+
142
+ # First line contains commit SHA and line info
143
+ first_line = lines[0]
144
+ match = first_line.match(/^([0-9a-f]+)\s+(\d+)\s+(\d+)/)
145
+ unless match
146
+ @error = "Incomplete git blame data"
147
+ return nil
148
+ end
149
+
150
+ sha = match[1]
151
+ data = { sha: sha }
152
+
153
+ # Parse subsequent lines
154
+ lines[1..].each do |line|
155
+ # Check for field: value lines
156
+ PORCELAIN_FIELDS.each do |field|
157
+ if line.start_with?("#{field} ")
158
+ value = line.sub("#{field} ", "")
159
+
160
+ case field
161
+ when "author"
162
+ data[:author] = value
163
+ when "author-mail"
164
+ # Remove < and > brackets
165
+ data[:email] = value.gsub(/[<>]/, "")
166
+ when "author-time"
167
+ # Convert Unix timestamp to Time object
168
+ data[:date] = Time.at(value.to_i)
169
+ when "summary"
170
+ data[:commit_message] = value
171
+ end
172
+ end
173
+ end
174
+
175
+ # Line content starts with tab
176
+ if line.start_with?("\t")
177
+ data[:line] = line.sub("\t", "")
178
+ end
179
+ end
180
+
181
+ # Validate required fields
182
+ if data[:author].present? && data[:sha].present?
183
+ data
184
+ else
185
+ @error = "Incomplete git blame data"
186
+ nil
187
+ end
188
+ rescue StandardError => e
189
+ @error = "Error parsing git blame output: #{e.message}"
190
+ RailsErrorDashboard::Logger.error("Git blame parsing error: #{e.message}")
191
+ nil
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Generates links to source code on GitHub, GitLab, or Bitbucket
6
+ # Supports commit SHA, branch, and tag references
7
+ #
8
+ # @example
9
+ # generator = GithubLinkGenerator.new(
10
+ # repository_url: "https://github.com/user/repo",
11
+ # file_path: "app/models/user.rb",
12
+ # line_number: 42,
13
+ # commit_sha: "abc123def456"
14
+ # )
15
+ # generator.generate_link
16
+ # # => "https://github.com/user/repo/blob/abc123def456/app/models/user.rb#L42"
17
+ class GithubLinkGenerator
18
+ attr_reader :repository_url, :file_path, :line_number, :commit_sha, :branch, :error
19
+
20
+ # Initialize a new link generator
21
+ #
22
+ # @param repository_url [String] Base repository URL (GitHub, GitLab, Bitbucket)
23
+ # @param file_path [String] Relative path to file from repository root
24
+ # @param line_number [Integer] Line number to link to
25
+ # @param commit_sha [String, nil] Git commit SHA (recommended for accuracy)
26
+ # @param branch [String, nil] Branch name (fallback if no commit SHA)
27
+ def initialize(repository_url:, file_path:, line_number:, commit_sha: nil, branch: nil)
28
+ @repository_url = repository_url
29
+ @file_path = file_path
30
+ @line_number = line_number.to_i
31
+ @commit_sha = commit_sha
32
+ @branch = branch || "main"
33
+ @error = nil
34
+ end
35
+
36
+ # Generate a link to the source file on the repository host
37
+ #
38
+ # @return [String, nil] URL to the source file or nil if invalid
39
+ def generate_link
40
+ return nil if repository_url.blank? || file_path.blank?
41
+
42
+ # Normalize repository URL
43
+ normalized_repo = normalize_repository_url
44
+
45
+ # Determine reference (commit SHA or branch)
46
+ reference = determine_reference
47
+
48
+ # Generate link based on repository type
49
+ case detect_repository_type
50
+ when :github
51
+ generate_github_link(normalized_repo, reference)
52
+ when :gitlab
53
+ generate_gitlab_link(normalized_repo, reference)
54
+ when :bitbucket
55
+ generate_bitbucket_link(normalized_repo, reference)
56
+ else
57
+ @error = "Unsupported repository type"
58
+ nil
59
+ end
60
+ rescue StandardError => e
61
+ @error = "Error generating link: #{e.message}"
62
+ RailsErrorDashboard::Logger.error("GithubLinkGenerator error for #{repository_url} - #{e.message}")
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ # Normalize repository URL (remove .git suffix, trailing slashes, etc.)
69
+ #
70
+ # @return [String]
71
+ def normalize_repository_url
72
+ url = repository_url.strip
73
+ url = url.chomp(".git")
74
+ url = url.chomp("/")
75
+ url
76
+ end
77
+
78
+ # Detect repository type from URL
79
+ #
80
+ # @return [Symbol] :github, :gitlab, :bitbucket, or :unknown
81
+ def detect_repository_type
82
+ normalized = normalize_repository_url.downcase
83
+
84
+ return :github if normalized.include?("github.com")
85
+ return :gitlab if normalized.include?("gitlab.com") || normalized.include?("gitlab.")
86
+ return :bitbucket if normalized.include?("bitbucket.org") || normalized.include?("bitbucket.")
87
+
88
+ :unknown
89
+ end
90
+
91
+ # Determine which reference to use (commit SHA or branch)
92
+ #
93
+ # @return [String]
94
+ def determine_reference
95
+ if commit_sha.present?
96
+ commit_sha
97
+ else
98
+ branch
99
+ end
100
+ end
101
+
102
+ # Normalize file path (remove leading slashes, Rails.root prefix, etc.)
103
+ #
104
+ # @return [String]
105
+ def normalize_file_path
106
+ path = file_path.strip
107
+
108
+ # Remove leading slash
109
+ path = path.sub(%r{^/}, "")
110
+
111
+ # Remove Rails.root or app root prefix if present
112
+ # Handles paths like "/Users/foo/myapp/app/models/user.rb" -> "app/models/user.rb"
113
+ # Match pattern: look for one of the standard Rails directories
114
+ match = path.match(%r{.*/?((?:app|lib|config|db|spec|test)/.*)$})
115
+ if match
116
+ path = match[1]
117
+ end
118
+
119
+ path
120
+ end
121
+
122
+ # Generate GitHub link
123
+ #
124
+ # Format: https://github.com/user/repo/blob/{ref}/path/to/file.rb#L42
125
+ #
126
+ # @param repo_url [String] Normalized repository URL
127
+ # @param ref [String] Commit SHA or branch name
128
+ # @return [String]
129
+ def generate_github_link(repo_url, ref)
130
+ normalized_path = normalize_file_path
131
+ "#{repo_url}/blob/#{ref}/#{normalized_path}#L#{line_number}"
132
+ end
133
+
134
+ # Generate GitLab link
135
+ #
136
+ # Format: https://gitlab.com/user/repo/-/blob/{ref}/path/to/file.rb#L42
137
+ #
138
+ # @param repo_url [String] Normalized repository URL
139
+ # @param ref [String] Commit SHA or branch name
140
+ # @return [String]
141
+ def generate_gitlab_link(repo_url, ref)
142
+ normalized_path = normalize_file_path
143
+ "#{repo_url}/-/blob/#{ref}/#{normalized_path}#L#{line_number}"
144
+ end
145
+
146
+ # Generate Bitbucket link
147
+ #
148
+ # Format: https://bitbucket.org/user/repo/src/{ref}/path/to/file.rb#lines-42
149
+ #
150
+ # @param repo_url [String] Normalized repository URL
151
+ # @param ref [String] Commit SHA or branch name
152
+ # @return [String]
153
+ def generate_bitbucket_link(repo_url, ref)
154
+ normalized_path = normalize_file_path
155
+ "#{repo_url}/src/#{ref}/#{normalized_path}#lines-#{line_number}"
156
+ end
157
+ end
158
+ end
159
+ end