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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Resolves fresh host authority for every request without altering host classes.
|
|
5
|
+
class ApplicationController < ActionController::Base
|
|
6
|
+
# Routing metadata is useful; arbitrary inputs and search text are not logs.
|
|
7
|
+
LOG_FILTER = /\A(?!(?:controller|action|resource|id|format)\z)/
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
wrap_parameters false
|
|
10
|
+
layout "rails_ai_admin/application"
|
|
11
|
+
helper RailsAiAdmin::FieldsHelper
|
|
12
|
+
helper RailsAiAdmin::NavigationHelper
|
|
13
|
+
helper RailsAiAdmin::FiltersHelper
|
|
14
|
+
helper RailsAiAdmin::ToolViewsHelper
|
|
15
|
+
helper RailsAiAdmin::HistoryHelper
|
|
16
|
+
helper RailsAiAdmin::DashboardHelper
|
|
17
|
+
prepend_before_action :secure_headers
|
|
18
|
+
before_action :authenticate_admin
|
|
19
|
+
after_action :notice_discarded_list_settings
|
|
20
|
+
helper_method :resource_navigation, :admin_csp_nonce, :list_page_path, :encoded_return_state, :navigation_state, :navigation_params, :list_return_path, :list_return_label, :return_state_discarded?, :return_navigation_fields, :list_state_fields, :visible_resources, :allowed_action?, :record_label, :collection_jobs_available?, :admin_associations, :assistant_available?, :searchable_resources, :audit_log_available?, :actor_labels, :assistant_for?
|
|
21
|
+
|
|
22
|
+
rescue_from AccessDenied, with: :deny_access
|
|
23
|
+
rescue_from ConfigurationError do
|
|
24
|
+
admin_status(:unavailable)
|
|
25
|
+
end
|
|
26
|
+
rescue_from ActionController::InvalidAuthenticityToken do
|
|
27
|
+
mark_form_recovery
|
|
28
|
+
admin_status(:invalid_request)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
# Layouts loading the prebuilt Turbo bundle need this nonce in csp-nonce meta.
|
|
34
|
+
def admin_csp_nonce
|
|
35
|
+
raise ConfigurationError, "Admin security headers have not been prepared" unless @admin_csp_nonce.is_a?(String) && @admin_csp_nonce.present?
|
|
36
|
+
String.new(@admin_csp_nonce).freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Like Rails render, this does not halt an action. Callers must return when
|
|
40
|
+
# selecting a status instead of continuing toward business work.
|
|
41
|
+
def admin_status(kind)
|
|
42
|
+
@admin_status = Presentation::Status.fetch(kind)
|
|
43
|
+
response.headers.delete("X-RailsAiAdmin-Form-Recovery") if response.headers["X-RailsAiAdmin-Form-Recovery"] == "ready"
|
|
44
|
+
@status_sign_in_path = nil
|
|
45
|
+
if @admin_status.kind == "authentication_required"
|
|
46
|
+
path = RailsAiAdmin.configuration.authentication_path
|
|
47
|
+
@status_sign_in_path = String.new(path).freeze if path.is_a?(String) && path.valid_encoding? && path.ascii_only? && path.match?(Configuration::INTERNAL_PATH)
|
|
48
|
+
end
|
|
49
|
+
if request.format.html? || request.format.turbo_stream?
|
|
50
|
+
begin
|
|
51
|
+
render template: "rails_ai_admin/statuses/show", layout: "rails_ai_admin/status", formats: [ :html ],
|
|
52
|
+
content_type: "text/html", status: @admin_status.http_status
|
|
53
|
+
rescue StandardError
|
|
54
|
+
self.response_body = nil
|
|
55
|
+
render plain: @admin_status.message, content_type: "text/plain", status: @admin_status.http_status
|
|
56
|
+
end
|
|
57
|
+
else
|
|
58
|
+
render plain: @admin_status.message, status: @admin_status.http_status
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def process_action(*)
|
|
65
|
+
filters = Array(request.get_header("action_dispatch.parameter_filter")) + [ LOG_FILTER ]
|
|
66
|
+
request.set_header("action_dispatch.parameter_filter", filters)
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def secure_headers
|
|
71
|
+
@admin_csp_nonce = SecureRandom.base64(24)
|
|
72
|
+
response.headers["Cache-Control"] = "no-store, private"
|
|
73
|
+
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'nonce-#{@admin_csp_nonce}'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
|
74
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
75
|
+
response.headers["Referrer-Policy"] = "same-origin"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authenticate_admin
|
|
79
|
+
name = RailsAiAdmin.configuration.context_adapter
|
|
80
|
+
@context = name&.constantize&.new&.resolve(self)
|
|
81
|
+
raise AccessDenied, "UNAUTHENTICATED" unless @context.is_a?(Context)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def deny_access(error)
|
|
85
|
+
status = Presentation::Status.for_code(error.code)
|
|
86
|
+
if status.kind == "authentication_required" && (target = sign_in_redirect)
|
|
87
|
+
return redirect_to(target, status: :see_other)
|
|
88
|
+
end
|
|
89
|
+
mark_form_recovery if status.kind == "authentication_required"
|
|
90
|
+
admin_status(status.kind)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A plain page visit by a signed-out person goes to the host's sign-in
|
|
94
|
+
# page with the requested path (RAA-024). Form submissions, recovery,
|
|
95
|
+
# frames and non-HTML requests keep the 401, so no open form is lost. The
|
|
96
|
+
# query string is left out: it can carry search text. The host must still
|
|
97
|
+
# validate return_to before redirecting to it.
|
|
98
|
+
def sign_in_redirect
|
|
99
|
+
path = RailsAiAdmin.configuration.authentication_path
|
|
100
|
+
return unless path.is_a?(String) && path.match?(Configuration::INTERNAL_PATH)
|
|
101
|
+
return unless request.get? && request.format.html? && !request.xhr?
|
|
102
|
+
return if request.headers["Turbo-Frame"] || request.headers["X-RailsAiAdmin-Recovery-Intent"]
|
|
103
|
+
requested = request.path
|
|
104
|
+
return path unless requested.is_a?(String) && requested.match?(%r{\A/(?![/\\])[^\s]*\z}) && requested.length <= 2000
|
|
105
|
+
"#{path}?#{{ return_to: requested }.to_query}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mark_form_recovery
|
|
109
|
+
if is_a?(ResourcesController) && %w[create update].include?(action_name)
|
|
110
|
+
response.headers["X-RailsAiAdmin-Form-Recovery"] = "session"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve_list_navigation
|
|
115
|
+
raw = params[:return_to]
|
|
116
|
+
raw = raw.to_unsafe_h if raw.is_a?(ActionController::Parameters)
|
|
117
|
+
access = Authorization::Access.new(@context, @resource)
|
|
118
|
+
@list_navigation = if action_name == "recover_form"
|
|
119
|
+
Navigation::ListState.from_header(access, request.headers["X-RailsAiAdmin-Return-State"])
|
|
120
|
+
else
|
|
121
|
+
Navigation::ListState.new(access, raw)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def encoded_return_state = @list_navigation&.encoded || ""
|
|
126
|
+
def navigation_state = @list_navigation&.state || {}
|
|
127
|
+
def navigation_params = navigation_state.empty? ? {} : { return_to: navigation_state }
|
|
128
|
+
def return_state_discarded? = @list_navigation&.discarded? == true
|
|
129
|
+
|
|
130
|
+
def list_return_path
|
|
131
|
+
@list_navigation&.index_allowed? ? resources_path(resource: @resource.key, **navigation_state.symbolize_keys) : root_path
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def list_page_path(page)
|
|
135
|
+
raise ConfigurationError, "Invalid list page" unless page.is_a?(Integer) && (1..1000).cover?(page)
|
|
136
|
+
state = page == 1 ? navigation_state.except("page") : navigation_state.merge("page" => page.to_s)
|
|
137
|
+
resources_path(resource: @resource.key, **state.symbolize_keys)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def list_return_label = @list_navigation&.index_allowed? ? @resource.label : t("rails_ai_admin.layout.administration")
|
|
141
|
+
|
|
142
|
+
def return_navigation_fields
|
|
143
|
+
helpers.safe_join(Navigation::ListState.form_pairs(navigation_state, "return_to").map { |name, value| helpers.hidden_field_tag(name, value, id: nil) })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Hidden inputs that keep selected list settings in another GET list form.
|
|
147
|
+
def list_state_fields(*keys)
|
|
148
|
+
helpers.safe_join(Navigation::ListState.form_pairs(navigation_state.slice(*keys)).map { |name, value| helpers.hidden_field_tag(name, value, id: nil) })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def notice_discarded_list_settings
|
|
152
|
+
return unless response.redirect? && return_state_discarded?
|
|
153
|
+
message = t("rails_ai_admin.layout.settings_unavailable")
|
|
154
|
+
flash[:notice] = [ flash[:notice], message ].compact.join(" ") unless flash[:notice].to_s.include?(message)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Request-only memoization; no menu/grant snapshot survives authentication.
|
|
158
|
+
def resource_navigation
|
|
159
|
+
@resource_navigation ||= Navigation::Menu.build(context: @context,
|
|
160
|
+
resources: RailsAiAdmin.registry.resources, current_key: @resource&.key)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def visible_resources
|
|
164
|
+
RailsAiAdmin.registry.resources.select do |resource|
|
|
165
|
+
Authorization::Access.new(@context, resource).authorize!(:index)
|
|
166
|
+
rescue AccessDenied
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Resources in the menu that this actor may list and search, for the
|
|
172
|
+
# header search box. Request-local; no grant survives the request.
|
|
173
|
+
def searchable_resources
|
|
174
|
+
@searchable_resources ||= RailsAiAdmin.registry.resources.select do |resource|
|
|
175
|
+
next false if resource.navigation.hidden
|
|
176
|
+
access = Authorization::Access.new(@context, resource)
|
|
177
|
+
access.authorize!(:index)
|
|
178
|
+
access.fields(:search).any?
|
|
179
|
+
rescue AccessDenied
|
|
180
|
+
false
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def allowed_action?(resource, action, record = nil)
|
|
185
|
+
Authorization::Access.new(@context, resource).authorize!(action, record)
|
|
186
|
+
rescue AccessDenied
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether this page may offer "Ask the assistant" for a resource.
|
|
191
|
+
def assistant_for?(resource)
|
|
192
|
+
return false unless assistant_available?
|
|
193
|
+
Authorization::Access.new(@context, resource).authorize_assistant!
|
|
194
|
+
rescue AccessDenied
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def audit_log_available?
|
|
199
|
+
return @audit_log_available unless @audit_log_available.nil?
|
|
200
|
+
@audit_log_available = RailsAiAdmin.registry.resources.any? do |resource|
|
|
201
|
+
Authorization::Access.new(@context, resource).authorize_audit_log!
|
|
202
|
+
rescue AccessDenied
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def collection_jobs_available?
|
|
208
|
+
RailsAiAdmin.registry.resources.any? do |resource|
|
|
209
|
+
RailsAiAdmin.operation_registry.custom(resource).any? do |definition|
|
|
210
|
+
next false unless definition.scope == :collection
|
|
211
|
+
Authorization::Access.new(@context, resource).authorize_receipt!(definition.key, nil)
|
|
212
|
+
rescue AccessDenied
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The menu link follows the same grant as the assistant pages.
|
|
219
|
+
def assistant_available?
|
|
220
|
+
return false unless RailsAiAdmin.configuration.assistant
|
|
221
|
+
require "rails_ai_admin/assistant"
|
|
222
|
+
@assistant_available = Assistant.available?(@context) if @assistant_available.nil?
|
|
223
|
+
@assistant_available
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Request-local reference cache. Each target is read through its own
|
|
227
|
+
# resource policy; nothing is shared across requests or actors.
|
|
228
|
+
def admin_associations
|
|
229
|
+
@admin_associations ||= Fields::Association.new(@context, @resource)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Display names for stored actor references. The context adapter may
|
|
233
|
+
# answer actor_labels(context:, actor_refs:) with a Hash; otherwise a
|
|
234
|
+
# reference reads as "User #42".
|
|
235
|
+
def actor_labels(refs)
|
|
236
|
+
adapter = RailsAiAdmin.configuration.context_adapter&.constantize&.new
|
|
237
|
+
given = adapter.respond_to?(:actor_labels) ? adapter.actor_labels(context: @context, actor_refs: refs) : {}
|
|
238
|
+
given = {} unless given.is_a?(Hash)
|
|
239
|
+
refs.to_h do |ref|
|
|
240
|
+
label = given[ref]
|
|
241
|
+
label = nil unless label.is_a?(String) && label.valid_encoding? && label.length.between?(1, 200)
|
|
242
|
+
[ ref, label || default_actor_label(ref) ]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def default_actor_label(ref)
|
|
247
|
+
_, _, kind, id = ref.to_s.split(":", 4)
|
|
248
|
+
kind && id ? "#{kind.demodulize.titleize} ##{id}" : ref.to_s
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def record_label(resource, record, values: nil, view: :show)
|
|
252
|
+
Authorization::Access.new(@context, resource).record_label(record, values: values, view: view)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Admin Chat pages. A request only saves a message as an inbox turn and
|
|
5
|
+
# enqueues work; no provider call runs in a web request. RubyLLM is loaded
|
|
6
|
+
# inside actions only, so conventional hosts can eager-load this file.
|
|
7
|
+
class AssistantController < ApplicationController
|
|
8
|
+
RETRYABLE = %w[PROVIDER_UNAVAILABLE ASSISTANT_FAILED ATTEMPTS_EXHAUSTED AI_UNAVAILABLE STEP_LIMIT].freeze
|
|
9
|
+
FAILURES = {
|
|
10
|
+
"AI_UNAVAILABLE" => "The assistant is not available right now. The rest of the administration works as usual.",
|
|
11
|
+
"AI_REJECTED" => "The AI provider rejected this request. Try a shorter or different message.",
|
|
12
|
+
"PROVIDER_UNAVAILABLE" => "The AI provider did not respond. Your message is saved; try again.",
|
|
13
|
+
"ASSISTANT_FAILED" => "The assistant could not finish. Your message is saved; try again.",
|
|
14
|
+
"ATTEMPTS_EXHAUSTED" => "The assistant stopped after several attempts. Your message is saved; try again.",
|
|
15
|
+
"STEP_LIMIT" => "The assistant stopped before finishing this request. Try a narrower question.",
|
|
16
|
+
"AUTHORIZATION_REVOKED" => "Your access changed, so this request was not answered.",
|
|
17
|
+
"NOT_ALLOWED" => "No resources are available to the assistant for your account.",
|
|
18
|
+
"CANCELLED" => "Stopped."
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
before_action :require_assistant
|
|
22
|
+
before_action :load_chat, except: %i[index create]
|
|
23
|
+
around_action :bind_surface_context
|
|
24
|
+
|
|
25
|
+
def index
|
|
26
|
+
@chats = Assistant::Chat.for_owner(@context).order(updated_at: :desc, id: :desc).limit(50).to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A new chat; from a record or list page it starts with the question and a
|
|
30
|
+
# server-written "Context:" line naming that page.
|
|
31
|
+
def create
|
|
32
|
+
question = params[:content]
|
|
33
|
+
raise AccessDenied, "INVALID_QUERY" unless question.nil? || question.is_a?(String)
|
|
34
|
+
chat = Assistant.create_chat!(@context)
|
|
35
|
+
if question.present?
|
|
36
|
+
content = [ page_context_line, question.strip ].compact.join("\n\n").first(Assistant::Coordinator::MAX_CONTENT)
|
|
37
|
+
Assistant::Coordinator.admit!(chat, kind: "message", content: content, admission_key: "message:#{SecureRandom.uuid}")
|
|
38
|
+
end
|
|
39
|
+
redirect_to assistant_conversation_path(chat_ref: chat.ref), status: :see_other
|
|
40
|
+
rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::ModelRegistryError
|
|
41
|
+
admin_status(:unavailable)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
prepare_transcript
|
|
46
|
+
if turbo_frame_request?
|
|
47
|
+
# A complete standards-mode document; Turbo extracts the matching frame.
|
|
48
|
+
render :frame, layout: "rails_ai_admin/frame"
|
|
49
|
+
else
|
|
50
|
+
@admission_key = "message:#{SecureRandom.uuid}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def message
|
|
55
|
+
return admin_status(:conflict) unless @chat.current_for?(@context)
|
|
56
|
+
Assistant::Coordinator.admit!(@chat, kind: "message", content: params[:content], admission_key: params[:admission_key])
|
|
57
|
+
redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
|
|
58
|
+
rescue AccessDenied => error
|
|
59
|
+
raise unless %w[INVALID_QUERY RATE_LIMITED].include?(error.code)
|
|
60
|
+
prepare_transcript
|
|
61
|
+
@admission_key = params[:admission_key].is_a?(String) && params[:admission_key].match?(Assistant::Coordinator::ADMISSION_KEY) ? params[:admission_key] : "message:#{SecureRandom.uuid}"
|
|
62
|
+
@draft = params[:content].is_a?(String) ? params[:content].first(Assistant::Coordinator::MAX_CONTENT + 1) : ""
|
|
63
|
+
if error.code == "RATE_LIMITED"
|
|
64
|
+
@message_error = t("rails_ai_admin.assistant.rate_limited", count: RailsAiAdmin.configuration.assistant_daily_message_limit)
|
|
65
|
+
return render :show, status: :too_many_requests
|
|
66
|
+
end
|
|
67
|
+
@message_error = t("rails_ai_admin.assistant.invalid_message", count: Assistant::Coordinator::MAX_CONTENT)
|
|
68
|
+
render :show, status: :unprocessable_content
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stops waiting turns now and asks a running one to stop at its next
|
|
72
|
+
# checkpoint. Committed business results are never undone.
|
|
73
|
+
def cancel
|
|
74
|
+
Assistant::Turn.transaction do
|
|
75
|
+
Assistant::Chat.lock.find(@chat.id)
|
|
76
|
+
@chat.turns.where(status: "queued").update_all(status: "cancelled", error_code: "CANCELLED", completed_at: Time.current, updated_at: Time.current)
|
|
77
|
+
@chat.cancel if @chat.turns.where(status: "running").exists?
|
|
78
|
+
end
|
|
79
|
+
redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Requeues the latest failed turn. The saved human message is reused, so
|
|
83
|
+
# retrying never repeats it in the conversation.
|
|
84
|
+
def retry_turn
|
|
85
|
+
return admin_status(:conflict) unless @chat.current_for?(@context)
|
|
86
|
+
Assistant::Turn.transaction do
|
|
87
|
+
Assistant::Chat.lock.find(@chat.id)
|
|
88
|
+
turn = @chat.turns.reorder(sequence: :desc).first
|
|
89
|
+
raise AccessDenied, "CONFLICT" unless turn&.status == "failed" && RETRYABLE.include?(turn.error_code)
|
|
90
|
+
turn.update!(status: "queued", attempts: 0, error_code: nil, next_attempt_at: nil, completed_at: nil)
|
|
91
|
+
end
|
|
92
|
+
Assistant::Coordinator.enqueue_after_commit(@chat.ref)
|
|
93
|
+
redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def archive
|
|
97
|
+
@chat.update!(status: "archived")
|
|
98
|
+
redirect_to assistant_path, status: :see_other, notice: t("rails_ai_admin.assistant.archived")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Names the page only when the actor may show it to the assistant now. The
|
|
104
|
+
# line is data for the model and grants nothing; tools check again.
|
|
105
|
+
def page_context_line
|
|
106
|
+
about = params[:about]
|
|
107
|
+
return unless about.is_a?(ActionController::Parameters)
|
|
108
|
+
resource = RailsAiAdmin.registry.fetch(about[:resource].to_s)
|
|
109
|
+
access = Authorization::Access.new(@context, resource)
|
|
110
|
+
access.authorize_assistant!
|
|
111
|
+
id = about[:id]
|
|
112
|
+
if id.is_a?(String) && id.present?
|
|
113
|
+
raise AccessDenied unless access.canonical_id?(id)
|
|
114
|
+
record = access.find!(id)
|
|
115
|
+
"Context: I am looking at #{resource.label.singularize} #{record.id} (\"#{access.record_label(record).truncate(120)}\") in the #{resource.key} resource."
|
|
116
|
+
else
|
|
117
|
+
query = about[:q].is_a?(String) ? about[:q].strip.first(100) : nil
|
|
118
|
+
scope = about[:scope].is_a?(String) ? access.named_scopes.find { |definition| definition.name.to_s == about[:scope] } : nil
|
|
119
|
+
details = [ (query.present? ? "searching for \"#{query}\"" : nil), (scope ? "on the #{scope.label} tab (scope #{scope.name})" : nil) ].compact
|
|
120
|
+
"Context: I am looking at the #{resource.label} list (the #{resource.key} resource)#{details.any? ? ", #{details.join(', ')}" : ''}."
|
|
121
|
+
end
|
|
122
|
+
rescue AccessDenied, ConfigurationError, KeyError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def require_assistant
|
|
127
|
+
raise AccessDenied unless RailsAiAdmin.configuration.assistant
|
|
128
|
+
require "rails_ai_admin/assistant"
|
|
129
|
+
raise AccessDenied unless Assistant.available?(@context)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def load_chat
|
|
133
|
+
@chat = Assistant::Chat.owned(@context, params[:chat_ref]) || raise(AccessDenied)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Surfaces in a conversation render through the admin-bound context.
|
|
137
|
+
def bind_surface_context(&block)
|
|
138
|
+
return yield unless RailsAiAdmin.configuration.surfaces
|
|
139
|
+
require "rails_ai_admin/integrations/tool_views"
|
|
140
|
+
Integrations::ToolViews.ensure_registered!
|
|
141
|
+
Integrations::ToolViews.with_context(@context, &block)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A security change closes the chat: its transcript, including old tool
|
|
145
|
+
# output, is no longer shown under the new authority.
|
|
146
|
+
def prepare_transcript
|
|
147
|
+
@current = @chat.current_for?(@context)
|
|
148
|
+
@entries = @current ? Assistant::Transcript.new(@context, @chat).entries : []
|
|
149
|
+
last = @chat.turns.reorder(sequence: :desc).first
|
|
150
|
+
@pending = @current && @chat.turns.where(status: Assistant::Turn::ACTIVE).exists?
|
|
151
|
+
running = @pending ? @chat.turns.where(status: "running").reorder(:sequence).first : nil
|
|
152
|
+
@running = !running.nil?
|
|
153
|
+
# The reply being written, shown only under the chat's current authority.
|
|
154
|
+
@streaming_text = running&.draft.presence
|
|
155
|
+
retrying = @pending && @chat.turns.where(status: "queued").where.not(error_code: nil).reorder(:sequence).first
|
|
156
|
+
@retry_status = t("rails_ai_admin.assistant.retrying", attempt: retrying.attempts + 1, attempts: Assistant::Coordinator::MAX_ATTEMPTS) if retrying
|
|
157
|
+
@failure = last if last&.status == "failed" || last&.status == "cancelled"
|
|
158
|
+
code = @failure && (FAILURES.key?(@failure.error_code.to_s) ? @failure.error_code.to_s : "ASSISTANT_FAILED")
|
|
159
|
+
@failure_message = code && t(code.downcase, scope: %i[rails_ai_admin assistant failures], default: FAILURES.fetch(code))
|
|
160
|
+
@retryable = @current && @failure&.status == "failed" && RETRYABLE.include?(@failure.error_code)
|
|
161
|
+
@surface_scope = Integrations::ToolViews.scope_for(@context) if RailsAiAdmin.configuration.surfaces
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Recent changes across records for the resources the actor may audit.
|
|
5
|
+
class AuditController < ApplicationController
|
|
6
|
+
FILTERS = %w[resource operation status actor before].freeze
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@log = Presentation::AuditLog.new(@context)
|
|
10
|
+
@filters = FILTERS.to_h { |key| [ key, params[key] ] }.compact_blank
|
|
11
|
+
raise AccessDenied, "INVALID_QUERY" unless @filters.values.all? { |value| value.is_a?(String) }
|
|
12
|
+
@page = @log.page(**@filters.transform_keys(&:to_sym))
|
|
13
|
+
@actor_labels = actor_labels(@page.entries.map(&:actor_ref).uniq | [ @filters["actor"] ].compact)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
class CollectionJobsController < ApplicationController
|
|
5
|
+
# The page exists only where its menu link does (RAA-030).
|
|
6
|
+
def index
|
|
7
|
+
raise AccessDenied unless collection_jobs_available?
|
|
8
|
+
@jobs = Collections::Queries.new.recent(context: @context)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
@job = Collections::Queries.new.fetch(context: @context, job_ref: params[:job_ref])
|
|
13
|
+
@resource = RailsAiAdmin.registry.fetch(@job.resource_key)
|
|
14
|
+
resolve_list_navigation
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancel
|
|
18
|
+
run = OperationRun.find_by(id: params[:job_ref], actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
|
|
19
|
+
raise AccessDenied unless run
|
|
20
|
+
@resource = RailsAiAdmin.registry.fetch(run.resource_key)
|
|
21
|
+
resolve_list_navigation
|
|
22
|
+
result = Collections::Runner.new.cancel(context: @context, job_ref: params[:job_ref])
|
|
23
|
+
return render plain: t("rails_ai_admin.collection_jobs.busy"), status: :conflict if result.code == "CONFLICT"
|
|
24
|
+
if result.code == "OUTCOME_UNKNOWN"
|
|
25
|
+
return redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other,
|
|
26
|
+
notice: t("rails_ai_admin.collection_jobs.cancel_unknown")
|
|
27
|
+
end
|
|
28
|
+
raise AccessDenied unless result.job_ref
|
|
29
|
+
redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other,
|
|
30
|
+
notice: t("rails_ai_admin.collection_jobs.cancelled")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
class CollectionOperationsController < ApplicationController
|
|
5
|
+
before_action :resolve_operation
|
|
6
|
+
|
|
7
|
+
def new
|
|
8
|
+
load_form
|
|
9
|
+
@form_values, @errors = {}, {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def preflight
|
|
13
|
+
input = submitted_input
|
|
14
|
+
@proposal = Collections::Runner.new.preflight(context: @context, resource: @resource, operation: @definition.key,
|
|
15
|
+
selection_ref: params[:selection_ref], input: input)
|
|
16
|
+
if @proposal.is_a?(Collections::Result)
|
|
17
|
+
render_rejection(@proposal, input)
|
|
18
|
+
else
|
|
19
|
+
@wire_input = @proposal.intent.input_payload.to_json
|
|
20
|
+
render :confirmation
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def execute
|
|
25
|
+
input = confirmed_input
|
|
26
|
+
result = Collections::Runner.new.admit(context: @context, intent_ref: params[:intent_ref], input: input,
|
|
27
|
+
human_confirmed: true, binding: { resource_key: @resource.key, operation: @definition.key, selection_id: params[:selection_ref] })
|
|
28
|
+
if result.accepted?
|
|
29
|
+
redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other, notice: t("rails_ai_admin.collection_operations.admitted")
|
|
30
|
+
else
|
|
31
|
+
render_rejection(result, input)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def resolve_operation
|
|
38
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
39
|
+
@definition = RailsAiAdmin.operation_registry.fetch(@resource, params[:operation])
|
|
40
|
+
raise AccessDenied unless @definition.custom? && @definition.scope == :collection
|
|
41
|
+
@access = Authorization::Access.new(@context, @resource)
|
|
42
|
+
resolve_list_navigation
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def load_form
|
|
46
|
+
@selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref], resource: @resource)
|
|
47
|
+
@access.authorize!(@definition.key)
|
|
48
|
+
@form_fields = @definition.fields(@access, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def submitted_input
|
|
52
|
+
value = params[:input]
|
|
53
|
+
raise AccessDenied, "INVALID_QUERY" unless value.nil? || value.is_a?(ActionController::Parameters)
|
|
54
|
+
value ? value.to_unsafe_h : {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def confirmed_input
|
|
58
|
+
value = params[:confirmed_input]
|
|
59
|
+
raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= 16_384
|
|
60
|
+
JSON.parse(value)
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_rejection(result, input)
|
|
66
|
+
raise AccessDenied if result.code == "NOT_FOUND"
|
|
67
|
+
@failure_code = result.code
|
|
68
|
+
@uncertain_job_ref = result.job_ref if result.code == "OUTCOME_UNKNOWN"
|
|
69
|
+
if result.code == "VALIDATION_FAILED"
|
|
70
|
+
load_form
|
|
71
|
+
@form_values = input.is_a?(Hash) ? input.stringify_keys.slice(*@form_fields.map { |field| field.name.to_s }).reject { |_, value| value.is_a?(Array) || value.is_a?(Hash) } : {}
|
|
72
|
+
@errors = result.errors
|
|
73
|
+
render :new, status: :unprocessable_content
|
|
74
|
+
else
|
|
75
|
+
render :failure, status: %w[TIMEOUT OPERATION_FAILED].include?(result.code) ? :service_unavailable : :conflict
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Public base for host-owned Rails routes, controllers and partials. Hosts keep
|
|
5
|
+
# their own route helpers/layout; admin engine links use the mounted proxy.
|
|
6
|
+
class CustomController < ApplicationController
|
|
7
|
+
layout false
|
|
8
|
+
helper_method :admin_routes
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def admin_context
|
|
13
|
+
raise AccessDenied, "UNAUTHENTICATED" unless @context.is_a?(Context)
|
|
14
|
+
@context
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolving an access object grants nothing: explicitly authorize, use its
|
|
18
|
+
# scoped find/relation and project permitted values before displaying them.
|
|
19
|
+
def admin_access(resource_key)
|
|
20
|
+
Authorization::Access.new(admin_context, RailsAiAdmin.registry.fetch(resource_key))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def admin_operation_runner = Operations::Runner.new
|
|
24
|
+
def admin_routes = rails_ai_admin
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
class DashboardController < ApplicationController # :nodoc:
|
|
5
|
+
# Counts appear only for resources whose list already opts into exact
|
|
6
|
+
# counts, with the same bound (10,000) and default list tab.
|
|
7
|
+
def index
|
|
8
|
+
@widgets = Presentation::Dashboard.build(@context, RailsAiAdmin.registry.resources.reject { |resource| resource.navigation.hidden })
|
|
9
|
+
@counts = RailsAiAdmin.registry.resources.each_with_object({}) do |resource, counts|
|
|
10
|
+
next unless resource.index.count == :exact
|
|
11
|
+
counts[resource.key.to_s] = Queries::List.new(@context, resource).count
|
|
12
|
+
rescue AccessDenied
|
|
13
|
+
next
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
# Downloads the current list as CSV. POST keeps GET requests free of the
|
|
7
|
+
# audit write and lets the CSRF token guard the download.
|
|
8
|
+
class ExportsController < ApplicationController
|
|
9
|
+
def create
|
|
10
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
11
|
+
access = Authorization::Access.new(@context, @resource)
|
|
12
|
+
access.authorize_export!
|
|
13
|
+
raw = params.to_unsafe_h.slice(*Navigation::ListState::KEYS).except("page")
|
|
14
|
+
@list_navigation = Navigation::ListState.new(access, raw, strict: true)
|
|
15
|
+
raise AccessDenied, "INVALID_QUERY" if @list_navigation.discarded?
|
|
16
|
+
state = @list_navigation.state
|
|
17
|
+
result = Presentation::CsvExport.new(@context, @resource).call(search: state["q"], sort: state["sort"],
|
|
18
|
+
direction: state.fetch("direction", "asc"), scope: state["scope"], filters: state["filter"])
|
|
19
|
+
if result.too_many
|
|
20
|
+
redirect_to resources_path(resource: @resource.key, **state.symbolize_keys), status: :see_other,
|
|
21
|
+
notice: t("rails_ai_admin.exports.too_many", limit: RailsAiAdmin.configuration.export_limit)
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
AuditEvent.create!(actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref, resource_key: @resource.key.to_s,
|
|
25
|
+
operation: "export", status: "succeeded", code: "OK", correlation_ref: SecureRandom.uuid, origin: "ui",
|
|
26
|
+
changed_fields: result.fields, created_at: Time.current)
|
|
27
|
+
filename = "#{@resource.key}-#{Time.current.in_time_zone(@context.time_zone).strftime('%Y%m%d-%H%M')}.csv"
|
|
28
|
+
send_data result.body, filename: filename, type: "text/csv; charset=utf-8", disposition: "attachment"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Serves an attached file after the same checks as the record's page: the
|
|
5
|
+
# record in scope, its show grant and a current read grant for the field.
|
|
6
|
+
# Only raster images are shown inline; everything else downloads.
|
|
7
|
+
class FilesController < ApplicationController
|
|
8
|
+
def show
|
|
9
|
+
raise AccessDenied unless defined?(::ActiveStorage::Blob)
|
|
10
|
+
resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
11
|
+
access = Authorization::Access.new(@context, resource)
|
|
12
|
+
raise AccessDenied unless access.canonical_id?(params[:id])
|
|
13
|
+
record = access.find!(params[:id])
|
|
14
|
+
field = access.fields(:read, record).find { |candidate| Field::FILE_TYPES.include?(candidate.scalar_type) && candidate.name.to_s == params[:field] }
|
|
15
|
+
raise AccessDenied unless field
|
|
16
|
+
attached = access.attachment(record, field)
|
|
17
|
+
blob = if field.scalar_type == :files
|
|
18
|
+
id = params[:attachment]
|
|
19
|
+
raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
|
|
20
|
+
access.stored_attachments(record, field).find_by(id: id)&.blob
|
|
21
|
+
else
|
|
22
|
+
attached.attached? ? attached.blob : nil
|
|
23
|
+
end
|
|
24
|
+
raise AccessDenied unless blob
|
|
25
|
+
inline = params[:disposition] == "inline" && Fields::INLINE_IMAGE_TYPES.include?(blob.content_type)
|
|
26
|
+
# A served file is a document of its own: no script, no framing.
|
|
27
|
+
response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox"
|
|
28
|
+
response.headers["Cache-Control"] = "no-store, private"
|
|
29
|
+
response.headers["Content-Type"] = inline ? blob.content_type : "application/octet-stream"
|
|
30
|
+
response.headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format(
|
|
31
|
+
disposition: inline ? "inline" : "attachment", filename: blob.filename.sanitized)
|
|
32
|
+
response.headers["Content-Length"] = blob.byte_size.to_s
|
|
33
|
+
# Streamed in the service's chunks; a large file is never held in memory.
|
|
34
|
+
self.response_body = Enumerator.new { |body| blob.download { |chunk| body << chunk } }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|