Right now you are viewing the "rendered" version of the markdown document.
If you want to download the "raw" version, go here.
- Database setup
- Dependency management
- Installing it
- The dashboard
- Launching a task
- Viewing the full logs of a task
- Viewing most recent logs of a task
- Frontend stuff
- Security considerations
- Annex - Rubocop linting
- Annex - The license
- Annex - Updating the CSS
Slow task supervisor
This article uses Literate Programming: it contains runnable source-code! To extract the code from the MarkDown File, use the tool presented in the A new spin on literate programming article.
{"filename": "app.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>
require 'securerandom'
require 'open3'
require 'sinatra'
require 'sequel'
<<start_and_monitor>>
<<make_database_connection>>
<<configure_block>>
<<requesting_a_new_task>>
<<dashboard_backend>>
<<full_logs_backend>>
<<short_logs_backend>>
One important part of the application is the configure block. It defines
a key-value data-structure that gets evaluated once, at the launch
of the web-application, and controls how the application runs.
{"name": "configure_block"}
configure do
set :port, 8765
set :bind, '0.0.0.0'
set :sequel_conn, make_database_connection
end
The bind and port keys control the address that the web-server will listen
on. By default the bind address is localhost, so in order to make the
mirror accessible across the network we have to replace it with 0.0.0.0,
meaning "listen on all available network interfaces", on port 8765.
Inside the configure block we add a field that calls the
make_database_connection method at the start of the web application.
This way, from any request-handler we'll be able to access the tables
by accessing settings.sequel_conn.
Database setup
{"name": "make_database_connection"}
def make_database_connection
db = Sequel.connect('sqlite://store.db')
<<create_table_if_not_exists>>
<<seed_table_if_needed>>
db
end
{"name": "create_table_if_not_exists"}
db.create_table? :tasks do
String :id, primary_key: true, null: false
String :command, null: false
String :cwd, null: false
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
DateTime :finished_at, null: true
String :return_message, null: true
end
db.create_table? :buffers do
primary_key :id
foreign_key :task_id, :tasks
Integer :buffer_type
Integer :sequence_number
String :message, null: false
String :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
end
db.create_table? :commands do
primary_key :id
String :command
String :cwd
end
Now, we're at a known state: the tables exist, but we the list of available commands is empty. Let's add some code that inserts some commands into the table. If the table was already initialized, the code does nothing.
Based on code from here: https://rossney.net/articles/upserting-with-rom-and-sequel/
{"name": "seed_table_if_needed"}
conflict_options = {
target: :id,
update: {
command: Sequel[:excluded][:command],
cwd: Sequel[:excluded][:cwd]
}
}
<<disable_rubocop_long_lines>>
# You probably want to customize the list of commands that you can execute.
command_list = [
{ id: 0, command: 'kas build setup-beaglebone.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 1, command: 'kas build setup-genericarm64.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 2, command: 'kas build setup-genericx86.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 3, command: 'kas build setup-genericx86-64.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 4, command: 'kas build setup-olinuxino.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 5, command: 'kas build setup-pcduino.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 6, command: 'kas build setup-raspberry0w.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 7, command: 'kas build setup-raspberry4.yml:machine-flatbox.yml -- --continue', cwd: '/drive/yoctospace' },
{ id: 8, command: 'kas build setup-beaglebone.yml:component-firefox.yml:machine-flatbox.yml -- --continue', cwd: '/drivessd/yoctospace' },
{ id: 9, command: 'kas build setup-genericarm64.yml:component-firefox.yml:machine-flatbox.yml -- --continue', cwd: '/drivessd/yoctospace' },
{ id: 10, command: 'kas build setup-genericx86.yml:component-firefox.yml:machine-flatbox.yml -- --continue', cwd: '/drivessd/yoctospace' },
{ id: 11, command: 'kas build setup-genericx86-64.yml:component-firefox.yml:machine-flatbox.yml -- --continue', cwd: '/drivessd/yoctospace' },
{ id: 12, command: 'kas build setup-raspberry4.yml:component-firefox.yml:machine-flatbox.yml -- --continue', cwd: '/drivessd/yoctospace' }
]
<<enable_rubocop_long_lines>>
db[:commands].insert_conflict(conflict_options)
.multi_insert(command_list)
Dependency management
We'll store the libraries in the vendor/bundle subdirectory
of the application. This is done to avoid changing the host system's
libraries.
{"filename": ".bundle/config"}
---
BUNDLE_PATH: "vendor/bundle"
And then we add the libraries, one by one, in the Gemfile:
{"filename": "Gemfile"}
<<spdx_headers>>
source 'https://rubygems.org'
# Web-application prerequisites
gem 'puma'
gem 'sinatra'
gem 'rackup'
# SQL storage backend
gem 'sequel'
gem 'sqlite3'
<<rubocop_dependencies>>
Installing it
# Download the literate-programming file
wget https://personalcompute.net/assets/literate-programming/supervisor.md
# Unpack it
rbenv exec literate_tool.rb supervisor.md
# Install dependencies
bundle install
# Run it
bundle exec app.rb
The dashboard
{"name": "dashboard_backend"}
get '/' do
commands = settings.sequel_conn[:commands]
.order(:id)
tasks = settings.sequel_conn[:tasks]
.order(Sequel.desc(:created_at))
.limit(100)
erb :index_tasks, locals: {
tasks: tasks,
commands: commands
}
end
{"filename": "views/index_tasks.erb"}
<%#
<<spdx_headers>>
%>
<h1 class="text-3xl font-bold p-10">Supervised processes</h1>
<h2 class="text-xl font-bold">Available commands</h2>
<table class="border-2 border-black">
<tr>
<% commands.columns.each do |column_name| %>
<th><%= column_name %></th>
<% end%>
<th>Launch</th>
</tr>
<% commands.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<% row.each do |cell| %>
<td class="p-2"><%= cell[1] %></td>
<% end %>
<td>
<form action="/newtask/<%= row.first[1] %>" method="post">
<button type="submit">Start!</button>
</form>
</td>
</tr>
<% end %>
</table>
<h2 class="text-xl font-bold">Launched commands</h2>
<table class="border-2 border-black">
<tr>
<% tasks.columns.each do |column_name| %>
<th><%= column_name %></th>
<% end%>
<th>Full logs</th>
<th>Short logs</th>
</tr>
<% tasks.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<% row.each do |cell| %>
<td class="p-2"><%= cell[1] %></td>
<% end %>
<td>
<a href="/getFullLogs/<%= row.first[1].to_s %>">🔗</a>
</td>
<td>
<a href="/getShortLogs/<%= row.first[1].to_s %>">🔗</a>
</td>
</tr>
<% end %>
</table>
Launching a task
Executing a task changes the status of the system, so it doesn't fit the pattern
of writing a GET handler.
Instead, we write a handler for a POST-type request, that reads the parameters of the
requested task (the command to run and its directory), generates an unique ID, and
inserts those informations in the database.
{"name": "requesting_a_new_task"}
post '/newtask/:cmdid' do
row = settings.sequel_conn[:commands]
.where(id: params[:cmdid])
.first
cmd = row[:command]
opts = { chdir: row[:cwd] }
execid = SecureRandom.uuid
settings.sequel_conn[:tasks]
.insert(
id: execid,
command: cmd,
cwd: opts[:chdir]
)
# Start a separate thread that executes the command, writes to the database
# all the command's output, and waits for the command to finish.
Thread.new do
start_and_monitor_cmd(cmd.split, opts, execid, settings.sequel_conn)
end
# While the background thread is working, this request finishes with a
# redirect-response towards the browser.
redirect "/getFullLogs/#{execid}"
end
The core of this application is based on code from https://dmytro.sh/blog/on-dangers-of-open3-popen3/.
{"name": "start_and_monitor"}
def start_and_monitor_cmd(cmd, opts, taskid, db_conn)
#
# Taken from https://dmytro.sh/blog/on-dangers-of-open3-popen3/
#
Open3.popen3(*cmd, opts) do |child_stdin, child_stdout, child_stderr, child_return|
child_stdin.close
readables = [child_stdout, child_stderr]
sequence_number = 0
until readables.empty?
ready_pipes, = IO.select(readables) # Blocking call
if ready_pipes.include? child_stdout
stdout_buf = child_stdout.read_nonblock(4096, exception: false)
if stdout_buf.nil?
# Child's STDOUT has reached the end-of-file. Stop reading from it.
readables.delete(child_stdout)
else
# The output from the child-process is in stdout_buf
<<stdout_processing>>
end
end
<<disable_rubocop_next>>
if ready_pipes.include? child_stderr
stderr_buf = child_stderr.read_nonblock(4096, exception: false)
if stderr_buf.nil?
# Child's STDERR has reached the end-of-file. Stop reading from it.
readables.delete(child_stderr)
else
# The output from the child-process is in stderr_buf
<<stderr_processing>>
end
end
<<enable_rubocop_next>>
end
return_status = child_return.value # Blocking call
<<exit_status_processing>>
end
end
On top of the generic code, we store the STDOUT and STDERR messages in the database, along with a sequence number.
We use sequence-numbers because the created_at timestamps have 1-second resolution,
and if the application produces multiple messages in a second, those messages might
be displayed out-of-order. With sequence-numbers, we can reliably show the messages
in the same order they were originally printed.
{"name": "stdout_processing"}
db_conn[:buffers].insert(
task_id: taskid,
buffer_type: 0,
sequence_number: sequence_number,
message: stdout_buf
)
sequence_number += 1
{"name": "stderr_processing"}
db_conn[:buffers].insert(
task_id: taskid,
buffer_type: 1,
sequence_number: sequence_number,
message: stderr_buf
)
sequence_number += 1
Also, we store the exit-status of a task, together with the timestamp it exited.
{"name": "exit_status_processing"}
db_conn[:tasks].where(id: taskid)
.update(
finished_at: Time.now,
return_message: return_status.exitstatus.to_s
)
Viewing the full logs of a task
On the backend, we create a handler for the GET requests to the path
/getFullLogs/:execid (where :execid is a parameter accessed under the name params[:execid]).
We query the database for the buffers associated with that task, and push that result-set to the :view_task frontend template.
{"name": "full_logs_backend"}
get '/getFullLogs/:execid' do
buffer_result = settings.sequel_conn[:buffers]
.where(task_id: params[:execid])
.order(:sequence_number)
.all
erb :view_task, locals: {
buffer: buffer_result
}
end
On the frontend side, we plug the data from the result set into an HTML-formatted table, one row at a time.
{"filename": "views/view_task.erb"}
<%#
<<spdx_headers>>
%>
<table class="border-2 border-black">
<tr>
<th>Timestamp</th>
<th>Content</th>
<th>Type</th>
</tr>
<% buffer.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<td class="p-2">
<%= row[:created_at] %>
</td>
<td class="p-2">
<%= Rack::Utils.escape_html(row[:message]).gsub("\n", '<br/>') %>
</td>
<td class="p-2">
<%= (row[:buffer_type] == 0) ? "STDOUT" : "STDERR" %>
</td>
</tr>
<% end %>
</table>
Viewing most recent logs of a task
Some long-running tasks acummulate a lot of output-lines.
We write a handler for /getShortLogs/:execid, that shows
just the last 100 rows.
The frontend template used for rendering is reused.
{"name": "short_logs_backend"}
get '/getShortLogs/:execid' do
buffer_result = settings.sequel_conn[:buffers]
.where(task_id: params[:execid])
.order(Sequel.desc(:sequence_number))
.limit(7)
.all
.reverse
erb :view_task, locals: {
buffer: buffer_result
}
end
Frontend stuff
{"filename": "views/layout.erb"}
<%#
<<spdx_headers>>
%>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/css/output.css" rel="stylesheet">
</head>
<body>
<main class="container mx-auto mt-28 mb-32">
<%= yield %>
</main>
</body>
</html>
Security considerations
Evven if the application is simple, we should spend some time discussing what security practices are applied here.
Preventing SQL Injection Attacks
Since we're using the sequel library to handle database access, preventing
SQL injection atacks is simple: as long as we don't manually build SQL
queries using string-concatenation, and instead use key-value pairs, we're
safe. The sequel library handles the prepared-statements for us.
Preventing path-traversal attacks
The Sinatra framework comes with path-traversal prevention out of the box,
implemented in the Rack::Protection
middleware.
What's not handled: privacy & authentication
Anyone who can access the web-server can read all the previous execution-logs and can launch new processes. This is a tool intended for use on trusted networks.
Annex - Rubocop linting
RuboCop is a Ruby linter: it flags strange or awkward snippets of source-code, and sometimes it even can correct them.
To add RuboCop to a project, we first add it as dependency on a Gemfile, with a separate gem-group (called development). This makes RuboCop not a "strong" dependency but an optional one (ignored by default).
{"name": "rubocop_dependencies"}
# Rubocop linting
group :development, optional: true do
gem 'rubocop'
gem 'rubocop-performance'
gem 'rubocop-sequel'
end
To install it, we must first "unlock" the development group of dependencis, install it, and then run it.
# Unlock the "development" group of dependencies
bundle config set with 'development'
# Install the dependencies
bundle install
# Look for formatting issues
bundle exec rubocop
# Auto-fix formatting issues
bundle exec rubocop -A
Although the enforced coding style is quite good, some rules are not fitting the project, and we can disable them. Every project can configure which rules are applied, by configuring the tool.
{"filename": ".rubocop.yml"}
AllCops:
NewCops: enable
plugins:
- rubocop-sequel
- rubocop-performance
# Having complex code is not a problem. Sometimes we solve complex problems.
Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/MethodLength:
Enabled: false
# Ignore frozen-string warnings.
Style/FrozenStringLiteralComment:
Enabled: false
Style/MutableConstant:
Enabled: false
# This project's Gemfile lists dependencies in the logical order.
# Ordering them alphabetically would make no sense.
Bundler/OrderedGems:
Enabled: false
# Tech limitation.
Lint/ScriptPermission:
Enabled: false
Often we want to disable a rubocop rule just in a snippet of code. For this we can add a comment
containing the rule's name. Below we have two examples: one that toggles the Style/Next rule,
and another that toggles the Layout/LineLength rule.
{"name": "disable_rubocop_next"}
# rubocop:disable Style/Next
{"name": "enable_rubocop_next"}
# rubocop:enable Style/Next
{"name": "disable_rubocop_long_lines"}
# rubocop:disable Layout/LineLength
{"name": "enable_rubocop_long_lines"}
# rubocop:enable Layout/LineLength
Annex - The license
This project attepts to be mostly-compliant with the REUSE Software specifications.
One requirement of the specification is to have the text of the chosen license (MIT in our case) in a file in the LICENSES directory. As such, we add it in:
{"filename": "LICENSES/MIT.txt"}
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
Another prerequisite of the specification is that every copyrightable file must include two comments using a special formatting, containing the license and the author/copyright holder.
{"name": "spdx_headers"}
# SPDX-FileCopyrightText: 2026 PersonalCompute.Net <publisher@PersonalCompute.Net>
# SPDX-License-Identifier: MIT
We can check the REUSE compliance using the lint tool:
pipx run reuse lint
Annex - Updating the CSS
Since the stylesheet doesn't change that often, the minified CSS is stored inline.
To regenerate it, download the standalong tool form https://github.com/tailwindlabs/tailwindcss/releases and run it in the main directory.
chmod +x ~/Downloads/tailwindcss-linux-x64
~/Downloads/tailwindcss-linux-x64 -i ./public/css/input.css -o ./public/css/output.css --minify
{"filename": "public/css/input.css"}
@import "tailwindcss";
{"filename": "public/css/output.css"}
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-sky-700:oklch(50% .134 242.749);--color-gray-100:oklch(96.7% .003 264.542);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-bold:700;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.static\!{position:static!important}.start-1{inset-inline-start:calc(var(--spacing) * 1)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-28{margin-top:calc(var(--spacing) * 28)}.mb-32{margin-bottom:calc(var(--spacing) * 32)}.\!block{display:block!important}.block{display:block}.contents{display:contents}.hidden{display:none}.inline{display:inline}.table{display:table}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-black{border-color:var(--color-black)}.p-2{padding:calc(var(--spacing) * 2)}.p-10{padding:calc(var(--spacing) * 10)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[rdoc-ref\:BigDecimal\@Not\+a\+Number\]{rdoc-ref:BigDecimal@Not+a+Number}.\[ruby-dev\:28445\]{ruby-dev:28445}.even\:bg-gray-100:nth-child(2n){background-color:var(--color-gray-100)}@media (hover:hover){.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:text-white:hover{color:var(--color-white)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}