ubicloud/clover_admin.rb
Jeremy Evans 5042633bb0 Support table-based model browsing and model searching on admin site using AutoForme
Table-based model browsing can be friendlier than the unordered list display
currently used on the admin site. However, you need to be careful to not
introduce N+1 queries when using table-based browsing.

AutoForme is a library that builds on top of Forme (already used on the
admin site) and provides the ability to browse and search the models in a
way that avoids N+1 queries.  It's quite flexible, requiring only a few
lines of configuration code per model to have it display and allow
searching of the columns desired.

AutoForme also supports CRUD actions for models, but those are currently
disabled, and it is only used for the tabular display and searching.
It also supports downloading of data in CSV format (both in browse and
search mode), which can be useful with external analysis tools.

One potential regression with the AutoForme based browsing and searching
is the use of offsets for pagination, instead of using a filter. If this
becomes problematic, it's possible to add filter-based pagination to
AutoForme.

While it is possible to implement table-based browsing without using
AutoForme, it would require reimplementing parts of AutoForme, and I
think using AutoForme will result in smaller and simpler code in the
long run.

Currently, this only implements the table-based browsing for Firewall
as a proof of concept. We can expand it to other models in the future.
2025-09-18 02:29:42 +09:00

295 lines
8.1 KiB
Ruby

