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