diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7ec51536b..04653336d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,8 @@ // https://aka.ms/devcontainer.json { - "name": "Existing Docker Compose (Extend)", + "name": "Speedtest Tracker Dev Environment", "dockerComposeFile": [ - "../docker-compose.yml" + "../compose.yaml" ], "service": "laravel.test", "workspaceFolder": "/var/www/html", @@ -20,7 +20,7 @@ } }, "remoteUser": "sail", - "postCreateCommand": "chown -R 1000:1000 /var/www/html 2>/dev/null || true" + "postCreateCommand": "composer install && npm install && npm run build && touch database/database.sqlite && php artisan migrate:fresh --force" // "forwardPorts": [], // "runServices": [], // "shutdownAction": "none", diff --git a/.env.example b/.env.example index 1a762a1ce..b09ac0014 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=sqlite +#DB_HOST= +#DB_PORT= +#DB_DATABASE= +#DB_USERNAME= +#DB_PASSWORD= SESSION_DRIVER=cookie SESSION_LIFETIME=10080 @@ -42,3 +47,7 @@ MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="Speedtest Tracker" VITE_APP_NAME="${APP_NAME}" + +# For the Dev Container +# WWWUSER=1000 +# WWWGROUP=1000 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index cf8b5abcb..ab4407dcc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -2,67 +2,100 @@ name: Bug Report description: Use this template to report a bug or issue. title: "[Question] " labels: ["question", "needs review"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to report this issue! We appreciate your help in improving the project. If this report is confirmed as a bug, we’ll update its type accordingly. - Please note: - - For **feature requests or changes**, use the [feature request form](https://github.com/alexjustesen/speedtest-tracker/issues/new?template=feature_request.yml). - - For **general questions**, **setup or configuration help**, or if you’re not sure this is a bug, please use **[GitHub Discussions](https://github.com/alexjustesen/speedtest-tracker/discussions)** instead. - - Any isseus with translations should be reported/solved within the [crowdin project](https://crowdin.com/project/speedtest-tracker). +body: - type: checkboxes + id: terms attributes: - label: Pre-work + label: Welcome! description: | - Before opening an issue make sure you've checked the resources below first, any issues that could have been solved by reading the docs or existing issues will be closed. + The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/alexjustesen/speedtest-tracker/discussions)** instead + + Please note: + - For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/speedtest-tracker). + - Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed. + - We only support installations that follow the methods described in the documentation. Installations using third-party or undocumented methods are not supported by the project. + options: - - label: I have read the [docs](https://docs.speedtest-tracker.dev). + - label: I have read the [documentation](https://docs.speedtest-tracker.dev) and my problem was not listed in the help section. + required: true + - label: I have searched open and closed issues and my problem was not mentioned before. required: true - - label: I have searched open and closed issues. + - label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/alexjustesen/speedtest-tracker/releases). required: true - label: I agree to follow this project's [Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md). required: true + - type: textarea id: description attributes: - label: Description - description: Explain the issue you experienced, please be clear and concise. - placeholder: I went to the coffee pot and it was empty. + label: What did you do? + description: | + How to write a good bug report? + + - Respect the issue template as much as possible. + - The title should be short and descriptive. + - Explain the conditions which led you to report this issue: the context. + - The context should lead to something, a problem that you’re facing. + - Remain clear and concise. + - Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown) validations: required: true + - type: textarea id: expected-behavior attributes: label: Expected Behavior - description: In a perfect world, what should have happened? + description: | + In a perfect world, what should have happened? + **Important:** Be specific. Vague descriptions like "it should work" are not helpful. placeholder: When I got to the coffee pot, it should have been full. validations: required: true + - type: textarea id: steps-to-reproduce attributes: label: Steps to Reproduce - description: Describe how to reproduce the issue in repeatable steps. + description: | + Provide detailed, numbered steps that someone else can follow to reproduce the issue. + **Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed. + Include specific actions, URLs, button clicks, and any relevant data or configuration. placeholder: | 1. Go to the coffee pot. 2. Make more coffee. 3. Pour it into a cup. + 4. Observe that the cup is empty instead of full. validations: required: true + - type: dropdown id: deployment-environment attributes: label: Deployment Environment - description: How did you deploy the application? + description: How did you deploy the application? Only supported deployment methods are listed. options: - Docker Compose - Docker Run - - Other default: 0 validations: required: true + + - type: textarea + id: environment-configuration + attributes: + label: What is your environment & configuration? + description: Please add your docker compose file or docker run command used to deploy the application. + placeholder: Add information here. + value: | + ```yaml + # (paste your configuration here) + ``` + + Add more configuration information here. + validations: + required: true + - type: textarea id: application-information attributes: @@ -71,6 +104,7 @@ body: render: json validations: required: true + - type: input id: browsers attributes: @@ -78,9 +112,12 @@ body: placeholder: Chrome, Firefox, Safari, etc. validations: required: true + - type: textarea id: logs attributes: label: Logs - description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. If you are unsure which logs to include, include all logs. You can get the logs by running `docker logs `. render: shell + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index aa13ebc08..bdd144689 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -2,22 +2,28 @@ name: Feature Request description: Use this template for requesting a new feature or change. title: "[Feature] " labels: ["feature", "needs review"] + body: - - type: markdown - attributes: - value: | - You should only use this form to request a change or new feature, to report a bug or issue use the [bug report form](https://github.com/alexjustesen/speedtest-tracker). - Any reqeusts for new translations should be reqeusted within the [crowdin project](https://crowdin.com/project/speedtest-tracker). - type: checkboxes attributes: - label: Pre-work + label: Welcome! + description: | + The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/alexjustesen/speedtest-tracker/discussions)** instead + + Please note: + - For **Bug reports**, use the [Bug Form](https://github.com/alexjustesen/speedtest-tracker/issues/new?template=bug_report.yml). + - Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/speedtest-tracker). + options: - - label: I have searched open and closed feature request to make sure this or similar feature request does not already exist. + - label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist. + required: true + - label: I have reviewed the [Milestones](https://github.com/alexjustesen/speedtest-tracker/milestones) to ensure that this feature request, or a similar one, has not already been proposed. required: true - - label: I have reviewed the [milestones](https://github.com/alexjustesen/speedtest-tracker/milestones) to ensure that this feature request, or a similar one, has not already been proposed. + - label: This is a feature request, not a bug report or support question. required: true - - label: I agree to follow this project's [Code of Conduct](). + - label: I agree to follow this project's [Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md). required: true + - type: dropdown id: idea-section attributes: @@ -28,14 +34,23 @@ body: - Notifications - Speedtest - Web UI/UX + - Other default: 0 validations: required: true + - type: textarea id: description attributes: label: Description - description: Describe the solution or feature you'd like, you should also mention if this solves a problem. - placeholder: Be sure to keep it clear and concise. + description: | + Describe the solution or feature you'd like. Explain what problem this solves or what value it adds. + **Important:** Be specific and detailed. Vague requests like "make it better" will be closed. + placeholder: | + Example: + - What is the feature? + - What problem does it solve? + - How should it work? + - Why would this be valuable? validations: required: true diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 73817d100..f6e40687f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Generate GitHub App token id: generate_token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v2 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -19,7 +19,7 @@ jobs: repositories: docker-speedtest-tracker - name: Trigger docker-speedtest-tracker build - uses: peter-evans/repository-dispatch@v3 + uses: peter-evans/repository-dispatch@v4 with: token: ${{ steps.generate_token.outputs.token }} repository: alexjustesen/docker-speedtest-tracker diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe74743b..1e6db763d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -57,7 +57,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -65,7 +65,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -115,7 +115,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -123,7 +123,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -173,7 +173,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -181,7 +181,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -231,7 +231,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -239,7 +239,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -289,7 +289,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -297,7 +297,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -347,7 +347,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -355,7 +355,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -405,7 +405,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -413,7 +413,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} @@ -454,7 +454,7 @@ jobs: php-version: '8.4' - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: vendor key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} @@ -462,7 +462,7 @@ jobs: composer-${{ runner.os }}- - name: Cache NPM dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/inactivity-actions.yml b/.github/workflows/inactivity-actions.yml index e4f4ab5b9..8cf93b3c6 100644 --- a/.github/workflows/inactivity-actions.yml +++ b/.github/workflows/inactivity-actions.yml @@ -14,7 +14,7 @@ jobs: name: Lock Inactive Issues runs-on: ubuntu-24.04 steps: - - uses: klaasnicolaas/action-inactivity-lock@v1.1.3 + - uses: klaasnicolaas/action-inactivity-lock@v2.0.1 id: lock with: days-inactive-issues: 14 diff --git a/app/Actions/CheckInternetConnection.php b/app/Actions/CheckInternetConnection.php deleted file mode 100644 index 22e26f78d..000000000 --- a/app/Actions/CheckInternetConnection.php +++ /dev/null @@ -1,33 +0,0 @@ -timeout(5) - ->get(config('speedtest.checkinternet_url')); - - if (! $response->ok()) { - return false; - } - - return Str::trim($response->body()); - } catch (Throwable $e) { - Log::error('Failed to connect to the internet.', [$e->getMessage()]); - - return false; - } - } -} diff --git a/app/Actions/GetExternalIpAddress.php b/app/Actions/GetExternalIpAddress.php index 6a4d0b114..d47cc0a6f 100644 --- a/app/Actions/GetExternalIpAddress.php +++ b/app/Actions/GetExternalIpAddress.php @@ -12,18 +12,28 @@ class GetExternalIpAddress { use AsAction; - public function handle(): bool|string + public function handle(?string $url = null): array { + $url = $url ?? config('speedtest.preflight.external_ip_url'); + try { $response = Http::retry(3, 100) ->timeout(5) - ->get(url: 'https://icanhazip.com/'); + ->get(url: $url); } catch (Throwable $e) { - Log::error('Failed to fetch external IP address.', [$e->getMessage()]); + $message = sprintf('Failed to fetch external IP address from "%s". See the logs for more details.', $url); + + Log::error($message, [$e->getMessage()]); - return false; + return [ + 'ok' => false, + 'body' => $message, + ]; } - return Str::trim($response->body()); + return [ + 'ok' => $response->ok(), + 'body' => Str::of($response->body())->trim()->toString(), + ]; } } diff --git a/app/Actions/Notifications/SendAppriseTestNotification.php b/app/Actions/Notifications/SendAppriseTestNotification.php new file mode 100644 index 000000000..64bba779b --- /dev/null +++ b/app/Actions/Notifications/SendAppriseTestNotification.php @@ -0,0 +1,100 @@ +title('You need to add Apprise channel URLs!') + ->warning() + ->send(); + + return; + } + + $settings = app(NotificationSettings::class); + $appriseUrl = rtrim($settings->apprise_server_url ?? '', '/'); + + if (empty($appriseUrl)) { + Notification::make() + ->title('Apprise Server URL is not configured') + ->body('Please configure the Apprise Server URL in the settings above.') + ->danger() + ->send(); + + return; + } + + try { + foreach ($channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + continue; + } + + // Use notifyNow() to send synchronously even though notification implements ShouldQueue + // This allows us to catch exceptions and show them in the UI immediately + FacadesNotification::route('apprise_urls', $channelUrl) + ->notifyNow(new TestNotification); + } + } catch (Throwable $e) { + $errorMessage = $this->cleanErrorMessage($e); + + Notification::make() + ->title('Failed to send Apprise test notification') + ->body($errorMessage) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Test Apprise notification sent.') + ->success() + ->send(); + } + + /** + * Clean up error message for display in UI. + */ + protected function cleanErrorMessage(Throwable $e): string + { + $message = $e->getMessage(); + + // Get the full Apprise server URL for error messages + $settings = app(NotificationSettings::class); + $appriseUrl = rtrim($settings->apprise_server_url ?? '', '/'); + + // Handle connection errors - extract just the important part + if (str_contains($message, 'cURL error')) { + if (str_contains($message, 'Could not resolve host')) { + return "Could not connect to Apprise server at {$appriseUrl}"; + } + + if (str_contains($message, 'Connection refused')) { + return "Connection refused by Apprise server at {$appriseUrl}"; + } + + if (str_contains($message, 'Operation timed out')) { + return "Connection to Apprise server at {$appriseUrl} timed out"; + } + + return "Failed to connect to Apprise server at {$appriseUrl}"; + } + + return $message; + } +} diff --git a/app/Actions/Notifications/SendWebhookTestNotification.php b/app/Actions/Notifications/SendWebhookTestNotification.php index 9ae6d922d..7842c6ba7 100644 --- a/app/Actions/Notifications/SendWebhookTestNotification.php +++ b/app/Actions/Notifications/SendWebhookTestNotification.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Helpers\Number; use App\Models\Result; use App\Services\SpeedtestFakeResultGenerator; use Filament\Notifications\Notification; @@ -33,16 +34,18 @@ public function handle(array $webhooks) ->payload([ 'result_id' => Str::uuid(), 'site_name' => __('settings/notifications.test_notifications.webhook.payload'), + 'server_name' => $fakeResult->data['server']['name'], + 'server_id' => $fakeResult->data['server']['id'], 'isp' => $fakeResult->data['isp'], - 'ping' => $fakeResult->ping, - 'download' => $fakeResult->download, - 'upload' => $fakeResult->upload, - 'packetLoss' => $fakeResult->data['packetLoss'], + 'ping' => round($fakeResult->ping), + 'download' => Number::bitsToMagnitude(bits: $fakeResult->upload, precision: 0, magnitude: 'mbit'), + 'upload' => Number::bitsToMagnitude(bits: $fakeResult->download, precision: 0, magnitude: 'mbit'), + 'packet_loss' => $fakeResult->data['packetLoss'], 'speedtest_url' => $fakeResult->data['result']['url'], 'url' => url('/admin/results'), ]) ->doNotSign() - ->dispatch(); + ->dispatchSync(); } Notification::make() diff --git a/app/Actions/PingHostname.php b/app/Actions/PingHostname.php new file mode 100644 index 000000000..04884fe31 --- /dev/null +++ b/app/Actions/PingHostname.php @@ -0,0 +1,36 @@ +run(); + + $data = $ping->toArray(); + unset($data['raw_output'], $data['lines']); + + Log::debug('Pinged hostname', [ + 'host' => $hostname, + 'data' => $data, + ]); + + return $ping; + } +} diff --git a/app/Events/SpeedtestBenchmarkFailed.php b/app/Events/SpeedtestBenchmarkHealthy.php similarity index 90% rename from app/Events/SpeedtestBenchmarkFailed.php rename to app/Events/SpeedtestBenchmarkHealthy.php index b00175b13..2838d442b 100644 --- a/app/Events/SpeedtestBenchmarkFailed.php +++ b/app/Events/SpeedtestBenchmarkHealthy.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class SpeedtestBenchmarkFailed +class SpeedtestBenchmarkHealthy { use Dispatchable, SerializesModels; diff --git a/app/Events/SpeedtestBenchmarkPassed.php b/app/Events/SpeedtestBenchmarkUnhealthy.php similarity index 90% rename from app/Events/SpeedtestBenchmarkPassed.php rename to app/Events/SpeedtestBenchmarkUnhealthy.php index ab8e9ae82..92b706e35 100644 --- a/app/Events/SpeedtestBenchmarkPassed.php +++ b/app/Events/SpeedtestBenchmarkUnhealthy.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class SpeedtestBenchmarkPassed +class SpeedtestBenchmarkUnhealthy { use Dispatchable, SerializesModels; diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index 6db680a00..bc6d7591d 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -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 @@ public static function getNavigationLabel(): string { 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(), - ]; - } } diff --git a/app/Filament/Pages/Settings/DataIntegration.php b/app/Filament/Pages/Settings/DataIntegration.php index d61f41df8..67a66bf52 100644 --- a/app/Filament/Pages/Settings/DataIntegration.php +++ b/app/Filament/Pages/Settings/DataIntegration.php @@ -23,7 +23,7 @@ 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'; @@ -62,7 +62,7 @@ public function form(Schema $schema): Schema ->schema([ Toggle::make('influxdb_v2_enabled') ->label(__('settings/data_integration.influxdb_v2_enabled')) - ->helpertext(__('settings/data_integration.influxdb_v2_description')) + ->helperText(__('settings/data_integration.influxdb_v2_description')) ->reactive() ->columnSpanFull(), Grid::make(['default' => 1, 'md' => 3]) diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php index 915225265..c01ee4e1f 100755 --- a/app/Filament/Pages/Settings/Notification.php +++ b/app/Filament/Pages/Settings/Notification.php @@ -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,8 @@ use App\Actions\Notifications\SendSlackTestNotification; use App\Actions\Notifications\SendTelegramTestNotification; use App\Actions\Notifications\SendWebhookTestNotification; +use App\Rules\AppriseScheme; +use App\Rules\ContainsString; use App\Settings\NotificationSettings; use CodeWithDennis\SimpleAlert\Components\SimpleAlert; use Filament\Actions\Action; @@ -33,7 +36,7 @@ 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'; @@ -83,10 +86,11 @@ public function form(Schema $schema): Schema ->columns(1) ->schema([ Checkbox::make('database_on_speedtest_run') - ->label(__('settings/notifications.notify_on_every_speedtest_run')), - + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')), Checkbox::make('database_on_threshold_failure') - ->label(__('settings/notifications.notify_on_threshold_failures')), + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')), ]), Actions::make([ @@ -115,10 +119,11 @@ public function form(Schema $schema): Schema ->columns(1) ->schema([ Checkbox::make('mail_on_speedtest_run') - ->label(__('settings/notifications.notify_on_every_speedtest_run')), - + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')), Checkbox::make('mail_on_threshold_failure') - ->label(__('settings/notifications.notify_on_threshold_failures')), + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')), ]), Repeater::make('mail_recipients') @@ -173,10 +178,11 @@ public function form(Schema $schema): Schema ->columns(1) ->schema([ Checkbox::make('webhook_on_speedtest_run') - ->label(__('settings/notifications.notify_on_every_speedtest_run')), - + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')), Checkbox::make('webhook_on_threshold_failure') - ->label(__('settings/notifications.notify_on_threshold_failures')), + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')), ]), Repeater::make('webhook_urls') @@ -199,6 +205,89 @@ public function form(Schema $schema): Schema // ... ]), + 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/notify') + ->helperText(__('settings/notifications.apprise_server_url_helper')) + ->maxLength(2000) + ->required() + ->url() + ->rule(new ContainsString('/notify')) + ->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')) + ->helpertext(__('settings/notifications.notify_on_every_speedtest_run_helper')) + ->columnSpanFull(), + Checkbox::make('apprise_on_threshold_failure') + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->helpertext(__('settings/notifications.notify_on_threshold_failures_helper')) + ->columnSpanFull(), + ]), + Repeater::make('apprise_channel_urls') + ->label(__('settings/notifications.apprise_channels')) + ->helperText(__('settings/notifications.apprise_save_to_test')) + ->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(function () { + $settings = app(NotificationSettings::class); + + return empty($settings->apprise_server_url) || ! count($settings->apprise_channel_urls ?? []); + }), + ]), + ]), + ]), ]) ->columnSpanFull(), diff --git a/app/Filament/Pages/Settings/Thresholds.php b/app/Filament/Pages/Settings/Thresholds.php index 6c8adb4ab..1953ff52a 100644 --- a/app/Filament/Pages/Settings/Thresholds.php +++ b/app/Filament/Pages/Settings/Thresholds.php @@ -15,7 +15,7 @@ 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'; diff --git a/app/Filament/Pages/Tools/ListOoklaServers.php b/app/Filament/Pages/Tools/ListOoklaServers.php index d2d5ea3eb..c82ad82b3 100644 --- a/app/Filament/Pages/Tools/ListOoklaServers.php +++ b/app/Filament/Pages/Tools/ListOoklaServers.php @@ -7,9 +7,9 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; -use Filament\Forms\Form; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Schemas\Schema; class ListOoklaServers extends Page implements HasForms { @@ -69,10 +69,10 @@ public function fetchServers(): void } } - public function form(Form $form): Form + public function form(Schema $schema): Schema { - return $form - ->schema([ + return $schema + ->components([ Textarea::make('servers') ->label(false) ->rows(20) diff --git a/app/Filament/Resources/Results/ResultResource.php b/app/Filament/Resources/Results/ResultResource.php index 6efd4a9f4..5ff0893d7 100644 --- a/app/Filament/Resources/Results/ResultResource.php +++ b/app/Filament/Resources/Results/ResultResource.php @@ -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 { diff --git a/app/Filament/Resources/Results/Tables/ResultTable.php b/app/Filament/Resources/Results/Tables/ResultTable.php index 7c1f4765e..6808e9f7d 100644 --- a/app/Filament/Resources/Results/Tables/ResultTable.php +++ b/app/Filament/Resources/Results/Tables/ResultTable.php @@ -4,8 +4,8 @@ use App\Enums\ResultStatus; use App\Filament\Exports\ResultExporter; +use App\Filament\Tables\Columns\ResultServerColumn; use App\Helpers\Number; -use App\Jobs\TruncateResults; use App\Models\Result; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -50,19 +50,9 @@ public static function table(Table $table): Table ->label(__('results.service')) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('data.server.id') - ->label(__('results.server_id')) - ->toggleable(isToggledHiddenByDefault: false) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->server->id', $direction); - }), - - TextColumn::make('data.server.name') - ->label(__('results.server_name')) - ->toggleable(isToggledHiddenByDefault: false) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->server->name', $direction); - }), + ResultServerColumn::make('server') + ->label(__('general.server')) + ->toggleable(isToggledHiddenByDefault: false), TextColumn::make('download') ->label(__('results.download')) @@ -102,26 +92,6 @@ public static function table(Table $table): Table return number_format((float) $state, 0, '.', '').' ms'; }), - TextColumn::make('data.download.latency.high') - ->label(__('results.download_latency_high')) - ->toggleable(isToggledHiddenByDefault: true) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->download->latency->high', $direction); - }) - ->formatStateUsing(function ($state) { - return number_format((float) $state, 0, '.', '').' ms'; - }), - - TextColumn::make('data.download.latency.low') - ->label(__('results.download_latency_low')) - ->toggleable(isToggledHiddenByDefault: true) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->download->latency->low', $direction); - }) - ->formatStateUsing(function ($state) { - return number_format((float) $state, 0, '.', '').' ms'; - }), - TextColumn::make('data.upload.latency.jitter') ->label(__('results.upload_latency_jitter')) ->toggleable(isToggledHiddenByDefault: true) @@ -132,26 +102,6 @@ public static function table(Table $table): Table return number_format((float) $state, 0, '.', '').' ms'; }), - TextColumn::make('data.upload.latency.high') - ->label(__('results.upload_latency_high')) - ->toggleable(isToggledHiddenByDefault: true) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->upload->latency->high', $direction); - }) - ->formatStateUsing(function ($state) { - return number_format((float) $state, 0, '.', '').' ms'; - }), - - TextColumn::make('data.upload.latency.low') - ->label(__('results.upload_latency_low')) - ->toggleable(isToggledHiddenByDefault: true) - ->sortable(query: function (Builder $query, string $direction): Builder { - return $query->orderBy('data->upload->latency->low', $direction); - }) - ->formatStateUsing(function ($state) { - return number_format((float) $state, 0, '.', '').' ms'; - }), - IconColumn::make('healthy') ->label(__('general.healthy')) ->boolean() @@ -172,11 +122,9 @@ public static function table(Table $table): Table ->label(__('general.created_at')) ->dateTime(config('app.datetime_format')) ->timezone(config('app.display_timezone')) - ->toggleable(isToggledHiddenByDefault: true) + ->toggleable(isToggledHiddenByDefault: false) ->sortable(), ]) - ->deferFilters(false) - ->deferColumnManager(false) ->filters([ Filter::make('created_at') ->label(__('general.created_at')) @@ -201,6 +149,7 @@ public static function table(Table $table): Table fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date), ); }), + SelectFilter::make('ip_address') ->label(__('results.ip_address')) ->multiple() @@ -218,6 +167,7 @@ public static function table(Table $table): Table ->toArray(); }) ->attribute('data->interface->externalIp'), + SelectFilter::make('server_name') ->label(__('results.server_name')) ->multiple() @@ -235,6 +185,25 @@ public static function table(Table $table): Table ->toArray(); }) ->attribute('data->server->name'), + + SelectFilter::make('server_id') + ->label(__('results.server_id')) + ->multiple() + ->options(function (): array { + return Result::query() + ->select('data->server->id AS data_server_id') + ->whereNotNull('data->server->id') + ->where('status', '=', ResultStatus::Completed) + ->distinct() + ->orderBy('data->server->id') + ->get() + ->mapWithKeys(function (Result $item, int $key) { + return [$item['data_server_id'] => $item['data_server_id']]; + }) + ->toArray(); + }) + ->attribute('data->server->id'), + TernaryFilter::make('scheduled') ->label(__('results.scheduled')) ->nullable() @@ -246,10 +215,12 @@ public static function table(Table $table): Table false: fn (Builder $query) => $query->where('scheduled', false), blank: fn (Builder $query) => $query, ), + SelectFilter::make('status') ->label(__('general.status')) ->multiple() ->options(ResultStatus::class), + TernaryFilter::make('healthy') ->label(__('general.healthy')) ->nullable() @@ -293,30 +264,15 @@ public static function table(Table $table): Table ]) ->toolbarActions([ DeleteBulkAction::make(), - ]) - ->headerActions([ ExportAction::make() ->exporter(ResultExporter::class) ->columnMapping(false) ->modalHeading(__('results.export_all_results')) ->modalDescription(__('results.export_all_results_description')) ->fileName(fn (): string => 'results-'.now()->timestamp), - ActionGroup::make([ - Action::make('truncate') - ->label(__('results.truncate_results')) - ->action(fn () => TruncateResults::dispatch(Auth::user())) - ->requiresConfirmation() - ->modalHeading(__('results.truncate_results')) - ->modalDescription(__('results.truncate_results_description')) - ->color('danger') - ->icon('heroicon-o-trash') - ->hidden(fn (): bool => ! Auth::user()->is_admin), - ]) - ->dropdownPlacement('left-start'), ]) ->defaultSort('id', 'desc') ->paginationPageOptions([10, 25, 50]) - ->deferLoading() ->poll('60s'); } } diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php index 914172ab8..c1b2d958d 100644 --- a/app/Filament/Resources/Users/UserResource.php +++ b/app/Filament/Resources/Users/UserResource.php @@ -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; diff --git a/app/Filament/Tables/Columns/ResultServerColumn.php b/app/Filament/Tables/Columns/ResultServerColumn.php new file mode 100644 index 000000000..357951e15 --- /dev/null +++ b/app/Filament/Tables/Columns/ResultServerColumn.php @@ -0,0 +1,28 @@ +serverName = $this->record->server_name; + + return $this->serverName; + } + + public function getServerId(): ?int + { + $this->serverId = $this->record->server_id; + + return $this->serverId; + } +} diff --git a/app/Filament/Widgets/Concerns/HasChartFilters.php b/app/Filament/Widgets/Concerns/HasChartFilters.php index ce12d9384..a792e07c9 100644 --- a/app/Filament/Widgets/Concerns/HasChartFilters.php +++ b/app/Filament/Widgets/Concerns/HasChartFilters.php @@ -7,9 +7,9 @@ trait HasChartFilters protected function getFilters(): ?array { return [ - '24h' => 'Last 24 hours', - 'week' => 'Last 7 days', - 'month' => 'Last 30 days', + '24h' => __('general.last_24h'), + 'week' => __('general.last_week'), + 'month' => __('general.last_month'), ]; } } diff --git a/app/Filament/Widgets/RecentDownloadChartWidget.php b/app/Filament/Widgets/RecentDownloadChartWidget.php index da62367ac..098708648 100644 --- a/app/Filament/Widgets/RecentDownloadChartWidget.php +++ b/app/Filament/Widgets/RecentDownloadChartWidget.php @@ -38,13 +38,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'download', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php b/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php index 1e73db54f..e06c86c87 100644 --- a/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php +++ b/app/Filament/Widgets/RecentDownloadLatencyChartWidget.php @@ -36,13 +36,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentJitterChartWidget.php b/app/Filament/Widgets/RecentJitterChartWidget.php index ff598cafd..03dd59b13 100644 --- a/app/Filament/Widgets/RecentJitterChartWidget.php +++ b/app/Filament/Widgets/RecentJitterChartWidget.php @@ -36,13 +36,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentPingChartWidget.php b/app/Filament/Widgets/RecentPingChartWidget.php index b31c02530..096a190ec 100644 --- a/app/Filament/Widgets/RecentPingChartWidget.php +++ b/app/Filament/Widgets/RecentPingChartWidget.php @@ -37,13 +37,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'ping', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentUploadChartWidget.php b/app/Filament/Widgets/RecentUploadChartWidget.php index df3d15ffb..1bb96eb04 100644 --- a/app/Filament/Widgets/RecentUploadChartWidget.php +++ b/app/Filament/Widgets/RecentUploadChartWidget.php @@ -38,13 +38,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'upload', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/RecentUploadLatencyChartWidget.php b/app/Filament/Widgets/RecentUploadLatencyChartWidget.php index 5b79fa0c5..90315ddd9 100644 --- a/app/Filament/Widgets/RecentUploadLatencyChartWidget.php +++ b/app/Filament/Widgets/RecentUploadLatencyChartWidget.php @@ -36,13 +36,13 @@ protected function getData(): array $results = Result::query() ->select(['id', 'data', 'created_at']) ->where('status', '=', ResultStatus::Completed) - ->when($this->filter == '24h', function ($query) { + ->when($this->filter === '24h', function ($query) { $query->where('created_at', '>=', now()->subDay()); }) - ->when($this->filter == 'week', function ($query) { + ->when($this->filter === 'week', function ($query) { $query->where('created_at', '>=', now()->subWeek()); }) - ->when($this->filter == 'month', function ($query) { + ->when($this->filter === 'month', function ($query) { $query->where('created_at', '>=', now()->subMonth()); }) ->orderBy('created_at') diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php deleted file mode 100644 index 8178c10e3..000000000 --- a/app/Filament/Widgets/StatsOverviewWidget.php +++ /dev/null @@ -1,76 +0,0 @@ -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'), - ]; - } -} diff --git a/app/Http/Controllers/Api/V1/SpeedtestController.php b/app/Http/Controllers/Api/V1/SpeedtestController.php index 19519f61e..d605d945c 100644 --- a/app/Http/Controllers/Api/V1/SpeedtestController.php +++ b/app/Http/Controllers/Api/V1/SpeedtestController.php @@ -37,6 +37,7 @@ public function __invoke(Request $request) } $result = RunSpeedtestAction::run( + scheduled: true, serverId: $request->input('server_id'), dispatchedBy: $request->user()->id, ); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 0b6d0e906..543c692e4 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -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'); } } diff --git a/app/Jobs/CheckForInternetConnectionJob.php b/app/Jobs/CheckForInternetConnectionJob.php index 37c5e6f04..c0f39a61f 100644 --- a/app/Jobs/CheckForInternetConnectionJob.php +++ b/app/Jobs/CheckForInternetConnectionJob.php @@ -2,7 +2,7 @@ namespace App\Jobs; -use App\Actions\CheckInternetConnection; +use App\Actions\PingHostname; use App\Enums\ResultStatus; use App\Events\SpeedtestChecking; use App\Events\SpeedtestFailed; @@ -44,14 +44,18 @@ public function handle(): void SpeedtestChecking::dispatch($this->result); - if (CheckInternetConnection::run() !== false) { + $ping = PingHostname::run(); + + if ($ping->isSuccess()) { return; } + $message = sprintf('Failed to connected to hostname "%s". Error received "%s".', $ping->getHost(), $ping->error()?->value); + $this->result->update([ 'data->type' => 'log', 'data->level' => 'error', - 'data->message' => 'Failed to connect to the internet.', + 'data->message' => $message, 'status' => ResultStatus::Failed, ]); diff --git a/app/Jobs/Ookla/BenchmarkSpeedtestJob.php b/app/Jobs/Ookla/BenchmarkSpeedtestJob.php index c1298a147..683fe395b 100644 --- a/app/Jobs/Ookla/BenchmarkSpeedtestJob.php +++ b/app/Jobs/Ookla/BenchmarkSpeedtestJob.php @@ -3,10 +3,11 @@ namespace App\Jobs\Ookla; use App\Enums\ResultStatus; -use App\Events\SpeedtestBenchmarkFailed; +use App\Events\SpeedtestBenchmarkHealthy; use App\Events\SpeedtestBenchmarking; -use App\Events\SpeedtestBenchmarkPassed; +use App\Events\SpeedtestBenchmarkUnhealthy; use App\Helpers\Benchmark; +use App\Helpers\Number; use App\Models\Result; use App\Settings\ThresholdSettings; use Illuminate\Bus\Batchable; @@ -70,8 +71,8 @@ public function handle(): void ]); $this->healthy - ? SpeedtestBenchmarkPassed::dispatch($this->result) - : SpeedtestBenchmarkFailed::dispatch($this->result); + ? SpeedtestBenchmarkHealthy::dispatch($this->result) + : SpeedtestBenchmarkUnhealthy::dispatch($this->result); } private function benchmark(Result $result, ThresholdSettings $settings): array @@ -83,7 +84,8 @@ private function benchmark(Result $result, ThresholdSettings $settings): array 'bar' => 'min', 'passed' => Benchmark::bitrate($result->download, ['value' => $settings->absolute_download, 'unit' => 'mbps']), 'type' => 'absolute', - 'value' => $settings->absolute_download, + 'test_value' => Number::bitsToMagnitude(bits: $result->download_bits, precision: 0, magnitude: 'mbit'), + 'benchmark_value' => $settings->absolute_download, 'unit' => 'mbps', ]); @@ -97,7 +99,8 @@ private function benchmark(Result $result, ThresholdSettings $settings): array 'bar' => 'min', 'passed' => filter_var(Benchmark::bitrate($result->upload, ['value' => $settings->absolute_upload, 'unit' => 'mbps']), FILTER_VALIDATE_BOOLEAN), 'type' => 'absolute', - 'value' => $settings->absolute_upload, + 'test_value' => Number::bitsToMagnitude(bits: $result->upload_bits, precision: 0, magnitude: 'mbit'), + 'benchmark_value' => $settings->absolute_upload, 'unit' => 'mbps', ]); @@ -111,7 +114,8 @@ private function benchmark(Result $result, ThresholdSettings $settings): array 'bar' => 'max', 'passed' => Benchmark::ping($result->ping, ['value' => $settings->absolute_ping]), 'type' => 'absolute', - 'value' => $settings->absolute_ping, + 'test_value' => round($result->ping), + 'benchmark_value' => $settings->absolute_ping, 'unit' => 'ms', ]); diff --git a/app/Jobs/Ookla/RunSpeedtestJob.php b/app/Jobs/Ookla/RunSpeedtestJob.php index 486faab6f..61b371939 100644 --- a/app/Jobs/Ookla/RunSpeedtestJob.php +++ b/app/Jobs/Ookla/RunSpeedtestJob.php @@ -60,6 +60,7 @@ public function handle(): void 'speedtest', '--accept-license', '--accept-gdpr', + '--selection-details', '--format=json', $this->result->server_id ? '--server-id='.$this->result->server_id : null, config('speedtest.interface') ? '--interface='.config('speedtest.interface') : null, diff --git a/app/Jobs/Ookla/SkipSpeedtestJob.php b/app/Jobs/Ookla/SkipSpeedtestJob.php index 13c444133..773d4a793 100644 --- a/app/Jobs/Ookla/SkipSpeedtestJob.php +++ b/app/Jobs/Ookla/SkipSpeedtestJob.php @@ -4,6 +4,7 @@ use App\Actions\GetExternalIpAddress; use App\Enums\ResultStatus; +use App\Events\SpeedtestFailed; use App\Events\SpeedtestSkipped; use App\Helpers\Network; use App\Models\Result; @@ -39,16 +40,31 @@ public function middleware(): array public function handle(): void { /** - * Only skip IPs for scheduled tests. + * Skip if test is not scheduled or no IPs are configured to skip. */ - if ($this->result->scheduled === false) { + if ($this->result->scheduled === false || empty(config('speedtest.preflight.skip_ips'))) { return; } $externalIp = GetExternalIpAddress::run(); + if ($externalIp['ok'] === false) { + $this->result->update([ + 'data->type' => 'log', + 'data->level' => 'error', + 'data->message' => $externalIp['body'], + 'status' => ResultStatus::Failed, + ]); + + SpeedtestFailed::dispatch($this->result); + + $this->batch()->cancel(); + + return; + } + $shouldSkip = $this->shouldSkip( - externalIp: $externalIp, + externalIp: $externalIp['body'], ); if ($shouldSkip === false) { @@ -76,11 +92,11 @@ private function shouldSkip(string $externalIp): bool|string $skipIPs = array_filter( array_map( 'trim', - explode(',', config('speedtest.skip_ips')), + explode(',', config('speedtest.preflight.skip_ips')), ), ); - if (count($skipIPs) < 1) { + if (empty($skipIPs)) { return false; } diff --git a/app/Jobs/TruncateResults.php b/app/Jobs/TruncateResults.php deleted file mode 100644 index e597fc2d3..000000000 --- a/app/Jobs/TruncateResults.php +++ /dev/null @@ -1,48 +0,0 @@ -truncate(); - } catch (Throwable $th) { - $this->fail($th); - - return; - } - - Notification::make() - ->title(__('results.truncate_results_success')) - ->success() - ->sendToDatabase($this->user); - } -} diff --git a/app/Listeners/LogWebhookFailure.php b/app/Listeners/LogWebhookFailure.php new file mode 100644 index 000000000..d9884b873 --- /dev/null +++ b/app/Listeners/LogWebhookFailure.php @@ -0,0 +1,21 @@ + $event->webhookUrl, + 'error_type' => $event->errorType, + 'error_message' => $event->errorMessage, + ]); + } +} diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php index 228151750..c1399c076 100644 --- a/app/Listeners/ProcessCompletedSpeedtest.php +++ b/app/Listeners/ProcessCompletedSpeedtest.php @@ -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 @@ -27,11 +31,17 @@ public function handle(SpeedtestCompleted $event): void { $result = $event->result; - $result->loadMissing(['dispatchedBy']); + if ($result->healthy === false) { + return; + } + + // Don't send notifications for unscheduled speedtests. + if ($result->unscheduled) { + return; + } - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); - $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); $this->notifyWebhookChannels($result); } @@ -41,12 +51,46 @@ public function handle(SpeedtestCompleted $event): void */ 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) { + // 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 ? round($result->packet_loss, precision: 2) : '', + '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', 'markdown')); + } } /** @@ -54,18 +98,13 @@ private function notifyAppriseChannels(Result $result): void */ private function notifyDatabaseChannels(Result $result): void { - // Don't send database notification if dispatched by a user or test is unhealthy. - if (filled($result->dispatched_by) || $result->healthy === false) { - return; - } - // Check if database notifications are enabled. if (! $this->notificationSettings->database_enabled || ! $this->notificationSettings->database_on_speedtest_run) { return; } foreach (User::all() as $user) { - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_completed')) ->actions([ Action::make('view') @@ -77,37 +116,11 @@ private function notifyDatabaseChannels(Result $result): void } } - /** - * Notify the user who dispatched the speedtest. - */ - private function notifyDispatchingUser(Result $result): void - { - if (empty($result->dispatched_by) || ! $result->healthy) { - return; - } - - $result->dispatchedBy->notify( - Notification::make() - ->title(__('results.speedtest_completed')) - ->actions([ - Action::make('view') - ->label(__('general.view')) - ->url(route('filament.admin.resources.results.index')), - ]) - ->success() - ->toDatabase(), - ); - } - /** * Notify mail channels. */ private function notifyMailChannels(Result $result): void { - if (filled($result->dispatched_by) || $result->healthy === false) { - return; - } - if (! $this->notificationSettings->mail_enabled || ! $this->notificationSettings->mail_on_speedtest_run) { return; } @@ -129,11 +142,6 @@ private function notifyMailChannels(Result $result): void */ private function notifyWebhookChannels(Result $result): void { - // Don't send webhook if dispatched by a user or test is unhealthy. - if (filled($result->dispatched_by) || $result->healthy === false) { - return; - } - // Check if webhook notifications are enabled. if (! $this->notificationSettings->webhook_enabled || ! $this->notificationSettings->webhook_on_speedtest_run) { return; @@ -154,10 +162,11 @@ private function notifyWebhookChannels(Result $result): void 'site_name' => config('app.name'), 'server_name' => Arr::get($result->data, 'server.name'), 'server_id' => Arr::get($result->data, 'server.id'), + 'status' => $result->status, 'isp' => Arr::get($result->data, 'isp'), - 'ping' => $result->ping, - 'download' => $result->downloadBits, - 'upload' => $result->uploadBits, + 'ping' => round($result->ping), + 'download' => Number::bitsToMagnitude(bits: $result->download_bits, precision: 0, magnitude: 'mbit'), + 'upload' => Number::bitsToMagnitude(bits: $result->upload_bits, precision: 0, magnitude: 'mbit'), 'packet_loss' => Arr::get($result->data, 'packetLoss'), 'speedtest_url' => Arr::get($result->data, 'result.url'), 'url' => url('/admin/results'), diff --git a/app/Listeners/ProcessFailedSpeedtest.php b/app/Listeners/ProcessFailedSpeedtest.php index 8ba154387..3cc4d9cd8 100644 --- a/app/Listeners/ProcessFailedSpeedtest.php +++ b/app/Listeners/ProcessFailedSpeedtest.php @@ -4,8 +4,6 @@ use App\Events\SpeedtestFailed; use App\Models\Result; -use Filament\Actions\Action; -use Filament\Notifications\Notification; class ProcessFailedSpeedtest { @@ -16,10 +14,12 @@ public function handle(SpeedtestFailed $event): void { $result = $event->result; - $result->loadMissing(['dispatchedBy']); + // Don't send notifications for unscheduled speedtests. + if ($result->unscheduled) { + return; + } // $this->notifyAppriseChannels($result); - $this->notifyDispatchingUser($result); } /** @@ -27,33 +27,6 @@ public function handle(SpeedtestFailed $event): void */ 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) { - return; - } - // } - - /** - * Notify the user who dispatched the speedtest. - */ - private function notifyDispatchingUser(Result $result): void - { - if (empty($result->dispatched_by)) { - return; - } - - $result->dispatchedBy->notify( - Notification::make() - ->title(__('results.speedtest_failed')) - ->actions([ - Action::make('view') - ->label(__('general.view')) - ->url(route('filament.admin.resources.results.index')), - ]) - ->warning() - ->toDatabase(), - ); - } } diff --git a/app/Listeners/ProcessUnhealthySpeedtest.php b/app/Listeners/ProcessUnhealthySpeedtest.php index 5a6d0e057..288f60739 100644 --- a/app/Listeners/ProcessUnhealthySpeedtest.php +++ b/app/Listeners/ProcessUnhealthySpeedtest.php @@ -2,15 +2,19 @@ namespace App\Listeners; -use App\Events\SpeedtestBenchmarkFailed; +use App\Events\SpeedtestBenchmarkUnhealthy; +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 @@ -25,15 +29,17 @@ public function __construct( /** * Handle the event. */ - public function handle(SpeedtestBenchmarkFailed $event): void + public function handle(SpeedtestBenchmarkUnhealthy $event): void { $result = $event->result; - $result->loadMissing(['dispatchedBy']); + // Don't send notifications for unscheduled speedtests. + if ($result->unscheduled) { + return; + } - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); - $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); $this->notifyWebhookChannels($result); } @@ -43,62 +49,102 @@ public function handle(SpeedtestBenchmarkFailed $event): void */ private function notifyAppriseChannels(Result $result): void { - // Don't send Apprise notification if dispatched by a user. - if (filled($result->dispatched_by)) { + 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.'); - /** - * Notify database channels. - */ - private function notifyDatabaseChannels(Result $result): void - { - // Don't send database notification if dispatched by a user. - if (filled($result->dispatched_by)) { return; } - // Check if database notifications are enabled. - if (! $this->notificationSettings->database_enabled || ! $this->notificationSettings->database_on_threshold_failure) { + if (empty($result->benchmarks)) { + Log::warning('Benchmark data not found, won\'t send Apprise notification.'); + return; } - foreach (User::all() as $user) { - Notification::make() - ->title(__('results.speedtest_benchmark_failed')) - ->actions([ - Action::make('view') - ->label(__('general.view')) - ->url(route('filament.admin.resources.results.index')), - ]) - ->success() - ->sendToDatabase($user); + // 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', 'markdown')); } } /** - * Notify the user who dispatched the speedtest. + * Format metric value for display in notification. */ - private function notifyDispatchingUser(Result $result): void + private function formatMetricValue(string $metric, Result $result): string { - if (empty($result->dispatched_by)) { + 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 => '', + }; + } + + /** + * Notify database channels. + */ + private function notifyDatabaseChannels(Result $result): void + { + // Check if database notifications are enabled. + if (! $this->notificationSettings->database_enabled || ! $this->notificationSettings->database_on_threshold_failure) { return; } - $result->dispatchedBy->notify( - Notification::make() + foreach (User::all() as $user) { + FilamentNotification::make() ->title(__('results.speedtest_benchmark_failed')) ->actions([ Action::make('view') ->label(__('general.view')) ->url(route('filament.admin.resources.results.index')), ]) - ->warning() - ->toDatabase(), - ); + ->success() + ->sendToDatabase($user); + } } /** @@ -106,11 +152,6 @@ private function notifyDispatchingUser(Result $result): void */ private function notifyMailChannels(Result $result): void { - // Don't send webhook if dispatched by a user. - if (filled($result->dispatched_by)) { - return; - } - // Check if mail notifications are enabled. if (! $this->notificationSettings->mail_enabled || ! $this->notificationSettings->mail_on_threshold_failure) { return; @@ -134,11 +175,6 @@ private function notifyMailChannels(Result $result): void */ private function notifyWebhookChannels(Result $result): void { - // Don't send webhook if dispatched by a user. - if (filled($result->dispatched_by)) { - return; - } - // Check if webhook notifications are enabled. if (! $this->notificationSettings->webhook_enabled || ! $this->notificationSettings->webhook_on_threshold_failure) { return; diff --git a/app/Listeners/UserNotificationSubscriber.php b/app/Listeners/UserNotificationSubscriber.php new file mode 100644 index 000000000..aefa0eaae --- /dev/null +++ b/app/Listeners/UserNotificationSubscriber.php @@ -0,0 +1,104 @@ +result; + + if (empty($result->dispatched_by)) { + return; + } + + $result->loadMissing('dispatchedBy'); + + Notification::make() + ->title(__('results.speedtest_completed')) + ->actions([ + Action::make('view') + ->label(__('general.view')) + ->url(route('filament.admin.resources.results.index')), + ]) + ->success() + ->sendToDatabase($result->dispatchedBy); + } + + /** + * Handle the event. + */ + public function handleBenchmarkFailed(SpeedtestBenchmarkUnhealthy $event): void + { + $result = $event->result; + + if (empty($result->dispatched_by)) { + return; + } + + // Don't send notifications for unscheduled speedtests. + if ($result->unscheduled) { + return; + } + + $result->loadMissing('dispatchedBy'); + + Notification::make() + ->title(__('results.speedtest_benchmark_failed')) + ->actions([ + Action::make('view') + ->label(__('general.view')) + ->url(route('filament.admin.resources.results.index')), + ]) + ->warning() + ->sendToDatabase($result->dispatchedBy); + } + + /** + * Handle the event. + */ + public function handleFailed(SpeedtestFailed $event): void + { + $result = $event->result; + + if (empty($result->dispatched_by)) { + return; + } + + $result->loadMissing('dispatchedBy'); + + Notification::make() + ->title(__('results.speedtest_failed')) + ->actions([ + Action::make('view') + ->label(__('general.view')) + ->url(route('filament.admin.resources.results.index')), + ]) + ->warning() + ->sendToDatabase($result->dispatchedBy); + } + + /** + * Register the listeners for the subscriber. + * + * @return array + */ + public function subscribe(Dispatcher $events): array + { + return [ + SpeedtestCompleted::class => 'handleCompleted', + SpeedtestBenchmarkUnhealthy::class => 'handleBenchmarkFailed', + SpeedtestFailed::class => 'handleFailed', + ]; + } +} diff --git a/app/Livewire/DeprecatedNotificationChannelsBanner.php b/app/Livewire/DeprecatedNotificationChannelsBanner.php new file mode 100644 index 000000000..84533de19 --- /dev/null +++ b/app/Livewire/DeprecatedNotificationChannelsBanner.php @@ -0,0 +1,66 @@ +discord_enabled + || $settings->gotify_enabled + || $settings->healthcheck_enabled + || $settings->ntfy_enabled + || $settings->pushover_enabled + || $settings->slack_enabled + || $settings->telegram_enabled; + } + + #[Computed] + public function deprecatedChannelsList(): array + { + $settings = app(NotificationSettings::class); + $channels = []; + + if ($settings->discord_enabled) { + $channels[] = 'Discord'; + } + + if ($settings->gotify_enabled) { + $channels[] = 'Gotify'; + } + + if ($settings->healthcheck_enabled) { + $channels[] = 'Healthchecks'; + } + + if ($settings->ntfy_enabled) { + $channels[] = 'Ntfy'; + } + + if ($settings->pushover_enabled) { + $channels[] = 'Pushover'; + } + + if ($settings->slack_enabled) { + $channels[] = 'Slack'; + } + + if ($settings->telegram_enabled) { + $channels[] = 'Telegram'; + } + + return $channels; + } + + public function render() + { + return view('livewire.deprecated-notification-channels-banner'); + } +} diff --git a/app/Livewire/LatestResultStats.php b/app/Livewire/LatestResultStats.php new file mode 100644 index 000000000..64217b8ab --- /dev/null +++ b/app/Livewire/LatestResultStats.php @@ -0,0 +1,24 @@ +latest() + ->first(); + } + + public function render() + { + return view('livewire.latest-result-stats'); + } +} diff --git a/app/Livewire/NextSpeedtestBanner.php b/app/Livewire/NextSpeedtestBanner.php new file mode 100644 index 000000000..2e20874ac --- /dev/null +++ b/app/Livewire/NextSpeedtestBanner.php @@ -0,0 +1,22 @@ +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'); + } +} diff --git a/app/Livewire/Topbar/RunSpeedtestAction.php b/app/Livewire/Topbar/Actions.php similarity index 82% rename from app/Livewire/Topbar/RunSpeedtestAction.php rename to app/Livewire/Topbar/Actions.php index ad8c05271..9077724c8 100644 --- a/app/Livewire/Topbar/RunSpeedtestAction.php +++ b/app/Livewire/Topbar/Actions.php @@ -13,21 +13,23 @@ 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 bool $showDashboard = true; + 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 +63,14 @@ public function speedtestAction(): Action ->success() ->send(); }) - ->modalHeading(__('results.run_speedtest')) + ->modalHeading(__('results.speedtest')) ->modalWidth('lg') ->modalSubmitActionLabel(__('results.start')) ->button() + ->size(request()->is('filament*') ? Size::Medium : Size::Large) ->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 +80,6 @@ public function speedtestAction(): Action public function render() { - return view('livewire.topbar.run-speedtest-action'); + return view('livewire.topbar.actions'); } } diff --git a/app/Mail/CompletedSpeedtestMail.php b/app/Mail/CompletedSpeedtestMail.php index 109d95360..cfd7cecf6 100644 --- a/app/Mail/CompletedSpeedtestMail.php +++ b/app/Mail/CompletedSpeedtestMail.php @@ -51,7 +51,7 @@ public function content(): Content 'ping' => round($this->result->ping, 2).' ms', 'download' => Number::toBitRate(bits: $this->result->download_bits, precision: 2), 'upload' => Number::toBitRate(bits: $this->result->upload_bits, precision: 2), - 'packetLoss' => is_numeric($this->result->packet_loss) ? $this->result->packet_loss : 'n/a', + 'packetLoss' => $result->packet_loss ? round($result->packet_loss, precision: 2) : '', 'speedtest_url' => $this->result->result_url, 'url' => url('/admin/results'), ], diff --git a/app/Models/Result.php b/app/Models/Result.php index bba1a37d9..084c04097 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -6,6 +6,7 @@ use App\Enums\ResultStatus; use App\Models\Traits\ResultDataAttributes; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Prunable; @@ -54,4 +55,14 @@ public function dispatchedBy(): BelongsTo { return $this->belongsTo(User::class, 'dispatched_by'); } + + /** + * Determine if the result was unscheduled. + */ + protected function unscheduled(): Attribute + { + return Attribute::make( + get: fn (): bool => ! $this->scheduled, + ); + } } diff --git a/app/Notifications/Apprise/AppriseMessage.php b/app/Notifications/Apprise/AppriseMessage.php new file mode 100644 index 000000000..a510ded7b --- /dev/null +++ b/app/Notifications/Apprise/AppriseMessage.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/app/Notifications/Apprise/SpeedtestNotification.php b/app/Notifications/Apprise/SpeedtestNotification.php new file mode 100644 index 000000000..710466e57 --- /dev/null +++ b/app/Notifications/Apprise/SpeedtestNotification.php @@ -0,0 +1,42 @@ + + */ + 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) + ->format($this->format); + } +} diff --git a/app/Notifications/Apprise/TestNotification.php b/app/Notifications/Apprise/TestNotification.php new file mode 100644 index 000000000..8ba9011cc --- /dev/null +++ b/app/Notifications/Apprise/TestNotification.php @@ -0,0 +1,38 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + $body = '👋 This is a test notification from **'.config('app.name')."**.\n\n"; + $body .= "If you're seeing this, your Apprise notification channel is configured correctly!\n\n"; + + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title('Test Notification') + ->body($body) + ->type('info') + ->format('markdown'); + } +} diff --git a/app/Notifications/AppriseChannel.php b/app/Notifications/AppriseChannel.php index af5ac3683..b5885202b 100644 --- a/app/Notifications/AppriseChannel.php +++ b/app/Notifications/AppriseChannel.php @@ -2,9 +2,12 @@ namespace App\Notifications; +use App\Settings\NotificationSettings; +use Exception; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Throwable; class AppriseChannel { @@ -20,35 +23,59 @@ public function send(object $notifiable, Notification $notification): void return; } - $appriseUrl = config('services.apprise.url'); + $settings = app(NotificationSettings::class); + $appriseUrl = $settings->apprise_server_url ?? ''; + + if (empty($appriseUrl)) { + Log::warning('Apprise notification skipped: No Server URL configured'); + + return; + } + + Log::debug('Attempting to send Apprise notification', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, + ]); try { - $response = Http::timeout(5) + $request = Http::timeout(30) ->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 ($response->failed()) { - Log::error('Apprise notification failed', [ - 'status' => $response->status(), - 'body' => $response->body(), - ]); + // If SSL verification is disabled in settings, skip it + if (! $settings->apprise_verify_ssl) { + $request = $request->withoutVerifying(); } - } catch (\Exception $e) { - Log::error('Apprise notification exception', [ + + $response = $request->post($appriseUrl, [ + 'urls' => $message->urls, + 'title' => $message->title, + 'body' => $message->body, + 'type' => $message->type ?? 'info', + 'format' => $message->format ?? 'text', + 'tag' => $message->tag ?? null, + ]); + + // Only accept 200 OK responses as successful + if ($response->status() !== 200) { + throw new Exception('Apprise returned an error, please check Apprise logs for details'); + } + + Log::debug('Apprise notification sent', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, + ]); + } catch (Throwable $e) { + Log::error('Apprise notification failed', [ + 'channel' => $message->urls ?? 'unknown', + 'instance' => $appriseUrl, 'message' => $e->getMessage(), + 'exception' => get_class($e), ]); + + // Re-throw the exception so it can be handled by the queue + throw $e; } } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4ea6209f..ba434c79e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,14 @@ 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 @@ public function boot(): void $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. * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f72019481..3a6ba6f40 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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 @@ public function panel(Panel $panel): Panel ]) ->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')), ]); } } diff --git a/app/Providers/FilamentServiceProvider.php b/app/Providers/FilamentServiceProvider.php index 1f6b2bd44..99e95cd2e 100644 --- a/app/Providers/FilamentServiceProvider.php +++ b/app/Providers/FilamentServiceProvider.php @@ -24,7 +24,7 @@ public function boot(): void { FilamentView::registerRenderHook( PanelsRenderHook::GLOBAL_SEARCH_BEFORE, - fn (): string => Blade::render("@livewire('topbar.run-speedtest-action')"), + fn (): string => Blade::render("@livewire('topbar.actions')"), ); } } diff --git a/app/Rules/AppriseScheme.php b/app/Rules/AppriseScheme.php new file mode 100644 index 000000000..03a50059e --- /dev/null +++ b/app/Rules/AppriseScheme.php @@ -0,0 +1,22 @@ +caseSensitive ? $value : strtolower($value); + $needle = $this->caseSensitive ? $this->needle : strtolower($this->needle); + + if (! str_contains($haystack, $needle)) { + $fail("The :attribute must contain '{$this->needle}'."); + } + } +} diff --git a/app/Services/ScheduledSpeedtestService.php b/app/Services/ScheduledSpeedtestService.php new file mode 100644 index 000000000..8f9b85fc6 --- /dev/null +++ b/app/Services/ScheduledSpeedtestService.php @@ -0,0 +1,29 @@ +getNextRunDate(timeZone: config('app.display_timezone')) + ); + } +} diff --git a/app/Settings/NotificationSettings.php b/app/Settings/NotificationSettings.php index 0796be61a..0f332c68b 100644 --- a/app/Settings/NotificationSettings.php +++ b/app/Settings/NotificationSettings.php @@ -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'; diff --git a/compose.yaml b/compose.yaml index d072cb3c5..a4ccb9e57 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,8 +23,9 @@ services: depends_on: - pgsql - mailpit + - apprise pgsql: - image: 'postgres:17-alpine' + image: 'postgres:18-alpine' ports: - '${FORWARD_DB_PORT:-5432}:5432' environment: @@ -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 diff --git a/composer.json b/composer.json index 60cca8e29..4fe2349e4 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "dragonmantank/cron-expression": "^3.6.0", "filament/filament": "4.1.0", "filament/spatie-laravel-settings-plugin": "^4.1", - "geerlingguy/ping": "^1.2.1", "influxdata/influxdb-client-php": "^3.8", "laravel-notification-channels/telegram": "^6.0", "laravel/framework": "^12.41.1", @@ -31,11 +30,12 @@ "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", "spatie/laravel-webhook-server": "^3.8.3", + "spatie/ping": "^1.1.1", "zircote/swagger-php": "^5.7.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index b380844bd..6c492d7d9 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "374762e19dbfc99374c14f3f12a4ae3e", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1779,43 +1779,6 @@ ], "time": "2025-12-03T09:33:47+00:00" }, - { - "name": "geerlingguy/ping", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/geerlingguy/Ping.git", - "reference": "e0206326e23c99e3e8820e24705f8ca517adff93" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/geerlingguy/Ping/zipball/e0206326e23c99e3e8820e24705f8ca517adff93", - "reference": "e0206326e23c99e3e8820e24705f8ca517adff93", - "shasum": "" - }, - "type": "library", - "autoload": { - "classmap": [ - "JJG/Ping.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jeff Geerling", - "email": "jeff@jeffgeerling.com" - } - ], - "description": "A PHP class to ping hosts.", - "support": { - "issues": "https://github.com/geerlingguy/Ping/issues", - "source": "https://github.com/geerlingguy/Ping/tree/1.2.1" - }, - "time": "2019-07-29T21:54:12+00:00" - }, { "name": "graham-campbell/result-type", "version": "v1.1.3", @@ -6919,6 +6882,65 @@ ], "time": "2025-02-14T12:55:41+00:00" }, + { + "name": "spatie/ping", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/ping.git", + "reference": "6123a6209148e8919f58121d256f43c75856ab35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/ping/zipball/6123a6209148e8919f58121d256f43c75856ab35", + "reference": "6123a6209148e8919f58121d256f43c75856ab35", + "shasum": "" + }, + "require": { + "php": "^8.4", + "symfony/process": "^7.0" + }, + "require-dev": { + "laravel/pint": "^1.0", + "pestphp/pest": "^3.0", + "spatie/pest-expectations": "^1.13", + "spatie/ray": "^1.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Ping\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Run an ICMP ping and get structured results", + "homepage": "https://github.com/spatie/ping", + "keywords": [ + "ping", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/ping/issues", + "source": "https://github.com/spatie/ping/tree/1.1.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-08-12T20:58:12+00:00" + }, { "name": "spatie/shiki-php", "version": "2.3.2", diff --git a/config/services.php b/config/services.php index 0ab59e5d6..c7f3bddd7 100644 --- a/config/services.php +++ b/config/services.php @@ -2,10 +2,6 @@ return [ - 'apprise' => [ - 'url' => env('APPRISE_URL', 'http://apprise:8000'), - ], - 'telegram-bot-api' => [ 'token' => env('TELEGRAM_BOT_TOKEN'), ], diff --git a/config/speedtest.php b/config/speedtest.php index c85c93937..4226f5a0a 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -6,9 +6,9 @@ /** * General settings. */ - 'build_date' => Carbon::parse('2025-12-04'), + 'build_date' => Carbon::parse('2026-01-08'), - 'build_version' => 'v1.11.1', + 'build_version' => 'v1.13.5', 'content_width' => env('CONTENT_WIDTH', '7xl'), @@ -29,15 +29,17 @@ 'interface' => env('SPEEDTEST_INTERFACE'), - 'checkinternet_url' => env('SPEEDTEST_CHECKINTERNET_URL', 'https://icanhazip.com'), + 'preflight' => [ + 'external_ip_url' => env('SPEEDTEST_CHECKINTERNET_URL') ?? env('SPEEDTEST_EXTERNAL_IP_URL', 'https://icanhazip.com'), + 'internet_check_hostname' => env('SPEEDTEST_CHECKINTERNET_URL') ?? env('SPEEDTEST_INTERNET_CHECK_HOSTNAME', 'icanhazip.com'), + 'skip_ips' => env('SPEEDTEST_SKIP_IPS'), + ], /** * IP filtering settings. */ 'allowed_ips' => env('ALLOWED_IPS'), - 'skip_ips' => env('SPEEDTEST_SKIP_IPS', ''), - /** * Threshold settings. */ diff --git a/database/settings/2024_12_31_164343_create_apprise_notification.php b/database/settings/2024_12_31_164343_create_apprise_notification.php new file mode 100644 index 000000000..1be9ac906 --- /dev/null +++ b/database/settings/2024_12_31_164343_create_apprise_notification.php @@ -0,0 +1,16 @@ +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); + } +}; diff --git a/lang/de_DE/auth.php b/lang/de_DE/auth.php index 6d83d009e..ed5c9f604 100644 --- a/lang/de_DE/auth.php +++ b/lang/de_DE/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Anmelden', 'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Datensätzen überein.', 'password' => 'Das angegebene Passwort ist falsch.', 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.', diff --git a/lang/de_DE/general.php b/lang/de_DE/general.php index 86af86298..4cbb97ede 100644 --- a/lang/de_DE/general.php +++ b/lang/de_DE/general.php @@ -1,6 +1,11 @@ 'Aktuelle Version', + 'latest_version' => 'Neueste Version', + 'github' => 'GitHub', + 'repository' => 'Repository', + // Common actions 'save' => 'Speichern', 'cancel' => 'Abbrechen', @@ -32,6 +37,10 @@ 'created_at' => 'Erstellt am', 'updated_at' => 'Aktualisiert am', 'url' => 'URL', + 'server' => 'Server', + 'servers' => 'Server', + 'stats' => 'Statistiken', + 'statistics' => 'Statistiken', // Navigation 'dashboard' => 'Dashboard', @@ -42,6 +51,7 @@ 'view_documentation' => 'Dokumentation anzeigen', 'links' => 'Links', 'donate' => 'Spenden', + 'donations' => 'Spenden', // Roles 'admin' => 'Admin', @@ -54,12 +64,15 @@ 'last_month' => 'Letzten Monat', // Metrics + 'metrics' => 'Metriken', 'average' => 'Durchschnitt', 'high' => 'Hoch', 'low' => 'Niedrig', 'faster' => 'schneller', 'slower' => 'langsamer', 'healthy' => 'Gesund', + 'not_measured' => 'Nicht gemessen', + 'unhealthy' => 'fehlerhaft', // Units 'ms' => 'M', diff --git a/lang/de_DE/results.php b/lang/de_DE/results.php index 127d85b81..a506bce62 100644 --- a/lang/de_DE/results.php +++ b/lang/de_DE/results.php @@ -55,9 +55,6 @@ // Actions 'update_comments' => 'Kommentare aktualisieren', - 'truncate_results' => 'Ergebnisse kürzen', - 'truncate_results_description' => 'Sind Sie sicher, dass Sie alle Ergebnisse kürzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.', - 'truncate_results_success' => 'Ergebnistabelle abgeschnitten!', 'view_on_speedtest_net' => 'Auf Speedtest.net anzeigen', // Notifications @@ -72,7 +69,6 @@ // Run Speedtest Action 'speedtest' => 'Schnelligkeit', - 'public_dashboard' => 'Öffentliches Dashboard', 'select_server' => 'Server auswählen', 'select_server_helper' => 'Leer lassen, um den Speedtest auszuführen, ohne einen Server anzugeben. Blockierte Server werden übersprungen.', 'manual_servers' => 'Manuelle Server', diff --git a/lang/de_DE/settings/data_integration.php b/lang/de_DE/settings/data_integration.php index 83464fa4d..71d1936dc 100644 --- a/lang/de_DE/settings/data_integration.php +++ b/lang/de_DE/settings/data_integration.php @@ -28,7 +28,7 @@ 'influxdb_test_success_body' => 'Testdaten wurden an InfluxDB gesendet. Überprüfen Sie, ob die Daten empfangen wurden.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Fehler beim Erstellen des Schreibens auf Influxdb.', + 'influxdb_bulk_write_failed' => 'Fehler beim Schreiben von Massendaten in InfluxDB.', 'influxdb_bulk_write_failed_body' => 'Überprüfen Sie die Protokolle für weitere Details.', 'influxdb_bulk_write_success' => 'Massendatenlade für Influxdb abgeschlossen.', 'influxdb_bulk_write_success_body' => 'Daten wurden an InfluxDB gesendet. Überprüfen Sie, ob die Daten empfangen wurden.', diff --git a/lang/de_DE/settings/notifications.php b/lang/de_DE/settings/notifications.php index 6d6623a47..8ec3ee6dc 100644 --- a/lang/de_DE/settings/notifications.php +++ b/lang/de_DE/settings/notifications.php @@ -14,6 +14,19 @@ 'recipients' => 'Empfänger', 'test_mail_channel' => 'Mail-Kanal testen', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Apprise Benachrichtigungen aktivieren', + 'apprise_server' => 'Apprise Server', + 'apprise_server_url' => 'Apprise Server URL', + 'apprise_verify_ssl' => 'SSL verifizieren', + 'apprise_channels' => 'Apprise Kanäle', + 'apprise_channel_url' => 'Kanal URL', + 'apprise_hint_description' => 'Lesen Sie für weitere Informationen zum Einrichten von Apprise die Dokumentation.', + 'apprise_channel_url_helper' => 'Geben Sie die Service Endpoint URL für Benachrichtigung an.', + 'test_apprise_channel' => 'Apprise testen', + 'apprise_channel_url_validation_error' => 'Die Apprise Channel URL muss nicht mit "HTTP" oder "HTTPS" starten. Geben Sie ein valides Apprise URL Schema an.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', diff --git a/lang/en/auth.php b/lang/en/auth.php index 6598e2c06..f0d112f16 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Sign in', 'failed' => 'These credentials do not match our records.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', diff --git a/lang/en/general.php b/lang/en/general.php index dbc2d675f..65ffa88ee 100644 --- a/lang/en/general.php +++ b/lang/en/general.php @@ -1,6 +1,11 @@ 'Current version', + 'latest_version' => 'Latest version', + 'github' => 'GitHub', + 'repository' => 'Repository', + // Common actions 'save' => 'Save', 'cancel' => 'Cancel', @@ -32,6 +37,10 @@ 'created_at' => 'Created at', 'updated_at' => 'Updated at', 'url' => 'URL', + 'server' => 'Server', + 'servers' => 'Servers', + 'stats' => 'Stats', + 'statistics' => 'Statistics', // Navigation 'dashboard' => 'Dashboard', @@ -39,9 +48,12 @@ 'settings' => 'Settings', 'users' => 'Users', 'documentation' => 'Documentation', + 'documentation_description' => 'Need help getting started or configuring your speedtests?', 'view_documentation' => 'View documentation', 'links' => 'Links', 'donate' => 'Donate', + 'donations' => 'Donations', + 'donations_description' => 'Support the development and maintenance of Speedtest Tracker by making a donation.', // Roles 'admin' => 'Admin', @@ -54,12 +66,19 @@ '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', + 'last_results' => 'Last results', + 'total_failed' => 'Total failed tests', + 'total_complted' => 'Total completed tests', + 'total' => 'Total', // Units 'ms' => 'ms', diff --git a/lang/en/results.php b/lang/en/results.php index 6d8a2016d..8b625d37a 100644 --- a/lang/en/results.php +++ b/lang/en/results.php @@ -55,9 +55,6 @@ // Actions 'update_comments' => 'Update comments', - 'truncate_results' => 'Truncate results', - 'truncate_results_description' => 'Are you sure you want to truncate all results? This action is irreversible.', - 'truncate_results_success' => 'Results table truncated!', 'view_on_speedtest_net' => 'View on Speedtest.net', // Notifications @@ -72,7 +69,6 @@ // 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', diff --git a/lang/en/settings/data_integration.php b/lang/en/settings/data_integration.php index f55a77441..60ee353d6 100644 --- a/lang/en/settings/data_integration.php +++ b/lang/en/settings/data_integration.php @@ -28,7 +28,7 @@ 'influxdb_test_success_body' => 'Test data has been sent to InfluxDB, check if the data was received.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Failed to build write to Influxdb.', + 'influxdb_bulk_write_failed' => 'Failed to bulk write to Influxdb.', 'influxdb_bulk_write_failed_body' => 'Check the logs for more details.', 'influxdb_bulk_write_success' => 'Finished bulk data load to Influxdb.', 'influxdb_bulk_write_success_body' => 'Data has been sent to InfluxDB, check if the data was received.', diff --git a/lang/en/settings/notifications.php b/lang/en/settings/notifications.php index ddc1baae8..8ccd940a2 100644 --- a/lang/en/settings/notifications.php +++ b/lang/en/settings/notifications.php @@ -14,15 +14,32 @@ '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_server_url_helper' => 'The URL of your Apprise Server. The URL must end on /notify', + 'apprise_verify_ssl' => 'Verify SSL', + 'apprise_channels' => 'Notification Channels', + 'apprise_channel_url' => 'Service URL', + 'apprise_hint_description' => 'Apprise allows you to send notifications to 90+ services. You need to run an Apprise server and configure service URLs below.', + 'apprise_channel_url_helper' => 'Use Apprise URL format. Examples: discord://WebhookID/Token, slack://TokenA/TokenB/TokenC', + 'apprise_save_to_test' => 'Save your settings to test the notification.', + 'test_apprise_channel' => 'Test Apprise', + 'apprise_channel_url_validation_error' => 'Invalid Apprise URL. Must use Apprise format (e.g., discord://, slack://), not http:// or https://. See the Apprise documentation for more information', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', 'test_webhook_channel' => 'Test webhook channel', - 'webhook_hint_description' => 'These are generic webhooks. For payload examples and implementation details, view the documentation.', + 'webhook_hint_description' => 'These are generic webhooks. For payload examples and implementation details, view the documentation. For services like Discord, Ntfy etc please use Apprise.', // Common notification messages - 'notify_on_every_speedtest_run' => 'Notify on every scheduled speedtest run', + 'notify_on_every_speedtest_run' => 'Notify on every completed scheduled speedtest run', + 'notify_on_every_speedtest_run_helper' => 'This will send a notification for every completed scheduled speedtest run, only for healthy or unbenchmarked tests', 'notify_on_threshold_failures' => 'Notify on threshold failures for scheduled speedtests', + 'notify_on_threshold_failures_helper' => 'This will send a notification when a scheduled speedtest fails any configured thresholds', // Test notification messages 'test_notifications' => [ @@ -39,6 +56,7 @@ 'webhook' => [ 'add' => 'Add webhook URLs!', 'sent' => 'Test webhook notification sent.', + 'failed' => 'Webhook notification failed.', 'payload' => 'Testing webhook notification', ], ], diff --git a/lang/es_ES/auth.php b/lang/es_ES/auth.php index f4b03c1d5..b150afe6b 100644 --- a/lang/es_ES/auth.php +++ b/lang/es_ES/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Iniciar sesión', 'failed' => 'Estas credenciales no coinciden con nuestros registros.', 'password' => 'La contraseña proporcionada es incorrecta.', 'throttle' => 'Demasiados intentos de inicio de sesión. Por favor, inténtalo de nuevo en :seconds segundos.', diff --git a/lang/es_ES/general.php b/lang/es_ES/general.php index bf2e3a571..fddf7a982 100644 --- a/lang/es_ES/general.php +++ b/lang/es_ES/general.php @@ -1,6 +1,11 @@ 'Versión actual', + 'latest_version' => 'Última versión', + 'github' => 'GitHub', + 'repository' => 'Repositorio', + // Common actions 'save' => 'Guardar', 'cancel' => 'Cancelar', @@ -16,6 +21,7 @@ 'no' => 'Nu', 'options' => 'Opciones', 'details' => 'Detalles', + 'view' => 'Ver', // Common labels 'name' => 'Nombre', @@ -31,6 +37,10 @@ 'created_at' => 'Creado el', 'updated_at' => 'Actualizado el', 'url' => 'URL', + 'server' => 'Servidor', + 'servers' => 'Servidores', + 'stats' => 'Estadísticas', + 'statistics' => 'Estadísticas', // Navigation 'dashboard' => 'Tablero', @@ -38,8 +48,10 @@ 'settings' => 'Ajustes', 'users' => 'Usuarios', 'documentation' => 'Documentación', + 'view_documentation' => 'Ver documentación', 'links' => 'Enlaces', 'donate' => 'Donar', + 'donations' => 'Donaciones', // Roles 'admin' => 'Admin', @@ -52,12 +64,15 @@ 'last_month' => 'Último mes', // Metrics + 'metrics' => 'Métricas', 'average' => 'Promedio', 'high' => 'Alta', 'low' => 'Baja', 'faster' => 'más rápido', 'slower' => 'más lento', 'healthy' => 'Saludable', + 'not_measured' => 'No medido', + 'unhealthy' => 'Poco saludable', // Units 'ms' => 'm', diff --git a/lang/es_ES/results.php b/lang/es_ES/results.php index f41af5df8..89df7e984 100644 --- a/lang/es_ES/results.php +++ b/lang/es_ES/results.php @@ -55,21 +55,20 @@ // Actions 'update_comments' => 'Actualizar comentarios', - 'truncate_results' => 'Truncar resultados', - 'truncate_results_description' => '¿Está seguro que desea truncar todos los resultados? Esta acción es irreversible.', - 'truncate_results_success' => '¡Tabla de resultados truncada!', 'view_on_speedtest_net' => 'Ver en Speedtest.net', // Notifications + 'speedtest_benchmark_passed' => 'La prueba de rendimiento de velocidad ha pasado', + 'speedtest_benchmark_failed' => 'Prueba de rendimiento de velocidad fallida', 'speedtest_started' => 'Velocidad iniciada', 'speedtest_completed' => 'Velocidad completada', + 'speedtest_failed' => 'Error en la prueba de velocidad', 'download_threshold_breached' => '¡Umbral de descarga incumplido!', 'upload_threshold_breached' => '¡Umbral de subida infringido!', 'ping_threshold_breached' => '¡Umbral de ping infringido!', // Run Speedtest Action 'speedtest' => 'Velocidad', - 'public_dashboard' => 'Panel público', 'select_server' => 'Seleccionar Servidor', 'select_server_helper' => 'Dejar en blanco para ejecutar el test de velocidad sin especificar un servidor. Se omitirán los servidores bloqueados.', 'manual_servers' => 'Servidores manuales', diff --git a/lang/es_ES/settings/data_integration.php b/lang/es_ES/settings/data_integration.php index 1c3633559..a1b024848 100644 --- a/lang/es_ES/settings/data_integration.php +++ b/lang/es_ES/settings/data_integration.php @@ -28,11 +28,18 @@ 'influxdb_test_success_body' => 'Los datos de prueba han sido enviados a InfluxDB, compruebe si los datos han sido recibidos.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Error al construir escritura en Influxdb.', + 'influxdb_bulk_write_failed' => 'Error al escribir en masa a Influxdb.', 'influxdb_bulk_write_failed_body' => 'Revisa los registros para más detalles.', 'influxdb_bulk_write_success' => 'Carga de datos en masa a Influxdb.', 'influxdb_bulk_write_success_body' => 'Los datos han sido enviados a InfluxDB, compruebe si los datos han sido recibidos.', + // Prometheus + 'prometheus' => 'Prometeo', + 'prometheus_enabled' => 'Activar', + 'prometheus_enabled_helper_text' => 'Cuando está activado, las métricas para cada prueba de velocidad nueva estarán disponibles en el punto final /prometheus.', + 'prometheus_allowed_ips' => 'Direcciones IP permitidas', + 'prometheus_allowed_ips_helper' => 'Lista de direcciones IP o rangos CIDR (por ejemplo, 192.168.1.0/24) permitieron acceder al extremo de las métricas. Dejar en blanco para permitir todas las IPs.', + // Common labels 'org' => 'Org', 'bucket' => 'Cubo', diff --git a/lang/es_ES/settings/notifications.php b/lang/es_ES/settings/notifications.php index ddbb10dca..83d8bbf20 100644 --- a/lang/es_ES/settings/notifications.php +++ b/lang/es_ES/settings/notifications.php @@ -7,27 +7,35 @@ // Database notifications 'database' => 'Base de datos', 'database_description' => 'Las notificaciones enviadas a este canal se mostrarán bajo el icono :belell: en el encabezado.', - 'database_on_speedtest_run' => 'Notificar en cada prueba de velocidad', - 'database_on_threshold_failure' => 'Notificar en los umbrales de fallos', 'test_database_channel' => 'Probar canal de base de datos', // Mail notifications 'mail' => 'Correo', 'recipients' => 'Destinatarios', - 'mail_on_speedtest_run' => 'Notificar en cada prueba de velocidad', - 'mail_on_threshold_failure' => 'Notificar en los umbrales de fallos', 'test_mail_channel' => 'Canal de prueba de correo', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Habilitar notificaciones Apprise', + 'apprise_server' => 'Servidor Apprise', + 'apprise_server_url' => 'URL del servidor', + 'apprise_verify_ssl' => 'Verificar SSL', + 'apprise_channels' => 'Canales de expedición', + 'apprise_channel_url' => 'URL del canal', + 'apprise_hint_description' => 'Para más información sobre cómo configurar Apprise, vea la documentación.', + 'apprise_channel_url_helper' => 'Proporcionar la URL de los puntos finales del servicio para las notificaciones.', + 'test_apprise_channel' => 'Prueba de aviso', + 'apprise_channel_url_validation_error' => 'La URL del canal Apprise no debe comenzar con "http" o "https". Por favor, proporcione un esquema de URL de Apprise válido.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', - 'webhook_on_speedtest_run' => 'Notificar en cada prueba de velocidad', - 'webhook_on_threshold_failure' => 'Notificar en los umbrales de fallos', 'test_webhook_channel' => 'Probar canal webhook', + 'webhook_hint_description' => 'Estos son webhooks genéricos. Para ejemplos de payload y detalles de la implementación, vea la documentación.', // Common notification messages - 'notify_on_every_speedtest_run' => 'Notificar en cada prueba de velocidad', - 'notify_on_threshold_failures' => 'Notificar en los umbrales de fallos', + 'notify_on_every_speedtest_run' => 'Notificar en cada prueba de velocidad programada', + 'notify_on_threshold_failures' => 'Notificar fallos de umbral para pruebas de velocidad programadas', // Test notification messages 'test_notifications' => [ diff --git a/lang/fr_FR/auth.php b/lang/fr_FR/auth.php index c1a529c3a..fc8d1390b 100644 --- a/lang/fr_FR/auth.php +++ b/lang/fr_FR/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Se connecter', 'failed' => 'Ces identifiants ne correspondent pas à nos enregistrements.', 'password' => 'Le mot de passe fourni est incorrect.', 'throttle' => 'Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.', diff --git a/lang/fr_FR/general.php b/lang/fr_FR/general.php index adeba836d..4b9422d7f 100644 --- a/lang/fr_FR/general.php +++ b/lang/fr_FR/general.php @@ -1,6 +1,11 @@ 'Version actuelle', + 'latest_version' => 'Dernière version', + 'github' => 'GitHub', + 'repository' => 'Dépôt', + // Common actions 'save' => 'Enregistrer', 'cancel' => 'Abandonner', @@ -32,6 +37,10 @@ 'created_at' => 'Créé le', 'updated_at' => 'Mis à jour le', 'url' => 'URL', + 'server' => 'Serveur', + 'servers' => 'Serveurs', + 'stats' => 'Stats', + 'statistics' => 'Statistiques', // Navigation 'dashboard' => 'Tableau de bord', @@ -42,6 +51,7 @@ 'view_documentation' => 'Afficher la documentation', 'links' => 'Liens', 'donate' => 'Faire un don', + 'donations' => 'Dons', // Roles 'admin' => 'Administrateur', @@ -54,12 +64,15 @@ 'last_month' => 'Le mois dernier', // Metrics + 'metrics' => 'Métriques', 'average' => 'Moyenne', 'high' => 'Élevé', 'low' => 'Bas', 'faster' => 'rapide', 'slower' => 'lent', 'healthy' => 'Sain', + 'not_measured' => 'Non mesuré', + 'unhealthy' => 'Malsain', // Units 'ms' => 'ms', diff --git a/lang/fr_FR/results.php b/lang/fr_FR/results.php index 7284dcbbc..ab2715930 100644 --- a/lang/fr_FR/results.php +++ b/lang/fr_FR/results.php @@ -55,9 +55,6 @@ // Actions 'update_comments' => 'Actualiser les commentaires', - 'truncate_results' => 'Tronquer les résultats', - 'truncate_results_description' => 'Êtes-vous sûr de vouloir tronquer tous les résultats ? Cette action est irréversible.', - 'truncate_results_success' => 'Tableau des résultats tronqué !', 'view_on_speedtest_net' => 'Voir sur Speedtest.net', // Notifications @@ -72,7 +69,6 @@ // Run Speedtest Action 'speedtest' => 'Test de vitesse', - 'public_dashboard' => 'Tableau de bord public', 'select_server' => 'Sélectionner un serveur', 'select_server_helper' => 'Laisser vide pour exécuter le test de vitesse sans spécifier de serveur. Les serveurs bloqués seront ignorés.', 'manual_servers' => 'Serveurs manuels', diff --git a/lang/fr_FR/settings/data_integration.php b/lang/fr_FR/settings/data_integration.php index 007362964..ce0af0775 100644 --- a/lang/fr_FR/settings/data_integration.php +++ b/lang/fr_FR/settings/data_integration.php @@ -28,7 +28,7 @@ 'influxdb_test_success_body' => 'Les données de test ont été envoyées à InfluxDB, vérifiez si les données ont été reçues.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Échec de la construction de l\'écriture sur Influxdb.', + 'influxdb_bulk_write_failed' => 'Impossible d\'écrire en masse sur Influxdb.', 'influxdb_bulk_write_failed_body' => 'Consultez les journaux pour plus de détails.', 'influxdb_bulk_write_success' => 'Charge de données en masse terminée sur Influxdb.', 'influxdb_bulk_write_success_body' => 'Les données ont été envoyées à InfluxDB, vérifiez si les données ont été reçues.', diff --git a/lang/fr_FR/settings/notifications.php b/lang/fr_FR/settings/notifications.php index 778334423..359a1424a 100644 --- a/lang/fr_FR/settings/notifications.php +++ b/lang/fr_FR/settings/notifications.php @@ -14,6 +14,19 @@ 'recipients' => 'Destinataires', 'test_mail_channel' => 'Tester le canal de messagerie', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Activer les notifications de base de données', + 'apprise_server' => 'Serveur Apprise', + 'apprise_server_url' => 'Serveur Apprise', + 'apprise_verify_ssl' => 'Vérifier SSL', + 'apprise_channels' => 'Canaux Apprise', + 'apprise_channel_url' => 'URL du canal de mise à jour', + 'apprise_hint_description' => 'Pour plus d\'informations sur la configuration d\'Apprise, consultez la documentation.', + 'apprise_channel_url_helper' => 'Fournir l\'URL de terminaison du service pour les notifications.', + 'test_apprise_channel' => 'Apprise de test', + 'apprise_channel_url_validation_error' => 'L\'URL du canal Apprise ne doit pas commencer par "http" ou "https". Veuillez fournir un schéma d\'URL Apprise valide.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', diff --git a/lang/nl_NL/auth.php b/lang/nl_NL/auth.php index 7667d5e81..2c9909f81 100644 --- a/lang/nl_NL/auth.php +++ b/lang/nl_NL/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Aanmelden', 'failed' => 'Deze gegevens komen niet overeen met onze administratie.', 'password' => 'Het opgegeven wachtwoord is onjuist.', 'throttle' => 'Te veel inlogpogingen. Probeer het over :seconds seconden opnieuw.', diff --git a/lang/nl_NL/general.php b/lang/nl_NL/general.php index 28e36b0f9..e17828565 100644 --- a/lang/nl_NL/general.php +++ b/lang/nl_NL/general.php @@ -1,6 +1,11 @@ 'Huidige versie', + 'latest_version' => 'Laatste versie', + 'github' => 'GitHub', + 'repository' => 'Repository', + // Common actions 'save' => 'Opslaan', 'cancel' => 'Annuleren', @@ -32,6 +37,10 @@ 'created_at' => 'Aangemaakt op', 'updated_at' => 'Bijgewerkt op', 'url' => 'URL', + 'server' => 'Server', + 'servers' => 'Servers', + 'stats' => 'Statistieken', + 'statistics' => 'Statistieken', // Navigation 'dashboard' => 'Dashboard', @@ -42,6 +51,7 @@ 'view_documentation' => 'Bekijk documentatie', 'links' => 'Koppelingen', 'donate' => 'Doneren', + 'donations' => 'Donaties', // Roles 'admin' => 'Beheerder', @@ -54,12 +64,15 @@ 'last_month' => 'Vorige maand', // Metrics + 'metrics' => 'Statistieken', 'average' => 'Gemiddeld', 'high' => 'Hoog', 'low' => 'Laag', 'faster' => 'sneller', 'slower' => 'langzamer', 'healthy' => 'Gezond', + 'not_measured' => 'Niet gemeten', + 'unhealthy' => 'Ongezond', // Units 'ms' => 'ms', diff --git a/lang/nl_NL/results.php b/lang/nl_NL/results.php index 69befafa6..4a17ed6c8 100644 --- a/lang/nl_NL/results.php +++ b/lang/nl_NL/results.php @@ -55,9 +55,6 @@ // Actions 'update_comments' => 'Reacties bijwerken', - 'truncate_results' => 'Afkappen resultaten', - 'truncate_results_description' => 'Weet je zeker dat je alle resultaten wilt afbreken? Deze actie is onomkeerbaar.', - 'truncate_results_success' => 'Resultatentabel afgekapt!', 'view_on_speedtest_net' => 'Bekijk op Speedtest.net', // Notifications @@ -72,7 +69,6 @@ // Run Speedtest Action 'speedtest' => 'Snelheidstest', - 'public_dashboard' => 'Openbaar Dashboard', 'select_server' => 'Selecteer Server', 'select_server_helper' => 'Laat leeg om de snelheidstest uit te voeren zonder een server op te geven. Geblokkeerde servers zullen worden overgeslagen.', 'manual_servers' => 'Handmatige servers', diff --git a/lang/nl_NL/settings/data_integration.php b/lang/nl_NL/settings/data_integration.php index 2508fdf6b..c95902fac 100644 --- a/lang/nl_NL/settings/data_integration.php +++ b/lang/nl_NL/settings/data_integration.php @@ -28,7 +28,7 @@ 'influxdb_test_success_body' => 'Test gegevens zijn verzonden naar de InfluxDB, controleer of de gegevens zijn ontvangen.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Kan schrijven naar Influxdb niet maken.', + 'influxdb_bulk_write_failed' => 'Bulk schrijven naar Influxdb mislukt.', 'influxdb_bulk_write_failed_body' => 'Bekijk de logs voor meer details.', 'influxdb_bulk_write_success' => 'Alle resultaten naar InfluxDB sturen afgerond.', 'influxdb_bulk_write_success_body' => 'Gegevens zijn verzonden naar InfluxDB, controleer of de gegevens zijn ontvangen.', diff --git a/lang/nl_NL/settings/notifications.php b/lang/nl_NL/settings/notifications.php index 9e1942078..7a37c850a 100644 --- a/lang/nl_NL/settings/notifications.php +++ b/lang/nl_NL/settings/notifications.php @@ -14,11 +14,25 @@ 'recipients' => 'Ontvangers', 'test_mail_channel' => 'Test e-mailkanaal', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Inschakelen Apprise meldingen', + 'apprise_server' => 'Apprise Server', + 'apprise_server_url' => 'Appprise Server-URL', + 'apprise_server_url_helper' => 'De URL van uw Apprise Server. De URL moet eindigen op /notify', + 'apprise_verify_ssl' => 'Controleer SSL', + 'apprise_channels' => 'Notificatie kanalen', + 'apprise_channel_url' => 'Service URL', + 'apprise_hint_description' => 'Met Apprise kan je meldingen verzenden naar meer dan 90 diensten. Je moet een Apprise server hebben draaien en onderstaande service URL\'s configureren.', + 'apprise_channel_url_helper' => 'Gebruik Apprise URL formaat. Bijvoorbeeld discord://WebhookID/Token, slack://TokenA/TokenB/TokenC', + 'test_apprise_channel' => 'Test Apprise', + 'apprise_channel_url_validation_error' => 'Ongeldige Apprise URL. De URL moet gebruik maken van Apprise formaat (bijv. discord://, slack://), niet http:// of https://. Zie de Apprise documentatie voor meer informatie', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', 'test_webhook_channel' => 'Test webhook kanaal', - 'webhook_hint_description' => 'Dit zijn generieke webhooks. Raadpleeg de documentatie voor voorbeelden van payloads en implementatiedetails.', + 'webhook_hint_description' => 'Dit zijn algemene webhooks. Voor payload voorbeelden en implementatiegegevens, bekijk de documentatie. Voor diensten zoals Discord, Ntfy etc. gebruik Apprise.', // Common notification messages 'notify_on_every_speedtest_run' => 'Notificatie bij elke geplande snelheidstest', diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php index 16ae2cdb5..13625436a 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -13,6 +13,7 @@ | */ + 'sign_in' => 'Entrar', 'failed' => 'Credenciais não correspondem aos nossos registros.', 'password' => 'A senha fornecida está incorreta.', 'throttle' => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.', diff --git a/lang/pt_BR/general.php b/lang/pt_BR/general.php index 57ee664d6..7a8b67af2 100644 --- a/lang/pt_BR/general.php +++ b/lang/pt_BR/general.php @@ -1,6 +1,11 @@ 'Versão atual', + 'latest_version' => 'Versão mais recente', + 'github' => 'GitHub', + 'repository' => 'Repositório', + // Common actions 'save' => 'Salvar', 'cancel' => 'Cancelar', @@ -32,6 +37,10 @@ 'created_at' => 'Criado em', 'updated_at' => 'Atualizado em', 'url' => 'URL', + 'server' => 'Servidor', + 'servers' => 'Servidores', + 'stats' => 'Estatísticas', + 'statistics' => 'Estatísticas', // Navigation 'dashboard' => 'Painel', @@ -42,6 +51,7 @@ 'view_documentation' => 'Ver documentação', 'links' => 'Links', 'donate' => 'Doar', + 'donations' => 'Doações', // Roles 'admin' => 'Admin', @@ -54,12 +64,15 @@ 'last_month' => 'Mês anterior', // Metrics + 'metrics' => 'Métricas', 'average' => 'Média', 'high' => 'Alta', 'low' => 'Baixa', 'faster' => 'mais rápido', 'slower' => 'lento', 'healthy' => 'Saudável', + 'not_measured' => 'Não medido', + 'unhealthy' => 'Não saudável', // Units 'ms' => 'ms', diff --git a/lang/pt_BR/results.php b/lang/pt_BR/results.php index bc725b402..5d3c62055 100644 --- a/lang/pt_BR/results.php +++ b/lang/pt_BR/results.php @@ -55,9 +55,6 @@ // Actions 'update_comments' => 'Atualizar comentários', - 'truncate_results' => 'Truncar resultados', - 'truncate_results_description' => 'Tem certeza que deseja truncar todos os resultados? Esta ação é irreversível.', - 'truncate_results_success' => 'Tabela de resultados truncada!', 'view_on_speedtest_net' => 'Ver em Speedtest.net', // Notifications @@ -72,7 +69,6 @@ // Run Speedtest Action 'speedtest' => 'Teste de velocidade', - 'public_dashboard' => 'Painel público', 'select_server' => 'Selecionar servidor', 'select_server_helper' => 'Deixe em branco para executar o acelerador sem especificar um servidor. Os servidores bloqueados serão ignorados.', 'manual_servers' => 'Servidores manuais', diff --git a/lang/pt_BR/settings/data_integration.php b/lang/pt_BR/settings/data_integration.php index 2b5dd5ae2..02cc2a6fd 100644 --- a/lang/pt_BR/settings/data_integration.php +++ b/lang/pt_BR/settings/data_integration.php @@ -28,7 +28,7 @@ 'influxdb_test_success_body' => 'Dados de teste enviados para InfluxDB, verifique se os dados foram recebidos.', // Bulk write notifications - 'influxdb_bulk_write_failed' => 'Falha ao escrever no Influxdb.', + 'influxdb_bulk_write_failed' => 'Falha ao escrever em massa no Influxdb.', 'influxdb_bulk_write_failed_body' => 'Confira os logs para mais detalhes.', 'influxdb_bulk_write_success' => 'Carga massiva de dados concluída para o Influxdb.', 'influxdb_bulk_write_success_body' => 'Os dados foram enviados para InfluxDB, verifique se os dados foram recebidos.', diff --git a/lang/pt_BR/settings/notifications.php b/lang/pt_BR/settings/notifications.php index 6d90ed8f4..827ce15f8 100644 --- a/lang/pt_BR/settings/notifications.php +++ b/lang/pt_BR/settings/notifications.php @@ -14,6 +14,19 @@ 'recipients' => 'Destinatários', 'test_mail_channel' => 'Testar canal de e-mail', + // Apprise notifications + 'apprise' => 'Informar', + 'enable_apprise_notifications' => 'Habilitar notificações Apprise', + 'apprise_server' => 'Servidor Apprise', + 'apprise_server_url' => 'URL do Servidor Apprise', + 'apprise_verify_ssl' => 'Verificar SSL', + 'apprise_channels' => 'Canais Apprise', + 'apprise_channel_url' => 'URL do Canal', + 'apprise_hint_description' => 'Para obter mais informações sobre como configurar o Apprise, veja a documentação.', + 'apprise_channel_url_helper' => 'Forneça o URL do serviço endpoint para notificações.', + 'test_apprise_channel' => 'Testar Apprise', + 'apprise_channel_url_validation_error' => 'O URL do canal Apprise não deve começar com "http" ou "https". Por favor, forneça um esquema válido de URL Apprise.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', diff --git a/resources/css/app.css b/resources/css/app.css index 54ea6064a..532b718c1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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}"); diff --git a/resources/css/custom.css b/resources/css/custom.css new file mode 100644 index 000000000..465ee0cf8 --- /dev/null +++ b/resources/css/custom.css @@ -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; +} diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index b39705336..6d386b0e9 100644 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -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, diff --git a/resources/views/apprise/speedtest-completed.blade.php b/resources/views/apprise/speedtest-completed.blade.php new file mode 100644 index 000000000..13a29162b --- /dev/null +++ b/resources/views/apprise/speedtest-completed.blade.php @@ -0,0 +1,15 @@ +A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}**. + +### Results +- **Server:** {{ $serverName }} (ID: {{ $serverId }}) +- **ISP:** {{ $isp }} +- **Ping:** {{ $ping }} +- **Download:** {{ $download }} +- **Upload:** {{ $upload }} +@filled($packetLoss) +- **Packet Loss:** {{ $packetLoss }}% +@endfilled + +### Links +- [View Ookla Results]({{ $speedtest_url }}) +- [View Dashboard]({{ $url }}) diff --git a/resources/views/apprise/speedtest-threshold.blade.php b/resources/views/apprise/speedtest-threshold.blade.php new file mode 100644 index 000000000..14a719d0e --- /dev/null +++ b/resources/views/apprise/speedtest-threshold.blade.php @@ -0,0 +1,12 @@ +A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached +### Failed Metrics +@foreach ($metrics as $item) +- **{{ $item['name'] }}** + - **Threshold:** {{ $item['threshold'] }} | **Actual:** {{ $item['value'] }} +@endforeach +### Server Information +- **Server:** {{ $serverName }} (ID: {{ $serverId }}) +- **ISP:** {{ $isp }} +### Links +- [View Ookla Results]({{ $speedtest_url }}) +- [View Dashboard]({{ $url }}) diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8a03ce90c..c66c27c1c 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,39 +1,28 @@ -
-
- @livewire(\App\Filament\Widgets\StatsOverviewWidget::class) -
+
+ + + + + - @isset($latestResult) -
- Latest result: -
- @endisset +
+

