From cba304d4775026e19d605ff2ba52d621a07e4d1e Mon Sep 17 00:00:00 2001 From: Leigh Halliday Date: Mon, 21 Sep 2026 18:04:43 -0400 Subject: [PATCH 1/2] Adding batches support --- CHANGELOG.md | 8 + Gemfile.lock | 44 ++--- README.md | 5 +- .../flightdeck/flightdeck-38ba36531cee.css | 3 - .../flightdeck/flightdeck-f31d22011660.css | 3 + app/assets/flightdeck/manifest.json | 8 +- .../flightdeck/application_controller.rb | 3 +- .../flightdeck/batches_controller.rb | 44 +++++ app/helpers/flightdeck/application_helper.rb | 9 + app/helpers/flightdeck/batches_helper.rb | 55 ++++++ app/models/flightdeck/batch_detail.rb | 51 ++++++ app/models/flightdeck/batch_row.rb | 39 ++++ app/models/flightdeck/batches_query.rb | 135 ++++++++++++++ app/models/flightdeck/job_detail.rb | 18 ++ app/models/flightdeck/jobs_query.rb | 19 +- app/views/flightdeck/batches/_list.html.erb | 25 +++ app/views/flightdeck/batches/_row.html.erb | 15 ++ app/views/flightdeck/batches/index.html.erb | 16 ++ app/views/flightdeck/batches/show.html.erb | 95 ++++++++++ app/views/flightdeck/jobs/_list.html.erb | 8 +- app/views/flightdeck/jobs/_pager.html.erb | 22 --- app/views/flightdeck/jobs/show.html.erb | 18 ++ app/views/flightdeck/shared/_pager.html.erb | 18 ++ .../layouts/flightdeck/application.html.erb | 7 + assets-src/input.css | 14 ++ config/routes.rb | 2 + solid_queue-flightdeck.gemspec | 2 +- test/batches_query_test.rb | 121 +++++++++++++ test/dummy/db/schema.rb | 31 ++++ test/dummy/db/seeds.rb | 19 +- test/integration/batches_test.rb | 166 ++++++++++++++++++ test/integration/csp_inline_styles_test.rb | 10 +- test/integration/frame_navigation_test.rb | 32 +++- test/integration/nested_mount_test.rb | 3 +- test/job_detail_test.rb | 19 ++ test/jobs_query_test.rb | 28 +-- test/support/solid_queue_scenario.rb | 63 +++++++ test/test_helper.rb | 28 +++ 38 files changed, 1128 insertions(+), 78 deletions(-) delete mode 100644 app/assets/flightdeck/flightdeck-38ba36531cee.css create mode 100644 app/assets/flightdeck/flightdeck-f31d22011660.css create mode 100644 app/controllers/flightdeck/batches_controller.rb create mode 100644 app/helpers/flightdeck/batches_helper.rb create mode 100644 app/models/flightdeck/batch_detail.rb create mode 100644 app/models/flightdeck/batch_row.rb create mode 100644 app/models/flightdeck/batches_query.rb create mode 100644 app/views/flightdeck/batches/_list.html.erb create mode 100644 app/views/flightdeck/batches/_row.html.erb create mode 100644 app/views/flightdeck/batches/index.html.erb create mode 100644 app/views/flightdeck/batches/show.html.erb delete mode 100644 app/views/flightdeck/jobs/_pager.html.erb create mode 100644 app/views/flightdeck/shared/_pager.html.erb create mode 100644 test/batches_query_test.rb create mode 100644 test/integration/batches_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e2ccd..574de3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- A Batches page (and a "Part of" card on job detail) when Solid Queue 1.7+ is installed and the optional batches migration has run. + +### Fixed + +- A list of one job says "1 job" (and "1 failed job") instead of the plural. + ## [1.2.0] - 2026-09-04 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 6c13732..189c9b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,7 +86,7 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) - bigdecimal (4.1.2) + bigdecimal (4.1.3) builder (3.3.0) capybara (3.40.0) addressable @@ -102,24 +102,24 @@ GEM crass (1.0.7) date (3.5.1) drb (2.2.3) - erb (6.0.6) + erb (6.0.7) erubi (1.13.1) - et-orbi (1.4.0) + et-orbi (1.4.2) tzinfo - fugit (1.13.0) + fugit (1.14.0) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) i18n (1.15.2) concurrent-ruby (~> 1.0) - io-console (0.8.2) + io-console (0.9.4) irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.21.1) + json (2.21.2) language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) @@ -177,7 +177,7 @@ GEM nio4r (~> 2.0) raabro (1.5.0) racc (1.8.1) - rack (3.2.6) + rack (3.2.7) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -217,7 +217,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) - rbs (4.1.0) + rbs (4.2.0) logger prism (>= 1.6.0) tsort @@ -227,7 +227,7 @@ GEM rbs (>= 4.0.0) tsort regexp_parser (2.12.0) - reline (0.6.3) + reline (0.7.0) io-console (~> 0.5) rexml (3.4.4) rubocop (1.88.2) @@ -267,7 +267,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - solid_queue (1.5.0) + solid_queue (1.7.0) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) @@ -306,7 +306,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.8.2) + zeitwerk (2.8.3) PLATFORMS aarch64-linux-gnu @@ -345,7 +345,7 @@ CHECKSUMS addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef @@ -354,15 +354,15 @@ CHECKSUMS crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc - fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + fugit (1.14.0) sha256=e311a9cc85a54bb67649df45060e721282a1e2c8ebad40e41874a3a7d576f37e globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 - io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 - json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -394,7 +394,7 @@ CHECKSUMS puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 @@ -404,10 +404,10 @@ CHECKSUMS railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - rbs (4.1.0) sha256=8baba59008b0643b4ba2090e9b1d0149655b0bbd42eb2ffe42b1d0eb6923fd72 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb - reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db @@ -418,7 +418,7 @@ CHECKSUMS rubyzip (3.4.1) sha256=0a79e745b5c25872ebd148457df5665da8530ed8626c993b01457128f173ca02 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.46.0) sha256=cb6cfa6f79eac041749df0b14cdcb0e9fe5df3715a64c77bfaa56b345b99f957 - solid_queue (1.5.0) sha256=329f29caab9cc68eefe489013eb5a38ea4a66708ed2bee301384dbed91890f7f + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 solid_queue-flightdeck (1.2.0) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d @@ -447,7 +447,7 @@ CHECKSUMS websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e - zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 BUNDLED WITH 4.0.16 diff --git a/README.md b/README.md index 645a020..b25b368 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ gem "solid_queue-flightdeck" ``` No migrations, no asset configuration. Requires Ruby >= 3.1, Rails >= 7.1 and -Solid Queue >= 1.0. +Solid Queue >= 1.0. The Batches page appears when the host is on Solid Queue +1.7+ **and** has run the optional batches migration (`bin/rails solid_queue:update`). +Hosts on older Solid Queue, or that have not migrated yet, see the rest of the +dashboard unchanged. ## Mounting diff --git a/app/assets/flightdeck/flightdeck-38ba36531cee.css b/app/assets/flightdeck/flightdeck-38ba36531cee.css deleted file mode 100644 index c75d50f..0000000 --- a/app/assets/flightdeck/flightdeck-38ba36531cee.css +++ /dev/null @@ -1,3 +0,0 @@ -@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-barlow-400-7a686e763128.woff2) format('woff2')}@font-face{font-family:'Barlow';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-barlow-500-80e7dc5605dd.woff2) format('woff2')}@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(flightdeck-barlow-600-488b58098126.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-general-sans-400-3ec2be771caf.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-general-sans-500-c30377df1de8.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:600;font-display:swap;src:url(flightdeck-general-sans-600-94a2a0e1ef59.woff2) format('woff2')}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-ibm-plex-mono-400-c36f509c0a8f.woff2) format('woff2')}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-ibm-plex-mono-500-a76f53ca6612.woff2) format('woff2')}@font-face{font-family:'Inter';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-inter-variable-c940764593d0.woff2) format('woff2')}@font-face{font-family:'Manrope';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-manrope-variable-e310b55a7fd9.woff2) format('woff2')}@font-face{font-family:'Public Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-public-sans-variable-c1b6da516e00.woff2) format('woff2')} -/*! tailwindcss v4.3.3 | 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-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-mono:var(--font-mono);--default-font-family:var(--font-ui);--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,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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:where(:not(iframe)){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{.table{display:table}.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,)}}:root{--fd-light-bg:#f3f5f7;--fd-light-panel:#fff;--fd-light-panel-2:#eef1f4;--fd-light-panel-3:#e6ebef;--fd-light-ink:#10161c;--fd-light-ink-2:#4e5a66;--fd-light-ink-3:#85919d;--fd-light-line:#dce2e8;--fd-light-line-2:#e9edf1;--fd-light-accent:#2a78d6;--fd-light-accent-ink:#1c5cab;--fd-light-accent-wash:#2a78d617;--fd-light-good:#0ca30c;--fd-light-good-text:#006300;--fd-light-warn:#fab219;--fd-light-serious:#ec835a;--fd-light-crit:#d03b3b;--fd-light-crit-text:#b02f2f;--fd-light-s1:#2a78d6;--fd-light-s2:#e34948;--fd-light-s3:#1baf7a;--fd-light-grid:#e4e7ea;--fd-light-axis:#c2c9d0;--fd-light-shadow:0 1px 2px #10161c0d, 0 4px 16px #10161c0d;--fd-light-led-halo:#0ca30c2e;--fd-light-icon-sun:none;--fd-light-icon-moon:block;--fd-dark-bg:#0c0f13;--fd-dark-panel:#14191f;--fd-dark-panel-2:#1a2027;--fd-dark-panel-3:#212932;--fd-dark-ink:#eef2f5;--fd-dark-ink-2:#a3aeb9;--fd-dark-ink-3:#67737f;--fd-dark-line:#232b34;--fd-dark-line-2:#1d242c;--fd-dark-accent:#3987e5;--fd-dark-accent-ink:#6da7ec;--fd-dark-accent-wash:#3987e521;--fd-dark-good:#0ca30c;--fd-dark-good-text:#2fbf2f;--fd-dark-warn:#fab219;--fd-dark-serious:#ec835a;--fd-dark-crit:#d03b3b;--fd-dark-crit-text:#e66767;--fd-dark-s1:#3987e5;--fd-dark-s2:#e66767;--fd-dark-s3:#199e70;--fd-dark-grid:#222a33;--fd-dark-axis:#38424c;--fd-dark-shadow:0 1px 2px #0000004d, 0 6px 20px #00000040;--fd-dark-led-halo:#0ca30c38;--fd-dark-icon-sun:block;--fd-dark-icon-moon:none;--font-system:system-ui, -apple-system, "Segoe UI", sans-serif;--font-ui:"Public Sans", var(--font-system);--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;color-scheme:light;--bg:var(--fd-light-bg);--panel:var(--fd-light-panel);--panel-2:var(--fd-light-panel-2);--panel-3:var(--fd-light-panel-3);--ink:var(--fd-light-ink);--ink-2:var(--fd-light-ink-2);--ink-3:var(--fd-light-ink-3);--line:var(--fd-light-line);--line-2:var(--fd-light-line-2);--accent:var(--fd-light-accent);--accent-ink:var(--fd-light-accent-ink);--accent-wash:var(--fd-light-accent-wash);--good:var(--fd-light-good);--good-text:var(--fd-light-good-text);--warn:var(--fd-light-warn);--serious:var(--fd-light-serious);--crit:var(--fd-light-crit);--crit-text:var(--fd-light-crit-text);--s1:var(--fd-light-s1);--s2:var(--fd-light-s2);--s3:var(--fd-light-s3);--grid:var(--fd-light-grid);--axis:var(--fd-light-axis);--shadow:var(--fd-light-shadow);--led-halo:var(--fd-light-led-halo);--icon-sun:var(--fd-light-icon-sun);--icon-moon:var(--fd-light-icon-moon)}@media (prefers-color-scheme:dark){:root:where(:not([data-theme=light])){color-scheme:dark;--bg:var(--fd-dark-bg);--panel:var(--fd-dark-panel);--panel-2:var(--fd-dark-panel-2);--panel-3:var(--fd-dark-panel-3);--ink:var(--fd-dark-ink);--ink-2:var(--fd-dark-ink-2);--ink-3:var(--fd-dark-ink-3);--line:var(--fd-dark-line);--line-2:var(--fd-dark-line-2);--accent:var(--fd-dark-accent);--accent-ink:var(--fd-dark-accent-ink);--accent-wash:var(--fd-dark-accent-wash);--good:var(--fd-dark-good);--good-text:var(--fd-dark-good-text);--warn:var(--fd-dark-warn);--serious:var(--fd-dark-serious);--crit:var(--fd-dark-crit);--crit-text:var(--fd-dark-crit-text);--s1:var(--fd-dark-s1);--s2:var(--fd-dark-s2);--s3:var(--fd-dark-s3);--grid:var(--fd-dark-grid);--axis:var(--fd-dark-axis);--shadow:var(--fd-dark-shadow);--led-halo:var(--fd-dark-led-halo);--icon-sun:var(--fd-dark-icon-sun);--icon-moon:var(--fd-dark-icon-moon)}}:root[data-theme=dark]{color-scheme:dark;--bg:var(--fd-dark-bg);--panel:var(--fd-dark-panel);--panel-2:var(--fd-dark-panel-2);--panel-3:var(--fd-dark-panel-3);--ink:var(--fd-dark-ink);--ink-2:var(--fd-dark-ink-2);--ink-3:var(--fd-dark-ink-3);--line:var(--fd-dark-line);--line-2:var(--fd-dark-line-2);--accent:var(--fd-dark-accent);--accent-ink:var(--fd-dark-accent-ink);--accent-wash:var(--fd-dark-accent-wash);--good:var(--fd-dark-good);--good-text:var(--fd-dark-good-text);--warn:var(--fd-dark-warn);--serious:var(--fd-dark-serious);--crit:var(--fd-dark-crit);--crit-text:var(--fd-dark-crit-text);--s1:var(--fd-dark-s1);--s2:var(--fd-dark-s2);--s3:var(--fd-dark-s3);--grid:var(--fd-dark-grid);--axis:var(--fd-dark-axis);--shadow:var(--fd-dark-shadow);--led-halo:var(--fd-dark-led-halo);--icon-sun:var(--fd-dark-icon-sun);--icon-moon:var(--fd-dark-icon-moon)}:root[data-theme=light]{color-scheme:light;--bg:var(--fd-light-bg);--panel:var(--fd-light-panel);--panel-2:var(--fd-light-panel-2);--panel-3:var(--fd-light-panel-3);--ink:var(--fd-light-ink);--ink-2:var(--fd-light-ink-2);--ink-3:var(--fd-light-ink-3);--line:var(--fd-light-line);--line-2:var(--fd-light-line-2);--accent:var(--fd-light-accent);--accent-ink:var(--fd-light-accent-ink);--accent-wash:var(--fd-light-accent-wash);--good:var(--fd-light-good);--good-text:var(--fd-light-good-text);--warn:var(--fd-light-warn);--serious:var(--fd-light-serious);--crit:var(--fd-light-crit);--crit-text:var(--fd-light-crit-text);--s1:var(--fd-light-s1);--s2:var(--fd-light-s2);--s3:var(--fd-light-s3);--grid:var(--fd-light-grid);--axis:var(--fd-light-axis);--shadow:var(--fd-light-shadow);--led-halo:var(--fd-light-led-halo);--icon-sun:var(--fd-light-icon-sun);--icon-moon:var(--fd-light-icon-moon)}:root[data-font=barlow]{--font-ui:"Barlow", var(--font-system)}:root[data-font=general-sans]{--font-ui:"General Sans", var(--font-system)}:root[data-font=inter]{--font-ui:"Inter", var(--font-system)}:root[data-font=manrope]{--font-ui:"Manrope", var(--font-system)}:root[data-font=public-sans]{--font-ui:"Public Sans", var(--font-system)}:root[data-font=system]{--font-ui:var(--font-system)}html,body{margin:0}body{background:var(--bg);color:var(--ink);font:400 14px/1.45 var(--font-ui);-webkit-font-smoothing:antialiased}.mono,.fd-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums}.fd-app{grid-template-columns:216px 1fr;min-height:100vh;display:grid}.fd-side{background:var(--panel-2);border-right:1px solid var(--line);flex-direction:column;padding:14px 10px;display:flex}.fd-brand{align-items:center;gap:9px;padding:4px 8px 14px;display:flex}.fd-brand svg{flex:none}.fd-brand-word{font:600 13px/1 var(--font-ui);letter-spacing:.22em;color:var(--ink)}.fd-brand-word em{color:var(--accent-ink);font-style:normal}.fd-nav{flex-direction:column;gap:1px;display:flex}.fd-nav-link{color:var(--ink-2);border:1px solid #0000;border-radius:6px;align-items:center;gap:9px;padding:7px 9px;font-size:13.5px;font-weight:500;text-decoration:none;display:flex}.fd-nav-link svg{opacity:.75;flex:none}.fd-nav-link:hover{background:var(--panel-3);color:var(--ink)}.fd-nav-link.on{background:var(--panel);color:var(--ink);border-color:var(--line);box-shadow:0 1px 2px #10161c0f}.fd-nav-link.on svg{opacity:1;color:var(--accent-ink)}.fd-nav-count{font:400 10.5px/1 var(--font-mono);color:var(--ink-3);font-variant-numeric:tabular-nums;margin-left:auto}.fd-nav-section{font:500 10px/1 var(--font-mono);letter-spacing:.14em;color:var(--ink-3);text-transform:uppercase;padding:16px 9px 6px}.fd-side-foot{border-top:1px solid var(--line);color:var(--ink-3);font:400 10.5px/1.7 var(--font-mono);justify-content:space-between;align-items:center;gap:8px;margin-top:auto;padding:10px 9px 8px;display:flex}.fd-side-tools{align-items:center;gap:2px;display:flex}.fd-side-tools .fd-font-picker,.fd-side-tools .fd-theme-toggle{color:var(--ink-3);background:0 0;border-color:#0000}.fd-side-tools .fd-font-picker:hover,.fd-side-tools .fd-theme-toggle:hover{border-color:var(--line);background:var(--panel-2);color:var(--ink)}.fd-side-foot b{color:var(--ink-2);font-weight:500}.fd-main{background:var(--bg);flex-direction:column;min-width:0;display:flex}.fd-topbar{background:var(--panel);border-bottom:1px solid var(--line);align-items:center;gap:14px;padding:10px 20px;display:flex}.fd-topbar h2{letter-spacing:.01em;margin:0;font-size:15px;font-weight:600}.fd-topbar-right{align-items:center;gap:14px;margin-left:auto;display:flex}.fd-clock{font:400 11.5px/1 var(--font-mono);color:var(--ink-2);font-variant-numeric:tabular-nums}.fd-live{border:1px solid var(--line);background:var(--panel-2);cursor:pointer;font:500 10.5px/1 var(--font-mono);letter-spacing:.08em;color:var(--ink-2);border-radius:999px;align-items:center;gap:7px;padding:5px 10px;display:inline-flex}.fd-live .fd-dot{background:var(--good);width:7px;height:7px;box-shadow:0 0 0 3px var(--led-halo);border-radius:50%;animation:2s infinite fd-pulse}.fd-live.off .fd-dot{background:var(--ink-3);box-shadow:none;animation:none}@keyframes fd-pulse{50%{box-shadow:0 0 0 5px var(--led-halo)}}@media (prefers-reduced-motion:reduce){.fd-live .fd-dot{animation:none}}.fd-theme-toggle{border:1px solid var(--line);background:var(--panel-2);width:27px;height:27px;color:var(--ink-2);cursor:pointer;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex}.fd-theme-toggle:hover{color:var(--ink);border-color:var(--axis)}.fd-font-picker{border:1px solid var(--line);background:var(--panel-2);width:27px;height:27px;color:var(--ink-2);cursor:pointer;border-radius:999px;justify-content:center;align-items:center;display:inline-flex;position:relative}.fd-font-picker:hover{color:var(--ink);border-color:var(--axis)}.fd-font-picker:focus-within{outline:2px solid var(--accent);outline-offset:1px}.fd-font-picker svg{flex:none}.fd-font-picker select{appearance:none;color:#0000;width:100%;height:100%;font:500 12px/1 var(--font-ui);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0;position:absolute;inset:0}.fd-font-picker option{background:var(--panel);color:var(--ink)}.fd-visually-hidden{clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fd-theme-toggle .fd-icon-sun{display:var(--icon-sun)}.fd-theme-toggle .fd-icon-moon{display:var(--icon-moon)}.fd-content{padding:18px 20px 28px}.fd-card{background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 1px 2px #10161c0a}.fd-card-hd{border-bottom:1px solid var(--line-2);align-items:center;gap:10px;padding:11px 14px;display:flex}.fd-card-hd h3{letter-spacing:.02em;margin:0;font-size:13px;font-weight:600}.fd-card-bd{color:var(--ink-2);padding:14px}.fd-card-bd p{margin:0 0 8px}.fd-card-bd p:last-child{margin-bottom:0}.fd-toasts{z-index:60;pointer-events:none;flex-direction:column;gap:8px;display:flex;position:fixed;bottom:16px;right:16px}.fd-toasts>*{pointer-events:auto}@media (max-width:860px){.fd-app{grid-template-columns:1fr}.fd-side{border-right:0;border-bottom:1px solid var(--line)}}.fd-card-hd .sub{color:var(--ink-3);font-size:12px}.fd-card-hd .right{align-items:center;gap:8px;margin-left:auto;display:flex}.fd-btn{border:1px solid var(--line);background:var(--panel);color:var(--ink);font:500 12.5px/1 var(--font-ui);cursor:pointer;border-radius:6px;align-items:center;gap:6px;padding:6px 11px;text-decoration:none;display:inline-flex}.fd-btn:hover{background:var(--panel-2)}.fd-btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.fd-btn.primary:hover{filter:brightness(1.07)}.fd-btn.danger{color:var(--crit-text);border-color:currentColor}.fd-btn.danger:hover{background:#d03b3b12}.fd-btn.sm{padding:4px 8px;font-size:11.5px}.fd-btn[disabled],.fd-btn.disabled{opacity:.45;pointer-events:none}.fd-btn:focus-visible,.fd-nav-link:focus-visible,.fd-theme-toggle:focus-visible,.fd-statetabs a:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.fd-inline-form{margin:0;display:inline-flex}.fd-pill{font:500 10.5px/1.5 var(--font-mono);letter-spacing:.05em;white-space:nowrap;border-radius:999px;align-items:center;gap:5px;padding:2.5px 8px;display:inline-flex}.fd-pill:before{content:"";background:currentColor;border-radius:50%;width:6px;height:6px}.fd-pill.ready{color:var(--accent-ink);background:var(--accent-wash)}.fd-pill.scheduled{color:var(--ink-2);background:var(--panel-3)}.fd-pill.progress{color:var(--accent-ink);background:var(--accent-wash)}.fd-pill.progress:before{border-radius:1px;animation:1.4s linear infinite fd-spin}@keyframes fd-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.fd-pill.progress:before{animation:none}}.fd-pill.blocked{color:var(--serious);background:#ec835a1f}.fd-pill.finished{color:var(--good-text);background:#0ca30c1a}.fd-pill.failed{color:var(--crit-text);background:#d03b3b1a}.fd-pill.paused{color:var(--warn);background:#fab21921}.fd-qbadge{border:1px solid var(--line);background:var(--panel-2);font:400 11px/1.5 var(--font-mono);color:var(--ink-2);border-radius:5px;padding:1.5px 7px;display:inline-block}table.fd-data{border-collapse:collapse;width:100%;font-size:13px}table.fd-data th{text-align:left;font:500 10.5px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);border-bottom:1px solid var(--line);white-space:nowrap;padding:9px 14px}table.fd-data td{border-bottom:1px solid var(--line-2);vertical-align:middle;padding:9px 14px}table.fd-data tbody tr:hover{background:var(--panel-2)}table.fd-data tbody tr:last-child td{border-bottom:0}table.fd-data .num{text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px}table.fd-data .cls{color:var(--ink);font-weight:500}table.fd-data .cls a{color:inherit;text-decoration:none}table.fd-data .cls a:hover{color:var(--accent-ink)}table.fd-data .args{font:400 11.5px/1.4 var(--font-mono);color:var(--ink-3);text-overflow:ellipsis;white-space:nowrap;max-width:300px;overflow:hidden}table.fd-data .dim{color:var(--ink-3);font-size:12px}table.fd-data .args.fd-error-cell{max-width:260px}table.fd-data th.fd-col-select{width:34px}table.fd-data th.fd-col-actions{width:90px}table.fd-data th.fd-col-actions-md{width:110px}table.fd-data th.fd-col-actions-lg{width:150px}.fd-scroll-x{overflow-x:auto}.fd-empty{text-align:center;color:var(--ink-3);padding:28px 14px;font-size:13px}.fd-pager{border-top:1px solid var(--line-2);color:var(--ink-3);font:400 11.5px/1 var(--font-mono);align-items:center;gap:10px;padding:10px 14px;display:flex}.fd-pager .right{gap:6px;margin-left:auto;display:flex}.fd-statetabs{border-bottom:1px solid var(--line);gap:2px;margin-bottom:14px;display:flex;overflow-x:auto}.fd-statetabs a{color:var(--ink-2);white-space:nowrap;border-bottom:2px solid #0000;align-items:center;gap:7px;margin-bottom:-1px;padding:8px 13px 9px;font-size:13px;font-weight:500;text-decoration:none;display:inline-flex}.fd-statetabs a .ct{font:400 10.5px/1.6 var(--font-mono);color:var(--ink-3);background:var(--panel-3);font-variant-numeric:tabular-nums;border-radius:999px;padding:0 6px}.fd-statetabs a:hover{color:var(--ink)}.fd-statetabs a.on{color:var(--accent-ink);border-bottom-color:var(--accent)}.fd-statetabs a.on .ct{background:var(--accent-wash);color:var(--accent-ink)}.fd-filterbar{flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px;display:flex}.fd-filterbar input[type=search]{flex:1;min-width:200px}.fd-filterbar .spacer{flex:1}input[type=search],input[type=text],select{background:var(--panel);border:1px solid var(--line);color:var(--ink);font:400 13px var(--font-ui);border-radius:6px;min-width:0;padding:6px 10px}input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:0}.fd-font-picker select:focus-visible{outline:none}input[type=checkbox]{accent-color:var(--accent);width:14px;height:14px}.fd-chip{border:1px solid var(--accent-ink);background:var(--accent-wash);color:var(--accent-ink);font:500 11.5px/1 var(--font-mono);border-radius:999px;align-items:center;gap:6px;padding:4.5px 10px;text-decoration:none;display:inline-flex}.fd-chip u{opacity:.65;font-weight:400;text-decoration:none}.fd-chip:hover u{opacity:1}.fd-bulkbar[hidden]{display:none}.fd-bulkbar{background:var(--accent-wash);border:1px solid var(--accent-ink);color:var(--accent-ink);border-radius:8px;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:12px;padding:9px 14px;font-size:13px;display:flex}.fd-bulkbar .spacer{flex:1}.fd-bulkbar-all{color:var(--ink-3)}.fd-group-row td{background:var(--panel-2);font:500 11px/1.6 var(--font-mono);color:var(--ink-2);letter-spacing:.03em;padding:6px 14px}.fd-group-row a,.fd-group-row button.fd-linkish{color:var(--accent-ink);cursor:pointer;font:inherit;background:0 0;border:0;padding:0;text-decoration:none}.fd-jd-head{flex-wrap:wrap;align-items:flex-start;gap:12px;margin-bottom:14px;display:flex}.fd-jd-head .t h2{letter-spacing:-.01em;margin:0 0 4px;font-size:19px;font-weight:600}.fd-jd-head .t .sub{color:var(--ink-3);font:400 11.5px/1.5 var(--font-mono)}.fd-jd-head .actions{gap:8px;margin-left:auto;display:flex}.fd-grid-jd{grid-template-columns:1fr 320px;align-items:start;gap:12px;display:grid}.fd-stack{flex-direction:column;gap:12px;min-width:0;display:flex}.fd-meta-grid{grid-template-columns:1fr 1fr;display:grid}.fd-meta-grid>div{border-bottom:1px solid var(--line-2);min-width:0;padding:10px 14px}.fd-meta-grid>div:nth-child(odd){border-right:1px solid var(--line-2)}.fd-meta-grid>div:nth-last-child(-n+2){border-bottom:0}.fd-meta-grid .k{font:500 10px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px}.fd-meta-grid .v{font-variant-numeric:tabular-nums;overflow-wrap:anywhere;font-size:13px}.fd-meta-grid .v.mono{font-size:12px;font-family:var(--font-mono)}pre.fd-code{font:400 12px/1.6 var(--font-mono);color:var(--ink-2);background:var(--panel-2);border-radius:0 0 9px 9px;margin:0;padding:12px 14px;overflow-x:auto}.fd-error-box .msg{font:400 12.5px/1.6 var(--font-mono);color:var(--crit-text);border-bottom:1px solid var(--line-2);overflow-wrap:anywhere;padding:12px 14px}.fd-error-box .msg b{font-weight:500}details.fd-bt summary{cursor:pointer;font:500 11px/1 var(--font-mono);letter-spacing:.08em;color:var(--ink-2);text-transform:uppercase;padding:9px 14px;list-style:none}details.fd-bt summary::-webkit-details-marker{display:none}details.fd-bt summary:before{content:"▸";color:var(--ink-3)}details.fd-bt[open] summary:before{content:"▾"}details.fd-bt pre.fd-code{border-radius:0 0 9px 9px;max-height:260px;overflow:auto}details.fd-bt pre.fd-code .app-line{color:var(--ink);background:var(--accent-wash);width:100%;display:inline-block}.fd-timeline{padding:6px 14px 14px}.fd-tl-item{grid-template-columns:14px 1fr;gap:10px;padding:7px 0;display:grid;position:relative}.fd-tl-item:before{content:"";background:var(--line);width:1px;position:absolute;top:24px;bottom:-8px;left:6px}.fd-tl-item:last-child:before{display:none}.fd-tl-dot{border:3px solid var(--panel);background:var(--axis);width:13px;height:13px;box-shadow:0 0 0 1px var(--line);border-radius:50%;margin-top:3px}.fd-tl-item.ok .fd-tl-dot{background:var(--good)}.fd-tl-item.bad .fd-tl-dot{background:var(--crit)}.fd-tl-item.now .fd-tl-dot{background:var(--accent)}.fd-tl-body .t{font-size:13px;font-weight:500}.fd-tl-body .d{color:var(--ink-3);font:400 11px/1.6 var(--font-mono);overflow-wrap:anywhere}.fd-toast{background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow);color:var(--ink);border-radius:8px;align-items:center;gap:10px;max-width:460px;padding:10px 13px;font-size:13px;animation:.18s ease-out fd-toast-in;display:flex}.fd-toast.error{border-color:var(--crit);color:var(--crit-text)}.fd-toast .fd-toast-close{color:var(--ink-3);cursor:pointer;font:400 15px/1 var(--font-mono);background:0 0;border:0;margin-left:auto;padding:0 2px}.fd-toast .fd-toast-close:hover{color:var(--ink)}@keyframes fd-toast-in{0%{opacity:0;transform:translateY(6px)}}@media (prefers-reduced-motion:reduce){.fd-toast{animation:none}}@media (max-width:1080px){.fd-grid-jd{grid-template-columns:1fr}}.fd-queue-cards{grid-template-columns:repeat(3,1fr);gap:12px;display:grid}.fd-qcard{padding:14px}.fd-qcard .top{align-items:center;gap:8px;margin-bottom:10px;display:flex}.fd-qcard h3{font:500 15px var(--font-ui);overflow-wrap:anywhere;margin:0}.fd-qcard .top .right{margin-left:auto}.fd-qcard .stats{grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:10px;display:grid}.fd-qcard .stats .k{font:500 9.5px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:4px}.fd-qcard .stats .v{font:500 17px/1.2 var(--font-ui);font-variant-numeric:tabular-nums}.fd-qcard .stats .v small{color:var(--ink-3);font-size:11px;font-weight:400}.fd-qcard.paused-card{opacity:.92;border-style:dashed}.fd-spark-placeholder{height:30px}.fd-alert{border:1px solid var(--crit);background:#d03b3b0f;border-radius:8px;align-items:flex-start;gap:10px;margin-bottom:12px;padding:11px 14px;font-size:13px;display:flex}.fd-alert .ic{color:var(--crit);flex:none;margin-top:1px}.fd-alert b{font-weight:600}.fd-alert .right{flex:none;gap:8px;margin-left:auto;display:flex}.fd-hb{font:400 11.5px/1 var(--font-mono);color:var(--ink-2);font-variant-numeric:tabular-nums;align-items:center;gap:7px;display:inline-flex}.fd-led{border-radius:50%;flex:none;width:7px;height:7px}.fd-led.fresh{background:var(--good);box-shadow:0 0 0 2.5px var(--led-halo)}.fd-led.stale{background:var(--warn);box-shadow:0 0 0 2.5px #fab2192e}.fd-led.dead{background:var(--crit);box-shadow:0 0 0 2.5px #d03b3b29}table.fd-data tr.fd-row-danger td{background:#d03b3b0d}table.fd-data td.fd-indent{padding-left:28px}.fd-cron{font:400 11.5px/1.5 var(--font-mono);color:var(--ink-2)}.fd-cron small{color:var(--ink-3);font-size:10.5px;display:block}@media (max-width:1080px){.fd-queue-cards{grid-template-columns:1fr}}.fd-tiles{grid-template-columns:repeat(7,1fr);gap:10px;margin-bottom:14px;display:grid}.fd-tile{min-width:0;padding:12px 14px;position:relative}.fd-tile .lbl{font:500 10px/1 var(--font-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--ink-3);align-items:center;gap:6px;display:flex}.fd-tile .val{font:500 26px/1.2 var(--font-ui);font-variant-numeric:tabular-nums;letter-spacing:-.01em;overflow-wrap:anywhere;margin-top:6px}.fd-tile .val small{color:var(--ink-3);margin-left:2px;font-size:14px;font-weight:400}.fd-tile .delta{font:400 11px/1.35 var(--font-mono);align-items:baseline;gap:4px;margin-top:5px;display:inline-flex}.fd-tile .delta.up{color:var(--good-text)}.fd-tile .delta.down{color:var(--crit-text)}.fd-tile .delta.flat{color:var(--ink-3)}.fd-grid-2{grid-template-columns:1.6fr 1fr;gap:12px;margin-bottom:14px;display:grid}.fd-grid-2b{grid-template-columns:1fr 1fr;gap:12px;display:grid}.fd-block{margin-top:12px;display:block}.fd-chart-body{padding:10px 14px 12px}.fd-chart{width:100%;height:auto;display:block}.fd-chart-grid{stroke:var(--grid);stroke-width:1px}.fd-chart-axis{stroke:var(--axis);stroke-width:1px}.fd-chart-label{fill:var(--ink-3);font-family:var(--font-mono);font-size:10px}.fd-chart-bar-succeeded{fill:var(--s1)}.fd-chart-bar-failed{fill:var(--s2)}.fd-chart-line{fill:none;stroke:var(--s3);stroke-width:2px;stroke-linejoin:round;stroke-linecap:round}.fd-chart-area{fill:var(--s3);opacity:.12;stroke:none}.fd-chart-dot{fill:var(--s3);stroke:var(--panel);stroke-width:2px}.fd-chart-note{color:var(--ink-3);font:400 11px/1.5 var(--font-mono);padding:0 14px 12px}.fd-legend{color:var(--ink-2);gap:14px;font-size:11.5px;display:flex}.fd-legend i{vertical-align:-1px;border-radius:2.5px;width:9px;height:9px;margin-right:5px;display:inline-block}.fd-swatch-s1{background:var(--s1)}.fd-swatch-s2{background:var(--s2)}.fd-swatch-s3{background:var(--s3)}.fd-seg{border:1px solid var(--line);background:var(--panel);border-radius:7px;display:inline-flex;overflow:hidden}.fd-seg a{font:500 11px/1 var(--font-mono);color:var(--ink-2);letter-spacing:.04em;background:0 0;border:0;padding:5px 11px;text-decoration:none}.fd-seg a+a{border-left:1px solid var(--line)}.fd-seg a.on{background:var(--accent-wash);color:var(--accent-ink)}.fd-seg a:hover{color:var(--ink)}.fd-sparkline{width:100%;height:30px;display:block}.fd-spark-line{fill:none;stroke:var(--s1);stroke-width:1.5px}.fd-spark-area{fill:var(--s1);opacity:.14;stroke:none}.fd-depth-bar{border-radius:2px;width:100%;min-width:70px;height:4px;display:block;overflow:hidden}.fd-depth-track{fill:var(--panel-3)}.fd-depth-fill{fill:var(--accent)}.fd-health-strip{flex-wrap:wrap;gap:8px;padding:12px 14px;display:flex}.fd-proc-chip{border:1px solid var(--line);background:var(--panel-2);font:400 11px/1.5 var(--font-mono);color:var(--ink-2);border-radius:7px;align-items:center;gap:7px;padding:5px 10px;display:inline-flex}@media (max-width:1320px){.fd-tiles{grid-template-columns:repeat(4,1fr)}}@media (max-width:1080px){.fd-grid-2,.fd-grid-2b{grid-template-columns:1fr}}@media (max-width:640px){.fd-tiles{grid-template-columns:repeat(2,1fr)}}.fd-live .fd-dot{flex:none}.fd-live[aria-pressed=false] .fd-dot,.fd-live.off .fd-dot{background:var(--ink-3);box-shadow:none;animation:none}.fd-nav-count.alert{color:var(--crit-text)}.fd-filterbar-actions{flex:none;gap:8px;display:flex}@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:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/app/assets/flightdeck/flightdeck-f31d22011660.css b/app/assets/flightdeck/flightdeck-f31d22011660.css new file mode 100644 index 0000000..8b382ec --- /dev/null +++ b/app/assets/flightdeck/flightdeck-f31d22011660.css @@ -0,0 +1,3 @@ +@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-barlow-400-7a686e763128.woff2) format('woff2')}@font-face{font-family:'Barlow';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-barlow-500-80e7dc5605dd.woff2) format('woff2')}@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(flightdeck-barlow-600-488b58098126.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-general-sans-400-3ec2be771caf.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-general-sans-500-c30377df1de8.woff2) format('woff2')}@font-face{font-family:'General Sans';font-style:normal;font-weight:600;font-display:swap;src:url(flightdeck-general-sans-600-94a2a0e1ef59.woff2) format('woff2')}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(flightdeck-ibm-plex-mono-400-c36f509c0a8f.woff2) format('woff2')}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(flightdeck-ibm-plex-mono-500-a76f53ca6612.woff2) format('woff2')}@font-face{font-family:'Inter';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-inter-variable-c940764593d0.woff2) format('woff2')}@font-face{font-family:'Manrope';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-manrope-variable-e310b55a7fd9.woff2) format('woff2')}@font-face{font-family:'Public Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(flightdeck-public-sans-variable-c1b6da516e00.woff2) format('woff2')} +/*! tailwindcss v4.3.3 | 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-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-mono:var(--font-mono);--default-font-family:var(--font-ui);--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,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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:where(:not(iframe)){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{.table{display:table}.grow{flex-grow:1}.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,)}}:root{--fd-light-bg:#f3f5f7;--fd-light-panel:#fff;--fd-light-panel-2:#eef1f4;--fd-light-panel-3:#e6ebef;--fd-light-ink:#10161c;--fd-light-ink-2:#4e5a66;--fd-light-ink-3:#85919d;--fd-light-line:#dce2e8;--fd-light-line-2:#e9edf1;--fd-light-accent:#2a78d6;--fd-light-accent-ink:#1c5cab;--fd-light-accent-wash:#2a78d617;--fd-light-good:#0ca30c;--fd-light-good-text:#006300;--fd-light-warn:#fab219;--fd-light-serious:#ec835a;--fd-light-crit:#d03b3b;--fd-light-crit-text:#b02f2f;--fd-light-s1:#2a78d6;--fd-light-s2:#e34948;--fd-light-s3:#1baf7a;--fd-light-grid:#e4e7ea;--fd-light-axis:#c2c9d0;--fd-light-shadow:0 1px 2px #10161c0d, 0 4px 16px #10161c0d;--fd-light-led-halo:#0ca30c2e;--fd-light-icon-sun:none;--fd-light-icon-moon:block;--fd-dark-bg:#0c0f13;--fd-dark-panel:#14191f;--fd-dark-panel-2:#1a2027;--fd-dark-panel-3:#212932;--fd-dark-ink:#eef2f5;--fd-dark-ink-2:#a3aeb9;--fd-dark-ink-3:#67737f;--fd-dark-line:#232b34;--fd-dark-line-2:#1d242c;--fd-dark-accent:#3987e5;--fd-dark-accent-ink:#6da7ec;--fd-dark-accent-wash:#3987e521;--fd-dark-good:#0ca30c;--fd-dark-good-text:#2fbf2f;--fd-dark-warn:#fab219;--fd-dark-serious:#ec835a;--fd-dark-crit:#d03b3b;--fd-dark-crit-text:#e66767;--fd-dark-s1:#3987e5;--fd-dark-s2:#e66767;--fd-dark-s3:#199e70;--fd-dark-grid:#222a33;--fd-dark-axis:#38424c;--fd-dark-shadow:0 1px 2px #0000004d, 0 6px 20px #00000040;--fd-dark-led-halo:#0ca30c38;--fd-dark-icon-sun:block;--fd-dark-icon-moon:none;--font-system:system-ui, -apple-system, "Segoe UI", sans-serif;--font-ui:"Public Sans", var(--font-system);--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;color-scheme:light;--bg:var(--fd-light-bg);--panel:var(--fd-light-panel);--panel-2:var(--fd-light-panel-2);--panel-3:var(--fd-light-panel-3);--ink:var(--fd-light-ink);--ink-2:var(--fd-light-ink-2);--ink-3:var(--fd-light-ink-3);--line:var(--fd-light-line);--line-2:var(--fd-light-line-2);--accent:var(--fd-light-accent);--accent-ink:var(--fd-light-accent-ink);--accent-wash:var(--fd-light-accent-wash);--good:var(--fd-light-good);--good-text:var(--fd-light-good-text);--warn:var(--fd-light-warn);--serious:var(--fd-light-serious);--crit:var(--fd-light-crit);--crit-text:var(--fd-light-crit-text);--s1:var(--fd-light-s1);--s2:var(--fd-light-s2);--s3:var(--fd-light-s3);--grid:var(--fd-light-grid);--axis:var(--fd-light-axis);--shadow:var(--fd-light-shadow);--led-halo:var(--fd-light-led-halo);--icon-sun:var(--fd-light-icon-sun);--icon-moon:var(--fd-light-icon-moon)}@media (prefers-color-scheme:dark){:root:where(:not([data-theme=light])){color-scheme:dark;--bg:var(--fd-dark-bg);--panel:var(--fd-dark-panel);--panel-2:var(--fd-dark-panel-2);--panel-3:var(--fd-dark-panel-3);--ink:var(--fd-dark-ink);--ink-2:var(--fd-dark-ink-2);--ink-3:var(--fd-dark-ink-3);--line:var(--fd-dark-line);--line-2:var(--fd-dark-line-2);--accent:var(--fd-dark-accent);--accent-ink:var(--fd-dark-accent-ink);--accent-wash:var(--fd-dark-accent-wash);--good:var(--fd-dark-good);--good-text:var(--fd-dark-good-text);--warn:var(--fd-dark-warn);--serious:var(--fd-dark-serious);--crit:var(--fd-dark-crit);--crit-text:var(--fd-dark-crit-text);--s1:var(--fd-dark-s1);--s2:var(--fd-dark-s2);--s3:var(--fd-dark-s3);--grid:var(--fd-dark-grid);--axis:var(--fd-dark-axis);--shadow:var(--fd-dark-shadow);--led-halo:var(--fd-dark-led-halo);--icon-sun:var(--fd-dark-icon-sun);--icon-moon:var(--fd-dark-icon-moon)}}:root[data-theme=dark]{color-scheme:dark;--bg:var(--fd-dark-bg);--panel:var(--fd-dark-panel);--panel-2:var(--fd-dark-panel-2);--panel-3:var(--fd-dark-panel-3);--ink:var(--fd-dark-ink);--ink-2:var(--fd-dark-ink-2);--ink-3:var(--fd-dark-ink-3);--line:var(--fd-dark-line);--line-2:var(--fd-dark-line-2);--accent:var(--fd-dark-accent);--accent-ink:var(--fd-dark-accent-ink);--accent-wash:var(--fd-dark-accent-wash);--good:var(--fd-dark-good);--good-text:var(--fd-dark-good-text);--warn:var(--fd-dark-warn);--serious:var(--fd-dark-serious);--crit:var(--fd-dark-crit);--crit-text:var(--fd-dark-crit-text);--s1:var(--fd-dark-s1);--s2:var(--fd-dark-s2);--s3:var(--fd-dark-s3);--grid:var(--fd-dark-grid);--axis:var(--fd-dark-axis);--shadow:var(--fd-dark-shadow);--led-halo:var(--fd-dark-led-halo);--icon-sun:var(--fd-dark-icon-sun);--icon-moon:var(--fd-dark-icon-moon)}:root[data-theme=light]{color-scheme:light;--bg:var(--fd-light-bg);--panel:var(--fd-light-panel);--panel-2:var(--fd-light-panel-2);--panel-3:var(--fd-light-panel-3);--ink:var(--fd-light-ink);--ink-2:var(--fd-light-ink-2);--ink-3:var(--fd-light-ink-3);--line:var(--fd-light-line);--line-2:var(--fd-light-line-2);--accent:var(--fd-light-accent);--accent-ink:var(--fd-light-accent-ink);--accent-wash:var(--fd-light-accent-wash);--good:var(--fd-light-good);--good-text:var(--fd-light-good-text);--warn:var(--fd-light-warn);--serious:var(--fd-light-serious);--crit:var(--fd-light-crit);--crit-text:var(--fd-light-crit-text);--s1:var(--fd-light-s1);--s2:var(--fd-light-s2);--s3:var(--fd-light-s3);--grid:var(--fd-light-grid);--axis:var(--fd-light-axis);--shadow:var(--fd-light-shadow);--led-halo:var(--fd-light-led-halo);--icon-sun:var(--fd-light-icon-sun);--icon-moon:var(--fd-light-icon-moon)}:root[data-font=barlow]{--font-ui:"Barlow", var(--font-system)}:root[data-font=general-sans]{--font-ui:"General Sans", var(--font-system)}:root[data-font=inter]{--font-ui:"Inter", var(--font-system)}:root[data-font=manrope]{--font-ui:"Manrope", var(--font-system)}:root[data-font=public-sans]{--font-ui:"Public Sans", var(--font-system)}:root[data-font=system]{--font-ui:var(--font-system)}html,body{margin:0}body{background:var(--bg);color:var(--ink);font:400 14px/1.45 var(--font-ui);-webkit-font-smoothing:antialiased}.mono,.fd-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums}.fd-app{grid-template-columns:216px 1fr;min-height:100vh;display:grid}.fd-side{background:var(--panel-2);border-right:1px solid var(--line);flex-direction:column;padding:14px 10px;display:flex}.fd-brand{align-items:center;gap:9px;padding:4px 8px 14px;display:flex}.fd-brand svg{flex:none}.fd-brand-word{font:600 13px/1 var(--font-ui);letter-spacing:.22em;color:var(--ink)}.fd-brand-word em{color:var(--accent-ink);font-style:normal}.fd-nav{flex-direction:column;gap:1px;display:flex}.fd-nav-link{color:var(--ink-2);border:1px solid #0000;border-radius:6px;align-items:center;gap:9px;padding:7px 9px;font-size:13.5px;font-weight:500;text-decoration:none;display:flex}.fd-nav-link svg{opacity:.75;flex:none}.fd-nav-link:hover{background:var(--panel-3);color:var(--ink)}.fd-nav-link.on{background:var(--panel);color:var(--ink);border-color:var(--line);box-shadow:0 1px 2px #10161c0f}.fd-nav-link.on svg{opacity:1;color:var(--accent-ink)}.fd-nav-count{font:400 10.5px/1 var(--font-mono);color:var(--ink-3);font-variant-numeric:tabular-nums;margin-left:auto}.fd-nav-section{font:500 10px/1 var(--font-mono);letter-spacing:.14em;color:var(--ink-3);text-transform:uppercase;padding:16px 9px 6px}.fd-side-foot{border-top:1px solid var(--line);color:var(--ink-3);font:400 10.5px/1.7 var(--font-mono);justify-content:space-between;align-items:center;gap:8px;margin-top:auto;padding:10px 9px 8px;display:flex}.fd-side-tools{align-items:center;gap:2px;display:flex}.fd-side-tools .fd-font-picker,.fd-side-tools .fd-theme-toggle{color:var(--ink-3);background:0 0;border-color:#0000}.fd-side-tools .fd-font-picker:hover,.fd-side-tools .fd-theme-toggle:hover{border-color:var(--line);background:var(--panel-2);color:var(--ink)}.fd-side-foot b{color:var(--ink-2);font-weight:500}.fd-main{background:var(--bg);flex-direction:column;min-width:0;display:flex}.fd-topbar{background:var(--panel);border-bottom:1px solid var(--line);align-items:center;gap:14px;padding:10px 20px;display:flex}.fd-topbar h2{letter-spacing:.01em;margin:0;font-size:15px;font-weight:600}.fd-topbar-right{align-items:center;gap:14px;margin-left:auto;display:flex}.fd-clock{font:400 11.5px/1 var(--font-mono);color:var(--ink-2);font-variant-numeric:tabular-nums}.fd-live{border:1px solid var(--line);background:var(--panel-2);cursor:pointer;font:500 10.5px/1 var(--font-mono);letter-spacing:.08em;color:var(--ink-2);border-radius:999px;align-items:center;gap:7px;padding:5px 10px;display:inline-flex}.fd-live .fd-dot{background:var(--good);width:7px;height:7px;box-shadow:0 0 0 3px var(--led-halo);border-radius:50%;animation:2s infinite fd-pulse}.fd-live.off .fd-dot{background:var(--ink-3);box-shadow:none;animation:none}@keyframes fd-pulse{50%{box-shadow:0 0 0 5px var(--led-halo)}}@media (prefers-reduced-motion:reduce){.fd-live .fd-dot{animation:none}}.fd-theme-toggle{border:1px solid var(--line);background:var(--panel-2);width:27px;height:27px;color:var(--ink-2);cursor:pointer;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex}.fd-theme-toggle:hover{color:var(--ink);border-color:var(--axis)}.fd-font-picker{border:1px solid var(--line);background:var(--panel-2);width:27px;height:27px;color:var(--ink-2);cursor:pointer;border-radius:999px;justify-content:center;align-items:center;display:inline-flex;position:relative}.fd-font-picker:hover{color:var(--ink);border-color:var(--axis)}.fd-font-picker:focus-within{outline:2px solid var(--accent);outline-offset:1px}.fd-font-picker svg{flex:none}.fd-font-picker select{appearance:none;color:#0000;width:100%;height:100%;font:500 12px/1 var(--font-ui);cursor:pointer;background:0 0;border:0;border-radius:999px;padding:0;position:absolute;inset:0}.fd-font-picker option{background:var(--panel);color:var(--ink)}.fd-visually-hidden{clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fd-theme-toggle .fd-icon-sun{display:var(--icon-sun)}.fd-theme-toggle .fd-icon-moon{display:var(--icon-moon)}.fd-content{padding:18px 20px 28px}.fd-card{background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 1px 2px #10161c0a}.fd-card-hd{border-bottom:1px solid var(--line-2);align-items:center;gap:10px;padding:11px 14px;display:flex}.fd-card-hd h3{letter-spacing:.02em;margin:0;font-size:13px;font-weight:600}.fd-card-bd{color:var(--ink-2);padding:14px}.fd-card-bd p{margin:0 0 8px}.fd-card-bd p:last-child{margin-bottom:0}.fd-toasts{z-index:60;pointer-events:none;flex-direction:column;gap:8px;display:flex;position:fixed;bottom:16px;right:16px}.fd-toasts>*{pointer-events:auto}@media (max-width:860px){.fd-app{grid-template-columns:1fr}.fd-side{border-right:0;border-bottom:1px solid var(--line)}}.fd-card-hd .sub{color:var(--ink-3);font-size:12px}.fd-card-hd .right{align-items:center;gap:8px;margin-left:auto;display:flex}.fd-btn{border:1px solid var(--line);background:var(--panel);color:var(--ink);font:500 12.5px/1 var(--font-ui);cursor:pointer;border-radius:6px;align-items:center;gap:6px;padding:6px 11px;text-decoration:none;display:inline-flex}.fd-btn:hover{background:var(--panel-2)}.fd-btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.fd-btn.primary:hover{filter:brightness(1.07)}.fd-btn.danger{color:var(--crit-text);border-color:currentColor}.fd-btn.danger:hover{background:#d03b3b12}.fd-btn.sm{padding:4px 8px;font-size:11.5px}.fd-btn[disabled],.fd-btn.disabled{opacity:.45;pointer-events:none}.fd-btn:focus-visible,.fd-nav-link:focus-visible,.fd-theme-toggle:focus-visible,.fd-statetabs a:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.fd-inline-form{margin:0;display:inline-flex}.fd-pill{font:500 10.5px/1.5 var(--font-mono);letter-spacing:.05em;white-space:nowrap;border-radius:999px;align-items:center;gap:5px;padding:2.5px 8px;display:inline-flex}.fd-pill:before{content:"";background:currentColor;border-radius:50%;width:6px;height:6px}.fd-pill.ready{color:var(--accent-ink);background:var(--accent-wash)}.fd-pill.scheduled{color:var(--ink-2);background:var(--panel-3)}.fd-pill.progress{color:var(--accent-ink);background:var(--accent-wash)}.fd-pill.progress:before{border-radius:1px;animation:1.4s linear infinite fd-spin}@keyframes fd-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.fd-pill.progress:before{animation:none}}.fd-pill.blocked{color:var(--serious);background:#ec835a1f}.fd-pill.finished{color:var(--good-text);background:#0ca30c1a}.fd-pill.failed{color:var(--crit-text);background:#d03b3b1a}.fd-pill.paused{color:var(--warn);background:#fab21921}.fd-qbadge{border:1px solid var(--line);background:var(--panel-2);font:400 11px/1.5 var(--font-mono);color:var(--ink-2);border-radius:5px;padding:1.5px 7px;display:inline-block}table.fd-data{border-collapse:collapse;width:100%;font-size:13px}table.fd-data th{text-align:left;font:500 10.5px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);border-bottom:1px solid var(--line);white-space:nowrap;padding:9px 14px}table.fd-data td{border-bottom:1px solid var(--line-2);vertical-align:middle;padding:9px 14px}table.fd-data tbody tr:hover{background:var(--panel-2)}table.fd-data tbody tr:last-child td{border-bottom:0}table.fd-data .num{text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:12px}table.fd-data .cls{color:var(--ink);font-weight:500}table.fd-data .cls a{color:inherit;text-decoration:none}table.fd-data .cls a:hover{color:var(--accent-ink)}table.fd-data .args{font:400 11.5px/1.4 var(--font-mono);color:var(--ink-3);text-overflow:ellipsis;white-space:nowrap;max-width:300px;overflow:hidden}table.fd-data .dim{color:var(--ink-3);font-size:12px}table.fd-data .args.fd-error-cell{max-width:260px}table.fd-data th.fd-col-select{width:34px}table.fd-data th.fd-col-actions{width:90px}table.fd-data th.fd-col-actions-md{width:110px}table.fd-data th.fd-col-actions-lg{width:150px}.fd-scroll-x{overflow-x:auto}.fd-empty{text-align:center;color:var(--ink-3);padding:28px 14px;font-size:13px}.fd-pager{border-top:1px solid var(--line-2);color:var(--ink-3);font:400 11.5px/1 var(--font-mono);align-items:center;gap:10px;padding:10px 14px;display:flex}.fd-pager .right{gap:6px;margin-left:auto;display:flex}.fd-statetabs{border-bottom:1px solid var(--line);gap:2px;margin-bottom:14px;display:flex;overflow-x:auto}.fd-statetabs a{color:var(--ink-2);white-space:nowrap;border-bottom:2px solid #0000;align-items:center;gap:7px;margin-bottom:-1px;padding:8px 13px 9px;font-size:13px;font-weight:500;text-decoration:none;display:inline-flex}.fd-statetabs a .ct{font:400 10.5px/1.6 var(--font-mono);color:var(--ink-3);background:var(--panel-3);font-variant-numeric:tabular-nums;border-radius:999px;padding:0 6px}.fd-statetabs a:hover{color:var(--ink)}.fd-statetabs a.on{color:var(--accent-ink);border-bottom-color:var(--accent)}.fd-statetabs a.on .ct{background:var(--accent-wash);color:var(--accent-ink)}.fd-filterbar{flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px;display:flex}.fd-filterbar input[type=search]{flex:1;min-width:200px}.fd-filterbar .spacer{flex:1}input[type=search],input[type=text],select{background:var(--panel);border:1px solid var(--line);color:var(--ink);font:400 13px var(--font-ui);border-radius:6px;min-width:0;padding:6px 10px}input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:0}.fd-font-picker select:focus-visible{outline:none}input[type=checkbox]{accent-color:var(--accent);width:14px;height:14px}.fd-chip{border:1px solid var(--accent-ink);background:var(--accent-wash);color:var(--accent-ink);font:500 11.5px/1 var(--font-mono);border-radius:999px;align-items:center;gap:6px;padding:4.5px 10px;text-decoration:none;display:inline-flex}.fd-chip u{opacity:.65;font-weight:400;text-decoration:none}.fd-chip:hover u{opacity:1}.fd-bulkbar[hidden]{display:none}.fd-bulkbar{background:var(--accent-wash);border:1px solid var(--accent-ink);color:var(--accent-ink);border-radius:8px;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:12px;padding:9px 14px;font-size:13px;display:flex}.fd-bulkbar .spacer{flex:1}.fd-bulkbar-all{color:var(--ink-3)}.fd-group-row td{background:var(--panel-2);font:500 11px/1.6 var(--font-mono);color:var(--ink-2);letter-spacing:.03em;padding:6px 14px}.fd-group-row a,.fd-group-row button.fd-linkish{color:var(--accent-ink);cursor:pointer;font:inherit;background:0 0;border:0;padding:0;text-decoration:none}.fd-jd-head{flex-wrap:wrap;align-items:flex-start;gap:12px;margin-bottom:14px;display:flex}.fd-jd-head .t h2{letter-spacing:-.01em;margin:0 0 4px;font-size:19px;font-weight:600}.fd-jd-head .t .sub{color:var(--ink-3);font:400 11.5px/1.5 var(--font-mono)}.fd-jd-head .actions{gap:8px;margin-left:auto;display:flex}.fd-grid-jd{grid-template-columns:1fr 320px;align-items:start;gap:12px;display:grid}.fd-stack{flex-direction:column;gap:12px;min-width:0;display:flex}.fd-meta-grid{grid-template-columns:1fr 1fr;display:grid}.fd-meta-grid>div{border-bottom:1px solid var(--line-2);min-width:0;padding:10px 14px}.fd-meta-grid>div:nth-child(odd){border-right:1px solid var(--line-2)}.fd-meta-grid>div:nth-last-child(-n+2){border-bottom:0}.fd-meta-grid .k{font:500 10px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px}.fd-meta-grid .v{font-variant-numeric:tabular-nums;overflow-wrap:anywhere;font-size:13px}.fd-meta-grid .v.mono{font-size:12px;font-family:var(--font-mono)}pre.fd-code{font:400 12px/1.6 var(--font-mono);color:var(--ink-2);background:var(--panel-2);border-radius:0 0 9px 9px;margin:0;padding:12px 14px;overflow-x:auto}.fd-error-box .msg{font:400 12.5px/1.6 var(--font-mono);color:var(--crit-text);border-bottom:1px solid var(--line-2);overflow-wrap:anywhere;padding:12px 14px}.fd-error-box .msg b{font-weight:500}details.fd-bt summary{cursor:pointer;font:500 11px/1 var(--font-mono);letter-spacing:.08em;color:var(--ink-2);text-transform:uppercase;padding:9px 14px;list-style:none}details.fd-bt summary::-webkit-details-marker{display:none}details.fd-bt summary:before{content:"▸";color:var(--ink-3)}details.fd-bt[open] summary:before{content:"▾"}details.fd-bt pre.fd-code{border-radius:0 0 9px 9px;max-height:260px;overflow:auto}details.fd-bt pre.fd-code .app-line{color:var(--ink);background:var(--accent-wash);width:100%;display:inline-block}.fd-timeline{padding:6px 14px 14px}.fd-tl-item{grid-template-columns:14px 1fr;gap:10px;padding:7px 0;display:grid;position:relative}.fd-tl-item:before{content:"";background:var(--line);width:1px;position:absolute;top:24px;bottom:-8px;left:6px}.fd-tl-item:last-child:before{display:none}.fd-tl-dot{border:3px solid var(--panel);background:var(--axis);width:13px;height:13px;box-shadow:0 0 0 1px var(--line);border-radius:50%;margin-top:3px}.fd-tl-item.ok .fd-tl-dot{background:var(--good)}.fd-tl-item.bad .fd-tl-dot{background:var(--crit)}.fd-tl-item.now .fd-tl-dot{background:var(--accent)}.fd-tl-body .t{font-size:13px;font-weight:500}.fd-tl-body .d{color:var(--ink-3);font:400 11px/1.6 var(--font-mono);overflow-wrap:anywhere}.fd-toast{background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow);color:var(--ink);border-radius:8px;align-items:center;gap:10px;max-width:460px;padding:10px 13px;font-size:13px;animation:.18s ease-out fd-toast-in;display:flex}.fd-toast.error{border-color:var(--crit);color:var(--crit-text)}.fd-toast .fd-toast-close{color:var(--ink-3);cursor:pointer;font:400 15px/1 var(--font-mono);background:0 0;border:0;margin-left:auto;padding:0 2px}.fd-toast .fd-toast-close:hover{color:var(--ink)}@keyframes fd-toast-in{0%{opacity:0;transform:translateY(6px)}}@media (prefers-reduced-motion:reduce){.fd-toast{animation:none}}@media (max-width:1080px){.fd-grid-jd{grid-template-columns:1fr}}.fd-queue-cards{grid-template-columns:repeat(3,1fr);gap:12px;display:grid}.fd-qcard{padding:14px}.fd-qcard .top{align-items:center;gap:8px;margin-bottom:10px;display:flex}.fd-qcard h3{font:500 15px var(--font-ui);overflow-wrap:anywhere;margin:0}.fd-qcard .top .right{margin-left:auto}.fd-qcard .stats{grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:10px;display:grid}.fd-qcard .stats .k{font:500 9.5px/1 var(--font-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:4px}.fd-qcard .stats .v{font:500 17px/1.2 var(--font-ui);font-variant-numeric:tabular-nums}.fd-qcard .stats .v small{color:var(--ink-3);font-size:11px;font-weight:400}.fd-qcard.paused-card{opacity:.92;border-style:dashed}.fd-spark-placeholder{height:30px}.fd-alert{border:1px solid var(--crit);background:#d03b3b0f;border-radius:8px;align-items:flex-start;gap:10px;margin-bottom:12px;padding:11px 14px;font-size:13px;display:flex}.fd-alert .ic{color:var(--crit);flex:none;margin-top:1px}.fd-alert b{font-weight:600}.fd-alert .right{flex:none;gap:8px;margin-left:auto;display:flex}.fd-hb{font:400 11.5px/1 var(--font-mono);color:var(--ink-2);font-variant-numeric:tabular-nums;align-items:center;gap:7px;display:inline-flex}.fd-led{border-radius:50%;flex:none;width:7px;height:7px}.fd-led.fresh{background:var(--good);box-shadow:0 0 0 2.5px var(--led-halo)}.fd-led.stale{background:var(--warn);box-shadow:0 0 0 2.5px #fab2192e}.fd-led.dead{background:var(--crit);box-shadow:0 0 0 2.5px #d03b3b29}table.fd-data tr.fd-row-danger td{background:#d03b3b0d}table.fd-data td.fd-indent{padding-left:28px}.fd-cron{font:400 11.5px/1.5 var(--font-mono);color:var(--ink-2)}.fd-cron small{color:var(--ink-3);font-size:10.5px;display:block}@media (max-width:1080px){.fd-queue-cards{grid-template-columns:1fr}}.fd-tiles{grid-template-columns:repeat(7,1fr);gap:10px;margin-bottom:14px;display:grid}.fd-tile{min-width:0;padding:12px 14px;position:relative}.fd-tile .lbl{font:500 10px/1 var(--font-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--ink-3);align-items:center;gap:6px;display:flex}.fd-tile .val{font:500 26px/1.2 var(--font-ui);font-variant-numeric:tabular-nums;letter-spacing:-.01em;overflow-wrap:anywhere;margin-top:6px}.fd-tile .val small{color:var(--ink-3);margin-left:2px;font-size:14px;font-weight:400}.fd-tile .delta{font:400 11px/1.35 var(--font-mono);align-items:baseline;gap:4px;margin-top:5px;display:inline-flex}.fd-tile .delta.up{color:var(--good-text)}.fd-tile .delta.down{color:var(--crit-text)}.fd-tile .delta.flat{color:var(--ink-3)}.fd-grid-2{grid-template-columns:1.6fr 1fr;gap:12px;margin-bottom:14px;display:grid}.fd-grid-2b{grid-template-columns:1fr 1fr;gap:12px;display:grid}.fd-block{margin-top:12px;display:block}.fd-chart-body{padding:10px 14px 12px}.fd-chart{width:100%;height:auto;display:block}.fd-chart-grid{stroke:var(--grid);stroke-width:1px}.fd-chart-axis{stroke:var(--axis);stroke-width:1px}.fd-chart-label{fill:var(--ink-3);font-family:var(--font-mono);font-size:10px}.fd-chart-bar-succeeded{fill:var(--s1)}.fd-chart-bar-failed{fill:var(--s2)}.fd-chart-line{fill:none;stroke:var(--s3);stroke-width:2px;stroke-linejoin:round;stroke-linecap:round}.fd-chart-area{fill:var(--s3);opacity:.12;stroke:none}.fd-chart-dot{fill:var(--s3);stroke:var(--panel);stroke-width:2px}.fd-chart-note{color:var(--ink-3);font:400 11px/1.5 var(--font-mono);padding:0 14px 12px}.fd-legend{color:var(--ink-2);gap:14px;font-size:11.5px;display:flex}.fd-legend i{vertical-align:-1px;border-radius:2.5px;width:9px;height:9px;margin-right:5px;display:inline-block}.fd-swatch-s1{background:var(--s1)}.fd-swatch-s2{background:var(--s2)}.fd-swatch-s3{background:var(--s3)}.fd-seg{border:1px solid var(--line);background:var(--panel);border-radius:7px;display:inline-flex;overflow:hidden}.fd-seg a{font:500 11px/1 var(--font-mono);color:var(--ink-2);letter-spacing:.04em;background:0 0;border:0;padding:5px 11px;text-decoration:none}.fd-seg a+a{border-left:1px solid var(--line)}.fd-seg a.on{background:var(--accent-wash);color:var(--accent-ink)}.fd-seg a:hover{color:var(--ink)}.fd-sparkline{width:100%;height:30px;display:block}.fd-spark-line{fill:none;stroke:var(--s1);stroke-width:1.5px}.fd-spark-area{fill:var(--s1);opacity:.14;stroke:none}.fd-depth-bar{border-radius:2px;width:100%;min-width:70px;height:4px;display:block;overflow:hidden}.fd-depth-track{fill:var(--panel-3)}.fd-depth-fill{fill:var(--accent)}.fd-batch-bar{border-radius:3px;width:100%;min-width:80px;height:6px;display:block;overflow:hidden}.fd-batch-fill-ok{fill:var(--good)}.fd-batch-fill-bad{fill:var(--crit)}.fd-batch-progress{flex-direction:column;gap:6px;min-width:140px;display:flex}.fd-batch-progress-mini{flex-shrink:0;width:160px}.fd-batch-progress-block{padding:12px 14px 14px}.fd-batch-counts{font:400 11.5px/1.5 var(--font-mono);color:var(--ink-3)}.fd-part-of{align-items:center;gap:16px;padding:12px 14px;display:flex}.fd-part-of .grow{flex:1;min-width:0}.fd-part-of .grow a{color:var(--ink);font-weight:500;text-decoration:none}.fd-part-of .grow a:hover{color:var(--accent-ink)}.fd-health-strip{flex-wrap:wrap;gap:8px;padding:12px 14px;display:flex}.fd-proc-chip{border:1px solid var(--line);background:var(--panel-2);font:400 11px/1.5 var(--font-mono);color:var(--ink-2);border-radius:7px;align-items:center;gap:7px;padding:5px 10px;display:inline-flex}@media (max-width:1320px){.fd-tiles{grid-template-columns:repeat(4,1fr)}}@media (max-width:1080px){.fd-grid-2,.fd-grid-2b{grid-template-columns:1fr}}@media (max-width:640px){.fd-tiles{grid-template-columns:repeat(2,1fr)}}.fd-live .fd-dot{flex:none}.fd-live[aria-pressed=false] .fd-dot,.fd-live.off .fd-dot{background:var(--ink-3);box-shadow:none;animation:none}.fd-nav-count.alert{color:var(--crit-text)}.fd-filterbar-actions{flex:none;gap:8px;display:flex}@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:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/app/assets/flightdeck/manifest.json b/app/assets/flightdeck/manifest.json index a4a136e..317047e 100644 --- a/app/assets/flightdeck/manifest.json +++ b/app/assets/flightdeck/manifest.json @@ -1,10 +1,10 @@ { "flightdeck.css": { - "file": "flightdeck-38ba36531cee.css", - "digest": "38ba36531cee", - "sha256": "38ba36531cee83452b312ce04f858f83eeb262b498b6614c775e1947ba2303da", + "file": "flightdeck-f31d22011660.css", + "digest": "f31d22011660", + "sha256": "f31d22011660f83a4b07b3ee983ecc0380d0bc448b0e85c72f2e4e910260f950", "content_type": "text/css; charset=utf-8", - "size": 31759 + "size": 32428 }, "flightdeck.js": { "file": "flightdeck-30004819780c.js", diff --git a/app/controllers/flightdeck/application_controller.rb b/app/controllers/flightdeck/application_controller.rb index 5099e3c..3fc6ac8 100644 --- a/app/controllers/flightdeck/application_controller.rb +++ b/app/controllers/flightdeck/application_controller.rb @@ -78,7 +78,8 @@ def list_filters @list_filters ||= { class_name: params[:class_name].presence, queue_name: params[:queue_name].presence, - q: params[:q].presence + q: params[:q].presence, + batch_id: params[:batch_id].presence }.compact end diff --git a/app/controllers/flightdeck/batches_controller.rb b/app/controllers/flightdeck/batches_controller.rb new file mode 100644 index 0000000..b4b33cd --- /dev/null +++ b/app/controllers/flightdeck/batches_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Flightdeck + class BatchesController < ApplicationController + rescue_from BatchesQuery::InvalidState, with: :unknown_state + rescue_from ActiveRecord::RecordNotFound, with: :batch_gone + + before_action :require_batches! + + def index + @query = BatchesQuery.new(state: state_param, before_id: params[:before_id]) + @rows = @query.rows + @state_counts = BatchesQuery.state_counts + end + + def show + @batch = BatchDetail.find(params[:id]) + @jobs_query = JobsQuery.new(batch_id: @batch.id, before_id: params[:before_id]) + @rows = @jobs_query.rows + end + + private + def require_batches! + return if BatchesQuery.available? + + redirect_to root_path, alert: "Batches are not available on this Solid Queue installation." + end + + def state_param + value = params[:state].presence&.to_sym || :all + raise BatchesQuery::InvalidState, "unknown batch state #{params[:state].inspect}" unless BatchesQuery::STATES.include?(value) + + value + end + + def unknown_state + redirect_to batches_path, alert: "Unknown batch state." + end + + def batch_gone + redirect_to batches_path, alert: "That batch no longer exists." + end + end +end diff --git a/app/helpers/flightdeck/application_helper.rb b/app/helpers/flightdeck/application_helper.rb index 3c06426..2457b6d 100644 --- a/app/helpers/flightdeck/application_helper.rb +++ b/app/helpers/flightdeck/application_helper.rb @@ -64,6 +64,15 @@ def fd_nav_recurring_count nav_count("recurring") { SolidQueue::RecurringTask.count } end + def fd_nav_batch_count + return "—" unless Flightdeck::BatchesQuery.available? + + query = Flightdeck::BatchesQuery.new + fd_count(query.count, capped: query.count_capped?) + rescue ActiveRecord::ActiveRecordError + "—" + end + def fd_nav_any_dead_processes? nav_count("dead_processes") do SolidQueue::Process.where(last_heartbeat_at: ..SolidQueue.process_alive_threshold.ago).exists? diff --git a/app/helpers/flightdeck/batches_helper.rb b/app/helpers/flightdeck/batches_helper.rb new file mode 100644 index 0000000..0472e19 --- /dev/null +++ b/app/helpers/flightdeck/batches_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Flightdeck + module BatchesHelper + STATE_LABELS = { + all: "All", + unfinished: "Unfinished", + completed: "Completed", + failed: "Failed", + pending: "Pending", + enqueued: "Enqueued" + }.freeze + + PILL_CLASSES = { + pending: "scheduled", + enqueued: "progress", + completed: "finished", + failed: "failed" + }.freeze + + def fd_batch_state_label(state) = STATE_LABELS.fetch(state.to_sym, state.to_s.humanize) + + def fd_batch_state_pill(state) + tag.span(fd_batch_state_label(state).upcase, + class: "fd-pill #{PILL_CLASSES.fetch(state.to_sym, "scheduled")}") + end + + def fd_batch_progress_bar(row) + tag.svg(class: "fd-batch-bar", viewBox: "0 0 100 4", preserveAspectRatio: "none", "aria-hidden": true) do + safe_join([ + tag.rect(class: "fd-depth-track", width: 100, height: 4), + (tag.rect(class: "fd-batch-fill-ok", width: row.completed_share, height: 4) if row.completed_share.positive?), + (tag.rect(class: "fd-batch-fill-bad", x: row.completed_share, width: row.failed_share, height: 4) if row.failed_share.positive?) + ]) + end + end + + def fd_batch_progress_counts(row) + parts = [ "#{number_with_delimiter(row.completed_jobs)} completed" ] + parts << "#{number_with_delimiter(row.failed_jobs)} failed" if row.failed_jobs.positive? + parts << "#{number_with_delimiter(row.pending_jobs)} pending" if row.pending_jobs.positive? + "#{parts.join(" · ")} of #{number_with_delimiter(row.total_jobs)}" + end + + def fd_current_batches_url + batches_path(request.query_parameters) + end + + def fd_current_batch_url(batch) + batch_path(batch.id, request.query_parameters.slice("before_id")) + end + + def dom_id_for_batch(id) = "fd-batch-#{id}" + end +end diff --git a/app/models/flightdeck/batch_detail.rb b/app/models/flightdeck/batch_detail.rb new file mode 100644 index 0000000..e039eaf --- /dev/null +++ b/app/models/flightdeck/batch_detail.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Flightdeck + # Everything the batch show page needs. Callback payloads are treated as + # JSON — they are never handed to ActiveJob to deserialize. + class BatchDetail + attr_reader :row + + def self.find(id) + raise ActiveRecord::RecordNotFound, "Batches are not available" unless BatchesQuery.available? + + new(SolidQueue::Batch.find(id)) + end + + def initialize(batch) + @batch = batch + counts = BatchesQuery.progress_for([ batch ]).fetch(batch.id) + @row = BatchRow.new(batch: batch, **counts) + end + + delegate :id, :title, :status, :total_jobs, :pending_jobs, + :completed_jobs, :failed_jobs, :completed_share, :failed_share, + :enqueued_at, :finished_at, :failed_at, :created_at, + :active_job_batch_id, to: :row + + def metadata + @batch.metadata + rescue StandardError + {} + end + + def metadata? = metadata.present? + + def metadata_json + JSON.pretty_generate(metadata) + rescue StandardError + metadata.to_s + end + + def on_finish_class = callback_class(@batch.on_finish) + def on_success_class = callback_class(@batch.on_success) + def on_failure_class = callback_class(@batch.on_failure) + + private + def callback_class(value) + return unless value.is_a?(Hash) + + value["job_class"].presence || value[:job_class].presence + end + end +end diff --git a/app/models/flightdeck/batch_row.rb b/app/models/flightdeck/batch_row.rb new file mode 100644 index 0000000..4edc1fb --- /dev/null +++ b/app/models/flightdeck/batch_row.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Flightdeck + # Progress counts are injected so a page of unfinished batches does not each + # fire their own live counter queries. + class BatchRow + attr_reader :batch, :pending_jobs, :completed_jobs, :failed_jobs + + def initialize(batch:, pending_jobs:, completed_jobs:, failed_jobs:) + @batch = batch + @pending_jobs = pending_jobs + @completed_jobs = completed_jobs + @failed_jobs = failed_jobs + end + + delegate :id, :description, :total_jobs, :enqueued_at, :finished_at, + :failed_at, :created_at, :active_job_batch_id, :status, to: :batch + + def title + description.presence || "Batch ##{id}" + end + + def completed_share + share(completed_jobs) + end + + def failed_share + remaining = 100 - completed_share + [ share(failed_jobs), remaining ].min + end + + private + def share(count) + return 0 if total_jobs.to_i.zero? + + [ (count.to_f / total_jobs * 100).round, 100 ].min + end + end +end diff --git a/app/models/flightdeck/batches_query.rb b/app/models/flightdeck/batches_query.rb new file mode 100644 index 0000000..e850c45 --- /dev/null +++ b/app/models/flightdeck/batches_query.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Flightdeck + # Feature-detect per request, never at boot — not Batch.migrated?, which memoizes. + class BatchesQuery + STATES = %i[all unfinished completed failed].freeze + PAYLOAD_COLUMNS = %w[on_finish on_success on_failure metadata].freeze + + class InvalidState < ArgumentError; end + + attr_reader :state, :before_id, :limit, :count_cap + + def self.available? + return false unless defined?(::SolidQueue::Batch) + + ::SolidQueue::Batch.table_exists? && + ::SolidQueue::BatchExecution.table_exists? && + ::SolidQueue::Job.column_names.include?("batch_id") + rescue ActiveRecord::ActiveRecordError + false + end + + def self.state_counts + STATES.index_with do |state| + query = new(state: state) + { count: query.count, capped: query.count_capped? } + end + end + + # Stored columns once finished; two grouped counts while running. + def self.progress_for(batches) + batches = Array(batches) + result = {} + + finished, unfinished = batches.partition { |batch| batch.finished_at.present? } + + finished.each do |batch| + result[batch.id] = { + pending_jobs: 0, + completed_jobs: batch[:completed_jobs].to_i, + failed_jobs: batch[:failed_jobs].to_i + } + end + + return result if unfinished.empty? + + ids = unfinished.map(&:id) + pending = SolidQueue::BatchExecution.where(batch_id: ids).group(:batch_id).count + failed = SolidQueue::Job.where(batch_id: ids).joins(:failed_execution).group(:batch_id).count + + unfinished.each do |batch| + pending_jobs = pending[batch.id].to_i + failed_jobs = failed[batch.id].to_i + completed_jobs = [ batch.total_jobs - pending_jobs - failed_jobs, 0 ].max + result[batch.id] = { pending_jobs: pending_jobs, completed_jobs: completed_jobs, failed_jobs: failed_jobs } + end + + result + end + + def initialize(state: :all, before_id: nil, limit: nil, count_cap: nil) + @state = normalize_state(state) + @before_id = before_id.presence && Integer(before_id, exception: false) + @limit = (limit || Flightdeck.config.per_page).to_i.clamp(1, 200) + @count_cap = (count_cap || Flightdeck.config.count_cap).to_i + end + + def rows + @rows ||= build_rows + end + + def next_cursor + rows + @next_cursor + end + + def next_page? = next_cursor.present? + + def count + @count ||= Flightdeck::Cache.fetch("count", "batches", state, count_cap, + expires_in: Flightdeck.config.poll_interval) do + filtered_relation.limit(count_cap).count + end + end + + def count_capped? = count >= count_cap + + def filtered_relation + @filtered_relation ||= apply_filters(model.all) + end + + def model = SolidQueue::Batch + + private + def normalize_state(value) + state = value.presence&.to_sym || :all + raise InvalidState, "unknown batch state #{value.inspect}" unless STATES.include?(state) + + state + end + + def build_rows + page = paginated_records + @next_cursor = page.size > limit ? page[limit - 1].id : nil + page = page.first(limit) + + return [] if page.empty? + + progress = self.class.progress_for(page) + page.map { |batch| BatchRow.new(batch: batch, **progress.fetch(batch.id)) } + end + + def paginated_records + relation = filtered_relation + .reorder(model.arel_table[:id].desc) + .limit(limit + 1) + + relation = relation.where(model.arel_table[:id].lt(before_id)) if before_id + relation.select(driving_columns).to_a + end + + def driving_columns + (model.column_names - PAYLOAD_COLUMNS).map { |name| model.arel_table[name] } + end + + def apply_filters(relation) + case state + when :unfinished then relation.unfinished + when :completed then relation.succeeded + when :failed then relation.failed + else relation + end + end + end +end diff --git a/app/models/flightdeck/job_detail.rb b/app/models/flightdeck/job_detail.rb index 5072b9f..6f5035d 100644 --- a/app/models/flightdeck/job_detail.rb +++ b/app/models/flightdeck/job_detail.rb @@ -32,6 +32,14 @@ def initialize(job:, annotation: nil) @process = load_process end + def batch + return @batch if defined?(@batch) + + @batch = load_batch + end + + def batched? = batch.present? + def id = job.id def class_name = job.class_name def queue_name = job.queue_name @@ -152,5 +160,15 @@ def load_process SolidQueue::Process.find_by(id: execution.process_id) end + + def load_batch + return nil unless BatchesQuery.available? + return nil unless job.has_attribute?(:batch_id) && job.batch_id.present? + + batch = SolidQueue::Batch.find_by(id: job.batch_id) + return nil unless batch + + BatchRow.new(batch: batch, **BatchesQuery.progress_for([ batch ]).fetch(batch.id)) + end end end diff --git a/app/models/flightdeck/jobs_query.rb b/app/models/flightdeck/jobs_query.rb index 712193c..3b15eac 100644 --- a/app/models/flightdeck/jobs_query.rb +++ b/app/models/flightdeck/jobs_query.rb @@ -25,14 +25,15 @@ class JobsQuery class InvalidState < ArgumentError; end - attr_reader :state, :class_name, :queue_name, :q, :before_id, :limit, :count_cap + attr_reader :state, :class_name, :queue_name, :q, :batch_id, :before_id, :limit, :count_cap - def initialize(state: :all, class_name: nil, queue_name: nil, q: nil, before_id: nil, - limit: nil, count_cap: nil) + def initialize(state: :all, class_name: nil, queue_name: nil, q: nil, batch_id: nil, + before_id: nil, limit: nil, count_cap: nil) @state = normalize_state(state) @class_name = class_name.presence @queue_name = queue_name.presence @q = q.presence + @batch_id = batch_id.presence && Integer(batch_id, exception: false) @before_id = before_id.presence && Integer(before_id, exception: false) @limit = (limit || Flightdeck.config.per_page).to_i.clamp(1, 200) @count_cap = (count_cap || Flightdeck.config.count_cap).to_i @@ -66,7 +67,7 @@ def count def count_capped? = count >= count_cap def filters? - class_name.present? || queue_name.present? || q.present? + class_name.present? || queue_name.present? || q.present? || batch_id.present? end # The filtered relation with no ordering, limit or cursor — what bulk @@ -97,7 +98,7 @@ def self.state_counts(**filters) # Only the filters change what a count means — the cursor and page size do # not, so they are deliberately absent from the key. def cache_filters - { class_name: class_name, queue_name: queue_name, q: q } + { class_name: class_name, queue_name: queue_name, q: q, batch_id: batch_id } end def normalize_state(value) @@ -206,6 +207,7 @@ def apply_filters(relation) relation = relation.where(jobs_table[:class_name].eq(class_name)) if class_name relation = relation.where(queue_table[:queue_name].eq(queue_name)) if queue_name relation = relation.where(jobs_table[:class_name].matches("#{escape_like(q)}%", nil, true)) if q + relation = relation.where(jobs_table[:batch_id].eq(batch_id)) if batch_id && batch_column? relation end @@ -213,7 +215,12 @@ def apply_filters(relation) def join_jobs? return false unless execution_backed? - class_name.present? || q.present? || (queue_name.present? && !own_queue_column?) + class_name.present? || q.present? || (batch_id.present? && batch_column?) || + (queue_name.present? && !own_queue_column?) + end + + def batch_column? + SolidQueue::Job.column_names.include?("batch_id") end def own_queue_column? diff --git a/app/views/flightdeck/batches/_list.html.erb b/app/views/flightdeck/batches/_list.html.erb new file mode 100644 index 0000000..ee74b27 --- /dev/null +++ b/app/views/flightdeck/batches/_list.html.erb @@ -0,0 +1,25 @@ +
+
+ + + + + + + + + + + + <% if rows.empty? %> + + <% else %> + <%= render partial: "flightdeck/batches/row", collection: rows, as: :row %> + <% end %> + +
BatchStatusProgressEnqueuedFinished
No batches.
+
+ <%= render "flightdeck/shared/pager", query: query, noun: "batch", + newest_path: batches_path({ state: params[:state].presence }.compact), + older_path: batches_path({ state: params[:state].presence, before_id: query.next_cursor }.compact) %> +
diff --git a/app/views/flightdeck/batches/_row.html.erb b/app/views/flightdeck/batches/_row.html.erb new file mode 100644 index 0000000..9817f29 --- /dev/null +++ b/app/views/flightdeck/batches/_row.html.erb @@ -0,0 +1,15 @@ + + + <%= link_to row.title, batch_path(row.id), data: { turbo_frame: "_top" } %> +
<%= number_with_delimiter(row.total_jobs) %> <%= "job".pluralize(row.total_jobs) %>
+ + <%= fd_batch_state_pill(row.status) %> + +
+ <%= fd_batch_progress_bar(row) %> +
<%= fd_batch_progress_counts(row) %>
+
+ + <%= fd_time(row.enqueued_at || row.created_at) %> + <%= row.finished_at ? fd_time(row.finished_at) : "—" %> + diff --git a/app/views/flightdeck/batches/index.html.erb b/app/views/flightdeck/batches/index.html.erb new file mode 100644 index 0000000..528a0fd --- /dev/null +++ b/app/views/flightdeck/batches/index.html.erb @@ -0,0 +1,16 @@ +<% content_for :page_title, "Batches" %> + +
+ <% Flightdeck::BatchesQuery::STATES.each do |state| %> + <% counts = @state_counts[state] %> + <%= link_to batches_path({ state: (state == :all ? nil : state) }.compact), + class: ("on" if @query.state == state) do %> + <%= fd_batch_state_label(state) %> + <%= fd_count(counts[:count], capped: counts[:capped]) %> + <% end %> + <% end %> +
+ +<%= fd_refresh_frame "fd-batches", url: fd_current_batches_url, target: nil do %> + <%= render "flightdeck/batches/list", query: @query, rows: @rows %> +<% end %> diff --git a/app/views/flightdeck/batches/show.html.erb b/app/views/flightdeck/batches/show.html.erb new file mode 100644 index 0000000..b6ef182 --- /dev/null +++ b/app/views/flightdeck/batches/show.html.erb @@ -0,0 +1,95 @@ +<% content_for :page_title, @batch.title %> + +<%= fd_refresh_frame "fd-batch", url: fd_current_batch_url(@batch) do %> +
+
+

