Files
Paul LütznerandClaude Opus 5 4ffff11955 fix(admin): one inset for every card, and two guards to keep it
Reported three times, because it was never one slip. _card_chrome.html.twig has
said px-4 since it was written, and only two admin templates used it — every
other card hand-rolled px-5, px-6, px-3.5 or px-4 by hand. Consistency was
accidental, and each new card was a fresh coin flip.

Twenty-eight cards now take their band and body from the macros, and the labels
and prose between cards take the same inset the cards do. What is deliberately
left alone is written down where it is: tinted cards cannot use the macro
because two border colour utilities on one element are resolved by stylesheet
order rather than attribute order, row-shaped summaries would change height,
and dense list rows would end up padded twice — 32px from the card edge to the
first word, which the chrome docblock warns about.

Two guards, because one would not hold.

The static test is a threshold and not a blocklist: inside templates/admin no
class attribute may inset further than the macros' px-4. Buttons, chips and
inputs live below that line by construction, so the rule needs no exception
list to maintain.

The rendered test measures. It walks every section in SECTIONS, resolves each
to its turbo-frame id so a renamed section fails loudly instead of silently
re-measuring the previous one, and descends until it finds something actually
padded — so a padded box, a list of self-padding rows and a full-bleed table
all answer the same question. It collects every offence before asserting, so
one run names every wrong card rather than the first.

Both were proven to fail against the old markup before being trusted. A guard
that has never failed is not known to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:26:49 +02:00

174 lines
11 KiB
Twig

{# Bare frame — src + auto-refresh live on the placeholder in index.html.twig
(a src on the returned frame causes a refetch loop). #}
{% macro duration_ms(ms) %}
{% if ms >= 1000 %}{{ (ms / 1000)|number_format(2) }}s{% else %}{{ ms|number_format(2) }}ms{% endif %}
{% endmacro %}
{% import _self as ui %}
{% import 'admin/_cards.html.twig' as cards %}
{% import '_partials/_card_chrome.html.twig' as chrome %}
<turbo-frame id="admin-db">
{# A container, so the gauges below break on how wide this frame is rather
than on the viewport. `md:` was the worst possible question to ask: 768px
is where the mail sidebar and the section nav both appear, so the frame is
at its narrowest exactly where four columns were being demanded, and the
labels spilled out of the tiles. #}
<div class="@container space-y-6">
{# ── Health gauges ─────────────────────────────────────────────── #}
<div class="grid grid-cols-2 @lg:grid-cols-4 gap-3">
<div class="rounded-pane border border-line bg-pane-soft px-4 py-3">
<p class="text-xs text-ink-faint">{{ 'admin.db.connections'|trans }}</p>
<p class="mt-1 text-lg font-semibold text-ink">{{ gauges.connections }}</p>
</div>
<div class="rounded-pane border border-line bg-pane-soft px-4 py-3">
<p class="text-xs text-ink-faint">{{ 'admin.db.cache_hit'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.cacheHitPct is not null and gauges.cacheHitPct < 99 ? 'text-warning' : 'text-ink' }}">
{{ gauges.cacheHitPct is not null ? gauges.cacheHitPct ~ '%' : '—' }}
</p>
</div>
<div class="rounded-pane border border-line bg-pane-soft px-4 py-3">
<p class="text-xs text-ink-faint">{{ 'admin.db.rollback_ratio'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.rollbackPct is not null and gauges.rollbackPct > 5 ? 'text-warning' : 'text-ink' }}">
{{ gauges.rollbackPct is not null ? gauges.rollbackPct ~ '%' : '—' }}
</p>
</div>
<div class="rounded-pane border border-line bg-pane-soft px-4 py-3">
<p class="text-xs text-ink-faint">{{ 'admin.db.deadlocks'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.deadlocks > 0 ? 'text-danger' : 'text-ink' }}">{{ gauges.deadlocks }}</p>
</div>
</div>
{# ── Slowest by mean ───────────────────────────────────────────── #}
{{ cards.open('fa-gauge-high', 'admin.db.slow_mean.heading'|trans, 'db-slow', collapsedPanels,
'admin.summary.queries'|trans({ '%count%': slowestByMean|length })) }}
<div class="flex items-center gap-2 px-4 py-2 border-b border-line">
<span class="text-xs text-ink-faint">{{ 'admin.db.slow_mean.hint'|trans }}</span>
{% if statStatementsAvailable %}
<form method="post" action="{{ path('app_admin_db_reset') }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.db.reset_confirm'|trans }}" class="ml-auto">
<input type="hidden" name="_token" value="{{ csrf_token('admin_db_reset') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-7 rounded-lg text-xs font-medium btn-quiet transition-colors cursor-pointer">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
{{ 'admin.db.reset'|trans }}
</button>
</form>
{% endif %}
</div>
{% if statStatementsAvailable == false %}
<div class="{{ chrome.body() }} text-sm text-ink-muted">
<p class="flex items-center gap-2">
<i class="fa-solid fa-circle-info text-amber-500" aria-hidden="true"></i>
{{ 'admin.db.extension_missing'|trans }}
</p>
{# The button only where pressing it could work. The library
is read once at server start, so if it is not loaded the
only honest instruction is the config plus a restart —
CREATE EXTENSION would succeed and leave a view that
raises on every read. #}
{% if statStatementsPossible %}
<p class="mt-1 text-xs text-ink-faint">{{ 'admin.db.extension_enable_hint'|trans }}</p>
<form method="post" action="{{ path('app_admin_db_stat_statements_enable') }}" class="mt-3">
<input type="hidden" name="_token" value="{{ csrf_token('admin_db_stat_statements_enable') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-3 h-8 rounded-lg text-xs font-medium
bg-accent hover:bg-accent-strong text-accent-ink transition-colors cursor-pointer">
<i class="fa-solid fa-bolt text-[11px]" aria-hidden="true"></i>
{{ 'admin.db.extension_enable'|trans }}
</button>
</form>
{% else %}
<pre class="mt-2 p-3 rounded-lg bg-sunken border border-line text-xs text-ink-soft overflow-x-auto">shared_preload_libraries = 'pg_stat_statements' # postgresql.conf, then restart
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;</pre>
{% endif %}
</div>
{% elseif slowestByMean|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.db.slow_mean.empty'|trans }}</p>
{% else %}
{{ _self.statement_table(slowestByMean, 'meanMs') }}
{% endif %}
{{ cards.close() }}
{# ── Heaviest by total time ────────────────────────────────────── #}
{% if statStatementsAvailable and heaviestByTotal|length > 0 %}
{{ cards.open('fa-weight-hanging', 'admin.db.heavy_total.heading'|trans, 'db-heavy', collapsedPanels,
'admin.summary.queries'|trans({ '%count%': heaviestByTotal|length })) }}
<p class="px-4 py-2 border-b border-line text-xs text-ink-faint">{{ 'admin.db.heavy_total.hint'|trans }}</p>
{{ _self.statement_table(heaviestByTotal, 'totalMs') }}
{{ cards.close() }}
{% endif %}
{# ── Live activity ─────────────────────────────────────────────── #}
{{ cards.open('fa-bolt', 'admin.db.active.heading'|trans, 'db-active', collapsedPanels,
activeQueries|length > 0
? 'admin.summary.active'|trans({ '%count%': activeQueries|length })
: 'admin.summary.idle'|trans) }}
{% if activeQueries|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.db.active.empty'|trans }}</p>
{% else %}
<div class="divide-y divide-line">
{% for row in activeQueries %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-3 text-xs">
<span class="font-mono text-ink-faint">pid {{ row.pid }}</span>
<span class="px-1.5 py-0.5 rounded-md bg-sunken text-ink-soft">{{ row.state }}</span>
{% if row.waitEvent %}
<span class="text-warning font-mono">{{ row.waitEvent }}</span>
{% endif %}
<span class="ml-auto font-mono {{ row.durationSeconds is not null and row.durationSeconds > 5 ? 'text-danger' : 'text-ink-muted' }}">
{{ row.durationSeconds is not null ? row.durationSeconds ~ 's' : '—' }}
</span>
</div>
<pre class="mt-1.5 text-xs text-ink-soft font-mono whitespace-pre-wrap break-all">{{ row.query }}</pre>
</div>
{% endfor %}
</div>
{% endif %}
{{ cards.close() }}
</div>
</turbo-frame>
{# ── Shared statement table ────────────────────────────────────────────── #}
{% macro statement_table(rows, highlight) %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-ink-faint">
<th class="px-4 py-2 font-medium">{{ 'admin.db.col.query'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.calls'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.mean'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.max'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.total'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.rows'|trans }}</th>
<th class="px-2 py-2 font-medium text-right pr-4">{{ 'admin.db.col.hit'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr class="border-t border-line text-ink-soft align-top">
<td class="px-4 py-2">
<code class="text-xs text-ink-soft break-all">{{ row.query }}</code>
</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.calls|number_format }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums {{ highlight == 'meanMs' ? 'font-semibold text-ink' : '' }}">{{ row.meanMs|number_format(2) }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.maxMs|number_format(2) }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums {{ highlight == 'totalMs' ? 'font-semibold text-ink' : '' }}">{{ (row.totalMs / 1000)|number_format(2) }}s</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.rows|number_format }}</td>
<td class="px-2 py-2 text-xs text-right pr-4 tabular-nums {{ row.hitPct is not null and row.hitPct < 90 ? 'text-warning' : '' }}">
{{ row.hitPct is not null ? row.hitPct ~ '%' : '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}