+ + {{ __('general.metrics') }} +

-
@livewire(\App\Filament\Widgets\RecentDownloadChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentUploadChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentPingChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentJitterChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentDownloadLatencyChartWidget::class) -
-
@livewire(\App\Filament\Widgets\RecentUploadLatencyChartWidget::class)
-
- diff --git a/resources/views/discord/speedtest-completed.blade.php b/resources/views/discord/speedtest-completed.blade.php index 6a8cdf9a5..851a8200a 100644 --- a/resources/views/discord/speedtest-completed.blade.php +++ b/resources/views/discord/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Discord notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Discord and many other services.** + **Speedtest Completed - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}**. diff --git a/resources/views/discord/speedtest-threshold.blade.php b/resources/views/discord/speedtest-threshold.blade.php index 95dc4bf01..b0716b50b 100644 --- a/resources/views/discord/speedtest-threshold.blade.php +++ b/resources/views/discord/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Discord notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Discord and many other services.** + **Speedtest Threshold Breached - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php index f351a1c97..4dd7514ee 100644 --- a/resources/views/filament/pages/dashboard.blade.php +++ b/resources/views/filament/pages/dashboard.blade.php @@ -1,3 +1,104 @@ - - {{-- Silence is golden --}} + +
+ + + + + + + + +
+ + + {{ __('general.documentation') }} + + +
+

{{ __('general.documentation_description') }}

+
+ +
+ + {{ __('general.view_documentation') }} + +
+
+ + + + {{ __('general.donations') }} + + +
+

{{ __('general.donations_description') }}

+
+ +
+ + {{ __('general.donate') }} + +
+
+ + + + {{ __('general.speedtest_tracker') }} + + + @if (\App\Services\GitHub\Repository::updateAvailable()) + + + {{ __('general.update_available') }} + + + @endif + +
    +
  • +

    {{ __('general.current_version') }}

    +

    {{ config('speedtest.build_version') }}

    +
  • + +
  • +

    {{ __('general.latest_version') }}

    +

    {{ \App\Services\GitHub\Repository::getLatestVersion() }}

    +
  • +
+ +
+ + {{ __('general.github') }} {{ str(__('general.repository'))->lower() }} + +
+
+
+
diff --git a/resources/views/filament/tables/columns/result-server-column.blade.php b/resources/views/filament/tables/columns/result-server-column.blade.php new file mode 100644 index 000000000..f16445828 --- /dev/null +++ b/resources/views/filament/tables/columns/result-server-column.blade.php @@ -0,0 +1,7 @@ +
+ {{ $getServerName() }} + + @isset($getServerId) + (#{{ $getServerId() }}) + @endisset +
diff --git a/resources/views/gotify/speedtest-completed.blade.php b/resources/views/gotify/speedtest-completed.blade.php index 910b9347c..c17d46daf 100644 --- a/resources/views/gotify/speedtest-completed.blade.php +++ b/resources/views/gotify/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Gotify notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Gotify and many other services.** + **Speedtest Completed - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}**. diff --git a/resources/views/gotify/speedtest-threshold.blade.php b/resources/views/gotify/speedtest-threshold.blade.php index dd86bcf8a..e731a0104 100644 --- a/resources/views/gotify/speedtest-threshold.blade.php +++ b/resources/views/gotify/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Gotify notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Gotify and many other services.** + **Speedtest Threshold Breached - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 80ba6986d..078505014 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -35,22 +35,11 @@
-
-
-