<%= @batch.title %>

+
+ batch #<%= number_with_delimiter(@batch.id) %> + <% if @batch.active_job_batch_id.present? %>· <%= @batch.active_job_batch_id %><% end %> +
+
+ <%= fd_batch_state_pill(@batch.status) %> +
+ <%= link_to "Back to batches", batches_path, class: "fd-btn" %> +
+
+ +
+
+
+
+

Progress

+ <%= fd_batch_progress_counts(@batch) %> +
+
+ <%= fd_batch_progress_bar(@batch) %> +
+
+ +
+

Jobs

+
+ + + + + + + + + + + <% if @rows.empty? %> + + <% else %> + <% @rows.each do |row| %> + + + + + + + <% end %> + <% end %> + +
JobQueueStateEnqueued
No jobs in this batch.
+ <%= link_to row.class_name, job_path(row.id) %> +
<%= fd_args_preview(row.args_preview) %>
+
<%= fd_queue_badge(row.queue_name) %><%= fd_state_pill(row.state) %><%= fd_time(row.enqueued_at) %>
+
+ <%= render "flightdeck/shared/pager", query: @jobs_query, noun: "job", + newest_path: batch_path(@batch.id), + older_path: batch_path(@batch.id, before_id: @jobs_query.next_cursor) %> +
+
+ +
+
+