# frozen_string_literal: true
require_relative "model"
require "roda"
require "tilt"
require "tilt/erubi"
require "openssl"
class CloverAdmin < Roda
# :nocov:
plugin :exception_page if Config.development?
default_fixed_locals = if Config.production? || ENV["CLOVER_FREEZE"] == "1"
"()"
# :nocov:
else
"(_no_kw: nil)"
end
plugin :render, views: "views/admin", escape: true, assume_fixed_locals: true, template_opts: {
chain_appends: !defined?(SimpleCov),
freeze: true,
skip_compiled_encoding_detection: true,
scope_class: self,
default_fixed_locals:,
extract_fixed_locals: true
}
plugin :public
plugin :flash
plugin :h
plugin :content_security_policy do |csp|
csp.default_src :none
csp.style_src :self
csp.img_src :self # /favicon.ico
csp.script_src :self # webauthn
csp.form_action :self
csp.base_uri :none
csp.frame_ancestors :none
end
plugin :sessions,
key: "_CloverAdmin.session",
cookie_options: {secure: !(Config.development? || Config.test?)},
secret: OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-site")
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
plugin :typecast_params do
ubid_regexp = /\A[a-tv-z0-9]{26}\z/
handle_type(:ubid) do
it if ubid_regexp.match?(it)
end
handle_type(:ubid_uuid) do
UBID.to_uuid(it) if ubid_regexp.match?(it)
end
end
plugin :symbol_matchers
symbol_matcher(:ubid, /([a-tv-z0-9]{26})/)
plugin :not_found do
raise "admin route not handled: #{request.path}" if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
@page_title = "File Not Found"
view(content: "")
end
plugin :route_csrf do |token|
flash.now["error"] = "An invalid security token submitted with this request, please try again"
@page_title = "Invalid Security Token"
view(content: "")
end
plugin :error_handler do |e|
raise e if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
Clog.emit("admin route exception") { Util.exception_to_hash(e) }
@page_title = "Internal Server Error"
view(content: "")
end
plugin :forme_route_csrf
Forme.register_config(:clover_admin, base: :default, labeler: :explicit)
Forme.default_config = :clover_admin
def self.create_admin_account(login)
if Config.production? && defined?(Pry)
raise "cannot create admin account in production via pry as it would log the password"
end
password = SecureRandom.urlsafe_base64(16)
password_hash = rodauth.new(nil).password_hash(password)
DB.transaction do
id = DB[:admin_account].insert(login:)
DB[:admin_password_hash].insert(id:, password_hash:)
end
Clog.emit("Created admin account") { {admin_account_created: login} }
password
end
plugin :rodauth, route_csrf: true do
enable :argon2, :login, :logout, :webauthn, :change_password
accounts_table :admin_account
password_hash_table :admin_password_hash
webauthn_keys_table :admin_webauthn_key
webauthn_user_ids_table :admin_webauthn_user_id
login_column :login
login_redirect do
uses_two_factor_authentication? ? "/webauthn-auth" : "/webauthn-setup"
end
check_csrf? false
require_bcrypt? false
title_instance_variable :@page_title
argon2_secret OpenSSL::HMAC.digest("SHA256", Config.clover_session_secret, "admin-argon2-secret")
hmac_secret OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-rodauth-hmac-secret")
function_name(&{
rodauth_get_salt: :rodauth_admin_get_salt,
rodauth_valid_password_hash: :rodauth_admin_valid_password_hash
}.to_proc)
password_minimum_length 16
password_maximum_bytes 72
password_meets_requirements? do |password|
super(password) && password.match?(/[a-z]/) && password.match?(/[A-Z]/) && password.match?(/[0-9]/)
end
end
ObjectAction = Data.define(:label, :flash, :action) do
def self.define(*, &action)
new(*, action)
end
def call(obj)
action.call(obj)
end
end
def self.object_action(...)
ObjectAction.define(...)
end
OBJECT_ACTIONS = {
"GithubRunner" => {
"provision" => object_action("Provision Spare Runner", "Spare runner provisioned", &:provision_spare_runner)
},
"PostgresResource" => {
"restart" => object_action("Restart", "Restart scheduled for PostgresResource", &:incr_restart)
},
"Strand" => {
"schedule" => object_action("Schedule Strand to Run Immediately", "Scheduled strand to run immediately") do |obj|
obj.this.update(schedule: Sequel::CURRENT_TIMESTAMP)
end
},
"Vm" => {
"restart" => object_action("Restart", "Restart scheduled for Vm", &:incr_restart)
},
"VmHost" => {
"accept" => object_action("Move to Accepting", "Host allocation state changed to accepting") do |obj|
obj.update(allocation_state: "accepting")
end,
"drain" => object_action("Move to Draining", "Host allocation state changed to draining") do |obj|
obj.update(allocation_state: "draining")
end,
"reset" => object_action("Hardware Reset", "Hardware reset scheduled for VmHost", &:incr_hardware_reset),
"reboot" => object_action("Reboot", "Reboot scheduled for VmHost", &:incr_reboot)
}
}.freeze
OBJECT_ACTIONS.each_value(&:freeze)
plugin :autoforme
autoforme_framework = ::AutoForme.for(:roda, self) do
# :nocov:
register_by_name if Config.development?
# :nocov:
order [:id]
supported_actions [:browse, :search]
form_options(wrapper: :div)
link = lambda do |obj|
"<a href=\"/model/#{obj.class}/#{obj.ubid}\">#{Erubi.h(obj.name)}</a>"
end
show_html do |obj, column|
case column
when :name
link.call(obj)
when :project, :location
link.call(obj.send(column))
end
end
model Firewall do
eager [:project, :location]
columns [:name, :project, :location, :description]
end
end
@autoforme_routes[nil] = autoforme_framework.route_proc
@autoforme_models = autoforme_framework.models
singleton_class.attr_reader :autoforme_models
route do |r|
r.public
check_csrf!
r.rodauth
rodauth.require_authentication
rodauth.require_two_factor_setup
# :nocov:
r.exception_page_assets if Config.development?
# :nocov:
r.on "model", /([A-Z][a-zA-Z]+)/ do |model_name|
begin
@klass = Object.const_get(model_name)
rescue NameError
next
end
next unless @klass.is_a?(Class) && @klass < ResourceMethods::InstanceMethods
r.get true do
limit = 101
ds = @klass.limit(limit).order(:id)
if (after = typecast_params.ubid_uuid("after"))
ds = ds.where { id > after }
end
@objects = ds.all
if @objects.length == limit
@objects.pop
@after = @objects.last.ubid
end
view("objects")
end
r.on :ubid do |ubid|
next unless (@obj = @klass[ubid])
r.get true do
view("object")
end
if (actions = OBJECT_ACTIONS[@obj.class.name])
r.is actions.keys do |key|
action = actions[key]
r.get do
@label = action.label
view("object_action")
end
r.post do
action.call(@obj)
flash["notice"] = action.flash
r.redirect("/model/#{@obj.class}/#{ubid}")
end
end
end
end
end
r.on "autoforme" do
autoforme
end
r.root do
if (ubid = typecast_params.ubid("ubid")) && (klass = UBID.class_for_ubid(ubid))
r.redirect("/model/#{klass.name}/#{ubid}")
elsif typecast_params.nonempty_str("ubid")
flash.now["error"] = "Invalid ubid provided"
end
@grouped_pages = Page.active.reverse(:created_at, :summary).group_by_vm_host
@classes = Sequel::Model
.subclasses
.map { [it, it.subclasses] }
.flatten
.select { it < ResourceMethods::InstanceMethods }
.sort_by(&:name)
view("index")
end
# :nocov:
if Config.test?
# :nocov:
r.get("error") { raise }
end
end
end