fix(alerts): fresh mailer at queue time (poller!) + branded HTML alert e-mail
ROOT CAUSE of "alert fires but no e-mail", third and final layer: the
metrics poller (clusev:poll-metrics, a long-lived supervisor process) is
what queues alert mail — and Laravel bakes the DEFAULT MAILER NAME into a
mailable at Mail::to()->queue() time. A poller that booted before SMTP
was configured bakes `log` into every alert e-mail forever; the worker
then faithfully "sends" them into laravel.log no matter how correct its
own config is. This is why manual test sends (fresh processes → smtp)
arrived while real alert mails (poller → log) never did.
Fix: extract the SMTP-settings application into App\Support\MailSettings
(::apply(), now with Mail::purge on change so long-lived processes
rebuild the transport) and call it from all three process shapes:
- web requests → AppServiceProvider::boot()
- queue worker → Queue::before
- poller/loops → AlertNotifier::notify() right before queueing,
so the CURRENT mailer name is baked in.
Also: enterprise HTML alert e-mail (frontend-design). Dark Tactical
Terminal card — signal rail + status pill (red ALARM / green BEHOBEN),
rule headline, mono terminal readout (server/metric/value/threshold),
bulletproof dashboard CTA, CID-embedded logo (no external assets; renders
in Gmail/Outlook with images blocked), full de/en, plain-text part kept
as fallback. Offline rules render state text (offline/back online)
instead of a bare 0/1.
Verified: app restarted (poller reloaded), synchronous + queued sends to
the admin both deliver over real SMTP (476ms send, no new log-mailer
entries). 750 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
5024675c94
commit
da53b6f7c7
|
|
@ -31,7 +31,10 @@ class AlertNotification extends Mailable implements ShouldQueue
|
||||||
|
|
||||||
public function content(): Content
|
public function content(): Content
|
||||||
{
|
{
|
||||||
|
// Branded HTML (table layout, inline styles, CID-embedded logo — no external assets, so it
|
||||||
|
// renders in Gmail/Outlook without image blocking) + the plain-text part as fallback.
|
||||||
return new Content(
|
return new Content(
|
||||||
|
html: 'mail.alert-html',
|
||||||
text: 'mail.alert',
|
text: 'mail.alert',
|
||||||
with: [
|
with: [
|
||||||
'incident' => $this->incident,
|
'incident' => $this->incident,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ namespace App\Providers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\DeploymentService;
|
use App\Services\DeploymentService;
|
||||||
|
use App\Support\MailSettings;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
@ -17,7 +16,6 @@ use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Vite;
|
use Illuminate\Support\Facades\Vite;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -89,57 +87,12 @@ class AppServiceProvider extends ServiceProvider
|
||||||
config(['app.url' => 'https://'.$domain]);
|
config(['app.url' => 'https://'.$domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyMailSettings();
|
// Operator SMTP settings → runtime mail config. Shared helper (MailSettings::apply) because
|
||||||
|
// THREE process shapes need it fresh: web requests (here, per request), the queue worker
|
||||||
// The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config
|
// (Queue::before, per job — boot() runs only once in that long-lived process), and the
|
||||||
// change (or SMTP first configured after the worker started — the normal case) would never
|
// long-lived metrics poller (AlertNotifier applies it right before queueing, since the
|
||||||
// reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer.
|
// default MAILER NAME is baked into a mailable at queue time).
|
||||||
// Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends
|
MailSettings::apply();
|
||||||
// with the current config.
|
Queue::before(fn () => MailSettings::apply());
|
||||||
Queue::before(fn () => $this->applyMailSettings());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the operator's SMTP settings (Settings\Email) to runtime config so
|
|
||||||
* password-reset mails and notifications actually send. boot() runs every request,
|
|
||||||
* so a saved change takes effect on the next request.
|
|
||||||
*
|
|
||||||
* Only switches to the `smtp` mailer when host + from-address are configured;
|
|
||||||
* otherwise the safe install default (`log`) is left untouched, so nothing breaks
|
|
||||||
* while unconfigured. The stored SMTP password is encrypted at rest (APP_KEY) and
|
|
||||||
* decrypted here. Wrapped in try/catch like DeploymentService — the settings table
|
|
||||||
* may not exist yet during install/migrate, and that must never break boot.
|
|
||||||
*/
|
|
||||||
private function applyMailSettings(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$host = Setting::get('mail_host');
|
|
||||||
$from = Setting::get('mail_from_address');
|
|
||||||
|
|
||||||
if ($host === null || $host === '' || $from === null || $from === '') {
|
|
||||||
return; // unconfigured -> keep the safe default mailer
|
|
||||||
}
|
|
||||||
|
|
||||||
$encryption = Setting::get('mail_encryption', 'tls');
|
|
||||||
$storedPassword = Setting::get('mail_password');
|
|
||||||
$password = null;
|
|
||||||
if ($storedPassword !== null && $storedPassword !== '') {
|
|
||||||
$password = Crypt::decryptString($storedPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
config([
|
|
||||||
'mail.default' => 'smtp',
|
|
||||||
'mail.mailers.smtp.host' => $host,
|
|
||||||
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
|
|
||||||
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
|
|
||||||
'mail.mailers.smtp.password' => $password ?: null,
|
|
||||||
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
|
|
||||||
'mail.from.address' => $from,
|
|
||||||
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
|
|
||||||
]);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// Settings table not migrated yet (install/migrate) or an undecryptable value —
|
|
||||||
// leave the default mailer untouched. Never break boot.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Mail\AlertNotification;
|
||||||
use App\Models\AlertIncident;
|
use App\Models\AlertIncident;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\MailSettings;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
@ -21,6 +22,12 @@ class AlertNotifier
|
||||||
{
|
{
|
||||||
public function notify(AlertIncident $incident, bool $resolved): void
|
public function notify(AlertIncident $incident, bool $resolved): void
|
||||||
{
|
{
|
||||||
|
// Re-apply the DB SMTP settings RIGHT BEFORE queueing: the metrics poller calling this is a
|
||||||
|
// long-lived process, and Laravel bakes the current default MAILER NAME into the mailable at
|
||||||
|
// queue time — without this, a poller that booted before SMTP was configured would bake the
|
||||||
|
// `log` mailer into every alert e-mail forever (they'd land in laravel.log, never the inbox).
|
||||||
|
MailSettings::apply();
|
||||||
|
|
||||||
$this->email($incident, $resolved);
|
$this->email($incident, $resolved);
|
||||||
$this->webhooks($incident, $resolved);
|
$this->webhooks($incident, $resolved);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the operator's DB-stored SMTP settings (Settings\Email) to runtime config. ONE shared
|
||||||
|
* entry point for every process shape:
|
||||||
|
*
|
||||||
|
* - web request → AppServiceProvider::boot() (per request)
|
||||||
|
* - queue worker → Queue::before hook (per job)
|
||||||
|
* - LONG-LIVED loops → AlertNotifier::notify() (per notification)
|
||||||
|
*
|
||||||
|
* The last one matters most: the metrics poller (clusev:poll-metrics under supervisor) runs for
|
||||||
|
* days. Laravel bakes the DEFAULT MAILER NAME into a mailable at Mail::to()->queue() time, so a
|
||||||
|
* loop that booted before SMTP was configured would bake `log` into every alert mail forever —
|
||||||
|
* the worker then "sends" them into laravel.log no matter how correct its own config is. Applying
|
||||||
|
* fresh settings immediately before queueing bakes the right mailer name.
|
||||||
|
*
|
||||||
|
* Mail::purge() drops the cached mailer instance so a config change actually rebuilds the
|
||||||
|
* transport in long-lived processes instead of reusing the old connection.
|
||||||
|
*/
|
||||||
|
class MailSettings
|
||||||
|
{
|
||||||
|
public static function apply(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$host = Setting::get('mail_host');
|
||||||
|
$from = Setting::get('mail_from_address');
|
||||||
|
|
||||||
|
if ($host === null || $host === '' || $from === null || $from === '') {
|
||||||
|
return; // unconfigured -> keep the safe default mailer
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryption = Setting::get('mail_encryption', 'tls');
|
||||||
|
$storedPassword = Setting::get('mail_password');
|
||||||
|
$password = null;
|
||||||
|
if ($storedPassword !== null && $storedPassword !== '') {
|
||||||
|
$password = Crypt::decryptString($storedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = config('mail.default') !== 'smtp'
|
||||||
|
|| config('mail.mailers.smtp.host') !== $host;
|
||||||
|
|
||||||
|
config([
|
||||||
|
'mail.default' => 'smtp',
|
||||||
|
'mail.mailers.smtp.host' => $host,
|
||||||
|
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
|
||||||
|
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
|
||||||
|
'mail.mailers.smtp.password' => $password ?: null,
|
||||||
|
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
|
||||||
|
'mail.from.address' => $from,
|
||||||
|
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
Mail::purge('smtp'); // rebuild the cached transport with the new settings
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Settings table not migrated yet (install/migrate) or an undecryptable value —
|
||||||
|
// leave the default mailer untouched. Never break the caller.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,4 +79,13 @@ return [
|
||||||
'mail_metric' => 'Metrik',
|
'mail_metric' => 'Metrik',
|
||||||
'mail_value' => 'Wert',
|
'mail_value' => 'Wert',
|
||||||
'mail_threshold' => 'Schwelle',
|
'mail_threshold' => 'Schwelle',
|
||||||
|
'mail_status_firing' => 'Alarm ausgelöst',
|
||||||
|
'mail_status_resolved' => 'Alarm behoben',
|
||||||
|
'mail_state_offline' => 'offline',
|
||||||
|
'mail_state_online' => 'wieder online',
|
||||||
|
'mail_brand_tag' => 'Fleet Control',
|
||||||
|
'mail_readout' => 'Messwerte',
|
||||||
|
'mail_open_dashboard' => 'Dashboard öffnen',
|
||||||
|
'mail_footer_auto' => 'Diese Benachrichtigung wurde automatisch von Clusev gesendet.',
|
||||||
|
'mail_footer_manage' => 'Empfänger verwaltest du im Dashboard unter Alarme → Benachrichtigungs-Kanäle.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,13 @@ return [
|
||||||
'mail_metric' => 'Metric',
|
'mail_metric' => 'Metric',
|
||||||
'mail_value' => 'Value',
|
'mail_value' => 'Value',
|
||||||
'mail_threshold' => 'Threshold',
|
'mail_threshold' => 'Threshold',
|
||||||
|
'mail_status_firing' => 'Alert firing',
|
||||||
|
'mail_status_resolved' => 'Alert resolved',
|
||||||
|
'mail_state_offline' => 'offline',
|
||||||
|
'mail_state_online' => 'back online',
|
||||||
|
'mail_brand_tag' => 'Fleet Control',
|
||||||
|
'mail_readout' => 'Readout',
|
||||||
|
'mail_open_dashboard' => 'Open dashboard',
|
||||||
|
'mail_footer_auto' => 'This notification was sent automatically by Clusev.',
|
||||||
|
'mail_footer_manage' => 'Manage recipients in the dashboard under Alerts → Notification channels.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
@php
|
||||||
|
// All colors inline (email clients strip <style>): Clusev "Tactical Terminal" palette.
|
||||||
|
$void = '#0b0e13'; // outer background
|
||||||
|
$surface = '#12161d'; // card
|
||||||
|
$inset = '#0e1218'; // readout background
|
||||||
|
$line = '#232a35'; // hairline
|
||||||
|
$ink = '#e9eef3'; // primary text
|
||||||
|
$ink2 = '#aab4c0'; // secondary
|
||||||
|
$ink3 = '#77828f'; // muted
|
||||||
|
$accent = '#ff6a2b'; // signal orange
|
||||||
|
$online = '#35d07f';
|
||||||
|
$offline = '#ff5247';
|
||||||
|
|
||||||
|
$state = $resolved ? $online : $offline;
|
||||||
|
$stateTint = $resolved ? '#0f2a1d' : '#2a1214';
|
||||||
|
$stateLabel = $resolved ? __('alerts.mail_status_resolved') : __('alerts.mail_status_firing');
|
||||||
|
$when = ($resolved ? $incident->resolved_at : $incident->started_at) ?? now();
|
||||||
|
|
||||||
|
$mono = "'JetBrains Mono','SFMono-Regular',Consolas,'Liberation Mono','Courier New',monospace";
|
||||||
|
$sans = "'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif";
|
||||||
|
|
||||||
|
$isOffline = $metric === 'offline';
|
||||||
|
$metricLabel = $isOffline ? __('alerts.metric_offline') : __('alerts.metric_'.$metric);
|
||||||
|
$valueText = $isOffline
|
||||||
|
? ($resolved ? __('alerts.mail_state_online') : __('alerts.mail_state_offline'))
|
||||||
|
: rtrim(rtrim(number_format((float) $value, 1, ',', '.'), '0'), ',').(in_array($metric, ['cpu', 'mem', 'disk'], true) ? ' %' : '');
|
||||||
|
|
||||||
|
$dashboardUrl = rtrim(config('app.url'), '/').'/alerts';
|
||||||
|
@endphp
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ app()->getLocale() }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta name="supported-color-schemes" content="dark">
|
||||||
|
<title>{{ $stateLabel }} — {{ $rule }}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:{{ $void }}; -webkit-text-size-adjust:100%;">
|
||||||
|
{{-- Preheader (inbox preview text, invisible in the mail body) --}}
|
||||||
|
<div style="display:none; max-height:0; overflow:hidden; mso-hide:all;">
|
||||||
|
{{ $stateLabel }}: {{ $rule }} · {{ $server }} · {{ $when->format('d.m.Y H:i') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:{{ $void }};">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:32px 12px;">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:100%;">
|
||||||
|
|
||||||
|
{{-- Brand row --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 4px 14px 4px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<img src="{{ $message->embed(public_path('icon-192.png')) }}" width="28" height="28" alt="Clusev"
|
||||||
|
style="display:block; border:0; border-radius:6px;">
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle; padding-left:10px; font-family:{{ $sans }}; font-size:17px; font-weight:700; letter-spacing:0.04em; color:{{ $ink }};">
|
||||||
|
Clus<span style="color:{{ $accent }};">e</span>v
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle; padding-left:12px; font-family:{{ $mono }}; font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:{{ $ink3 }};">
|
||||||
|
{{ __('alerts.mail_brand_tag') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Card --}}
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:{{ $surface }}; border:1px solid {{ $line }}; border-radius:12px; overflow:hidden;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
|
||||||
|
{{-- Signal rail --}}
|
||||||
|
<tr><td style="height:4px; background-color:{{ $state }}; font-size:0; line-height:0;"> </td></tr>
|
||||||
|
|
||||||
|
{{-- Status band --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 28px 0 28px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:{{ $stateTint }}; border:1px solid {{ $state }}; border-radius:999px; padding:5px 14px; font-family:{{ $mono }}; font-size:11px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase; color:{{ $state }};">
|
||||||
|
● {{ $stateLabel }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Headline --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 28px 4px 28px; font-family:{{ $sans }}; font-size:24px; line-height:1.25; font-weight:700; color:{{ $ink }};">
|
||||||
|
{{ $rule }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 28px 20px 28px; font-family:{{ $mono }}; font-size:12px; color:{{ $ink3 }};">
|
||||||
|
{{ $server }} · {{ $when->format('d.m.Y H:i:s') }} UTC
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Terminal readout --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="background-color:{{ $inset }}; border:1px solid {{ $line }}; border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 16px; border-bottom:1px solid {{ $line }}; font-family:{{ $mono }}; font-size:10px; letter-spacing:0.2em; text-transform:uppercase; color:{{ $ink3 }};">
|
||||||
|
{{ __('alerts.mail_readout') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px 4px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{{ $mono }};">
|
||||||
|
<tr>
|
||||||
|
<td width="38%" style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }};">{{ __('alerts.mail_server') }}</td>
|
||||||
|
<td style="padding:6px 0; font-size:13px; color:{{ $ink }};">{{ $server }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_metric') }}</td>
|
||||||
|
<td style="padding:6px 0; font-size:13px; color:{{ $ink }}; border-top:1px solid {{ $line }};">{{ $metricLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_value') }}</td>
|
||||||
|
<td style="padding:6px 0; font-size:16px; font-weight:700; color:{{ $state }}; border-top:1px solid {{ $line }};">{{ $valueText }}</td>
|
||||||
|
</tr>
|
||||||
|
@unless ($isOffline)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0 12px 0; font-size:11px; letter-spacing:0.14em; text-transform:uppercase; color:{{ $ink3 }}; border-top:1px solid {{ $line }};">{{ __('alerts.mail_threshold') }}</td>
|
||||||
|
<td style="padding:6px 0 12px 0; font-size:13px; color:{{ $ink2 }}; border-top:1px solid {{ $line }};">{{ rtrim(rtrim(number_format((float) $threshold, 1, ',', '.'), '0'), ',') }}{{ in_array($metric, ['cpu', 'mem', 'disk'], true) ? ' %' : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endunless
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- CTA --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:22px 28px 26px 28px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:{{ $accent }}; border-radius:8px;">
|
||||||
|
<a href="{{ $dashboardUrl }}"
|
||||||
|
style="display:inline-block; padding:11px 22px; font-family:{{ $sans }}; font-size:13px; font-weight:700; color:#151006; text-decoration:none;">
|
||||||
|
{{ __('alerts.mail_open_dashboard') }} →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Footer --}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 6px 0 6px; font-family:{{ $mono }}; font-size:11px; line-height:1.7; color:{{ $ink3 }};">
|
||||||
|
{{ __('alerts.mail_footer_auto') }}<br>
|
||||||
|
{{ __('alerts.mail_footer_manage') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue