{# Auto-refreshed live panels. Wrapped in the same turbo-frame id so frame.reload() swaps the whole block. #} {% macro dot(healthy) %} {% 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 %}
{# ── 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 %}

{{ 'admin.maintenance.restart_pending'|trans({ '%seconds%': restartRequestedAgo }) }}

{% endif %} {% if heartbeats|length == 0 %}

{{ 'admin.processes.empty'|trans }}

{% else %} {% for row in heartbeats %} {% endfor %}
{{ 'admin.processes.type'|trans }} {{ 'admin.processes.key'|trans }} {{ 'admin.processes.pid'|trans }} {{ 'admin.processes.last_beat'|trans }}
{{ ui.dot(row.healthy) }} {{ row.type }} {{ row.key }} {{ row.pid ?? '—' }} {{ 'admin.ago'|trans({ '%age%': row.age }) }}
{% endif %} {{ cards.close() }} {# ── Maintenance ───────────────────────────────────────────────── #} {{ cards.open('fa-screwdriver-wrench', 'admin.maintenance.heading'|trans, 'maintenance', collapsedPanels) }}

{{ 'admin.maintenance.restart_title'|trans }}

{{ 'admin.maintenance.restart_help'|trans }}

{{ 'admin.maintenance.restart_app_title'|trans }}

{{ 'admin.maintenance.restart_app_help'|trans }}

{# 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. #}

{{ 'admin.maintenance.run_title'|trans }}

{{ 'admin.maintenance.run_help'|trans }}

{% 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', } %}
{% endfor %}

{{ 'admin.maintenance.heartbeats_title'|trans }}

{{ 'admin.maintenance.heartbeats_help'|trans }}

{{ cards.close() }}
{# ── 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 %}

{{ 'admin.webhooks.empty'|trans }}

{% else %}
{% for row in webhooks %}
{{ ui.dot(row.watchActive) }} {{ row.account.email }}
{{ 'admin.webhooks.watch_expiry'|trans }}
{{ row.watchExpiry ? row.watchExpiry|date('Y-m-d H:i') : 'admin.webhooks.none'|trans }}
{{ 'admin.webhooks.last_push'|trans }}
{{ row.lastPushAt ? 'admin.ago'|trans({ '%age%': row.lastPushAge }) : 'admin.webhooks.never'|trans }}
{{ 'admin.webhooks.history_id'|trans }}
{{ row.historyId ?? '—' }}
{{ 'admin.webhooks.resource'|trans }}
{{ row.resourceName ?? '—' }}
{{ 'admin.webhooks.armed'|trans }}
{{ ('admin.webhooks.state.' ~ (row.pushEnabled ? 'push_on' : 'push_off'))|trans }} · {{ ('admin.webhooks.state.' ~ (row.accountActive ? 'account_active' : 'account_inactive'))|trans }}
{{ 'admin.webhooks.health'|trans }}
{{ ('admin.webhooks.health_state.' ~ row.health)|trans }}
{% endfor %}
{% 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. #}

{{ 'admin.webhooks.delivery.heading'|trans }}

{{ 'admin.webhooks.delivery.topic'|trans }}
{{ pushDiagnostics.topic }}
{{ 'admin.webhooks.delivery.endpoint'|trans }}
{{ pushDiagnostics.endpoint }}
{% if not pushDiagnostics.tokenConfigured %}

{{ 'admin.webhooks.delivery.no_token'|trans }}

{% elseif not pushDiagnostics.publicUrlUsable %}

{{ 'admin.webhooks.delivery.no_public_url'|trans }}

{% else %}

{{ 'admin.webhooks.delivery.endpoint_hint'|trans }}

{% 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. #}

{{ '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 }}

{% if pushDiagnostics.lastFailure %}

{{ pushDiagnostics.lastFailure.message }} {% if pushDiagnostics.lastFailure.at %} · {{ pushDiagnostics.lastFailure.at|date('Y-m-d H:i') }} {% endif %}

{% if pushDiagnostics.lastFailure.context %}
{{ pushDiagnostics.lastFailure.context|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) }}
{% endif %}
{% endif %} {% if pushDiagnostics.events|length > 0 %} {% for event in pushDiagnostics.events %} {% endfor %}
{{ event.message }} ×{{ event.count }} {{ 'admin.ago'|trans({ '%age%': event.age }) }}
{% endif %} {% if not pushDiagnostics.deliveryLogged %}

{{ 'admin.webhooks.delivery.info_logs_off'|trans }}

{% endif %}
{{ 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 %}

{{ 'admin.tokens.empty'|trans }}

{% else %}
{% for row in tokenHealth %}
{{ ui.dot(row.healthy) }} {{ row.account.email }} {{ row.lastRefreshAt ? 'admin.ago'|trans({ '%age%': row.lastRefreshAge }) : '—' }}
{% if row.error %}

{{ row.error }}

{% endif %}
{% endfor %}
{% endif %} {{ cards.close() }}
{# ── 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 %}

{{ 'admin.queues.empty'|trans }}

{% 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. #}

{{ 'admin.queues.in_flight'|trans }} ({{ runningMessages|length }})

{% if runningMessages|length == 0 %}

{{ 'admin.queues.none_running'|trans }}

{% else %}
{% for message in runningMessages %} {{ queue.row(message) }} {% endfor %}
{% endif %} {% for row in queueStats %} {# Age of the oldest waiting message, which unlike availability does not move when a retry backs off. #} {% endfor %}
{{ 'admin.queues.queue'|trans }} {{ 'admin.queues.pending'|trans }} {{ 'admin.queues.running'|trans }} {{ 'admin.queues.oldest'|trans }} {{ 'admin.queues.waiting_since'|trans }}
{{ row.queue }} {{ row.pending }} {% if row.running > 0 %} {{ row.running }} {% else %} 0 {% endif %} {% if row.oldestAgeSeconds is not null %} {{ queue.duration(row.oldestAgeSeconds) }} {% else %} — {% endif %} {% if row.waitingSinceSeconds is not null %} {{ queue.duration(row.waitingSinceSeconds) }} {% else %} — {% endif %}
{# 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. #}
{{ 'admin.queues.showing'|trans({ '%shown%': waitingMessages|length, '%total%': waitingTotal }) }}
{% for message in waitingMessages %} {{ queue.row(message) }} {% endfor %}
{% if waitingMessages|length == 0 %}

{{ 'admin.queues.empty'|trans }}

{% endif %}
{% 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 %}

{{ 'admin.failed.empty'|trans }}

{% else %} {# Bulk actions: a bad deploy fails messages by the hundred, and the per-row buttons are not a recovery path. #}
{% for row in failedMessages %}

{{ row.class }}

{% if row.error %}

{{ row.error }}

{% endif %} {% if row.failedAt %}

{{ row.failedAt|date('Y-m-d H:i:s') }}

{% endif %}
{% endfor %}
{% endif %} {{ cards.close() }}
{# ── Accounts ──────────────────────────────────────────────── #} {{ cards.open('fa-at', 'admin.accounts.heading'|trans, 'accounts', collapsedPanels, 'admin.summary.accounts'|trans({ '%count%': accountOverview|length })) }} {% for row in accountOverview %} {% endfor %}
{{ 'admin.accounts.account'|trans }} {{ 'admin.accounts.threads'|trans }} {{ 'admin.accounts.messages'|trans }} {{ 'admin.accounts.last_activity'|trans }}
{{ row.email }} {{ row.threads }} {{ row.messages }} {{ row.last_activity ? row.last_activity|date('Y-m-d H:i') : '—' }}
{{ cards.close() }} {# ── Table sizes ───────────────────────────────────────────── #} {{ cards.open('fa-database', 'admin.tables.heading'|trans, 'tables', collapsedPanels, 'admin.summary.tables'|trans({ '%count%': tableSizes|length })) }} {% for row in tableSizes %} {% endfor %}
{{ row.table }} {{ row.size }}
{{ cards.close() }}