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:
Alex Justesen
2025-12-05 15:33:44 -05:00
committed by GitHub
parent 9211aa4cfe
commit a47e3225e5
44 changed files with 1049 additions and 230 deletions
@@ -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();
}
}
+1 -38
View File
@@ -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';
+77 -1
View File
@@ -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(),
+1 -1
View File
@@ -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'),
];
}
}
+1 -11
View File
@@ -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');
}
}
+49 -6
View File
@@ -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')
+82 -6
View File
@@ -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;
}
+24
View File
@@ -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');
}
}
+45
View File
@@ -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');
}
}
+29 -8
View File
@@ -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,17 +21,27 @@ 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", [
]);
// 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,
@@ -41,13 +52,23 @@ class AppriseChannel
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),
]);
}
}
+16
View File
@@ -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.
*
+1 -20
View File
@@ -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')),
]);
}
}
+1 -1
View File
@@ -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')"),
);
}
}
+22
View File
@@ -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'));
}
}
}
+12
View File
@@ -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';
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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",
-4
View File
@@ -2,10 +2,6 @@
return [
'apprise' => [
'url' => env('APPRISE_URL', 'http://apprise:8000'),
],
'telegram-bot-api' => [
'token' => env('TELEGRAM_BOT_TOKEN'),
],
+1 -1
View File
@@ -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);
}
};
+11
View File
@@ -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',
-1
View File
@@ -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',
+13
View File
@@ -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
View File
@@ -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}");
+11
View File
@@ -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;
}
+3
View File
@@ -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 }}
+9 -22
View File
@@ -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>
+51 -1
View File
@@ -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>