From 06db468e055e9c3c95a6cee57615359bfdb756d6 Mon Sep 17 00:00:00 2001 From: Ryan Rodriguez Date: Thu, 5 Nov 2026 16:30:35 -0600 Subject: [PATCH] feat(owners): map failed jobs to their CODEOWNERS teams Add an Owners page listing failed jobs per class, with the owners of each class and counts for the last 1h, 3h, 24h, 3d and 7d. It's off until config.codeowners_path is set, so existing installs see no change. Each class is matched by the file that defines it rather than a naming convention, so components and engines resolve like any other path. A class that can't be loaded or lives outside the repo shows as Unowned. Class names and counts open the Failed list filtered by class and window. That needs a new failed_within filter on JobsQuery. It travels in list_filters, so pagination, tabs, polling and "all matching" bulk actions all see the same rows. The Filter button also now keeps a class filter, and the jobs page shows the class and window it's filtered by. Owner chips and a class search narrow the page in Ruby, since every class is already in memory. Counts only cover jobs that are still failed: Solid Queue deletes a failed execution when its job is retried or discarded. --- CHANGELOG.md | 4 + README.md | 24 +++ ...531cee.css => flightdeck-a9144fe69881.css} | 2 +- app/assets/flightdeck/manifest.json | 8 +- .../flightdeck/application_controller.rb | 3 +- .../flightdeck/owners_controller.rb | 15 ++ app/helpers/flightdeck/owners_helper.rb | 11 + app/models/flightdeck/codeowners.rb | 103 +++++++++ app/models/flightdeck/jobs_query.rb | 33 ++- app/models/flightdeck/owner_failures.rb | 83 ++++++++ .../flightdeck/jobs/_filter_bar.html.erb | 5 + app/views/flightdeck/owners/_table.html.erb | 59 +++++ app/views/flightdeck/owners/index.html.erb | 32 +++ .../layouts/flightdeck/application.html.erb | 7 + assets-src/input.css | 2 + config/routes.rb | 2 + lib/flightdeck/configuration.rb | 4 +- test/codeowners_test.rb | 201 ++++++++++++++++++ test/dummy/.github/CODEOWNERS | 24 +++ .../jobs/billing/charge_subscription_job.rb | 8 + .../app/jobs/billing/refund_order_job.rb | 8 + .../app/jobs/billing/sync_invoices_job.rb | 8 + .../app/jobs/finance/export_report_job.rb | 8 + .../dummy/app/jobs/finance/sync_ledger_job.rb | 8 + test/dummy/app/jobs/legacy/export_csv_job.rb | 8 + test/dummy/app/jobs/legacy/import_csv_job.rb | 8 + .../dummy/app/jobs/mobile/sync_devices_job.rb | 8 + test/dummy/app/jobs/notifications/push_job.rb | 8 + .../app/jobs/notifications/send_email_job.rb | 8 + .../app/jobs/notifications/send_sms_job.rb | 8 + .../app/jobs/platform/export_audit_log_job.rb | 8 + .../dummy/app/jobs/platform/sync_users_job.rb | 8 + .../dummy/app/jobs/sales/export_report_job.rb | 8 + .../app/jobs/sales/send_quote_email_job.rb | 8 + test/dummy/app/jobs/sales/sync_leads_job.rb | 8 + .../app/jobs/security/rotate_keys_job.rb | 8 + .../app/jobs/security/scan_uploads_job.rb | 8 + .../config/initializers/demo_job_classes.rb | 2 + test/dummy/db/seeds.rb | 31 +++ test/integration/job_actions_test.rb | 20 ++ test/integration/jobs_test.rb | 29 +++ test/integration/owners_test.rb | 145 +++++++++++++ test/jobs_query_test.rb | 50 +++++ test/owner_failures_test.rb | 124 +++++++++++ 44 files changed, 1155 insertions(+), 12 deletions(-) rename app/assets/flightdeck/{flightdeck-38ba36531cee.css => flightdeck-a9144fe69881.css} (68%) create mode 100644 app/controllers/flightdeck/owners_controller.rb create mode 100644 app/helpers/flightdeck/owners_helper.rb create mode 100644 app/models/flightdeck/codeowners.rb create mode 100644 app/models/flightdeck/owner_failures.rb create mode 100644 app/views/flightdeck/owners/_table.html.erb create mode 100644 app/views/flightdeck/owners/index.html.erb create mode 100644 test/codeowners_test.rb create mode 100644 test/dummy/.github/CODEOWNERS create mode 100644 test/dummy/app/jobs/billing/charge_subscription_job.rb create mode 100644 test/dummy/app/jobs/billing/refund_order_job.rb create mode 100644 test/dummy/app/jobs/billing/sync_invoices_job.rb create mode 100644 test/dummy/app/jobs/finance/export_report_job.rb create mode 100644 test/dummy/app/jobs/finance/sync_ledger_job.rb create mode 100644 test/dummy/app/jobs/legacy/export_csv_job.rb create mode 100644 test/dummy/app/jobs/legacy/import_csv_job.rb create mode 100644 test/dummy/app/jobs/mobile/sync_devices_job.rb create mode 100644 test/dummy/app/jobs/notifications/push_job.rb create mode 100644 test/dummy/app/jobs/notifications/send_email_job.rb create mode 100644 test/dummy/app/jobs/notifications/send_sms_job.rb create mode 100644 test/dummy/app/jobs/platform/export_audit_log_job.rb create mode 100644 test/dummy/app/jobs/platform/sync_users_job.rb create mode 100644 test/dummy/app/jobs/sales/export_report_job.rb create mode 100644 test/dummy/app/jobs/sales/send_quote_email_job.rb create mode 100644 test/dummy/app/jobs/sales/sync_leads_job.rb create mode 100644 test/dummy/app/jobs/security/rotate_keys_job.rb create mode 100644 test/dummy/app/jobs/security/scan_uploads_job.rb create mode 100644 test/integration/owners_test.rb create mode 100644 test/owner_failures_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e2ccd..2b08e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- An Owners page mapping failed jobs to their CODEOWNERS teams, with search, turned on with `config.codeowners_path`. Its class names and counts open the Failed list, filtered by class and time window. + ## [1.2.0] - 2026-09-04 ### Added diff --git a/README.md b/README.md index 645a020..49d17f1 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ the engine answers 401 behind your constraint. | `display_timezone` | `"UTC"` | Timezone for displayed timestamps. Queries stay in UTC. | | `backtrace_lines` | `50` | Backtrace frames shown on job detail. | | `ui_font` | `"public-sans"` | Default interface typeface. Each user can pick another one in the sidebar. | +| `codeowners_path` | `nil` | CODEOWNERS file, relative to `Rails.root` or absolute. Turns on the Owners page. | ## Interface font @@ -157,6 +158,29 @@ rather than inlined, so a browser only downloads the one face it is rendering in. The monospace face (IBM Plex Mono) is fixed: the job tables depend on its tabular figures. +## Job owners + +The Owners page lists failed jobs per class, with the owners of each class and +counts for the last 1h, 3h, 24h, 3d and 7d. It reads your CODEOWNERS file: + +```ruby +Flightdeck.configure do |config| + config.codeowners_path = ".github/CODEOWNERS" +end +``` + +The file has to be present wherever the dashboard runs, so check that your +deploy doesn't leave `.github` out. Each class is matched by the file that +defines it, which means loading the class. A class that can't be loaded, or is +defined outside the repo (`ActionMailer::MailDeliveryJob`), shows as Unowned. + +Filter by owner with the chips, and search by any part of the class name. Class +names and counts open the Failed list, filtered to that class and time window; +"Retry all matching" and "Discard all matching" there stay inside that window. + +Counts cover jobs that are still failed: Solid Queue deletes a failed execution +when its job is retried or discarded, so this isn't a history of failures. + ## Chart history and job retention Flightdeck adds no tables and changes no retention: charts read Solid Queue's diff --git a/app/assets/flightdeck/flightdeck-38ba36531cee.css b/app/assets/flightdeck/flightdeck-a9144fe69881.css similarity index 68% rename from app/assets/flightdeck/flightdeck-38ba36531cee.css rename to app/assets/flightdeck/flightdeck-a9144fe69881.css index c75d50f..89375aa 100644 --- a/app/assets/flightdeck/flightdeck-38ba36531cee.css +++ b/app/assets/flightdeck/flightdeck-a9144fe69881.css @@ -1,3 +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}.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 +@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.off{border-color:var(--line);color:var(--ink-2);background:0 0}.fd-chip.off:hover{color:var(--ink)}.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/manifest.json b/app/assets/flightdeck/manifest.json index a4a136e..0b481c1 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-a9144fe69881.css", + "digest": "a9144fe69881", + "sha256": "a9144fe6988181da79c000d89c826bfbaa02ce2a932d672b69f858698a1e62d0", "content_type": "text/css; charset=utf-8", - "size": 31759 + "size": 31867 }, "flightdeck.js": { "file": "flightdeck-30004819780c.js", diff --git a/app/controllers/flightdeck/application_controller.rb b/app/controllers/flightdeck/application_controller.rb index 5099e3c..7156fab 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, + failed_within: params[:failed_within].presence_in(JobsQuery::FAILED_WINDOWS.keys) }.compact end diff --git a/app/controllers/flightdeck/owners_controller.rb b/app/controllers/flightdeck/owners_controller.rb new file mode 100644 index 0000000..93a266f --- /dev/null +++ b/app/controllers/flightdeck/owners_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Flightdeck + class OwnersController < ApplicationController + def index + @codeowners = Codeowners.current + @failures = OwnerFailures.new(@codeowners, **owner_filters) if @codeowners + end + + private + def owner_filters + { owner: params[:owner].to_s.presence, q: params[:q].to_s.strip.presence } + end + end +end diff --git a/app/helpers/flightdeck/owners_helper.rb b/app/helpers/flightdeck/owners_helper.rb new file mode 100644 index 0000000..819ec2c --- /dev/null +++ b/app/helpers/flightdeck/owners_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Flightdeck + module OwnersHelper + # A count that opens the failed jobs behind it. Zero has nothing to open. + def fd_failed_count_link(count, **filters) + label = number_with_delimiter(count) + count.zero? ? label : link_to(label, jobs_path(state: :failed, **filters)) + end + end +end diff --git a/app/models/flightdeck/codeowners.rb b/app/models/flightdeck/codeowners.rb new file mode 100644 index 0000000..aa95189 --- /dev/null +++ b/app/models/flightdeck/codeowners.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Flightdeck + # Answers "who owns this job class?" from the host's CODEOWNERS file. + # + # Rules follow GitHub's: the last matching line wins, and a matching line with + # no owners leaves the path unowned. Patterns are matched against the file + # that defines the class, so components and engines resolve like any other + # path. A class that can't be loaded or lives outside the repo has no owners. + class Codeowners + Rule = Struct.new(:regexp, :owners) + + # Directories CODEOWNERS may sit in; its patterns are relative to their parent. + NESTED_IN = %w[.github .gitlab docs].freeze + + class << self + # Read per request, not at boot, and re-parsed only when the file changes. + def current + path = Flightdeck.config.codeowners_path.presence + return unless path + + path = File.realpath(path.to_s, Rails.root) + mtime = File.mtime(path) + return @current if @current && @current.path == path && @current.mtime == mtime + + @current = new(File.read(path, encoding: "UTF-8").scrub, path: path, mtime: mtime) + rescue SystemCallError + nil + end + end + + attr_reader :path, :mtime + + def initialize(source, path: nil, mtime: nil) + @path = path + @mtime = mtime + @rules = source.each_line.filter_map { |line| parse(line) } + @class_owners = Concurrent::Map.new + end + + def owners_for_class(class_name) + @class_owners.compute_if_absent(class_name) do + file = relative_source_file(class_name) + file ? owners_for_path(file) : [] + end + end + + def owners_for_path(relative_path) + @rules.reverse_each.find { |rule| rule.regexp.match?(relative_path) }&.owners || [] + end + + private + def root + directory = File.dirname(path) + NESTED_IN.include?(File.basename(directory)) ? File.dirname(directory) : directory + end + + # One broken job class must not take the page down with it, hence + # ScriptError: a file that fails to load raises SyntaxError or LoadError. + def relative_source_file(class_name) + return unless class_name.safe_constantize + + file = File.realpath(Object.const_source_location(class_name).first) + file.delete_prefix("#{root}/") if file.start_with?("#{root}/") + rescue StandardError, ScriptError + nil + end + + def parse(line) + pattern, *rest = line.scan(/(?:\\.|\S)+/) + return if pattern.nil? || pattern.start_with?("#", "\\#") + + owners = rest.take_while { |token| !token.start_with?("#") }.map { |owner| display_name(owner) } + Rule.new(regexp_for(pattern), owners) + end + + # @acme/billing and @alice read as billing and alice; emails stay whole. + def display_name(owner) = owner.delete_prefix("@").split("/").last + + def regexp_for(pattern) + anchored = pattern.chomp("/").include?("/") + # A trailing `/**` says what the descendants clause below already does. + glob = pattern.delete_prefix("/").chomp("/").chomp("/**") + # `dir/*` is direct children only; anything else also covers what's below. + descendants = glob.end_with?("/*") ? "" : "(?:/.*)?" + + Regexp.new("\\A#{"(?:.*/)?" unless anchored}#{translate(glob)}#{descendants}\\z") + end + + # `**` crosses directories only as a whole segment; elsewhere it is a `*`. + def translate(glob) + glob.gsub(%r{\\.?|(? 1.hour, + "3h" => 3.hours, + "24h" => 24.hours, + "3d" => 3.days, + "7d" => 7.days + }.freeze + 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, :failed_within, :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, failed_within: 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 + @failed_within = failed_within.presence_in(FAILED_WINDOWS.keys) @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 +76,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? || failed_within.present? end # The filtered relation with no ordering, limit or cursor — what bulk @@ -97,7 +107,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, failed_within: failed_within } end def normalize_state(value) @@ -206,10 +216,23 @@ 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 = apply_failed_within(relation) if failed_within relation end + # Only failed jobs can have failed recently, so every other state is empty + # and the tabs add up. + def apply_failed_within(relation) + recent = SolidQueue::FailedExecution.where(created_at: (Time.current - FAILED_WINDOWS.fetch(failed_within))..) + + case state + when :failed then relation.merge(recent) + when :all then relation.where(id: recent.select(:job_id)) + else relation.none + end + end + def join_jobs? return false unless execution_backed? diff --git a/app/models/flightdeck/owner_failures.rb b/app/models/flightdeck/owner_failures.rb new file mode 100644 index 0000000..ad78168 --- /dev/null +++ b/app/models/flightdeck/owner_failures.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Flightdeck + # Failed jobs per class, with the owners of each class. + # + # Solid Queue deletes a failed execution when its job is retried or discarded, + # so a window counts jobs that failed in it and are still failed. It is not a + # history of failures. + class OwnerFailures + WINDOWS = JobsQuery::FAILED_WINDOWS + UNOWNED = "Unowned" + + Row = Struct.new(:class_name, :owners, :total, :windows, keyword_init: true) + + attr_reader :owner, :q + + def initialize(codeowners, owner: nil, q: nil) + @codeowners = codeowners + @owner = owner + @q = q + end + + def filtered? = owner.present? || q.present? + + def rows + searched_rows.select { |row| owner.nil? || labels(row).include?(owner) } + end + + def total = rows.sum(&:total) + + def window_totals + WINDOWS.keys.each_index.map { |index| rows.sum { |row| row.windows[index] } } + end + + # Largest first, Unowned last. A class with two owners counts for both. The + # selected owner stays listed at zero, or it could not be seen or changed. + def owner_totals + totals = Hash.new(0) + searched_rows.each { |row| labels(row).each { |label| totals[label] += row.total } } + totals[owner] += 0 if owner + unowned = totals.delete(UNOWNED) + sorted = totals.sort_by { |label, count| [ -count, label ] }.to_h + unowned ? sorted.merge(UNOWNED => unowned) : sorted + end + + private + def labels(row) = row.owners.presence || [ UNOWNED ] + + # Case-insensitive substring. Every class is already in memory here, so + # this costs no extra query. + def searched_rows + return all_rows unless q + + needle = q.downcase + all_rows.select { |row| row.class_name.downcase.include?(needle) } + end + + def all_rows + @all_rows ||= counts + .map do |class_name, total, *windows| + Row.new(class_name: class_name, owners: @codeowners.owners_for_class(class_name), + total: total.to_i, windows: windows.map(&:to_i)) + end + .sort_by { |row| [ -row.total, row.class_name ] } + end + + # Owners stay out of the cached value, so a CODEOWNERS change shows at once. + def counts + Cache.fetch("owner_failures", expires_in: Flightdeck.config.chart_cache_ttl) { count_by_class } + end + + def count_by_class + failed = SolidQueue::FailedExecution.arel_table + class_name = SolidQueue::Job.arel_table[:class_name] + now = Time.current + in_window = WINDOWS.values.map do |length| + Arel::Nodes::Case.new.when(failed[:created_at].gteq(now - length)).then(1).else(0).sum + end + + SolidQueue::FailedExecution.joins(:job).group(class_name).pluck(class_name, failed[:id].count, *in_window) + end + end +end diff --git a/app/views/flightdeck/jobs/_filter_bar.html.erb b/app/views/flightdeck/jobs/_filter_bar.html.erb index 3871d89..1f24c75 100644 --- a/app/views/flightdeck/jobs/_filter_bar.html.erb +++ b/app/views/flightdeck/jobs/_filter_bar.html.erb @@ -1,5 +1,7 @@ <%= form_with url: jobs_path, method: :get, class: "fd-filterbar", data: { turbo_action: "advance" } do |form| %> <%= form.hidden_field :state, value: params[:state], id: "fd-filter-state" %> + <%= form.hidden_field :class_name, value: query.class_name, id: nil if query.class_name %> + <%= form.hidden_field :failed_within, value: query.failed_within, id: nil if query.failed_within %> <%= form.search_field :q, value: params[:q], placeholder: "Search job class…", "aria-label": "Search jobs by class name" %> @@ -7,6 +9,9 @@ placeholder: "Queue", "aria-label": "Filter by queue" %> <%= form.submit "Filter", class: "fd-btn" %> + <% if query.class_name %><%= query.class_name %><% end %> + <% if query.failed_within %>failed in last <%= query.failed_within %><% end %> + <% if query.filters? %> <%= link_to jobs_path(state: params[:state].presence), class: "fd-chip" do %> clear filters ✕ diff --git a/app/views/flightdeck/owners/_table.html.erb b/app/views/flightdeck/owners/_table.html.erb new file mode 100644 index 0000000..a1b9440 --- /dev/null +++ b/app/views/flightdeck/owners/_table.html.erb @@ -0,0 +1,59 @@ +<% rows = failures.rows %> +<%# The owner and search filters here aren't the jobs list's, so totals link out only when unfiltered. %> +<% windows = Flightdeck::OwnerFailures::WINDOWS.keys %> + +
+ <%= link_to "All", owners_path(q: failures.q), class: "fd-chip#{" off" if failures.owner}" %> + <% failures.owner_totals.each do |owner, count| %> + <%= link_to owners_path(owner: owner, q: failures.q), class: "fd-chip#{" off" unless failures.owner == owner}" do %> + <%= owner %> <%= number_with_delimiter(count) %> + <% end %> + <% end %> +
+ +
+
+

