mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
562 lines
36 KiB
Twig
562 lines
36 KiB
Twig
{# 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>
|