From b4bf06a8f3e47a4439033bcdc549f42a2807e8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Tue, 21 Jul 2026 11:57:33 +0200 Subject: [PATCH] add an admin dashboard --- .env | 3 + assets/controllers/auto_refresh_controller.js | 51 ++++ compose.yaml | 3 + config/packages/monolog.yaml | 8 + migrations/Version20260721092957.php | 43 ++++ src/Command/ImapIdleCommand.php | 70 ++++-- src/Command/ImapSuperviseCommand.php | 8 +- src/Command/PromoteUserCommand.php | 74 ++++++ src/Command/PruneMonitoringDataCommand.php | 60 +++++ .../Admin/AdminDashboardController.php | 122 +++++++++ src/Controller/GmailPushController.php | 10 +- src/Entity/Account.php | 52 ++++ src/Entity/LogEntry.php | 50 ++++ src/Entity/ProcessHeartbeat.php | 41 ++++ src/EventListener/WorkerHeartbeatListener.php | 47 ++++ src/Logging/DoctrineLogHandler.php | 57 +++++ src/Repository/LogEntryRepository.php | 77 ++++++ src/Repository/ProcessHeartbeatRepository.php | 29 +++ .../Monitoring/AdminMonitoringService.php | 220 +++++++++++++++++ .../Monitoring/ProcessHeartbeatService.php | 56 +++++ src/Service/Monitoring/QueueMonitor.php | 143 +++++++++++ src/Service/OAuth/OAuthProviderFactory.php | 1 - src/Service/OAuth/OAuthTokenManager.php | 37 ++- templates/_partials/_topbar.html.twig | 6 + templates/admin/_live_frame.html.twig | 232 ++++++++++++++++++ templates/admin/_logs_frame.html.twig | 85 +++++++ templates/admin/index.html.twig | 72 ++++++ translations/messages.en.yaml | 54 +++- 28 files changed, 1680 insertions(+), 31 deletions(-) create mode 100644 assets/controllers/auto_refresh_controller.js create mode 100644 migrations/Version20260721092957.php create mode 100644 src/Command/PromoteUserCommand.php create mode 100644 src/Command/PruneMonitoringDataCommand.php create mode 100644 src/Controller/Admin/AdminDashboardController.php create mode 100644 src/Entity/LogEntry.php create mode 100644 src/Entity/ProcessHeartbeat.php create mode 100644 src/EventListener/WorkerHeartbeatListener.php create mode 100644 src/Logging/DoctrineLogHandler.php create mode 100644 src/Repository/LogEntryRepository.php create mode 100644 src/Repository/ProcessHeartbeatRepository.php create mode 100644 src/Service/Monitoring/AdminMonitoringService.php create mode 100644 src/Service/Monitoring/ProcessHeartbeatService.php create mode 100644 src/Service/Monitoring/QueueMonitor.php create mode 100644 templates/admin/_live_frame.html.twig create mode 100644 templates/admin/_logs_frame.html.twig create mode 100644 templates/admin/index.html.twig diff --git a/.env b/.env index 1c4afb6d..d520c1d3 100644 --- a/.env +++ b/.env @@ -72,3 +72,6 @@ GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= GMAIL_PUBSUB_TOPIC=projects/plMail/topics/gmail-push ###< google oauth ### + +APP_DB_LOG_LEVEL=warning +APP_CONTAINER_NAME=web diff --git a/assets/controllers/auto_refresh_controller.js b/assets/controllers/auto_refresh_controller.js new file mode 100644 index 00000000..3aba2b03 --- /dev/null +++ b/assets/controllers/auto_refresh_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Periodically reloads the turbo-frame it is attached to. Pauses while the + * tab is hidden so a backgrounded dashboard doesn't hammer the server. + */ +export default class extends Controller { + static values = { + interval: { type: Number, default: 10000 }, + }; + + connect() { + this.onVisibilityChange = this.onVisibilityChange.bind(this); + document.addEventListener('visibilitychange', this.onVisibilityChange); + this.start(); + } + + disconnect() { + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.stop(); + } + + start() { + this.stop(); + this.timer = setInterval(() => { + this.reload(); + }, this.intervalValue); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + reload() { + if (typeof this.element.reload === 'function') { + this.element.reload(); + } + } + + onVisibilityChange() { + if (document.hidden) { + this.stop(); + } else { + this.reload(); + this.start(); + } + } +} diff --git a/compose.yaml b/compose.yaml index 013de9de..3b3743a8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,6 +18,7 @@ services: <<: *app-env SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 MAILER_DSN: ${MAILER_DSN:-null://null} + APP_CONTAINER_NAME: web volumes: - caddy_data:/data - caddy_config:/config @@ -42,6 +43,7 @@ services: stop_grace_period: 30s environment: <<: *app-env + APP_CONTAINER_NAME: imap-supervisor depends_on: database: condition: service_healthy @@ -53,6 +55,7 @@ services: stop_grace_period: 15s environment: <<: *app-env + APP_CONTAINER_NAME: messenger-worker depends_on: database: condition: service_healthy diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index bb597086..fcd4fbd6 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -14,6 +14,10 @@ when@dev: type: console process_psr_3_messages: false channels: ["!event", "!doctrine", "!console"] + database: + type: service + id: App\Logging\DoctrineLogHandler + channels: [ "!event", "!doctrine", "!php", "!deprecation", "!cache" ] when@test: monolog: @@ -53,3 +57,7 @@ when@prod: channels: [deprecation] path: php://stderr formatter: monolog.formatter.json + database: + type: service + id: App\Logging\DoctrineLogHandler + channels: [ "!event", "!doctrine", "!php", "!deprecation", "!cache" ] diff --git a/migrations/Version20260721092957.php b/migrations/Version20260721092957.php new file mode 100644 index 00000000..4e3e0495 --- /dev/null +++ b/migrations/Version20260721092957.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE log_entry (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, channel VARCHAR(64) NOT NULL, level INT NOT NULL, level_name VARCHAR(32) NOT NULL, message TEXT NOT NULL, context JSON DEFAULT NULL, source VARCHAR(64) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_log_entry_created_at ON log_entry (created_at)'); + $this->addSql('CREATE INDEX idx_log_entry_level ON log_entry (level)'); + $this->addSql('CREATE INDEX idx_log_entry_channel ON log_entry (channel)'); + $this->addSql('CREATE TABLE process_heartbeat (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(64) NOT NULL, beat_key VARCHAR(128) NOT NULL, pid INT DEFAULT NULL, last_beat_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, meta JSON DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_heartbeat_type_key ON process_heartbeat (type, beat_key)'); + $this->addSql('ALTER TABLE account ADD gmail_last_push_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE account ADD oauth_last_refresh_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE account ADD oauth_last_refresh_error TEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE log_entry'); + $this->addSql('DROP TABLE process_heartbeat'); + $this->addSql('ALTER TABLE account DROP gmail_last_push_at'); + $this->addSql('ALTER TABLE account DROP oauth_last_refresh_at'); + $this->addSql('ALTER TABLE account DROP oauth_last_refresh_error'); + } +} diff --git a/src/Command/ImapIdleCommand.php b/src/Command/ImapIdleCommand.php index 5ebb7d18..b979addb 100644 --- a/src/Command/ImapIdleCommand.php +++ b/src/Command/ImapIdleCommand.php @@ -5,6 +5,7 @@ namespace App\Command; use App\Domain\Helper\ImapConnectionFactory; use App\Message\SyncImapMailboxMessage; use App\Repository\MailboxRepository; +use App\Service\Monitoring\ProcessHeartbeatService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -20,23 +21,28 @@ use Webklex\PHPIMAP\Connection\Protocols\Response; )] class ImapIdleCommand extends Command { - private const IDLE_TIMEOUT = 1740; // 29 minutes in seconds (RFC max is 30) - private const RECONNECT_DELAY = 5; // seconds between reconnection attempts - private const MAX_RETRIES = 10; + private const IDLE_TIMEOUT = 1740; // 29 minutes in seconds (RFC max is 30) + private const RECONNECT_DELAY = 5; // seconds between reconnection attempts + private const MAX_RETRIES = 10; + private const HEARTBEAT_MIN_GAP = 30; // min seconds between idle-loop keepalive beats private bool $shouldStop = false; + /** Unix time of the last heartbeat, to throttle the idle-loop keepalive. */ + private int $lastBeatAt = 0; + public function __construct( - private readonly MailboxRepository $mailboxRepository, - private readonly MessageBusInterface $bus, - private readonly ImapConnectionFactory $imapConnectionFactory, + private readonly MailboxRepository $mailboxRepository, + private readonly MessageBusInterface $bus, + private readonly ImapConnectionFactory $imapConnectionFactory, + private readonly ProcessHeartbeatService $heartbeats, ) { parent::__construct(); } private function registerSignalHandlers(SymfonyStyle $io): void { - if (!function_exists('pcntl_async_signals')) { + if (false === function_exists('pcntl_async_signals')) { $io->warning('pcntl extension not available — signal handling disabled'); return; } @@ -64,12 +70,12 @@ class ImapIdleCommand extends Command $mailboxId = (int) $input->getArgument('mailbox-id'); $mailbox = $this->mailboxRepository->find($mailboxId); - if ($mailbox === null) { + if (null === $mailbox) { $io->error(sprintf('Mailbox %d not found.', $mailboxId)); return Command::FAILURE; } - if (!$mailbox->isIdleEnabled() || !$mailbox->isSyncEnabled()) { + if (false === $mailbox->isIdleEnabled() || false === $mailbox->isSyncEnabled()) { $io->error('Mailbox is not enabled for IDLE.'); return Command::FAILURE; } @@ -84,12 +90,12 @@ class ImapIdleCommand extends Command $retries = 0; - while (!$this->shouldStop) { + while (false === $this->shouldStop) { try { $this->idle($mailboxId, $io); $retries = 0; } catch (\Throwable $e) { - if ($this->shouldStop) { + if (true === $this->shouldStop) { break; } @@ -124,11 +130,13 @@ class ImapIdleCommand extends Command $client = $this->imapConnectionFactory->connect($account); $folder = $client->getFolder($mailbox->getName()); - if ($folder === null) { + if (null === $folder) { $client->disconnect(); throw new \RuntimeException(sprintf('Folder "%s" not found.', $mailbox->getName())); } + $this->beat($mailbox, $account, true); + $connection = $client->getConnection(); $connection->selectFolder($folder->path); $connection->idle(); @@ -138,7 +146,8 @@ class ImapIdleCommand extends Command $startTime = time(); while (true) { - if ($this->shouldStop) { + + if (true === $this->shouldStop) { $io->text(sprintf('[%s] Shutdown requested — closing IDLE cleanly.', date('H:i:s'))); $connection->done(); $client->disconnect(); @@ -149,26 +158,31 @@ class ImapIdleCommand extends Command $io->text(sprintf('[%s] IDLE timeout — reconnecting.', date('H:i:s'))); $connection->done(); $client->disconnect(); + $this->beat($mailbox, $account, true); return; } try { $line = $connection->nextLine(new Response(0, false)); } catch (\Throwable $e) { - if ($this->shouldStop) { + if (true === $this->shouldStop) { $connection->done(); $client->disconnect(); return; } - if (str_contains($e->getMessage(), 'empty response')) { - // Stream read timed out — normal, just keep looping + if (true === str_contains($e->getMessage(), 'empty response')) { + // Stream read timed out — the normal quiet-mailbox case. + // Beat (throttled) so liveness is detected far faster than + // the 29-minute IDLE re-issue would allow. + $this->beat($mailbox, $account); continue; } throw $e; } - if (str_contains($line, 'EXISTS')) { + if (true === str_contains($line, 'EXISTS')) { + $this->beat($mailbox, $account, true); $io->text(sprintf('[%s] Notification received — dispatching sync.', date('H:i:s'))); try { $envelope = $this->bus->dispatch(new SyncImapMailboxMessage($mailboxId)); @@ -179,4 +193,26 @@ class ImapIdleCommand extends Command } } } + + /** + * Record a liveness heartbeat for this mailbox's IDLE process. Lifecycle + * beats (connect, timeout re-issue, EXISTS wake) pass $force so they + * always land; the idle-loop keepalive is throttled to HEARTBEAT_MIN_GAP. + */ + private function beat(object $mailbox, object $account, bool $force = false): void + { + $now = time(); + + if (false === $force && ($now - $this->lastBeatAt) < self::HEARTBEAT_MIN_GAP) { + return; + } + + $this->lastBeatAt = $now; + + $this->heartbeats->beat( + ProcessHeartbeatService::TYPE_IMAP_IDLE, + (string) $mailbox->getId(), + ['mailbox' => $mailbox->getFullPath(), 'account' => $account->getEmail()], + ); + } } diff --git a/src/Command/ImapSuperviseCommand.php b/src/Command/ImapSuperviseCommand.php index 4116ce8d..148a04b5 100644 --- a/src/Command/ImapSuperviseCommand.php +++ b/src/Command/ImapSuperviseCommand.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Command; use App\Repository\MailboxRepository; +use App\Service\Monitoring\ProcessHeartbeatService; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -42,6 +43,7 @@ final class ImapSuperviseCommand extends Command public function __construct( private readonly MailboxRepository $mailboxRepository, + private readonly ProcessHeartbeatService $heartbeats, private readonly LoggerInterface $logger, #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, ) { @@ -97,7 +99,11 @@ final class ImapSuperviseCommand extends Command $this->reconcile($io); $lastPollAt = microtime(true); } - + $this->heartbeats->beat( + ProcessHeartbeatService::TYPE_IMAP_SUPERVISE, + 'main', + ['children' => count($this->processes)], + ); usleep(500000); } diff --git a/src/Command/PromoteUserCommand.php b/src/Command/PromoteUserCommand.php new file mode 100644 index 00000000..ae0de386 --- /dev/null +++ b/src/Command/PromoteUserCommand.php @@ -0,0 +1,74 @@ +addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote') + ->addOption('revoke', null, InputOption::VALUE_NONE, 'Revoke ROLE_ADMIN instead of granting it'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $email = (string) $input->getArgument('email'); + $revoke = true === (bool) $input->getOption('revoke'); + + $user = $this->userRepository->findOneBy(['email' => $email]); + + if (null === $user) { + $io->error(sprintf('No user found for email "%s".', $email)); + + return Command::FAILURE; + } + + $roles = $user->getRoles(); + + if (true === $revoke) { + $roles = array_values(array_diff($roles, ['ROLE_ADMIN'])); + } else { + if (false === in_array('ROLE_ADMIN', $roles, true)) { + $roles[] = 'ROLE_ADMIN'; + } + } + + // ROLE_USER is implied by getRoles(); persist without it to keep the + // stored list minimal. + $user->setRoles(array_values(array_diff($roles, ['ROLE_USER']))); + $this->em->flush(); + + $io->success(sprintf( + '%s ROLE_ADMIN for %s.', + true === $revoke ? 'Revoked' : 'Granted', + $email, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Command/PruneMonitoringDataCommand.php b/src/Command/PruneMonitoringDataCommand.php new file mode 100644 index 00000000..eda4342f --- /dev/null +++ b/src/Command/PruneMonitoringDataCommand.php @@ -0,0 +1,60 @@ +addOption('days', null, InputOption::VALUE_REQUIRED, 'Log retention in days', '14') + ->addOption('heartbeat-days', null, InputOption::VALUE_REQUIRED, 'Heartbeat retention in days', '30'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $logDays = max(1, (int) $input->getOption('days')); + $heartbeatDays = max(1, (int) $input->getOption('heartbeat-days')); + + $prunedLogs = $this->logEntryRepository->pruneOlderThan( + new \DateTimeImmutable(sprintf('-%d days', $logDays)), + ); + + $prunedHeartbeats = $this->heartbeats->pruneOlderThan( + new \DateTimeImmutable(sprintf('-%d days', $heartbeatDays)), + ); + + $io->success(sprintf( + 'Pruned %d log entries (older than %dd) and %d dead heartbeats (older than %dd).', + $prunedLogs, + $logDays, + $prunedHeartbeats, + $heartbeatDays, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/AdminDashboardController.php b/src/Controller/Admin/AdminDashboardController.php new file mode 100644 index 00000000..b3741b7d --- /dev/null +++ b/src/Controller/Admin/AdminDashboardController.php @@ -0,0 +1,122 @@ + 'info', + 250 => 'notice', + 300 => 'warning', + 400 => 'error', + 500 => 'critical', + ]; + + public function __construct( + private readonly AdminMonitoringService $monitoring, + private readonly QueueMonitor $queueMonitor, + private readonly LogEntryRepository $logEntryRepository, + ) {} + + #[Route('', name: 'dashboard')] + public function index(): Response + { + return $this->render('admin/index.html.twig'); + } + + /** + * Auto-refreshed live panels. Loaded (and re-loaded) as a Turbo Frame. + */ + #[Route('/live', name: 'live')] + public function live(): Response + { + return $this->render('admin/_live_frame.html.twig', [ + 'heartbeats' => $this->monitoring->heartbeats(), + 'webhooks' => $this->monitoring->gmailWebhooks(), + 'tokenHealth' => $this->monitoring->tokenHealth(), + 'queueStats' => $this->queueMonitor->queueStats(), + 'failedMessages' => $this->queueMonitor->failedMessages(), + 'accountOverview' => $this->monitoring->accountOverview(), + 'tableSizes' => $this->monitoring->tableSizes(), + ]); + } + + /** + * Filterable log browser. Its own frame, not auto-refreshed, so reading + * a stack trace doesn't get yanked away mid-scroll. + */ + #[Route('/logs', name: 'logs')] + public function logs(Request $request): Response + { + $minLevel = (int) $request->query->get('level', 300); + + if (false === array_key_exists($minLevel, self::LOG_LEVELS)) { + $minLevel = 300; + } + + $channel = trim((string) $request->query->get('channel', '')); + $page = max(1, (int) $request->query->get('page', 1)); + $offset = ($page - 1) * self::LOGS_PER_PAGE; + + $entries = $this->logEntryRepository->search($minLevel, $channel, self::LOGS_PER_PAGE, $offset); + $total = $this->logEntryRepository->countSearch($minLevel, $channel); + + return $this->render('admin/_logs_frame.html.twig', [ + 'entries' => $entries, + 'total' => $total, + 'page' => $page, + 'pages' => max(1, (int) ceil($total / self::LOGS_PER_PAGE)), + 'minLevel' => $minLevel, + 'channel' => $channel, + 'levels' => self::LOG_LEVELS, + 'channels' => $this->logEntryRepository->distinctChannels(), + ]); + } + + #[Route('/failed/{id}/retry', name: 'failed_retry', methods: ['POST'])] + public function retryFailed(Request $request, string $id): Response + { + $this->validateCsrf($request, $id); + + $this->queueMonitor->retry($id); + + return $this->redirectToRoute('app_admin_dashboard'); + } + + #[Route('/failed/{id}/delete', name: 'failed_delete', methods: ['POST'])] + public function deleteFailed(Request $request, string $id): Response + { + $this->validateCsrf($request, $id); + + $this->queueMonitor->remove($id); + + return $this->redirectToRoute('app_admin_dashboard'); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function validateCsrf(Request $request, string $id): void + { + $token = (string) $request->request->get('_token', ''); + + if (false === $this->isCsrfTokenValid('admin_failed_' . $id, $token)) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + } +} diff --git a/src/Controller/GmailPushController.php b/src/Controller/GmailPushController.php index 7195e746..8f008e51 100644 --- a/src/Controller/GmailPushController.php +++ b/src/Controller/GmailPushController.php @@ -6,6 +6,8 @@ namespace App\Controller; use App\Message\SyncAccountMessage; use App\Repository\AccountRepository; +use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -43,6 +45,7 @@ final class GmailPushController extends AbstractController private readonly AccountRepository $accountRepository, private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger, + private readonly EntityManagerInterface $entityManager, ) {} public function __invoke(Request $request): Response @@ -76,8 +79,8 @@ final class GmailPushController extends AbstractController return new Response('', Response::HTTP_OK); } - $emailAddress = (string) ($data['emailAddress'] ?? ''); - $historyId = (string) ($data['historyId'] ?? ''); + $emailAddress = $data['emailAddress'] ?? ''; + $historyId = $data['historyId'] ?? ''; $this->logger->info('GmailPush: received notification', [ 'emailAddress' => $emailAddress, @@ -99,6 +102,9 @@ final class GmailPushController extends AbstractController return new Response('', Response::HTTP_OK); } + $account->setGmailLastPushAt(new DateTimeImmutable()); + $this->entityManager->flush(); + // Find the inbox mailbox for this account $this->bus->dispatch(new SyncAccountMessage($account->getId())); diff --git a/src/Entity/Account.php b/src/Entity/Account.php index da1fd891..41f5c205 100644 --- a/src/Entity/Account.php +++ b/src/Entity/Account.php @@ -120,6 +120,20 @@ class Account extends AccountModel #[ORM\Column(type: Types::JSON, options: ['jsonb' => true, 'default' => '{}'])] private array $settings = []; + /** + * Last time Google's Pub/Sub push actually reached /gmail/push for this + * account — distinguishes "watch registered but subscription broken" + * from "healthy but quiet". + */ + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $gmailLastPushAt = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $oauthLastRefreshAt = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $oauthLastRefreshError = null; + public function __construct() { $this->mailboxes = new ArrayCollection(); @@ -484,6 +498,7 @@ class Account extends AccountModel return $this->gmailWatchExpiry > new \DateTimeImmutable(); } + public function getSetting(string $key, mixed $default = null): mixed { if (true === array_key_exists($key, $this->settings)) { @@ -499,6 +514,43 @@ class Account extends AccountModel return $this; } + + public function getGmailLastPushAt(): ?\DateTimeImmutable + { + return $this->gmailLastPushAt; + } + + public function setGmailLastPushAt(?\DateTimeImmutable $gmailLastPushAt): static + { + $this->gmailLastPushAt = $gmailLastPushAt; + + return $this; + } + + public function getOauthLastRefreshAt(): ?\DateTimeImmutable + { + return $this->oauthLastRefreshAt; + } + + public function setOauthLastRefreshAt(?\DateTimeImmutable $oauthLastRefreshAt): static + { + $this->oauthLastRefreshAt = $oauthLastRefreshAt; + + return $this; + } + + public function getOauthLastRefreshError(): ?string + { + return $this->oauthLastRefreshError; + } + + public function setOauthLastRefreshError(?string $oauthLastRefreshError): static + { + $this->oauthLastRefreshError = $oauthLastRefreshError; + + return $this; + } + public function isGmail(): bool { return AuthType::OAuth2->value === $this->authType diff --git a/src/Entity/LogEntry.php b/src/Entity/LogEntry.php new file mode 100644 index 00000000..28ec0661 --- /dev/null +++ b/src/Entity/LogEntry.php @@ -0,0 +1,50 @@ +|null */ + #[ORM\Column(type: 'json', nullable: true)] + public ?array $context = null; + + /** Which container produced this (APP_CONTAINER_NAME). */ + #[ORM\Column(length: 64, nullable: true)] + public ?string $source = null; + + #[ORM\Column] + public ?\DateTimeImmutable $createdAt = null; +} diff --git a/src/Entity/ProcessHeartbeat.php b/src/Entity/ProcessHeartbeat.php new file mode 100644 index 00000000..67328606 --- /dev/null +++ b/src/Entity/ProcessHeartbeat.php @@ -0,0 +1,41 @@ +|null */ + #[ORM\Column(type: 'json', nullable: true)] + public ?array $meta = null; +} diff --git a/src/EventListener/WorkerHeartbeatListener.php b/src/EventListener/WorkerHeartbeatListener.php new file mode 100644 index 00000000..3cb7a086 --- /dev/null +++ b/src/EventListener/WorkerHeartbeatListener.php @@ -0,0 +1,47 @@ +lastBeatAt) < self::INTERVAL_SECONDS) { + return; + } + + $this->lastBeatAt = $now; + + $hostname = gethostname(); + + $this->heartbeats->beat( + ProcessHeartbeatService::TYPE_MESSENGER_WORKER, + false !== $hostname ? $hostname : 'worker', + ['idle' => $event->isWorkerIdle()], + ); + } +} diff --git a/src/Logging/DoctrineLogHandler.php b/src/Logging/DoctrineLogHandler.php new file mode 100644 index 00000000..f8268a9b --- /dev/null +++ b/src/Logging/DoctrineLogHandler.php @@ -0,0 +1,57 @@ +context, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); + + $this->connection->insert('log_entry', [ + 'channel' => mb_substr($record->channel, 0, 64), + 'level' => $record->level->value, + 'level_name' => mb_substr($record->level->getName(), 0, 32), + 'message' => mb_substr($record->message, 0, self::MAX_MESSAGE_LENGTH), + 'context' => false !== $context ? $context : null, + 'source' => mb_substr($this->source, 0, 64), + 'created_at' => $record->datetime->format('Y-m-d H:i:s'), + ]); + } catch (\Throwable) { + // Never let logging failures cascade. + } + } +} diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php new file mode 100644 index 00000000..21f3f5b1 --- /dev/null +++ b/src/Repository/LogEntryRepository.php @@ -0,0 +1,77 @@ + + */ + public function search(int $minLevel, ?string $channel, int $limit, int $offset): array + { + $qb = $this->createQueryBuilder('l') + ->where('l.level >= :minLevel') + ->setParameter('minLevel', $minLevel) + ->orderBy('l.createdAt', 'DESC') + ->addOrderBy('l.id', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + if (null !== $channel && '' !== $channel) { + $qb->andWhere('l.channel = :channel') + ->setParameter('channel', $channel); + } + + return $qb->getQuery()->getResult(); + } + + public function countSearch(int $minLevel, ?string $channel): int + { + $qb = $this->createQueryBuilder('l') + ->select('COUNT(l.id)') + ->where('l.level >= :minLevel') + ->setParameter('minLevel', $minLevel); + + if (null !== $channel && '' !== $channel) { + $qb->andWhere('l.channel = :channel') + ->setParameter('channel', $channel); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * @return list + */ + public function distinctChannels(): array + { + $rows = $this->createQueryBuilder('l') + ->select('DISTINCT l.channel') + ->orderBy('l.channel', 'ASC') + ->getQuery() + ->getScalarResult(); + + return array_values(array_map(static fn (array $row): string => (string) $row['channel'], $rows)); + } + + public function pruneOlderThan(\DateTimeImmutable $cutoff): int + { + return (int) $this->createQueryBuilder('l') + ->delete() + ->where('l.createdAt < :cutoff') + ->setParameter('cutoff', $cutoff) + ->getQuery() + ->execute(); + } +} diff --git a/src/Repository/ProcessHeartbeatRepository.php b/src/Repository/ProcessHeartbeatRepository.php new file mode 100644 index 00000000..7ebda7fa --- /dev/null +++ b/src/Repository/ProcessHeartbeatRepository.php @@ -0,0 +1,29 @@ + + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('h') + ->orderBy('h.type', 'ASC') + ->addOrderBy('h.key', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/Monitoring/AdminMonitoringService.php b/src/Service/Monitoring/AdminMonitoringService.php new file mode 100644 index 00000000..4dfd05ef --- /dev/null +++ b/src/Service/Monitoring/AdminMonitoringService.php @@ -0,0 +1,220 @@ + 2100, // just over the 29-min IDLE reissue + ProcessHeartbeatService::TYPE_IMAP_SUPERVISE => 300, + ProcessHeartbeatService::TYPE_MESSENGER_WORKER => 120, // listener beats every 30s + ]; + + private const int DEFAULT_STALE_THRESHOLD = 600; + + public function __construct( + private readonly ProcessHeartbeatRepository $heartbeatRepository, + private readonly AccountRepository $accountRepository, + private readonly Connection $connection, + ) {} + + /** + * @return list|null, ageSeconds: int|null, age: string, healthy: bool}> + */ + public function heartbeats(): array + { + $rows = []; + + foreach ($this->heartbeatRepository->findAllOrdered() as $heartbeat) { + $ageSeconds = null; + + if (null !== $heartbeat->lastBeatAt) { + $ageSeconds = max(0, time() - $heartbeat->lastBeatAt->getTimestamp()); + } + + $threshold = self::STALE_THRESHOLDS[$heartbeat->type] ?? self::DEFAULT_STALE_THRESHOLD; + + $rows[] = [ + 'type' => $heartbeat->type, + 'key' => $heartbeat->key, + 'pid' => $heartbeat->pid, + 'lastBeatAt' => $heartbeat->lastBeatAt, + 'meta' => $heartbeat->meta, + 'ageSeconds' => $ageSeconds, + 'age' => $this->formatAge($ageSeconds), + 'healthy' => null !== $ageSeconds && $ageSeconds < $threshold, + ]; + } + + return $rows; + } + + /** + * Gmail push/webhook status for every account that has ever touched the + * Gmail sync machinery. Detected via watch/history fields rather than the + * provider enum so this stays decoupled from provider modelling. + * + * @return list + */ + public function gmailWebhooks(): array + { + $rows = []; + $now = new \DateTimeImmutable(); + + foreach ($this->accountRepository->findAll() as $account) { + $watchExpiry = $account->getGmailWatchExpiry(); + $resourceName = $account->getGmailWatchResourceName(); + $historyId = $account->getGmailHistoryId(); + + if (null === $watchExpiry && null === $resourceName && null === $historyId) { + continue; + } + + $lastPushAt = $account->getGmailLastPushAt(); + $lastPushAge = null; + + if (null !== $lastPushAt) { + $lastPushAge = max(0, $now->getTimestamp() - $lastPushAt->getTimestamp()); + } + + $rows[] = [ + 'account' => $account, + 'watchActive' => null !== $watchExpiry && $watchExpiry > $now, + 'watchExpiry' => $watchExpiry, + 'lastPushAt' => $lastPushAt, + 'lastPushAge' => $this->formatAge($lastPushAge), + 'historyId' => $historyId, + ]; + } + + return $rows; + } + + /** + * OAuth token refresh health for accounts that have gone through the + * token manager at least once. + * + * @return list + */ + public function tokenHealth(): array + { + $rows = []; + + foreach ($this->accountRepository->findAll() as $account) { + $lastRefreshAt = $account->getOauthLastRefreshAt(); + $error = $account->getOauthLastRefreshError(); + + if (null === $lastRefreshAt && null === $error) { + continue; + } + + $age = null; + + if (null !== $lastRefreshAt) { + $age = max(0, time() - $lastRefreshAt->getTimestamp()); + } + + $rows[] = [ + 'account' => $account, + 'lastRefreshAt' => $lastRefreshAt, + 'lastRefreshAge' => $this->formatAge($age), + 'error' => $error, + 'healthy' => null === $error, + ]; + } + + return $rows; + } + + /** + * Per-account sync overview: thread/message volume and last activity. + * Messages attach to an account via mailbox (IMAP) or thread (Gmail API), + * hence the OR join. + * + * @return list> + */ + public function accountOverview(): array + { + return $this->connection->fetchAllAssociative( + 'SELECT + a.id, + a.email, + a.is_active, + (SELECT COUNT(*) FROM message_thread t WHERE t.account_id = a.id) AS threads, + (SELECT COUNT(*) + FROM message m + LEFT JOIN mailbox mb ON m.mailbox_id = mb.id + LEFT JOIN message_thread mt ON m.thread_id = mt.id + WHERE mb.account_id = a.id OR mt.account_id = a.id) AS messages, + (SELECT MAX(t.last_message_at) FROM message_thread t WHERE t.account_id = a.id) AS last_activity + FROM account a + ORDER BY a.email', + ); + } + + /** + * @return list + */ + public function tableSizes(int $limit = 12): array + { + $rows = $this->connection->fetchAllAssociative( + "SELECT + c.relname AS table_name, + pg_size_pretty(pg_total_relation_size(c.oid)) AS pretty_size, + pg_total_relation_size(c.oid) AS bytes + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r' + ORDER BY bytes DESC + LIMIT :limit", + ['limit' => $limit], + ); + + $sizes = []; + + foreach ($rows as $row) { + $sizes[] = [ + 'table' => (string) $row['table_name'], + 'size' => (string) $row['pretty_size'], + 'bytes' => (int) $row['bytes'], + ]; + } + + return $sizes; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function formatAge(?int $seconds): string + { + if (null === $seconds) { + return '—'; + } + + if ($seconds < 60) { + return $seconds . 's'; + } + + if ($seconds < 3600) { + return intdiv($seconds, 60) . 'm ' . ($seconds % 60) . 's'; + } + + if ($seconds < 86400) { + return intdiv($seconds, 3600) . 'h ' . intdiv($seconds % 3600, 60) . 'm'; + } + + return intdiv($seconds, 86400) . 'd ' . intdiv($seconds % 86400, 3600) . 'h'; + } +} diff --git a/src/Service/Monitoring/ProcessHeartbeatService.php b/src/Service/Monitoring/ProcessHeartbeatService.php new file mode 100644 index 00000000..695a2e2b --- /dev/null +++ b/src/Service/Monitoring/ProcessHeartbeatService.php @@ -0,0 +1,56 @@ +|null $meta + */ + public function beat(string $type, string $key, ?array $meta = null): void + { + try { + $this->connection->executeStatement( + 'INSERT INTO process_heartbeat (type, beat_key, pid, last_beat_at, meta) + VALUES (:type, :key, :pid, NOW(), :meta) + ON CONFLICT (type, beat_key) DO UPDATE + SET pid = EXCLUDED.pid, last_beat_at = EXCLUDED.last_beat_at, meta = EXCLUDED.meta', + [ + 'type' => $type, + 'key' => $key, + 'pid' => false !== getmypid() ? getmypid() : null, + 'meta' => null === $meta ? null : json_encode($meta, JSON_PARTIAL_OUTPUT_ON_ERROR), + ], + ); + } catch (\Throwable) { + // Heartbeats must never take the process down. + } + } + + public function pruneOlderThan(\DateTimeImmutable $cutoff): int + { + return (int) $this->connection->executeStatement( + 'DELETE FROM process_heartbeat WHERE last_beat_at < :cutoff', + ['cutoff' => $cutoff->format('Y-m-d H:i:s')], + ); + } +} diff --git a/src/Service/Monitoring/QueueMonitor.php b/src/Service/Monitoring/QueueMonitor.php new file mode 100644 index 00000000..025bc655 --- /dev/null +++ b/src/Service/Monitoring/QueueMonitor.php @@ -0,0 +1,143 @@ + + */ + public function queueStats(): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT queue_name, COUNT(*) AS pending, MIN(available_at) AS oldest + FROM messenger_messages + WHERE delivered_at IS NULL + GROUP BY queue_name + ORDER BY queue_name', + ); + + $stats = []; + + foreach ($rows as $row) { + $oldestAge = null; + + if (null !== $row['oldest']) { + $oldestAge = max(0, time() - (int) strtotime((string) $row['oldest'])); + } + + $stats[] = [ + 'queue' => (string) $row['queue_name'], + 'pending' => (int) $row['pending'], + 'oldestAgeSeconds' => $oldestAge, + ]; + } + + return $stats; + } + + /** + * @return list + */ + public function failedMessages(int $limit = 50): array + { + if (false === $this->failureTransport instanceof ListableReceiver) { + return []; + } + + $failed = []; + + foreach ($this->failureTransport->all($limit) as $envelope) { + /** @var TransportMessageIdStamp|null $idStamp */ + $idStamp = $envelope->last(TransportMessageIdStamp::class); + + /** @var ErrorDetailsStamp|null $errorStamp */ + $errorStamp = $envelope->last(ErrorDetailsStamp::class); + + /** @var RedeliveryStamp|null $redeliveryStamp */ + $redeliveryStamp = $envelope->last(RedeliveryStamp::class); + + if (null === $idStamp) { + continue; + } + + $failed[] = [ + 'id' => (string) $idStamp->getId(), + 'class' => $envelope->getMessage()::class, + 'error' => $errorStamp?->getExceptionMessage(), + 'failedAt' => $redeliveryStamp?->getRedeliveredAt(), + ]; + } + + return $failed; + } + + /** + * Re-dispatch a failed message onto the bus and drop it from the + * failure transport. Stamps are intentionally not carried over — the + * message re-enters routing as a fresh dispatch. + */ + public function retry(string $id): bool + { + $envelope = $this->find($id); + + if (null === $envelope) { + return false; + } + + $this->bus->dispatch($envelope->getMessage()); + $this->failureTransport->reject($envelope); + + return true; + } + + public function remove(string $id): bool + { + $envelope = $this->find($id); + + if (null === $envelope) { + return false; + } + + $this->failureTransport->reject($envelope); + + return true; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function find(string $id): ?\Symfony\Component\Messenger\Envelope + { + if (false === $this->failureTransport instanceof ListableReceiver) { + return null; + } + + return $this->failureTransport->find($id); + } +} diff --git a/src/Service/OAuth/OAuthProviderFactory.php b/src/Service/OAuth/OAuthProviderFactory.php index 01996636..98f93f85 100644 --- a/src/Service/OAuth/OAuthProviderFactory.php +++ b/src/Service/OAuth/OAuthProviderFactory.php @@ -26,7 +26,6 @@ class OAuthProviderFactory public function create(MailProvider $provider): AbstractProvider { - dump($this->googleClientId); $redirectUri = $this->urlGenerator->generate( 'app_oauth_callback', ['provider' => $provider->value], diff --git a/src/Service/OAuth/OAuthTokenManager.php b/src/Service/OAuth/OAuthTokenManager.php index 86ee55df..b795ab79 100644 --- a/src/Service/OAuth/OAuthTokenManager.php +++ b/src/Service/OAuth/OAuthTokenManager.php @@ -8,6 +8,9 @@ use App\Domain\Enum\MailProvider; use App\Entity\Account; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use GuzzleHttp\Exception\GuzzleException; +use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use Throwable; /** * Single source of a valid access token for an OAuth account. @@ -26,7 +29,8 @@ class OAuthTokenManager public function __construct( private readonly OAuthProviderFactory $providerFactory, private readonly EntityManagerInterface $em, - ) { + ) + { } public function getValidAccessToken(Account $account): string @@ -61,6 +65,11 @@ class OAuthTokenManager return false; } + /** + * @throws Throwable + * @throws GuzzleException + * @throws IdentityProviderException + */ private function refresh(Account $account): string { $refreshToken = $account->getOauthRefreshToken(); @@ -68,22 +77,29 @@ class OAuthTokenManager if (null === $refreshToken) { throw new \RuntimeException(sprintf( 'Account %d has no refresh token; the account must be reconnected.', - (int) $account->getId(), + $account->getId(), )); } $provider = $this->providerFactory->create($this->providerFor($account)); - $newToken = $provider->getAccessToken('refresh_token', [ - 'refresh_token' => $refreshToken, - ]); + try { + $newToken = $provider->getAccessToken('refresh_token', [ + 'refresh_token' => $refreshToken, + ]); + } catch (Throwable $e) { + $account->setOauthLastRefreshError($e->getMessage()); + $this->em->flush(); + + throw $e; + } $account->setOauthAccessToken($newToken->getToken()); $expires = $newToken->getExpires(); if (null !== $expires) { $account->setOauthTokenExpiry( - (new DateTimeImmutable())->setTimestamp($expires), + new DateTimeImmutable()->setTimestamp($expires), ); } @@ -91,7 +107,10 @@ class OAuthTokenManager // existing one unless a new one is explicitly returned. $returnedRefresh = $newToken->getRefreshToken(); if (null !== $returnedRefresh) { - $account->setOauthRefreshToken($returnedRefresh); + $account + ->setOauthRefreshToken($returnedRefresh) + ->setOauthLastRefreshAt(new DateTimeImmutable()) + ->setOauthLastRefreshError(null);; } $account->setUpdatedAt(new DateTimeImmutable()); @@ -108,7 +127,7 @@ class OAuthTokenManager if (null === $providerValue) { throw new \RuntimeException(sprintf( 'Account %d has no OAuth provider set.', - (int) $account->getId(), + $account->getId(), )); } @@ -117,7 +136,7 @@ class OAuthTokenManager if (null === $provider) { throw new \RuntimeException(sprintf( 'Account %d has unknown OAuth provider "%s".', - (int) $account->getId(), + $account->getId(), $providerValue, )); } diff --git a/templates/_partials/_topbar.html.twig b/templates/_partials/_topbar.html.twig index c9cbb48e..66fa0fe0 100644 --- a/templates/_partials/_topbar.html.twig +++ b/templates/_partials/_topbar.html.twig @@ -122,6 +122,12 @@ {{ 'header.user_menu.settings'|trans }} + {% if is_granted('ROLE_ADMIN') %} + + + {{ 'header.user_menu.admin'|trans }} + + {% endif %} {{ 'header.user_menu.sign_out'|trans }} diff --git a/templates/admin/_live_frame.html.twig b/templates/admin/_live_frame.html.twig new file mode 100644 index 00000000..cd71de33 --- /dev/null +++ b/templates/admin/_live_frame.html.twig @@ -0,0 +1,232 @@ +{# Auto-refreshed live panels. Wrapped in the same turbo-frame id so + frame.reload() swaps the whole block. #} + +{% macro dot(healthy) %} + +{% endmacro %} + +{% macro card_open(icon, title) %} +
+
+ +