Failed jobs by class

+ Still failed; retried and discarded jobs drop out +
+
+ + + + + + <% windows.each do |window| %> + + <% end %> + + + + + <% if rows.empty? %> + + <% else %> + <% rows.each do |row| %> + + + + <% windows.zip(row.windows).each do |window, count| %> + + <% end %> + + + <% end %> + + + + <% windows.zip(failures.window_totals).each do |window, count| %> + + <% end %> + + + <% end %> + +
ClassFailed<%= window %>Owners
No failed jobs<%= " match this search" if failures.q %>.
<%= link_to row.class_name, jobs_path(state: :failed, class_name: row.class_name) %> + <%= fd_failed_count_link(row.total, class_name: row.class_name) %> + <%= fd_failed_count_link(count, class_name: row.class_name, failed_within: window) %><%= row.owners.join(", ").presence || "—" %>
Total<%= failures.filtered? ? number_with_delimiter(failures.total) : fd_failed_count_link(failures.total) %><%= failures.filtered? ? number_with_delimiter(count) : fd_failed_count_link(count, failed_within: window) %>
+
+
diff --git a/app/views/flightdeck/owners/index.html.erb b/app/views/flightdeck/owners/index.html.erb new file mode 100644 index 0000000..ce3b05c --- /dev/null +++ b/app/views/flightdeck/owners/index.html.erb @@ -0,0 +1,32 @@ +<% content_for :page_title, "Owners" %> + +<% if @failures %> + <%= form_with url: owners_path, method: :get, class: "fd-filterbar", data: { turbo_action: "advance" } do |form| %> + <%= form.hidden_field :owner, value: @failures.owner, id: nil if @failures.owner %> + <%= form.search_field :q, value: @failures.q, id: nil, + placeholder: "Search job class…", "aria-label": "Search by job class" %> + <%= form.submit "Filter", name: nil, class: "fd-btn" %> + + <% if @failures.q %> + <%= link_to owners_path(owner: @failures.owner), class: "fd-chip" do %> + clear search ✕ + <% end %> + <% end %> + <% end %> +<% end %> + +<%= fd_refresh_frame "fd-owners", url: owners_path(owner: @failures&.owner, q: @failures&.q), interval: fd_chart_poll_ms do %> + <% if @failures %> + <%= render "flightdeck/owners/table", failures: @failures %> + <% else %> +
+
+ <% if Flightdeck.config.codeowners_path.present? %> + No CODEOWNERS file at <%= Flightdeck.config.codeowners_path %>. + <% else %> + Set config.codeowners_path to map job classes to their owners. + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/layouts/flightdeck/application.html.erb b/app/views/layouts/flightdeck/application.html.erb index f0ee034..503d96a 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.config.codeowners_path.present? %> + <%= link_to owners_path, class: "fd-nav-link#{" on" if controller_name == "owners"}" do %> + + Owners + <% 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..56cb1b0 100644 --- a/assets-src/input.css +++ b/assets-src/input.css @@ -770,6 +770,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; border: 1px solid var(--accent-ink); background: var(--accent-wash); color: var(--accent-ink); font: 500 11.5px/1 var(--font-mono); text-decoration: none; } +.fd-chip.off { border-color: var(--line); background: transparent; color: var(--ink-2); } +.fd-chip.off:hover { color: var(--ink); } .fd-chip u { text-decoration: none; opacity: .65; font-weight: 400; } .fd-chip:hover u { opacity: 1; } diff --git a/config/routes.rb b/config/routes.rb index 90115c2..b2e34a1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,8 @@ end end + resources :owners, only: :index + resources :processes, only: :index do member { post :prune } end diff --git a/lib/flightdeck/configuration.rb b/lib/flightdeck/configuration.rb index 030eb3b..83e6774 100644 --- a/lib/flightdeck/configuration.rb +++ b/lib/flightdeck/configuration.rb @@ -12,7 +12,8 @@ class Configuration :bulk_action_limit, :chart_cache_ttl, :display_timezone, - :backtrace_lines + :backtrace_lines, + :codeowners_path attr_reader :ui_font @@ -28,6 +29,7 @@ def initialize @chart_cache_ttl = 30.seconds @display_timezone = "UTC" @backtrace_lines = 50 + @codeowners_path = nil @ui_font = UiFonts::DEFAULT end diff --git a/test/codeowners_test.rb b/test/codeowners_test.rb new file mode 100644 index 0000000..d380056 --- /dev/null +++ b/test/codeowners_test.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "test_helper" +require "tmpdir" + +class Flightdeck::CodeownersTest < ActiveSupport::TestCase + test "the last matching rule wins" do + owners = codeowners(<<~TEXT) + * @acme/everyone + /app/jobs/ @acme/jobs + /app/jobs/billing @acme/billing + TEXT + + assert_equal %w[billing], owners.owners_for_path("app/jobs/billing/charge_job.rb") + assert_equal %w[jobs], owners.owners_for_path("app/jobs/sync_job.rb") + assert_equal %w[everyone], owners.owners_for_path("lib/thing.rb") + end + + test "a later rule with no owners leaves the path unowned" do + owners = codeowners(<<~TEXT) + /app/ @acme/app + /app/jobs/legacy/ + TEXT + + assert_empty owners.owners_for_path("app/jobs/legacy/old_job.rb") + end + + test "a pattern without a leading or inner slash matches at any depth" do + owners = codeowners("jobs/ @a\n*.rake @b\n") + + assert_equal %w[a], owners.owners_for_path("components/sales/app/jobs/report_job.rb") + assert_equal %w[b], owners.owners_for_path("lib/tasks/seed.rake") + end + + test "a pattern with an inner slash is anchored to the root" do + owners = codeowners("app/jobs @a\n") + + assert_equal %w[a], owners.owners_for_path("app/jobs/thing_job.rb") + assert_empty owners.owners_for_path("components/sales/app/jobs/thing_job.rb") + end + + test "dir/* covers direct children only" do + owners = codeowners("/app/jobs/* @a\n") + + assert_equal %w[a], owners.owners_for_path("app/jobs/thing_job.rb") + assert_empty owners.owners_for_path("app/jobs/billing/thing_job.rb") + end + + test "double stars cross directories and single stars do not" do + owners = codeowners("**/billing/*_job.rb @a\ncomponents/**/report.rb @b\n/app/j?bs/one_*.rb @c\n") + + assert_equal %w[a], owners.owners_for_path("billing/charge_job.rb") + assert_equal %w[a], owners.owners_for_path("components/sales/billing/charge_job.rb") + assert_empty owners.owners_for_path("billing/nested/charge_job.rb") + assert_equal %w[b], owners.owners_for_path("components/a/b/report.rb") + assert_equal %w[c], owners.owners_for_path("app/jobs/one_x.rb") + end + + test "patterns are matched literally apart from their wildcards" do + owners = codeowners("/app/a+b.c/ @a\n") + + assert_equal %w[a], owners.owners_for_path("app/a+b.c/job.rb") + assert_empty owners.owners_for_path("app/aab-c/job.rb") + end + + test "comments, blank lines and escaped spaces are understood" do + owners = codeowners(<<~'TEXT') + # a comment + + /my\ dir/ @a # who to ask + TEXT + + assert_equal %w[a], owners.owners_for_path("my dir/job.rb") + end + + test "an escaped hash is not a pattern, as on GitHub" do + owners = codeowners("* @default +\\#hash/ @b +") + + assert_equal %w[default], owners.owners_for_path("#hash/job.rb") + end + + test "double stars inside a segment do not cross directories" do + owners = codeowners("* @default +/app/jobs/bill**_job.rb @b +") + + assert_equal %w[b], owners.owners_for_path("app/jobs/billing_job.rb") + assert_equal %w[default], owners.owners_for_path("app/jobs/billing/nested_job.rb") + end + + test "a trailing double star covers everything below" do + owners = codeowners("* @default +/components/** @b +") + + assert_equal %w[b], owners.owners_for_path("components/sales/app/job.rb") + assert_equal %w[default], owners.owners_for_path("lib/job.rb") + end + + test "escaped wildcards are literal" do + owners = codeowners("* @default +/app/jobs/\\*.rb @b +/app/w\\?t.rb @c +") + + assert_equal %w[default], owners.owners_for_path("app/jobs/charge.rb") + assert_equal %w[b], owners.owners_for_path("app/jobs/*.rb") + assert_equal %w[default], owners.owners_for_path("app/wat.rb") + assert_equal %w[c], owners.owners_for_path("app/w?t.rb") + end + + test "a stray trailing backslash is literal rather than an error" do + assert_empty codeowners("* @default\napp\\\n").owners_for_path("app\\") + end + + test "owners read without their org, and emails stay whole" do + assert_equal %w[billing alice ops@acme.test], + codeowners("* @acme/billing @alice ops@acme.test\n").owners_for_path("a.rb") + end + + test "a job class resolves through the file that defines it" do + with_codeowners do |owners| + assert_equal %w[billing], owners.owners_for_class("Billing::ChargeSubscriptionJob") + assert_equal %w[growth mobile], owners.owners_for_class("Notifications::PushJob") + end + end + + test "the sample CODEOWNERS gives overlapping and overriding owners" do + with_codeowners do |owners| + assert_equal %w[billing finance], owners.owners_for_class("Billing::SyncInvoicesJob") + assert_equal %w[finance analytics], owners.owners_for_class("Finance::ExportReportJob") + assert_equal %w[sales analytics], owners.owners_for_class("Sales::ExportReportJob") + assert_equal %w[security alice], owners.owners_for_class("Security::RotateKeysJob") + assert_equal %w[platform], owners.owners_for_class("Legacy::ExportCsvJob") + assert_empty owners.owners_for_class("Legacy::ImportCsvJob") + end + end + + test "a class that is gone, or outside the repo, has no owners" do + with_codeowners do |owners| + assert_empty owners.owners_for_class("Gone::SinceDeployedJob") + assert_empty owners.owners_for_class("not a constant") + assert_empty owners.owners_for_class("ActionMailer::MailDeliveryJob") + end + end + + test "a class that fails to load has no owners and does not raise" do + with_codeowners do |owners| + broken = Class.new { def safe_constantize = raise(SyntaxError, "boom") }.new + + assert_empty owners.owners_for_class(broken) + end + end + + test "patterns are relative to the repo root when the file sits in .github or docs" do + Dir.mktmpdir do |dir| + dir = File.realpath(dir) + [ ".github", "docs" ].each do |nested| + owners = Flightdeck::Codeowners.new("", path: File.join(dir, nested, "CODEOWNERS")) + assert_equal dir, owners.send(:root) + end + + assert_equal dir, Flightdeck::Codeowners.new("", path: File.join(dir, "CODEOWNERS")).send(:root) + end + end + + test "current is nil when unconfigured or the file is missing" do + assert_nil Flightdeck::Codeowners.current + + with_codeowners(".github/NOPE") { assert_nil Flightdeck::Codeowners.current } + end + + test "current re-parses only when the file changes" do + Dir.mktmpdir do |dir| + path = File.join(dir, "CODEOWNERS") + File.write(path, "* @a\n") + + with_codeowners(path) do + first = Flightdeck::Codeowners.current + assert_same first, Flightdeck::Codeowners.current + + File.write(path, "* @b\n") + File.utime(Time.now, Time.now + 10, path) + assert_equal %w[b], Flightdeck::Codeowners.current.owners_for_path("a.rb") + end + end + end + + private + private + def codeowners(source) = Flightdeck::Codeowners.new(source) + + def with_codeowners(path = ".github/CODEOWNERS") + Flightdeck.config.codeowners_path = path + yield Flightdeck::Codeowners.current + ensure + Flightdeck.config.codeowners_path = nil + end +end diff --git a/test/dummy/.github/CODEOWNERS b/test/dummy/.github/CODEOWNERS new file mode 100644 index 0000000..b52b9be --- /dev/null +++ b/test/dummy/.github/CODEOWNERS @@ -0,0 +1,24 @@ +# Used by the Owners tests and by bin/demo. +/app/jobs/billing/ @acme/billing +/app/jobs/billing/sync_* @acme/billing @acme/finance + +/app/jobs/finance/ @acme/finance +/app/jobs/finance/export_report_job.rb @acme/finance @acme/analytics + +/app/jobs/sales/ @acme/sales +/app/jobs/sales/export_report_job.rb @acme/sales @acme/analytics +/app/jobs/sales/send_quote_email_job.rb @acme/sales @acme/growth + +/app/jobs/notifications/ @acme/growth @acme/mobile +/app/jobs/notifications/send_email_job.rb @acme/growth + +/app/jobs/mobile/ @acme/mobile @acme/platform + +/app/jobs/platform/ @acme/platform +/app/jobs/platform/export_* @acme/platform @acme/security + +/app/jobs/security/ @acme/security @alice + +# The last match wins, so the import job is deliberately unowned. +/app/jobs/legacy/ @acme/platform +/app/jobs/legacy/import_csv_job.rb diff --git a/test/dummy/app/jobs/billing/charge_subscription_job.rb b/test/dummy/app/jobs/billing/charge_subscription_job.rb new file mode 100644 index 0000000..260e44c --- /dev/null +++ b/test/dummy/app/jobs/billing/charge_subscription_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Billing + class ChargeSubscriptionJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/billing/refund_order_job.rb b/test/dummy/app/jobs/billing/refund_order_job.rb new file mode 100644 index 0000000..fe0f630 --- /dev/null +++ b/test/dummy/app/jobs/billing/refund_order_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Billing + class RefundOrderJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/billing/sync_invoices_job.rb b/test/dummy/app/jobs/billing/sync_invoices_job.rb new file mode 100644 index 0000000..de7ac71 --- /dev/null +++ b/test/dummy/app/jobs/billing/sync_invoices_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Billing + class SyncInvoicesJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/finance/export_report_job.rb b/test/dummy/app/jobs/finance/export_report_job.rb new file mode 100644 index 0000000..d6d1e6a --- /dev/null +++ b/test/dummy/app/jobs/finance/export_report_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Finance + class ExportReportJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/finance/sync_ledger_job.rb b/test/dummy/app/jobs/finance/sync_ledger_job.rb new file mode 100644 index 0000000..5886a95 --- /dev/null +++ b/test/dummy/app/jobs/finance/sync_ledger_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Finance + class SyncLedgerJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/legacy/export_csv_job.rb b/test/dummy/app/jobs/legacy/export_csv_job.rb new file mode 100644 index 0000000..b91e0e8 --- /dev/null +++ b/test/dummy/app/jobs/legacy/export_csv_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Legacy + class ExportCsvJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/legacy/import_csv_job.rb b/test/dummy/app/jobs/legacy/import_csv_job.rb new file mode 100644 index 0000000..756fcd1 --- /dev/null +++ b/test/dummy/app/jobs/legacy/import_csv_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Legacy + class ImportCsvJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/mobile/sync_devices_job.rb b/test/dummy/app/jobs/mobile/sync_devices_job.rb new file mode 100644 index 0000000..eb909fd --- /dev/null +++ b/test/dummy/app/jobs/mobile/sync_devices_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Mobile + class SyncDevicesJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/notifications/push_job.rb b/test/dummy/app/jobs/notifications/push_job.rb new file mode 100644 index 0000000..a9903d0 --- /dev/null +++ b/test/dummy/app/jobs/notifications/push_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Notifications + class PushJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/notifications/send_email_job.rb b/test/dummy/app/jobs/notifications/send_email_job.rb new file mode 100644 index 0000000..cca52f3 --- /dev/null +++ b/test/dummy/app/jobs/notifications/send_email_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Notifications + class SendEmailJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/notifications/send_sms_job.rb b/test/dummy/app/jobs/notifications/send_sms_job.rb new file mode 100644 index 0000000..4ccd0e0 --- /dev/null +++ b/test/dummy/app/jobs/notifications/send_sms_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Notifications + class SendSmsJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/platform/export_audit_log_job.rb b/test/dummy/app/jobs/platform/export_audit_log_job.rb new file mode 100644 index 0000000..2640832 --- /dev/null +++ b/test/dummy/app/jobs/platform/export_audit_log_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Platform + class ExportAuditLogJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/platform/sync_users_job.rb b/test/dummy/app/jobs/platform/sync_users_job.rb new file mode 100644 index 0000000..1257c65 --- /dev/null +++ b/test/dummy/app/jobs/platform/sync_users_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Platform + class SyncUsersJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/sales/export_report_job.rb b/test/dummy/app/jobs/sales/export_report_job.rb new file mode 100644 index 0000000..cfca69d --- /dev/null +++ b/test/dummy/app/jobs/sales/export_report_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Sales + class ExportReportJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/sales/send_quote_email_job.rb b/test/dummy/app/jobs/sales/send_quote_email_job.rb new file mode 100644 index 0000000..df50255 --- /dev/null +++ b/test/dummy/app/jobs/sales/send_quote_email_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Sales + class SendQuoteEmailJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/sales/sync_leads_job.rb b/test/dummy/app/jobs/sales/sync_leads_job.rb new file mode 100644 index 0000000..47af229 --- /dev/null +++ b/test/dummy/app/jobs/sales/sync_leads_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Sales + class SyncLeadsJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/security/rotate_keys_job.rb b/test/dummy/app/jobs/security/rotate_keys_job.rb new file mode 100644 index 0000000..28fc5b7 --- /dev/null +++ b/test/dummy/app/jobs/security/rotate_keys_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Security + class RotateKeysJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/app/jobs/security/scan_uploads_job.rb b/test/dummy/app/jobs/security/scan_uploads_job.rb new file mode 100644 index 0000000..4f87939 --- /dev/null +++ b/test/dummy/app/jobs/security/scan_uploads_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Security + class ScanUploadsJob < ActiveJob::Base + def perform(*) + end + end +end diff --git a/test/dummy/config/initializers/demo_job_classes.rb b/test/dummy/config/initializers/demo_job_classes.rb index 831aefa..6a641a3 100644 --- a/test/dummy/config/initializers/demo_job_classes.rb +++ b/test/dummy/config/initializers/demo_job_classes.rb @@ -30,3 +30,5 @@ def perform(*) end) end end + +Flightdeck.config.codeowners_path = ".github/CODEOWNERS" if Rails.env.development? diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb index 1a94c9c..e49cc92 100644 --- a/test/dummy/db/seeds.rb +++ b/test/dummy/db/seeds.rb @@ -23,6 +23,19 @@ class FlightdeckSeeder "low" => %w[ImageVariantJob Reports::DailyDigestJob CleanupSessionsJob] }.freeze + # Named to overlap on the Owners page: `Sync`, `Export` and `Report` cut across + # teams, and test/dummy/.github/CODEOWNERS gives many of these two owners. + OWNED_FAILURES = %w[ + Billing::SyncInvoicesJob Billing::RefundOrderJob + Finance::SyncLedgerJob Finance::ExportReportJob + Sales::SyncLeadsJob Sales::ExportReportJob Sales::SendQuoteEmailJob + Notifications::PushJob Notifications::SendEmailJob Notifications::SendSmsJob + Mobile::SyncDevicesJob + Platform::SyncUsersJob Platform::ExportAuditLogJob + Security::RotateKeysJob Security::ScanUploadsJob + Legacy::ImportCsvJob Legacy::ExportCsvJob + ].freeze + RECURRING_TASKS = { "daily_digest" => [ "0 23 * * *", "Reports::DailyDigestJob" ], "sync_inventory" => [ "*/5 * * * *", "SyncInventoryJob" ], @@ -44,6 +57,7 @@ def call seed_fleet seed_history + seed_owned_failures seed_backlog seed_recurring @@ -99,6 +113,23 @@ def seed_history end end + # One failure per Owners window, then a few at random, so every column of + # every class has a number to click. + def seed_owned_failures + OWNED_FAILURES.each do |class_name| + ages = [ 30.minutes, 2.hours, 12.hours, 2.days, 5.days ] + Array.new(rand(4)) { rand(1.hour..6.days) } + + ages.each do |age| + exception_class, message = ERRORS.sample + create_failed_job( + class_name: class_name, queue_name: "default", + exception_class: exception_class, message: message, + created_at: age.ago - 60, failed_at: age.ago, executions: rand(1..5) + ) + end + end + end + def seed_backlog pause_queue("webhooks") diff --git a/test/integration/job_actions_test.rb b/test/integration/job_actions_test.rb index c129bb3..47b891d 100644 --- a/test/integration/job_actions_test.rb +++ b/test/integration/job_actions_test.rb @@ -110,6 +110,26 @@ class Flightdeck::JobActionsTest < FlightdeckIntegrationTest # --- all matching --------------------------------------------------------- + test "retrying all matching leaves jobs outside the failed_within window alone" do + recent = create_failed_job(failed_at: 30.minutes.ago) + older = create_failed_job(failed_at: 2.hours.ago) + + post_fd "/flightdeck/jobs/retry", params: { scope: "all", state: "failed", failed_within: "1h" } + + assert in_table?(SolidQueue::ReadyExecution, recent), "the job in the window should be retried" + assert in_table?(SolidQueue::FailedExecution, older), "the older job should still be failed" + end + + test "discarding all matching leaves jobs outside the failed_within window alone" do + recent = create_failed_job(failed_at: 30.minutes.ago) + older = create_failed_job(failed_at: 2.hours.ago) + + post_fd "/flightdeck/jobs/discard", params: { scope: "all", state: "failed", failed_within: "1h" } + + assert_nil SolidQueue::Job.find_by(id: recent.id) + assert in_table?(SolidQueue::FailedExecution, older), "the older job should still be failed" + end + test "applying to all matching respects the filter that was on screen" do matching = 2.times.map { create_failed_job(class_name: "AlphaJob") } other = create_failed_job(class_name: "BetaJob") diff --git a/test/integration/jobs_test.rb b/test/integration/jobs_test.rb index 428d5a1..8a3a1f5 100644 --- a/test/integration/jobs_test.rb +++ b/test/integration/jobs_test.rb @@ -205,4 +205,33 @@ class Flightdeck::JobsTest < FlightdeckIntegrationTest assert_redirected_to "/flightdeck/jobs" end + + test "the failed list shows the class and window it arrived with, and Filter keeps them" do + create_failed_job(class_name: "AlphaJob", failed_at: 30.minutes.ago) + + get_fd "/flightdeck/jobs?state=failed&class_name=AlphaJob&failed_within=1h" + + assert_select ".fd-chip", text: "AlphaJob" + assert_select ".fd-chip", text: "failed in last 1h" + assert_select "form.fd-filterbar input[type=hidden][name=class_name][value=AlphaJob]" + assert_select "form.fd-filterbar input[type=hidden][name=failed_within][value='1h']" + end + + test "bulk buttons and tabs carry the window" do + create_failed_job(failed_at: 30.minutes.ago) + + get_fd "/flightdeck/jobs?state=failed&failed_within=1h" + + assert_select "form[action*=?]", "failed_within=1h", minimum: 2 + assert_select ".fd-statetabs a[href*=?]", "failed_within=1h", count: Flightdeck::JobsQuery::STATES.size + end + + test "an unknown window is dropped, not carried along" do + create_failed_job(failed_at: 30.days.ago) + + get_fd "/flightdeck/jobs?state=failed&failed_within=99y" + + assert_response :success + assert_select "form[action*=?]", "failed_within", count: 0 + end end diff --git a/test/integration/owners_test.rb b/test/integration/owners_test.rb new file mode 100644 index 0000000..8db8bef --- /dev/null +++ b/test/integration/owners_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "test_helper" + +class Flightdeck::OwnersTest < FlightdeckIntegrationTest + teardown { Flightdeck.config.codeowners_path = nil } + + test "the page stays out of the nav until CODEOWNERS is configured" do + get_fd "/flightdeck" + + assert_response :success + assert_select "a.fd-nav-link[href='/flightdeck/owners']", 0 + end + + test "the page explains how to turn it on" do + get_fd "/flightdeck/owners" + + assert_response :success + assert_includes response.body, "config.codeowners_path" + end + + test "a configured file that is missing is reported, not raised" do + Flightdeck.config.codeowners_path = ".github/NOPE" + + get_fd "/flightdeck/owners" + + assert_response :success + assert_includes response.body, ".github/NOPE" + end + + test "lists classes with their owners, and links to their failed jobs" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + create_failed_job(class_name: "Notifications::PushJob") + + get_fd "/flightdeck" + assert_select "a.fd-nav-link[href='/flightdeck/owners']", 1 + + get_fd "/flightdeck/owners" + + assert_response :success + assert_select "td.cls", text: "Billing::ChargeSubscriptionJob" + assert_select "td", text: "growth, mobile" + assert_select "a[href=?]", "/flightdeck/jobs?class_name=Billing%3A%3AChargeSubscriptionJob&state=failed" + end + + test "filtering by owner narrows the rows and keeps the filter across polls" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + create_failed_job(class_name: "Notifications::PushJob") + + get_fd "/flightdeck/owners", params: { owner: "billing" } + + assert_select "td.cls", text: "Billing::ChargeSubscriptionJob" + assert_select "td.cls", text: "Notifications::PushJob", count: 0 + assert_select "turbo-frame#fd-owners[data-refresh-url-value=?]", "/flightdeck/owners?owner=billing" + end + + test "a malformed owner parameter finds nothing rather than failing" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + + get_fd "/flightdeck/owners", params: { owner: { nested: "x" } } + + assert_response :success + end + + test "class names and non-zero counts open the failed list, filtered" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob", failed_at: 2.hours.ago) + + get_fd "/flightdeck/owners" + + list = "/flightdeck/jobs?class_name=Billing%3A%3AChargeSubscriptionJob" + assert_select "td.cls a[href=?]", "#{list}&state=failed", text: "Billing::ChargeSubscriptionJob" + assert_select "td a[href=?]", "#{list}&failed_within=3h&state=failed", text: "1" + assert_select "td a[href=?]", "/flightdeck/jobs?failed_within=3h&state=failed", text: "1" + assert_select "a[href*=?]", "failed_within=1h", count: 0 + end + + test "totals stop linking when an owner is selected, since the list cannot show it" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + + get_fd "/flightdeck/owners", params: { owner: "billing" } + + assert_select "td a[href=?]", "/flightdeck/jobs?failed_within=1h&state=failed", count: 0 + end + + test "searching narrows the rows and survives polls and owner chips" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + create_failed_job(class_name: "Notifications::PushJob") + + get_fd "/flightdeck/owners", params: { q: "charge" } + + assert_select "td.cls", text: "Billing::ChargeSubscriptionJob" + assert_select "td.cls", text: "Notifications::PushJob", count: 0 + assert_select "input[type=search][name=q][value=charge]" + assert_select "turbo-frame#fd-owners[data-refresh-url-value=?]", "/flightdeck/owners?q=charge" + assert_select ".fd-chip[href=?]", "/flightdeck/owners?owner=billing&q=charge" + assert_select "turbo-frame form", 0 + end + + test "searching keeps the selected owner" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + + get_fd "/flightdeck/owners", params: { owner: "billing" } + + assert_select "form.fd-filterbar input[type=hidden][name=owner][value=billing]" + end + + test "totals stop linking during a search, and a miss says so" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + + get_fd "/flightdeck/owners", params: { q: "charge" } + assert_select "td a[href=?]", "/flightdeck/jobs?failed_within=1h&state=failed", count: 0 + assert_select "td a[href*=?]", "class_name=Billing", minimum: 1 + + get_fd "/flightdeck/owners", params: { q: "nothing" } + assert_includes response.body, "No failed jobs match this search" + end + + test "a blank search shows everything" do + configure + create_failed_job(class_name: "Billing::ChargeSubscriptionJob") + + get_fd "/flightdeck/owners", params: { q: " " } + + assert_select "td.cls", text: "Billing::ChargeSubscriptionJob" + end + + test "the panel's links escape its frame" do + configure + + get_fd "/flightdeck/owners" + + assert_select "turbo-frame#fd-owners[target=_top]", 1 + end + + private + def configure = Flightdeck.config.codeowners_path = ".github/CODEOWNERS" +end diff --git a/test/jobs_query_test.rb b/test/jobs_query_test.rb index 1638215..7b3e855 100644 --- a/test/jobs_query_test.rb +++ b/test/jobs_query_test.rb @@ -67,6 +67,52 @@ class Flightdeck::JobsQueryTest < ActiveSupport::TestCase assert_empty Flightdeck::JobsQuery.new(state: :ready, q: "_lphaJob").rows end + test "failed_within keeps jobs that failed inside the window" do + recent = create_failed_job(failed_at: 30.minutes.ago) + create_failed_job(failed_at: 2.hours.ago) + + query = Flightdeck::JobsQuery.new(state: :failed, failed_within: "1h") + + assert_equal [ recent.id ], query.rows.map(&:id) + assert_equal 1, query.count + assert query.filters? + end + + test "failed_within composes with the class filter" do + match = create_failed_job(class_name: "AlphaJob", failed_at: 30.minutes.ago) + create_failed_job(class_name: "BetaJob", failed_at: 30.minutes.ago) + + query = Flightdeck::JobsQuery.new(state: :failed, class_name: "AlphaJob", failed_within: "1h") + + assert_equal [ match.id ], query.rows.map(&:id) + assert_equal 1, query.count + end + + test "failed_within empties every tab but failed and all" do + recent = create_failed_job(failed_at: 30.minutes.ago) + create_failed_job(failed_at: 2.hours.ago) + create_ready_job + create_finished_job + + counts = Flightdeck::JobsQuery.state_counts(failed_within: "1h").transform_values { |tab| tab[:count] } + + assert_equal({ all: 1, ready: 0, scheduled: 0, in_progress: 0, blocked: 0, finished: 0, failed: 1 }, counts) + assert_equal [ recent.id ], ids_for_within(:all) + end + + test "failed_within ignores a window it does not know" do + create_failed_job(failed_at: 30.days.ago) + + assert_equal 1, Flightdeck::JobsQuery.new(state: :failed, failed_within: "99y").count + end + + test "counts for different windows are not shared in the cache" do + create_failed_job(failed_at: 2.hours.ago) + + assert_equal 0, Flightdeck::JobsQuery.new(state: :failed, failed_within: "1h").count + assert_equal 1, Flightdeck::JobsQuery.new(state: :failed, failed_within: "3h").count + end + test "keyset pagination walks pages without overlap or gaps" do jobs = 7.times.map { create_ready_job } expected = jobs.map(&:id).sort.reverse @@ -177,6 +223,10 @@ def ids_for(state) Flightdeck::JobsQuery.new(state: state).rows.map(&:id) end + def ids_for_within(state) + Flightdeck::JobsQuery.new(state: state, failed_within: "1h").rows.map(&:id) + end + def capture_sql statements = [] subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| diff --git a/test/owner_failures_test.rb b/test/owner_failures_test.rb new file mode 100644 index 0000000..fb5b4e6 --- /dev/null +++ b/test/owner_failures_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "test_helper" + +class Flightdeck::OwnerFailuresTest < ActiveSupport::TestCase + BILLING = "Billing::ChargeSubscriptionJob" + NOTIFICATIONS = "Notifications::PushJob" + + setup do + @codeowners = Flightdeck::Codeowners.new("", path: Rails.root.join(".github/CODEOWNERS").to_s) + @codeowners.define_singleton_method(:owners_for_class) do |name| + { BILLING => %w[billing], NOTIFICATIONS => %w[growth mobile] }.fetch(name, []) + end + end + + test "counts each class into every window its failures fall in" do + create_failed_job(class_name: BILLING, failed_at: 10.minutes.ago) + create_failed_job(class_name: BILLING, failed_at: 2.hours.ago) + create_failed_job(class_name: BILLING, failed_at: 2.days.ago) + create_failed_job(class_name: BILLING, failed_at: 30.days.ago) + + row = report.rows.sole + + assert_equal BILLING, row.class_name + assert_equal 4, row.total + assert_equal [ 1, 2, 2, 3, 3 ], row.windows + end + + test "only failed jobs count" do + create_ready_job(class_name: BILLING) + create_finished_job(class_name: BILLING) + + assert_empty report.rows + end + + test "rows list the biggest failures first" do + create_failed_job(class_name: BILLING) + 2.times { create_failed_job(class_name: NOTIFICATIONS) } + + assert_equal [ NOTIFICATIONS, BILLING ], report.rows.map(&:class_name) + end + + test "filtering by an owner keeps the classes it shares" do + create_failed_job(class_name: BILLING) + create_failed_job(class_name: NOTIFICATIONS) + + assert_equal [ NOTIFICATIONS ], report(owner: "mobile").rows.map(&:class_name) + assert_equal [ NOTIFICATIONS ], report(owner: "growth").rows.map(&:class_name) + end + + test "classes with no owners can be filtered as Unowned" do + create_failed_job(class_name: "NobodysJob") + create_failed_job(class_name: BILLING) + + assert_equal [ "NobodysJob" ], report(owner: "Unowned").rows.map(&:class_name) + end + + test "owner totals count a shared class for each owner and put Unowned last" do + 3.times { create_failed_job(class_name: "NobodysJob") } + 2.times { create_failed_job(class_name: NOTIFICATIONS) } + create_failed_job(class_name: BILLING) + + assert_equal [ [ "growth", 2 ], [ "mobile", 2 ], [ "billing", 1 ], [ "Unowned", 3 ] ], + report.owner_totals.to_a + end + + test "totals follow the owner filter and count a shared class once" do + 2.times { create_failed_job(class_name: NOTIFICATIONS) } + create_failed_job(class_name: BILLING) + + assert_equal 3, report.total + assert_equal 2, report(owner: "mobile").total + assert_equal [ 2, 2, 2, 2, 2 ], report(owner: "mobile").window_totals + end + + test "search matches any part of the class, ignoring case" do + create_failed_job(class_name: BILLING) + create_failed_job(class_name: NOTIFICATIONS) + + assert_equal [ BILLING ], report(q: "chargesub").rows.map(&:class_name) + assert_equal [ NOTIFICATIONS ], report(q: "PUSH").rows.map(&:class_name) + assert_empty report(q: "nothing").rows + end + + test "search treats regexp and LIKE characters literally" do + create_failed_job(class_name: BILLING) + + assert_empty report(q: "%").rows + assert_empty report(q: "Billing.*").rows + end + + test "search and owner filters combine" do + create_failed_job(class_name: BILLING) + create_failed_job(class_name: NOTIFICATIONS) + + assert_equal [ BILLING ], report(owner: "billing", q: "charge").rows.map(&:class_name) + assert_empty report(owner: "billing", q: "push").rows + end + + test "owner totals follow the search, keeping the selected owner listed" do + create_failed_job(class_name: BILLING) + create_failed_job(class_name: "NobodysJob") + + assert_equal [ [ "Unowned", 1 ] ], report(q: "nobody").owner_totals.to_a + assert_equal [ [ "billing", 0 ], [ "Unowned", 1 ] ], report(owner: "billing", q: "nobody").owner_totals.to_a + end + + test "counting never selects the arguments or error columns" do + create_failed_job(class_name: BILLING) + + statements = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + statements << payload[:sql] unless payload[:name] == "SCHEMA" + end + report.rows + ActiveSupport::Notifications.unsubscribe(subscriber) + + assert statements.any? + statements.each { |sql| refute_match(/\b(arguments|error)\b/i, sql) } + end + + private + def report(**filters) = Flightdeck::OwnerFailures.new(@codeowners, **filters) +end