{{ $title ?? 'Page Title' }} - {{ config('app.name') }}

-
- -
- - Admin Panel - -
-
+ @include('layouts.partials.header') {{ $slot }} + + @include('layouts.partials.footer')
{{-- Scripts --}} diff --git a/resources/views/layouts/partials/footer.blade.php b/resources/views/layouts/partials/footer.blade.php new file mode 100644 index 000000000..bca1d3815 --- /dev/null +++ b/resources/views/layouts/partials/footer.blade.php @@ -0,0 +1,15 @@ + diff --git a/resources/views/layouts/partials/header.blade.php b/resources/views/layouts/partials/header.blade.php new file mode 100644 index 000000000..d26419f78 --- /dev/null +++ b/resources/views/layouts/partials/header.blade.php @@ -0,0 +1,80 @@ +
+
+

{{ $title ?? 'Page Title' }} - {{ config('app.name') }}

+
+ +
+
+ + + + + +
+ + @auth + + + + {{ __('general.admin') }} + + @else + + {{ __('auth.sign_in') }} + + @endauth +
+
diff --git a/resources/views/livewire/deprecated-notification-channels-banner.blade.php b/resources/views/livewire/deprecated-notification-channels-banner.blade.php new file mode 100644 index 000000000..a96ed0441 --- /dev/null +++ b/resources/views/livewire/deprecated-notification-channels-banner.blade.php @@ -0,0 +1,25 @@ +
+ @if ($this->hasDeprecatedChannels) +
+
+
+ +
+ +
+

