Public: anyone can read this; the crew can change it.
Design: /fleet/graph is built from the design system, and its fleet-* classes leave §5d #3220
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3197-design-fleet-graph-is-built-from-the-des → main 2 commits fork
7 files
changed +74
−49
docs/DESIGN.md +4 −3
Diff not shown — a line 2,234 characters wide (limit 2,000). +4 −3
View filesrc/web/fleet-graph.ts +16 −14
| @@ -25,6 +25,7 @@ import type { | ||
| 25 | 25 | FleetWindow, |
| 26 | 26 | } from "../actions/fleet-graph.ts"; |
| 27 | 27 | import { html, type Html } from "./html.ts"; |
| 28 | import { seriesDot } from "./ui.ts"; | |
| 28 | 29 | |
| 29 | 30 | export const SVG_W = 600; |
| 30 | 31 | const LEFT = 48; |
| @@ -119,8 +120,10 @@ export function windowSvg(graph: FleetGraph, w: FleetWindow): Html { | ||
| 119 | 120 | }); |
| 120 | 121 | |
| 121 | 122 | return html`<svg |
| 122 | class="fleet-svg" | |
| 123 | class="plot" | |
| 123 | 124 | viewBox="0 0 ${SVG_W} ${SVG_H}" |
| 125 | width="${SVG_W}" | |
| 126 | height="${SVG_H}" | |
| 124 | 127 | preserveAspectRatio="xMinYMin meet" |
| 125 | 128 | font-size="10" |
| 126 | 129 | role="img" |
| @@ -129,29 +132,29 @@ export function windowSvg(graph: FleetGraph, w: FleetWindow): Html { | ||
| 129 | 132 | data-spend-top="${spendTop}" |
| 130 | 133 | data-runs-top="${runsTopWhole}" |
| 131 | 134 | > |
| 132 | <g class="fleet-grid"> | |
| 135 | <g> | |
| 133 | 136 | <line x1="${LEFT}" y1="${TOP}" x2="${SVG_W - RIGHT}" y2="${TOP}" /> |
| 134 | 137 | <line x1="${LEFT}" y1="${TOP + SPEND_H / 2}" x2="${SVG_W - RIGHT}" y2="${TOP + SPEND_H / 2}" /> |
| 135 | 138 | <line x1="${LEFT}" y1="${spendY0}" x2="${SVG_W - RIGHT}" y2="${spendY0}" /> |
| 136 | 139 | <line x1="${LEFT}" y1="${runsY0 - RUNS_H}" x2="${SVG_W - RIGHT}" y2="${runsY0 - RUNS_H}" /> |
| 137 | 140 | <line x1="${LEFT}" y1="${runsY0}" x2="${SVG_W - RIGHT}" y2="${runsY0}" /> |
| 138 | 141 | </g> |
| 139 | <g class="fleet-ticks"> | |
| 142 | <g text-anchor="end"> | |
| 140 | 143 | <text x="${LEFT - 6}" y="${TOP + 4}">${moneyTick(spendTop)}</text> |
| 141 | 144 | <text x="${LEFT - 6}" y="${TOP + SPEND_H / 2 + 4}">${moneyTick(spendTop / 2)}</text> |
| 142 | 145 | <text x="${LEFT - 6}" y="${spendY0 + 4}">$0</text> |
| 143 | 146 | <text x="${LEFT - 6}" y="${runsY0 - RUNS_H + 4}">${runsTopWhole}</text> |
| 144 | 147 | <text x="${LEFT - 6}" y="${runsY0 + 4}">0</text> |
| 145 | 148 | </g> |
| 146 | <g class="fleet-panel-name"> | |
| 149 | <g class="plot-name"> | |
| 147 | 150 | <text x="${LEFT + 4}" y="${TOP + 12}">spend, USD</text> |
| 148 | 151 | <text x="${LEFT + 4}" y="${runsY0 - RUNS_H + 12}">runs ended</text> |
| 149 | 152 | </g> |
| 150 | <g class="fleet-bars"> | |
| 153 | <g> | |
| 151 | 154 | ${segments("costUsd", spendTop, SPEND_H, spendY0)} |
| 152 | 155 | ${segments("runs", runsTopWhole, RUNS_H, runsY0)} |
| 153 | 156 | </g> |
| 154 | <g class="fleet-axis"> | |
| 157 | <g text-anchor="middle"> | |
| 155 | 158 | ${w.buckets.map((b, i) => |
| 156 | 159 | (n - 1 - i) % every === 0 |
| 157 | 160 | ? html`<text x="${r2(LEFT + i * slot + slot / 2)}" y="${runsY0 + 14}">${bucketWords(w, b.start)}</text>` |
| @@ -164,11 +167,11 @@ export function windowSvg(graph: FleetGraph, w: FleetWindow): Html { | ||
| 164 | 167 | /** The legend: one swatch per series, with its 14-day spend. */ |
| 165 | 168 | function legend(graph: FleetGraph): Html { |
| 166 | 169 | const fortnight = graph.windows[1]; |
| 167 | return html`<ul class="fleet-legend"> | |
| 170 | return html`<ul class="plot-legend"> | |
| 168 | 171 | ${graph.series.map( |
| 169 | 172 | (s, i) => |
| 170 | 173 | html`<li title="${s.repos.join(", ")}"> |
| 171 | <span class="fleet-swatch fg-s${i}" aria-hidden="true"></span> | |
| 174 | ${seriesDot(i)} | |
| 172 | 175 | ${s.repos.length === 1 |
| 173 | 176 | ? html`<a href="/${s.label}">${s.label}</a>` |
| 174 | 177 | : s.label} |
| @@ -181,7 +184,7 @@ function legend(graph: FleetGraph): Html { | ||
| 181 | 184 | /** Every repository's own figures, the chart's numbers as text. */ |
| 182 | 185 | function table(graph: FleetGraph): Html { |
| 183 | 186 | return html`<div class="table-scroll"> |
| 184 | <table class="table fleet-table" data-dense> | |
| 187 | <table class="table" data-dense> | |
| 185 | 188 | <caption class="sr-only"> |
| 186 | 189 | Spend and runs per repository, last 24 hours and last 14 days |
| 187 | 190 | </caption> |
| @@ -199,7 +202,7 @@ function table(graph: FleetGraph): Html { | ||
| 199 | 202 | ${graph.repos.map( |
| 200 | 203 | (r) => html`<tr data-repo="${r.repo}"> |
| 201 | 204 | <th scope="row"> |
| 202 | <span class="fleet-swatch fg-s${r.series}" aria-hidden="true"></span> | |
| 205 | ${seriesDot(r.series)} | |
| 203 | 206 | <a href="/${r.repo}">${r.repo}</a> |
| 204 | 207 | </th> |
| 205 | 208 | <td class="num">${money(r.day.costUsd)}</td> |
| @@ -232,7 +235,6 @@ export function selectionQuery(graph: FleetGraph): string { | ||
| 232 | 235 | export function fleetGraphSection(graph: FleetGraph): Html { |
| 233 | 236 | const q = selectionQuery(graph); |
| 234 | 237 | return html`<section |
| 235 | class="fleet-graph" | |
| 236 | 238 | id="fleet-graph" |
| 237 | 239 | data-src="/fleet/graph/live${q === "" ? "" : `?${q}`}" |
| 238 | 240 | data-at="${graph.at}" |
| @@ -244,9 +246,9 @@ export function fleetGraphSection(graph: FleetGraph): Html { | ||
| 244 | 246 | : "None of these repositories is yours to see spend for."} |
| 245 | 247 | </p>` |
| 246 | 248 | : html`${legend(graph)} |
| 247 | <div class="fleet-windows"> | |
| 249 | <div class="plots"> | |
| 248 | 250 | ${graph.windows.map( |
| 249 | (w) => html`<figure class="fleet-window"> | |
| 251 | (w) => html`<figure> | |
| 250 | 252 | <figcaption> |
| 251 | 253 | ${w.name === "24h" ? "Last 24 hours" : "Last 14 days"} |
| 252 | 254 | <span class="muted" |
| @@ -262,7 +264,7 @@ export function fleetGraphSection(graph: FleetGraph): Html { | ||
| 262 | 264 | ${graph.hidden > 0 |
| 263 | 265 | ? html`<p class="muted small">${hiddenWords(graph.hidden)}</p>` |
| 264 | 266 | : null} |
| 265 | <p class="muted small fleet-assume"> | |
| 267 | <p class="muted small mt"> | |
| 266 | 268 | Read from the usage ledger at |
| 267 | 269 | <time datetime="${graph.at}">${graph.at.slice(11, 16)} UTC</time>. A |
| 268 | 270 | run counts in the UTC hour or day it ended; spend is the provider's |
src/web/pages/fleet-graph.ts +13 −11
| @@ -23,7 +23,7 @@ import { | ||
| 23 | 23 | } from "../fleet-graph.ts"; |
| 24 | 24 | import { html } from "../html.ts"; |
| 25 | 25 | import { fragment, page } from "../layout.ts"; |
| 26 | import { btn } from "../ui.ts"; | |
| 26 | import { btn, field } from "../ui.ts"; | |
| 27 | 27 | |
| 28 | 28 | /** The selection from the URL, or the refusal's words. */ |
| 29 | 29 | function selection(ctx: Ctx) { |
| @@ -65,16 +65,18 @@ export function fleetGraphPage(ctx: Ctx): Response { | ||
| 65 | 65 | hour by hour and day by day. |
| 66 | 66 | </p> |
| 67 | 67 | </div> |
| 68 | <form method="get" action="/fleet/graph" class="fleet-pick"> | |
| 69 | <label for="fleet-repos">Repositories</label> | |
| 70 | <input | |
| 71 | id="fleet-repos" | |
| 72 | name="repo" | |
| 73 | value="${names}" | |
| 74 | placeholder="owner/name owner/name — empty is everything you can write that spent" | |
| 75 | autocomplete="off" | |
| 76 | spellcheck="false" | |
| 77 | /> | |
| 68 | <form method="get" action="/fleet/graph" class="field-row mb"> | |
| 69 | ${field({ | |
| 70 | label: "Repositories", | |
| 71 | name: "repo", | |
| 72 | id: "fleet-repos", | |
| 73 | value: names, | |
| 74 | placeholder: | |
| 75 | "owner/name owner/name — empty is everything you can write that spent", | |
| 76 | autocomplete: "off", | |
| 77 | mono: true, | |
| 78 | spellcheck: false, | |
| 79 | })} | |
| 78 | 80 | ${btn("Draw", { type: "submit", variant: "secondary" })} |
| 79 | 81 | </form> |
| 80 | 82 | ${graph === undefined |
src/web/style/components.ts +19
| @@ -220,6 +220,24 @@ progress:not(.tests-bar)::-moz-progress-bar { background: var(--success); } | ||
| 220 | 220 | .dot[data-tone="danger"] { background: var(--danger); } |
| 221 | 221 | .dot[data-tone="attn"] { background: var(--attn); } |
| 222 | 222 | .dot[data-tone="merged"] { background: var(--merged); } |
| 223 | .dot[data-series="0"] { background: var(--series-0); } | |
| 224 | .dot[data-series="1"] { background: var(--series-1); } | |
| 225 | .dot[data-series="2"] { background: var(--series-2); } | |
| 226 | .dot[data-series="3"] { background: var(--series-3); } | |
| 227 | .dot[data-series="4"] { background: var(--series-4); } | |
| 228 | .dot[data-series="5"] { background: var(--series-5); } | |
| 229 | .dot[data-series="6"] { background: var(--series-6); } | |
| 230 | .dot[data-series="7"] { background: var(--series-7); } | |
| 231 | ||
| 232 | /* ---- stacked bar chart: series stacked per bucket, drawn as SVG on the server ---- */ | |
| 233 | .plots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 16px; margin: 0 0 16px; } | |
| 234 | .plots figcaption { font-size: 13px; font-weight: 600; margin: 0 0 4px; display: flex; gap: 8px; align-items: baseline; } | |
| 235 | .plot { display: block; width: 100%; height: auto; } | |
| 236 | .plot line { stroke: var(--border); stroke-width: 1; } | |
| 237 | .plot text { fill: var(--muted-fg); font-family: var(--mono); } | |
| 238 | .plot .plot-name text { fill: var(--faint); font-family: inherit; } | |
| 239 | .plot-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; font-size: 13px; } | |
| 240 | .plot-legend li { display: inline-flex; align-items: center; gap: 6px; } | |
| 223 | 241 | |
| 224 | 242 | /* ---- meter: a share as a bar — spend against a ceiling, a window used, how sure ---- */ |
| 225 | 243 | meter { |
| @@ -421,6 +439,7 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 421 | 439 | } |
| 422 | 440 | .table tbody tr:first-child td { border-top: none; } |
| 423 | 441 | .table[data-dense] td, .table[data-dense] th { padding: 5px 10px; } |
| 442 | .table .num { text-align: right; } | |
| 424 | 443 | .table-scroll { overflow-x: auto; scrollbar-width: thin; } |
| 425 | 444 | /* The row's name stays in view while a narrow screen scrolls the figures. */ |
| 426 | 445 | .table[data-sticky-lead] th:first-child, |
src/web/style/content.ts +3 −20
| @@ -361,17 +361,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 361 | 361 | } |
| 362 | 362 | .backups-now { margin-top: 12px; } |
| 363 | 363 | .backups-drill { overflow-wrap: anywhere; } |
| 364 | /* The fleet graph: a selection's spend and runs, hour by hour and day by day. | |
| 365 | Series colours are Okabe–Ito (told apart with common colour-vision | |
| 366 | deficiencies); the eighth, the folded "others", is grey. */ | |
| 367 | .fleet-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 16px; } | |
| 368 | .fleet-pick input { flex: 1 1 24em; min-width: 0; font-family: var(--mono); font-size: 13px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); } | |
| 369 | .fleet-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; padding: 0; list-style: none; font-size: 13px; } | |
| 370 | .fleet-legend li { display: inline-flex; align-items: center; gap: 6px; } | |
| 371 | .fleet-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; vertical-align: middle; flex: none; } | |
| 372 | .fleet-windows { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 16px; margin: 0 0 16px; } | |
| 373 | .fleet-window { margin: 0; } | |
| 374 | .fleet-window figcaption { font-size: 13px; font-weight: 600; margin: 0 0 4px; display: flex; gap: 8px; align-items: baseline; } | |
| 375 | 364 | .chart-box { color: var(--accent); aspect-ratio: 720 / 260; max-width: 720px; } |
| 376 | 365 | .chart-box .chart { display: block; width: 100%; height: auto; } |
| 377 | 366 | .chart-box text { fill: var(--muted-fg); font-family: var(--mono); } |
| @@ -413,14 +402,9 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 413 | 402 | .bgrid h2 { font-size: 14px; } |
| 414 | 403 | .bmanage { display: grid; gap: 8px; } |
| 415 | 404 | @media (max-width: 720px) { .bgrid { grid-template-columns: minmax(0, 1fr); } .bp-w2, .bp-w3 { grid-column: auto; } } |
| 416 | .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; } | |
| 417 | .fleet-grid line { stroke: var(--border); stroke-width: 1; } | |
| 418 | .fleet-ticks text { fill: var(--muted-fg); text-anchor: end; font-family: var(--mono); } | |
| 419 | .fleet-axis text { fill: var(--muted-fg); text-anchor: middle; font-family: var(--mono); } | |
| 420 | .fleet-panel-name text { fill: var(--faint); } | |
| 421 | .fleet-table .num { text-align: right; font-variant-numeric: tabular-nums; } | |
| 422 | .fleet-table th[scope="row"] { font-weight: 500; white-space: nowrap; text-align: left; } | |
| 423 | .fleet-assume { margin-top: 8px; } | |
| 405 | /* The stacked bar chart's series colours, for its bars (fleet-graph.ts). | |
| 406 | Okabe–Ito, told apart with common colour-vision deficiencies; the eighth, | |
| 407 | the folded "others", is grey. */ | |
| 424 | 408 | .fg-s0 { fill: var(--series-0); color: var(--series-0); } |
| 425 | 409 | .fg-s1 { fill: var(--series-1); color: var(--series-1); } |
| 426 | 410 | .fg-s2 { fill: var(--series-2); color: var(--series-2); } |
| @@ -429,7 +413,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 429 | 413 | .fg-s5 { fill: var(--series-5); color: var(--series-5); } |
| 430 | 414 | .fg-s6 { fill: var(--series-6); color: var(--series-6); } |
| 431 | 415 | .fg-s7 { fill: var(--series-7); color: var(--series-7); } |
| 432 | @media print { .fleet-pick { display: none; } } | |
| 433 | 416 | .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); } |
| 434 | 417 | .backups-restore { margin: 8px 0 0; padding: 8px 10px; font-size: 12.5px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); } |
| 435 | 418 | /* The records a domain waits on: type, name, value — as a zone file reads. */ |
src/web/ui.ts +9 −1
| @@ -667,6 +667,11 @@ export function dot(tone: Tone = "default"): Html { | ||
| 667 | 667 | ></span>`; |
| 668 | 668 | } |
| 669 | 669 | |
| 670 | /** A chart series' swatch: the dot in the `--series-N` colour its bars wear. */ | |
| 671 | export function seriesDot(series: number): Html { | |
| 672 | return html`<span class="dot" data-series="${series}" aria-hidden="true"></span>`; | |
| 673 | } | |
| 674 | ||
| 670 | 675 | /** |
| 671 | 676 | * A share as a bar: a whole percentage, 0–100, and what it is a share of in |
| 672 | 677 | * words for a screen reader. `attn` when the share is a limit reached. |
| @@ -1028,6 +1033,8 @@ export interface FieldOptions { | ||
| 1028 | 1033 | maxlength?: number; |
| 1029 | 1034 | rows?: number; |
| 1030 | 1035 | mono?: boolean; |
| 1036 | /** `false` for names and paths, which a spelling checker only underlines. */ | |
| 1037 | spellcheck?: boolean; | |
| 1031 | 1038 | id?: string; |
| 1032 | 1039 | } |
| 1033 | 1040 | |
| @@ -1044,7 +1051,8 @@ export function field(o: FieldOptions): Html { | ||
| 1044 | 1051 | ${o.required ? raw("required") : null} |
| 1045 | 1052 | ${o.autofocus ? raw("autofocus") : null} |
| 1046 | 1053 | ${o.readonly ? raw("readonly") : null} |
| 1047 | ${o.placeholder ? html`placeholder="${o.placeholder}"` : null}`; | |
| 1054 | ${o.placeholder ? html`placeholder="${o.placeholder}"` : null} | |
| 1055 | ${o.spellcheck === false ? raw('spellcheck="false"') : null}`; | |
| 1048 | 1056 | const control = |
| 1049 | 1057 | o.type === "textarea" |
| 1050 | 1058 | ? html`<textarea ${shared} rows="${o.rows ?? 6}" data-grow> |
test/fleet-graph.test.ts +10
| @@ -301,6 +301,16 @@ describe("the graph", () => { | ||
| 301 | 301 | expect(body).toContain("Last 24 hours"); |
| 302 | 302 | expect(body).toContain("in the UTC hour or day it ended"); |
| 303 | 303 | expect(body).toContain('href="/fleet/graph.csv?repo=alice%2Ftwo"'); |
| 304 | // Built from DESIGN.md §5: the field row, the stacked bar chart, the | |
| 305 | // series dot and the table — no page-local fleet-* class (#3197). | |
| 306 | expect(body).toContain('<form method="get" action="/fleet/graph" class="field-row mb">'); | |
| 307 | expect(body).toMatch(/<input\s+class="input mono" id="fleet-repos"[^>]*spellcheck="false"/); | |
| 308 | expect(body).toContain('<div class="plots">'); | |
| 309 | expect(body).toMatch(/<svg\s+class="plot"[^>]*width="600"\s+height="230"/); | |
| 310 | expect(body).toContain('<ul class="plot-legend">'); | |
| 311 | expect(body).toContain('<span class="dot" data-series="0" aria-hidden="true"></span>'); | |
| 312 | expect(body).toContain('<table class="table" data-dense>'); | |
| 313 | expect(body).not.toMatch(/class="[^"]*\bfleet-/); | |
| 304 | 314 | |
| 305 | 315 | const live = await fetch(`${servers.url}/fleet/graph/live?repo=alice/two`, { |
| 306 | 316 | headers: { cookie }, |
new above
offline