# Slow task supervisor

{% infobox %}
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](https://personalcompute.net/resources/literate-programming) article.
{% endinfobox %}

```lruby
{"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.

```lruby
{"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

```lruby
{"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
```

```lruby
{"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/>

```lruby
{"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.

```yml
{"filename": ".bundle/config"}
---
BUNDLE_PATH: "vendor/bundle"
```

And then we add the libraries, one by one, in the `Gemfile`:

```lruby
{"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

```bash
# 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

```lruby
{"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
```

```erb
{"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.

```lruby
{"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/>.


```lruby
{"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.

```lruby
{"name": "stdout_processing"}
          db_conn[:buffers].insert(
            task_id: taskid,
            buffer_type: 0,
            sequence_number: sequence_number,
            message: stdout_buf
          )

          sequence_number += 1
```

```lruby
{"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.

```lruby
{"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.

```lruby
{"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.

```erb
{"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.

```lruby
{"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

```erb
{"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`](https://sinatrarb.com/intro.html#configuring-attack-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](https://rubocop.org/) 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).

```lruby
{"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.

```bash
# 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](https://rubystyle.guide/) 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.

```yaml
{"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.

```lruby
{"name": "disable_rubocop_next"}
      # rubocop:disable Style/Next
```

```lruby
{"name": "enable_rubocop_next"}
      # rubocop:enable Style/Next
```

```lruby
{"name": "disable_rubocop_long_lines"}
  # rubocop:disable Layout/LineLength
```

```lruby
{"name": "enable_rubocop_long_lines"}
  # rubocop:enable Layout/LineLength
```

## Annex - The license

This project attepts to be mostly-compliant with the [REUSE Software](https://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.

```lruby
{"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:

```bash
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.

```bash
chmod +x ~/Downloads/tailwindcss-linux-x64                                            
~/Downloads/tailwindcss-linux-x64 -i ./public/css/input.css -o ./public/css/output.css --minify
```

```css
{"filename": "public/css/input.css"}
@import "tailwindcss";
```

```css
{"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}
```