+ Deprecated Notification Channels +

+
+

+ You are currently using the following deprecated notification channels: {{ implode(', ', $this->deprecatedChannelsList) }}. +

+

+ These channels will be removed at the end of January 2026. As of that moment you will no longer receive notifications. Please migrate to Apprise which supports all these services and more. +

+
+
+
+
+ @endif +
diff --git a/resources/views/livewire/latest-result-stats.blade.php b/resources/views/livewire/latest-result-stats.blade.php new file mode 100644 index 000000000..f6997016a --- /dev/null +++ b/resources/views/livewire/latest-result-stats.blade.php @@ -0,0 +1,150 @@ +
+ @filled($this->latestResult) +
+
+
+
+

+ + {{ __('general.last_results') }} +

+ +

{{ $this->latestResult->created_at->timezone(config('app.display_timezone'))->format(config('app.datetime_format')) }}

+
+ + @auth + + {{ __('general.view') }} + + @endauth +
+
+ + + + {{ __('general.download') }} + + + @php + $downloadBenchmark = Arr::get($this->latestResult->benchmarks, 'download'); + $downloadBenchmarkPassed = Arr::get($downloadBenchmark, 'passed', false); + @endphp + + @filled($downloadBenchmark) + + $downloadBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $downloadBenchmarkPassed, + ]) title="Benchmark {{ $downloadBenchmarkPassed ? 'passed' : 'failed' }}"> + @if ($downloadBenchmarkPassed) + + @else + + @endif + {{ Arr::get($downloadBenchmark, 'value').' '.str(Arr::get($downloadBenchmark, 'unit'))->title() }} + + + @endfilled + +

+ @php + $download = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->download)); + + $download = explode(' ', $download); + @endphp + + {{ $download[0] }} + {{ $download[1].'ps' }} +

+
+ + + + {{ __('general.upload') }} + + + @php + $uploadBenchmark = Arr::get($this->latestResult->benchmarks, 'upload'); + $uploadBenchmarkPassed = Arr::get($uploadBenchmark, 'passed', false); + @endphp + + @filled($uploadBenchmark) + + $uploadBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $uploadBenchmarkPassed, + ]) title="Benchmark {{ $uploadBenchmarkPassed ? 'passed' : 'failed' }}"> + @if ($uploadBenchmarkPassed) + + @else + + @endif + {{ Arr::get($uploadBenchmark, 'value').' '.str(Arr::get($uploadBenchmark, 'unit'))->title() }} + + + @endfilled + +

+ @php + $upload = \App\Helpers\Bitrate::formatBits(\App\Helpers\Bitrate::bytesToBits($this->latestResult?->upload)); + + $upload = explode(' ', $upload); + @endphp + + {{ $upload[0] }} + {{ $upload[1].'ps' }} +

+
+ + + + {{ __('general.ping') }} + + + @php + $pingBenchmark = Arr::get($this->latestResult->benchmarks, 'ping'); + $pingBenchmarkPassed = Arr::get($pingBenchmark, 'passed', false); + @endphp + + @filled($pingBenchmark) + + $pingBenchmarkPassed, + 'text-amber-500 dark:text-amber-400' => ! $pingBenchmarkPassed, + ]) title="Benchmark {{ $pingBenchmarkPassed ? 'passed' : 'failed' }}"> + @if ($pingBenchmarkPassed) + + @else + + @endif + {{ Arr::get($pingBenchmark, 'value').' '.str(Arr::get($pingBenchmark, 'unit')) }} + + + @endfilled + +

+ {{ round($this->latestResult?->ping, 2) }} + ms +

+
+ + + + {{ __('results.packet_loss') }} + + +

+ {{ round($this->latestResult?->packet_loss, 2) }} + % +

+
+
+ @endfilled +
diff --git a/resources/views/livewire/next-speedtest-banner.blade.php b/resources/views/livewire/next-speedtest-banner.blade.php new file mode 100644 index 000000000..f7f9211c0 --- /dev/null +++ b/resources/views/livewire/next-speedtest-banner.blade.php @@ -0,0 +1,17 @@ +
+ @if ($this->nextSpeedtest) +
+
+
+ +
+ +
+

+ {{ __('dashboard.next_speedtest_at') }} {{ $this->nextSpeedtest->timezone(config('app.display_timezone'))->format(config('app.datetime_format')) }}. +

+
+
+
+ @endif +
diff --git a/resources/views/livewire/platform-stats.blade.php b/resources/views/livewire/platform-stats.blade.php new file mode 100644 index 000000000..00caf7a17 --- /dev/null +++ b/resources/views/livewire/platform-stats.blade.php @@ -0,0 +1,50 @@ +
+
+

+ + {{ __('general.statistics') }} +

+ + {{-- +
+

Quota Usage

+ Edit +
+ +
+
+ Bandwidth + 450MB of 1 GB +
+ +
+
+
+
+
--}} + + + + {{ __('general.total') }} + + +

{{ $this->platformStats['total'] }}

+
+ + + + {{ __('general.total_complted') }} + + +

{{ $this->platformStats['completed'] }}

+
+ + + + {{ __('general.total_failed') }} + + +

{{ $this->platformStats['failed'] }}

+
+
+
diff --git a/resources/views/livewire/topbar/actions.blade.php b/resources/views/livewire/topbar/actions.blade.php new file mode 100644 index 000000000..20db6f5ea --- /dev/null +++ b/resources/views/livewire/topbar/actions.blade.php @@ -0,0 +1,11 @@ +
+
+ {{ $this->speedtestAction }} + + @if ($showDashboard) + {{ $this->dashboardAction }} + @endif +
+ + +
diff --git a/resources/views/livewire/topbar/run-speedtest-action.blade.php b/resources/views/livewire/topbar/run-speedtest-action.blade.php deleted file mode 100644 index 3c88447e0..000000000 --- a/resources/views/livewire/topbar/run-speedtest-action.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -
-
- {{ $this->dashboard }} - - {{ $this->speedtestAction }} -
- - -
diff --git a/resources/views/mail/speedtest/completed.blade.php b/resources/views/mail/speedtest/completed.blade.php index ac40c0e0f..cb0413e47 100644 --- a/resources/views/mail/speedtest/completed.blade.php +++ b/resources/views/mail/speedtest/completed.blade.php @@ -12,7 +12,9 @@ | Ping | {{ $ping }} | | Download | {{ $download }} | | Upload | {{ $upload }} | +@filled($packetLoss) | Packet Loss | {{ $packetLoss }} **%** | +@endfilled diff --git a/resources/views/ntfy/speedtest-completed.blade.php b/resources/views/ntfy/speedtest-completed.blade.php index 24c67a908..64501e794 100644 --- a/resources/views/ntfy/speedtest-completed.blade.php +++ b/resources/views/ntfy/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +Deprecation Notice: The Ntfy notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Ntfy and many other services. + Speedtest Completed - #{{ $id }} A new speedtest on {{ config('app.name') }} was completed using {{ $service }}. diff --git a/resources/views/ntfy/speedtest-threshold.blade.php b/resources/views/ntfy/speedtest-threshold.blade.php index 8ad956c03..7e9ef3c93 100644 --- a/resources/views/ntfy/speedtest-threshold.blade.php +++ b/resources/views/ntfy/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +Deprecation Notice: The Ntfy notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Ntfy and many other services. + Speedtest Threshold Breached - #{{ $id }} A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/resources/views/pushover/speedtest-completed.blade.php b/resources/views/pushover/speedtest-completed.blade.php index 8760eb5de..dfae02299 100644 --- a/resources/views/pushover/speedtest-completed.blade.php +++ b/resources/views/pushover/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +Deprecation Notice: The Pushover notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Pushover and many other services. + Speedtest Completed - #{{ $id }} A new speedtest on {{ config('app.name') }} was completed using {{ $service }}. diff --git a/resources/views/pushover/speedtest-threshold.blade.php b/resources/views/pushover/speedtest-threshold.blade.php index 8ad956c03..6f222fa19 100644 --- a/resources/views/pushover/speedtest-threshold.blade.php +++ b/resources/views/pushover/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +Deprecation Notice: The Pushover notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Pushover and many other services. + Speedtest Threshold Breached - #{{ $id }} A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/resources/views/slack/speedtest-completed.blade.php b/resources/views/slack/speedtest-completed.blade.php index f93d8daf4..c1abcefc5 100644 --- a/resources/views/slack/speedtest-completed.blade.php +++ b/resources/views/slack/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +*Deprecation Notice: The Slack notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Slack and many other services.* + *Speedtest Completed - #{{ $id }}* A new speedtest on *{{ config('app.name') }}* was completed using *{{ $service }}*. diff --git a/resources/views/slack/speedtest-threshold.blade.php b/resources/views/slack/speedtest-threshold.blade.php index 612475a28..2d4a31fc5 100644 --- a/resources/views/slack/speedtest-threshold.blade.php +++ b/resources/views/slack/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +*Deprecation Notice: The Slack notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Slack and many other services.* + **Speedtest Threshold Breached - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/resources/views/telegram/speedtest-completed.blade.php b/resources/views/telegram/speedtest-completed.blade.php index 497c3bf12..ba168cb10 100644 --- a/resources/views/telegram/speedtest-completed.blade.php +++ b/resources/views/telegram/speedtest-completed.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Telegram notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Telegram and many other services.** + *Speedtest Completed - #{{ $id }}* A new speedtest on *{{ config('app.name') }}* was completed using *{{ $service }}*. diff --git a/resources/views/telegram/speedtest-threshold.blade.php b/resources/views/telegram/speedtest-threshold.blade.php index 95dc4bf01..81ef8bb74 100644 --- a/resources/views/telegram/speedtest-threshold.blade.php +++ b/resources/views/telegram/speedtest-threshold.blade.php @@ -1,3 +1,5 @@ +**Deprecation Notice: The Telegram notification channel will stop working at the end of January 2026. Please migrate to Apprise which supports Telegram and many other services.** + **Speedtest Threshold Breached - #{{ $id }}** A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. diff --git a/tests/Unit/Services/ScheduledSpeedtestServiceTest.php b/tests/Unit/Services/ScheduledSpeedtestServiceTest.php new file mode 100644 index 000000000..e8c4cc5af --- /dev/null +++ b/tests/Unit/Services/ScheduledSpeedtestServiceTest.php @@ -0,0 +1,67 @@ +set('speedtest.schedule', null); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeNull(); +}); + +test('returns null when schedule config is false', function () { + config()->set('speedtest.schedule', false); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeNull(); +}); + +test('returns null when schedule config is blank string', function () { + config()->set('speedtest.schedule', ''); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeNull(); +}); + +test('returns Carbon instance when schedule is configured', function () { + config()->set('speedtest.schedule', '*/5 * * * *'); // Every 5 minutes + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeInstanceOf(Carbon::class); +}); + +test('returns correct next scheduled time for hourly cron', function () { + config()->set('speedtest.schedule', '0 * * * *'); // Every hour at minute 0 + config()->set('app.display_timezone', 'UTC'); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeInstanceOf(Carbon::class); + expect($result->minute)->toBe(0); +}); + +test('returns correct next scheduled time for daily cron', function () { + config()->set('speedtest.schedule', '0 0 * * *'); // Every day at midnight + config()->set('app.display_timezone', 'UTC'); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeInstanceOf(Carbon::class); + expect($result->hour)->toBe(0); + expect($result->minute)->toBe(0); +}); + +test('returns future date for next scheduled test', function () { + config()->set('speedtest.schedule', '*/5 * * * *'); // Every 5 minutes + config()->set('app.display_timezone', 'UTC'); + + $result = ScheduledSpeedtestService::getNextScheduledTest(); + + expect($result)->toBeInstanceOf(Carbon::class); + expect($result->isFuture())->toBeTrue(); +});