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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- 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
|