Metadata

+
+
Enqueued
<%= fd_full_time(@batch.enqueued_at || @batch.created_at) %>
+
Finished
<%= @batch.finished_at ? fd_full_time(@batch.finished_at) : "—" %>
+
Total jobs
<%= number_with_delimiter(@batch.total_jobs) %>
+
Failed
<%= number_with_delimiter(@batch.failed_jobs) %>
+ <% if @batch.on_finish_class.present? %> +
On finish
<%= @batch.on_finish_class %>
+ <% end %> + <% if @batch.on_success_class.present? %> +
On success
<%= @batch.on_success_class %>
+ <% end %> + <% if @batch.on_failure_class.present? %> +
On failure
<%= @batch.on_failure_class %>
+ <% end %> +
+
+ + <% if @batch.metadata? %> +
+

Attached metadata

+
<%= @batch.metadata_json %>
+
+ <% end %> +
+
+<% end %> diff --git a/app/views/flightdeck/jobs/_list.html.erb b/app/views/flightdeck/jobs/_list.html.erb index dfc6c43..ffddc58 100644 --- a/app/views/flightdeck/jobs/_list.html.erb +++ b/app/views/flightdeck/jobs/_list.html.erb @@ -62,7 +62,9 @@ - <%= render "flightdeck/jobs/pager", query: query, noun: "failed job" %> + <%= render "flightdeck/shared/pager", query: query, noun: "failed job", + newest_path: jobs_path(list_filters.merge(state: params[:state].presence).compact), + older_path: jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact) %> <% end %> <% else %> @@ -89,6 +91,8 @@ - <%= render "flightdeck/jobs/pager", query: query, noun: "job" %> + <%= render "flightdeck/shared/pager", query: query, noun: "job", + newest_path: jobs_path(list_filters.merge(state: params[:state].presence).compact), + older_path: jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact) %> <% end %> diff --git a/app/views/flightdeck/jobs/_pager.html.erb b/app/views/flightdeck/jobs/_pager.html.erb deleted file mode 100644 index 8ed2f9e..0000000 --- a/app/views/flightdeck/jobs/_pager.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -
- - <%= fd_count(query.count, capped: query.count_capped?) %> <%= noun.pluralize %><% - %><% if query.count_capped? %> · counts capped at <%= number_with_delimiter(query.count_cap) %><% end %> - -
- <% if params[:before_id].present? %> - <%= link_to "‹ Newest", jobs_path(list_filters.merge(state: params[:state].presence).compact), - class: "fd-btn sm" %> - <% else %> - ‹ Newest - <% end %> - - <% if query.next_page? %> - <%= link_to "Older ›", - jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact), - class: "fd-btn sm" %> - <% else %> - Older › - <% end %> -
-
diff --git a/app/views/flightdeck/jobs/show.html.erb b/app/views/flightdeck/jobs/show.html.erb index b4b7e76..7c7e2b9 100644 --- a/app/views/flightdeck/jobs/show.html.erb +++ b/app/views/flightdeck/jobs/show.html.erb @@ -49,6 +49,24 @@ <% end %> + <% if @job.batched? %> +
+
+

