CI / test (push) Successful in 1m50s
The dashboard charted what the cluster had been billed for — requests, tokens, spend, all from llmgw's own store — and nothing about whether it was healthy. Saturation, queue depth, cache occupancy and replica counts were numbers in a table at the instant the page loaded, with no way to see that one of them had been climbing for an hour. Four line charts now, read back from wherever the metrics were scraped to. Read back rather than kept twice. llmgw publishes these already; what a gauge cannot do is remember, and a ring buffer in this process would be a rival copy of the same numbers with its own retention, lost on every restart and disagreeing with the one an operator has on screen elsewhere. monitoring.prometheus is empty by default and the panels are then absent rather than empty. One axis per chart, always. Two measures of different scale belong in two charts: a second y-axis lets any two lines be made to cross wherever the author likes and the reader cannot tell it happened. Four colours, handed out in fixed order, never cycled. A fifth series is summed into "other" and drawn in grey — dropping it would make a chart that does not add up, and inventing a hue for it would put two lines on the page that nobody can tell apart. The fold is by timestamp, not by position: the tail's series do not share sample times, and adding them by index slides points sideways onto each other's clock. A legend for every chart with more than one line, because colour alone is not an encoding a reader can rely on. A flat zero is drawn along the bottom rather than left blank: nothing happening is a real answer, and an empty frame reads as a broken chart. A single sample is not a line and says so instead of putting a dot in the corner. One failed query fails all four panels. Three drawn and one blank invites reading the blank one as "nothing is happening there". NaN is dropped rather than plotted. Prometheus sends it as a string for a sample it has no value for, Go's ParseFloat accepts it happily, and one NaN coordinate is an SVG path that does not draw at all — taking the rest of the line with it. Axis maxima are rounded to a number a person would pick. An axis topping out at 0.8347 makes every gridline unreadable, and the shape is what a chart is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
16 KiB
CSS
299 lines
16 KiB
CSS
/* Deliberately one small stylesheet with no build step. The single-binary goal
|
|
rules out a JS toolchain, and nothing here needs one. */
|
|
:root {
|
|
color-scheme: light dark;
|
|
--bg: #fbfbfa;
|
|
--fg: #1c1c1a;
|
|
--muted: #6b6b66;
|
|
--line: #e3e3df;
|
|
--card: #ffffff;
|
|
--accent: #2f5d50;
|
|
--ok: #2f7a4f;
|
|
--warn: #9a6b1f;
|
|
--bad: #a33a32;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #17181a; --fg: #e8e8e4; --muted: #9a9a93; --line: #2c2e31;
|
|
--card: #1e2022; --accent: #7fb3a2; --ok: #6fbd8c; --warn: #d6a44e; --bad: #e07b72;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; background: var(--bg); color: var(--fg);
|
|
font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
|
|
header { border-bottom: 1px solid var(--line); margin-bottom: 2rem; padding-bottom: 1.25rem; }
|
|
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
|
|
h2 { font-size: 1.05rem; margin: 2.25rem 0 .75rem; letter-spacing: -.005em; }
|
|
.sub { color: var(--muted); margin: 0; }
|
|
nav { margin-top: .75rem; display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
nav a { color: var(--accent); text-decoration: none; font-size: .9rem; }
|
|
nav a:hover { text-decoration: underline; }
|
|
table { width: 100%; border-collapse: collapse; font-size: .875rem; background: var(--card);
|
|
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); }
|
|
th { font-weight: 600; color: var(--muted); font-size: .78rem; text-transform: uppercase;
|
|
letter-spacing: .04em; }
|
|
tr:last-child td { border-bottom: none; }
|
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
|
|
pre { background: var(--card); border: 1px solid var(--line); border-radius: 6px;
|
|
padding: .9rem 1rem; overflow-x: auto; }
|
|
.state { font-weight: 600; font-size: .8rem; }
|
|
.state-ready, .state-active { color: var(--ok); }
|
|
.state-pending, .state-starting, .state-draining, .state-cordoned { color: var(--warn); }
|
|
.state-failed, .state-lost, .state-unreachable, .state-revoked { color: var(--bad); }
|
|
.empty { color: var(--muted); font-style: italic; padding: .9rem 0; }
|
|
.note { color: var(--muted); font-size: .85rem; margin: .4rem 0 0; }
|
|
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
|
color: var(--muted); font-size: .8rem; display: flex; justify-content: space-between; gap: 1rem; }
|
|
button {
|
|
font: inherit; font-weight: 600; cursor: pointer;
|
|
background: var(--accent); color: var(--bg); border: none;
|
|
border-radius: 6px; padding: .55rem 1.1rem;
|
|
}
|
|
button:hover { filter: brightness(1.1); }
|
|
ul { padding-left: 1.2rem; }
|
|
li { margin: .25rem 0; }
|
|
a { color: var(--accent); }
|
|
.wrap.narrow { max-width: 30rem; }
|
|
label { display: block; margin: .9rem 0; font-size: .9rem; font-weight: 600; }
|
|
input[type=text], input[type=email], input[type=password] {
|
|
display: block; width: 100%; margin-top: .3rem; font: inherit; font-weight: 400;
|
|
padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px;
|
|
background: var(--card); color: var(--fg);
|
|
}
|
|
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
.error { color: var(--bad); border: 1px solid var(--bad); border-radius: 6px;
|
|
padding: .6rem .8rem; background: var(--card); }
|
|
form.inline { display: inline; }
|
|
button.link { background: none; color: var(--accent); padding: 0;
|
|
text-decoration: underline; font-weight: 400; font-size: .9rem; }
|
|
button.link.danger { color: var(--bad); }
|
|
.ok { color: var(--ok); border: 1px solid var(--ok); border-radius: 6px;
|
|
padding: .6rem .8rem; background: var(--card); }
|
|
select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line);
|
|
border-radius: 6px; background: var(--card); color: var(--fg); }
|
|
.chart-wrap { position: relative; margin: .5rem 0 1.5rem; }
|
|
.chart { width: 100%; height: 120px; display: block;
|
|
background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
|
|
.chart rect { transition: opacity .1s; }
|
|
.chart rect:hover { opacity: .65; }
|
|
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
|
.chart-peak { position: absolute; top: .4rem; right: .6rem;
|
|
font-size: .72rem; color: var(--muted); }
|
|
.chart-row { display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; }
|
|
@media (max-width: 720px) { .chart-row { grid-template-columns: 1fr; } }
|
|
.chat-bar { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; }
|
|
.inline-label { font-size: .85rem; font-weight: 600; margin: 0; }
|
|
.inline-label select { margin-left: .4rem; font-weight: 400; }
|
|
.chat-log { min-height: 16rem; max-height: 55vh; overflow-y: auto; padding: .5rem;
|
|
background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
|
|
.bubble { white-space: pre-wrap; word-wrap: break-word; padding: .55rem .75rem;
|
|
margin: .4rem 0; border-radius: 8px; max-width: 46rem; }
|
|
.bubble.you { background: var(--accent); color: var(--bg); margin-left: auto; }
|
|
.bubble.assistant { background: var(--bg); border: 1px solid var(--line); }
|
|
.bubble.failed { color: var(--bad); border-color: var(--bad); }
|
|
.chat-composer { display: flex; gap: .6rem; margin-top: .75rem; align-items: flex-end; }
|
|
.chat-composer textarea { flex: 1; font: inherit; padding: .5rem .6rem; resize: vertical;
|
|
border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
|
|
|
|
/* KaTeX sits inside a bubble that sets white-space: pre-wrap for plain text;
|
|
display maths needs to escape that or every fraction gets the newlines in
|
|
its own source. */
|
|
.bubble .katex-display { white-space: normal; margin: .6rem 0; overflow-x: auto; }
|
|
.bubble .katex { white-space: normal; }
|
|
.bubble.you .katex { color: inherit; }
|
|
/* A reply that stopped because it ran out of room, not because it was done. */
|
|
.note.truncated { color: var(--warn, #a15c00); margin: .2rem 0 .8rem; }
|
|
/* Offered under a reply that contains files worth keeping. */
|
|
.link.download { display: inline-block; margin: .3rem 0 .8rem; }
|
|
|
|
/* A usage bar inside a table cell. Drawn with two elements rather than a
|
|
gradient so the number stays selectable and readable by a screen reader. */
|
|
.meter { position: relative; min-width: 9rem; }
|
|
.meter > span {
|
|
display: block; height: .75rem; border-radius: 2px;
|
|
background: var(--accent); opacity: .55;
|
|
}
|
|
.meter > em { font-style: normal; font-size: .8rem; color: var(--muted); }
|
|
.bad { color: var(--bad); }
|
|
|
|
/* Chart palette.
|
|
*
|
|
* Roles rather than raw colours, so light and dark swap in one place and the
|
|
* charts are written against meaning. The values are a validated categorical
|
|
* set: every adjacent pair clears the colour-vision-deficiency separation
|
|
* threshold, and the dark column is the same hues re-stepped for the dark
|
|
* surface rather than an automatic flip of the light one.
|
|
*
|
|
* Checked with a validator rather than by eye, which is the only way anyone
|
|
* can honestly claim a palette is colourblind-safe. */
|
|
:root {
|
|
--viz-1: #2a78d6; /* blue */
|
|
--viz-2: #eb6834; /* orange */
|
|
--viz-3: #1baf7a; /* aqua */
|
|
--viz-4: #eda100; /* yellow */
|
|
--viz-grid: #e6e5e1;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--viz-1: #3987e5;
|
|
--viz-2: #d95926;
|
|
--viz-3: #199e70;
|
|
--viz-4: #c98500;
|
|
--viz-grid: #2e2e2c;
|
|
}
|
|
}
|
|
:root[data-theme="dark"] {
|
|
--viz-1: #3987e5;
|
|
--viz-2: #d95926;
|
|
--viz-3: #199e70;
|
|
--viz-4: #c98500;
|
|
--viz-grid: #2e2e2c;
|
|
}
|
|
|
|
/* Stat tiles: the headline figures, which are numbers rather than charts. */
|
|
.tiles { display: flex; flex-wrap: wrap; gap: .75rem; margin: .5rem 0 1.25rem; }
|
|
.tile {
|
|
flex: 1 1 8.5rem; min-width: 8.5rem; padding: .6rem .75rem;
|
|
border: 1px solid var(--viz-grid); border-radius: 6px; background: var(--card);
|
|
}
|
|
.tile .v { font-size: 1.5rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
|
.tile .k { font-size: .8rem; color: var(--muted); }
|
|
.tile svg { display: block; margin-top: .35rem; }
|
|
/* Ranked horizontal bars. A wrapper, not the SVG itself: .chart above is the
|
|
* fixed-height time-series box, and sharing the name gave these a 120px cage
|
|
* their taller content overflowed, over the table beneath them. Capped at the
|
|
* design width so scaling up never inflates the 12px labels. */
|
|
.ranked { margin: .25rem 0 1rem; }
|
|
.ranked svg { display: block; width: 100%; max-width: 720px; height: auto; }
|
|
|
|
/* The landing page.
|
|
*
|
|
* It is the only page here a stranger sees, and for most readers it is the whole
|
|
* product until they have a key. Everything below is layout and surface: the
|
|
* page still says exactly what it said before, and still names no model, no node
|
|
* and no count. */
|
|
.banner { border-radius: 6px; padding: .7rem .9rem; margin: 0 0 1.5rem;
|
|
border: 1px solid var(--line); background: var(--card); font-size: .875rem; }
|
|
.warn-banner { border-color: var(--warn); color: var(--warn); }
|
|
|
|
.hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
|
|
gap: 2rem; align-items: center; border-bottom: 1px solid var(--line);
|
|
margin-bottom: 2.25rem; padding-bottom: 2rem; }
|
|
.hero h1 { font-size: 2.6rem; line-height: 1; margin: 0 0 .5rem; letter-spacing: -.03em; }
|
|
.hero .sub { font-size: 1.15rem; color: var(--fg); font-weight: 500; }
|
|
.hero .lede { color: var(--muted); max-width: 34rem; margin: .75rem 0 0; }
|
|
.cta { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.35rem 0 0; }
|
|
.btn { display: inline-block; font-size: .9rem; font-weight: 600; text-decoration: none;
|
|
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
|
|
border-radius: 6px; padding: .5rem 1.1rem; }
|
|
.btn:hover { filter: brightness(1.1); }
|
|
.btn.ghost { background: none; color: var(--accent); border-color: var(--line); }
|
|
.btn.ghost:hover { border-color: var(--accent); filter: none; }
|
|
|
|
.schematic { margin: 0; }
|
|
.schematic svg { display: block; width: 100%; height: auto; }
|
|
.schematic figcaption { color: var(--muted); font-size: .8rem; margin-top: .6rem; }
|
|
@media (max-width: 760px) {
|
|
.hero { grid-template-columns: 1fr; gap: 1.5rem; }
|
|
.hero h1 { font-size: 2rem; }
|
|
}
|
|
|
|
/* The base URL is what a reader came for, so it is a headline rather than a
|
|
* line of prose with a colon in it. */
|
|
.url-card { display: flex; flex-wrap: wrap; align-items: baseline; gap: .35rem 1rem;
|
|
background: var(--card); border: 1px solid var(--line);
|
|
border-left: 3px solid var(--accent); border-radius: 6px;
|
|
padding: .85rem 1.1rem; }
|
|
.url-card .k { font-size: .72rem; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: .06em; color: var(--muted); }
|
|
.url-card code { font-size: 1.05rem; word-break: break-all; }
|
|
|
|
.steps { list-style: none; padding: 0; margin: .5rem 0 0;
|
|
display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
.steps > li { margin: 0; background: var(--card); border: 1px solid var(--line);
|
|
border-radius: 8px; padding: 1.1rem 1.1rem 1.25rem; }
|
|
.steps .n { display: inline-grid; place-items: center; width: 1.6rem; height: 1.6rem;
|
|
border-radius: 50%; background: var(--accent); color: var(--bg);
|
|
font-size: .8rem; font-weight: 700; }
|
|
.steps h3 { font-size: .98rem; margin: .6rem 0 .4rem; }
|
|
.steps p { margin: .4rem 0 0; font-size: .9rem; }
|
|
/* A card is narrower than a line of shell, and a block that scrolls sideways
|
|
* inside a card is one nobody discovers they can scroll. */
|
|
.steps pre { margin: .7rem 0 0; font-size: .78em;
|
|
white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
@media (max-width: 860px) { .steps { grid-template-columns: 1fr; } }
|
|
|
|
/* Tabs with no JavaScript: the radios carry the state, so the panel a reader
|
|
* chose survives everything including the script never loading. */
|
|
.tabs { display: flex; flex-wrap: wrap; gap: .35rem; margin: .5rem 0 .75rem; }
|
|
.tabs > input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
.tabs > label { display: inline-block; margin: 0; cursor: pointer; font-size: .85rem;
|
|
font-weight: 600; color: var(--muted); padding: .35rem .85rem;
|
|
border: 1px solid transparent; border-radius: 999px; }
|
|
.tabs > label:hover { color: var(--fg); }
|
|
.tabs > input:checked + label { color: var(--fg); background: var(--card);
|
|
border-color: var(--line); }
|
|
.tabs > input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
.tabs > .panels { flex: 1 0 100%; }
|
|
.tabs .panel { display: none; margin: 0; }
|
|
.tabs > input:nth-of-type(1):checked ~ .panels > .panel:nth-child(1),
|
|
.tabs > input:nth-of-type(2):checked ~ .panels > .panel:nth-child(2),
|
|
.tabs > input:nth-of-type(3):checked ~ .panels > .panel:nth-child(3) { display: block; }
|
|
|
|
/* Copy buttons are injected by script, so they are positioned against the block
|
|
* they copy rather than reserving space that stays empty without one. */
|
|
pre { position: relative; }
|
|
pre .copy { position: absolute; top: .4rem; right: .4rem; padding: .15rem .5rem;
|
|
font-size: .72rem; font-weight: 600; background: var(--bg);
|
|
color: var(--muted); border: 1px solid var(--line); border-radius: 4px;
|
|
opacity: 0; transition: opacity .12s; }
|
|
pre:hover .copy, pre .copy:focus-visible { opacity: 1; }
|
|
pre .copy:hover { color: var(--fg); filter: none; }
|
|
|
|
.endpoints { list-style: none; padding: 0; margin: .5rem 0 0; }
|
|
.endpoints > li { display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem .8rem;
|
|
padding: .55rem 0; border-bottom: 1px solid var(--line); margin: 0; }
|
|
.endpoints > li:last-child { border-bottom: none; }
|
|
.endpoints > li span { color: var(--muted); font-size: .85rem; }
|
|
/* A button revealed by hover is a button a phone never has. */
|
|
@media (hover: none) { pre .copy { opacity: 1; } }
|
|
|
|
/* The mirror page. A model version spills onto a second line under its row:
|
|
the catalog URL is long, and a column wide enough for it would squeeze every
|
|
other column on the table into uselessness. */
|
|
.url-row code, .error-row { font-size: .78rem; padding-top: 0; }
|
|
.url-row { color: var(--muted); }
|
|
.url-row code { color: var(--muted); }
|
|
.error-row { color: var(--bad); }
|
|
tr:has(+ .url-row) td, tr:has(+ .error-row) td { border-bottom: none; }
|
|
/* The fetch form's three short fields sit on one line on a wide screen and
|
|
stack on a narrow one, because two of them are a word long and a full-width
|
|
input for "2026-09" reads as though more is wanted. */
|
|
.field-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
.field-row label { flex: 1 1 12rem; }
|
|
.opt { font-weight: 400; color: var(--muted); font-size: .8rem; }
|
|
|
|
/* The row whose quota form is open. Tinted rather than outlined: a border
|
|
inside a table cell breaks the ruling of every column beside it. */
|
|
tr.editing > td { background: color-mix(in srgb, var(--accent) 8%, var(--card)); }
|
|
tr.editing form { margin: .4rem 0; }
|
|
|
|
/* Multi-line time series. Thin marks, recessive grid: the data is the darkest
|
|
thing on the chart, which is the only rule that makes a chart readable. */
|
|
.chart-lines { height: 150px; }
|
|
.chart-lines .line { stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
|
.chart-lines .axis { stroke: var(--viz-grid); stroke-width: 1; }
|
|
.chart-lines .tick { fill: var(--muted); font-size: 9px; text-anchor: end; }
|
|
/* A legend is always present for two lines or more: colour alone is not an
|
|
encoding a reader can rely on. */
|
|
.legend { display: flex; flex-wrap: wrap; gap: .1rem 1rem; margin: .2rem 0 1rem; font-size: .8rem; }
|
|
.legend span { display: inline-flex; align-items: center; gap: .35rem; color: var(--muted); }
|
|
.legend i { width: 10px; height: 2px; border-radius: 1px; display: inline-block; }
|
|
.legend b { font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
|
.chart-title { font-size: .9rem; font-weight: 600; margin: 0 0 .3rem; }
|