mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
174 lines
11 KiB
Twig
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 %}
|