📌 Note:

Right now you are viewing the "rendered" version of the markdown document.

If you want to download the "raw" version, go here.

Slow task supervisor

📌 Note:

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}