{{ title }}

+
+{% endmacro %} + +{% import _self as ui %} + + + +
+ + {# ── Processes ─────────────────────────────────────────────────── #} + {{ ui.card_open('fa-heart-pulse', 'admin.processes.heading'|trans) }} + {% 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 ?? '—' }} + {{ row.age }} {{ 'admin.ago'|trans }} +
+ {% endif %} +
+ +
+ + {# ── Gmail webhooks ────────────────────────────────────────── #} + {{ ui.card_open('fa-satellite-dish', 'admin.webhooks.heading'|trans) }} + {% 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 ? (row.lastPushAge ~ ' ' ~ 'admin.ago'|trans) : 'admin.webhooks.never'|trans }}
+
{{ 'admin.webhooks.history_id'|trans }}
+
{{ row.historyId ?? '—' }}
+
+
+ {% endfor %} +
+ {% endif %} + + + {# ── OAuth token health ────────────────────────────────────── #} + {{ ui.card_open('fa-key', 'admin.tokens.heading'|trans) }} + {% if tokenHealth|length == 0 %} +

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

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

{{ row.error }}

+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + +
+ + {# ── Messenger queues ──────────────────────────────────────────── #} + {{ ui.card_open('fa-list-check', 'admin.queues.heading'|trans) }} + {% if queueStats|length == 0 %} +

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

+ {% else %} + + + + + + + + + + {% for row in queueStats %} + + + + + + {% endfor %} + +
{{ 'admin.queues.queue'|trans }}{{ 'admin.queues.pending'|trans }}{{ 'admin.queues.oldest'|trans }}
{{ row.queue }}{{ row.pending }} + {% if row.oldestAgeSeconds is not null and row.oldestAgeSeconds > 300 %} + {{ (row.oldestAgeSeconds // 60) }}m + {% elseif row.oldestAgeSeconds is not null %} + {{ row.oldestAgeSeconds }}s + {% else %} + — + {% endif %} +
+ {% endif %} + + + {# ── Failed messages ───────────────────────────────────────────── #} + {{ ui.card_open('fa-triangle-exclamation', 'admin.failed.heading'|trans) }} + {% if failedMessages|length == 0 %} +

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

+ {% else %} +
+ {% 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 %} + + +
+ + {# ── Accounts ──────────────────────────────────────────────── #} + {{ ui.card_open('fa-at', 'admin.accounts.heading'|trans) }} + + + + + + + + + + + {% 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') : '—' }}
+ + + {# ── Table sizes ───────────────────────────────────────────── #} + {{ ui.card_open('fa-database', 'admin.tables.heading'|trans) }} + + + {% for row in tableSizes %} + + + + + {% endfor %} + +
{{ row.table }}{{ row.size }}
+ + +
+ + + + diff --git a/templates/admin/_logs_frame.html.twig b/templates/admin/_logs_frame.html.twig new file mode 100644 index 00000000..435d49cd --- /dev/null +++ b/templates/admin/_logs_frame.html.twig @@ -0,0 +1,85 @@ + + +
+ +
+ +

{{ 'admin.logs.heading'|trans }}

+ {{ total }} {{ 'admin.logs.entries'|trans }} + +
+ + + +
+
+ + {% if entries|length == 0 %} +

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

+ {% else %} +
+ {% for entry in entries %} +
+ + + {{ entry.levelName }} + + {{ entry.createdAt|date('m-d H:i:s') }} + {{ entry.source ?? '—' }} + {{ entry.channel }} + {{ entry.message }} + + + {% if entry.context %} +
{{ entry.context|json_encode(constant('JSON_PRETTY_PRINT')) }}
+ {% endif %} +
+ {% endfor %} +
+ + {% if pages > 1 %} +
+ {% endif %} + {% endif %} + +
+ + diff --git a/templates/admin/index.html.twig b/templates/admin/index.html.twig new file mode 100644 index 00000000..9de31873 --- /dev/null +++ b/templates/admin/index.html.twig @@ -0,0 +1,72 @@ +{% extends '_layout/app.html.twig' %} + +{% block title %}{{ 'admin.title'|trans }} — {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + {# Mobile backdrop #} + + + {# Mobile sidebar drawer #} +
+ {% include '_partials/_sidebar.html.twig' with { _drawerMode: true } %} +
+ +
+ + {% include '_partials/_topbar.html.twig' with { _showBurger: true } %} + +
+ + {# Sidebar — desktop #} + + + {# Main content pane — admin dashboard #} +
+
+ + {# Pane header #} +
+ +

{{ 'admin.title'|trans }}

+ {{ 'admin.refresh_hint'|trans }} +
+ + {# Scrollable body #} +
+
+ + +
+ {{ 'admin.loading'|trans }} +
+
+ + +
+ {{ 'admin.loading'|trans }} +
+
+ +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index f121eb63..133da077 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1,6 +1,57 @@ app: name: Mail - +admin: + title: 'Admin' + refresh_hint: 'Live panels refresh every 10s' + loading: 'Loading…' + ago: 'ago' + processes: + heading: 'Processes' + empty: 'No heartbeats yet — processes report here after their first beat.' + type: 'Type' + key: 'Instance' + pid: 'PID' + last_beat: 'Last beat' + webhooks: + heading: 'Gmail webhooks' + empty: 'No Gmail accounts with watch state.' + watch_expiry: 'Watch expiry' + last_push: 'Last push' + history_id: 'History ID' + none: 'not registered' + never: 'never' + tokens: + heading: 'OAuth token health' + empty: 'No token refreshes recorded yet.' + queues: + heading: 'Messenger queues' + empty: 'No pending messages.' + queue: 'Queue' + pending: 'Pending' + oldest: 'Oldest waiting' + failed: + heading: 'Failed messages' + empty: 'Failure transport is empty. 🎉' + retry: 'Retry' + delete: 'Delete' + delete_confirm: 'Permanently drop this failed message?' + accounts: + heading: 'Accounts' + account: 'Account' + threads: 'Threads' + messages: 'Messages' + last_activity: 'Last activity' + tables: + heading: 'Table sizes' + logs: + heading: 'Logs' + entries: 'entries' + all_channels: 'All channels' + refresh: 'Refresh' + empty: 'Nothing logged at this level.' + page: 'Page' + newer: 'Newer' + older: 'Older' login: page_title: Sign in tagline: Sign in to your account @@ -27,6 +78,7 @@ header: label: User menu for %email% signed_in_as: Signed in as settings: Settings + admin: Admin sign_out: Sign out sidebar: