mirror of
https://github.com/alexjustesen/speedtest-tracker.git
synced 2026-06-23 02:10:08 +00:00
Release v1.12.0 (#2493)
Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com> Co-authored-by: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Notifications\Apprise\TestNotification;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Notification as FacadesNotification;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class SendAppriseTestNotification
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(array $channel_urls)
|
||||
{
|
||||
if (! count($channel_urls)) {
|
||||
Notification::make()
|
||||
->title('You need to add Apprise channel URLs!')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($channel_urls as $row) {
|
||||
$channelUrl = $row['channel_url'] ?? null;
|
||||
if (! $channelUrl) {
|
||||
Notification::make()
|
||||
->title('Skipping missing channel URL!')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
FacadesNotification::route('apprise_urls', $channelUrl)
|
||||
->notify(new TestNotification);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Test Apprise notification sent.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,11 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Widgets\RecentDownloadChartWidget;
|
||||
use App\Filament\Widgets\RecentDownloadLatencyChartWidget;
|
||||
use App\Filament\Widgets\RecentJitterChartWidget;
|
||||
use App\Filament\Widgets\RecentPingChartWidget;
|
||||
use App\Filament\Widgets\RecentUploadChartWidget;
|
||||
use App\Filament\Widgets\RecentUploadLatencyChartWidget;
|
||||
use App\Filament\Widgets\StatsOverviewWidget;
|
||||
use Carbon\Carbon;
|
||||
use Cron\CronExpression;
|
||||
use Filament\Pages\Dashboard as BasePage;
|
||||
|
||||
class Dashboard extends BasePage
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-layout-dashboard';
|
||||
|
||||
protected string $view = 'filament.pages.dashboard';
|
||||
|
||||
@@ -28,32 +19,4 @@ class Dashboard extends BasePage
|
||||
{
|
||||
return __('dashboard.title');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$schedule = config('speedtest.schedule');
|
||||
|
||||
if (blank($schedule) || $schedule === false) {
|
||||
return __('dashboard.no_speedtests_scheduled');
|
||||
}
|
||||
|
||||
$cronExpression = new CronExpression($schedule);
|
||||
|
||||
$nextRunDate = Carbon::parse($cronExpression->getNextRunDate(timeZone: config('app.display_timezone')))->format(config('app.datetime_format'));
|
||||
|
||||
return __('dashboard.next_speedtest_at').': '.$nextRunDate;
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
StatsOverviewWidget::make(),
|
||||
RecentDownloadChartWidget::make(),
|
||||
RecentUploadChartWidget::make(),
|
||||
RecentPingChartWidget::make(),
|
||||
RecentJitterChartWidget::make(),
|
||||
RecentDownloadLatencyChartWidget::make(),
|
||||
RecentUploadLatencyChartWidget::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class DataIntegration extends SettingsPage
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-circle-stack';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-database';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Pages\Settings;
|
||||
|
||||
use App\Actions\Notifications\SendAppriseTestNotification;
|
||||
use App\Actions\Notifications\SendDatabaseTestNotification;
|
||||
use App\Actions\Notifications\SendDiscordTestNotification;
|
||||
use App\Actions\Notifications\SendGotifyTestNotification;
|
||||
@@ -12,6 +13,7 @@ use App\Actions\Notifications\SendPushoverTestNotification;
|
||||
use App\Actions\Notifications\SendSlackTestNotification;
|
||||
use App\Actions\Notifications\SendTelegramTestNotification;
|
||||
use App\Actions\Notifications\SendWebhookTestNotification;
|
||||
use App\Rules\AppriseScheme;
|
||||
use App\Settings\NotificationSettings;
|
||||
use CodeWithDennis\SimpleAlert\Components\SimpleAlert;
|
||||
use Filament\Actions\Action;
|
||||
@@ -33,7 +35,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Notification extends SettingsPage
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-bell-ringing';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
@@ -199,6 +201,80 @@ class Notification extends SettingsPage
|
||||
|
||||
// ...
|
||||
]),
|
||||
Tab::make(__('settings/notifications.apprise'))
|
||||
->icon(Heroicon::CloudArrowUp)
|
||||
->schema([
|
||||
SimpleAlert::make('wehbook_info')
|
||||
->title(__('general.documentation'))
|
||||
->description(__('settings/notifications.apprise_hint_description'))
|
||||
->border()
|
||||
->info()
|
||||
->actions([
|
||||
Action::make('webhook_docs')
|
||||
->label(__('general.view_documentation'))
|
||||
->icon('heroicon-m-arrow-long-right')
|
||||
->color('info')
|
||||
->link()
|
||||
->url('https://docs.speedtest-tracker.dev/settings/notifications/apprise')
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('apprise_enabled')
|
||||
->label(__('settings/notifications.enable_apprise_notifications'))
|
||||
->reactive()
|
||||
->columnSpanFull(),
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
])
|
||||
->hidden(fn (Get $get) => $get('apprise_enabled') !== true)
|
||||
->schema([
|
||||
Fieldset::make(__('settings/notifications.apprise_server'))
|
||||
->schema([
|
||||
TextInput::make('apprise_server_url')
|
||||
->label(__('settings/notifications.apprise_server_url'))
|
||||
->placeholder('http://localhost:8000')
|
||||
->maxLength(2000)
|
||||
->required()
|
||||
->url()
|
||||
->columnSpanFull(),
|
||||
Checkbox::make('apprise_verify_ssl')
|
||||
->label(__('settings/notifications.apprise_verify_ssl'))
|
||||
->default(true)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Fieldset::make(__('settings.triggers'))
|
||||
->schema([
|
||||
Checkbox::make('apprise_on_speedtest_run')
|
||||
->label(__('settings/notifications.notify_on_every_speedtest_run'))
|
||||
->columnSpanFull(),
|
||||
Checkbox::make('apprise_on_threshold_failure')
|
||||
->label(__('settings/notifications.notify_on_threshold_failures'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Repeater::make('apprise_channel_urls')
|
||||
->label(__('settings/notifications.apprise_channels'))
|
||||
->schema([
|
||||
TextInput::make('channel_url')
|
||||
->label(__('settings/notifications.apprise_channel_url'))
|
||||
->placeholder('discord://WebhookID/WebhookToken')
|
||||
->helperText(__('settings/notifications.apprise_channel_url_helper'))
|
||||
->maxLength(2000)
|
||||
->distinct()
|
||||
->required()
|
||||
->rule(new AppriseScheme),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Actions::make([
|
||||
Action::make('test apprise')
|
||||
->label(__('settings/notifications.test_apprise_channel'))
|
||||
->action(fn (Get $get) => SendAppriseTestNotification::run(
|
||||
channel_urls: $get('apprise_channel_urls'),
|
||||
))
|
||||
->hidden(fn (Get $get) => ! count($get('apprise_channel_urls'))),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Thresholds extends SettingsPage
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-alert-triangle';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class ResultResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Result::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-table';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
|
||||
@@ -14,9 +14,7 @@ class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'tabler-users';
|
||||
|
||||
protected static ?int $navigationSort = 4;
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Enums\ResultStatus;
|
||||
use App\Helpers\Number;
|
||||
use App\Models\Result;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class StatsOverviewWidget extends BaseWidget
|
||||
{
|
||||
public ?Result $result = null;
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
$this->result = Result::query()
|
||||
->select(['id', 'ping', 'download', 'upload', 'status', 'created_at'])
|
||||
->where('status', '=', ResultStatus::Completed)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (blank($this->result)) {
|
||||
return [
|
||||
Stat::make(__('dashboard.latest_download'), '-')
|
||||
->icon('heroicon-o-arrow-down-tray'),
|
||||
Stat::make(__('dashboard.latest_upload'), '-')
|
||||
->icon('heroicon-o-arrow-up-tray'),
|
||||
Stat::make(__('dashboard.latest_ping'), '-')
|
||||
->icon('heroicon-o-clock'),
|
||||
];
|
||||
}
|
||||
|
||||
$previous = Result::query()
|
||||
->select(['id', 'ping', 'download', 'upload', 'status', 'created_at'])
|
||||
->where('id', '<', $this->result->id)
|
||||
->where('status', '=', ResultStatus::Completed)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $previous) {
|
||||
return [
|
||||
Stat::make(__('dashboard.latest_download'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->download_bits, precision: 2) : 'n/a')
|
||||
->icon('heroicon-o-arrow-down-tray'),
|
||||
Stat::make(__('dashboard.latest_upload'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->upload_bits, precision: 2) : 'n/a')
|
||||
->icon('heroicon-o-arrow-up-tray'),
|
||||
Stat::make(__('dashboard.latest_ping'), fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' ms' : 'n/a')
|
||||
->icon('heroicon-o-clock'),
|
||||
];
|
||||
}
|
||||
|
||||
$downloadChange = percentChange($this->result->download, $previous->download, 2);
|
||||
$uploadChange = percentChange($this->result->upload, $previous->upload, 2);
|
||||
$pingChange = percentChange($this->result->ping, $previous->ping, 2);
|
||||
|
||||
return [
|
||||
Stat::make(__('dashboard.latest_download'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->download_bits, precision: 2) : 'n/a')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->description($downloadChange > 0 ? $downloadChange.'% '.__('general.faster') : abs($downloadChange).'% '.__('general.slower'))
|
||||
->descriptionIcon($downloadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->color($downloadChange > 0 ? 'success' : 'danger'),
|
||||
Stat::make(__('dashboard.latest_upload'), fn (): string => ! blank($this->result) ? Number::toBitRate(bits: $this->result->upload_bits, precision: 2) : 'n/a')
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
->description($uploadChange > 0 ? $uploadChange.'% '.__('general.faster') : abs($uploadChange).'% '.__('general.slower'))
|
||||
->descriptionIcon($uploadChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->color($uploadChange > 0 ? 'success' : 'danger'),
|
||||
Stat::make(__('dashboard.latest_ping'), fn (): string => ! blank($this->result) ? number_format($this->result->ping, 2).' ms' : 'n/a')
|
||||
->icon('heroicon-o-clock')
|
||||
->description($pingChange > 0 ? $pingChange.'% '.__('general.slower') : abs($pingChange).'% '.__('general.faster'))
|
||||
->descriptionIcon($pingChange > 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->color($pingChange > 0 ? 'danger' : 'success'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ResultStatus;
|
||||
use App\Models\Result;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
@@ -13,14 +11,6 @@ class HomeController extends Controller
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$latestResult = Result::query()
|
||||
->select(['id', 'ping', 'download', 'upload', 'status', 'created_at'])
|
||||
->where('status', '=', ResultStatus::Completed)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return view('dashboard', [
|
||||
'latestResult' => $latestResult,
|
||||
]);
|
||||
return view('dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\SpeedtestCompleted;
|
||||
use App\Helpers\Number;
|
||||
use App\Mail\CompletedSpeedtestMail;
|
||||
use App\Models\Result;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Apprise\SpeedtestNotification;
|
||||
use App\Settings\NotificationSettings;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\WebhookServer\WebhookCall;
|
||||
|
||||
class ProcessCompletedSpeedtest
|
||||
@@ -29,7 +33,7 @@ class ProcessCompletedSpeedtest
|
||||
|
||||
$result->loadMissing(['dispatchedBy']);
|
||||
|
||||
// $this->notifyAppriseChannels($result);
|
||||
$this->notifyAppriseChannels($result);
|
||||
$this->notifyDatabaseChannels($result);
|
||||
$this->notifyDispatchingUser($result);
|
||||
$this->notifyMailChannels($result);
|
||||
@@ -42,11 +46,50 @@ class ProcessCompletedSpeedtest
|
||||
private function notifyAppriseChannels(Result $result): void
|
||||
{
|
||||
// Don't send Apprise notification if dispatched by a user or test is unhealthy.
|
||||
if (filled($result->dispatched_by) || ! $result->healthy) {
|
||||
if (filled($result->dispatched_by) || $result->healthy === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Check if Apprise notifications are enabled.
|
||||
if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_speedtest_run) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! count($this->notificationSettings->apprise_channel_urls)) {
|
||||
Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the speedtest data
|
||||
$body = view('apprise.speedtest-completed', [
|
||||
'id' => $result->id,
|
||||
'service' => Str::title($result->service->getLabel()),
|
||||
'serverName' => $result->server_name,
|
||||
'serverId' => $result->server_id,
|
||||
'isp' => $result->isp,
|
||||
'ping' => round($result->ping).' ms',
|
||||
'download' => Number::toBitRate(bits: $result->download_bits, precision: 2),
|
||||
'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2),
|
||||
'packetLoss' => $result->packet_loss,
|
||||
'speedtest_url' => $result->result_url,
|
||||
'url' => url('/admin/results'),
|
||||
])->render();
|
||||
|
||||
$title = 'Speedtest Completed – #'.$result->id;
|
||||
|
||||
// Send notification to each configured channel URL
|
||||
foreach ($this->notificationSettings->apprise_channel_urls as $row) {
|
||||
$channelUrl = $row['channel_url'] ?? null;
|
||||
if (! $channelUrl) {
|
||||
Log::warning('Skipping entry with missing channel_url.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Notification::route('apprise_urls', $channelUrl)
|
||||
->notify(new SpeedtestNotification($title, $body, 'info'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +108,7 @@ class ProcessCompletedSpeedtest
|
||||
}
|
||||
|
||||
foreach (User::all() as $user) {
|
||||
Notification::make()
|
||||
FilamentNotification::make()
|
||||
->title(__('results.speedtest_completed'))
|
||||
->actions([
|
||||
Action::make('view')
|
||||
@@ -87,7 +130,7 @@ class ProcessCompletedSpeedtest
|
||||
}
|
||||
|
||||
$result->dispatchedBy->notify(
|
||||
Notification::make()
|
||||
FilamentNotification::make()
|
||||
->title(__('results.speedtest_completed'))
|
||||
->actions([
|
||||
Action::make('view')
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\SpeedtestBenchmarkFailed;
|
||||
use App\Helpers\Number;
|
||||
use App\Mail\UnhealthySpeedtestMail;
|
||||
use App\Models\Result;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Apprise\SpeedtestNotification;
|
||||
use App\Settings\NotificationSettings;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\WebhookServer\WebhookCall;
|
||||
|
||||
class ProcessUnhealthySpeedtest
|
||||
@@ -31,7 +35,7 @@ class ProcessUnhealthySpeedtest
|
||||
|
||||
$result->loadMissing(['dispatchedBy']);
|
||||
|
||||
// $this->notifyAppriseChannels($result);
|
||||
$this->notifyAppriseChannels($result);
|
||||
$this->notifyDatabaseChannels($result);
|
||||
$this->notifyDispatchingUser($result);
|
||||
$this->notifyMailChannels($result);
|
||||
@@ -48,7 +52,79 @@ class ProcessUnhealthySpeedtest
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_threshold_failure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! count($this->notificationSettings->apprise_channel_urls)) {
|
||||
Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($result->benchmarks)) {
|
||||
Log::warning('Benchmark data not found, won\'t send Apprise notification.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Build metrics array from failed benchmarks
|
||||
$failed = [];
|
||||
|
||||
foreach ($result->benchmarks as $metric => $benchmark) {
|
||||
if ($benchmark['passed'] === false) {
|
||||
$failed[] = [
|
||||
'name' => ucfirst($metric),
|
||||
'threshold' => $benchmark['value'].' '.$benchmark['unit'],
|
||||
'value' => $this->formatMetricValue($metric, $result),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! count($failed)) {
|
||||
Log::warning('No failed thresholds found in benchmarks, won\'t send Apprise notification.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$body = view('apprise.speedtest-threshold', [
|
||||
'id' => $result->id,
|
||||
'service' => Str::title($result->service->getLabel()),
|
||||
'serverName' => $result->server_name,
|
||||
'serverId' => $result->server_id,
|
||||
'isp' => $result->isp,
|
||||
'metrics' => $failed,
|
||||
'speedtest_url' => $result->result_url,
|
||||
'url' => url('/admin/results'),
|
||||
])->render();
|
||||
|
||||
$title = 'Speedtest Threshold Breach – #'.$result->id;
|
||||
|
||||
// Send notification to each configured channel URL
|
||||
foreach ($this->notificationSettings->apprise_channel_urls as $row) {
|
||||
$channelUrl = $row['channel_url'] ?? null;
|
||||
if (! $channelUrl) {
|
||||
Log::warning('Skipping entry with missing channel_url.');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Notification::route('apprise_urls', $channelUrl)
|
||||
->notify(new SpeedtestNotification($title, $body, 'warning'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format metric value for display in notification.
|
||||
*/
|
||||
private function formatMetricValue(string $metric, Result $result): string
|
||||
{
|
||||
return match ($metric) {
|
||||
'download' => Number::toBitRate(bits: $result->download_bits, precision: 2),
|
||||
'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2),
|
||||
'ping' => round($result->ping, 2).' ms',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +143,7 @@ class ProcessUnhealthySpeedtest
|
||||
}
|
||||
|
||||
foreach (User::all() as $user) {
|
||||
Notification::make()
|
||||
FilamentNotification::make()
|
||||
->title(__('results.speedtest_benchmark_failed'))
|
||||
->actions([
|
||||
Action::make('view')
|
||||
@@ -89,7 +165,7 @@ class ProcessUnhealthySpeedtest
|
||||
}
|
||||
|
||||
$result->dispatchedBy->notify(
|
||||
Notification::make()
|
||||
FilamentNotification::make()
|
||||
->title(__('results.speedtest_benchmark_failed'))
|
||||
->actions([
|
||||
Action::make('view')
|
||||
@@ -106,7 +182,7 @@ class ProcessUnhealthySpeedtest
|
||||
*/
|
||||
private function notifyMailChannels(Result $result): void
|
||||
{
|
||||
// Don't send webhook if dispatched by a user.
|
||||
// Don't send mail if dispatched by a user.
|
||||
if (filled($result->dispatched_by)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\ResultStatus;
|
||||
use App\Models\Result;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class LatestResultStats extends Component
|
||||
{
|
||||
#[Computed]
|
||||
public function latestResult(): ?Result
|
||||
{
|
||||
return Result::where('status', ResultStatus::Completed)
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.latest-result-stats');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\ResultStatus;
|
||||
use App\Models\Result;
|
||||
use Carbon\Carbon;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class PlatformStats extends Component
|
||||
{
|
||||
#[Computed]
|
||||
public function nextSpeedtest(): ?Carbon
|
||||
{
|
||||
if ($schedule = config('speedtest.schedule')) {
|
||||
$cronExpression = new CronExpression($schedule);
|
||||
|
||||
return Carbon::parse(time: $cronExpression->getNextRunDate(timeZone: config('app.display_timezone')));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function platformStats(): array
|
||||
{
|
||||
$totalResults = Result::count();
|
||||
$completedResults = Result::where('status', ResultStatus::Completed)->count();
|
||||
$failedResults = Result::where('status', ResultStatus::Failed)->count();
|
||||
|
||||
return [
|
||||
'total' => Number::format($totalResults),
|
||||
'completed' => Number::format($completedResults),
|
||||
'failed' => Number::format($failedResults),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.platform-stats');
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,21 @@ use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Enums\IconPosition;
|
||||
use Filament\Support\Enums\Size;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class RunSpeedtestAction extends Component implements HasActions, HasForms
|
||||
class Actions extends Component implements HasActions, HasForms
|
||||
{
|
||||
use InteractsWithActions, InteractsWithForms;
|
||||
|
||||
public function dashboardAction(): Action
|
||||
{
|
||||
return Action::make('home')
|
||||
->label(__('results.public_dashboard'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->iconPosition(IconPosition::Before)
|
||||
return Action::make('metrics')
|
||||
->iconButton()
|
||||
->icon('tabler-chart-histogram')
|
||||
->color('gray')
|
||||
->url(shouldOpenInNewTab: true, url: route('home'))
|
||||
->url(url: route('home'))
|
||||
->extraAttributes([
|
||||
'id' => 'dashboardAction',
|
||||
]);
|
||||
@@ -61,13 +61,14 @@ class RunSpeedtestAction extends Component implements HasActions, HasForms
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->modalHeading(__('results.run_speedtest'))
|
||||
->modalHeading(__('results.speedtest'))
|
||||
->modalWidth('lg')
|
||||
->modalSubmitActionLabel(__('results.start'))
|
||||
->button()
|
||||
->size(Size::Medium)
|
||||
->color('primary')
|
||||
->label(__('results.speedtest'))
|
||||
->icon('heroicon-o-rocket-launch')
|
||||
->icon('tabler-rocket')
|
||||
->iconPosition(IconPosition::Before)
|
||||
->hidden(! Auth::check() && Auth::user()->is_admin)
|
||||
->extraAttributes([
|
||||
@@ -77,6 +78,6 @@ class RunSpeedtestAction extends Component implements HasActions, HasForms
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.topbar.run-speedtest-action');
|
||||
return view('livewire.topbar.actions');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Apprise;
|
||||
|
||||
class AppriseMessage
|
||||
{
|
||||
public function __construct(
|
||||
public string|array $urls,
|
||||
public string $title,
|
||||
public string $body,
|
||||
public string $type = 'info',
|
||||
public string $format = 'text',
|
||||
public ?string $tag = null,
|
||||
) {}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
return new self(
|
||||
urls: '',
|
||||
title: '',
|
||||
body: '',
|
||||
);
|
||||
}
|
||||
|
||||
public function urls(string|array $urls): self
|
||||
{
|
||||
$this->urls = $urls;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function title(string $title): self
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function body(string $body): self
|
||||
{
|
||||
$this->body = $body;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function type(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function format(string $format): self
|
||||
{
|
||||
$this->format = $format;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tag(string $tag): self
|
||||
{
|
||||
$this->tag = $tag;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Apprise;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class SpeedtestNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $body,
|
||||
public string $type = 'info',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['apprise'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Apprise message representation of the notification.
|
||||
*/
|
||||
public function toApprise(object $notifiable): AppriseMessage
|
||||
{
|
||||
return AppriseMessage::create()
|
||||
->urls($notifiable->routes['apprise_urls'])
|
||||
->title($this->title)
|
||||
->body($this->body)
|
||||
->type($this->type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Apprise;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TestNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['apprise'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Apprise message representation of the notification.
|
||||
*/
|
||||
public function toApprise(object $notifiable): AppriseMessage
|
||||
{
|
||||
return AppriseMessage::create()
|
||||
->urls($notifiable->routes['apprise_urls'])
|
||||
->title('Test Notification')
|
||||
->body('👋 Testing the Apprise notification channel.')
|
||||
->type('info');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Settings\NotificationSettings;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -20,34 +21,54 @@ class AppriseChannel
|
||||
return;
|
||||
}
|
||||
|
||||
$appriseUrl = config('services.apprise.url');
|
||||
$settings = app(NotificationSettings::class);
|
||||
$appriseUrl = rtrim($settings->apprise_server_url ?? '', '/');
|
||||
|
||||
if (empty($appriseUrl)) {
|
||||
Log::warning('Apprise notification skipped: No Server URL configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)
|
||||
$request = Http::timeout(5)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
// ->when(true, function ($http) {
|
||||
// $http->withoutVerifying();
|
||||
// })
|
||||
->post("{$appriseUrl}/notify", [
|
||||
'urls' => $message->urls,
|
||||
'title' => $message->title,
|
||||
'body' => $message->body,
|
||||
'type' => $message->type ?? 'info',
|
||||
'format' => $message->format ?? 'text',
|
||||
'tag' => $message->tag ?? null,
|
||||
]);
|
||||
|
||||
// If SSL verification is disabled in settings, skip it
|
||||
if (! $settings->apprise_verify_ssl) {
|
||||
$request = $request->withoutVerifying();
|
||||
}
|
||||
|
||||
$response = $request->post("{$appriseUrl}/notify", [
|
||||
'urls' => $message->urls,
|
||||
'title' => $message->title,
|
||||
'body' => $message->body,
|
||||
'type' => $message->type ?? 'info',
|
||||
'format' => $message->format ?? 'text',
|
||||
'tag' => $message->tag ?? null,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Apprise notification failed', [
|
||||
'channel' => $message->urls,
|
||||
'instance' => $appriseUrl,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
} else {
|
||||
Log::info('Apprise notification sent', [
|
||||
'channel' => $message->urls,
|
||||
'instance' => $appriseUrl,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Apprise notification exception', [
|
||||
'channel' => $message->urls ?? 'unknown',
|
||||
'instance' => $appriseUrl,
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace App\Providers;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AppriseChannel;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Notifications\ChannelManager;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -44,12 +47,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->defineGates();
|
||||
$this->forceHttps();
|
||||
$this->setApiRateLimit();
|
||||
$this->registerNotificationChannels();
|
||||
|
||||
AboutCommand::add('Speedtest Tracker', fn () => [
|
||||
'Version' => config('speedtest.build_version'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom notification channels.
|
||||
*/
|
||||
protected function registerNotificationChannels(): void
|
||||
{
|
||||
Notification::resolved(function (ChannelManager $service) {
|
||||
$service->extend('apprise', function ($app) {
|
||||
return new AppriseChannel;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Define custom if statements, these were added to make the blade templates more readable.
|
||||
*
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Services\GitHub\Repository;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@@ -59,25 +57,8 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
->navigationGroups([
|
||||
NavigationGroup::make()
|
||||
->label(__('general.settings')),
|
||||
NavigationGroup::make()
|
||||
->label(__('general.links'))
|
||||
->label(__('general.settings'))
|
||||
->collapsible(false),
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make(__('general.documentation'))
|
||||
->url('https://docs.speedtest-tracker.dev/', shouldOpenInNewTab: true)
|
||||
->icon('heroicon-o-book-open')
|
||||
->group(__('general.links')),
|
||||
NavigationItem::make(__('general.donate'))
|
||||
->url('https://github.com/sponsors/alexjustesen', shouldOpenInNewTab: true)
|
||||
->icon('heroicon-o-banknotes')
|
||||
->group(__('general.links')),
|
||||
NavigationItem::make(config('speedtest.build_version'))
|
||||
->url('https://github.com/alexjustesen/speedtest-tracker', shouldOpenInNewTab: true)
|
||||
->icon('tabler-brand-github')
|
||||
->badge(fn (): string => Repository::updateAvailable() ? __('general.update_available') : __('general.up_to_date'))
|
||||
->group(__('general.links')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class FilamentServiceProvider extends ServiceProvider
|
||||
{
|
||||
FilamentView::registerRenderHook(
|
||||
PanelsRenderHook::GLOBAL_SEARCH_BEFORE,
|
||||
fn (): string => Blade::render("@livewire('topbar.run-speedtest-action')"),
|
||||
fn (): string => Blade::render("@livewire('topbar.actions')"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class AppriseScheme implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string):PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (str_starts_with(strtolower($value), 'http')) {
|
||||
$fail(__('settings/notifications.apprise_channel_url_validation_error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,18 @@ class NotificationSettings extends Settings
|
||||
|
||||
public ?array $gotify_webhooks;
|
||||
|
||||
public bool $apprise_enabled;
|
||||
|
||||
public ?string $apprise_server_url;
|
||||
|
||||
public bool $apprise_on_speedtest_run;
|
||||
|
||||
public bool $apprise_on_threshold_failure;
|
||||
|
||||
public bool $apprise_verify_ssl;
|
||||
|
||||
public ?array $apprise_channel_urls;
|
||||
|
||||
public static function group(): string
|
||||
{
|
||||
return 'notification';
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
depends_on:
|
||||
- pgsql
|
||||
- mailpit
|
||||
- apprise
|
||||
pgsql:
|
||||
image: 'postgres:17-alpine'
|
||||
ports:
|
||||
@@ -55,9 +56,29 @@ services:
|
||||
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
|
||||
networks:
|
||||
- sail
|
||||
apprise:
|
||||
image: 'caronc/apprise:latest'
|
||||
ports:
|
||||
- '${FORWARD_APPRISE_PORT:-8000}:8000'
|
||||
volumes:
|
||||
- 'sail-apprise:/config'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- 'wget'
|
||||
- '--quiet'
|
||||
- '--tries=1'
|
||||
- '--spider'
|
||||
- 'http://localhost:8000/health'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
sail-pgsql:
|
||||
driver: local
|
||||
sail-apprise:
|
||||
driver: local
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"maennchen/zipstream-php": "^2.4",
|
||||
"promphp/prometheus_client_php": "^2.14.1",
|
||||
"saloonphp/laravel-plugin": "^3.7",
|
||||
"secondnetwork/blade-tabler-icons": "^3.35.0",
|
||||
"secondnetwork/blade-tabler-icons": "^3.35",
|
||||
"spatie/laravel-json-api-paginate": "^1.16.3",
|
||||
"spatie/laravel-query-builder": "^6.3.6",
|
||||
"spatie/laravel-settings": "^3.6.0",
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "eb6b685d0e7829bbf17c30d67fccb511",
|
||||
"content-hash": "405d221f03e4de1894ce759a9e751448",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
return [
|
||||
|
||||
'apprise' => [
|
||||
'url' => env('APPRISE_URL', 'http://apprise:8000'),
|
||||
],
|
||||
|
||||
'telegram-bot-api' => [
|
||||
'token' => env('TELEGRAM_BOT_TOKEN'),
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ return [
|
||||
*/
|
||||
'build_date' => Carbon::parse('2025-12-05'),
|
||||
|
||||
'build_version' => 'v1.11.2',
|
||||
'build_version' => 'v1.12.0',
|
||||
|
||||
'content_width' => env('CONTENT_WIDTH', '7xl'),
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
||||
|
||||
return new class extends SettingsMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->migrator->add('notification.apprise_enabled', false);
|
||||
$this->migrator->add('notification.apprise_server_url', null);
|
||||
$this->migrator->add('notification.apprise_on_speedtest_run', false);
|
||||
$this->migrator->add('notification.apprise_on_threshold_failure', false);
|
||||
$this->migrator->add('notification.apprise_verify_ssl', true);
|
||||
$this->migrator->add('notification.apprise_channel_urls', null);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'current_version' => 'Current version',
|
||||
'latest_version' => 'Latest version',
|
||||
'github' => 'GitHub',
|
||||
'repository' => 'Repository',
|
||||
|
||||
// Common actions
|
||||
'save' => 'Save',
|
||||
'cancel' => 'Cancel',
|
||||
@@ -32,6 +37,8 @@ return [
|
||||
'created_at' => 'Created at',
|
||||
'updated_at' => 'Updated at',
|
||||
'url' => 'URL',
|
||||
'stats' => 'Stats',
|
||||
'statistics' => 'Statistics',
|
||||
|
||||
// Navigation
|
||||
'dashboard' => 'Dashboard',
|
||||
@@ -42,6 +49,7 @@ return [
|
||||
'view_documentation' => 'View documentation',
|
||||
'links' => 'Links',
|
||||
'donate' => 'Donate',
|
||||
'donations' => 'Donations',
|
||||
|
||||
// Roles
|
||||
'admin' => 'Admin',
|
||||
@@ -54,12 +62,15 @@ return [
|
||||
'last_month' => 'Last month',
|
||||
|
||||
// Metrics
|
||||
'metrics' => 'Metrics',
|
||||
'average' => 'Average',
|
||||
'high' => 'High',
|
||||
'low' => 'Low',
|
||||
'faster' => 'faster',
|
||||
'slower' => 'slower',
|
||||
'healthy' => 'Healthy',
|
||||
'not_measured' => 'Not measured',
|
||||
'unhealthy' => 'Unhealthy',
|
||||
|
||||
// Units
|
||||
'ms' => 'ms',
|
||||
|
||||
@@ -72,7 +72,6 @@ return [
|
||||
|
||||
// Run Speedtest Action
|
||||
'speedtest' => 'Speedtest',
|
||||
'public_dashboard' => 'Public Dashboard',
|
||||
'select_server' => 'Select Server',
|
||||
'select_server_helper' => 'Leave empty to run the speedtest without specifying a server. Blocked servers will be skipped.',
|
||||
'manual_servers' => 'Manual servers',
|
||||
|
||||
@@ -14,6 +14,19 @@ return [
|
||||
'recipients' => 'Recipients',
|
||||
'test_mail_channel' => 'Test mail channel',
|
||||
|
||||
// Apprise notifications
|
||||
'apprise' => 'Apprise',
|
||||
'enable_apprise_notifications' => 'Enable Apprise notifications',
|
||||
'apprise_server' => 'Apprise Server',
|
||||
'apprise_server_url' => 'Apprise Server URL',
|
||||
'apprise_verify_ssl' => 'Verify SSL',
|
||||
'apprise_channels' => 'Apprise Channels',
|
||||
'apprise_channel_url' => 'Channel URL',
|
||||
'apprise_hint_description' => 'For more information on setting up Apprise, view the documentation.',
|
||||
'apprise_channel_url_helper' => 'Provide the service endpoint URL for notifications.',
|
||||
'test_apprise_channel' => 'Test Apprise',
|
||||
'apprise_channel_url_validation_error' => 'The Apprise channel URL must not start with "http" or "https". Please provide a valid Apprise URL scheme.',
|
||||
|
||||
// Webhook
|
||||
'webhook' => 'Webhook',
|
||||
'webhooks' => 'Webhooks',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@import './custom.css';
|
||||
|
||||
/* Safelist max-width utilities to always generate them */
|
||||
@source inline("max-w-{xs,sm,md,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl,full,min,max,fit,prose,screen-sm,screen-md,screen-lg,screen-xl,screen-2xl}");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.dashboard-page .fi-section-header {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.dashboard-page .fi-section-header .fi-section-header-heading {
|
||||
@apply font-medium text-zinc-600 dark:text-zinc-400;
|
||||
}
|
||||
|
||||
.dashboard-page .fi-section-content-ctn {
|
||||
border-top: none;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||
@import '../../custom.css';
|
||||
|
||||
@source '../../../../app/Filament/**/*';
|
||||
@source '../../../../resources/views/filament/**/*';
|
||||
@@ -6,6 +8,7 @@
|
||||
/* Filament Plugins */
|
||||
@source '../../../../vendor/codewithdennis/filament-simple-alert/resources/**/*.blade.php';
|
||||
@source inline('animate-{spin,pulse,bounce}');
|
||||
@source inline('{bg,text,border,ring}-{amber,zinc}-{50,100,200,300,400,500,600,700,800,900,950}');
|
||||
|
||||
/* Additional styles */
|
||||
.fi-topbar #dashboardAction .fi-btn-label,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
A new speedtest on {{ config('app.name') }} was completed using {{ $service }}.
|
||||
|
||||
Server name: {{ $serverName }}
|
||||
Server ID: {{ $serverId }}
|
||||
ISP: {{ $isp }}
|
||||
Ping: {{ $ping }}
|
||||
Download: {{ $download }}
|
||||
Upload: {{ $upload }}
|
||||
Packet Loss: {{ $packetLoss }} %
|
||||
Ookla Speedtest: {{ $speedtest_url }}
|
||||
URL: {{ $url }}
|
||||
@@ -0,0 +1,7 @@
|
||||
A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached.
|
||||
|
||||
@foreach ($metrics as $item)
|
||||
- **{{ $item['name'] }}** {{ $item['threshold'] }}: {{ $item['value'] }}
|
||||
@endforeach
|
||||
- **Ookla Speedtest:** {{ $speedtest_url }}
|
||||
- **URL:** {{ $url }}
|
||||
@@ -1,39 +1,26 @@
|
||||
<x-app-layout title="Dashboard">
|
||||
<div class="grid gap-4 sm:grid-cols-6 sm:gap-8">
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\StatsOverviewWidget::class)
|
||||
</div>
|
||||
<div class="space-y-6 md:space-y-12 dashboard-page">
|
||||
<livewire:platform-stats />
|
||||
|
||||
@isset($latestResult)
|
||||
<div class="text-sm font-semibold leading-6 text-center col-span-full sm:text-base">
|
||||
Latest result: <time title="{{ $latestResult->created_at->timezone(config('app.display_timezone'))->format(config('app.datetime_format')) }}" datetime="{{ $latestResult->created_at->timezone(config('app.display_timezone')) }}">{{ $latestResult->created_at->diffForHumans() }}</time>
|
||||
</div>
|
||||
@endisset
|
||||
<livewire:latest-result-stats />
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<h2 class="flex items-center gap-x-2 text-base md:text-lg font-semibold text-zinc-900 dark:text-zinc-100 col-span-full">
|
||||
<x-tabler-chart-histogram class="size-5" />
|
||||
Metrics
|
||||
</h2>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentDownloadChartWidget::class)
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentUploadChartWidget::class)
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentPingChartWidget::class)
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentJitterChartWidget::class)
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentDownloadLatencyChartWidget::class)
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
@livewire(\App\Filament\Widgets\RecentUploadLatencyChartWidget::class)
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</x-app-layout>
|
||||
|
||||
@@ -1,3 +1,97 @@
|
||||
<x-filament-panels::page>
|
||||
{{-- Silence is golden --}}
|
||||
<x-filament-panels::page class="dashboard-page">
|
||||
<div class="space-y-6 md:space-y-12">
|
||||
<livewire:platform-stats />
|
||||
|
||||
<livewire:latest-result-stats />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<x-filament::section
|
||||
class="col-span-1"
|
||||
icon="tabler-book"
|
||||
icon-size="md"
|
||||
>
|
||||
<x-slot name="heading">
|
||||
{{ __('general.documentation') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<p>Need help getting started or configuring your speedtests?</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<x-filament::button
|
||||
href="https://docs.speedtest-tracker.dev?utm_source=app&utm_medium=dashboard&utm_campaign=view_documentation"
|
||||
tag="a"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('general.view_documentation') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
class="col-span-1"
|
||||
icon="tabler-cash-banknote-heart"
|
||||
icon-size="md"
|
||||
>
|
||||
<x-slot name="heading">
|
||||
{{ __('general.donations') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<p>Support the development and maintenance of Speedtest Tracker by making a donation.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<x-filament::button
|
||||
href="https://github.com/sponsors/alexjustesen?utm_source=app&utm_medium=dashboard&utm_campaign=donate"
|
||||
tag="a"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('general.donate') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
class="col-span-1"
|
||||
icon="tabler-brand-github"
|
||||
icon-size="md"
|
||||
>
|
||||
<x-slot name="heading">
|
||||
{{ __('general.speedtest_tracker') }}
|
||||
</x-slot>
|
||||
|
||||
@if (\App\Services\GitHub\Repository::updateAvailable())
|
||||
<x-slot name="afterHeader">
|
||||
<x-filament::badge>
|
||||
{{ __('general.update_available') }}
|
||||
</x-filament::badge>
|
||||
</x-slot>
|
||||
@endif
|
||||
|
||||
<ul role="list" class="divide-y divide-zinc-200 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<li class="flex items-center justify-between pb-2">
|
||||
<p class="font-medium">{{ __('general.current_version') }}</p>
|
||||
<p>{{ config('speedtest.build_version') }}</p>
|
||||
</li>
|
||||
|
||||
<li class="flex items-center justify-between">
|
||||
<p class="font-medium">{{ __('general.latest_version') }}</p>
|
||||
<p>{{ \App\Services\GitHub\Repository::getLatestVersion() }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
<x-filament::button
|
||||
href="https://github.com/alexjustesen/speedtest-tracker?utm_source=app&utm_medium=dashboard&utm_campaign=github"
|
||||
tag="a"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('general.github') }} {{ str(__('general.repository'))->lower() }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -40,7 +40,57 @@
|
||||
<h1 class="text-2xl font-bold tracking-tight text-gray-950 dark:text-white sm:text-3xl">{{ $title ?? 'Page Title' }} - {{ config('app.name') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div
|
||||
x-data="{ theme: null }"
|
||||
x-init="
|
||||
theme = localStorage.getItem('theme') || 'system'
|
||||
$watch('theme', () => {
|
||||
localStorage.setItem('theme', theme)
|
||||
const effectiveTheme = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme
|
||||
if (effectiveTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
$dispatch('theme-changed', theme)
|
||||
})
|
||||
"
|
||||
class="flex items-center gap-1 p-1 rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="theme = 'light'"
|
||||
x-bind:class="{ 'bg-white dark:bg-gray-900 shadow-sm': theme === 'light' }"
|
||||
class="p-2 rounded-md transition-all"
|
||||
aria-label="Light mode"
|
||||
>
|
||||
<x-tabler-sun class="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="theme = 'dark'"
|
||||
x-bind:class="{ 'bg-white dark:bg-gray-900 shadow-sm': theme === 'dark' }"
|
||||
class="p-2 rounded-md transition-all"
|
||||
aria-label="Dark mode"
|
||||
>
|
||||
<x-tabler-moon class="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="theme = 'system'"
|
||||
x-bind:class="{ 'bg-white dark:bg-gray-900 shadow-sm': theme === 'system' }"
|
||||
class="p-2 rounded-md transition-all"
|
||||
aria-label="System theme"
|
||||
>
|
||||
<x-tabler-device-desktop class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
href="{{ url('/admin') }}"
|
||||
tag="a"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<div wire:poll.60s>
|
||||
@filled($this->latestResult)
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 latest-result-stats">
|
||||
<div class="flex items-center justify-between col-span-full">
|
||||
<h2 class="flex items-center gap-x-2 text-base md:text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<x-tabler-rocket class="size-5" />
|
||||
Latest result
|
||||
</h2>
|
||||
|
||||
<x-filament::button
|
||||
href="{{ url('admin/results') }}"
|
||||
tag="a"
|
||||
size="sm"
|
||||
>
|
||||
{{ __('general.view') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<x-filament::section class="col-span-1" icon="tabler-ruler" icon-size="md">
|
||||
<x-slot name="heading">
|
||||
Benchmark status
|
||||
</x-slot>
|
||||
|
||||
<div class="flex items-center gap-x-2">
|
||||
@if($this->latestResult->healthy === true)
|
||||
<div class="flex-none rounded-full bg-emerald-500/20 p-1">
|
||||
<div class="size-2 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ __('general.healthy') }}</span>
|
||||
@elseif($this->latestResult->healthy === false)
|
||||
<div class="flex-none rounded-full bg-amber-500/20 p-1">
|
||||
<div class="size-2 rounded-full bg-amber-500"></div>
|
||||
</div>
|
||||
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ __('general.unhealthy') }}</span>
|
||||
@else
|
||||
<div class="flex-none rounded-full bg-zinc-500/20 p-1">
|
||||
<div class="size-2 rounded-full bg-zinc-500"></div>
|
||||
</div>
|
||||
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ __('general.not_measured') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section class="col-span-1" icon="tabler-download" icon-size="md">
|
||||
<x-slot name="heading">
|
||||
{{ __('general.download') }}
|
||||
</x-slot>
|
||||
|
||||
@php
|
||||
$downloadBenchmark = Arr::get($this->latestResult->benchmarks, 'download');
|
||||
$downloadBenchmarkPassed = Arr::get($downloadBenchmark, 'passed', false);
|
||||
@endphp
|
||||
|
||||
@filled($downloadBenchmark)
|
||||
<x-slot name="afterHeader">
|
||||
<span @class([
|
||||
'inline-flex items-center gap-x-1.5 text-xs font-medium underline decoration-dotted decoration-1 decoration-zinc-500 underline-offset-4',
|
||||
'text-zinc-700 dark:text-zinc-300' => $downloadBenchmarkPassed,
|
||||
'text-amber-500 dark:text-amber-400' => ! $downloadBenchmarkPassed,
|
||||
]) title="Benchmark {{ $downloadBenchmarkPassed ? 'passed' : 'failed' }}">
|
||||
@if (! $downloadBenchmarkPassed)
|
||||
<x-tabler-alert-triangle class="size-4" />
|
||||
@endif
|
||||
{{ Arr::get($downloadBenchmark, 'value').' '.str(Arr::get($downloadBenchmark, 'unit'))->title() }}
|
||||
</span>
|
||||
</x-slot>
|
||||
@endfilled
|
||||
|
||||
<p class="flex items-baseline gap-x-2">
|
||||
@php
|
||||
$download = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->download));
|
||||
|
||||
$download = explode(' ', $download);
|
||||
@endphp
|
||||
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $download[0] }}</span>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ $download[1].'ps' }}</span>
|
||||
</p>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section class="col-span-1" icon="tabler-upload" icon-size="md">
|
||||
<x-slot name="heading">
|
||||
{{ __('general.upload') }}
|
||||
</x-slot>
|
||||
|
||||
@php
|
||||
$uploadBenchmark = Arr::get($this->latestResult->benchmarks, 'upload');
|
||||
$uploadBenchmarkPassed = Arr::get($uploadBenchmark, 'passed', false);
|
||||
@endphp
|
||||
|
||||
@filled($uploadBenchmark)
|
||||
<x-slot name="afterHeader">
|
||||
<span @class([
|
||||
'inline-flex items-center gap-x-1.5 text-xs font-medium underline decoration-dotted decoration-1 decoration-zinc-500 underline-offset-4',
|
||||
'text-zinc-700 dark:text-zinc-300' => $uploadBenchmarkPassed,
|
||||
'text-amber-500 dark:text-amber-400' => ! $uploadBenchmarkPassed,
|
||||
]) title="Benchmark {{ $uploadBenchmarkPassed ? 'passed' : 'failed' }}">
|
||||
@if (! $uploadBenchmarkPassed)
|
||||
<x-tabler-alert-triangle class="size-4" />
|
||||
@endif
|
||||
{{ Arr::get($uploadBenchmark, 'value').' '.str(Arr::get($uploadBenchmark, 'unit'))->title() }}
|
||||
</span>
|
||||
</x-slot>
|
||||
@endfilled
|
||||
|
||||
<p class="flex items-baseline gap-x-2">
|
||||
@php
|
||||
$upload = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->upload));
|
||||
|
||||
$upload = explode(' ', $upload);
|
||||
@endphp
|
||||
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $upload[0] }}</span>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ $upload[1].'ps' }}</span>
|
||||
</p>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section class="col-span-1" icon="tabler-clock-bolt" icon-size="sm">
|
||||
<x-slot name="heading">
|
||||
{{ __('general.ping') }}
|
||||
</x-slot>
|
||||
|
||||
@php
|
||||
$pingBenchmark = Arr::get($this->latestResult->benchmarks, 'ping');
|
||||
$pingBenchmarkPassed = Arr::get($pingBenchmark, 'passed', false);
|
||||
@endphp
|
||||
|
||||
@filled($pingBenchmark)
|
||||
<x-slot name="afterHeader">
|
||||
<span @class([
|
||||
'inline-flex items-center gap-x-1.5 text-xs font-medium underline decoration-dotted decoration-1 decoration-zinc-500 underline-offset-4',
|
||||
'text-zinc-700 dark:text-zinc-300' => $pingBenchmarkPassed,
|
||||
'text-amber-500 dark:text-amber-400' => ! $pingBenchmarkPassed,
|
||||
]) title="Benchmark {{ $pingBenchmarkPassed ? 'passed' : 'failed' }}">
|
||||
@if (! $pingBenchmarkPassed)
|
||||
<x-tabler-alert-triangle class="size-4" />
|
||||
@endif
|
||||
{{ Arr::get($pingBenchmark, 'value').' '.str(Arr::get($pingBenchmark, 'unit')) }}
|
||||
</span>
|
||||
</x-slot>
|
||||
@endfilled
|
||||
|
||||
<p class="flex items-baseline gap-x-2">
|
||||
<span class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $this->latestResult?->ping }}</span>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">ms</span>
|
||||
</p>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@endfilled
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<div wire:poll.60s>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<h2 class="flex items-center gap-x-2 text-base md:text-lg font-semibold text-zinc-900 dark:text-zinc-100 col-span-full">
|
||||
<x-tabler-chart-bar class="size-5" />
|
||||
{{ __('general.statistics') }}
|
||||
</h2>
|
||||
|
||||
{{-- <x-filament::section class="col-span-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm/6 font-medium text-zinc-500">Quota Usage</p>
|
||||
<a href="#" class="text-sm font-medium text-zinc-600 hover:text-amber-500 underline">Edit</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-sm font-medium text-body">Bandwidth</span>
|
||||
<span class="text-sm font-medium text-body">450MB of 1 GB</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-zinc-200 rounded-full h-2">
|
||||
<div class="bg-amber-500 h-2 rounded-full" style="width: 45%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section> --}}
|
||||
|
||||
@filled($this->nextSpeedtest)
|
||||
<x-filament::section class="col-span-1">
|
||||
<x-slot name="heading">
|
||||
Next Speedtest in
|
||||
</x-slot>
|
||||
|
||||
<p class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100" title="{{ $this->nextSpeedtest->format('F jS, Y g:i A') }}">{{ $this->nextSpeedtest->diffForHumans() }}</p>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<x-filament::section class="col-span-1 bg-zinc-100 shadow-none">
|
||||
<x-slot name="heading">
|
||||
Next Speedtest in
|
||||
</x-slot>
|
||||
|
||||
<p class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">No scheduled speedtests</p>
|
||||
</x-filament::section>
|
||||
@endfilled
|
||||
|
||||
<x-filament::section class="col-span-1">
|
||||
<x-slot name="heading">
|
||||
Total tests
|
||||
</x-slot>
|
||||
|
||||
<p class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $this->platformStats['total'] }}</p>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section class="col-span-1">
|
||||
<x-slot name="heading">
|
||||
Total successful tests
|
||||
</x-slot>
|
||||
|
||||
<p class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $this->platformStats['completed'] }}</p>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section class="col-span-1">
|
||||
<x-slot name="heading">
|
||||
Total failed tests
|
||||
</x-slot>
|
||||
|
||||
<p class="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{{ $this->platformStats['failed'] }}</p>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="py-3">
|
||||
<div class="flex items-center gap-4">
|
||||
{{ $this->speedtestAction }}
|
||||
|
||||
{{ $this->dashboardAction }}
|
||||
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<div>
|
||||
<div class="flex flex-wrap items-start justify-start gap-3">
|
||||
{{ $this->dashboard }}
|
||||
|
||||
{{ $this->speedtestAction }}
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</div>
|
||||
Reference in New Issue
Block a user