Part of

+ <%= fd_batch_state_pill(@job.batch.status) %> +
+
+
+ <%= link_to @job.batch.title, batch_path(@job.batch.id) %> +
<%= fd_batch_progress_counts(@job.batch) %>
+
+
+ <%= fd_batch_progress_bar(@job.batch) %> +
+
+
+ <% end %> +

Arguments

diff --git a/app/views/flightdeck/shared/_pager.html.erb b/app/views/flightdeck/shared/_pager.html.erb new file mode 100644 index 0000000..53660f1 --- /dev/null +++ b/app/views/flightdeck/shared/_pager.html.erb @@ -0,0 +1,18 @@ +
+ + <%= fd_count(query.count, capped: query.count_capped?) %> <%= noun.pluralize(query.count) %><% if query.count_capped? %> · counts capped at <%= number_with_delimiter(query.count_cap) %><% end %> + +
+ <% if params[:before_id].present? %> + <%= link_to "‹ Newest", newest_path, class: "fd-btn sm" %> + <% else %> + ‹ Newest + <% end %> + + <% if query.next_page? %> + <%= link_to "Older ›", older_path, class: "fd-btn sm" %> + <% else %> + Older › + <% end %> +
+
diff --git a/app/views/layouts/flightdeck/application.html.erb b/app/views/layouts/flightdeck/application.html.erb index f0ee034..46ffe55 100644 --- a/app/views/layouts/flightdeck/application.html.erb +++ b/app/views/layouts/flightdeck/application.html.erb @@ -43,6 +43,13 @@ Failed <%= fd_nav_count(:failed) %> <% end %> + <% if Flightdeck::BatchesQuery.available? %> + <%= link_to batches_path, class: "fd-nav-link#{" on" if controller_name == "batches"}" do %> + + Batches <%= fd_nav_batch_count %> + <% end %> + <% end %> + Infrastructure <%= link_to queues_path, class: "fd-nav-link#{" on" if controller_name == "queues"}" do %> diff --git a/assets-src/input.css b/assets-src/input.css index 15314ae..ed8b41e 100644 --- a/assets-src/input.css +++ b/assets-src/input.css @@ -1004,6 +1004,20 @@ table.fd-data td.fd-indent { padding-left: 28px; } .fd-depth-track { fill: var(--panel-3); } .fd-depth-fill { fill: var(--accent); } +.fd-batch-bar { display: block; width: 100%; height: 6px; min-width: 80px; border-radius: 3px; overflow: hidden; } +.fd-batch-fill-ok { fill: var(--good); } +.fd-batch-fill-bad { fill: var(--crit); } +.fd-batch-progress { display: flex; flex-direction: column; gap: 6px; min-width: 140px; } +.fd-batch-progress-mini { width: 160px; flex-shrink: 0; } +.fd-batch-progress-block { padding: 12px 14px 14px; } +.fd-batch-counts { font: 400 11.5px/1.5 var(--font-mono); color: var(--ink-3); } +.fd-part-of { + display: flex; align-items: center; gap: 16px; padding: 12px 14px; +} +.fd-part-of .grow { flex: 1; min-width: 0; } +.fd-part-of .grow a { font-weight: 500; text-decoration: none; color: var(--ink); } +.fd-part-of .grow a:hover { color: var(--accent-ink); } + /* -------------------------------------------------------------------------- Fleet strip -------------------------------------------------------------------------- */ diff --git a/config/routes.rb b/config/routes.rb index 90115c2..c4f9d77 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ Flightdeck::Engine.routes.draw do root to: "overview#index" + resources :batches, only: %i[index show] + resources :jobs, only: %i[index show] do member do post :retry, to: "jobs/retries#create" diff --git a/solid_queue-flightdeck.gemspec b/solid_queue-flightdeck.gemspec index dafea3c..db9ac48 100644 --- a/solid_queue-flightdeck.gemspec +++ b/solid_queue-flightdeck.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.summary = "A beautiful, featureful dashboard for Solid Queue." spec.description = "Flightdeck is a mountable Rails engine that gives Solid Queue a fast, " \ - "polished dashboard: jobs, failures, queues, processes, recurring tasks " \ + "polished dashboard: jobs, failures, batches, queues, processes, recurring tasks " \ "and live charts. Works in API-only hosts and ships its own precompiled assets." spec.homepage = "https://github.com/cmer/solid_queue-flightdeck" spec.license = "MIT" diff --git a/test/batches_query_test.rb b/test/batches_query_test.rb new file mode 100644 index 0000000..a490628 --- /dev/null +++ b/test/batches_query_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "test_helper" + +class Flightdeck::BatchesQueryTest < ActiveSupport::TestCase + test "available? is true once the batches schema is present" do + assert Flightdeck::BatchesQuery.available? + end + + test "available? is false when jobs lack a batch_id column" do + original = SolidQueue::Job.method(:column_names) + SolidQueue::Job.define_singleton_method(:column_names) { original.call - [ "batch_id" ] } + + refute Flightdeck::BatchesQuery.available? + ensure + SolidQueue::Job.singleton_class.remove_method(:column_names) + end + + test "rejects an unknown state" do + assert_raises(Flightdeck::BatchesQuery::InvalidState) { Flightdeck::BatchesQuery.new(state: :nope) } + end + + test "each tab lists the batches in that status" do + running = create_batch(description: "Running", total_jobs: 1, enqueued_at: Time.current) + add_to_batch(running, create_ready_job, pending: true) + completed = create_finished_batch(description: "Done") + failed = create_failed_batch(description: "Broken") + + assert_equal [ running.id ], ids_for(:unfinished) + assert_equal [ completed.id ], ids_for(:completed) + assert_equal [ failed.id ], ids_for(:failed) + assert_equal [ running.id, completed.id, failed.id ].sort, ids_for(:all).sort + end + + test "unfinished progress is hydrated from grouped counts, not the stale columns" do + batch = create_batch(description: "Sweep", total_jobs: 5, completed_jobs: 0, failed_jobs: 0) + 3.times { add_to_batch(batch, create_ready_job(class_name: "SweepJob"), pending: true) } + add_to_batch(batch, create_finished_job(class_name: "SweepJob")) + add_to_batch(batch, create_failed_job(class_name: "SweepJob")) + + row = Flightdeck::BatchesQuery.new.rows.first + + assert_equal 3, row.pending_jobs + assert_equal 1, row.failed_jobs + assert_equal 1, row.completed_jobs + assert_equal :enqueued, row.status + end + + test "finished batches use the stored counters" do + create_finished_batch(description: "Done", job_count: 3) + + row = Flightdeck::BatchesQuery.new(state: :completed).rows.first + + assert_equal 0, row.pending_jobs + assert_equal 3, row.completed_jobs + assert_equal 0, row.failed_jobs + assert_equal :completed, row.status + end + + test "keyset pagination walks pages without overlap or gaps" do + batches = 7.times.map { |i| create_batch(description: "Batch #{i}") } + expected = batches.map(&:id).sort.reverse + + seen = [] + cursor = nil + 3.times do + query = Flightdeck::BatchesQuery.new(limit: 3, before_id: cursor) + seen.concat(query.rows.map(&:id)) + cursor = query.next_cursor + end + + assert_equal expected, seen + assert_equal seen.uniq, seen + assert_nil cursor + end + + test "counts are capped" do + 5.times { |i| create_batch(description: "B#{i}") } + + query = Flightdeck::BatchesQuery.new(count_cap: 3) + + assert_equal 3, query.count + assert query.count_capped? + end + + test "list queries never select callback or metadata columns" do + create_finished_batch + + statements = capture_sql do + Flightdeck::BatchesQuery.new.rows + end + + driving = statements.select { |sql| sql.match?(/FROM ["`]?solid_queue_batches/i) } + + assert driving.any?, "expected a driving query against solid_queue_batches" + driving.each do |sql| + refute_match(/SELECT[^;]*\bon_finish\b/i, sql, "a list query selected on_finish:\n#{sql}") + refute_match(/SELECT[^;]*\bon_success\b/i, sql, "a list query selected on_success:\n#{sql}") + refute_match(/SELECT[^;]*\bon_failure\b/i, sql, "a list query selected on_failure:\n#{sql}") + refute_match(/SELECT[^;]*\bmetadata\b/i, sql, "a list query selected metadata:\n#{sql}") + end + end + + test "state_counts reports every tab" do + create_batch(description: "Running", total_jobs: 1) + create_finished_batch + create_failed_batch + + counts = Flightdeck::BatchesQuery.state_counts + + assert_equal 3, counts[:all][:count] + assert_equal 1, counts[:unfinished][:count] + assert_equal 1, counts[:completed][:count] + assert_equal 1, counts[:failed][:count] + end + + private + def ids_for(state) + Flightdeck::BatchesQuery.new(state: state).rows.map(&:id) + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 85194b6..f9a71da 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -37,7 +37,9 @@ t.string "concurrency_key" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "batch_id" t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" @@ -120,6 +122,35 @@ t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true end + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb index 1a94c9c..3928fa1 100644 --- a/test/dummy/db/seeds.rb +++ b/test/dummy/db/seeds.rb @@ -46,10 +46,11 @@ def call seed_history seed_backlog seed_recurring + seed_batches puts "Seeded: #{SolidQueue::Job.count} jobs, #{SolidQueue::FailedExecution.count} failed, " \ "#{SolidQueue::ReadyExecution.count} ready, #{SolidQueue::Process.count} processes, " \ - "#{SolidQueue::RecurringTask.count} recurring tasks." + "#{SolidQueue::RecurringTask.count} recurring tasks, #{SolidQueue::Batch.count} batches." end private @@ -136,6 +137,22 @@ def seed_recurring record_recurring_run(task, run_at: rand(1..90).minutes.ago, failed: key == "reconcile_payments") end end + + def seed_batches + running = create_batch(description: "Nightly billing sweep", total_jobs: 8, + enqueued_at: 12.minutes.ago, metadata: { "user_id" => 42 }, + on_finish: { "job_class" => "NotifyBillingFinishedJob" }) + 4.times do + add_to_batch(running, create_ready_job(class_name: "Billing::ChargeSubscriptionJob", queue_name: "critical"), + pending: true) + end + 2.times { add_to_batch(running, create_finished_job(class_name: "Billing::ChargeSubscriptionJob", queue_name: "critical")) } + 2.times { add_to_batch(running, create_failed_job(class_name: "Billing::ChargeSubscriptionJob", queue_name: "critical")) } + + create_finished_batch(description: "Image variants", job_count: 5, + metadata: { "source" => "uploads" }) + create_failed_batch(description: "Webhook replay") + end end FlightdeckSeeder.new.call diff --git a/test/integration/batches_test.rb b/test/integration/batches_test.rb new file mode 100644 index 0000000..82d1ef2 --- /dev/null +++ b/test/integration/batches_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +class Flightdeck::BatchesTest < FlightdeckIntegrationTest + test "the index requires authentication" do + get "/flightdeck/batches" + + assert_response :unauthorized + end + + test "the sidebar links to batches when the schema is present" do + create_batch(description: "Nightly import") + + get_fd "/flightdeck/jobs" + + assert_select ".fd-nav-link[href=?]", "/flightdeck/batches" + end + + test "the sidebar hides batches when they are unavailable" do + with_batches_unavailable do + get_fd "/flightdeck/jobs" + + assert_select ".fd-nav-link[href=?]", "/flightdeck/batches", count: 0 + end + end + + test "visiting batches when unavailable redirects to the overview" do + with_batches_unavailable do + get_fd "/flightdeck/batches" + + assert_redirected_to "/flightdeck/" + end + end + + test "each state tab lists the matching batches" do + running = create_batch(description: "Running import", total_jobs: 1) + add_to_batch(running, create_ready_job, pending: true) + completed = create_finished_batch(description: "Completed import") + failed = create_failed_batch(description: "Failed import") + + get_fd "/flightdeck/batches?state=unfinished" + assert_equal [ running.id ], batch_ids_in_response + + get_fd "/flightdeck/batches?state=completed" + assert_equal [ completed.id ], batch_ids_in_response + + get_fd "/flightdeck/batches?state=failed" + assert_equal [ failed.id ], batch_ids_in_response + end + + test "the all tab lists every batch and labels its status" do + create_batch(description: "Running import") + create_finished_batch(description: "Completed import") + create_failed_batch(description: "Failed import") + + get_fd "/flightdeck/batches" + + assert_response :success + assert_includes response.body, "ENQUEUED" + assert_includes response.body, "COMPLETED" + assert_includes response.body, "FAILED" + assert_select ".fd-statetabs a", count: Flightdeck::BatchesQuery::STATES.size + end + + test "an empty list says so" do + get_fd "/flightdeck/batches" + + assert_select ".fd-empty", text: /No batches/ + end + + test "the list lives in a polling turbo frame" do + create_batch + + get_fd "/flightdeck/batches?state=unfinished" + + assert_select "turbo-frame#fd-batches" do |frames| + frame = frames.first + assert_equal "refresh", frame["data-controller"] + assert_includes frame["data-refresh-url-value"], "state=unfinished" + end + end + + test "keyset pagination walks pages" do + batches = 7.times.map { |i| create_batch(description: "Batch #{i}") } + Flightdeck.config.per_page = 3 + + seen = [] + path = "/flightdeck/batches" + + 3.times do + get_fd path + assert_response :success + seen.concat(batch_ids_in_response) + + cursor = response.body[/href="[^"]*before_id=(\d+)/, 1] + break if cursor.nil? + + path = "/flightdeck/batches?before_id=#{cursor}" + end + + assert_equal batches.map(&:id).sort.reverse, seen + ensure + Flightdeck.config.per_page = 25 + end + + test "the detail page shows progress, jobs and callback class names" do + batch = create_batch( + description: "Nightly billing sweep", + total_jobs: 2, + on_finish: { "job_class" => "NotifyBillingFinishedJob" }, + metadata: { "user_id" => 42 } + ) + ready = create_ready_job(class_name: "Billing::ChargeSubscriptionJob") + failed = create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + add_to_batch(batch, ready, pending: true) + add_to_batch(batch, failed) + + get_fd "/flightdeck/batches/#{batch.id}" + + assert_response :success + assert_select ".fd-jd-head h2", text: "Nightly billing sweep" + assert_includes response.body, "NotifyBillingFinishedJob" + assert_includes response.body, "user_id" + assert_equal [ ready.id, failed.id ].sort, job_ids_in_response.sort + assert_select "a[href=?]", "/flightdeck/jobs/#{ready.id}" + end + + test "a missing batch redirects to the list" do + get_fd "/flightdeck/batches/999999" + + assert_redirected_to "/flightdeck/batches" + end + + test "an unknown state redirects to the default list" do + get_fd "/flightdeck/batches?state=nonsense" + + assert_redirected_to "/flightdeck/batches" + end + + test "a job that belongs to a batch links back from the job page" do + batch = create_batch(description: "Nightly billing sweep", total_jobs: 1) + job = create_ready_job(class_name: "Billing::ChargeSubscriptionJob") + add_to_batch(batch, job, pending: true) + + get_fd "/flightdeck/jobs/#{job.id}" + + assert_response :success + assert_select ".fd-part-of a[href=?]", "/flightdeck/batches/#{batch.id}", text: "Nightly billing sweep" + assert_includes response.body, "pending" + end + + test "a job with no batch does not render the part-of card" do + job = create_ready_job + + get_fd "/flightdeck/jobs/#{job.id}" + + assert_response :success + assert_select ".fd-part-of", count: 0 + end + + private + def batch_ids_in_response + response.body.scan(/id="fd-batch-(\d+)"/).flatten.map(&:to_i) + end +end diff --git a/test/integration/csp_inline_styles_test.rb b/test/integration/csp_inline_styles_test.rb index fc189c5..7bfd8b6 100644 --- a/test/integration/csp_inline_styles_test.rb +++ b/test/integration/csp_inline_styles_test.rb @@ -29,9 +29,9 @@ class Flightdeck::CspInlineStylesTest < FlightdeckIntegrationTest private def dashboard_paths - paths = %w[/flightdeck /flightdeck/queues /flightdeck/processes /flightdeck/recurring_tasks] + paths = %w[/flightdeck /flightdeck/queues /flightdeck/processes /flightdeck/recurring_tasks /flightdeck/batches] paths += Flightdeck::JobsQuery::STATES.map { |state| "/flightdeck/jobs?state=#{state}" } - paths + @job_paths + paths + @job_paths + @batch_paths end def assert_no_inline_styles(path) @@ -57,5 +57,11 @@ def seed_everything "/flightdeck/jobs/#{scenario[:failed].id}?state=failed", "/flightdeck/jobs/#{scenario[:ready].id}" ] + + batch = create_batch(description: "Nightly import", total_jobs: 1) + batched = create_ready_job(class_name: "ImportRowJob") + add_to_batch(batch, batched, pending: true) + @job_paths << "/flightdeck/jobs/#{batched.id}" + @batch_paths = [ "/flightdeck/batches/#{batch.id}" ] end end diff --git a/test/integration/frame_navigation_test.rb b/test/integration/frame_navigation_test.rb index 256c309..5a0eeef 100644 --- a/test/integration/frame_navigation_test.rb +++ b/test/integration/frame_navigation_test.rb @@ -64,11 +64,40 @@ class Flightdeck::FrameNavigationTest < FlightdeckIntegrationTest assert_select "turbo-frame#fd-jobs td.cls a[data-turbo-frame=_top]", 1 end + test "the batches frame keeps pagination in-frame" do + 30.times { |i| create_batch(description: "Batch #{i}") } + Flightdeck.config.per_page = 5 + + get_fd "/flightdeck/batches" + + assert_select "turbo-frame#fd-batches[target=_top]", 0, + "pagination must stay inside the batches frame" + assert_select "turbo-frame#fd-batches a[href*='before_id']", minimum: 1 + ensure + Flightdeck.config.per_page = 25 + end + + test "batch links inside the batches frame escape it" do + create_batch(description: "Nightly import") + + get_fd "/flightdeck/batches" + + assert_select "turbo-frame#fd-batches td.cls a[data-turbo-frame=_top]", 1 + end + + test "the batch show frame lets its links escape" do + batch = create_finished_batch(description: "Nightly import") + + get_fd "/flightdeck/batches/#{batch.id}" + + assert_select "turbo-frame#fd-batch[target=_top]", 1 + end + # The actual failure mode: a link's destination has no frame with its id. test "no link inside a frame points at a page that lacks that frame" do seed_everything - ([ "/flightdeck/jobs", "/flightdeck/jobs?state=failed" ] + TOP_LEVEL_FRAMES.keys).each do |path| + ([ "/flightdeck/jobs", "/flightdeck/jobs?state=failed", "/flightdeck/batches" ] + TOP_LEVEL_FRAMES.keys).each do |path| get_fd path document = Nokogiri::HTML(response.body) @@ -99,5 +128,6 @@ def seed_everything task = create_recurring_task(key: "digest") record_recurring_run(task, run_at: 1.hour.ago) create_finished_job(queue_name: "critical", finished_at: 30.minutes.ago) + create_finished_batch(description: "Nightly import") end end diff --git a/test/integration/nested_mount_test.rb b/test/integration/nested_mount_test.rb index 3432c1e..f94159d 100644 --- a/test/integration/nested_mount_test.rb +++ b/test/integration/nested_mount_test.rb @@ -76,7 +76,7 @@ def assert_no_bare_mount_urls(body, context) test "every page under the nested mount keeps its links inside it" do seed_everything - %w[/ /jobs /jobs?state=failed /queues /processes /recurring_tasks].each do |path| + %w[/ /jobs /jobs?state=failed /batches /queues /processes /recurring_tasks].each do |path| get_fd "#{MOUNT}#{path}" assert_response :success, "#{MOUNT}#{path} did not render" @@ -243,5 +243,6 @@ def seed_everything task = create_recurring_task(key: "digest") record_recurring_run(task, run_at: 1.hour.ago) create_finished_job(queue_name: "critical", finished_at: 30.minutes.ago) + create_finished_batch(description: "Nightly import") end end diff --git a/test/job_detail_test.rb b/test/job_detail_test.rb index c82685c..5fbad37 100644 --- a/test/job_detail_test.rb +++ b/test/job_detail_test.rb @@ -92,6 +92,25 @@ class Flightdeck::JobDetailTest < ActiveSupport::TestCase assert_includes detail.timeline.map(&:label), "Finished" end + test "a job that belongs to a batch exposes the batch on the detail object" do + batch = create_batch(description: "Nightly import", total_jobs: 1) + job = create_ready_job + add_to_batch(batch, job, pending: true) + + detail = Flightdeck::JobDetail.find(job.id) + + assert detail.batched? + assert_equal batch.id, detail.batch.id + assert_equal "Nightly import", detail.batch.title + assert_equal :enqueued, detail.batch.status + end + + test "a job with no batch is not batched" do + job = create_ready_job + + refute Flightdeck::JobDetail.find(job.id).batched? + end + test "an in-progress job names the process that claimed it" do process = create_process(name: "jobs-01", pid: 4172) job = create_claimed_job(process: process) diff --git a/test/jobs_query_test.rb b/test/jobs_query_test.rb index 1638215..dac2474 100644 --- a/test/jobs_query_test.rb +++ b/test/jobs_query_test.rb @@ -41,6 +41,23 @@ class Flightdeck::JobsQueryTest < ActiveSupport::TestCase assert_equal [ a.id ], composed.rows.map(&:id) end + test "filters by batch membership on ready and failed states" do + batch = create_batch(total_jobs: 2) + ready = create_ready_job(class_name: "AlphaJob") + failed = create_failed_job(class_name: "AlphaJob") + add_to_batch(batch, ready, pending: true) + add_to_batch(batch, failed) + create_ready_job(class_name: "BetaJob") + create_failed_job(class_name: "BetaJob") + + ready_query = Flightdeck::JobsQuery.new(state: :ready, batch_id: batch.id) + failed_query = Flightdeck::JobsQuery.new(state: :failed, batch_id: batch.id) + + assert_equal [ ready.id ], ready_query.rows.map(&:id) + assert_equal 1, ready_query.count + assert_equal [ failed.id ], failed_query.rows.map(&:id) + end + test "filters compose on states whose execution table has no queue column" do a = create_failed_job(class_name: "AlphaJob", queue_name: "critical") create_failed_job(class_name: "AlphaJob", queue_name: "low") @@ -176,15 +193,4 @@ class Flightdeck::JobsQueryTest < ActiveSupport::TestCase def ids_for(state) Flightdeck::JobsQuery.new(state: state).rows.map(&:id) end - - def capture_sql - statements = [] - subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| - statements << payload[:sql] unless payload[:name] == "SCHEMA" - end - yield - statements - ensure - ActiveSupport::Notifications.unsubscribe(subscriber) - end end diff --git a/test/support/solid_queue_scenario.rb b/test/support/solid_queue_scenario.rb index 3018677..f5d4ae8 100644 --- a/test/support/solid_queue_scenario.rb +++ b/test/support/solid_queue_scenario.rb @@ -16,6 +16,12 @@ module SolidQueueScenario }.freeze def clear_solid_queue! + if defined?(SolidQueue::BatchExecution) && SolidQueue::BatchExecution.table_exists? + SolidQueue::BatchExecution.delete_all + end + if defined?(SolidQueue::Batch) && SolidQueue::Batch.table_exists? + SolidQueue::Batch.delete_all + end SolidQueue::ClaimedExecution.delete_all SolidQueue::ReadyExecution.delete_all SolidQueue::ScheduledExecution.delete_all @@ -198,6 +204,63 @@ def create_process(name: nil, kind: "Worker", hostname: "jobs-01", pid: 4172, ) end + # --- batches -------------------------------------------------------------- + + def create_batch(description: "Nightly import", total_jobs: 0, completed_jobs: 0, failed_jobs: 0, + enqueued_at: Time.current, finished_at: nil, failed_at: nil, + metadata: nil, on_finish: nil, on_success: nil, on_failure: nil, + created_at: Time.current) + attrs = { + active_job_batch_id: SecureRandom.uuid, + description: description, + total_jobs: total_jobs, + completed_jobs: completed_jobs, + failed_jobs: failed_jobs, + enqueued_at: enqueued_at, + finished_at: finished_at, + failed_at: failed_at, + created_at: created_at, + updated_at: created_at + } + attrs[:metadata] = metadata if metadata + attrs[:on_finish] = on_finish if on_finish + attrs[:on_success] = on_success if on_success + attrs[:on_failure] = on_failure if on_failure + + SolidQueue::Batch.insert_all!([ attrs ]) + SolidQueue::Batch.order(id: :desc).first + end + + # Membership is the jobs.batch_id column. A pending tracking row is only + # present while the job is still outstanding — finished and failed jobs + # drop theirs, matching Solid Queue. + def add_to_batch(batch, job, pending: false) + SolidQueue::Job.where(id: job.id).update_all(batch_id: batch.id) + if pending + SolidQueue::BatchExecution.insert_all!([ + { job_id: job.id, batch_id: batch.id, created_at: Time.current } + ]) + end + job + end + + def create_finished_batch(description: "Completed import", job_count: 2, **attributes) + batch = create_batch(description: description, total_jobs: job_count, + completed_jobs: job_count, failed_jobs: 0, + enqueued_at: 1.hour.ago, finished_at: 50.minutes.ago, **attributes) + job_count.times { add_to_batch(batch, create_finished_job(class_name: "ImportRowJob")) } + batch + end + + def create_failed_batch(description: "Failed import", **attributes) + batch = create_batch(description: description, total_jobs: 2, completed_jobs: 1, failed_jobs: 1, + enqueued_at: 1.hour.ago, finished_at: 50.minutes.ago, + failed_at: 50.minutes.ago, **attributes) + add_to_batch(batch, create_finished_job(class_name: "ImportRowJob")) + add_to_batch(batch, create_failed_job(class_name: "ImportRowJob")) + batch + end + # One job in every state, keyed by state name. def create_full_scenario { diff --git a/test/test_helper.rb b/test/test_helper.rb index 1525b39..c28da81 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,6 +11,15 @@ ActiveRecord::Schema.verbose = false load Rails.root.join("db", "schema.rb") +# Schema is loaded after boot, so cached column lists (and Solid Queue's +# Batch.migrated? memo) would otherwise describe the previous process's tables. +SolidQueue::Job.reset_column_information +if defined?(SolidQueue::Batch) + SolidQueue::Batch.reset_column_information + SolidQueue::Batch.remove_instance_variable(:@migrated) if SolidQueue::Batch.instance_variable_defined?(:@migrated) +end +SolidQueue::BatchExecution.reset_column_information if defined?(SolidQueue::BatchExecution) + module Flightdeck module TestHelpers BASIC_ENV_KEYS = %w[FLIGHTDECK_USERNAME FLIGHTDECK_PASSWORD].freeze @@ -51,6 +60,25 @@ def with_env_basic_auth(username: "pilot", password: "correct-horse-battery") def basic_auth_header(username, password) ActionController::HttpAuthentication::Basic.encode_credentials(username, password) end + + def with_batches_unavailable + original = Flightdeck::BatchesQuery.method(:available?) + Flightdeck::BatchesQuery.define_singleton_method(:available?) { false } + yield + ensure + Flightdeck::BatchesQuery.define_singleton_method(:available?, original) + end + + def capture_sql + statements = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + statements << payload[:sql] unless payload[:name] == "SCHEMA" + end + yield + statements + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end end end From 4aae0f5e880ba8f836b4800e649d79664fd3e48a Mon Sep 17 00:00:00 2001 From: Leigh Halliday Date: Mon, 21 Sep 2026 20:22:46 -0400 Subject: [PATCH 2/2] Refactoring and code cleanup --- .../flightdeck/application_controller.rb | 3 +- .../flightdeck/batches_controller.rb | 9 +--- app/helpers/flightdeck/jobs_helper.rb | 4 ++ app/models/flightdeck/batch_detail.rb | 42 +++++++++---------- app/views/flightdeck/batches/show.html.erb | 10 +---- app/views/flightdeck/jobs/_list.html.erb | 8 ++-- test/integration/batches_test.rb | 16 +++++++ 7 files changed, 47 insertions(+), 45 deletions(-) diff --git a/app/controllers/flightdeck/application_controller.rb b/app/controllers/flightdeck/application_controller.rb index 3fc6ac8..5099e3c 100644 --- a/app/controllers/flightdeck/application_controller.rb +++ b/app/controllers/flightdeck/application_controller.rb @@ -78,8 +78,7 @@ def list_filters @list_filters ||= { class_name: params[:class_name].presence, queue_name: params[:queue_name].presence, - q: params[:q].presence, - batch_id: params[:batch_id].presence + q: params[:q].presence }.compact end diff --git a/app/controllers/flightdeck/batches_controller.rb b/app/controllers/flightdeck/batches_controller.rb index b4b33cd..2376a07 100644 --- a/app/controllers/flightdeck/batches_controller.rb +++ b/app/controllers/flightdeck/batches_controller.rb @@ -8,7 +8,7 @@ class BatchesController < ApplicationController before_action :require_batches! def index - @query = BatchesQuery.new(state: state_param, before_id: params[:before_id]) + @query = BatchesQuery.new(state: params[:state], before_id: params[:before_id]) @rows = @query.rows @state_counts = BatchesQuery.state_counts end @@ -26,13 +26,6 @@ def require_batches! redirect_to root_path, alert: "Batches are not available on this Solid Queue installation." end - def state_param - value = params[:state].presence&.to_sym || :all - raise BatchesQuery::InvalidState, "unknown batch state #{params[:state].inspect}" unless BatchesQuery::STATES.include?(value) - - value - end - def unknown_state redirect_to batches_path, alert: "Unknown batch state." end diff --git a/app/helpers/flightdeck/jobs_helper.rb b/app/helpers/flightdeck/jobs_helper.rb index d897916..c7d1129 100644 --- a/app/helpers/flightdeck/jobs_helper.rb +++ b/app/helpers/flightdeck/jobs_helper.rb @@ -99,6 +99,10 @@ def fd_current_list_url jobs_path(request.query_parameters) end + def fd_jobs_list_path(**extra) + jobs_path(list_filters.merge(state: params[:state].presence, **extra).compact) + end + def dom_id_for_job(id) = "fd-job-#{id}" # Under an exception-class group header, repeating the class on every row is diff --git a/app/models/flightdeck/batch_detail.rb b/app/models/flightdeck/batch_detail.rb index e039eaf..acf5ee3 100644 --- a/app/models/flightdeck/batch_detail.rb +++ b/app/models/flightdeck/batch_detail.rb @@ -3,28 +3,16 @@ module Flightdeck # Everything the batch show page needs. Callback payloads are treated as # JSON — they are never handed to ActiveJob to deserialize. - class BatchDetail - attr_reader :row - + class BatchDetail < BatchRow def self.find(id) raise ActiveRecord::RecordNotFound, "Batches are not available" unless BatchesQuery.available? - new(SolidQueue::Batch.find(id)) - end - - def initialize(batch) - @batch = batch - counts = BatchesQuery.progress_for([ batch ]).fetch(batch.id) - @row = BatchRow.new(batch: batch, **counts) + batch = SolidQueue::Batch.find(id) + new(batch: batch, **BatchesQuery.progress_for([ batch ]).fetch(batch.id)) end - delegate :id, :title, :status, :total_jobs, :pending_jobs, - :completed_jobs, :failed_jobs, :completed_share, :failed_share, - :enqueued_at, :finished_at, :failed_at, :created_at, - :active_job_batch_id, to: :row - def metadata - @batch.metadata + batch.metadata rescue StandardError {} end @@ -37,15 +25,23 @@ def metadata_json metadata.to_s end - def on_finish_class = callback_class(@batch.on_finish) - def on_success_class = callback_class(@batch.on_success) - def on_failure_class = callback_class(@batch.on_failure) + def callbacks + [ + [ "On finish", :on_finish ], + [ "On success", :on_success ], + [ "On failure", :on_failure ] + ].filter_map do |label, name| + job_class = callback_class(name) + [ label, job_class ] if job_class.present? + end + end private - def callback_class(value) - return unless value.is_a?(Hash) - - value["job_class"].presence || value[:job_class].presence + def callback_class(name) + value = batch.public_send(name) + value["job_class"].presence if value.is_a?(Hash) + rescue StandardError + nil end end end diff --git a/app/views/flightdeck/batches/show.html.erb b/app/views/flightdeck/batches/show.html.erb index b6ef182..14ab5e2 100644 --- a/app/views/flightdeck/batches/show.html.erb +++ b/app/views/flightdeck/batches/show.html.erb @@ -72,14 +72,8 @@
Finished
<%= @batch.finished_at ? fd_full_time(@batch.finished_at) : "—" %>
Total jobs
<%= number_with_delimiter(@batch.total_jobs) %>
Failed
<%= number_with_delimiter(@batch.failed_jobs) %>
- <% if @batch.on_finish_class.present? %> -
On finish
<%= @batch.on_finish_class %>
- <% end %> - <% if @batch.on_success_class.present? %> -
On success
<%= @batch.on_success_class %>
- <% end %> - <% if @batch.on_failure_class.present? %> -
On failure
<%= @batch.on_failure_class %>
+ <% @batch.callbacks.each do |label, job_class| %> +
<%= label %>
<%= job_class %>
<% end %>
diff --git a/app/views/flightdeck/jobs/_list.html.erb b/app/views/flightdeck/jobs/_list.html.erb index ffddc58..3ead439 100644 --- a/app/views/flightdeck/jobs/_list.html.erb +++ b/app/views/flightdeck/jobs/_list.html.erb @@ -63,8 +63,8 @@ <%= render "flightdeck/shared/pager", query: query, noun: "failed job", - newest_path: jobs_path(list_filters.merge(state: params[:state].presence).compact), - older_path: jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact) %> + newest_path: fd_jobs_list_path, + older_path: fd_jobs_list_path(before_id: query.next_cursor) %> <% end %> <% else %> @@ -92,7 +92,7 @@ <%= render "flightdeck/shared/pager", query: query, noun: "job", - newest_path: jobs_path(list_filters.merge(state: params[:state].presence).compact), - older_path: jobs_path(list_filters.merge(state: params[:state].presence, before_id: query.next_cursor).compact) %> + newest_path: fd_jobs_list_path, + older_path: fd_jobs_list_path(before_id: query.next_cursor) %> <% end %> diff --git a/test/integration/batches_test.rb b/test/integration/batches_test.rb index 82d1ef2..60fcb15 100644 --- a/test/integration/batches_test.rb +++ b/test/integration/batches_test.rb @@ -126,6 +126,22 @@ class Flightdeck::BatchesTest < FlightdeckIntegrationTest assert_select "a[href=?]", "/flightdeck/jobs/#{ready.id}" end + test "a batch with unreadable callback JSON still renders" do + batch = create_batch( + description: "Nightly billing sweep", + on_success: { "job_class" => "NotifyBillingSucceededJob" } + ) + SolidQueue::Batch.where(id: batch.id).update_all("on_finish = '{'") + + get_fd "/flightdeck/batches/#{batch.id}" + + assert_response :success + assert_select ".fd-jd-head h2", text: "Nightly billing sweep" + assert_select ".k", text: "On finish", count: 0 + assert_select ".k", text: "On success" + assert_includes response.body, "NotifyBillingSucceededJob" + end + test "a missing batch redirects to the list" do get_fd "/flightdeck/batches/999999"