Files
pl_mail/templates/admin/_live_frame.html.twig
Paul LütznerandClaude Opus 5.5 8f7d2cb073 Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.

A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.

The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.

A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.

A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.

Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.

Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.

Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:39 +02:00

562 lines
36 KiB
Twig
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# Auto-refreshed live panels. Wrapped in the same turbo-frame id so
frame.reload() swaps the whole block. #}
{% macro dot(healthy) %}
<span class="inline-block w-2 h-2 rounded-full {{ healthy ? 'bg-emerald-500' : 'bg-red-500' }}" aria-hidden="true"></span>
{% endmacro %}
{% import _self as ui %}
{% import 'admin/_cards.html.twig' as cards %}
{% import '_partials/_card_chrome.html.twig' as chrome %}
{# Row markup shared with the pages the queue browser fetches. #}
{% import 'admin/_queue_messages.html.twig' as queue %}
<turbo-frame id="admin-live">
<div class="space-y-6">
{# ── Processes ─────────────────────────────────────────────────── #}
{% set processesDown = heartbeats|filter(row => not row.healthy)|length %}
{{ cards.open('fa-heart-pulse', 'admin.processes.heading'|trans, 'processes', collapsedPanels,
processesDown > 0
? 'admin.summary.down'|trans({ '%count%': processesDown })
: 'admin.summary.healthy'|trans({ '%count%': heartbeats|length }),
processesDown > 0 ? 'danger' : null) }}
{#
A restart CLEARS heartbeat rows rather than reddening them —
WorkerHeartbeatListener drops the row on clean shutdown. Without
this banner the table just loses rows for ~30s, which reads as an
outage rather than the restart the admin just asked for.
#}
{# 150s, not 30: measured worst case is the IMAP supervisor, which
only checks the signal once per 60s reconcile poll, plus its
children's shutdown and the container coming back. #}
{% if restartRequestedAgo is not null and restartRequestedAgo < 150 %}
<div class="flex items-start gap-2.5 px-4 py-3 border-b border-line alert-info text-sm text-accent">
<i class="fa-solid fa-arrows-rotate mt-0.5 shrink-0 text-accent/70" aria-hidden="true"></i>
<p>{{ 'admin.maintenance.restart_pending'|trans({ '%seconds%': restartRequestedAgo }) }}</p>
</div>
{% endif %}
{% if heartbeats|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.processes.empty'|trans }}</p>
{% else %}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-ink-faint">
<th class="px-4 py-2 font-medium"></th>
<th class="px-2 py-2 font-medium">{{ 'admin.processes.type'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.processes.key'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.processes.pid'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.processes.last_beat'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in heartbeats %}
<tr class="border-t border-line text-ink-soft">
<td class="px-4 py-2 w-8">{{ ui.dot(row.healthy) }}</td>
<td class="px-2 py-2 font-mono text-xs">{{ row.type }}</td>
<td class="px-2 py-2 font-mono text-xs">{{ row.key }}</td>
<td class="px-2 py-2 font-mono text-xs">{{ row.pid ?? '—' }}</td>
<td class="px-2 py-2 text-xs {{ row.healthy ? '' : 'text-danger' }}">
{{ 'admin.ago'|trans({ '%age%': row.age }) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{{ cards.close() }}
{# ── Maintenance ───────────────────────────────────────────────── #}
{{ cards.open('fa-screwdriver-wrench', 'admin.maintenance.heading'|trans, 'maintenance', collapsedPanels) }}
<div class="{{ chrome.body() }} space-y-4">
<div>
<p class="text-sm text-ink">{{ 'admin.maintenance.restart_title'|trans }}</p>
<p class="mt-0.5 text-xs text-ink-faint">{{ 'admin.maintenance.restart_help'|trans }}</p>
<form method="post" action="{{ path('app_admin_system_restart_workers') }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.maintenance.restart_confirm'|trans }}" class="mt-2">
<input type="hidden" name="_token" value="{{ csrf_token('admin_restart_workers') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 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-arrows-rotate" aria-hidden="true"></i>
{{ 'admin.maintenance.restart'|trans }}
</button>
</form>
</div>
<div class="pt-4 border-t border-line">
<p class="text-sm text-ink">{{ 'admin.maintenance.restart_app_title'|trans }}</p>
<p class="mt-0.5 text-xs text-ink-faint">{{ 'admin.maintenance.restart_app_help'|trans }}</p>
{# data-turbo="false": this answers with a page, not a redirect,
because the process that would have served the redirect is the
one being restarted, and Turbo will not render a non-redirect
response to a form submission.
No data-turbo-confirm for the same reason — Turbo is not
handling this submission, so it would never fire, and an
attribute that looks like a guard but is not is worse than
none. The action is a two-second outage that recovers itself,
which is what makes that acceptable here and not on the reset
buttons. #}
<form method="post" action="{{ path('app_admin_system_restart_app') }}" data-turbo="false" class="mt-2">
<input type="hidden" name="_token" value="{{ csrf_token('admin_restart_app') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-power-off" aria-hidden="true"></i>
{{ 'admin.maintenance.restart_app'|trans }}
</button>
</form>
</div>
<div class="pt-4 border-t border-line">
<p class="text-sm text-ink">{{ 'admin.maintenance.run_title'|trans }}</p>
<p class="mt-0.5 text-xs text-ink-faint">{{ 'admin.maintenance.run_help'|trans }}</p>
<div class="mt-2 flex flex-wrap gap-2">
{% for task, label in {
'mail-sync': 'admin.maintenance.task.mail_sync',
'push-renew': 'admin.maintenance.task.push_renew',
'monitoring-prune': 'admin.maintenance.task.monitoring_prune',
'prune-blobs': 'admin.maintenance.task.prune_blobs',
} %}
<form method="post" action="{{ path('app_admin_system_run', { task: task }) }}" data-turbo-frame="_top">
<input type="hidden" name="_token" value="{{ csrf_token('admin_run_' ~ task) }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-play text-[10px]" aria-hidden="true"></i>
{{ label|trans }}
</button>
</form>
{% endfor %}
</div>
</div>
<div class="pt-4 border-t border-line">
<p class="text-sm text-ink">{{ 'admin.maintenance.heartbeats_title'|trans }}</p>
<p class="mt-0.5 text-xs text-ink-faint">{{ 'admin.maintenance.heartbeats_help'|trans }}</p>
<form method="post" action="{{ path('app_admin_system_heartbeats_prune') }}" data-turbo-frame="_top" class="mt-2">
<input type="hidden" name="_token" value="{{ csrf_token('admin_heartbeats_prune') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-broom" aria-hidden="true"></i>
{{ 'admin.maintenance.heartbeats_prune'|trans }}
</button>
</form>
</div>
</div>
{{ cards.close() }}
<div class="grid md:grid-cols-2 gap-6">
{# ── Gmail webhooks ────────────────────────────────────────── #}
{% set webhooksArmed = webhooks|filter(row => row.watchActive)|length %}
{{ cards.open('fa-satellite-dish', 'admin.webhooks.heading'|trans, 'webhooks', collapsedPanels,
'admin.summary.armed'|trans({ '%active%': webhooksArmed, '%total%': webhooks|length }),
webhooksArmed < webhooks|length ? 'warn' : null) }}
{% if webhooks|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.webhooks.empty'|trans }}</p>
{% else %}
<div class="divide-y divide-line">
{% for row in webhooks %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
{{ ui.dot(row.watchActive) }}
<span class="font-medium text-ink">{{ row.account.email }}</span>
</div>
<dl class="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-ink-muted">
<dt>{{ 'admin.webhooks.watch_expiry'|trans }}</dt>
<dd class="{{ row.watchActive ? '' : 'text-danger' }}">
{{ row.watchExpiry ? row.watchExpiry|date('Y-m-d H:i') : 'admin.webhooks.none'|trans }}
</dd>
<dt>{{ 'admin.webhooks.last_push'|trans }}</dt>
<dd>{{ row.lastPushAt ? 'admin.ago'|trans({ '%age%': row.lastPushAge }) : 'admin.webhooks.never'|trans }}</dd>
<dt>{{ 'admin.webhooks.history_id'|trans }}</dt>
<dd class="font-mono">{{ row.historyId ?? '—' }}</dd>
<dt>{{ 'admin.webhooks.resource'|trans }}</dt>
<dd class="font-mono break-all">{{ row.resourceName ?? '—' }}</dd>
<dt>{{ 'admin.webhooks.armed'|trans }}</dt>
<dd class="{{ (row.pushEnabled and row.accountActive) ? '' : 'text-danger' }}">
{{ ('admin.webhooks.state.' ~ (row.pushEnabled ? 'push_on' : 'push_off'))|trans }}
·
{{ ('admin.webhooks.state.' ~ (row.accountActive ? 'account_active' : 'account_inactive'))|trans }}
</dd>
<dt>{{ 'admin.webhooks.health'|trans }}</dt>
<dd class="{{ row.health == 'degraded' ? 'text-danger' : '' }}">
{{ ('admin.webhooks.health_state.' ~ row.health)|trans }}
</dd>
</dl>
</div>
{% endfor %}
</div>
{% endif %}
{# ── Delivery path ──────────────────────────────────────────
Watch registration and delivery fail independently: the
rows above show the former, everything here explains the
latter, which is otherwise invisible from inside plMail. #}
<div class="border-t border-line px-4 py-3">
<h3 class="text-xs font-semibold text-ink-soft">{{ 'admin.webhooks.delivery.heading'|trans }}</h3>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-ink-muted">
<dt>{{ 'admin.webhooks.delivery.topic'|trans }}</dt>
<dd class="font-mono break-all {{ pushDiagnostics.topicConfigured ? '' : 'text-danger' }}">
{{ pushDiagnostics.topic }}
</dd>
<dt>{{ 'admin.webhooks.delivery.endpoint'|trans }}</dt>
<dd class="font-mono break-all {{ (pushDiagnostics.publicUrlUsable and pushDiagnostics.tokenConfigured) ? '' : 'text-danger' }}">
{{ pushDiagnostics.endpoint }}
</dd>
</dl>
{% if not pushDiagnostics.tokenConfigured %}
<p class="mt-2 text-xs text-danger">{{ 'admin.webhooks.delivery.no_token'|trans }}</p>
{% elseif not pushDiagnostics.publicUrlUsable %}
<p class="mt-2 text-xs text-danger">{{ 'admin.webhooks.delivery.no_public_url'|trans }}</p>
{% else %}
<p class="mt-2 text-xs text-ink-faint">{{ 'admin.webhooks.delivery.endpoint_hint'|trans }}</p>
{% endif %}
{# Calendar push is a second delivery path with its own
endpoints and its own way of failing, and neither is
visible anywhere else in the app — the rows above are
mail watches only.
The two route literals stay literal — they are paths, not
prose, and a translator asked to render them would only
be given the chance to break them. #}
<p class="mt-3 text-xs text-ink-faint">
{{ 'admin.push.calendar_routes'|trans({
'%google%': '/webhook/google/calendar',
'%graph%': '/webhook/graph/calendar',
}) }}
{{ 'admin.push.calendar_domain_verification'|trans }}
{{ 'admin.push.calendar_without_public_address'|trans }}
</p>
{% if pushDiagnostics.lastFailure %}
<div class="mt-3">
<p class="text-xs font-medium text-danger">
{{ pushDiagnostics.lastFailure.message }}
{% if pushDiagnostics.lastFailure.at %}
<span class="font-normal text-ink-faint">
· {{ pushDiagnostics.lastFailure.at|date('Y-m-d H:i') }}
</span>
{% endif %}
</p>
{% if pushDiagnostics.lastFailure.context %}
<pre class="mt-1 overflow-x-auto rounded bg-pane px-2 py-1.5 text-[11px] leading-relaxed font-mono text-ink-muted">{{ pushDiagnostics.lastFailure.context|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) }}</pre>
{% endif %}
</div>
{% endif %}
{% if pushDiagnostics.events|length > 0 %}
<table class="mt-3 w-full text-xs">
<tbody>
{% for event in pushDiagnostics.events %}
<tr class="text-ink-muted">
<td class="py-0.5 pr-2 font-mono break-all">{{ event.message }}</td>
<td class="py-0.5 pr-2 text-right tabular-nums">×{{ event.count }}</td>
<td class="py-0.5 text-right whitespace-nowrap text-ink-faint">
{{ 'admin.ago'|trans({ '%age%': event.age }) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if not pushDiagnostics.deliveryLogged %}
<p class="mt-2 text-xs text-ink-faint">{{ 'admin.webhooks.delivery.info_logs_off'|trans }}</p>
{% endif %}
</div>
{{ cards.close() }}
{# ── OAuth token health ────────────────────────────────────── #}
{% set tokensFailing = tokenHealth|filter(row => not row.healthy)|length %}
{{ cards.open('fa-key', 'admin.tokens.heading'|trans, 'tokens', collapsedPanels,
tokensFailing > 0
? 'admin.summary.failing'|trans({ '%count%': tokensFailing })
: 'admin.summary.ok'|trans({ '%count%': tokenHealth|length }),
tokensFailing > 0 ? 'danger' : null) }}
{% if tokenHealth|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.tokens.empty'|trans }}</p>
{% else %}
<div class="divide-y divide-line">
{% for row in tokenHealth %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
{{ ui.dot(row.healthy) }}
<span class="font-medium text-ink">{{ row.account.email }}</span>
<span class="ml-auto text-xs text-ink-faint">
{{ row.lastRefreshAt ? 'admin.ago'|trans({ '%age%': row.lastRefreshAge }) : '—' }}
</span>
</div>
{% if row.error %}
<p class="mt-1 text-xs text-danger font-mono break-all">{{ row.error }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{{ cards.close() }}
</div>
{# ── Messenger queues ──────────────────────────────────────────── #}
{% set queueRunningTotal = queueStats|reduce((carry, row) => carry + row.running, 0) %}
{{ cards.open('fa-list-check', 'admin.queues.heading'|trans, 'queues', collapsedPanels,
'admin.summary.queue'|trans({ '%running%': queueRunningTotal, '%waiting%': waitingTotal }),
waitingTotal > 0 and queueRunningTotal == 0 ? 'warn' : null) }}
{% if queueStats|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.queues.empty'|trans }}</p>
{% else %}
{#
In flight first, and above the totals. A queue that is not
moving looks identical to a queue that is empty from the depth
column alone; the question being answered here is "what is the
worker actually doing right now", so it goes at the top and
names the handler rather than counting it.
Never paginated: there is one of these per worker process.
#}
<div class="px-4 py-3 border-b border-line bg-sunken">
<p class="text-xs font-medium text-ink-faint">
{{ 'admin.queues.in_flight'|trans }}
<span class="ml-1 text-ink-soft">({{ runningMessages|length }})</span>
</p>
</div>
{% if runningMessages|length == 0 %}
<p class="px-4 py-3 text-sm text-ink-faint border-b border-line">
{{ 'admin.queues.none_running'|trans }}
</p>
{% else %}
<div class="py-1 border-b border-line">
{% for message in runningMessages %}
{{ queue.row(message) }}
{% endfor %}
</div>
{% endif %}
<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.queues.queue'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.queues.pending'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.queues.running'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.queues.oldest'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.queues.waiting_since'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in queueStats %}
<tr class="border-t border-line text-ink-soft">
<td class="px-4 py-2 font-mono text-xs">{{ row.queue }}</td>
<td class="px-2 py-2">{{ row.pending }}</td>
<td class="px-2 py-2">
{% if row.running > 0 %}
<span class="{{ row.runningForSeconds > 300 ? 'text-warning' : 'text-ink' }}">{{ row.running }}</span>
{% else %}
<span class="text-ink-faint">0</span>
{% endif %}
</td>
<td class="px-2 py-2 text-xs">
{% if row.oldestAgeSeconds is not null %}
<span class="{{ row.oldestAgeSeconds > 300 ? 'text-warning' : '' }}">
{{ queue.duration(row.oldestAgeSeconds) }}
</span>
{% else %}
—
{% endif %}
</td>
{# Age of the oldest waiting message, which unlike
availability does not move when a retry backs off. #}
<td class="px-2 py-2 text-xs">
{% if row.waitingSinceSeconds is not null %}
<span class="{{ row.waitingSinceSeconds > 900 ? 'text-warning' : '' }}">
{{ queue.duration(row.waitingSinceSeconds) }}
</span>
{% else %}
—
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{#
The backlog. Fixed height and its own scrollbar: a queue is
unbounded, and a panel that grows with it pushes every other
panel off the screen exactly when they are worth reading.
The filter box queries the whole queue rather than the rows
already fetched — filtering the page would answer a question
nobody asked. Scrolling to the end loads the next page.
#}
<div class="border-t border-line"
data-controller="admin--queue-browser"
data-admin--queue-browser-url-value="{{ path('app_admin_queue_waiting') }}"
data-admin--queue-browser-per-page-value="{{ queuePerPage }}"
data-admin--queue-browser-total-value="{{ waitingTotal }}"
{# Placeholders left in on purpose: the controller fills
them per page without another round trip. #}
data-admin--queue-browser-count-template-value="{{ 'admin.queues.showing'|trans }}">
<div class="flex items-center gap-2 px-4 py-2 border-b border-line">
<i class="fa-solid fa-magnifying-glass text-[10px] text-ink-faint" aria-hidden="true"></i>
<input type="search"
data-admin--queue-browser-target="input"
data-action="input->admin--queue-browser#filter search->admin--queue-browser#filter"
placeholder="{{ 'admin.queues.filter_placeholder'|trans }}"
class="flex-1 bg-transparent text-xs text-ink placeholder:text-ink-faint focus:outline-none">
<span data-admin--queue-browser-target="count" class="text-xs text-ink-faint shrink-0">
{{ 'admin.queues.showing'|trans({ '%shown%': waitingMessages|length, '%total%': waitingTotal }) }}
</span>
</div>
<div data-admin--queue-browser-target="scroller"
data-action="scroll->admin--queue-browser#onScroll"
class="max-h-80 overflow-y-auto overscroll-contain">
<div data-admin--queue-browser-target="list" class="py-1 divide-y divide-line/60">
{% for message in waitingMessages %}
{{ queue.row(message) }}
{% endfor %}
</div>
{% if waitingMessages|length == 0 %}
<p data-admin--queue-browser-target="empty"
class="px-4 py-3 text-sm text-ink-faint">{{ 'admin.queues.empty'|trans }}</p>
{% endif %}
<p data-admin--queue-browser-target="loading"
class="hidden px-4 py-2 text-xs text-ink-faint">{{ 'admin.queues.loading'|trans }}</p>
</div>
</div>
{% endif %}
{{ cards.close() }}
{# ── Failed messages ───────────────────────────────────────────── #}
{{ cards.open('fa-triangle-exclamation', 'admin.failed.heading'|trans, 'failed', collapsedPanels,
failedMessages|length > 0
? 'admin.summary.failed'|trans({ '%count%': failedMessages|length })
: 'admin.summary.none'|trans,
failedMessages|length > 0 ? 'danger' : null) }}
{% if failedMessages|length == 0 %}
<p class="{{ chrome.body() }} text-sm text-ink-faint">{{ 'admin.failed.empty'|trans }}</p>
{% else %}
{# Bulk actions: a bad deploy fails messages by the hundred,
and the per-row buttons are not a recovery path. #}
<div class="flex items-center justify-end gap-2 px-4 py-2.5 border-b border-line bg-sunken">
<form method="post" action="{{ path('app_admin_system_failed_retry_all') }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.failed.retry_all_confirm'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('admin_failed_retry_all') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
{{ 'admin.failed.retry_all'|trans }}
</button>
</form>
<form method="post" action="{{ path('app_admin_system_failed_purge') }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.failed.purge_confirm'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('admin_failed_purge') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:text-danger hover:bg-danger-soft transition-colors cursor-pointer">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
{{ 'admin.failed.purge'|trans }}
</button>
</form>
</div>
<div class="divide-y divide-line">
{% for row in failedMessages %}
<div class="px-4 py-3 text-sm">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<p class="font-mono text-xs text-ink truncate">{{ row.class }}</p>
{% if row.error %}
<p class="mt-1 text-xs text-danger font-mono break-all line-clamp-2">{{ row.error }}</p>
{% endif %}
{% if row.failedAt %}
<p class="mt-1 text-xs text-ink-faint">{{ row.failedAt|date('Y-m-d H:i:s') }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
<form method="post" action="{{ path('app_admin_failed_retry', { id: row.id }) }}" data-turbo-frame="_top">
<input type="hidden" name="_token" value="{{ csrf_token('admin_failed_' ~ row.id) }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 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-rotate-right" aria-hidden="true"></i>
{{ 'admin.failed.retry'|trans }}
</button>
</form>
<form method="post" action="{{ path('app_admin_failed_delete', { id: row.id }) }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.failed.delete_confirm'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('admin_failed_' ~ row.id) }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:text-danger hover:bg-danger-soft transition-colors cursor-pointer">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
{{ 'admin.failed.delete'|trans }}
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{{ cards.close() }}
<div class="grid md:grid-cols-2 gap-6">
{# ── Accounts ──────────────────────────────────────────────── #}
{{ cards.open('fa-at', 'admin.accounts.heading'|trans, 'accounts', collapsedPanels,
'admin.summary.accounts'|trans({ '%count%': accountOverview|length })) }}
<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.accounts.account'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.accounts.threads'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.accounts.messages'|trans }}</th>
<th class="px-2 py-2 font-medium">{{ 'admin.accounts.last_activity'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in accountOverview %}
<tr class="border-t border-line text-ink-soft {{ row.is_active ? '' : 'opacity-50' }}">
<td class="px-4 py-2 text-xs truncate max-w-40">{{ row.email }}</td>
<td class="px-2 py-2 text-xs">{{ row.threads }}</td>
<td class="px-2 py-2 text-xs">{{ row.messages }}</td>
<td class="px-2 py-2 text-xs">{{ row.last_activity ? row.last_activity|date('Y-m-d H:i') : '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ cards.close() }}
{# ── Table sizes ───────────────────────────────────────────── #}
{{ cards.open('fa-database', 'admin.tables.heading'|trans, 'tables', collapsedPanels,
'admin.summary.tables'|trans({ '%count%': tableSizes|length })) }}
<table class="w-full text-sm">
<tbody>
{% for row in tableSizes %}
<tr class="border-t border-line first:border-t-0 text-ink-soft">
<td class="px-4 py-2 font-mono text-xs">{{ row.table }}</td>
<td class="px-2 py-2 text-xs text-right pr-4">{{ row.size }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ cards.close() }}
</div>
</div>
</turbo-frame>