fix(ui): align the content column and rebuild health as a real dashboard
The shell had a genuine bug: `main` carried both `max-width: var(--shell)` and a centering inline padding, and with `box-sizing: border-box` the max-width *includes* that padding. On a wide display the content column collapsed to a fraction of its intended width while the header still spanned the full shell, so the page sat narrow and misaligned with its own header. Both now use the one gutter formula and no max-width, against `100%` rather than `100vw` so a scrollbar cannot turn the gutter into overflow. Health mixed three kinds of thing into one tile grid — bare counts, a status pill, a latency with a unit, a model name — which is why it read as a ragged list. Tiles are now uniformly one label and one number, muted at zero and amber when a non-zero count wants attention (pending vectors, unresolved links, orphans). Everything with a unit or a status moved into an Embeddings card beside a Maintenance card, and a lexical-only backend now says so in a banner instead of hiding inside a pill. Mobile: tiles hold two columns down to ~340px, the nav scrolls sideways instead of wrapping onto a second line, the user's name gives way to the sign-out link, action buttons and filter controls go full width rather than forming a ragged two-and-a-half-button row, and table cells stop wrapping so the scroll container does its job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
This commit is contained in:
1 parent
e18f42a4d0
commit
527799cbc9
2 files changed
+109
-42
No files matched your search
@@ -99,13 +99,23 @@ header {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.55rem max(1rem, calc((100vw - var(--shell)) / 2));
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
backdrop-filter: saturate(1.4) blur(10px);
|
||||
}
|
||||
|
||||
nav { display: flex; align-items: center; gap: 0.2rem; flex-wrap: wrap; }
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
nav::-webkit-scrollbar { display: none; }
|
||||
nav a { flex: 0 0 auto; }
|
||||
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
@@ -136,9 +146,11 @@ nav a[aria-current="page"] { background: var(--accent-soft); color: var(--accent
|
||||
.who a:hover { color: var(--fg); text-decoration: underline; }
|
||||
|
||||
main {
|
||||
max-width: var(--shell);
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem max(1rem, calc((100vw - var(--shell)) / 2)) 6rem;
|
||||
/* Same gutter formula as the header so both content columns align. No
|
||||
max-width here: with border-box it would include the gutter and squeeze
|
||||
the column to a fraction of the header's width. */
|
||||
padding-block: 1.75rem 6rem;
|
||||
padding-inline: max(1rem, calc((100% - var(--shell)) / 2));
|
||||
}
|
||||
|
||||
/* -- type ----------------------------------------------------------------- */
|
||||
@@ -309,6 +321,7 @@ form { display: inline; }
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.actions.bare { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
.actions details { width: 100%; margin-top: 0.3rem; }
|
||||
.actions details summary { cursor: pointer; font-size: 0.86rem; padding: 0.2rem 0; }
|
||||
.actions details[open] summary { margin-bottom: 0.5rem; }
|
||||
@@ -323,18 +336,33 @@ form { display: inline; }
|
||||
.cols ul, .history { list-style: none; padding: 0; margin: 0.2rem 0; }
|
||||
.cols li, .history li { padding: 0.18rem 0; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat {
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem;
|
||||
min-height: 4.6rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat dt { margin: 0 0 0.15rem; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
.stat dd { margin: 0; font-size: 1.4rem; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.stat dd.text { font-size: 0.95rem; font-weight: 500; letter-spacing: 0; }
|
||||
.stat dd small { display: block; font-size: 0.74rem; font-weight: 400; color: var(--muted); letter-spacing: 0; }
|
||||
.stat dt { margin: 0; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
.stat dd { margin: 0; font-size: 1.65rem; line-height: 1.1; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.03em; }
|
||||
.stat.attention dd { color: var(--warn); }
|
||||
.stat.zero dd { color: var(--muted); }
|
||||
|
||||
/* key/value rows for mixed-type facts that do not belong in a number tile */
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 1rem; margin: 0; font-size: 0.88rem; }
|
||||
.kv dt { color: var(--muted); white-space: nowrap; }
|
||||
.kv dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
|
||||
/* -- notices -------------------------------------------------------------- */
|
||||
.notice {
|
||||
@@ -366,7 +394,8 @@ form { display: inline; }
|
||||
/* -- table ---------------------------------------------------------------- */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); }
|
||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
td.wrap, th.wrap { white-space: normal; min-width: 12rem; }
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -427,10 +456,29 @@ pre.diff .hunk { color: var(--muted); font-weight: 600; }
|
||||
.cloud .tag { margin: 0; display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
header { flex-wrap: wrap; padding: 0.5rem 1rem; }
|
||||
main { padding: 1.25rem 1rem 4rem; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
body { font-size: 14.5px; }
|
||||
header { gap: 0.5rem; }
|
||||
.brand { margin-right: 0.2rem; }
|
||||
.who span { display: none; } /* the name costs a line; the link is the point */
|
||||
main { padding-block: 1.25rem 4rem; }
|
||||
h1 { font-size: 1.28rem; }
|
||||
h1 small { display: block; margin: 0.2rem 0 0; }
|
||||
.card { padding: 0.9rem 1rem; }
|
||||
|
||||
/* two tiles per row stays readable down to ~340px */
|
||||
.stats { grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: 0.5rem; }
|
||||
.stat { min-height: 4rem; padding: 0.6rem 0.7rem; }
|
||||
.stat dd { font-size: 1.35rem; }
|
||||
|
||||
/* full-width controls beat a ragged two-and-a-half-button row */
|
||||
.actions form { display: flex; width: 100%; }
|
||||
.actions > form > button, .actions > .button { width: 100%; justify-content: center; }
|
||||
.filters { padding: 0.6rem; }
|
||||
.filters input, .filters select, .filters button { width: 100%; }
|
||||
.filters input[type="search"] { flex: 1 1 100%; }
|
||||
.filters label { width: 100%; }
|
||||
.history-compare { width: 100%; }
|
||||
.history-compare label, .history-compare select { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -3,36 +3,48 @@
|
||||
{% block content %}
|
||||
<h1>Health</h1>
|
||||
|
||||
<div class="stats">
|
||||
{% for s, n in health.counts.items() %}
|
||||
<dl class="stat"><dt>{{ s }}</dt><dd>{{ n }}</dd></dl>
|
||||
{% endfor %}
|
||||
<dl class="stat"><dt>Chunks</dt><dd>{{ health.chunks }}<small>{{ health.vec_pending }} without a vector</small></dd></dl>
|
||||
<dl class="stat">
|
||||
<dt>Embeddings</dt>
|
||||
<dd class="text">
|
||||
<span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'lexical only' }}</span>
|
||||
<small>{{ health.embed_last_ms or '–' }} ms · {{ health.embed_errors }} errors</small>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="stat"><dt>Unresolved links</dt><dd>{{ health.unresolved_links }}<small>{{ health.orphans }} orphaned notes</small></dd></dl>
|
||||
</div>
|
||||
|
||||
<h3>Model</h3>
|
||||
<p class="meta">{{ health.embed_model }} · dim {{ health.embed_dim }}</p>
|
||||
|
||||
{% if health.secrets_in_vault %}
|
||||
<div class="conflict">
|
||||
<h3>Possible credentials in the vault</h3>
|
||||
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
{% if not health.embed_ok %}
|
||||
<div class="notice warn">
|
||||
<p><strong>Lexical only.</strong> The embedding backend did not answer, so search is running
|
||||
without the dense channel. Notes are still written and indexed; vectors fill in once it recovers.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/health/reindex"><button type="submit">Refresh index</button></form>
|
||||
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Rebuild index</button></form>
|
||||
<form method="post" action="/ui/health/reembed"><button type="submit">Re-embed everything</button></form>
|
||||
<form method="post" action="/ui/health/eval"><button type="submit">Run eval</button></form>
|
||||
{# Uniform number tiles: one label, one number, nothing else. Anything that needs
|
||||
a unit, a status or a model name goes in the cards below instead — mixing the
|
||||
two is what made this read as a ragged list rather than a dashboard. #}
|
||||
<div class="stats">
|
||||
{% for s in ['inbox', 'active', 'archive'] %}
|
||||
<dl class="stat {{ 'zero' if not health.counts.get(s) }}"><dt>{{ s }}</dt><dd>{{ health.counts.get(s, 0) }}</dd></dl>
|
||||
{% endfor %}
|
||||
<dl class="stat {{ 'zero' if not health.chunks }}"><dt>Chunks</dt><dd>{{ health.chunks }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.vec_pending else 'zero' }}"><dt>No vector</dt><dd>{{ health.vec_pending }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.unresolved_links else 'zero' }}"><dt>Unresolved</dt><dd>{{ health.unresolved_links }}</dd></dl>
|
||||
<dl class="stat {{ 'attention' if health.orphans else 'zero' }}"><dt>Orphans</dt><dd>{{ health.orphans }}</dd></dl>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<section class="card">
|
||||
<h2>Embeddings</h2>
|
||||
<dl class="kv">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="pill {{ 'ok' if health.embed_ok else 'warn' }}">{{ 'ok' if health.embed_ok else 'lexical only' }}</span></dd>
|
||||
<dt>Model</dt><dd>{{ health.embed_model }}</dd>
|
||||
<dt>Dimension</dt><dd>{{ health.embed_dim }}</dd>
|
||||
<dt>Last call</dt><dd>{{ health.embed_last_ms or '–' }} ms</dd>
|
||||
<dt>Errors</dt><dd>{{ health.embed_errors }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Maintenance</h2>
|
||||
<div class="actions bare">
|
||||
<form method="post" action="/ui/health/reindex"><button type="submit">Refresh index</button></form>
|
||||
<form method="post" action="/ui/health/reindex"><input type="hidden" name="full" value="1"><button type="submit">Rebuild index</button></form>
|
||||
<form method="post" action="/ui/health/reembed"><button type="submit">Re-embed everything</button></form>
|
||||
<form method="post" action="/ui/health/eval"><button type="submit">Run eval</button></form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if eval_status.startswith("started:") %}
|
||||
@@ -41,6 +53,13 @@
|
||||
<div class="notice warn"><p>No query set at {{ eval_status[8:] }} — label 50–100 real queries first (§11).</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% if health.secrets_in_vault %}
|
||||
<div class="conflict">
|
||||
<h3>Possible credentials in the vault</h3>
|
||||
<ul>{% for f in health.secrets_in_vault %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3>Unresolved links</h3>
|
||||
<ul>{% for u in unresolved %}<li><a href="/ui/n/{{ u.src }}">{{ u.title }}</a> → <span class="unresolved">{{ u.target }}</span> ({{ u.rel }})</li>{% else %}<li class="empty">none</li>{% endfor %}</ul>
|
||||
|
||||
|
||||
Reference in new issue
Block a user