rails_ai_admin 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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Creates records from a CSV file, one row at a time through the native
6
+ # create operation: each row has its own policy check, coercion, model
7
+ # validation, transaction and audit event. A check run validates the
8
+ # values without writing anything.
9
+ class CsvImport
10
+ MAX_BYTES = 2 * 1024 * 1024
11
+ # Types a CSV cell can carry. Files, passwords and lists are left out.
12
+ TYPES = %i[text textarea integer decimal boolean date datetime time enum select email url json belongs_to rich_text].freeze
13
+ TRUE_WORDS = %w[true yes y 1].freeze
14
+ FALSE_WORDS = %w[false no n 0].freeze
15
+ RowResult = Data.define(:number, :code, :errors, :record_id)
16
+ Report = Data.define(:columns, :ignored, :rows, :checked) do
17
+ def created = rows.count { |row| row.code == "OK" && row.record_id }
18
+ def valid = rows.count { |row| row.code == "OK" }
19
+ def failed = rows.reject { |row| row.code == "OK" }
20
+ end
21
+
22
+ class Invalid < StandardError; end
23
+
24
+ def initialize(context, resource, limit: RailsAiAdmin.configuration.import_limit)
25
+ @context, @resource, @limit = context, resource, limit
26
+ @access = Authorization::Access.new(context, resource)
27
+ end
28
+
29
+ # Fields a file may fill: writable on create and representable as text.
30
+ def fields
31
+ @fields ||= @access.fields(:write, @access.relation.new, operation: :create).select { |field| TYPES.include?(field.scalar_type) && !field.plugin? }
32
+ end
33
+
34
+ def template = "#{fields.map { |field| %("#{field.label.gsub('"', '""')}") }.join(',')}\r\n"
35
+
36
+ def call(text, write:)
37
+ @access.authorize_import!
38
+ raise Invalid, I18n.t("rails_ai_admin.imports.too_large") if text.bytesize > MAX_BYTES
39
+ rows = parse(text.dup.force_encoding(Encoding::UTF_8).delete_prefix(""))
40
+ header = rows.shift
41
+ raise Invalid, I18n.t("rails_ai_admin.imports.empty") if header.nil? || rows.empty?
42
+ raise Invalid, I18n.t("rails_ai_admin.imports.too_many", limit: @limit) if rows.size > @limit
43
+ mapping = map_columns(header)
44
+ raise Invalid, I18n.t("rails_ai_admin.imports.no_columns") if mapping.empty?
45
+ runner = Operations::Runner.new
46
+ results = rows.each_with_index.map do |cells, index|
47
+ input = mapping.each_with_object({}) do |(position, field), values|
48
+ cell = cells[position].to_s
49
+ values[field.name.to_s] = normalize(field, cell) unless cell.strip.empty?
50
+ end
51
+ run_row(runner, index + 2, input, write)
52
+ end
53
+ ignored = header.each_with_index.reject { |_, position| mapping.key?(position) }.map(&:first).reject(&:blank?)
54
+ Report.new(mapping.values.map(&:label), ignored, results, !write)
55
+ end
56
+
57
+ private
58
+
59
+ def run_row(runner, number, input, write)
60
+ if write
61
+ intent = runner.prepare(context: @context, resource: @resource, operation: :create)
62
+ result = runner.execute(context: @context, intent_ref: intent.id, input: input, human_confirmed: true, origin: :ui)
63
+ RowResult.new(number, result.code, result.errors || {}, result.success? ? result.record_id : nil)
64
+ else
65
+ result = runner.preview(context: @context, resource: @resource, operation: :create, input: input)
66
+ result.is_a?(Operations::Result) ? RowResult.new(number, result.code, result.errors || {}, nil) : RowResult.new(number, "OK", {}, nil)
67
+ end
68
+ rescue AccessDenied => error
69
+ RowResult.new(number, error.code, {}, nil)
70
+ end
71
+
72
+ # Header cells match a field's label or name, ignoring case and spaces.
73
+ def map_columns(header)
74
+ names = fields.flat_map { |field| [ [ key(field.label), field ], [ key(field.name.to_s), field ] ] }.to_h
75
+ used = {}
76
+ header.each_with_index.each_with_object({}) do |(cell, position), mapping|
77
+ field = names[key(cell.to_s)]
78
+ next unless field && !used[field.name]
79
+ used[field.name] = true
80
+ mapping[position] = field
81
+ end
82
+ end
83
+
84
+ def key(text) = text.to_s.unicode_normalize(:nfkc).downcase.gsub(/[\s_]+/, " ").strip
85
+
86
+ # Export writes readable text; import reads it back where that is exact.
87
+ def normalize(field, cell)
88
+ case field.scalar_type
89
+ when :boolean
90
+ word = cell.strip.downcase
91
+ localized = { I18n.t("rails_ai_admin.fields.yes").downcase => "true", I18n.t("rails_ai_admin.fields.no").downcase => "false" }
92
+ return localized[word] if localized.key?(word)
93
+ TRUE_WORDS.include?(word) ? "true" : FALSE_WORDS.include?(word) ? "false" : cell
94
+ when :select then field.choice_options.find { |label, _| label == cell }&.last || cell
95
+ when :integer, :decimal, :date, :datetime, :time, :belongs_to then cell.strip
96
+ else cell
97
+ end
98
+ end
99
+
100
+ # RFC 4180: quoted cells may hold commas, quotes ("") and line breaks.
101
+ def parse(text)
102
+ raise Invalid, I18n.t("rails_ai_admin.imports.not_utf8") unless text.valid_encoding?
103
+ rows, row, cell = [], [], +""
104
+ quoted = false
105
+ index = 0
106
+ while index < text.length
107
+ char = text[index]
108
+ if quoted
109
+ if char == '"'
110
+ if text[index + 1] == '"'
111
+ cell << '"'
112
+ index += 1
113
+ else
114
+ quoted = false
115
+ end
116
+ else
117
+ cell << char
118
+ end
119
+ elsif char == '"' && cell.empty?
120
+ quoted = true
121
+ elsif char == ","
122
+ row << cell
123
+ cell = +""
124
+ elsif char == "\n" || char == "\r"
125
+ index += 1 if char == "\r" && text[index + 1] == "\n"
126
+ row << cell
127
+ rows << row unless row.all?(&:empty?)
128
+ row, cell = [], +""
129
+ raise Invalid, I18n.t("rails_ai_admin.imports.too_many", limit: @limit) if rows.size > @limit + 1
130
+ else
131
+ cell << char
132
+ end
133
+ index += 1
134
+ end
135
+ raise Invalid, I18n.t("rails_ai_admin.imports.unclosed_quote") if quoted
136
+ row << cell
137
+ rows << row unless row.all?(&:empty?)
138
+ rows
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Declared overview widgets: counts over a granted list tab and daily
6
+ # trends over a readable date field. Every number comes from the
7
+ # resource's policy relation; nothing is cached across requests.
8
+ module Dashboard
9
+ MAX_WIDGETS = 8
10
+ MAX_DAYS = 90
11
+ # A trend reads at most this many timestamps.
12
+ TREND_LIMIT = 50_000
13
+ Metric = Data.define(:name, :scope, :label)
14
+ Trend = Data.define(:name, :field, :days, :label)
15
+ MetricValue = Data.define(:label, :count, :capped, :scope)
16
+ TrendValue = Data.define(:label, :days, :capped) do
17
+ def max = [ days.map(&:last).max.to_i, 1 ].max
18
+ def total = days.sum(&:last)
19
+ end
20
+ Section = Data.define(:resource, :metrics, :trends)
21
+
22
+ # Collects a resource's dashboard declarations.
23
+ class Builder
24
+ attr_reader :widgets
25
+
26
+ def initialize = @widgets = []
27
+
28
+ def metric(name, scope: nil, label: nil)
29
+ add(Metric.new(stable(name), scope && stable(scope), text(label)))
30
+ end
31
+
32
+ def trend(field, days: 30, label: nil)
33
+ raise ConfigurationError, "trend days must be 7..#{MAX_DAYS}" unless days.is_a?(Integer) && days.between?(7, MAX_DAYS)
34
+ add(Trend.new(stable(field), stable(field), days, text(label)))
35
+ end
36
+
37
+ private
38
+
39
+ def add(widget)
40
+ raise ConfigurationError, "A dashboard declares at most #{MAX_WIDGETS} widgets" if @widgets.size >= MAX_WIDGETS
41
+ raise ConfigurationError, "Duplicate dashboard widget #{widget.name}" if @widgets.any? { |entry| entry.class == widget.class && entry.name == widget.name }
42
+ @widgets << widget.freeze
43
+ end
44
+
45
+ def stable(name)
46
+ raise ConfigurationError, "Dashboard names must be stable identifiers" unless (name.is_a?(Symbol) || name.is_a?(String)) && name.to_s.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
47
+ name.to_sym
48
+ end
49
+
50
+ def text(label)
51
+ return if label.nil?
52
+ raise ConfigurationError, "Dashboard labels must be short plain text" unless label.is_a?(String) && label.valid_encoding? && label.length.between?(1, 80)
53
+ label.dup.freeze
54
+ end
55
+ end
56
+
57
+ class << self
58
+ def validate!(resource)
59
+ resource.dashboard.each do |widget|
60
+ case widget
61
+ when Metric
62
+ if widget.scope && resource.scopes.none? { |definition| definition.name == widget.scope }
63
+ raise ConfigurationError, "dashboard metric #{widget.name} names undeclared list tab #{widget.scope}"
64
+ end
65
+ when Trend
66
+ field = resource.fields.find { |candidate| candidate.name == widget.field }
67
+ unless field && %i[date datetime].include?(field.scalar_type) && !field.virtual? && !field.plugin?
68
+ raise ConfigurationError, "dashboard trend #{widget.field} must name a declared date or datetime field"
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # One section per menu resource with widgets the actor may see now.
75
+ def build(context, resources)
76
+ resources.filter_map do |resource|
77
+ next if resource.dashboard.empty?
78
+ access = Authorization::Access.new(context, resource)
79
+ access.authorize!(:index)
80
+ metrics = resource.dashboard.grep(Metric).filter_map { |widget| metric(access, widget) }
81
+ trends = resource.dashboard.grep(Trend).filter_map { |widget| trend(access, widget) }
82
+ Section.new(resource, metrics.freeze, trends.freeze) if metrics.any? || trends.any?
83
+ rescue AccessDenied
84
+ nil
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def metric(access, widget)
91
+ relation = access.relation
92
+ if widget.scope
93
+ named = access.named_scopes.find { |definition| definition.name == widget.scope }
94
+ return unless named
95
+ relation = named.apply(relation)
96
+ end
97
+ total = relation.except(:order).limit(List::COUNT_LIMIT + 1).count
98
+ label = widget.label || (named ? "#{access.resource.label}: #{named.label}" : access.resource.label)
99
+ MetricValue.new(label, [ total, List::COUNT_LIMIT ].min, total > List::COUNT_LIMIT, named&.name&.to_s)
100
+ end
101
+
102
+ # Counts per day in the context's time zone for the last days.
103
+ def trend(access, widget)
104
+ field = access.fields(:read).find { |candidate| candidate.name == widget.field }
105
+ return unless field
106
+ zone = ActiveSupport::TimeZone[access.context.time_zone]
107
+ today = zone.today
108
+ first = today - (widget.days - 1)
109
+ start = field.scalar_type == :date ? first : zone.local(first.year, first.month, first.day)
110
+ column = access.resource.model_class.arel_table[field.column]
111
+ values = access.relation.where(column.gteq(start)).reorder(nil).limit(TREND_LIMIT + 1).pluck(field.column)
112
+ counts = Hash.new(0)
113
+ values.first(TREND_LIMIT).compact.each { |value| counts[value.respond_to?(:in_time_zone) && !value.is_a?(Date) ? value.in_time_zone(zone).to_date : value.to_date] += 1 }
114
+ days = (first..today).map { |day| [ day, counts[day] ] }
115
+ TrendValue.new(widget.label || "#{access.resource.label} by #{field.label.downcase_first}", days.freeze, values.size > TREND_LIMIT)
116
+ end
117
+ end
118
+
119
+ List = Queries::List
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # One record's audit trail for its history page. It reads only the
6
+ # current tenant's events for this resource and record, and names only
7
+ # fields the actor may currently read or write.
8
+ class History
9
+ PAGE_SIZE = 50
10
+ Entry = Data.define(:id, :at, :operation, :status, :code, :origin, :actor_ref, :fields, :hidden_fields)
11
+ Page = Data.define(:entries, :older)
12
+
13
+ def initialize(access, record)
14
+ @access, @record = access, record
15
+ access.authorize_history!(record)
16
+ end
17
+
18
+ # before: the id of the oldest entry already shown (canonical integer text).
19
+ def page(before: nil)
20
+ unless before.nil? || before.is_a?(String) && before.match?(/\A[1-9][0-9]{0,18}\z/)
21
+ raise AccessDenied, "INVALID_QUERY"
22
+ end
23
+ scope = AuditEvent.where(tenant_ref: @access.context.tenant_ref, resource_key: @access.resource.key.to_s, record_id: @record.id.to_s)
24
+ scope = scope.where(AuditEvent.arel_table[:id].lt(Integer(before, 10))) if before
25
+ events = scope.order(id: :desc).limit(PAGE_SIZE + 1).to_a
26
+ labels = visible_labels
27
+ entries = events.first(PAGE_SIZE).map do |event|
28
+ names = Array(event.changed_fields).grep(String)
29
+ shown = names.filter_map { |name| labels[name] }
30
+ Entry.new(event.id, event.created_at, event.operation, event.status, event.code, event.origin,
31
+ event.actor_ref, shown.freeze, names.size - shown.size)
32
+ end
33
+ Page.new(entries.freeze, events.size > PAGE_SIZE ? entries.last.id : nil)
34
+ end
35
+
36
+ private
37
+
38
+ def visible_labels
39
+ fields = @access.fields(:read, @record) | @access.fields(:write, @record, operation: :update)
40
+ fields.to_h { |field| [ field.name.to_s, field.label ] }
41
+ rescue AccessDenied
42
+ {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Static index table presentation. Columns and the default sort only narrow
6
+ # or order what current read/sort grants already allow; nothing here grants.
7
+ class IndexSettings < Data.define(:columns, :default_sort, :default_direction, :page_size, :count)
8
+ MAX_COLUMNS = 50
9
+ MAX_PAGE_SIZE = 100
10
+ COUNT_STRATEGIES = %i[none exact].freeze
11
+ # Exact counts stop here so a large table never needs a full COUNT(*).
12
+ COUNT_LIMIT = 10_000
13
+ # Short machine-formatted values (IDs, numbers, ISO dates) stay on one line
14
+ # in index cells; hyphens in dates are otherwise legal break points.
15
+ COMPACT_TYPES = %i[id integer decimal date datetime boolean time].freeze
16
+
17
+ def initialize(columns: nil, default_sort: nil, default_direction: "asc", page_size: 25, count: :none)
18
+ unless columns.nil? || columns.is_a?(Array) && columns.size.between?(1, MAX_COLUMNS) && columns.all? { |name| name.is_a?(Symbol) } && columns.uniq.size == columns.size
19
+ raise ConfigurationError, "Index columns must be 1 to #{MAX_COLUMNS} distinct field-name symbols"
20
+ end
21
+ raise ConfigurationError, "Index default sort must be a field-name symbol" unless default_sort.nil? || default_sort.is_a?(Symbol)
22
+ raise ConfigurationError, "Index default direction must be asc or desc" unless %w[asc desc].include?(default_direction)
23
+ raise ConfigurationError, "Index page size must be an integer from 1 to #{MAX_PAGE_SIZE}" unless page_size.is_a?(Integer) && page_size.between?(1, MAX_PAGE_SIZE)
24
+ raise ConfigurationError, "Index count must be :none or :exact" unless COUNT_STRATEGIES.include?(count)
25
+ super(columns: columns&.dup&.freeze, default_sort: default_sort, default_direction: String.new(default_direction).freeze, page_size: page_size, count: count)
26
+ end
27
+
28
+ # Checks references against the resource's declarations at registration.
29
+ def validate!(fields)
30
+ by_name = fields.index_by(&:name)
31
+ Array(columns).each do |name|
32
+ field = by_name[name]
33
+ unless field && !field.hidden? && field.options[:index]
34
+ raise ConfigurationError, "Index column #{name} must be a declared field visible on index"
35
+ end
36
+ end
37
+ if default_sort
38
+ field = by_name[default_sort]
39
+ raise ConfigurationError, "Index default sort #{default_sort} must be a declared sortable field" unless field && !field.hidden? && !field.virtual? && field.options[:sortable]
40
+ end
41
+ self
42
+ end
43
+
44
+ # Orders already-authorized index fields; unlisted fields are omitted.
45
+ def order(fields)
46
+ return fields unless columns
47
+ by_name = fields.index_by(&:name)
48
+ columns.filter_map { |name| by_name[name] }
49
+ end
50
+ end
51
+
52
+ # Collects index settings without consulting the database at boot.
53
+ class IndexBuilder
54
+ attr_reader :options
55
+
56
+ def initialize
57
+ @options = {}
58
+ end
59
+
60
+ def columns(*names) = set(:columns, names.flatten)
61
+ def page_size(value) = set(:page_size, value)
62
+ def count(value) = set(:count, value)
63
+
64
+ def default_sort(name, direction = :asc)
65
+ set(:default_sort, name)
66
+ @options[:default_direction] = direction.to_s
67
+ end
68
+
69
+ private
70
+
71
+ def set(key, value)
72
+ raise ConfigurationError, "Index #{key} is declared twice" if @options.key?(key)
73
+ @options[key] = value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # One table from stable result codes to the sentence a person reads
6
+ # (RAA-027). The code stays available as secondary text for support, but no
7
+ # page should show a bare code as its explanation.
8
+ module Outcomes
9
+ SENTENCES = {
10
+ "OK" => "Done.",
11
+ "VALIDATION_FAILED" => "Some values need attention.",
12
+ "CONFLICT" => "The record changed after this page was opened.",
13
+ "RESTRICTED" => "The record is still in use and cannot be deleted.",
14
+ "NOT_FOUND" => "The record is not available.",
15
+ "UNAUTHENTICATED" => "Sign in to continue.",
16
+ "INVALID_QUERY" => "The list settings are not available.",
17
+ "APPROVAL_REQUIRED" => "This change needs your explicit confirmation.",
18
+ "APPROVAL_EXPIRED" => "This form expired before it was submitted.",
19
+ "APPROVAL_INVALIDATED" => "Your access or the operation changed after this form was opened.",
20
+ "IDEMPOTENCY_CONFLICT" => "This form was already submitted with different values.",
21
+ "AUTHORIZATION_REVOKED" => "Your access changed before the work ran.",
22
+ "SELECTION_INVALIDATED" => "The selection no longer matches the records.",
23
+ "SELECTION_EXPIRED" => "The selection expired.",
24
+ "TIMEOUT" => "The operation took too long and was stopped.",
25
+ "OPERATION_FAILED" => "The operation failed.",
26
+ "POST_COMMIT_CALLBACK_FAILED" => "The change was saved, but a follow-up step failed.",
27
+ "OUTCOME_UNKNOWN" => "The result could not be confirmed.",
28
+ "ATTEMPTS_EXHAUSTED" => "The work failed after several attempts.",
29
+ "CANCELLED" => "The work was cancelled.",
30
+ "EXECUTION_EXPIRED" => "The work expired before it could run.",
31
+ "WORK_EXPIRED" => "The work expired before it could run.",
32
+ "HANDLER_CHANGED" => "The operation changed after the work was queued.",
33
+ "UNAVAILABLE" => "This is not available right now."
34
+ }.freeze
35
+
36
+ # Translatable under rails_ai_admin.outcomes; English is the fallback.
37
+ def self.sentence(code)
38
+ fallback = SENTENCES.fetch(code.to_s) { "The request could not be completed." }
39
+ return fallback unless code.to_s.match?(/\A[A-Z][A-Z_]{1,63}\z/)
40
+ I18n.t(code.to_s.downcase, scope: %i[rails_ai_admin outcomes], default: fallback)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Fixed public status text. Exceptions, params and record values are not inputs.
6
+ class Status < Data.define(:kind, :http_status, :code, :title, :message)
7
+ DEFINITIONS = {
8
+ "authentication_required" => [ 401, "UNAUTHENTICATED", "Authentication required", "Sign in through the host application to continue." ].freeze,
9
+ "forbidden" => [ 403, "FORBIDDEN", "Access unavailable", "Your current permissions do not allow this page." ].freeze,
10
+ "not_found" => [ 404, "NOT_FOUND", "Not found", "This page or record is not available under your current access." ].freeze,
11
+ "conflict" => [ 409, "CONFLICT", "The request has changed", "Review the current records and permissions before preparing a fresh request." ].freeze,
12
+ "invalid_query" => [ 422, "INVALID_QUERY", "Invalid query", "Open a fresh list and use the available search, sort and page settings." ].freeze,
13
+ "invalid_request" => [ 422, "INVALID_REQUEST", "Invalid request", "Your session or request could not be verified. Sign in and review a fresh form before submitting." ].freeze,
14
+ "unavailable" => [ 503, "UNAVAILABLE", "Administration unavailable", "Administration is not configured safely. Contact the host maintainer." ].freeze,
15
+ "error" => [ 500, "ERROR", "The request needs review", "Check current records or existing operation results before retrying. A prior write may have completed." ].freeze
16
+ }.freeze
17
+
18
+ def self.fetch(kind)
19
+ raise ConfigurationError, "Unknown status kind" unless kind.is_a?(String) || kind.is_a?(Symbol)
20
+ key = kind.to_s
21
+ http_status, code, title, message = DEFINITIONS.fetch(key) { raise ConfigurationError, "Unknown status kind" }
22
+ # The English text is the fallback, so a missing translation never
23
+ # breaks the page that reports another failure.
24
+ title = I18n.t(:title, scope: [ :rails_ai_admin, :statuses, key ], default: title).to_s.freeze
25
+ message = I18n.t(:message, scope: [ :rails_ai_admin, :statuses, key ], default: message).to_s.freeze
26
+ new(String.new(key).freeze, http_status, code, title, message)
27
+ end
28
+
29
+ def self.for_code(code)
30
+ kind = case code
31
+ when "UNAUTHENTICATED" then :authentication_required
32
+ when "INVALID_QUERY" then :invalid_query
33
+ when "CONFLICT", "SELECTION_EXPIRED", "SELECTION_INVALIDATED" then :conflict
34
+ else :not_found
35
+ end
36
+ fetch(kind)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Deletes bookkeeping rows that can no longer be used (RAA-005): proposed
5
+ # intents and fixed selections whose expiry passed more than older_than ago.
6
+ # It never touches a run that started, succeeded or belongs to a collection,
7
+ # anything still referenced by work, batch or child rows, or audit events
8
+ # (their retention is the host's decision, see ADR 0005).
9
+ class Prune
10
+ DEFAULT_AGE = 7.days
11
+ BATCH = 1000
12
+ Result = Data.define(:intents, :selections)
13
+
14
+ def initialize(older_than: DEFAULT_AGE, now: Time.current)
15
+ unless older_than.is_a?(ActiveSupport::Duration) || older_than.is_a?(Numeric)
16
+ raise ConfigurationError, "older_than must be a duration"
17
+ end
18
+ raise ConfigurationError, "older_than must not be negative" if older_than.to_i.negative?
19
+ @cutoff = now - older_than
20
+ end
21
+
22
+ def call
23
+ Result.new(prune_intents, prune_selections)
24
+ end
25
+
26
+ private
27
+
28
+ def prune_intents
29
+ runs = OperationRun.arel_table
30
+ candidates = OperationRun.where(status: "proposed", parent_run_id: nil, selection_id: nil)
31
+ .where(runs[:expires_at].lt(@cutoff))
32
+ .where.not(id: WorkItem.select(:operation_run_id))
33
+ .where.not(id: BatchItem.select(:operation_run_id))
34
+ .where.not(id: BatchItem.select(:parent_run_id))
35
+ .where.not(id: OperationRun.where.not(parent_run_id: nil).select(:parent_run_id))
36
+ delete_in_batches(candidates) { |ids| OperationRun.where(id: ids).delete_all }
37
+ end
38
+
39
+ def prune_selections
40
+ candidates = Selection.where(Selection.arel_table[:expires_at].lt(@cutoff))
41
+ .where.not(id: OperationRun.where.not(selection_id: nil).select(:selection_id))
42
+ delete_in_batches(candidates) do |ids|
43
+ Selection.transaction do
44
+ SelectionItem.where(selection_id: ids).delete_all
45
+ Selection.where(id: ids).delete_all
46
+ end
47
+ end
48
+ end
49
+
50
+ def delete_in_batches(relation)
51
+ total = 0
52
+ loop do
53
+ ids = relation.limit(BATCH).pluck(:id)
54
+ break if ids.empty?
55
+ total += yield(ids)
56
+ break if ids.size < BATCH
57
+ end
58
+ total
59
+ end
60
+ end
61
+ end