diff --git a/.env b/.env index d4d3fb6e5..23ba5e558 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ APP_NAME=Virtnet.bond APP_ENV=production APP_KEY=base64:+cQPcLVv62SHfVXP2ZARswRh5voT74qiCb36Pgvunqo= -APP_DEBUG=false +APP_DEBUG=true APP_TIMEZONE=UTC APP_URL=https://billing.virtnet.bond APP_LOCALE=en diff --git a/composer.lock b/composer.lock index cb60c5400..174a70556 100644 --- a/composer.lock +++ b/composer.lock @@ -77,16 +77,16 @@ }, { "name": "andreiio/blade-remix-icon", - "version": "3.8.0", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/andreiio/blade-remix-icon.git", - "reference": "ae93ab98fc912a9ad718b8102f55090653a69930" + "reference": "622f77d25bae5d9de6aed31e70dfea92bb0cbe8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andreiio/blade-remix-icon/zipball/ae93ab98fc912a9ad718b8102f55090653a69930", - "reference": "ae93ab98fc912a9ad718b8102f55090653a69930", + "url": "https://api.github.com/repos/andreiio/blade-remix-icon/zipball/622f77d25bae5d9de6aed31e70dfea92bb0cbe8f", + "reference": "622f77d25bae5d9de6aed31e70dfea92bb0cbe8f", "shasum": "" }, "require": { @@ -123,7 +123,7 @@ "description": "A package to easily make use of Remix Icon in your Laravel Blade views.", "support": { "issues": "https://github.com/andreiio/blade-remix-icon/issues", - "source": "https://github.com/andreiio/blade-remix-icon/tree/3.8.0" + "source": "https://github.com/andreiio/blade-remix-icon/tree/3.9.1" }, "funding": [ { @@ -131,7 +131,7 @@ "type": "github" } ], - "time": "2025-12-29T12:31:33+00:00" + "time": "2026-01-29T12:19:49+00:00" }, { "name": "anourvalar/eloquent-serialize", @@ -526,16 +526,16 @@ }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -574,7 +574,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -582,7 +582,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1344,29 +1344,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1386,9 +1386,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -2036,16 +2036,16 @@ }, { "name": "filament/actions", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1" + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", - "reference": "f8ea2b015b12c00522f1d6a7bcb9453b5f08beb1", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/3cb3e1f9094ed3b4bc102616966c365138c908bc", + "reference": "3cb3e1f9094ed3b4bc102616966c365138c908bc", "shasum": "" }, "require": { @@ -2085,20 +2085,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:27+00:00" + "time": "2026-02-07T21:52:11+00:00" }, { "name": "filament/filament", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "790e3c163e93f5746beea88b93d38673424984b6" + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/790e3c163e93f5746beea88b93d38673424984b6", - "reference": "790e3c163e93f5746beea88b93d38673424984b6", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/6098e568b4257dc438ff68aced0a260f06ba6d52", + "reference": "6098e568b4257dc438ff68aced0a260f06ba6d52", "shasum": "" }, "require": { @@ -2150,20 +2150,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:34+00:00" + "time": "2026-02-07T21:52:21+00:00" }, { "name": "filament/forms", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58" + "reference": "c64bf142f808d292b0c6c21fdd3c75cbef9e9d30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/f708ce490cff3770071d18e9ea678eb4b7c65c58", - "reference": "f708ce490cff3770071d18e9ea678eb4b7c65c58", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/c64bf142f808d292b0c6c21fdd3c75cbef9e9d30", + "reference": "c64bf142f808d292b0c6c21fdd3c75cbef9e9d30", "shasum": "" }, "require": { @@ -2206,20 +2206,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:33+00:00" + "time": "2026-02-19T23:07:33+00:00" }, { "name": "filament/infolists", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155" + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/ac7fc1c8acc651c6c793696f0772747791c91155", - "reference": "ac7fc1c8acc651c6c793696f0772747791c91155", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/9cef7bf9f46756a8adf762ced62952e8c239b840", + "reference": "9cef7bf9f46756a8adf762ced62952e8c239b840", "shasum": "" }, "require": { @@ -2257,11 +2257,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:28:31+00:00" + "time": "2026-02-07T21:51:54+00:00" }, { "name": "filament/notifications", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -2313,16 +2313,16 @@ }, { "name": "filament/support", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505" + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/c37f4b9045a7c514974e12562b5a41813860b505", - "reference": "c37f4b9045a7c514974e12562b5a41813860b505", + "url": "https://api.github.com/repos/filamentphp/support/zipball/493bd79d4f4ae7b9256c317af742354318a6f4e0", + "reference": "493bd79d4f4ae7b9256c317af742354318a6f4e0", "shasum": "" }, "require": { @@ -2368,20 +2368,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-09T09:01:14+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "filament/tables", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180" + "reference": "fb0ab986950dc8129725f676bdb310851b18403f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/c88d17248827b3fbca09db53d563498d29c6b180", - "reference": "c88d17248827b3fbca09db53d563498d29c6b180", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/fb0ab986950dc8129725f676bdb310851b18403f", + "reference": "fb0ab986950dc8129725f676bdb310851b18403f", "shasum": "" }, "require": { @@ -2420,20 +2420,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:37+00:00" + "time": "2026-02-07T21:52:06+00:00" }, { "name": "filament/widgets", - "version": "v3.3.47", + "version": "v3.3.49", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e" + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/2bf59fd94007b69c22c161f7a4749ea19560e03e", - "reference": "2bf59fd94007b69c22c161f7a4749ea19560e03e", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/f58ff26e81ca2557205e3111e1d9d05c258cc206", + "reference": "f58ff26e81ca2557205e3111e1d9d05c258cc206", "shasum": "" }, "require": { @@ -2464,7 +2464,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-01-01T16:29:32+00:00" + "time": "2026-02-07T21:51:58+00:00" }, { "name": "firebase/php-jwt", @@ -3300,16 +3300,16 @@ }, { "name": "laravel/framework", - "version": "v12.48.1", + "version": "v12.52.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830" + "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/0f0974a9769378ccd9c9935c09b9927f3a606830", - "reference": "0f0974a9769378ccd9c9935c09b9927f3a606830", + "url": "https://api.github.com/repos/laravel/framework/zipball/d5511fa74f4608dbb99864198b1954042aa8d5a7", + "reference": "d5511fa74f4608dbb99864198b1954042aa8d5a7", "shasum": "" }, "require": { @@ -3518,20 +3518,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-20T16:12:36+00:00" + "time": "2026-02-17T17:07:04+00:00" }, { "name": "laravel/octane", - "version": "v2.13.4", + "version": "v2.13.5", "source": { "type": "git", "url": "https://github.com/laravel/octane.git", - "reference": "ae618600cb54826a21f67d130a39446f68be1a9a" + "reference": "c343716659c280a7613a0c10d3241215512355ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/octane/zipball/ae618600cb54826a21f67d130a39446f68be1a9a", - "reference": "ae618600cb54826a21f67d130a39446f68be1a9a", + "url": "https://api.github.com/repos/laravel/octane/zipball/c343716659c280a7613a0c10d3241215512355ee", + "reference": "c343716659c280a7613a0c10d3241215512355ee", "shasum": "" }, "require": { @@ -3608,7 +3608,7 @@ "issues": "https://github.com/laravel/octane/issues", "source": "https://github.com/laravel/octane" }, - "time": "2025-12-21T23:59:50+00:00" + "time": "2026-01-22T17:24:46+00:00" }, { "name": "laravel/passport", @@ -3688,30 +3688,30 @@ }, { "name": "laravel/prompts", - "version": "v0.3.10", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3", - "reference": "360ba095ef9f51017473505191fbd4ab73e1cab3", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -3741,33 +3741,33 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.10" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2026-01-13T20:29:29+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.8", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", - "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -3804,7 +3804,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-01-08T16:22:46+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/socialite", @@ -3880,16 +3880,16 @@ }, { "name": "laravel/tinker", - "version": "v2.11.0", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468", - "reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -3940,9 +3940,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.0" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-12-19T19:16:45+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "lcobucci/clock", @@ -4951,16 +4951,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.6", + "version": "v3.7.10", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "276ac156f6ae414990784854a2673e3d23c68b24" + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/276ac156f6ae414990784854a2673e3d23c68b24", - "reference": "276ac156f6ae414990784854a2673e3d23c68b24", + "url": "https://api.github.com/repos/livewire/livewire/zipball/0dc679eb4c8b4470cb12522b5927ef08ca2358bb", + "reference": "0dc679eb4c8b4470cb12522b5927ef08ca2358bb", "shasum": "" }, "require": { @@ -5015,7 +5015,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.6" + "source": "https://github.com/livewire/livewire/tree/v3.7.10" }, "funding": [ { @@ -5023,7 +5023,7 @@ "type": "github" } ], - "time": "2026-01-23T05:41:38+00:00" + "time": "2026-02-09T22:49:33+00:00" }, { "name": "masterminds/html5", @@ -5197,16 +5197,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", "shasum": "" }, "require": { @@ -5230,7 +5230,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -5273,14 +5273,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -5298,20 +5298,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2026-01-29T09:26:29+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -5319,8 +5319,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -5361,22 +5361,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -5388,8 +5388,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -5450,9 +5452,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -5514,31 +5516,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -5570,7 +5572,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -5581,7 +5583,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -5597,7 +5599,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nyholm/psr7", @@ -5966,16 +5968,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.48", + "version": "3.0.49", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", "shasum": "" }, "require": { @@ -6056,7 +6058,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" }, "funding": [ { @@ -6072,7 +6074,7 @@ "type": "tidelift" } ], - "time": "2025-12-15T11:51:42+00:00" + "time": "2026-01-27T09:17:28+00:00" }, { "name": "postare/blade-mdi", @@ -6608,16 +6610,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -6681,9 +6683,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "qirolab/laravel-themer", @@ -7569,16 +7571,16 @@ }, { "name": "spatie/laravel-query-builder", - "version": "6.4.0", + "version": "6.4.2", "source": { "type": "git", "url": "https://github.com/spatie/laravel-query-builder.git", - "reference": "f73627028538bfa58d5437f79344fa93ae254a36" + "reference": "3d86f49fffa39d542753d15b741b83ca33087fc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/f73627028538bfa58d5437f79344fa93ae254a36", - "reference": "f73627028538bfa58d5437f79344fa93ae254a36", + "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/3d86f49fffa39d542753d15b741b83ca33087fc1", + "reference": "3d86f49fffa39d542753d15b741b83ca33087fc1", "shasum": "" }, "require": { @@ -7639,7 +7641,7 @@ "type": "custom" } ], - "time": "2026-01-09T13:35:54+00:00" + "time": "2026-02-18T09:22:31+00:00" }, { "name": "symfony/clock", @@ -8198,16 +8200,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "01b24a145bbeaa7141e75887ec904c34a6728a5f" + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/01b24a145bbeaa7141e75887ec904c34a6728a5f", - "reference": "01b24a145bbeaa7141e75887ec904c34a6728a5f", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", "shasum": "" }, "require": { @@ -8242,7 +8244,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.4" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -8262,7 +8264,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/html-sanitizer", @@ -8340,16 +8342,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "977a554a34cf8edc95ca351fbecb1bb1ad05cc94" + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/977a554a34cf8edc95ca351fbecb1bb1ad05cc94", - "reference": "977a554a34cf8edc95ca351fbecb1bb1ad05cc94", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", "shasum": "" }, "require": { @@ -8398,7 +8400,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" }, "funding": [ { @@ -8418,20 +8420,20 @@ "type": "tidelift" } ], - "time": "2026-01-09T12:14:21+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "48b067768859f7b68acf41dfb857a5a4be00acdd" + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/48b067768859f7b68acf41dfb857a5a4be00acdd", - "reference": "48b067768859f7b68acf41dfb857a5a4be00acdd", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", "shasum": "" }, "require": { @@ -8517,7 +8519,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" }, "funding": [ { @@ -8537,7 +8539,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T22:13:01+00:00" + "time": "2026-01-28T10:33:42+00:00" }, { "name": "symfony/mailer", @@ -8625,16 +8627,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "40945014c0a9471ccfe19673c54738fa19367a3c" + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/40945014c0a9471ccfe19673c54738fa19367a3c", - "reference": "40945014c0a9471ccfe19673c54738fa19367a3c", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", "shasum": "" }, "require": { @@ -8645,15 +8647,15 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -8690,7 +8692,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.4" + "source": "https://github.com/symfony/mime/tree/v7.4.5" }, "funding": [ { @@ -8710,7 +8712,7 @@ "type": "tidelift" } ], - "time": "2026-01-08T16:12:55+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/polyfill-ctype", @@ -9543,16 +9545,16 @@ }, { "name": "symfony/process", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "626f07a53f4b4e2f00e11824cc29f928d797783b" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/626f07a53f4b4e2f00e11824cc29f928d797783b", - "reference": "626f07a53f4b4e2f00e11824cc29f928d797783b", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -9584,7 +9586,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.4" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -9604,7 +9606,7 @@ "type": "tidelift" } ], - "time": "2026-01-20T09:23:51+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10306,16 +10308,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.3.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -10425,7 +10427,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -10436,12 +10438,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-05-14T06:15:44+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11145,16 +11151,16 @@ }, { "name": "iamcal/sql-parser", - "version": "v0.6", + "version": "v0.7", "source": { "type": "git", "url": "https://github.com/iamcal/SQLParser.git", - "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", - "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", "shasum": "" }, "require-dev": { @@ -11180,27 +11186,27 @@ "description": "MySQL schema parser", "support": { "issues": "https://github.com/iamcal/SQLParser/issues", - "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" }, - "time": "2025-03-17T16:59:46+00:00" + "time": "2026-01-28T22:20:33+00:00" }, { "name": "larastan/larastan", - "version": "v3.9.1", + "version": "v3.9.2", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "4b92d9627f779fd32bdc16f53f8ce88c50446ff5" + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/4b92d9627f779fd32bdc16f53f8ce88c50446ff5", - "reference": "4b92d9627f779fd32bdc16f53f8ce88c50446ff5", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", "shasum": "" }, "require": { "ext-json": "*", - "iamcal/sql-parser": "^0.6.0", + "iamcal/sql-parser": "^0.7.0", "illuminate/console": "^11.44.2 || ^12.4.1", "illuminate/container": "^11.44.2 || ^12.4.1", "illuminate/contracts": "^11.44.2 || ^12.4.1", @@ -11264,7 +11270,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.1" + "source": "https://github.com/larastan/larastan/tree/v3.9.2" }, "funding": [ { @@ -11272,20 +11278,20 @@ "type": "github" } ], - "time": "2026-01-21T09:15:17+00:00" + "time": "2026-01-30T15:16:32+00:00" }, { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -11296,13 +11302,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -11339,32 +11345,32 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { "name": "laravel/sail", - "version": "v1.52.0", + "version": "v1.53.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3" + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3", - "reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.0" }, "bin": [ @@ -11402,7 +11408,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-01-01T02:46:03+00:00" + "time": "2026-02-06T12:16:02+00:00" }, { "name": "mockery/mockery", @@ -11549,39 +11555,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -11644,7 +11647,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "phar-io/manifest", @@ -11887,11 +11890,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.37", + "version": "2.1.39", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/28cd424c5ea984128c95cfa7ea658808e8954e49", - "reference": "28cd424c5ea984128c95cfa7ea658808e8954e49", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", "shasum": "" }, "require": { @@ -11936,7 +11939,7 @@ "type": "github" } ], - "time": "2026-01-24T08:21:55+00:00" + "time": "2026-02-11T14:48:56+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12030,28 +12033,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -12079,15 +12082,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -12275,16 +12290,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.49", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4f1750675ba411dd6c2d5fa8a3cca07f6742020e", - "reference": "4f1750675ba411dd6c2d5fa8a3cca07f6742020e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -12299,7 +12314,7 @@ "phar-io/version": "^3.2.1", "php": ">=8.2", "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", @@ -12311,6 +12326,7 @@ "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -12356,7 +12372,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.49" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -12380,7 +12396,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T16:09:28+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "sebastian/cli-parser", diff --git a/extensions/Others/CustomPages/resources/views/show.blade.php b/extensions/Others/CustomPages/resources/views/show.blade.php index cdeaa97aa..32c438895 100644 --- a/extensions/Others/CustomPages/resources/views/show.blade.php +++ b/extensions/Others/CustomPages/resources/views/show.blade.php @@ -1,137 +1,139 @@ -
-
-
- {!! \Illuminate\Support\Str::markdown($page->content) !!} -
-
-
- - - - +
+
+
+
+ {!! \Illuminate\Support\Str::markdown($page->content) !!} +
+
+
+ + + + +
diff --git a/extensions/Others/KnowledgeBase/resources/views/show.blade.php b/extensions/Others/KnowledgeBase/resources/views/show.blade.php index 0b01e545f..8a23a6bac 100644 --- a/extensions/Others/KnowledgeBase/resources/views/show.blade.php +++ b/extensions/Others/KnowledgeBase/resources/views/show.blade.php @@ -1,168 +1,170 @@ -
-
- - - {{ __('Back to Documentation') }} - -
- -
-
-
-
-
-
- - {{ $article->category->name ?? __('General') }} -
-

- {{ $article->title }} -

-
- - - {{ optional($article->published_at)->format('F d, Y') ?? $article->created_at->format('F d, Y') }} - - - - {{ optional($article->published_at)->diffForHumans() ?? $article->created_at->diffForHumans() }} - -
-
-
-
- -
- {!! $article->content !!} -
-
-
- - - - +
+
+ + +
+
+
+
+
+
+ + {{ $article->category->name ?? __('General') }} +
+

+ {{ $article->title }} +

+
+ + + {{ optional($article->published_at)->format('F d, Y') ?? $article->created_at->format('F d, Y') }} + + + + {{ optional($article->published_at)->diffForHumans() ?? $article->created_at->diffForHumans() }} + +
+
+
+
+ +
+ {!! $article->content !!} +
+
+
+ + + + +
diff --git a/extensions/Servers/Proxmox/Livewire/Statistics.php b/extensions/Servers/Proxmox/Livewire/Statistics.php index 5da3998f1..53874f95f 100644 --- a/extensions/Servers/Proxmox/Livewire/Statistics.php +++ b/extensions/Servers/Proxmox/Livewire/Statistics.php @@ -1,4307 +1,4307 @@ - [], - 'memory' => [], - 'network' => [], - 'disk' => [], - ]; - public bool $shouldRenderCharts = false; - - public bool $networkAnalysisLoading = false; - public int $networkAnalysisRetryCount = 0; - public int $networkAnalysisMaxRetries = 3; - - // Network Analysis Pagination - public int $connectionsPerPage = 25; - public int $connectionsCurrentPage = 1; - public int $topConnectionsPerPage = 25; - public int $topConnectionsCurrentPage = 1; - public int $topPortsPerPage = 25; - public int $topPortsCurrentPage = 1; - - // Statistics History Pagination - public int $statisticsPerPage = 25; - - // Task History Pagination - public int $taskHistoryPerPage = 25; - public int $taskHistoryCurrentPage = 1; - - // Lifecycle Events Pagination - public int $lifecycleEventsPerPage = 25; - public int $lifecycleEventsCurrentPage = 1; - - public array $networkAnalysis = [ - 'connectionCount' => 0, - 'topConnectionCount' => 0, - 'topPortCount' => 0, - 'security' => [], - 'lastUpdated' => null, - 'error' => null, - ]; - - public array $cachedServerIps = []; - public array $serverIpCidrs = []; - - // Historical comparison settings - public string $historicalPeriod = '1_hour_ago'; // Options: 1_hour_ago, 2_hours_ago, 6_hours_ago, 12_hours_ago, 24_hours_ago, custom - public string $currentPeriod = 'now'; // Options: now, 1_hour_ago, custom - public ?string $historicalCustomDate = null; - public ?string $currentCustomDate = null; - - public function getChartEventsProperty() - { - $tasks = $this->taskHistory; - $events = []; - - foreach ($tasks as $task) { - $type = strtolower($task['type'] ?? ''); - $status = strtolower($task['status'] ?? ''); - $startTime = $task['starttime'] ?? null; - $endTime = $task['endtime'] ?? null; - $upid = $task['upid'] ?? $task['id'] ?? null; - - if (!$startTime) { - continue; - } - - $event = null; - if (in_array($type, ['qmstart', 'qmresume'])) { - $event = ['label' => 'Start', 'type' => 'start', 'icon' => 'ri-play-fill']; - } elseif (in_array($type, ['qmstop', 'qmshutdown'])) { - $event = ['label' => 'Shutdown', 'type' => 'shutdown', 'icon' => 'ri-stop-fill']; - } elseif (in_array($type, ['qmreboot'])) { - $event = ['label' => 'Reboot', 'type' => 'reboot', 'icon' => 'ri-loop-left-line']; - } elseif (in_array($type, ['qmreset'])) { - $event = ['label' => 'Reset', 'type' => 'reset', 'icon' => 'ri-restart-line']; - } elseif ($type === 'vzdump') { - $event = ['label' => 'Backup', 'type' => 'backup', 'icon' => 'ri-save-3-fill']; - } - - if ($event) { - $startCarbon = \Carbon\Carbon::createFromTimestamp($startTime); - $endCarbon = $endTime && $endTime > 0 ? \Carbon\Carbon::createFromTimestamp($endTime) : null; - $duration = null; - if ($startCarbon && $endCarbon) { - $duration = $startCarbon->diffInSeconds($endCarbon); - } elseif ($startCarbon && $status === 'running') { - $duration = $startCarbon->diffInSeconds(now()); - } - - $events[] = [ - 'timestamp' => $startTime * 1000, // Convert to milliseconds - 'label' => $event['label'], - 'type' => $event['type'], - 'icon' => $event['icon'], - 'status' => $status, - 'original_type' => $task['type'] ?? 'N/A', - 'starttime' => $startTime, - 'endtime' => $endTime, - 'duration' => $duration, - 'upid' => $upid, - 'start_datetime' => $startCarbon->format('M d, Y H:i:s'), - 'end_datetime' => $endCarbon ? $endCarbon->format('M d, Y H:i:s') : null, - 'duration_formatted' => $duration !== null ? $this->formatUptime($duration) : null, - ]; - } - } - - return $events; - } - - public function mount(Server $server): void - { - parent::mount($server); - - $this->server->loadMissing('ipAddresses'); - // Network Analysis is loaded lazily via JavaScript to improve page load speed - // Initialize error state if not configured to prevent infinite loading - if (!$this->coreRouterEnabled()) { - $this->networkAnalysis['error'] = 'Core Router API is not configured.'; - $this->networkAnalysis['connectionCount'] = 0; - $this->networkAnalysis['topConnectionCount'] = 0; - $this->networkAnalysis['topPortCount'] = 0; - $this->networkAnalysis['security'] = []; - } - - // Preload graph data on mount to speed up initial display - // Dispatch individual chart events for more reliable parallel loading - // Each chart loads independently so a failure in one doesn't block others - $this->dispatch('proxmox:stats:cpu'); - $this->dispatch('proxmox:stats:memory'); - $this->dispatch('proxmox:stats:network'); - $this->dispatch('proxmox:stats:disk'); - } - - #[On('proxmox:stats')] - public function stats(): void - { - // Fetch each graph independently to prevent one failure from blocking others - // Each fetch is wrapped in its own try-catch in fetchGraphData() - $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); - $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); - $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); - $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); - } - - /** - * Fetch individual graph data - allows frontend to request specific graphs independently - * This improves reliability as each graph can be retried separately if it fails - */ - #[On('proxmox:stats:cpu')] - public function fetchCpuStats(): void - { - $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); - } - - #[On('proxmox:stats:memory')] - public function fetchMemoryStats(): void - { - $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); - } - - #[On('proxmox:stats:network')] - public function fetchNetworkStats(): void - { - $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); - } - - #[On('proxmox:stats:disk')] - public function fetchDiskStats(): void - { - $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); - } - - /** - * Explicit event handler for Network Analysis lazy loading. - * - * This mirrors the Live Telemetry pattern (proxmox:stats) and allows the - * frontend to trigger loading via Livewire.dispatch(), which is more - * robust than relying on @this in inline scripts – especially on the - * first-ever load of the Statistics page for a given server. - */ - #[On('proxmox:network-analysis-load')] - public function handleNetworkAnalysisLoad(): void - { - $this->loadNetworkAnalysis(); - } - - public function loadNetworkAnalysis(): void - { - Log::debug('Network Analysis: loadNetworkAnalysis() called', [ - 'current_loading_state' => $this->networkAnalysisLoading, - 'has_last_updated' => ! empty($this->networkAnalysis['lastUpdated']), - 'has_error' => ! empty($this->networkAnalysis['error']), - ]); - - // Use a short-lived cache to avoid repeatedly performing heavy analysis - $cacheKey = 'proxmox_network_analysis_' . $this->server->id; - - // If we already have a fairly recent result, reuse it immediately - $cachedAnalysis = Cache::get($cacheKey); - if (is_array($cachedAnalysis) && ! empty($cachedAnalysis['lastUpdated']) && empty($this->networkAnalysis['error'])) { - Log::debug('Network Analysis: served from cache', [ - 'cache_key' => $cacheKey, - 'last_updated' => $cachedAnalysis['lastUpdated'] ?? null, - ]); - - // Only store lightweight metadata in the public property (not the heavy arrays) - $this->networkAnalysis = [ - 'connectionCount' => count($cachedAnalysis['connections'] ?? []), - 'topConnectionCount' => count($cachedAnalysis['topConnections'] ?? []), - 'topPortCount' => count($cachedAnalysis['topPorts'] ?? []), - 'security' => $cachedAnalysis['security'] ?? [], - 'lastUpdated' => $cachedAnalysis['lastUpdated'], - 'error' => $cachedAnalysis['error'] ?? null, - ]; - $this->networkAnalysisLoading = false; - - // Reset retry count when we successfully hydrate from cache - $this->networkAnalysisRetryCount = 0; - - return; - } - - // Prevent duplicate loads while a request is already in-flight - if ($this->networkAnalysisLoading) { - Log::debug('Network Analysis: Already loading, skipping new request'); - - return; - } - - if (! $this->coreRouterEnabled()) { - $this->networkAnalysis['error'] = 'Core Router API is not configured.'; - $this->networkAnalysis['connectionCount'] = 0; - $this->networkAnalysis['topConnectionCount'] = 0; - $this->networkAnalysis['topPortCount'] = 0; - $this->networkAnalysis['security'] = []; - $this->networkAnalysisLoading = false; - - return; - } - - $serverIps = $this->getServerIps(); - if (empty($serverIps)) { - $this->networkAnalysis['error'] = 'No IP addresses are associated with this server.'; - $this->networkAnalysis['connectionCount'] = 0; - $this->networkAnalysis['topConnectionCount'] = 0; - $this->networkAnalysis['topPortCount'] = 0; - $this->networkAnalysis['security'] = []; - $this->networkAnalysisLoading = false; - - return; - } - - $this->networkAnalysisLoading = true; - $this->networkAnalysis['error'] = null; - - try { - $client = app(CoreRouterClient::class); - - // Fetch all firewall states (no API-level filtering) - $allStates = $client->listStates(); - - // Normalize server IPs for matching - $normalizedServerIps = array_map(static function ($ip) { - return strtolower(trim($ip)); - }, $serverIps); - - // Sample a few states to debug extraction (kept minimal for performance) - $sampleStatesForDebug = []; - foreach (array_slice($allStates, 0, 10) as $sampleState) { - $rawSrc = (string) ($sampleState['source'] ?? ''); - $rawDst = (string) ($sampleState['destination'] ?? ''); - $sampleStatesForDebug[] = [ - 'raw_source' => $rawSrc, - 'raw_destination' => $rawDst, - 'extracted_source' => $this->extractIpFromField($rawSrc), - 'extracted_destination' => $this->extractIpFromField($rawDst), - 'protocol' => $sampleState['protocol'] ?? 'N/A', - ]; - } - - Log::debug('Core Router filtering states', [ - 'total_states' => count($allStates), - 'server_ips' => $serverIps, - 'normalized_server_ips' => $normalizedServerIps, - 'sample_states_extraction' => $sampleStatesForDebug, - ]); - - // Filter states: keep only where source OR destination matches a server IP - $filteredStates = []; - $sampleChecks = []; - foreach ($allStates as $state) { - $rawSource = (string) ($state['source'] ?? ''); - $rawDestination = (string) ($state['destination'] ?? ''); - - // Extract IP from "IP[port]" or "IP:port" format - $sourceIp = $this->extractIpFromField($rawSource); - $destinationIp = $this->extractIpFromField($rawDestination); - - $sourceNormalized = strtolower(trim($sourceIp)); - $destNormalized = strtolower(trim($destinationIp)); - - $matches = false; - $matchReason = ''; - - // Normalize raw fields for case-insensitive matching - $rawSourceLower = strtolower($rawSource); - $rawDestinationLower = strtolower($rawDestination); - - // Check exact match - foreach ($normalizedServerIps as $serverIp) { - if ($sourceNormalized === $serverIp) { - $matches = true; - $matchReason = "source exact match: {$sourceNormalized} === {$serverIp}"; - break; - } - if ($destNormalized === $serverIp) { - $matches = true; - $matchReason = "destination exact match: {$destNormalized} === {$serverIp}"; - break; - } - // Also check raw fields contain the IP (handles "IP[port]" format) - case-insensitive - if (stripos($rawSourceLower, $serverIp) !== false) { - $matches = true; - $matchReason = "raw source contains: {$rawSource} contains {$serverIp}"; - break; - } - if (stripos($rawDestinationLower, $serverIp) !== false) { - $matches = true; - $matchReason = "raw destination contains: {$rawDestination} contains {$serverIp}"; - break; - } - } - - if ($matches) { - $filteredStates[] = $state; - - // Log first few matches for debugging - if (count($sampleChecks) < 5) { - $sampleChecks[] = [ - 'raw_source' => $rawSource, - 'raw_destination' => $rawDestination, - 'extracted_source' => $sourceIp, - 'extracted_destination' => $destinationIp, - 'source_normalized' => $sourceNormalized, - 'dest_normalized' => $destNormalized, - 'match_reason' => $matchReason, - 'protocol' => $state['protocol'] ?? 'N/A', - ]; - } - } - } - - Log::debug('Core Router filtered states result', [ - 'filtered_count' => count($filteredStates), - 'sample_matches' => $sampleChecks, - ]); - - // Format connections - $connections = $this->formatConnectionsSimple(array_values($filteredStates)); - $topConnections = $this->aggregateTopConnections($connections); - $topPorts = $this->aggregateTopPorts($connections); - $securityWarnings = $this->checkSecurityThreats($connections); - - Log::debug('Core Router network analysis - final formatting', [ - 'filtered_states_count' => count($filteredStates), - 'connections_count' => count($connections), - 'sample_connections' => array_slice($connections, 0, 3), - ]); - - // Store all connections in cache only — NOT in the public property - // This prevents thousands of connections from being serialized in every Livewire request - $analysis = [ - 'connections' => $connections, - 'topConnections' => $topConnections, - 'topPorts' => $topPorts, - 'security' => $securityWarnings, - 'lastUpdated' => now()->toDateTimeString(), - 'error' => null, - ]; - - // Persist the full analysis in cache for pagination and export - Cache::put($cacheKey, $analysis, now()->addSeconds(30)); - - // Only store lightweight metadata in the Livewire public property - $this->networkAnalysis = [ - 'connectionCount' => count($connections), - 'topConnectionCount' => count($topConnections), - 'topPortCount' => count($topPorts), - 'security' => $securityWarnings, - 'lastUpdated' => now()->toDateTimeString(), - 'error' => null, - ]; - - // Reset pagination to page 1 when data is refreshed - $this->connectionsCurrentPage = 1; - $this->topConnectionsCurrentPage = 1; - $this->topPortsCurrentPage = 1; - - // Reset retry count on successful load - $this->networkAnalysisRetryCount = 0; - } catch (\Throwable $e) { - report($e); - - $errorMessage = $e->getMessage(); - $is502Error = str_contains($errorMessage, 'HTTP 502') || str_contains($errorMessage, '(HTTP 502)'); - - // Reset counts and security on error - $this->networkAnalysis['connectionCount'] = 0; - $this->networkAnalysis['topConnectionCount'] = 0; - $this->networkAnalysis['topPortCount'] = 0; - $this->networkAnalysis['security'] = []; - - // On failure, do not leave any stale cache behind for this server - Cache::forget($cacheKey); - - // Check if it's a 502 error and we haven't exceeded max retries - if ($is502Error && $this->networkAnalysisRetryCount < $this->networkAnalysisMaxRetries) { - $this->networkAnalysisRetryCount++; - - Log::warning('Core Router API returned 502 error, will retry', [ - 'retry_count' => $this->networkAnalysisRetryCount, - 'max_retries' => $this->networkAnalysisMaxRetries, - ]); - - // Set error message indicating retry - $this->networkAnalysis['error'] = 'Temporary server error (HTTP 502). Retrying... (Attempt ' . $this->networkAnalysisRetryCount . '/' . $this->networkAnalysisMaxRetries . ')'; - - // Dispatch event to retry after a delay - $this->dispatch('network-analysis-502-retry', [ - 'retryCount' => $this->networkAnalysisRetryCount, - ]); - } else { - // Not a 502 error or max retries reached - show final error - $this->networkAnalysisRetryCount = 0; - $this->networkAnalysis['error'] = 'Unable to load network analysis: ' . $errorMessage; - } - } finally { - // Always clear loading state in finally block so UI can recover - $this->networkAnalysisLoading = false; - } - } - - /** - * Retry loading network analysis after a 502 error - */ - public function retryNetworkAnalysis(): void - { - // Simple helper that can be invoked from Livewire/JS if needed. - // We intentionally avoid blocking sleep here; the browser-side - // retry logic already introduces a delay between attempts. - $this->networkAnalysisLoading = false; - $this->loadNetworkAnalysis(); - } - - // ========================================== - // Network Analysis Pagination Methods - // ========================================== - - /** - * Get paginated connections for display - */ - public function getPaginatedConnectionsProperty(): array - { - $cacheKey = 'proxmox_network_analysis_' . $this->server->id; - $cachedAnalysis = Cache::get($cacheKey, []); - $connections = $cachedAnalysis['connections'] ?? []; - $offset = ($this->connectionsCurrentPage - 1) * $this->connectionsPerPage; - - return array_slice($connections, $offset, $this->connectionsPerPage); - } - - /** - * Get total pages for connections - */ - public function getConnectionsTotalPagesProperty(): int - { - $total = $this->networkAnalysis['connectionCount'] ?? 0; - return max(1, (int) ceil($total / $this->connectionsPerPage)); - } - - /** - * Get paginated top connections for display - */ - public function getPaginatedTopConnectionsProperty(): array - { - $cacheKey = 'proxmox_network_analysis_' . $this->server->id; - $cachedAnalysis = Cache::get($cacheKey, []); - $connections = $cachedAnalysis['topConnections'] ?? []; - $offset = ($this->topConnectionsCurrentPage - 1) * $this->topConnectionsPerPage; - - return array_slice($connections, $offset, $this->topConnectionsPerPage); - } - - /** - * Get total pages for top connections - */ - public function getTopConnectionsTotalPagesProperty(): int - { - $total = $this->networkAnalysis['topConnectionCount'] ?? 0; - return max(1, (int) ceil($total / $this->topConnectionsPerPage)); - } - - /** - * Get paginated top ports for display - */ - public function getPaginatedTopPortsProperty(): array - { - $cacheKey = 'proxmox_network_analysis_' . $this->server->id; - $cachedAnalysis = Cache::get($cacheKey, []); - $ports = $cachedAnalysis['topPorts'] ?? []; - $offset = ($this->topPortsCurrentPage - 1) * $this->topPortsPerPage; - - return array_slice($ports, $offset, $this->topPortsPerPage); - } - - /** - * Get total pages for top ports - */ - public function getTopPortsTotalPagesProperty(): int - { - $total = $this->networkAnalysis['topPortCount'] ?? 0; - return max(1, (int) ceil($total / $this->topPortsPerPage)); - } - - /** - * Reset page to 1 when perPage changes for connections - */ - public function updatedConnectionsPerPage(): void - { - $this->connectionsCurrentPage = 1; - } - - /** - * Reset page to 1 when perPage changes for top connections - */ - public function updatedTopConnectionsPerPage(): void - { - $this->topConnectionsCurrentPage = 1; - } - - /** - * Reset page to 1 when perPage changes for top ports - */ - public function updatedTopPortsPerPage(): void - { - $this->topPortsCurrentPage = 1; - } - - /** - * Navigate to a specific page for connections - */ - public function goToConnectionsPage(int $page): void - { - $this->connectionsCurrentPage = max(1, min($page, $this->connectionsTotalPages)); - } - - /** - * Navigate to previous page for connections - */ - public function previousConnectionsPage(): void - { - $this->goToConnectionsPage($this->connectionsCurrentPage - 1); - } - - /** - * Navigate to next page for connections - */ - public function nextConnectionsPage(): void - { - $this->goToConnectionsPage($this->connectionsCurrentPage + 1); - } - - /** - * Navigate to a specific page for top connections - */ - public function goToTopConnectionsPage(int $page): void - { - $this->topConnectionsCurrentPage = max(1, min($page, $this->topConnectionsTotalPages)); - } - - /** - * Navigate to previous page for top connections - */ - public function previousTopConnectionsPage(): void - { - $this->goToTopConnectionsPage($this->topConnectionsCurrentPage - 1); - } - - /** - * Navigate to next page for top connections - */ - public function nextTopConnectionsPage(): void - { - $this->goToTopConnectionsPage($this->topConnectionsCurrentPage + 1); - } - - /** - * Navigate to a specific page for top ports - */ - public function goToTopPortsPage(int $page): void - { - $this->topPortsCurrentPage = max(1, min($page, $this->topPortsTotalPages)); - } - - /** - * Navigate to previous page for top ports - */ - public function previousTopPortsPage(): void - { - $this->goToTopPortsPage($this->topPortsCurrentPage - 1); - } - - /** - * Navigate to next page for top ports - */ - public function nextTopPortsPage(): void - { - $this->goToTopPortsPage($this->topPortsCurrentPage + 1); - } - - // ========================================== - // Statistics History Pagination Methods - // ========================================== - - /** - * Reset to page 1 when perPage changes for statistics - * (Laravel paginator will automatically handle this with the paginate() method) - */ - public function updatedStatisticsPerPage(): void - { - // Reset to page 1 when changing per-page (handled by Livewire automatically) - $this->resetPage(); - } - - // ========================================== - // Task History Pagination Methods - // ========================================== - - /** - * Get paginated task history for display - */ - public function getPaginatedTaskHistoryProperty(): array - { - $tasks = $this->taskHistory; - if (empty($tasks)) { - return []; - } - $offset = ($this->taskHistoryCurrentPage - 1) * $this->taskHistoryPerPage; - - return array_slice($tasks, $offset, $this->taskHistoryPerPage); - } - - /** - * Get total pages for task history - */ - public function getTaskHistoryTotalPagesProperty(): int - { - $total = count($this->taskHistory); - return max(1, (int) ceil($total / $this->taskHistoryPerPage)); - } - - /** - * Reset page to 1 when perPage changes for task history - */ - public function updatedTaskHistoryPerPage(): void - { - $this->taskHistoryCurrentPage = 1; - } - - /** - * Navigate to a specific page for task history - */ - public function goToTaskHistoryPage(int $page): void - { - $this->taskHistoryCurrentPage = max(1, min($page, $this->taskHistoryTotalPages)); - } - - /** - * Navigate to previous page for task history - */ - public function previousTaskHistoryPage(): void - { - $this->goToTaskHistoryPage($this->taskHistoryCurrentPage - 1); - } - - /** - * Navigate to next page for task history - */ - public function nextTaskHistoryPage(): void - { - $this->goToTaskHistoryPage($this->taskHistoryCurrentPage + 1); - } - - // ========================================== - // Lifecycle Events Pagination Methods - // ========================================== - - /** - * Get paginated lifecycle events for display - */ - public function getPaginatedLifecycleEventsProperty(): array - { - $events = collect($this->chartEvents)->values()->all(); - if (empty($events)) { - return []; - } - $offset = ($this->lifecycleEventsCurrentPage - 1) * $this->lifecycleEventsPerPage; - - return array_slice($events, $offset, $this->lifecycleEventsPerPage); - } - - /** - * Get total pages for lifecycle events - */ - public function getLifecycleEventsTotalPagesProperty(): int - { - $total = count($this->chartEvents); - return max(1, (int) ceil($total / $this->lifecycleEventsPerPage)); - } - - /** - * Reset page to 1 when perPage changes for lifecycle events - */ - public function updatedLifecycleEventsPerPage(): void - { - $this->lifecycleEventsCurrentPage = 1; - } - - /** - * Navigate to a specific page for lifecycle events - */ - public function goToLifecycleEventsPage(int $page): void - { - $this->lifecycleEventsCurrentPage = max(1, min($page, $this->lifecycleEventsTotalPages)); - } - - /** - * Navigate to previous page for lifecycle events - */ - public function previousLifecycleEventsPage(): void - { - $this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage - 1); - } - - /** - * Navigate to next page for lifecycle events - */ - public function nextLifecycleEventsPage(): void - { - $this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage + 1); - } - - /** - * Extract IP address from field (handles "IP[port]" and "IP:port" formats) - */ - protected function extractIpFromField(string $field): string - { - if (empty($field)) { - return ''; - } - - $field = trim($field); - - // Handle IPv4 "IP:port" format FIRST (e.g., "45.154.99.28:22") - // Check this before the bracket format since IPv4 also uses colons - if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { - return $matches[1]; - } - - // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") - // This regex matches: IP part (anything that's not [ or ]) optionally followed by [port] - if (preg_match('/^([^\[\]]+)(?:\[[^\]]+\])?$/', $field, $matches)) { - $ip = trim($matches[1]); - // Remove any trailing colons or brackets - $ip = rtrim($ip, ':[]'); - return $ip; - } - - // If no special format, return as-is - return $field; - } - - /** - * Simple connection formatting - */ - protected function formatConnectionsSimple(array $states): array - { - $connections = []; - foreach ($states as $state) { - $rawSource = (string)($state['source'] ?? ''); - $rawDestination = (string)($state['destination'] ?? ''); - - $sourceIp = $this->extractIpFromField($rawSource); - $destinationIp = $this->extractIpFromField($rawDestination); - - // Extract ports - $sourcePort = null; - $destinationPort = null; - - if (preg_match('/\[(\d+)\]$/', $rawSource, $matches)) { - $sourcePort = (int)$matches[1]; - } elseif (preg_match('/:(\d+)$/', $rawSource, $matches) && filter_var(explode(':', $rawSource)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $sourcePort = (int)$matches[1]; - } - - if (preg_match('/\[(\d+)\]$/', $rawDestination, $matches)) { - $destinationPort = (int)$matches[1]; - } elseif (preg_match('/:(\d+)$/', $rawDestination, $matches) && filter_var(explode(':', $rawDestination)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $destinationPort = (int)$matches[1]; - } - - $bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0); - $packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0); - $protocol = strtoupper($state['protocol'] ?? 'N/A'); - $stateName = strtoupper($state['state'] ?? 'UNKNOWN'); - $interface = strtoupper($state['interface'] ?? 'N/A'); - $direction = strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound'; - - $connections[] = [ - 'protocol' => $protocol, - 'source' => $sourceIp, - 'destination' => $destinationIp, - 'source_port' => $sourcePort, - 'destination_port' => $destinationPort, - 'raw_source' => $rawSource, - 'raw_destination' => $rawDestination, - 'bytes' => $bytes, - 'bytes_human' => $this->formatBytes($bytes), - 'packets' => $packets, - 'state' => $stateName, - 'interface' => $interface, - 'direction' => $direction, - ]; - } - - usort($connections, fn($a, $b) => $b['bytes'] <=> $a['bytes']); - return $connections; - } - - protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation, bool $forceRefresh = false): void - { - $allowedRanges = ['hour', 'day', 'week', 'month', 'year']; - $allowedAggs = ['average', 'maximum']; - if (!in_array($timeRange, $allowedRanges, true) || !in_array($aggregation, $allowedAggs, true)) { - return; - } - - $cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation; - - try { - if ($forceRefresh) { - Cache::forget($cacheKey); - } - // Reduced cache TTL from 60 to 30 seconds for fresher data and faster updates - $data = Cache::remember($cacheKey, 30, function () use ($timeRange, $aggregation) { - $proxmox = new Proxmox; - $stats = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/rrddata', data: [ - 'timeframe' => $timeRange, - 'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE', - ]); - - $rawData = $stats->json()['data'] ?? []; - - // Limit data points to prevent memory issues and 413 errors - // Always limit to 40 points regardless of time range - $maxPoints = 40; - - // Sample data if it exceeds max points - if (count($rawData) > $maxPoints) { - $step = ceil(count($rawData) / $maxPoints); - $sampledData = []; - for ($i = 0; $i < count($rawData); $i += $step) { - $sampledData[] = $rawData[$i]; - } - // Always include the last point - if (end($rawData) !== end($sampledData)) { - $sampledData[] = end($rawData); - } - return $sampledData; - } - - return $rawData; - }); - } catch (\Throwable $e) { - report($e); - $data = []; - } - - $this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data); - } - - #[On('render-chart-events')] - public function renderChartEvents() - { - $this->dispatch('proxmox:chart-events', $this->chartEvents); - } - - public function updatedCpuTimeRange(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); - $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); - } - - public function updatedCpuAggregation(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); - $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); - } - - public function updatedMemoryTimeRange(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); - $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); - } - - public function updatedMemoryAggregation(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); - $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); - } - - public function updatedNetworkTimeRange(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); - $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); - } - - public function updatedNetworkAggregation(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); - $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); - } - - public function updatedDiskTimeRange(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); - $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); - } - - public function updatedDiskAggregation(): void - { - Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); - $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); - } - - public function showExportInfo(string $message): void - { - $this->exportInfoMessage = $message; - $this->showExportInfoModal = true; - } - - public function closeExportInfoModal(): void - { - $this->showExportInfoModal = false; - } - - public function openDeleteModal($statisticId) - { - $this->statisticToDeleteId = $statisticId; - $this->showDeleteModal = true; - } - - public function closeDeleteModal() - { - $this->showDeleteModal = false; - $this->statisticToDeleteId = null; - } - - public function selectStatistic($statisticId) - { - $this->selectedStatisticId = $statisticId; - $this->dispatch('statistic-selected'); - } - - public function createLiveStatistic() - { - try { - // Check if user has reached the maximum of 5 manual statistics - $manualStatsCount = ServerStatistics::where('server_id', $this->server->id) - ->where('is_automatic', false) - ->count(); - - if ($manualStatsCount >= 5) { - $this->notify('Maximum limit of 5 manual statistics reached. Please delete an existing manual statistic before creating a new one.', 'error'); - return; - } - - $proxmox = new Proxmox; - $status = $proxmox->request( - '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/status/current' - )->json()['data'] ?? null; - - if ($status) { - // Get disk I/O from rrddata (latest point) - $diskRead = 0; - $diskWrite = 0; - $diskUsed = $status['disk'] ?? 0; - $diskMax = $status['maxdisk'] ?? 0; - - try { - $rrdData = $proxmox->request( - '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/rrddata', - 'get', - ['timeframe' => 'hour', 'cf' => 'AVERAGE'] - )->json()['data'] ?? null; - - if ($rrdData && is_array($rrdData) && count($rrdData) > 0) { - $latest = end($rrdData); - $diskRead = (int)($latest['diskread'] ?? 0); - $diskWrite = (int)($latest['diskwrite'] ?? 0); - } - } catch (\Throwable $e) { - // If rrddata fails, continue with 0 values - } - - // Always try to get diskmax from VM config (more reliable than status endpoint) - // The status endpoint may not return maxdisk, especially if guest agent is not enabled - try { - $config = $proxmox->request( - '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config' - )->json()['data'] ?? null; - - if ($config) { - // Try to find disk configuration (scsi0, virtio0, sata0, ide0, scsi1, etc.) - $diskKeys = ['scsi0', 'scsi1', 'scsi2', 'virtio0', 'virtio1', 'virtio2', 'sata0', 'sata1', 'sata2', 'ide0', 'ide1', 'ide2']; - $totalDiskSize = 0; - - foreach ($diskKeys as $diskKey) { - if (isset($config[$diskKey])) { - // Parse disk size from format like "local-lvm:vm-100-disk-0,size=32G" - $diskConfig = $config[$diskKey]; - if (preg_match('/size=(\d+)([TGMK]?)/i', $diskConfig, $matches)) { - $size = (int)$matches[1]; - $unit = strtoupper($matches[2] ?? 'G'); - - // Convert to bytes - switch ($unit) { - case 'T': - $totalDiskSize += $size * 1024 * 1024 * 1024 * 1024; - break; - case 'G': - $totalDiskSize += $size * 1024 * 1024 * 1024; - break; - case 'M': - $totalDiskSize += $size * 1024 * 1024; - break; - case 'K': - $totalDiskSize += $size * 1024; - break; - default: - $totalDiskSize += $size * 1024 * 1024 * 1024; // Default to GB - } - } - } - } - - // Use config disk size if we found it, otherwise keep status value - if ($totalDiskSize > 0) { - $diskMax = $totalDiskSize; - } - } - } catch (\Throwable $e) { - // If config fetch fails, continue with status value - } - - $statistic = ServerStatistics::create([ - 'server_id' => $this->server->id, - 'netin' => $status['netin'] ?? 0, - 'netout' => $status['netout'] ?? 0, - 'diskread' => $diskRead, - 'diskwrite' => $diskWrite, - 'diskused' => $diskUsed, - 'diskmax' => $diskMax, - 'memoryused' => $status['mem'] ?? 0, - 'memorymax' => $status['maxmem'] ?? 0, - 'cpuused' => $status['cpu'] ?? 0, - 'cpumax' => 1.0, // CPU max is typically 1.0 (100%) - 'uptime' => $status['uptime'] ?? 0, - 'vmstatus' => $status['status'] ?? null, - 'DateTime' => now(), - 'is_automatic' => false, // Manual statistics are not automatic - ]); - - // Auto-select the newly created statistic - $this->selectedStatisticId = $statistic->id; - $this->dispatch('statistic-selected'); - - $this->notify('Statistics created successfully', 'success'); - } else { - $this->notify('Failed to retrieve server status from Proxmox', 'error'); - } - } catch (\Throwable $e) { - $this->notify('Error creating statistics: ' . $e->getMessage(), 'error'); - } - } - - public function getSelectedStatisticProperty() - { - if (!$this->selectedStatisticId) { - return null; - } - - return ServerStatistics::where('id', $this->selectedStatisticId) - ->where('server_id', $this->server->id) - ->first(); - } - - public function getStatisticsProperty() - { - return ServerStatistics::where('server_id', $this->server->id) - ->orderBy('DateTime', 'desc') - ->paginate($this->statisticsPerPage); - } - - public function deleteStatistic($statisticId) - { - if (!$statisticId) { - return; - } - - try { - $statistic = ServerStatistics::where('id', $statisticId) - ->where('server_id', $this->server->id) - ->first(); - - if (!$statistic) { - $this->notify('Statistics entry not found', 'error'); - return; - } - - // Clear selection if this is the selected statistic - if ($this->selectedStatisticId == $statisticId) { - $this->selectedStatisticId = null; - } - - $statistic->delete(); - $this->notify('Statistics entry deleted successfully', 'success'); - } catch (\Throwable $e) { - $this->notify('Error deleting statistics: ' . $e->getMessage(), 'error'); - } - } - - public function confirmDeleteStatistic() - { - if (!$this->statisticToDeleteId) { - return; - } - - try { - $statistic = ServerStatistics::where('id', $this->statisticToDeleteId) - ->where('server_id', $this->server->id) - ->first(); - - if (!$statistic) { - $this->notify('Statistics entry not found', 'error'); - $this->closeDeleteModal(); - return; - } - - // Clear selection if this is the selected statistic - if ($this->selectedStatisticId == $this->statisticToDeleteId) { - $this->selectedStatisticId = null; - } - - $statistic->delete(); - $this->notify('Statistics entry deleted successfully', 'success'); - $this->closeDeleteModal(); - } catch (\Throwable $e) { - $this->notify('Error deleting statistics: ' . $e->getMessage(), 'error'); - $this->closeDeleteModal(); - } - } - - public function openClearStatisticsModal(): void - { - $this->showClearStatisticsModal = true; - } - - public function closeClearStatisticsModal(): void - { - $this->showClearStatisticsModal = false; - } - - public function clearStatisticsHistory() - { - try { - ServerStatistics::where('server_id', $this->server->id)->delete(); - $this->selectedStatisticId = null; - $this->statisticToDeleteId = null; - $this->showDeleteModal = false; - $this->showClearStatisticsModal = false; - $this->notify('Statistics history cleared successfully', 'success'); - } catch (\Throwable $e) { - $this->notify('Error clearing statistics history: ' . $e->getMessage(), 'error'); - } - } - - public function openClearTaskHistoryModal(): void - { - $this->showClearTaskHistoryModal = true; - } - - public function closeClearTaskHistoryModal(): void - { - $this->showClearTaskHistoryModal = false; - } - - public function clearTaskHistory() - { - try { - // Clear any cached task history - $cacheKey = 'proxmox_task_history_' . $this->server->id; - Cache::forget($cacheKey); - - // Clear expanded task upids - $this->expandedTaskUpids = []; - - $this->showClearTaskHistoryModal = false; - $this->notify('Task history cache cleared successfully', 'success'); - } catch (\Throwable $e) { - $this->notify('Error clearing task history: ' . $e->getMessage(), 'error'); - } - } - - public function getStatisticToDeleteProperty() - { - if (!$this->statisticToDeleteId) { - return null; - } - - return ServerStatistics::where('id', $this->statisticToDeleteId) - ->where('server_id', $this->server->id) - ->first(); - } - - public function getCurrentNetworkStats($statistic) - { - // Get the previous statistic to calculate the difference - $previous = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<', $statistic->DateTime) - ->orderBy('DateTime', 'desc') - ->first(); - - if (!$previous) { - // No previous statistic, return current values as 0 - return [ - 'netin' => 0, - 'netout' => 0, - 'netin_rate' => 0, - 'netout_rate' => 0, - ]; - } - - // Calculate the difference - $netinDiff = max(0, $statistic->netin - $previous->netin); - $netoutDiff = max(0, $statistic->netout - $previous->netout); - - // Calculate time difference in seconds - $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); - if ($timeDiff == 0) { - $timeDiff = 1; // Avoid division by zero - } - - // Calculate rate (bytes per second) - $netinRate = $netinDiff / $timeDiff; - $netoutRate = $netoutDiff / $timeDiff; - - return [ - 'netin' => $netinDiff, - 'netout' => $netoutDiff, - 'netin_rate' => $netinRate, - 'netout_rate' => $netoutRate, - ]; - } - - public function getCurrentDiskStats($statistic) - { - // Get the previous statistic to calculate the difference - $previous = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<', $statistic->DateTime) - ->orderBy('DateTime', 'desc') - ->first(); - - if (!$previous) { - // No previous statistic, return current values as 0 - return [ - 'diskread' => 0, - 'diskwrite' => 0, - 'diskread_rate' => 0, - 'diskwrite_rate' => 0, - ]; - } - - // Calculate the difference (handle null values) - $currentDiskread = $statistic->diskread ?? 0; - $previousDiskread = $previous->diskread ?? 0; - $currentDiskwrite = $statistic->diskwrite ?? 0; - $previousDiskwrite = $previous->diskwrite ?? 0; - - $diskreadDiff = max(0, $currentDiskread - $previousDiskread); - $diskwriteDiff = max(0, $currentDiskwrite - $previousDiskwrite); - - // Calculate time difference in seconds - $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); - if ($timeDiff == 0) { - $timeDiff = 1; // Avoid division by zero - } - - // Calculate rate (bytes per second) - $diskreadRate = $diskreadDiff / $timeDiff; - $diskwriteRate = $diskwriteDiff / $timeDiff; - - return [ - 'diskread' => $diskreadDiff, - 'diskwrite' => $diskwriteDiff, - 'diskread_rate' => $diskreadRate, - 'diskwrite_rate' => $diskwriteRate, - ]; - } - - public function formatUptime($seconds) - { - if ($seconds < 60) { - return $seconds . 's'; - } elseif ($seconds < 3600) { - return floor($seconds / 60) . 'm ' . ($seconds % 60) . 's'; - } elseif ($seconds < 86400) { - $hours = floor($seconds / 3600); - $minutes = floor(($seconds % 3600) / 60); - return $hours . 'h ' . $minutes . 'm'; - } else { - $days = floor($seconds / 86400); - $hours = floor(($seconds % 86400) / 3600); - return $days . 'd ' . $hours . 'h'; - } - } - - public function getLatestStatProperty() - { - return ServerStatistics::where('server_id', $this->server->id) - ->orderBy('DateTime', 'desc') - ->first(); - } - - public function getTaskHistoryProperty() - { - $cacheKey = 'proxmox_task_history_' . $this->server->id; - - try { - return Cache::remember($cacheKey, 30, function () { - return $this->fetchTaskHistoryFromApi(); - }); - } catch (\Throwable $e) { - \Log::error('Error fetching task history for server ' . $this->server->id . ': ' . $e->getMessage()); - return []; - } - } - - /** - * Fetch task history directly from the Proxmox API. - * Separated from the computed property so Cache::remember can wrap it. - */ - protected function fetchTaskHistoryFromApi(): array - { - $proxmox = new Proxmox; - $vmId = (string)$this->server->vm_id; - $allTasks = []; - - // Try cluster/tasks endpoint first (includes all tasks across nodes) - // Note: cluster/tasks doesn't support vmid filter, so we'll filter manually - try { - $clusterResponse = $proxmox->request('/cluster/tasks', 'get', []); - $clusterTasks = $clusterResponse->json()['data'] ?? []; - if (is_array($clusterTasks)) { - $allTasks = array_merge($allTasks, $clusterTasks); - } - } catch (\Throwable $e) { - // Cluster endpoint might not be available, continue with node endpoint - \Log::debug('Cluster tasks endpoint failed: ' . $e->getMessage()); - } - - // Also try node-specific tasks endpoint with vmid filter - try { - $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', [ - 'vmid' => (int)$vmId, // Filter by VM ID - ]); - $nodeTasks = $nodeResponse->json()['data'] ?? []; - if (is_array($nodeTasks)) { - $allTasks = array_merge($allTasks, $nodeTasks); - } - } catch (\Throwable $e) { - // Node endpoint might fail, try without vmid filter as fallback - try { - $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', []); - $nodeTasks = $nodeResponse->json()['data'] ?? []; - if (is_array($nodeTasks)) { - $allTasks = array_merge($allTasks, $nodeTasks); - } - } catch (\Throwable $e2) { - \Log::debug('Node tasks endpoint failed: ' . $e2->getMessage()); - } - } - - // Remove duplicates based on UPID - $uniqueTasks = []; - $seenUpids = []; - foreach ($allTasks as $task) { - $upid = $task['upid'] ?? $task['id'] ?? null; - if ($upid && !in_array($upid, $seenUpids)) { - $seenUpids[] = $upid; - $uniqueTasks[] = $task; - } elseif (!$upid) { - // If no UPID, include it anyway - $uniqueTasks[] = $task; - } - } - $allTasks = $uniqueTasks; - - // If no tasks, return empty - if (empty($allTasks)) { - return []; - } - - // Filter tasks for this VM - // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid - // The VMID is the last component after the last colon - $tasks = []; - - foreach ($allTasks as $task) { - $isVmTask = false; - - // Check if task has vmid field directly (as integer or string) - if (isset($task['vmid'])) { - $taskVmid = (string)$task['vmid']; - if ($taskVmid === $vmId) { - $isVmTask = true; - } - } - - // Check UPID format - VMID is the last component - // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid - if (!$isVmTask && isset($task['upid']) && is_string($task['upid'])) { - $upid = $task['upid']; - // Extract VMID from UPID - it's the last part after the last colon - $lastColonPos = strrpos($upid, ':'); - if ($lastColonPos !== false) { - $taskVmid = substr($upid, $lastColonPos + 1); - if ($taskVmid === $vmId) { - $isVmTask = true; - } - } - - // Also try parsing by splitting (in case format is different) - if (!$isVmTask) { - $parts = explode(':', $upid); - if (count($parts) >= 8) { - // Last part (index count-1) is the vmid - $taskVmid = end($parts); - if ($taskVmid === $vmId) { - $isVmTask = true; - } - } - } - } - - // Check if task id contains the vmid - if (!$isVmTask && isset($task['id'])) { - $taskId = (string)$task['id']; - // Check if ID ends with the VMID or contains it - if (str_ends_with($taskId, ':' . $vmId) || str_contains($taskId, ':' . $vmId . ':') || $taskId === $vmId) { - $isVmTask = true; - } - } - - // Check if node matches and type is qemu (VM-related task) - if (!$isVmTask && isset($task['node']) && $task['node'] === $this->server->node->name) { - $taskType = strtolower($task['type'] ?? ''); - // QEMU tasks are VM-related - if ($taskType === 'qemu' || $taskType === 'qm') { - // For qemu tasks, check UPID for vmid - if (isset($task['upid']) && is_string($task['upid'])) { - $lastColonPos = strrpos($task['upid'], ':'); - if ($lastColonPos !== false) { - $taskVmid = substr($task['upid'], $lastColonPos + 1); - if ($taskVmid === $vmId) { - $isVmTask = true; - } - } - } - } - } - - if ($isVmTask) { - $tasks[] = $task; - } - } - - // Sort by starttime descending (newest first) - usort($tasks, function($a, $b) { - $timeA = $a['starttime'] ?? 0; - $timeB = $b['starttime'] ?? 0; - return $timeB <=> $timeA; - }); - - // Log for debugging if no tasks found - if (empty($tasks)) { - if (!empty($allTasks)) { - \Log::warning('Task history: Found ' . count($allTasks) . ' total tasks, but none matched VM ID ' . $vmId . ' for server ' . $this->server->id, [ - 'vm_id' => $vmId, - 'node' => $this->server->node->name ?? 'N/A', - 'sample_tasks' => array_map(function($task) { - return [ - 'type' => $task['type'] ?? 'N/A', - 'vmid' => $task['vmid'] ?? 'N/A', - 'upid' => $task['upid'] ?? 'N/A', - 'node' => $task['node'] ?? 'N/A', - ]; - }, array_slice($allTasks, 0, 5)), - ]); - } else { - \Log::info('Task history: No tasks found from API for server ' . $this->server->id . ' (VM ID: ' . $vmId . ')'); - } - } else { - \Log::debug('Task history: Found ' . count($tasks) . ' tasks for VM ID ' . $vmId); - } - - // Limit to last 100 tasks - return array_slice($tasks, 0, 100); - } - - public function getTaskDetails($taskId) - { - try { - $proxmox = new Proxmox; - // Use the /log endpoint to get task log details - $logResponse = $proxmox->request( - '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/log' - ); - - $logData = $logResponse->json()['data'] ?? null; - - // Also get status for additional information - $statusData = null; - try { - $statusResponse = $proxmox->request( - '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/status' - ); - $statusData = $statusResponse->json()['data'] ?? null; - } catch (\Throwable $e) { - // Status endpoint might fail, continue with log data only - } - - // Combine log and status data - $taskDetails = []; - if ($logData) { - $taskDetails['log'] = $logData; - } - if ($statusData) { - $taskDetails = array_merge($taskDetails, $statusData); - } - - if (!empty($taskDetails)) { - // Sanitize the task details - $taskDetails = $this->sanitizeTaskData($taskDetails); - } - - return $taskDetails; - } catch (\Throwable $e) { - \Log::error('Error fetching task details: ' . $e->getMessage()); - return null; - } - } - - public function sanitizeTaskData($data) - { - if (is_array($data)) { - $sanitized = []; - foreach ($data as $key => $value) { - $keyLower = strtolower($key); - // Format log output to be human readable - if ($keyLower === 'log' && is_array($value)) { - $sanitized[$key] = $this->formatTaskLog($value); - continue; - } - // Sanitize username fields - if (in_array($keyLower, ['user', 'username'])) { - $sanitized[$key] = '[REDACTED]'; - } - // Sanitize VMID if it matches this server's VMID - elseif ($keyLower === 'vmid' && $value == $this->server->vm_id) { - $sanitized[$key] = '[REDACTED]'; - } - // Recursively sanitize nested arrays - elseif (is_array($value)) { - $sanitized[$key] = $this->sanitizeTaskData($value); - } - // Sanitize VMID in string paths - elseif (is_string($value) && preg_match('/\/qemu\/(\d+)/', $value)) { - $sanitized[$key] = preg_replace('/\/qemu\/\d+/', '/qemu/[REDACTED]', $value); - } - // Sanitize VMID in UPID format (UPID:node:type:pid:user:starttime:extratype:vmid) - elseif (is_string($value) && preg_match('/^UPID:([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):(\d+)$/', $value, $matches)) { - $sanitized[$key] = preg_replace('/:(\d+)$/', ':[REDACTED]', $value); - } - else { - $sanitized[$key] = $value; - } - } - return $sanitized; - } - return $data; - } - - protected function formatTaskLog(array $entries): string - { - $lines = []; - foreach ($entries as $entry) { - if (is_array($entry)) { - $text = isset($entry['t']) ? trim((string) $entry['t']) : trim(json_encode($entry)); - if ($text === '') { - $text = '[no output]'; - } - $lines[] = $text; - } else { - $lines[] = trim((string) $entry); - } - } - - return implode(PHP_EOL, $lines); - } - - public function getChartDataProperty() - { - // Get last 50 statistics for chart display - $stats = ServerStatistics::where('server_id', $this->server->id) - ->orderBy('DateTime', 'asc') - ->limit(50) - ->get(); - - $data = [ - 'dates' => [], - 'cpu' => [], - 'memory' => [], - 'disk' => [], - 'networkIn' => [], - 'networkOut' => [], - ]; - - $previousStat = null; - foreach ($stats as $stat) { - $data['dates'][] = $stat->DateTime->format('M d H:i'); - $data['cpu'][] = round($stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0, 2); - $data['memory'][] = round($stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0, 2); - $data['disk'][] = round(($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0, 2); - - // Calculate network rates - if ($previousStat) { - $netinDiff = max(0, $stat->netin - $previousStat->netin); - $netoutDiff = max(0, $stat->netout - $previousStat->netout); - $timeDiff = $stat->DateTime->diffInSeconds($previousStat->DateTime); - if ($timeDiff == 0) $timeDiff = 1; - $data['networkIn'][] = round($netinDiff / $timeDiff, 2); - $data['networkOut'][] = round($netoutDiff / $timeDiff, 2); - } else { - $data['networkIn'][] = 0; - $data['networkOut'][] = 0; - } - - $previousStat = $stat; - } - - return $data; - } - - public function expandTask($upid) - { - if (!in_array($upid, $this->expandedTaskUpids)) { - $this->expandedTaskUpids[] = $upid; - } - } - - public function collapseTask($upid) - { - $this->expandedTaskUpids = array_values(array_filter($this->expandedTaskUpids, function($id) use ($upid) { - return $id !== $upid; - })); - } - - public function getHistoricalComparisonProperty() - { - try { - // Get current period statistics with its previous record (for rate calculation) - $currentStats = $this->getStatisticsForPeriodWithPrevious($this->currentPeriod, $this->currentCustomDate); - - // Get historical period statistics with its previous record - $historicalStats = $this->getStatisticsForPeriodWithPrevious($this->historicalPeriod, $this->historicalCustomDate); - - // Check if we have both current and historical stats - if (!$currentStats || !$currentStats['stat']) { - return [ - 'error' => 'current', - 'message' => 'No statistics found for the selected current period (' . $this->currentPeriod . ').', - 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), - ]; - } - - if (!$historicalStats || !$historicalStats['stat']) { - return [ - 'error' => 'historical', - 'message' => 'No statistics found for the selected historical period (' . $this->historicalPeriod . '). Try selecting a different time period.', - 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), - ]; - } - - $current = $currentStats['stat']; - $historical = $historicalStats['stat']; - $currentPrevious = $currentStats['previous']; - $historicalPrevious = $historicalStats['previous']; - - // Calculate differences and percentages - $comparison = [ - 'current' => $current, - 'historical' => $historical, - 'differences' => [ - 'cpu' => $this->calculateDifference($current->cpuused, $current->cpumax, $historical->cpuused, $historical->cpumax), - 'memory' => $this->calculateDifference($current->memoryused, $current->memorymax, $historical->memoryused, $historical->memorymax), - 'disk' => $this->calculateDifference($current->diskused ?? 0, $current->diskmax ?? 0, $historical->diskused ?? 0, $historical->diskmax ?? 0), - 'network_in' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netin'), - 'network_out' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netout'), - 'disk_read' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskread'), - 'disk_write' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskwrite'), - ], - ]; - - return $comparison; - } catch (\Throwable $e) { - \Log::error('Error getting historical comparison: ' . $e->getMessage()); - return [ - 'error' => 'exception', - 'message' => 'An error occurred while loading comparison data: ' . $e->getMessage(), - ]; - } - } - - /** - * Get statistics for a period along with the previous record (for rate calculation) - * This reduces queries from 10+ to just 4 total - */ - protected function getStatisticsForPeriodWithPrevious(string $period, ?string $customDate = null): ?array - { - $stat = $this->getStatisticsForPeriod($period, $customDate); - - if (!$stat) { - return null; - } - - // Get the previous record for rate calculation - $previous = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<', $stat->DateTime) - ->orderBy('DateTime', 'desc') - ->first(); - - return [ - 'stat' => $stat, - 'previous' => $previous, - ]; - } - - protected function getStatisticsForPeriod(string $period, ?string $customDate = null) - { - $now = now(); - $targetTime = null; - - switch ($period) { - case 'now': - // Get the most recent statistic - return ServerStatistics::where('server_id', $this->server->id) - ->orderBy('DateTime', 'desc') - ->first(); - - case '1_hour_ago': - $targetTime = $now->copy()->subHour(); - break; - - case '2_hours_ago': - $targetTime = $now->copy()->subHours(2); - break; - - case '6_hours_ago': - $targetTime = $now->copy()->subHours(6); - break; - - case '12_hours_ago': - $targetTime = $now->copy()->subHours(12); - break; - - case '24_hours_ago': - $targetTime = $now->copy()->subDay(); - break; - - case 'custom': - if ($customDate) { - try { - $targetTime = \Carbon\Carbon::parse($customDate); - } catch (\Exception $e) { - return null; - } - } else { - return null; - } - break; - - default: - return null; - } - - if ($targetTime) { - // Find the statistic closest to the target time (within 90 minutes) - // Using 90 minutes to ensure we catch hourly statistics even with timing variations - $minTime = $targetTime->copy()->subMinutes(90); - $maxTime = $targetTime->copy()->addMinutes(90); - - $stats = ServerStatistics::where('server_id', $this->server->id) - ->whereBetween('DateTime', [$minTime, $maxTime]) - ->orderBy('DateTime', 'asc') - ->get(); - - if ($stats->isEmpty()) { - // Fallback: get the closest stat before or after target time - $closestBefore = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<=', $targetTime) - ->orderBy('DateTime', 'desc') - ->first(); - - $closestAfter = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '>=', $targetTime) - ->orderBy('DateTime', 'asc') - ->first(); - - if (!$closestBefore && !$closestAfter) { - return null; - } - - if (!$closestBefore) return $closestAfter; - if (!$closestAfter) return $closestBefore; - - // Return whichever is closer - $diffBefore = abs($closestBefore->DateTime->diffInSeconds($targetTime)); - $diffAfter = abs($closestAfter->DateTime->diffInSeconds($targetTime)); - - return $diffBefore <= $diffAfter ? $closestBefore : $closestAfter; - } - - // Find the closest one to target time (database-agnostic) - $closest = null; - $smallestDiff = PHP_INT_MAX; - - foreach ($stats as $stat) { - $diff = abs($stat->DateTime->diffInSeconds($targetTime)); - if ($diff < $smallestDiff) { - $smallestDiff = $diff; - $closest = $stat; - } - } - - return $closest; - } - - return null; - } - - protected function calculateDifference($currentValue, $currentMax, $historicalValue, $historicalMax) - { - $currentPercent = $currentMax > 0 ? ($currentValue / $currentMax * 100) : 0; - $historicalPercent = $historicalMax > 0 ? ($historicalValue / $historicalMax * 100) : 0; - - $absoluteDiff = $currentValue - $historicalValue; - $percentDiff = $currentPercent - $historicalPercent; - $percentChange = $historicalPercent > 0 ? (($currentPercent - $historicalPercent) / $historicalPercent * 100) : 0; - - return [ - 'current_value' => $currentValue, - 'current_percent' => round($currentPercent, 2), - 'historical_value' => $historicalValue, - 'historical_percent' => round($historicalPercent, 2), - 'absolute_diff' => $absoluteDiff, - 'percent_diff' => round($percentDiff, 2), - 'percent_change' => round($percentChange, 2), - ]; - } - - /** - * Calculate rate difference using pre-fetched previous stats (optimized) - * This eliminates N+1 queries - previous stats are fetched once per period - */ - protected function calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field) - { - $currentRate = 0; - $historicalRate = 0; - - if ($currentPrevious && $currentStat) { - $timeDiff = $currentStat->DateTime->diffInSeconds($currentPrevious->DateTime); - if ($timeDiff > 0) { - $currentDiff = max(0, ($currentStat->$field ?? 0) - ($currentPrevious->$field ?? 0)); - $currentRate = $currentDiff / $timeDiff; - } - } - - if ($historicalPrevious && $historicalStat) { - $timeDiff = $historicalStat->DateTime->diffInSeconds($historicalPrevious->DateTime); - if ($timeDiff > 0) { - $historicalDiff = max(0, ($historicalStat->$field ?? 0) - ($historicalPrevious->$field ?? 0)); - $historicalRate = $historicalDiff / $timeDiff; - } - } - - $absoluteDiff = $currentRate - $historicalRate; - $percentChange = $historicalRate > 0 ? (($currentRate - $historicalRate) / $historicalRate * 100) : ($currentRate > 0 ? 100 : 0); - - return [ - 'current_rate' => $currentRate, - 'historical_rate' => $historicalRate, - 'absolute_diff' => $absoluteDiff, - 'percent_change' => round($percentChange, 2), - ]; - } - - /** - * @deprecated Use calculateRateDifferenceOptimized instead - */ - protected function calculateRateDifference($currentStat, $historicalStat, $field) - { - // Get previous stats to calculate rates - $currentPrevious = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<', $currentStat->DateTime) - ->orderBy('DateTime', 'desc') - ->first(); - - $historicalPrevious = ServerStatistics::where('server_id', $this->server->id) - ->where('DateTime', '<', $historicalStat->DateTime) - ->orderBy('DateTime', 'desc') - ->first(); - - return $this->calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field); - } - - public function getHistoricalComparisonExportDataProperty() - { - $comparison = $this->historicalComparison; - if (!$comparison || isset($comparison['error'])) { - return []; - } - - $current = $comparison['current'] ?? null; - $historical = $comparison['historical'] ?? null; - $diff = $comparison['differences'] ?? null; - - if (!$current || !$historical || !$diff) { - return []; - } - - $historicalDateTime = $historical->DateTime->format('Y-m-d H:i:s'); - $currentDateTime = $current->DateTime->format('Y-m-d H:i:s'); - - return [ - [ - 'metric' => 'CPU Usage', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => round($diff['cpu']['current_percent'], 2) . '%', - 'historical_value' => round($diff['cpu']['historical_percent'], 2) . '%', - 'difference' => round($diff['cpu']['percent_diff'], 2) . '%', - 'percent_change' => round($diff['cpu']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Memory Usage', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => round($diff['memory']['current_percent'], 2) . '%', - 'historical_value' => round($diff['memory']['historical_percent'], 2) . '%', - 'difference' => round($diff['memory']['percent_diff'], 2) . '%', - 'percent_change' => round($diff['memory']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Disk Usage', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => round($diff['disk']['current_percent'], 2) . '%', - 'historical_value' => round($diff['disk']['historical_percent'], 2) . '%', - 'difference' => round($diff['disk']['percent_diff'], 2) . '%', - 'percent_change' => round($diff['disk']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Network In Rate', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => $this->formatBytes($diff['network_in']['current_rate']) . '/s', - 'historical_value' => $this->formatBytes($diff['network_in']['historical_rate']) . '/s', - 'difference' => $this->formatBytes($diff['network_in']['absolute_diff']) . '/s', - 'percent_change' => round($diff['network_in']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Network Out Rate', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => $this->formatBytes($diff['network_out']['current_rate']) . '/s', - 'historical_value' => $this->formatBytes($diff['network_out']['historical_rate']) . '/s', - 'difference' => $this->formatBytes($diff['network_out']['absolute_diff']) . '/s', - 'percent_change' => round($diff['network_out']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Disk Read Rate', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => $this->formatBytes($diff['disk_read']['current_rate']) . '/s', - 'historical_value' => $this->formatBytes($diff['disk_read']['historical_rate']) . '/s', - 'difference' => $this->formatBytes($diff['disk_read']['absolute_diff']) . '/s', - 'percent_change' => round($diff['disk_read']['percent_change'], 2) . '%', - ], - [ - 'metric' => 'Disk Write Rate', - 'historical_datetime' => $historicalDateTime, - 'current_datetime' => $currentDateTime, - 'current_value' => $this->formatBytes($diff['disk_write']['current_rate']) . '/s', - 'historical_value' => $this->formatBytes($diff['disk_write']['historical_rate']) . '/s', - 'difference' => $this->formatBytes($diff['disk_write']['absolute_diff']) . '/s', - 'percent_change' => round($diff['disk_write']['percent_change'], 2) . '%', - ], - ]; - } - - public function exportHistoricalComparison() - { - $data = $this->historicalComparisonExportData; - if (empty($data)) { - $this->showExportInfo('No historical comparison data available to export. Please ensure comparison data is displayed before exporting.'); - return; - } - - $csv = 'Metric,Historical Date/Time,Current Date/Time,Historical Value,Current Value,Difference,Percent Change' . PHP_EOL; - - foreach ($data as $row) { - $csv .= sprintf( - '"%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, - str_replace('"', '""', $row['metric'] ?? ''), - str_replace('"', '""', $row['historical_datetime'] ?? ''), - str_replace('"', '""', $row['current_datetime'] ?? ''), - str_replace('"', '""', $row['historical_value'] ?? ''), - str_replace('"', '""', $row['current_value'] ?? ''), - str_replace('"', '""', $row['difference'] ?? ''), - str_replace('"', '""', $row['percent_change'] ?? '') - ); - } - - $filename = 'historical-comparison-' . date('Y-m-d') . '.csv'; - - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, $filename, [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', - ]); - } - - public function exportNetworkAnalysis() - { - $cacheKey = 'proxmox_network_analysis_' . $this->server->id; - $cachedAnalysis = Cache::get($cacheKey, []); - $connections = $cachedAnalysis['connections'] ?? []; - - if (empty($connections)) { - $this->showExportInfo('No network analysis data available to export. Please refresh the network analysis first.'); - return; - } - - $csv = 'Direction,Protocol,Source,Source Port,Destination,Destination Port,State,Bytes,Packets,Bytes (Human)' . PHP_EOL; - - foreach ($connections as $connection) { - $csv .= sprintf( - '"%s","%s","%s","%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, - str_replace('"', '""', $connection['direction'] ?? ''), - str_replace('"', '""', $connection['protocol'] ?? ''), - str_replace('"', '""', $connection['source'] ?? ''), - str_replace('"', '""', (string)($connection['source_port'] ?? '')), - str_replace('"', '""', $connection['destination'] ?? ''), - str_replace('"', '""', (string)($connection['destination_port'] ?? '')), - str_replace('"', '""', $connection['state'] ?? ''), - str_replace('"', '""', (string)($connection['bytes'] ?? 0)), - str_replace('"', '""', (string)($connection['packets'] ?? 0)), - str_replace('"', '""', $connection['bytes_human'] ?? '') - ); - } - - $filename = 'network-analysis-' . date('Y-m-d_His') . '.csv'; - - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, $filename, [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', - ]); - } - - public function formatBytes(int|float $bytes): string - { - if ($bytes == 0) return '0 B'; - $prefix = ''; - if ($bytes < 0) { - $prefix = '-'; - $bytes = abs($bytes); - } - $k = 1024; - $sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - $i = floor(log($bytes) / log($k)); - if ($i < 0) $i = 0; - if ($i >= count($sizes)) $i = count($sizes) - 1; - return $prefix . round($bytes / pow($k, $i), 2) . ' ' . $sizes[$i]; - } - - public function getGraphData(string $type): array - { - $fullData = $this->graphPayloads[$type] ?? []; - - // Filter data to only return essential fields for this chart type to reduce response size - $filteredData = []; - foreach ($fullData as $point) { - $filteredPoint = ['time' => $point['time'] ?? 0]; - - switch ($type) { - case 'cpu': - $filteredPoint['cpu'] = $point['cpu'] ?? 0; - break; - case 'memory': - $filteredPoint['mem'] = $point['mem'] ?? 0; - $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; - break; - case 'network': - $filteredPoint['netin'] = $point['netin'] ?? 0; - $filteredPoint['netout'] = $point['netout'] ?? 0; - break; - case 'disk': - $filteredPoint['diskread'] = $point['diskread'] ?? 0; - $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; - break; - } - - $filteredData[] = $filteredPoint; - } - - return $filteredData; - } - - protected function coreRouterEnabled(): bool - { - $baseUrl = config('corerouter.base_url'); - $apiKey = config('corerouter.api_key'); - $username = config('corerouter.username'); - $password = config('corerouter.password'); - - // Check if base_url is configured and not empty - $hasBaseUrl = !empty(trim((string) $baseUrl)); - - // Check if API key is configured (not empty after trimming) - $hasKey = !empty(trim((string) $apiKey)); - - // Check if basic auth credentials are both configured - $hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password)); - - // Configuration is valid if base_url is set and at least one auth method is configured - $configured = $hasBaseUrl && ($hasKey || $hasBasic); - - if (!$configured) { - Log::debug('Core Router configuration check', [ - 'base_url' => $baseUrl ?: '(empty)', - 'base_url_configured' => $hasBaseUrl, - 'api_key_present' => $hasKey, - 'username_present' => !empty(trim((string) $username)), - 'password_present' => !empty(trim((string) $password)), - 'basic_auth_configured' => $hasBasic, - 'configured' => $configured, - ]); - } - - return $configured; - } - - protected function getServerIps(): array - { - if (!empty($this->cachedServerIps)) { - return $this->cachedServerIps; - } - - $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); - - $rawValues = collect(); - - $primaryIpv4Value = $this->server->primaryIpv4?->ip ?? (is_string($this->server->primary_ipv4) ? $this->server->primary_ipv4 : null); - $primaryIpv6Value = $this->server->primaryIpv6?->ip ?? (is_string($this->server->primary_ipv6) ? $this->server->primary_ipv6 : null); - - if ($primaryIpv4Value) { - $rawValues->push($primaryIpv4Value); - } - - if ($primaryIpv6Value) { - $rawValues->push($primaryIpv6Value); - } - - $rawValues = $rawValues->merge($this->server->ipAddresses->pluck('ip')); - - $exactIps = []; - $cidrs = []; - - foreach ($rawValues->filter() as $value) { - foreach ($this->expandIpValue($value) as $parsed) { - if ($parsed['type'] === 'cidr') { - $cidrs[] = $parsed['value']; - } elseif ($parsed['type'] === 'ip') { - // Normalize IP to lowercase for consistent matching - $normalized = $this->normalizeSingleIp($parsed['value']); - if ($normalized) { - $exactIps[] = $normalized; - - // For IPv6 addresses, automatically add the /64 subnet to CIDR list - if (str_contains($normalized, ':')) { - // Extract the /64 network prefix for IPv6 - $ipBinary = @inet_pton($normalized); - if ($ipBinary !== false && strlen($ipBinary) === 16) { - // Zero out the last 64 bits (last 8 bytes) to get the /64 network - $networkBinary = substr($ipBinary, 0, 8) . str_repeat("\0", 8); - $networkIp = @inet_ntop($networkBinary); - if ($networkIp !== false) { - $cidrs[] = $networkIp . '/64'; - } - } - } - // For IPv4 addresses, CIDR ranges are only added if explicitly stored in database - // (e.g., "45.154.99.28/28" format). No auto-generation to avoid false matches. - } - } - } - } - - $this->serverIpCidrs = array_values(array_unique($cidrs)); - $this->cachedServerIps = array_values(array_unique($exactIps)); - - Log::debug('Core Router getServerIps - extracted IPs', [ - 'raw_values' => $rawValues->all(), - 'exact_ips' => $this->cachedServerIps, - 'cidrs' => $this->serverIpCidrs, - 'primary_ipv4' => $primaryIpv4Value, - 'primary_ipv6' => $primaryIpv6Value, - ]); - - return $this->cachedServerIps; - } - - - /** - * Check if a connection's source or destination matches server's assigned IPs - * Used for visual filtering in the blade template - */ - public function isConnectionRelevant(string $source, string $destination): bool - { - $serverIps = $this->getServerIps(); - if (empty($serverIps)) { - return false; - } - - // Normalize for comparison - $sourceNormalized = strtolower(trim($source)); - $destinationNormalized = strtolower(trim($destination)); - - // Check exact matches - foreach ($serverIps as $serverIp) { - $serverIpNormalized = strtolower(trim($serverIp)); - if ($sourceNormalized === $serverIpNormalized || $destinationNormalized === $serverIpNormalized) { - return true; - } - } - - return false; - } - - protected function formatConnections(array $states, array $ips): array - { - // Legacy method - redirect to simple version - return $this->formatConnectionsSimple($states); - } - - protected function aggregateTopConnections(array $connections): array - { - if (empty($connections)) { - return []; - } - - $grouped = []; - foreach ($connections as $connection) { - $key = $connection['direction'] === 'Outbound' - ? $connection['destination'] . ':' . ($connection['destination_port'] ?? '0') - : $connection['source'] . ':' . ($connection['source_port'] ?? '0'); - - if (!isset($grouped[$key])) { - $grouped[$key] = [ - 'endpoint' => $connection['direction'] === 'Outbound' - ? $connection['destination'] - : $connection['source'], - 'port' => $connection['direction'] === 'Outbound' - ? $connection['destination_port'] - : $connection['source_port'], - 'bytes' => 0, - 'packets' => 0, - 'count' => 0, - ]; - } - - $grouped[$key]['bytes'] += $connection['bytes']; - $grouped[$key]['packets'] += $connection['packets']; - $grouped[$key]['count']++; - } - - $results = array_map(function ($item) { - $item['bytes_human'] = $this->formatBytes($item['bytes']); - - return $item; - }, array_values($grouped)); - - usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); - - return $results; - } - - protected function aggregateTopPorts(array $connections): array - { - if (empty($connections)) { - return []; - } - - $grouped = []; - foreach ($connections as $connection) { - $port = $connection['destination_port'] ?? $connection['source_port'] ?? null; - if (!$port) { - continue; - } - - if (!isset($grouped[$port])) { - $grouped[$port] = [ - 'port' => $port, - 'bytes' => 0, - 'packets' => 0, - 'connections' => 0, - ]; - } - - $grouped[$port]['bytes'] += $connection['bytes']; - $grouped[$port]['packets'] += $connection['packets']; - $grouped[$port]['connections']++; - } - - $results = array_map(function ($item) { - $item['bytes_human'] = $this->formatBytes($item['bytes']); - - return $item; - }, array_values($grouped)); - - usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); - - return $results; - } - - /** - * Get gateways from server's network configuration (network tab) - */ - protected function getServerGateways(): array - { - $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); - - $gateways = []; - - // Get gateways from IP addresses - foreach ($this->server->ipAddresses as $ipAddress) { - if (!empty($ipAddress->gateway)) { - $gatewayIp = trim($ipAddress->gateway); - // Avoid duplicates - if (!isset($gateways[$gatewayIp])) { - $gateways[$gatewayIp] = [ - 'ip' => $gatewayIp, - 'name' => $gatewayIp, - 'type' => $ipAddress->type ?? 'ipv4', // ipv4 or ipv6 - 'associated_ip' => $ipAddress->ip, - ]; - } - } - } - - return array_values($gateways); - } - - /** - * Check connections against known malicious IP blocklists - */ - protected function checkSecurityThreats(array $connections): array - { - $warnings = []; - - if (empty($connections)) { - Log::debug('Security check: No connections to check'); - return []; - } - - // Fetch and parse blocklists - $ipv6Blocklist = $this->getIpv6Blocklist(); - $ipv4Blocklist = $this->getIpv4Blocklist(); - - Log::debug('Security check: Blocklists loaded', [ - 'ipv6_count' => count($ipv6Blocklist), - 'ipv4_count' => count($ipv4Blocklist), - 'connections_count' => count($connections), - ]); - - foreach ($connections as $connection) { - $source = $connection['source'] ?? ''; - $destination = $connection['destination'] ?? ''; - - // Check source IP - $sourceThreat = $this->checkIpAgainstBlocklist($source, $ipv4Blocklist, $ipv6Blocklist); - if ($sourceThreat) { - // For source threats (malicious IP attacking us), analyze the destination port (port being targeted) - // Always use destination_port as that's the service port being targeted, not the ephemeral source port - $threatPort = $connection['destination_port'] ?? null; - // If destination_port is null, try to extract from raw destination field - if ($threatPort === null && isset($connection['raw_destination'])) { - $threatPort = $this->extractPortFromField($connection['raw_destination']); - } - // NEVER use source_port for attack analysis - it's just an ephemeral port - // Log for debugging - Log::debug('Security threat detected: Source IP - Port Analysis', [ - 'ip' => $source, - 'threat' => $sourceThreat, - 'destination_port' => $connection['destination_port'] ?? 'null', - 'source_port' => $connection['source_port'] ?? 'null', - 'raw_destination' => $connection['raw_destination'] ?? 'null', - 'extracted_threat_port' => $threatPort, - ]); - $riskAnalysis = $this->analyzeThreatRisk($source, $threatPort, $connection); - $warnings[] = [ - 'ip' => $source, - 'type' => 'source', - 'threat' => $sourceThreat, - 'connection' => $connection, - 'risk_level' => $riskAnalysis['risk_level'], - 'attack_type' => $riskAnalysis['attack_type'], - 'risk_description' => $riskAnalysis['risk_description'], - 'mitigations' => $riskAnalysis['mitigations'] ?? [], - ]; - Log::debug('Security threat detected: Source IP', [ - 'ip' => $source, - 'threat' => $sourceThreat, - 'risk_level' => $riskAnalysis['risk_level'], - 'attack_type' => $riskAnalysis['attack_type'], - 'threat_port' => $threatPort, - ]); - } - - // Check destination IP - $destThreat = $this->checkIpAgainstBlocklist($destination, $ipv4Blocklist, $ipv6Blocklist); - if ($destThreat) { - // For destination threats (we're connecting to malicious IP), analyze the destination port (port on malicious server) - // Always use destination_port as that's the service port on the malicious server - $threatPort = $connection['destination_port'] ?? null; - // If destination_port is null, try to extract from raw destination field - if ($threatPort === null && isset($connection['raw_destination'])) { - $threatPort = $this->extractPortFromField($connection['raw_destination']); - } - // NEVER use source_port for attack analysis - it's just an ephemeral port - $riskAnalysis = $this->analyzeThreatRisk($destination, $threatPort, $connection); - $warnings[] = [ - 'ip' => $destination, - 'type' => 'destination', - 'threat' => $destThreat, - 'connection' => $connection, - 'risk_level' => $riskAnalysis['risk_level'], - 'attack_type' => $riskAnalysis['attack_type'], - 'risk_description' => $riskAnalysis['risk_description'], - 'mitigations' => $riskAnalysis['mitigations'] ?? [], - ]; - Log::debug('Security threat detected: Destination IP', [ - 'ip' => $destination, - 'threat' => $destThreat, - 'risk_level' => $riskAnalysis['risk_level'], - 'attack_type' => $riskAnalysis['attack_type'], - ]); - } - } - - Log::debug('Security check completed', [ - 'warnings_count' => count($warnings), - ]); - - // Group similar threats together - $groupedWarnings = $this->groupSimilarThreats($warnings); - - Log::debug('Security threats grouped', [ - 'original_count' => count($warnings), - 'grouped_count' => count($groupedWarnings), - ]); - - return $groupedWarnings; - } - - /** - * Group similar security threats by source, destination, ports, and protocol - */ - protected function groupSimilarThreats(array $warnings): array - { - if (empty($warnings)) { - return []; - } - - $grouped = []; - - foreach ($warnings as $warning) { - $connection = $warning['connection'] ?? []; - $source = $connection['source'] ?? ''; - $destination = $connection['destination'] ?? ''; - $sourcePort = $connection['source_port'] ?? null; - $destinationPort = $connection['destination_port'] ?? null; - $protocol = $connection['protocol'] ?? 'N/A'; - $threatType = $warning['type'] ?? 'source'; - $threatIp = $warning['ip'] ?? ''; - - // Create a unique key based on source IP, destination IP, destination port, protocol, and threat type - // Exclude source port from grouping since it's often ephemeral and varies between connections - $key = sprintf( - '%s|%s|%s|%s|%s|%s', - $threatType, - $threatIp, - $source, - $destination, - $destinationPort ?? 'null', - $protocol - ); - - if (!isset($grouped[$key])) { - // First occurrence - use this warning as the base - $grouped[$key] = $warning; - $grouped[$key]['quantity'] = 1; - $grouped[$key]['is_repeated'] = false; - } else { - // Duplicate found - increment quantity and mark as repeated - $grouped[$key]['quantity'] = ($grouped[$key]['quantity'] ?? 1) + 1; - $grouped[$key]['is_repeated'] = true; - - // Aggregate connection data (sum bytes and packets) - $existingConn = $grouped[$key]['connection'] ?? []; - $newConn = $connection; - - $grouped[$key]['connection'] = [ - 'source' => $existingConn['source'] ?? $source, - 'destination' => $existingConn['destination'] ?? $destination, - 'source_port' => $existingConn['source_port'] ?? $sourcePort, - 'destination_port' => $existingConn['destination_port'] ?? $destinationPort, - 'protocol' => $existingConn['protocol'] ?? $protocol, - 'bytes' => ($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0), - 'packets' => ($existingConn['packets'] ?? 0) + ($newConn['packets'] ?? 0), - 'bytes_human' => $this->formatBytes(($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0)), - 'state' => $existingConn['state'] ?? $newConn['state'] ?? 'N/A', - 'direction' => $existingConn['direction'] ?? $newConn['direction'] ?? 'N/A', - 'raw_source' => $existingConn['raw_source'] ?? $newConn['raw_source'] ?? '', - 'raw_destination' => $existingConn['raw_destination'] ?? $newConn['raw_destination'] ?? '', - ]; - } - } - - // Convert back to indexed array and sort by quantity (descending), then by risk level - $result = array_values($grouped); - usort($result, function ($a, $b) { - $quantityA = $a['quantity'] ?? 1; - $quantityB = $b['quantity'] ?? 1; - - // First sort by quantity (descending) - if ($quantityA !== $quantityB) { - return $quantityB <=> $quantityA; - } - - // Then by risk level severity - $severityA = $this->getRiskSeverity($a['risk_level'] ?? 'Medium'); - $severityB = $this->getRiskSeverity($b['risk_level'] ?? 'Medium'); - - return $severityB <=> $severityA; - }); - - return $result; - } - - /** - * Check if an IP matches any entry in the blocklists - */ - protected function checkIpAgainstBlocklist(string $ip, array $ipv4Blocklist, array $ipv6Blocklist): ?string - { - if (empty($ip)) { - return null; - } - - $normalizedIp = $this->normalizeSingleIp($ip); - if ($normalizedIp === null) { - Log::debug('Security check: Failed to normalize IP', [ - 'ip' => $ip, - ]); - return null; - } - - $isIPv6 = str_contains($normalizedIp, ':'); - - if ($isIPv6) { - // Check IPv6 blocklist (CIDR ranges) - if (empty($ipv6Blocklist)) { - Log::debug('Security check: IPv6 blocklist is empty', [ - 'ip' => $normalizedIp, - ]); - return null; - } - - foreach ($ipv6Blocklist as $cidr) { - if ($this->ipInCidr($normalizedIp, $cidr)) { - Log::debug('Security check: IPv6 match found', [ - 'ip' => $normalizedIp, - 'cidr' => $cidr, - ]); - return "IPv6 address matches Spamhaus DROPv6 blocklist: {$cidr}"; - } - } - } else { - // Check IPv4 blocklist (exact IPs) - if (empty($ipv4Blocklist)) { - Log::debug('Security check: IPv4 blocklist is empty', [ - 'ip' => $normalizedIp, - ]); - return null; - } - - if (in_array($normalizedIp, $ipv4Blocklist, true)) { - Log::debug('Security check: IPv4 match found', [ - 'ip' => $normalizedIp, - ]); - return "IPv4 address matches blocklist.de malicious IP list"; - } - } - - return null; - } - - /** - * Analyze threat risk level and attack type based on port and IP - */ - protected function analyzeThreatRisk(string $ip, ?int $port, array $connection): array - { - $riskLevel = 'Medium'; - $attackType = 'Unknown Threat'; - $riskDescription = 'Malicious IP detected from blocklist'; - - // Port-based analysis - if ($port !== null) { - $portAnalysis = $this->analyzePortRisk($port, $connection); - $riskLevel = $portAnalysis['risk_level']; - $attackType = $portAnalysis['attack_type']; - $riskDescription = $portAnalysis['description']; - } - - // IP-based risk adjustment - $ipRisk = $this->analyzeIpRisk($ip); - if ($ipRisk['severity'] > $this->getRiskSeverity($riskLevel)) { - $riskLevel = $ipRisk['risk_level']; - if ($attackType === 'Unknown Threat') { - $attackType = $ipRisk['attack_type']; - } - } - - // Direction-based risk adjustment - $direction = $connection['direction'] ?? ''; - if ($direction === 'Inbound' && $riskLevel !== 'Critical') { - // Inbound threats are generally more dangerous - if ($riskLevel === 'Low') { - $riskLevel = 'Medium'; - } elseif ($riskLevel === 'Medium') { - $riskLevel = 'High'; - } - } - - // Get mitigation suggestions based on attack type and port - $mitigations = $this->getMitigationSuggestions($attackType, $port, $connection); - - return [ - 'risk_level' => $riskLevel, - 'attack_type' => $attackType, - 'risk_description' => $riskDescription, - 'mitigations' => $mitigations, - ]; - } - - /** - * Get mitigation suggestions based on attack type and port - */ - protected function getMitigationSuggestions(string $attackType, ?int $port, array $connection): array - { - $mitigations = []; - $direction = $connection['direction'] ?? ''; - $isInbound = $direction === 'Inbound'; - - // Don't provide mitigations for outbound threats (we're connecting to malicious IPs) - if (!$isInbound) { - return [ - [ - 'priority' => 'High', - 'title' => 'Review Outbound Connections', - 'description' => 'This server is initiating connections to a malicious IP. Review the application or service making these connections and ensure it is legitimate and up-to-date.', - 'action' => 'Review application logs and system processes to identify why connections are being made to this malicious IP address.', - ] - ]; - } - - // SSH Brute Force (port 22) - if ($attackType === 'SSH Brute Force' || $port === 22) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Implement fail2ban', - 'description' => 'Automatically ban IPs that fail SSH authentication multiple times. This is highly effective against brute force attacks.', - 'action' => "Install: sudo apt install fail2ban (Ubuntu/Debian) or sudo yum install fail2ban (RHEL/CentOS). Configure /etc/fail2ban/jail.local to enable sshd protection.", - ], - [ - 'priority' => 'Critical', - 'title' => 'Disable password authentication, use SSH keys only', - 'description' => 'Eliminate the attack vector by removing password-based authentication entirely.', - 'action' => 'Edit /etc/ssh/sshd_config: Set PasswordAuthentication no, PubkeyAuthentication yes. Restart SSH: sudo systemctl restart sshd', - ], - [ - 'priority' => 'High', - 'title' => 'Change SSH port', - 'description' => 'Reduce automated attack attempts by moving SSH to a non-standard port.', - 'action' => 'Edit /etc/ssh/sshd_config: Change Port 22 to Port [your chosen port]. Update firewall rules. Restart SSH: sudo systemctl restart sshd', - ], - [ - 'priority' => 'High', - 'title' => 'Disable root login', - 'description' => 'Prevent direct root login attempts via SSH.', - 'action' => 'Edit /etc/ssh/sshd_config: Set PermitRootLogin no. Restart SSH: sudo systemctl restart sshd', - ], - [ - 'priority' => 'Medium', - 'title' => 'Configure iptables/UFW rate limiting', - 'description' => 'Limit connection attempts per IP address to prevent brute force attacks.', - 'action' => 'UFW: sudo ufw limit ssh/tcp. iptables: sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh && sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP', - ], - [ - 'priority' => 'Medium', - 'title' => 'Use TCP Wrappers', - 'description' => 'Additional access control layer for SSH connections.', - 'action' => 'Edit /etc/hosts.allow and /etc/hosts.deny to restrict SSH access by IP address or hostname.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Implement two-factor authentication (2FA)', - 'description' => 'Add an extra layer of security even if credentials are compromised.', - 'action' => 'Use Google Authenticator or similar: Install libpam-google-authenticator, configure PAM for SSH.', - ], - ]; - } - - // RDP Attack (port 3389) - if ($attackType === 'RDP Attack' || $port === 3389) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Disable RDP if not needed', - 'description' => 'RDP is a high-value target for attackers. If not required, disable it completely.', - 'action' => 'Windows: Disable Remote Desktop in System Settings > Remote Desktop. Linux with xrdp: sudo systemctl disable xrdp && sudo systemctl stop xrdp', - ], - [ - 'priority' => 'Critical', - 'title' => 'Use VPN instead of exposing RDP', - 'description' => 'Never expose RDP directly to the internet. Route connections through a VPN.', - 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to RDP through VPN tunnel only.', - ], - [ - 'priority' => 'High', - 'title' => 'Implement fail2ban for RDP', - 'description' => 'Automatically ban IPs that fail RDP authentication multiple times.', - 'action' => 'Install fail2ban and configure a custom jail for RDP/xrdp. Monitor authentication logs and ban repeated failures.', - ], - [ - 'priority' => 'High', - 'title' => 'Change RDP port', - 'description' => 'Reduce automated attack attempts by moving RDP to a non-standard port.', - 'action' => 'Windows: Edit registry key HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp\\PortNumber. Linux xrdp: Edit /etc/xrdp/xrdp.ini, change port=3389', - ], - [ - 'priority' => 'High', - 'title' => 'Enable Network Level Authentication (NLA)', - 'description' => 'Require authentication before establishing RDP session.', - 'action' => 'Windows: Enable NLA in Remote Desktop settings. This adds an extra authentication layer before the session is established.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Configure firewall rules', - 'description' => 'Restrict RDP access to specific trusted IP addresses only.', - 'action' => 'UFW: sudo ufw allow from [trusted-ip] to any port 3389. iptables: sudo iptables -A INPUT -p tcp -s [trusted-ip] --dport 3389 -j ACCEPT && sudo iptables -A INPUT -p tcp --dport 3389 -j DROP', - ], - ]; - } - - // Database attacks (MySQL, PostgreSQL, SQL Server) - if (in_array($attackType, ['Database Attack', 'SQL Injection']) || in_array($port, [1433, 3306, 5432])) { - $dbType = $port === 1433 ? 'SQL Server' : ($port === 3306 ? 'MySQL' : ($port === 5432 ? 'PostgreSQL' : 'Database')); - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Block database port at firewall level', - 'description' => "Never expose {$dbType} directly to the internet. Databases should only be accessible from application servers.", - 'action' => "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP", - ], - [ - 'priority' => 'Critical', - 'title' => 'Use SSH tunnel or VPN for database access', - 'description' => 'If remote database access is needed, route it through an encrypted tunnel.', - 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:[db-port] user@server. Connect database client to localhost:[local-port]', - ], - [ - 'priority' => 'High', - 'title' => 'Bind database to localhost only', - 'description' => "Configure {$dbType} to only listen on localhost interface, not on public interfaces.", - 'action' => "MySQL: Edit my.cnf, set bind-address = 127.0.0.1. PostgreSQL: Edit postgresql.conf, set listen_addresses = 'localhost'. Restart database service.", - ], - [ - 'priority' => 'High', - 'title' => 'Use strong passwords and limit user privileges', - 'description' => 'Implement least-privilege access and strong authentication.', - 'action' => "Change default passwords. Create database users with minimal required privileges. Use {$dbType} user management to restrict access by IP.", - ], - [ - 'priority' => 'High', - 'title' => 'Implement fail2ban for database', - 'description' => 'Automatically ban IPs that fail database authentication multiple times.', - 'action' => 'Configure fail2ban to monitor database authentication logs. Create custom jail for repeated authentication failures.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Enable SSL/TLS for database connections', - 'description' => 'Encrypt database connections to prevent credential theft.', - 'action' => "Configure SSL certificates for {$dbType}. Update connection strings to require SSL. Update firewall rules accordingly.", - ], - ]; - } - - // SMB/NetBIOS attacks - if (in_array($attackType, ['SMB Exploit', 'NetBIOS Attack']) || in_array($port, [445, 139])) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Disable SMB if not needed', - 'description' => 'SMB is a common attack vector for ransomware and lateral movement. Disable if not required.', - 'action' => 'Linux: sudo systemctl disable smbd nmbd && sudo systemctl stop smbd nmbd. Windows: Disable SMB in Windows Features.', - ], - [ - 'priority' => 'High', - 'title' => 'Block SMB ports at firewall', - 'description' => 'Prevent SMB access from the internet. It should only be accessible on local network.', - 'action' => 'UFW: sudo ufw deny 445/tcp && sudo ufw deny 139/tcp. iptables: Block ports 445 and 139 from external interfaces.', - ], - [ - 'priority' => 'High', - 'title' => 'Use VPN for SMB access', - 'description' => 'If SMB access is required remotely, route it through a VPN tunnel.', - 'action' => 'Set up OpenVPN or WireGuard VPN. Only allow SMB access through VPN tunnel. Configure firewall accordingly.', - ], - [ - 'priority' => 'High', - 'title' => 'Update Samba/Windows to latest version', - 'description' => 'Older SMB versions have known vulnerabilities. Keep software updated.', - 'action' => 'Update Samba: sudo apt update && sudo apt upgrade samba (Ubuntu/Debian). Windows: Install latest security updates.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Disable SMBv1 protocol', - 'description' => 'SMBv1 is deprecated and vulnerable. Use SMBv2 or SMBv3 only.', - 'action' => 'Linux: Edit smb.conf, add min protocol = SMB2. Windows: Disable SMBv1 in Windows Features or via PowerShell: Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol', - ], - [ - 'priority' => 'Medium', - 'title' => 'Restrict SMB access by IP', - 'description' => 'Limit SMB access to specific trusted IP addresses only.', - 'action' => 'Configure firewall rules to only allow SMB connections from trusted IPs. Use hosts.allow for additional access control.', - ], - ]; - } - - // Web attacks (HTTP/HTTPS) - if ($attackType === 'Web Attack' || in_array($port, [80, 443, 8080, 8443])) { - $mitigations = [ - [ - 'priority' => 'High', - 'title' => 'Implement Web Application Firewall (WAF)', - 'description' => 'Protect against web application attacks, SQL injection, XSS, and other web-based exploits.', - 'action' => 'Use ModSecurity with Apache/Nginx, Cloudflare WAF, or AWS WAF. Configure rules to block common attack patterns.', - ], - [ - 'priority' => 'High', - 'title' => 'Configure rate limiting', - 'description' => 'Limit requests per IP to prevent DDoS and brute force attacks.', - 'action' => 'Nginx: limit_req_zone and limit_req directives. Apache: mod_evasive or mod_ratelimit. Also configure at firewall level with iptables/UFW.', - ], - [ - 'priority' => 'High', - 'title' => 'Keep web server and applications updated', - 'description' => 'Outdated software has known vulnerabilities that attackers exploit.', - 'action' => 'Regularly update web server (Apache/Nginx), PHP, and all web applications. Subscribe to security advisories.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Implement fail2ban for web services', - 'description' => 'Automatically ban IPs showing malicious web request patterns.', - 'action' => 'Configure fail2ban to monitor web server logs. Create custom filters for repeated 404 errors, SQL injection attempts, etc.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Use HTTPS only (port 443)', - 'description' => 'Encrypt web traffic to prevent man-in-the-middle attacks and credential theft.', - 'action' => 'Install SSL certificate (Let\'s Encrypt). Configure web server to redirect HTTP (80) to HTTPS (443). Enable HSTS headers.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Configure firewall to block unnecessary ports', - 'description' => 'If using standard HTTPS (443), block alternative ports like 8080 and 8443 unless needed.', - 'action' => 'UFW: sudo ufw deny 8080/tcp && sudo ufw deny 8443/tcp (if not needed). Only allow required web ports.', - ], - [ - 'priority' => 'Low', - 'title' => 'Hide server version information', - 'description' => 'Reduce information disclosure that helps attackers identify vulnerabilities.', - 'action' => 'Configure web server to hide version banners. Disable server signature: ServerTokens Prod (Apache), server_tokens off (Nginx).', - ], - ]; - } - - // Email attacks - if (str_contains($attackType, 'Email') || in_array($port, [25, 110, 143, 993, 995])) { - $mitigations = [ - [ - 'priority' => 'High', - 'title' => 'Configure SPF, DKIM, and DMARC records', - 'description' => 'Prevent email spoofing and improve email authentication.', - 'action' => 'Add SPF, DKIM, and DMARC DNS records for your domain. Configure mail server to sign outgoing emails.', - ], - [ - 'priority' => 'High', - 'title' => 'Require strong authentication', - 'description' => 'Use strong passwords and consider implementing two-factor authentication for email.', - 'action' => 'Enforce password complexity policies. Enable 2FA if supported. Use OAuth2 or modern authentication methods.', - ], - [ - 'priority' => 'High', - 'title' => 'Block port 25 (SMTP) from external access', - 'description' => 'Prevent unauthorized email relaying and spam attacks.', - 'action' => 'Configure mail server to only accept SMTP connections from trusted IPs. Use UFW/iptables to restrict port 25 access.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Implement fail2ban for mail services', - 'description' => 'Automatically ban IPs that fail email authentication multiple times.', - 'action' => 'Configure fail2ban to monitor mail server authentication logs. Create jails for POP3, IMAP, and SMTP authentication failures.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Use encrypted email protocols', - 'description' => 'Prefer secure protocols (IMAPS, POP3S) over unencrypted ones.', - 'action' => 'Disable unencrypted POP3 (110) and IMAP (143). Require SSL/TLS for all email connections. Update firewall rules.', - ], - [ - 'priority' => 'Low', - 'title' => 'Change default email ports', - 'description' => 'Reduce automated attack attempts by using non-standard ports.', - 'action' => 'Consider changing to non-standard ports for email services if your email clients support it. However, this reduces compatibility.', - ], - ]; - } - - // FTP attacks - if ($attackType === 'FTP Attack' || $port === 21) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Disable FTP, use SFTP instead', - 'description' => 'FTP is unencrypted and insecure. SFTP (over SSH) is the secure alternative.', - 'action' => 'Disable FTP server: sudo systemctl disable vsftpd (or proftpd). Use SSH/SFTP instead, which uses port 22 and is encrypted.', - ], - [ - 'priority' => 'High', - 'title' => 'If FTP is required, use FTPS only', - 'description' => 'Use FTP over SSL/TLS to encrypt credentials and data.', - 'action' => 'Configure FTP server to require TLS/SSL. Disable plain FTP. Block port 21 for plain FTP connections.', - ], - [ - 'priority' => 'High', - 'title' => 'Restrict FTP access by IP', - 'description' => 'Limit FTP access to specific trusted IP addresses only.', - 'action' => 'Configure firewall to only allow FTP from trusted IPs. Use FTP server configuration to restrict access by IP address.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Implement fail2ban for FTP', - 'description' => 'Automatically ban IPs that fail FTP authentication multiple times.', - 'action' => 'Configure fail2ban to monitor FTP authentication logs. Create custom jail for repeated authentication failures.', - ], - ]; - } - - // Telnet attacks - if ($attackType === 'Telnet Exploit' || $port === 23) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Disable Telnet completely', - 'description' => 'Telnet is completely unencrypted and should never be used. Use SSH instead.', - 'action' => 'Disable Telnet: sudo systemctl disable telnet && sudo systemctl stop telnet. Remove telnet server package. Block port 23 at firewall.', - ], - [ - 'priority' => 'Critical', - 'title' => 'Use SSH instead', - 'description' => 'SSH provides encrypted remote access, replacing the insecure Telnet protocol.', - 'action' => 'Use SSH (port 22) for all remote terminal access. SSH encrypts all data and credentials.', - ], - ]; - } - - // VNC attacks - if ($attackType === 'VNC Attack' || $port === 5900) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Never expose VNC directly to internet', - 'description' => 'VNC is vulnerable to brute force attacks. Always route through VPN or SSH tunnel.', - 'action' => 'Block VNC port at firewall. Use SSH tunnel: ssh -L 5900:localhost:5900 user@server. Connect VNC client to localhost:5900', - ], - [ - 'priority' => 'High', - 'title' => 'Use SSH tunnel for VNC access', - 'description' => 'Encrypt VNC connections by tunneling through SSH.', - 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:5900 user@server. Connect VNC to localhost:[local-port]. Configure firewall to block port 5900.', - ], - [ - 'priority' => 'High', - 'title' => 'Enable VNC password and strong authentication', - 'description' => 'Use strong passwords if VNC must be used without tunneling.', - 'action' => 'Set strong VNC password: vncpasswd. Consider using VNC over SSL if available. Still prefer SSH tunneling.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Change VNC port', - 'description' => 'Reduce automated attack attempts by using non-standard port.', - 'action' => 'Configure VNC to use non-standard port. Update firewall rules. Still route through SSH tunnel for security.', - ], - ]; - } - - // DNS attacks - if ($attackType === 'DNS Attack' || $port === 53) { - $mitigations = [ - [ - 'priority' => 'High', - 'title' => 'Restrict DNS to local network only', - 'description' => 'DNS servers should typically only serve local networks, not be exposed to the internet.', - 'action' => 'Configure DNS server to only listen on local network interface. Block external DNS queries at firewall unless you operate a public DNS server.', - ], - [ - 'priority' => 'High', - 'title' => 'Use DNS over HTTPS (DoH) or DNS over TLS (DoT)', - 'description' => 'Encrypt DNS queries to prevent interception and manipulation.', - 'action' => 'Configure DNS clients to use DoH/DoT. Update applications to use encrypted DNS protocols.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Configure rate limiting for DNS', - 'description' => 'Prevent DNS amplification attacks and excessive queries.', - 'action' => 'Configure DNS server (BIND, unbound) with rate limiting. Limit queries per IP address.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Keep DNS software updated', - 'description' => 'Outdated DNS software has known vulnerabilities.', - 'action' => 'Regularly update DNS server software. Subscribe to security advisories for your DNS server (BIND, unbound, etc.).', - ], - ]; - } - - // Windows RPC attacks - if ($attackType === 'Windows Exploit' || $port === 135) { - $mitigations = [ - [ - 'priority' => 'Critical', - 'title' => 'Block Windows RPC ports at firewall', - 'description' => 'Windows RPC should never be exposed to the internet. It is used for service enumeration and attacks.', - 'action' => 'Block ports 135, 137, 138, 139, 445 at firewall. Windows: Configure Windows Firewall. Linux firewall: Use UFW/iptables to block these ports.', - ], - [ - 'priority' => 'High', - 'title' => 'Use VPN for Windows RPC access', - 'description' => 'If Windows RPC access is needed remotely, route it through a VPN tunnel.', - 'action' => 'Set up VPN server. Only allow Windows RPC access through VPN tunnel. Configure firewall to block direct RPC access.', - ], - [ - 'priority' => 'High', - 'title' => 'Keep Windows updated', - 'description' => 'Windows RPC vulnerabilities are regularly patched. Keep system updated.', - 'action' => 'Install latest Windows security updates. Enable automatic updates for critical security patches.', - ], - ]; - } - - // Generic/unknown attacks or port scans - if (empty($mitigations)) { - $mitigations = [ - [ - 'priority' => 'High', - 'title' => 'Block port at firewall', - 'description' => "If this port ({$port}) is not needed, block it completely at the firewall level.", - 'action' => $port ? "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP" : 'Configure firewall to block this port.', - ], - [ - 'priority' => 'High', - 'title' => 'Implement fail2ban', - 'description' => 'Automatically ban IPs showing malicious connection patterns.', - 'action' => 'Install fail2ban. Create custom filter to monitor logs for this service. Configure jail to ban repeated connection attempts.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Review service necessity', - 'description' => "Evaluate if the service on port {$port} is necessary. Disable unused services to reduce attack surface.", - 'action' => 'Review running services: sudo netstat -tulpn or sudo ss -tulpn. Disable services that are not required.', - ], - [ - 'priority' => 'Medium', - 'title' => 'Restrict access by IP address', - 'description' => 'Limit access to specific trusted IP addresses only.', - 'action' => $port ? "UFW: sudo ufw allow from [trusted-ip] to any port {$port}. iptables: Allow only from trusted IPs." : 'Configure firewall to restrict access by IP.', - ], - [ - 'priority' => 'Low', - 'title' => 'Use VPN for remote access', - 'description' => 'If remote access is needed, route connections through a VPN instead of exposing services directly.', - 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to services through VPN tunnel.', - ], - ]; - } - - // Add general mitigations for all attacks - $generalMitigations = [ - [ - 'priority' => 'Medium', - 'title' => 'Implement IP blocklist at firewall', - 'description' => 'Automatically block known malicious IP addresses at the firewall level.', - 'action' => 'Use ipset with iptables to maintain blocklists. Integrate with threat intelligence feeds. Automatically update blocklists.', - ], - [ - 'priority' => 'Low', - 'title' => 'Monitor and log all connection attempts', - 'description' => 'Maintain comprehensive logs for security analysis and incident response.', - 'action' => 'Configure syslog to centralize logs. Use log analysis tools (ELK stack, Splunk). Set up alerts for suspicious activity patterns.', - ], - ]; - - // Merge general mitigations - $mitigations = array_merge($mitigations, $generalMitigations); - - // Sort by priority (Critical > High > Medium > Low) - $priorityOrder = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1]; - usort($mitigations, function ($a, $b) use ($priorityOrder) { - $priA = $priorityOrder[$a['priority']] ?? 0; - $priB = $priorityOrder[$b['priority']] ?? 0; - return $priB <=> $priA; - }); - - return $mitigations; - } - - /** - * Analyze risk based on port number - */ - protected function analyzePortRisk(int $port, array $connection): array - { - // Critical risk ports (commonly exploited) - $criticalPorts = [ - 22 => ['attack_type' => 'SSH Brute Force', 'description' => 'SSH (port 22) is a common target for brute force attacks attempting to gain unauthorized access'], - 23 => ['attack_type' => 'Telnet Exploit', 'description' => 'Telnet (port 23) is unencrypted and highly vulnerable to credential theft and man-in-the-middle attacks'], - 3389 => ['attack_type' => 'RDP Attack', 'description' => 'RDP (port 3389) is frequently targeted for remote desktop brute force and ransomware attacks'], - 445 => ['attack_type' => 'SMB Exploit', 'description' => 'SMB (port 445) is commonly exploited for ransomware, lateral movement, and data exfiltration'], - 1433 => ['attack_type' => 'SQL Injection', 'description' => 'SQL Server (port 1433) is targeted for SQL injection attacks and database compromise'], - 3306 => ['attack_type' => 'Database Attack', 'description' => 'MySQL (port 3306) is targeted for database brute force and SQL injection attacks'], - 5432 => ['attack_type' => 'Database Attack', 'description' => 'PostgreSQL (port 5432) is targeted for database exploitation and unauthorized access'], - 135 => ['attack_type' => 'Windows Exploit', 'description' => 'Windows RPC (port 135) is used for service enumeration and remote procedure call attacks'], - 139 => ['attack_type' => 'NetBIOS Attack', 'description' => 'NetBIOS (port 139) is exploited for network enumeration and SMB protocol attacks'], - ]; - - // High risk ports - $highRiskPorts = [ - 21 => ['attack_type' => 'FTP Attack', 'description' => 'FTP (port 21) is vulnerable to credential theft and unencrypted data transmission'], - 25 => ['attack_type' => 'Email Spam', 'description' => 'SMTP (port 25) is commonly used for spam, phishing, and email-based attacks'], - 53 => ['attack_type' => 'DNS Attack', 'description' => 'DNS (port 53) can be exploited for DNS amplification, cache poisoning, or data exfiltration'], - 110 => ['attack_type' => 'Email Attack', 'description' => 'POP3 (port 110) is vulnerable to credential theft and email interception'], - 143 => ['attack_type' => 'Email Attack', 'description' => 'IMAP (port 143) is vulnerable to credential theft and email interception'], - 993 => ['attack_type' => 'Email Attack', 'description' => 'IMAPS (port 993) can be targeted for email account compromise'], - 995 => ['attack_type' => 'Email Attack', 'description' => 'POP3S (port 995) can be targeted for email account compromise'], - 8080 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTP (port 8080) is often targeted for web application attacks and exploits'], - 8443 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTPS (port 8443) can be targeted for web application vulnerabilities'], - 5900 => ['attack_type' => 'VNC Attack', 'description' => 'VNC (port 5900) is vulnerable to brute force attacks and unauthorized remote access'], - ]; - - // Medium risk ports - $mediumRiskPorts = [ - 80 => ['attack_type' => 'Web Attack', 'description' => 'HTTP (port 80) can be targeted for web application vulnerabilities and DDoS attacks'], - 443 => ['attack_type' => 'Web Attack', 'description' => 'HTTPS (port 443) can be targeted for SSL/TLS vulnerabilities and web application attacks'], - 161 => ['attack_type' => 'Network Scan', 'description' => 'SNMP (port 161) is used for network monitoring but can be exploited for information gathering'], - 514 => ['attack_type' => 'Log Injection', 'description' => 'Syslog (port 514) can be targeted for log injection and denial of service attacks'], - 1521 => ['attack_type' => 'Database Attack', 'description' => 'Oracle (port 1521) can be targeted for database exploitation'], - 27017 => ['attack_type' => 'Database Attack', 'description' => 'MongoDB (port 27017) can be targeted for database compromise if misconfigured'], - ]; - - // Check critical ports - if (isset($criticalPorts[$port])) { - return [ - 'risk_level' => 'Critical', - 'attack_type' => $criticalPorts[$port]['attack_type'], - 'description' => $criticalPorts[$port]['description'], - ]; - } - - // Check high risk ports - if (isset($highRiskPorts[$port])) { - return [ - 'risk_level' => 'High', - 'attack_type' => $highRiskPorts[$port]['attack_type'], - 'description' => $highRiskPorts[$port]['description'], - ]; - } - - // Check medium risk ports - if (isset($mediumRiskPorts[$port])) { - return [ - 'risk_level' => 'Medium', - 'attack_type' => $mediumRiskPorts[$port]['attack_type'], - 'description' => $mediumRiskPorts[$port]['description'], - ]; - } - - // Low ports (< 1024) are generally more risky - if ($port < 1024) { - return [ - 'risk_level' => 'Medium', - 'attack_type' => 'System Service Exploit', - 'description' => "Low port number ({$port}) indicates a system service that could be targeted for exploitation", - ]; - } - - // High ports (> 49152) are often used for attacks - if ($port > 49152) { - return [ - 'risk_level' => 'Low', - 'attack_type' => 'Ephemeral Port Scan', - 'description' => "High port number ({$port}) suggests ephemeral port usage, possibly part of a port scan or connection attempt", - ]; - } - - // Default for other ports - return [ - 'risk_level' => 'Low', - 'attack_type' => 'Port Scan', - 'description' => "Connection attempt targeting port {$port} (destination service port) from a malicious IP address", - ]; - } - - /** - * Analyze risk based on IP characteristics - */ - protected function analyzeIpRisk(string $ip): array - { - // Check if IP is in private range (less risky if internal) - if (!$this->isPublicIP($ip)) { - return [ - 'risk_level' => 'Low', - 'attack_type' => 'Internal Threat', - 'severity' => 1, - 'description' => 'Malicious IP is in private/internal range', - ]; - } - - // IPv6 addresses from blocklists are generally high risk - if (str_contains($ip, ':')) { - return [ - 'risk_level' => 'High', - 'attack_type' => 'IPv6 Blocklist Match', - 'severity' => 4, - 'description' => 'IPv6 address matches Spamhaus DROPv6 blocklist (known malicious network)', - ]; - } - - // IPv4 addresses from blocklists are high risk - return [ - 'risk_level' => 'High', - 'attack_type' => 'IPv4 Blocklist Match', - 'severity' => 4, - 'description' => 'IPv4 address matches blocklist.de malicious IP list', - ]; - } - - /** - * Get numeric severity for risk level comparison - */ - protected function getRiskSeverity(string $riskLevel): int - { - return match($riskLevel) { - 'Critical' => 5, - 'High' => 4, - 'Medium' => 3, - 'Low' => 2, - default => 1, - }; - } - - /** - * Fetch and cache IPv6 blocklist from Spamhaus DROPv6 - */ - protected function getIpv6Blocklist(): array - { - return Cache::remember('spamhaus-dropv6-blocklist', 3600, function () { - try { - $response = Http::timeout(10)->get('https://www.spamhaus.org/drop/dropv6.txt'); - - if (!$response->successful()) { - Log::warning('Failed to fetch Spamhaus DROPv6 blocklist', [ - 'status' => $response->status(), - ]); - return []; - } - - $lines = explode("\n", $response->body()); - $cidrs = []; - - foreach ($lines as $line) { - $line = trim($line); - - // Skip comments and empty lines - if (empty($line) || str_starts_with($line, ';') || str_starts_with($line, '#')) { - continue; - } - - // Extract CIDR (everything before ;) - $parts = explode(';', $line, 2); - $cidr = trim($parts[0]); - - if (!empty($cidr)) { - // Normalize CIDR if needed - if (str_contains($cidr, '/')) { - [$ip, $prefix] = explode('/', $cidr, 2); - $normalizedIp = $this->normalizeSingleIp($ip); - if ($normalizedIp !== null) { - $normalizedCidr = $this->normalizeCidr($normalizedIp, (int)$prefix); - if ($normalizedCidr) { - $cidrs[] = $normalizedCidr; - } - } - } - } - } - - Log::debug('Fetched Spamhaus DROPv6 blocklist', [ - 'cidr_count' => count($cidrs), - 'sample_cidrs' => array_slice($cidrs, 0, 5), - ]); - - return array_unique($cidrs); - } catch (\Throwable $e) { - Log::error('Error fetching Spamhaus DROPv6 blocklist', [ - 'error' => $e->getMessage(), - ]); - return []; - } - }); - } - - /** - * Fetch and cache IPv4 blocklist from blocklist.de - */ - protected function getIpv4Blocklist(): array - { - return Cache::remember('blocklist-de-ipv4-blocklist', 3600, function () { - try { - $response = Http::timeout(10)->get('https://lists.blocklist.de/lists/all.txt'); - - if (!$response->successful()) { - Log::warning('Failed to fetch blocklist.de IPv4 blocklist', [ - 'status' => $response->status(), - ]); - return []; - } - - $lines = explode("\n", $response->body()); - $ips = []; - - foreach ($lines as $line) { - $line = trim($line); - - // Skip empty lines - if (empty($line)) { - continue; - } - - // Validate and normalize IP - $normalizedIp = $this->normalizeSingleIp($line); - if ($normalizedIp !== null && !str_contains($normalizedIp, ':')) { - // Only IPv4 addresses - $ips[] = $normalizedIp; - } - } - - Log::debug('Fetched blocklist.de IPv4 blocklist', [ - 'ip_count' => count($ips), - 'sample_ips' => array_slice($ips, 0, 5), - ]); - - return array_unique($ips); - } catch (\Throwable $e) { - Log::error('Error fetching blocklist.de IPv4 blocklist', [ - 'error' => $e->getMessage(), - ]); - return []; - } - }); - } - - protected function extractStateIp(array $state, string $direction): string - { - // Core Router API returns source/destination in format: "IP[port]" or separate fields - $candidates = [ - $direction === 'src' ? 'source' : 'destination', - "{$direction}_ip", - "{$direction}ip", - $direction, - $direction === 'src' ? 'source_ip' : 'destination_ip', - ]; - - foreach ($candidates as $candidate) { - if (!empty($state[$candidate])) { - $value = (string) $state[$candidate]; - - // Parse format: "2406:da12:fc3:5300::11[53443]" or "45.154.99.28[22]" -> extract IP part - // Also handle IPv6 format: "2a0b:4e07:8:1::1:7[12844]" - if (preg_match('/^([^\[\]]+)(\[.*\])?$/', $value, $matches)) { - $ip = trim($matches[1]); - // Normalize the extracted IP - $normalized = $this->normalizeSingleIp($ip); - return $normalized ?: $ip; - } - - // Handle IPv4 in "ip:port" format (ensure IPv6 not affected) - $colonPos = strrpos($value, ':'); - if ($colonPos !== false) { - $ipPart = substr($value, 0, $colonPos); - $portPart = substr($value, $colonPos + 1); - - // Only treat as IPv4 if the portion before colon is a valid IPv4 address - if (filter_var($ipPart, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($portPart)) { - $normalized = $this->normalizeSingleIp($ipPart); - return $normalized ?: $ipPart; - } - } - - // If no bracket format, try to normalize anyway - $normalized = $this->normalizeSingleIp($value); - return $normalized ?: $value; - } - } - - return 'N/A'; - } - - protected function extractStatePort(array $state, string $direction): ?int - { - // Core Router API returns source/destination in format: "IP[port]" or separate fields - // Check dedicated port fields first, then fall back to composite IP[port] fields - $portOnlyCandidates = [ - "{$direction}_port", - "{$direction}port", - $direction === 'src' ? 'source_port' : 'destination_port', - $direction === 'src' ? 'sport' : 'dport', - ]; - - foreach ($portOnlyCandidates as $candidate) { - if (isset($state[$candidate]) && is_numeric($state[$candidate])) { - return (int) $state[$candidate]; - } - } - - // Fall back to extracting port from composite "IP[port]" or "IP:port" fields - $compositeCandidates = [ - $direction === 'src' ? 'source' : 'destination', - ]; - - foreach ($compositeCandidates as $candidate) { - if (isset($state[$candidate]) && is_string($state[$candidate])) { - $value = $state[$candidate]; - - // Handle "IP[port]" format - if (preg_match('/\[(\d+)\]$/', $value, $matches)) { - return (int) $matches[1]; - } - - // Handle IPv4 "IP:port" format (not IPv6) - if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $value, $matches)) { - return (int) $matches[2]; - } - } - } - - return null; - } - - /** - * Extract port number from a field that may contain IP:port or IP[port] format - */ - protected function extractPortFromField(string $field): ?int - { - if (empty($field)) { - return null; - } - - $field = trim($field); - - // Handle IPv4 "IP:port" format (e.g., "45.154.99.22:22") - if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { - return (int)$matches[2]; - } - - // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") - if (preg_match('/\[(\d+)\]$/', $field, $matches)) { - return (int)$matches[1]; - } - - return null; - } - - protected function expandIpValue(?string $value): array - { - $results = []; - if (!$value) { - return $results; - } - - $sanitized = str_replace(["\r", "\n", "\t"], ' ', (string) $value); - $parts = preg_split('/[\s,]+/', $sanitized, -1, PREG_SPLIT_NO_EMPTY); - - foreach ($parts as $part) { - $part = trim($part); - if ($part === '') { - continue; - } - - if (str_contains($part, '/')) { - [$ipCandidate, $prefixPart] = explode('/', $part, 2); - $normalizedIp = $this->normalizeSingleIp($ipCandidate); - $prefixPart = trim($prefixPart); - - if ($normalizedIp === null || $prefixPart === '' || !ctype_digit($prefixPart)) { - continue; - } - - $prefix = (int) $prefixPart; - $maxPrefix = str_contains($normalizedIp, ':') ? 128 : 32; - if ($prefix < 0 || $prefix > $maxPrefix) { - continue; - } - - // Normalize CIDR to network address (important for matching) - $networkCidr = $this->normalizeCidr($normalizedIp, $prefix); - if ($networkCidr) { - // Add the base IP as an exact match (important for exact IP matching) - // This ensures IPs stored as "IP/CIDR" can be matched exactly - $results[] = [ - 'type' => 'ip', - 'value' => $normalizedIp, - ]; - // Also add the CIDR network for CIDR matching - $results[] = [ - 'type' => 'cidr', - 'value' => $networkCidr, - ]; - } - - continue; - } - - $normalizedIp = $this->normalizeSingleIp($part); - if ($normalizedIp !== null) { - $results[] = [ - 'type' => 'ip', - 'value' => $normalizedIp, - ]; - } - } - - return $results; - } - - protected function normalizeSingleIp(?string $ip): ?string - { - if ($ip === null) { - return null; - } - - $ip = strtolower(trim($ip)); - if ($ip === '') { - return null; - } - - $ip = trim($ip, "[]'\""); - - if (str_contains($ip, '%')) { - $ip = strstr($ip, '%', true) ?: $ip; - } - - return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null; - } - - /** - * Normalize CIDR to network address (e.g., 45.154.99.28/28 -> 45.154.99.16/28) - */ - protected function normalizeCidr(string $ip, int $prefix): ?string - { - $isIPv6 = str_contains($ip, ':'); - $ipBinary = @inet_pton($ip); - - if ($ipBinary === false) { - return null; - } - - $addressLength = strlen($ipBinary) * 8; - if ($prefix < 0 || $prefix > $addressLength) { - return null; - } - - // Calculate network address by zeroing out host bits - $fullBytes = intdiv($prefix, 8); - $remainingBits = $prefix % 8; - - // Apply mask to the partial byte first (before zeroing), preserving network bits - if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) { - $mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF; - $ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask); - } - - // Zero out all host bytes after the partial byte - for ($i = $fullBytes + ($remainingBits > 0 ? 1 : 0); $i < strlen($ipBinary); $i++) { - $ipBinary[$i] = "\0"; - } - - $networkIp = @inet_ntop($ipBinary); - if ($networkIp === false) { - return null; - } - - return $networkIp . '/' . $prefix; - } - - protected function ipMatchesServer(string $ip, array $exactIps): bool - { - $normalized = $this->normalizeSingleIp($ip); - if ($normalized === null) { - return false; - } - - if (in_array($normalized, $exactIps, true)) { - return true; - } - - foreach ($this->serverIpCidrs as $cidr) { - if ($this->ipInCidr($normalized, $cidr)) { - return true; - } - } - - return false; - } - - protected function ipInCidr(string $ip, string $cidr): bool - { - if (!str_contains($cidr, '/')) { - return $ip === $cidr; - } - - [$network, $prefixLength] = explode('/', $cidr, 2); - $prefixLength = (int) $prefixLength; - - $ipBinary = @inet_pton($ip); - $networkBinary = @inet_pton($network); - - if ($ipBinary === false || $networkBinary === false) { - return false; - } - - if (strlen($ipBinary) !== strlen($networkBinary)) { - return false; - } - - $maxPrefix = strlen($networkBinary) * 8; - if ($prefixLength < 0 || $prefixLength > $maxPrefix) { - return false; - } - - $fullBytes = intdiv($prefixLength, 8); - $remainingBits = $prefixLength % 8; - - if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($networkBinary, 0, $fullBytes)) { - return false; - } - - if ($remainingBits === 0) { - return true; - } - - $mask = (~(0xff >> $remainingBits)) & 0xff; - - return (ord($ipBinary[$fullBytes]) & $mask) === (ord($networkBinary[$fullBytes]) & $mask); - } - - public function getGraphPayloads(): array - { - return $this->graphPayloads; - } - - public function updateChartData(string $chartType): array - { - $allowedTypes = ['cpu', 'memory', 'network', 'disk']; - if (!in_array($chartType, $allowedTypes, true)) { - return []; - } - - $timeRangeProperty = $chartType . 'TimeRange'; - $aggregationProperty = $chartType . 'Aggregation'; - - $timeRange = $this->$timeRangeProperty; - $aggregation = $this->$aggregationProperty; - - $this->fetchGraphData($chartType, $timeRange, $aggregation, true); - - $fullData = $this->graphPayloads[$chartType] ?? []; - - // Filter data to only return essential fields for this chart type to reduce response size - $filteredData = []; - foreach ($fullData as $point) { - $filteredPoint = ['time' => $point['time'] ?? 0]; - - switch ($chartType) { - case 'cpu': - $filteredPoint['cpu'] = $point['cpu'] ?? 0; - break; - case 'memory': - $filteredPoint['mem'] = $point['mem'] ?? 0; - $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; - break; - case 'network': - $filteredPoint['netin'] = $point['netin'] ?? 0; - $filteredPoint['netout'] = $point['netout'] ?? 0; - break; - case 'disk': - $filteredPoint['diskread'] = $point['diskread'] ?? 0; - $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; - break; - } - - $filteredData[] = $filteredPoint; - } - - return $filteredData; - } - - public function render() - { - return view('proxmox::statistics')->layoutData([ - 'sidebar' => true, - ]); - } - - public function exportChart(string $chartKey) - { - $rows = $this->exportRowsFor($chartKey); - - if (empty($rows)) { - $this->showExportInfo('No data available to export for this chart. Please refresh the chart and try again.'); - return; - } - - $headers = array_keys($rows[0]); - $csv = implode(',', $headers) . PHP_EOL; - - foreach ($rows as $row) { - $csv .= implode(',', array_map(function ($value) { - return '"' . str_replace('"', '""', (string)$value) . '"'; - }, $row)) . PHP_EOL; - } - - $filename = sprintf('%s-chart-%s.csv', strtolower($chartKey), now()->format('Y-m-d_Hi')); - - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, $filename, [ - 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', - ]); - } - - protected function exportRowsFor(string $chartKey): array - { - $chartKey = strtolower($chartKey); - $data = $this->graphPayloads[$chartKey] ?? []; - - if (empty($data)) { - return []; - } - - $rows = []; - - foreach ($data as $point) { - if (!isset($point['time'])) { - continue; - } - - $timestamp = Carbon::createFromTimestamp((int)$point['time'])->toDateTimeString(); - - switch ($chartKey) { - case 'cpu': - $rows[] = [ - 'Timestamp' => $timestamp, - 'CPU (%)' => round(($point['cpu'] ?? 0) * 100, 2), - ]; - break; - case 'memory': - $rows[] = [ - 'Timestamp' => $timestamp, - 'Used (GB)' => $this->convertBytes($point['mem'] ?? 0, 'GB'), - 'Max (GB)' => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), - ]; - break; - case 'network': - $rows[] = [ - 'Timestamp' => $timestamp, - 'Download (MB/s)' => $this->convertBytes($point['netin'] ?? 0, 'MB'), - 'Upload (MB/s)' => $this->convertBytes($point['netout'] ?? 0, 'MB'), - ]; - break; - case 'disk': - $rows[] = [ - 'Timestamp' => $timestamp, - 'Read (MB/s)' => $this->convertBytes($point['diskread'] ?? 0, 'MB'), - 'Write (MB/s)' => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), - ]; - break; - } - } - - return $rows; - } - - protected function makeCpuChart() - { - $data = $this->graphPayloads['cpu'] ?? []; - - if (empty($data)) { - return null; - } - - $labels = $this->buildLabels($data, $this->cpuTimeRange); - $values = array_map(fn ($point) => round(($point['cpu'] ?? 0) * 100, 2), $data); - - return Chartjs::build() - ->name('cpuChart_' . $this->server->id . '_' . $this->cpuTimeRange . '_' . $this->cpuAggregation) - ->type('line') - ->size(['height' => 280, 'width' => 400]) - ->labels($labels) - ->datasets([ - [ - 'label' => 'CPU Usage (%)', - 'data' => $values, - 'borderColor' => 'rgb(74, 222, 128)', - 'backgroundColor' => 'rgba(74, 222, 128, 0.15)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - ]) - ->options($this->chartOptions(100, 'CPU %', false)); - } - - protected function makeMemoryChart() - { - $data = $this->graphPayloads['memory'] ?? []; - - if (empty($data)) { - return null; - } - - $labels = $this->buildLabels($data, $this->memoryTimeRange); - $used = array_map(fn ($point) => $this->convertBytes($point['mem'] ?? 0, 'GB'), $data); - $max = array_map(fn ($point) => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), $data); - - return Chartjs::build() - ->name('memoryChart_' . $this->server->id . '_' . $this->memoryTimeRange . '_' . $this->memoryAggregation) - ->type('line') - ->size(['height' => 280, 'width' => 400]) - ->labels($labels) - ->datasets([ - [ - 'label' => 'Used (GB)', - 'data' => $used, - 'borderColor' => 'rgb(96, 165, 250)', - 'backgroundColor' => 'rgba(96, 165, 250, 0.15)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - [ - 'label' => 'Provisioned (GB)', - 'data' => $max, - 'borderColor' => 'rgb(30, 64, 175)', - 'backgroundColor' => 'rgba(37, 99, 235, 0.08)', - 'fill' => false, - 'pointRadius' => 0, - 'borderDash' => [6, 6], - ], - ]) - ->options($this->chartOptions(null, 'GB')); - } - - protected function makeNetworkChart() - { - $data = $this->graphPayloads['network'] ?? []; - - if (empty($data)) { - return null; - } - - $labels = $this->buildLabels($data, $this->networkTimeRange); - $download = array_map(fn ($point) => $this->convertBytes($point['netin'] ?? 0, 'MB'), $data); - $upload = array_map(fn ($point) => $this->convertBytes($point['netout'] ?? 0, 'MB'), $data); - - return Chartjs::build() - ->name('networkChart_' . $this->server->id . '_' . $this->networkTimeRange . '_' . $this->networkAggregation) - ->type('line') - ->size(['height' => 280, 'width' => 400]) - ->labels($labels) - ->datasets([ - [ - 'label' => 'Download (MB/s)', - 'data' => $download, - 'borderColor' => 'rgb(45, 212, 191)', - 'backgroundColor' => 'rgba(45, 212, 191, 0.12)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - [ - 'label' => 'Upload (MB/s)', - 'data' => $upload, - 'borderColor' => 'rgb(14, 165, 233)', - 'backgroundColor' => 'rgba(14, 165, 233, 0.12)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - ]) - ->options($this->chartOptions(null, 'MB/s')); - } - - protected function makeDiskChart() - { - $data = $this->graphPayloads['disk'] ?? []; - - if (empty($data)) { - return null; - } - - $labels = $this->buildLabels($data, $this->diskTimeRange); - $read = array_map(fn ($point) => $this->convertBytes($point['diskread'] ?? 0, 'MB'), $data); - $write = array_map(fn ($point) => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), $data); - - return Chartjs::build() - ->name('diskChart_' . $this->server->id . '_' . $this->diskTimeRange . '_' . $this->diskAggregation) - ->type('line') - ->size(['height' => 280, 'width' => 400]) - ->labels($labels) - ->datasets([ - [ - 'label' => 'Read (MB/s)', - 'data' => $read, - 'borderColor' => 'rgb(147, 51, 234)', - 'backgroundColor' => 'rgba(147, 51, 234, 0.14)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - [ - 'label' => 'Write (MB/s)', - 'data' => $write, - 'borderColor' => 'rgb(236, 72, 153)', - 'backgroundColor' => 'rgba(236, 72, 153, 0.12)', - 'fill' => true, - 'tension' => 0.35, - 'pointRadius' => 0, - ], - ]) - ->options($this->chartOptions(null, 'MB/s')); - } - - protected function chartOptions(?int $suggestedMax = null, ?string $yLabel = null, bool $showLegend = true): array - { - $options = [ - 'responsive' => true, - 'maintainAspectRatio' => false, - 'plugins' => [ - 'legend' => [ - 'display' => $showLegend, - 'labels' => [ - 'color' => 'rgb(148, 163, 184)', - 'usePointStyle' => true, - ], - ], - 'tooltip' => [ - 'backgroundColor' => 'rgba(15, 23, 42, 0.92)', - 'titleColor' => '#fff', - 'bodyColor' => '#fff', - ], - ], - 'elements' => [ - 'point' => [ - 'hitRadius' => 20, - ], - ], - 'scales' => [ - 'x' => [ - 'grid' => [ - 'color' => 'rgba(148, 163, 184, 0.12)', - ], - 'ticks' => [ - 'color' => 'rgb(148, 163, 184)', - 'maxRotation' => 0, - ], - ], - 'y' => [ - 'beginAtZero' => true, - 'grid' => [ - 'color' => 'rgba(148, 163, 184, 0.08)', - ], - 'ticks' => [ - 'color' => 'rgb(148, 163, 184)', - ], - ], - ], - ]; - - if ($suggestedMax !== null) { - $options['scales']['y']['suggestedMax'] = $suggestedMax; - } - - if ($yLabel) { - $options['scales']['y']['title'] = [ - 'display' => true, - 'text' => $yLabel, - 'color' => 'rgb(148, 163, 184)', - 'font' => ['size' => 12], - ]; - } - - return $options; - } - - protected function buildLabels(array $data, string $range): array - { - $format = $this->labelFormatForRange($range); - - return array_map(function ($point) use ($format) { - if (!isset($point['time'])) { - return ''; - } - - return Carbon::createFromTimestamp((int)$point['time'])->format($format); - }, $data); - } - - protected function labelFormatForRange(string $range): string - { - return match ($range) { - 'hour' => 'H:i', - 'day' => 'M d H:i', - 'week' => 'M d', - 'month' => 'M d', - 'year' => 'M Y', - default => 'M d H:i', - }; - } - - protected function convertBytes($bytes, string $unit = 'MB'): float - { - if (!is_numeric($bytes) || $bytes <= 0) { - return 0.0; - } - - $unit = strtoupper($unit); - $divisors = [ - 'B' => 1, - 'KB' => 1024, - 'MB' => 1024 ** 2, - 'GB' => 1024 ** 3, - 'TB' => 1024 ** 4, - ]; - - $divisor = $divisors[$unit] ?? $divisors['MB']; - - return round($bytes / $divisor, 2); - } - - /** - * Extract IP address from a string, handling ports for both IPv4 and IPv6 - */ - protected function extractIPFromString(string $ip): string - { - $ip = trim($ip); - - // Handle IPv4 with port (e.g., "192.168.1.1:8080") - if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $ip, $matches)) { - return $matches[1]; - } - - // Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080") - if (preg_match('/^\[([^\]]+)\]:(\d+)$/', $ip, $matches)) { - return $matches[1]; - } - - // Handle IPv6 with port without brackets (less common, but possible) - // For IPv6, we need to check if it's actually an IPv6 address before removing port - if (str_contains($ip, ':')) { - // Check if it's a valid IPv6 address - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - // No port to remove, it's just an IPv6 address - return $ip; - } - // Might be IPv6 with port, try to extract - $parts = explode(']:', $ip); - if (count($parts) === 2) { - return trim($parts[0], '[]'); - } - } - - return $ip; - } - - /** - * Check if an IP address is public (not private/localhost) - public method for Blade templates - */ - public function checkIsPublicIP(string $ip): bool - { - return $this->isPublicIP($ip); - } - - /** - * Check if an IP address is public (not private/localhost) - */ - protected function isPublicIP(string $ip): bool - { - $ip = $this->extractIPFromString($ip); - - if (empty($ip)) { - return false; - } - - // Check for localhost - if (in_array(strtolower($ip), ['localhost', '127.0.0.1', '::1'])) { - return false; - } - - // Validate and check if it's a public IP - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - // IPv4: Check if it's not in private ranges - return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; - } - - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - // IPv6: Check if it's not in private ranges - // Private IPv6 ranges: - // - ::1 (localhost) - // - fc00::/7 (Unique Local Address - ULA) - // - fe80::/10 (Link-local) - // - ::ffff:0:0/96 (IPv4-mapped IPv6) - - // Check for link-local (fe80::/10 - addresses starting with fe80 through febf) - if (preg_match('/^fe[89ab][0-9a-fA-F]:/i', $ip)) { - return false; - } - - // Check for ULA (fc00::/7 means fc00:: to fdff:ffff:...) - if (str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) { - return false; - } - - // Check for IPv4-mapped IPv6 - if (str_starts_with($ip, '::ffff:')) { - $ipv4Part = substr($ip, 7); - return $this->isPublicIP($ipv4Part); - } - - // If it's a valid IPv6 and not in private ranges, consider it public - return true; - } - - return false; - } - - /** - * Fetch IP geolocation data from ip-api.com via HTTP (server-side proxy) - * This avoids mixed content issues when the site is served over HTTPS - */ - public function fetchIPLocation($ip): array - { - if (empty($ip)) { - return ['status' => 'fail', 'message' => 'IP address is required']; - } - - // Extract and validate IP address - $cleanIP = $this->extractIPFromString((string)$ip); - - if (!$this->isPublicIP($cleanIP)) { - return ['status' => 'fail', 'message' => 'Not a valid public IP address']; - } - - try { - // Use HTTP protocol as requested (not HTTPS) - $response = Http::timeout(5)->get("http://ip-api.com/json/{$cleanIP}"); - - if ($response->successful()) { - return $response->json(); - } - - return [ - 'status' => 'fail', - 'message' => 'Failed to fetch location data: HTTP ' . $response->status() - ]; - } catch (\Exception $e) { - Log::error('Failed to fetch IP location', [ - 'ip' => $cleanIP, - 'original_ip' => $ip, - 'error' => $e->getMessage() - ]); - - return [ - 'status' => 'fail', - 'message' => 'Failed to fetch location data: ' . $e->getMessage() - ]; - } - } -} - + [], + 'memory' => [], + 'network' => [], + 'disk' => [], + ]; + public bool $shouldRenderCharts = false; + + public bool $networkAnalysisLoading = false; + public int $networkAnalysisRetryCount = 0; + public int $networkAnalysisMaxRetries = 3; + + // Network Analysis Pagination + public int $connectionsPerPage = 25; + public int $connectionsCurrentPage = 1; + public int $topConnectionsPerPage = 25; + public int $topConnectionsCurrentPage = 1; + public int $topPortsPerPage = 25; + public int $topPortsCurrentPage = 1; + + // Statistics History Pagination + public int $statisticsPerPage = 25; + + // Task History Pagination + public int $taskHistoryPerPage = 25; + public int $taskHistoryCurrentPage = 1; + + // Lifecycle Events Pagination + public int $lifecycleEventsPerPage = 25; + public int $lifecycleEventsCurrentPage = 1; + + public array $networkAnalysis = [ + 'connectionCount' => 0, + 'topConnectionCount' => 0, + 'topPortCount' => 0, + 'security' => [], + 'lastUpdated' => null, + 'error' => null, + ]; + + public array $cachedServerIps = []; + public array $serverIpCidrs = []; + + // Historical comparison settings + public string $historicalPeriod = '1_hour_ago'; // Options: 1_hour_ago, 2_hours_ago, 6_hours_ago, 12_hours_ago, 24_hours_ago, custom + public string $currentPeriod = 'now'; // Options: now, 1_hour_ago, custom + public ?string $historicalCustomDate = null; + public ?string $currentCustomDate = null; + + public function getChartEventsProperty() + { + $tasks = $this->taskHistory; + $events = []; + + foreach ($tasks as $task) { + $type = strtolower($task['type'] ?? ''); + $status = strtolower($task['status'] ?? ''); + $startTime = $task['starttime'] ?? null; + $endTime = $task['endtime'] ?? null; + $upid = $task['upid'] ?? $task['id'] ?? null; + + if (!$startTime) { + continue; + } + + $event = null; + if (in_array($type, ['qmstart', 'qmresume'])) { + $event = ['label' => 'Start', 'type' => 'start', 'icon' => 'ri-play-fill']; + } elseif (in_array($type, ['qmstop', 'qmshutdown'])) { + $event = ['label' => 'Shutdown', 'type' => 'shutdown', 'icon' => 'ri-stop-fill']; + } elseif (in_array($type, ['qmreboot'])) { + $event = ['label' => 'Reboot', 'type' => 'reboot', 'icon' => 'ri-loop-left-line']; + } elseif (in_array($type, ['qmreset'])) { + $event = ['label' => 'Reset', 'type' => 'reset', 'icon' => 'ri-restart-line']; + } elseif ($type === 'vzdump') { + $event = ['label' => 'Backup', 'type' => 'backup', 'icon' => 'ri-save-3-fill']; + } + + if ($event) { + $startCarbon = \Carbon\Carbon::createFromTimestamp($startTime); + $endCarbon = $endTime && $endTime > 0 ? \Carbon\Carbon::createFromTimestamp($endTime) : null; + $duration = null; + if ($startCarbon && $endCarbon) { + $duration = $startCarbon->diffInSeconds($endCarbon); + } elseif ($startCarbon && $status === 'running') { + $duration = $startCarbon->diffInSeconds(now()); + } + + $events[] = [ + 'timestamp' => $startTime * 1000, // Convert to milliseconds + 'label' => $event['label'], + 'type' => $event['type'], + 'icon' => $event['icon'], + 'status' => $status, + 'original_type' => $task['type'] ?? 'N/A', + 'starttime' => $startTime, + 'endtime' => $endTime, + 'duration' => $duration, + 'upid' => $upid, + 'start_datetime' => $startCarbon->format('M d, Y H:i:s'), + 'end_datetime' => $endCarbon ? $endCarbon->format('M d, Y H:i:s') : null, + 'duration_formatted' => $duration !== null ? $this->formatUptime($duration) : null, + ]; + } + } + + return $events; + } + + public function mount(Server $server): void + { + parent::mount($server); + + $this->server->loadMissing('ipAddresses'); + // Network Analysis is loaded lazily via JavaScript to improve page load speed + // Initialize error state if not configured to prevent infinite loading + if (!$this->coreRouterEnabled()) { + $this->networkAnalysis['error'] = 'Core Router API is not configured.'; + $this->networkAnalysis['connectionCount'] = 0; + $this->networkAnalysis['topConnectionCount'] = 0; + $this->networkAnalysis['topPortCount'] = 0; + $this->networkAnalysis['security'] = []; + } + + // Preload graph data on mount to speed up initial display + // Dispatch individual chart events for more reliable parallel loading + // Each chart loads independently so a failure in one doesn't block others + $this->dispatch('proxmox:stats:cpu'); + $this->dispatch('proxmox:stats:memory'); + $this->dispatch('proxmox:stats:network'); + $this->dispatch('proxmox:stats:disk'); + } + + #[On('proxmox:stats')] + public function stats(): void + { + // Fetch each graph independently to prevent one failure from blocking others + // Each fetch is wrapped in its own try-catch in fetchGraphData() + $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); + $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); + $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); + $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); + } + + /** + * Fetch individual graph data - allows frontend to request specific graphs independently + * This improves reliability as each graph can be retried separately if it fails + */ + #[On('proxmox:stats:cpu')] + public function fetchCpuStats(): void + { + $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); + } + + #[On('proxmox:stats:memory')] + public function fetchMemoryStats(): void + { + $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); + } + + #[On('proxmox:stats:network')] + public function fetchNetworkStats(): void + { + $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); + } + + #[On('proxmox:stats:disk')] + public function fetchDiskStats(): void + { + $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); + } + + /** + * Explicit event handler for Network Analysis lazy loading. + * + * This mirrors the Live Telemetry pattern (proxmox:stats) and allows the + * frontend to trigger loading via Livewire.dispatch(), which is more + * robust than relying on @this in inline scripts – especially on the + * first-ever load of the Statistics page for a given server. + */ + #[On('proxmox:network-analysis-load')] + public function handleNetworkAnalysisLoad(): void + { + $this->loadNetworkAnalysis(); + } + + public function loadNetworkAnalysis(): void + { + Log::debug('Network Analysis: loadNetworkAnalysis() called', [ + 'current_loading_state' => $this->networkAnalysisLoading, + 'has_last_updated' => ! empty($this->networkAnalysis['lastUpdated']), + 'has_error' => ! empty($this->networkAnalysis['error']), + ]); + + // Use a short-lived cache to avoid repeatedly performing heavy analysis + $cacheKey = 'proxmox_network_analysis_' . $this->server->id; + + // If we already have a fairly recent result, reuse it immediately + $cachedAnalysis = Cache::get($cacheKey); + if (is_array($cachedAnalysis) && ! empty($cachedAnalysis['lastUpdated']) && empty($this->networkAnalysis['error'])) { + Log::debug('Network Analysis: served from cache', [ + 'cache_key' => $cacheKey, + 'last_updated' => $cachedAnalysis['lastUpdated'] ?? null, + ]); + + // Only store lightweight metadata in the public property (not the heavy arrays) + $this->networkAnalysis = [ + 'connectionCount' => count($cachedAnalysis['connections'] ?? []), + 'topConnectionCount' => count($cachedAnalysis['topConnections'] ?? []), + 'topPortCount' => count($cachedAnalysis['topPorts'] ?? []), + 'security' => $cachedAnalysis['security'] ?? [], + 'lastUpdated' => $cachedAnalysis['lastUpdated'], + 'error' => $cachedAnalysis['error'] ?? null, + ]; + $this->networkAnalysisLoading = false; + + // Reset retry count when we successfully hydrate from cache + $this->networkAnalysisRetryCount = 0; + + return; + } + + // Prevent duplicate loads while a request is already in-flight + if ($this->networkAnalysisLoading) { + Log::debug('Network Analysis: Already loading, skipping new request'); + + return; + } + + if (! $this->coreRouterEnabled()) { + $this->networkAnalysis['error'] = 'Core Router API is not configured.'; + $this->networkAnalysis['connectionCount'] = 0; + $this->networkAnalysis['topConnectionCount'] = 0; + $this->networkAnalysis['topPortCount'] = 0; + $this->networkAnalysis['security'] = []; + $this->networkAnalysisLoading = false; + + return; + } + + $serverIps = $this->getServerIps(); + if (empty($serverIps)) { + $this->networkAnalysis['error'] = 'No IP addresses are associated with this server.'; + $this->networkAnalysis['connectionCount'] = 0; + $this->networkAnalysis['topConnectionCount'] = 0; + $this->networkAnalysis['topPortCount'] = 0; + $this->networkAnalysis['security'] = []; + $this->networkAnalysisLoading = false; + + return; + } + + $this->networkAnalysisLoading = true; + $this->networkAnalysis['error'] = null; + + try { + $client = app(CoreRouterClient::class); + + // Fetch all firewall states (no API-level filtering) + $allStates = $client->listStates(); + + // Normalize server IPs for matching + $normalizedServerIps = array_map(static function ($ip) { + return strtolower(trim($ip)); + }, $serverIps); + + // Sample a few states to debug extraction (kept minimal for performance) + $sampleStatesForDebug = []; + foreach (array_slice($allStates, 0, 10) as $sampleState) { + $rawSrc = (string) ($sampleState['source'] ?? ''); + $rawDst = (string) ($sampleState['destination'] ?? ''); + $sampleStatesForDebug[] = [ + 'raw_source' => $rawSrc, + 'raw_destination' => $rawDst, + 'extracted_source' => $this->extractIpFromField($rawSrc), + 'extracted_destination' => $this->extractIpFromField($rawDst), + 'protocol' => $sampleState['protocol'] ?? 'N/A', + ]; + } + + Log::debug('Core Router filtering states', [ + 'total_states' => count($allStates), + 'server_ips' => $serverIps, + 'normalized_server_ips' => $normalizedServerIps, + 'sample_states_extraction' => $sampleStatesForDebug, + ]); + + // Filter states: keep only where source OR destination matches a server IP + $filteredStates = []; + $sampleChecks = []; + foreach ($allStates as $state) { + $rawSource = (string) ($state['source'] ?? ''); + $rawDestination = (string) ($state['destination'] ?? ''); + + // Extract IP from "IP[port]" or "IP:port" format + $sourceIp = $this->extractIpFromField($rawSource); + $destinationIp = $this->extractIpFromField($rawDestination); + + $sourceNormalized = strtolower(trim($sourceIp)); + $destNormalized = strtolower(trim($destinationIp)); + + $matches = false; + $matchReason = ''; + + // Normalize raw fields for case-insensitive matching + $rawSourceLower = strtolower($rawSource); + $rawDestinationLower = strtolower($rawDestination); + + // Check exact match + foreach ($normalizedServerIps as $serverIp) { + if ($sourceNormalized === $serverIp) { + $matches = true; + $matchReason = "source exact match: {$sourceNormalized} === {$serverIp}"; + break; + } + if ($destNormalized === $serverIp) { + $matches = true; + $matchReason = "destination exact match: {$destNormalized} === {$serverIp}"; + break; + } + // Also check raw fields contain the IP (handles "IP[port]" format) - case-insensitive + if (stripos($rawSourceLower, $serverIp) !== false) { + $matches = true; + $matchReason = "raw source contains: {$rawSource} contains {$serverIp}"; + break; + } + if (stripos($rawDestinationLower, $serverIp) !== false) { + $matches = true; + $matchReason = "raw destination contains: {$rawDestination} contains {$serverIp}"; + break; + } + } + + if ($matches) { + $filteredStates[] = $state; + + // Log first few matches for debugging + if (count($sampleChecks) < 5) { + $sampleChecks[] = [ + 'raw_source' => $rawSource, + 'raw_destination' => $rawDestination, + 'extracted_source' => $sourceIp, + 'extracted_destination' => $destinationIp, + 'source_normalized' => $sourceNormalized, + 'dest_normalized' => $destNormalized, + 'match_reason' => $matchReason, + 'protocol' => $state['protocol'] ?? 'N/A', + ]; + } + } + } + + Log::debug('Core Router filtered states result', [ + 'filtered_count' => count($filteredStates), + 'sample_matches' => $sampleChecks, + ]); + + // Format connections + $connections = $this->formatConnectionsSimple(array_values($filteredStates)); + $topConnections = $this->aggregateTopConnections($connections); + $topPorts = $this->aggregateTopPorts($connections); + $securityWarnings = $this->checkSecurityThreats($connections); + + Log::debug('Core Router network analysis - final formatting', [ + 'filtered_states_count' => count($filteredStates), + 'connections_count' => count($connections), + 'sample_connections' => array_slice($connections, 0, 3), + ]); + + // Store all connections in cache only — NOT in the public property + // This prevents thousands of connections from being serialized in every Livewire request + $analysis = [ + 'connections' => $connections, + 'topConnections' => $topConnections, + 'topPorts' => $topPorts, + 'security' => $securityWarnings, + 'lastUpdated' => now()->toDateTimeString(), + 'error' => null, + ]; + + // Persist the full analysis in cache for pagination and export + Cache::put($cacheKey, $analysis, now()->addSeconds(30)); + + // Only store lightweight metadata in the Livewire public property + $this->networkAnalysis = [ + 'connectionCount' => count($connections), + 'topConnectionCount' => count($topConnections), + 'topPortCount' => count($topPorts), + 'security' => $securityWarnings, + 'lastUpdated' => now()->toDateTimeString(), + 'error' => null, + ]; + + // Reset pagination to page 1 when data is refreshed + $this->connectionsCurrentPage = 1; + $this->topConnectionsCurrentPage = 1; + $this->topPortsCurrentPage = 1; + + // Reset retry count on successful load + $this->networkAnalysisRetryCount = 0; + } catch (\Throwable $e) { + report($e); + + $errorMessage = $e->getMessage(); + $is502Error = str_contains($errorMessage, 'HTTP 502') || str_contains($errorMessage, '(HTTP 502)'); + + // Reset counts and security on error + $this->networkAnalysis['connectionCount'] = 0; + $this->networkAnalysis['topConnectionCount'] = 0; + $this->networkAnalysis['topPortCount'] = 0; + $this->networkAnalysis['security'] = []; + + // On failure, do not leave any stale cache behind for this server + Cache::forget($cacheKey); + + // Check if it's a 502 error and we haven't exceeded max retries + if ($is502Error && $this->networkAnalysisRetryCount < $this->networkAnalysisMaxRetries) { + $this->networkAnalysisRetryCount++; + + Log::warning('Core Router API returned 502 error, will retry', [ + 'retry_count' => $this->networkAnalysisRetryCount, + 'max_retries' => $this->networkAnalysisMaxRetries, + ]); + + // Set error message indicating retry + $this->networkAnalysis['error'] = 'Temporary server error (HTTP 502). Retrying... (Attempt ' . $this->networkAnalysisRetryCount . '/' . $this->networkAnalysisMaxRetries . ')'; + + // Dispatch event to retry after a delay + $this->dispatch('network-analysis-502-retry', [ + 'retryCount' => $this->networkAnalysisRetryCount, + ]); + } else { + // Not a 502 error or max retries reached - show final error + $this->networkAnalysisRetryCount = 0; + $this->networkAnalysis['error'] = 'Unable to load network analysis: ' . $errorMessage; + } + } finally { + // Always clear loading state in finally block so UI can recover + $this->networkAnalysisLoading = false; + } + } + + /** + * Retry loading network analysis after a 502 error + */ + public function retryNetworkAnalysis(): void + { + // Simple helper that can be invoked from Livewire/JS if needed. + // We intentionally avoid blocking sleep here; the browser-side + // retry logic already introduces a delay between attempts. + $this->networkAnalysisLoading = false; + $this->loadNetworkAnalysis(); + } + + // ========================================== + // Network Analysis Pagination Methods + // ========================================== + + /** + * Get paginated connections for display + */ + public function getPaginatedConnectionsProperty(): array + { + $cacheKey = 'proxmox_network_analysis_' . $this->server->id; + $cachedAnalysis = Cache::get($cacheKey, []); + $connections = $cachedAnalysis['connections'] ?? []; + $offset = ($this->connectionsCurrentPage - 1) * $this->connectionsPerPage; + + return array_slice($connections, $offset, $this->connectionsPerPage); + } + + /** + * Get total pages for connections + */ + public function getConnectionsTotalPagesProperty(): int + { + $total = $this->networkAnalysis['connectionCount'] ?? 0; + return max(1, (int) ceil($total / $this->connectionsPerPage)); + } + + /** + * Get paginated top connections for display + */ + public function getPaginatedTopConnectionsProperty(): array + { + $cacheKey = 'proxmox_network_analysis_' . $this->server->id; + $cachedAnalysis = Cache::get($cacheKey, []); + $connections = $cachedAnalysis['topConnections'] ?? []; + $offset = ($this->topConnectionsCurrentPage - 1) * $this->topConnectionsPerPage; + + return array_slice($connections, $offset, $this->topConnectionsPerPage); + } + + /** + * Get total pages for top connections + */ + public function getTopConnectionsTotalPagesProperty(): int + { + $total = $this->networkAnalysis['topConnectionCount'] ?? 0; + return max(1, (int) ceil($total / $this->topConnectionsPerPage)); + } + + /** + * Get paginated top ports for display + */ + public function getPaginatedTopPortsProperty(): array + { + $cacheKey = 'proxmox_network_analysis_' . $this->server->id; + $cachedAnalysis = Cache::get($cacheKey, []); + $ports = $cachedAnalysis['topPorts'] ?? []; + $offset = ($this->topPortsCurrentPage - 1) * $this->topPortsPerPage; + + return array_slice($ports, $offset, $this->topPortsPerPage); + } + + /** + * Get total pages for top ports + */ + public function getTopPortsTotalPagesProperty(): int + { + $total = $this->networkAnalysis['topPortCount'] ?? 0; + return max(1, (int) ceil($total / $this->topPortsPerPage)); + } + + /** + * Reset page to 1 when perPage changes for connections + */ + public function updatedConnectionsPerPage(): void + { + $this->connectionsCurrentPage = 1; + } + + /** + * Reset page to 1 when perPage changes for top connections + */ + public function updatedTopConnectionsPerPage(): void + { + $this->topConnectionsCurrentPage = 1; + } + + /** + * Reset page to 1 when perPage changes for top ports + */ + public function updatedTopPortsPerPage(): void + { + $this->topPortsCurrentPage = 1; + } + + /** + * Navigate to a specific page for connections + */ + public function goToConnectionsPage(int $page): void + { + $this->connectionsCurrentPage = max(1, min($page, $this->connectionsTotalPages)); + } + + /** + * Navigate to previous page for connections + */ + public function previousConnectionsPage(): void + { + $this->goToConnectionsPage($this->connectionsCurrentPage - 1); + } + + /** + * Navigate to next page for connections + */ + public function nextConnectionsPage(): void + { + $this->goToConnectionsPage($this->connectionsCurrentPage + 1); + } + + /** + * Navigate to a specific page for top connections + */ + public function goToTopConnectionsPage(int $page): void + { + $this->topConnectionsCurrentPage = max(1, min($page, $this->topConnectionsTotalPages)); + } + + /** + * Navigate to previous page for top connections + */ + public function previousTopConnectionsPage(): void + { + $this->goToTopConnectionsPage($this->topConnectionsCurrentPage - 1); + } + + /** + * Navigate to next page for top connections + */ + public function nextTopConnectionsPage(): void + { + $this->goToTopConnectionsPage($this->topConnectionsCurrentPage + 1); + } + + /** + * Navigate to a specific page for top ports + */ + public function goToTopPortsPage(int $page): void + { + $this->topPortsCurrentPage = max(1, min($page, $this->topPortsTotalPages)); + } + + /** + * Navigate to previous page for top ports + */ + public function previousTopPortsPage(): void + { + $this->goToTopPortsPage($this->topPortsCurrentPage - 1); + } + + /** + * Navigate to next page for top ports + */ + public function nextTopPortsPage(): void + { + $this->goToTopPortsPage($this->topPortsCurrentPage + 1); + } + + // ========================================== + // Statistics History Pagination Methods + // ========================================== + + /** + * Reset to page 1 when perPage changes for statistics + * (Laravel paginator will automatically handle this with the paginate() method) + */ + public function updatedStatisticsPerPage(): void + { + // Reset to page 1 when changing per-page (handled by Livewire automatically) + $this->resetPage(); + } + + // ========================================== + // Task History Pagination Methods + // ========================================== + + /** + * Get paginated task history for display + */ + public function getPaginatedTaskHistoryProperty(): array + { + $tasks = $this->taskHistory; + if (empty($tasks)) { + return []; + } + $offset = ($this->taskHistoryCurrentPage - 1) * $this->taskHistoryPerPage; + + return array_slice($tasks, $offset, $this->taskHistoryPerPage); + } + + /** + * Get total pages for task history + */ + public function getTaskHistoryTotalPagesProperty(): int + { + $total = count($this->taskHistory); + return max(1, (int) ceil($total / $this->taskHistoryPerPage)); + } + + /** + * Reset page to 1 when perPage changes for task history + */ + public function updatedTaskHistoryPerPage(): void + { + $this->taskHistoryCurrentPage = 1; + } + + /** + * Navigate to a specific page for task history + */ + public function goToTaskHistoryPage(int $page): void + { + $this->taskHistoryCurrentPage = max(1, min($page, $this->taskHistoryTotalPages)); + } + + /** + * Navigate to previous page for task history + */ + public function previousTaskHistoryPage(): void + { + $this->goToTaskHistoryPage($this->taskHistoryCurrentPage - 1); + } + + /** + * Navigate to next page for task history + */ + public function nextTaskHistoryPage(): void + { + $this->goToTaskHistoryPage($this->taskHistoryCurrentPage + 1); + } + + // ========================================== + // Lifecycle Events Pagination Methods + // ========================================== + + /** + * Get paginated lifecycle events for display + */ + public function getPaginatedLifecycleEventsProperty(): array + { + $events = collect($this->chartEvents)->values()->all(); + if (empty($events)) { + return []; + } + $offset = ($this->lifecycleEventsCurrentPage - 1) * $this->lifecycleEventsPerPage; + + return array_slice($events, $offset, $this->lifecycleEventsPerPage); + } + + /** + * Get total pages for lifecycle events + */ + public function getLifecycleEventsTotalPagesProperty(): int + { + $total = count($this->chartEvents); + return max(1, (int) ceil($total / $this->lifecycleEventsPerPage)); + } + + /** + * Reset page to 1 when perPage changes for lifecycle events + */ + public function updatedLifecycleEventsPerPage(): void + { + $this->lifecycleEventsCurrentPage = 1; + } + + /** + * Navigate to a specific page for lifecycle events + */ + public function goToLifecycleEventsPage(int $page): void + { + $this->lifecycleEventsCurrentPage = max(1, min($page, $this->lifecycleEventsTotalPages)); + } + + /** + * Navigate to previous page for lifecycle events + */ + public function previousLifecycleEventsPage(): void + { + $this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage - 1); + } + + /** + * Navigate to next page for lifecycle events + */ + public function nextLifecycleEventsPage(): void + { + $this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage + 1); + } + + /** + * Extract IP address from field (handles "IP[port]" and "IP:port" formats) + */ + protected function extractIpFromField(string $field): string + { + if (empty($field)) { + return ''; + } + + $field = trim($field); + + // Handle IPv4 "IP:port" format FIRST (e.g., "45.154.99.28:22") + // Check this before the bracket format since IPv4 also uses colons + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { + return $matches[1]; + } + + // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") + // This regex matches: IP part (anything that's not [ or ]) optionally followed by [port] + if (preg_match('/^([^\[\]]+)(?:\[[^\]]+\])?$/', $field, $matches)) { + $ip = trim($matches[1]); + // Remove any trailing colons or brackets + $ip = rtrim($ip, ':[]'); + return $ip; + } + + // If no special format, return as-is + return $field; + } + + /** + * Simple connection formatting + */ + protected function formatConnectionsSimple(array $states): array + { + $connections = []; + foreach ($states as $state) { + $rawSource = (string)($state['source'] ?? ''); + $rawDestination = (string)($state['destination'] ?? ''); + + $sourceIp = $this->extractIpFromField($rawSource); + $destinationIp = $this->extractIpFromField($rawDestination); + + // Extract ports + $sourcePort = null; + $destinationPort = null; + + if (preg_match('/\[(\d+)\]$/', $rawSource, $matches)) { + $sourcePort = (int)$matches[1]; + } elseif (preg_match('/:(\d+)$/', $rawSource, $matches) && filter_var(explode(':', $rawSource)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $sourcePort = (int)$matches[1]; + } + + if (preg_match('/\[(\d+)\]$/', $rawDestination, $matches)) { + $destinationPort = (int)$matches[1]; + } elseif (preg_match('/:(\d+)$/', $rawDestination, $matches) && filter_var(explode(':', $rawDestination)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $destinationPort = (int)$matches[1]; + } + + $bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0); + $packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0); + $protocol = strtoupper($state['protocol'] ?? 'N/A'); + $stateName = strtoupper($state['state'] ?? 'UNKNOWN'); + $interface = strtoupper($state['interface'] ?? 'N/A'); + $direction = strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound'; + + $connections[] = [ + 'protocol' => $protocol, + 'source' => $sourceIp, + 'destination' => $destinationIp, + 'source_port' => $sourcePort, + 'destination_port' => $destinationPort, + 'raw_source' => $rawSource, + 'raw_destination' => $rawDestination, + 'bytes' => $bytes, + 'bytes_human' => $this->formatBytes($bytes), + 'packets' => $packets, + 'state' => $stateName, + 'interface' => $interface, + 'direction' => $direction, + ]; + } + + usort($connections, fn($a, $b) => $b['bytes'] <=> $a['bytes']); + return $connections; + } + + protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation, bool $forceRefresh = false): void + { + $allowedRanges = ['hour', 'day', 'week', 'month', 'year']; + $allowedAggs = ['average', 'maximum']; + if (!in_array($timeRange, $allowedRanges, true) || !in_array($aggregation, $allowedAggs, true)) { + return; + } + + $cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation; + + try { + if ($forceRefresh) { + Cache::forget($cacheKey); + } + // Reduced cache TTL from 60 to 30 seconds for fresher data and faster updates + $data = Cache::remember($cacheKey, 30, function () use ($timeRange, $aggregation) { + $proxmox = new Proxmox; + $stats = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata', data: [ + 'timeframe' => $timeRange, + 'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE', + ]); + + $rawData = $stats->json()['data'] ?? []; + + // Limit data points to prevent memory issues and 413 errors + // Always limit to 40 points regardless of time range + $maxPoints = 40; + + // Sample data if it exceeds max points + if (count($rawData) > $maxPoints) { + $step = ceil(count($rawData) / $maxPoints); + $sampledData = []; + for ($i = 0; $i < count($rawData); $i += $step) { + $sampledData[] = $rawData[$i]; + } + // Always include the last point + if (end($rawData) !== end($sampledData)) { + $sampledData[] = end($rawData); + } + return $sampledData; + } + + return $rawData; + }); + } catch (\Throwable $e) { + report($e); + $data = []; + } + + $this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data); + } + + #[On('render-chart-events')] + public function renderChartEvents() + { + $this->dispatch('proxmox:chart-events', $this->chartEvents); + } + + public function updatedCpuTimeRange(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); + $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); + } + + public function updatedCpuAggregation(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); + $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); + } + + public function updatedMemoryTimeRange(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); + $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); + } + + public function updatedMemoryAggregation(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); + $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); + } + + public function updatedNetworkTimeRange(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); + $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); + } + + public function updatedNetworkAggregation(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); + $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); + } + + public function updatedDiskTimeRange(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); + $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); + } + + public function updatedDiskAggregation(): void + { + Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); + $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); + } + + public function showExportInfo(string $message): void + { + $this->exportInfoMessage = $message; + $this->showExportInfoModal = true; + } + + public function closeExportInfoModal(): void + { + $this->showExportInfoModal = false; + } + + public function openDeleteModal($statisticId) + { + $this->statisticToDeleteId = $statisticId; + $this->showDeleteModal = true; + } + + public function closeDeleteModal() + { + $this->showDeleteModal = false; + $this->statisticToDeleteId = null; + } + + public function selectStatistic($statisticId) + { + $this->selectedStatisticId = $statisticId; + $this->dispatch('statistic-selected'); + } + + public function createLiveStatistic() + { + try { + // Check if user has reached the maximum of 5 manual statistics + $manualStatsCount = ServerStatistics::where('server_id', $this->server->id) + ->where('is_automatic', false) + ->count(); + + if ($manualStatsCount >= 5) { + $this->notify('Maximum limit of 5 manual statistics reached. Please delete an existing manual statistic before creating a new one.', 'error'); + return; + } + + $proxmox = new Proxmox; + $status = $proxmox->request( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current' + )->json()['data'] ?? null; + + if ($status) { + // Get disk I/O from rrddata (latest point) + $diskRead = 0; + $diskWrite = 0; + $diskUsed = $status['disk'] ?? 0; + $diskMax = $status['maxdisk'] ?? 0; + + try { + $rrdData = $proxmox->request( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata', + 'get', + ['timeframe' => 'hour', 'cf' => 'AVERAGE'] + )->json()['data'] ?? null; + + if ($rrdData && is_array($rrdData) && count($rrdData) > 0) { + $latest = end($rrdData); + $diskRead = (int)($latest['diskread'] ?? 0); + $diskWrite = (int)($latest['diskwrite'] ?? 0); + } + } catch (\Throwable $e) { + // If rrddata fails, continue with 0 values + } + + // Always try to get diskmax from VM config (more reliable than status endpoint) + // The status endpoint may not return maxdisk, especially if guest agent is not enabled + try { + $config = $proxmox->request( + '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' + )->json()['data'] ?? null; + + if ($config) { + // Try to find disk configuration (scsi0, virtio0, sata0, ide0, scsi1, etc.) + $diskKeys = ['scsi0', 'scsi1', 'scsi2', 'virtio0', 'virtio1', 'virtio2', 'sata0', 'sata1', 'sata2', 'ide0', 'ide1', 'ide2']; + $totalDiskSize = 0; + + foreach ($diskKeys as $diskKey) { + if (isset($config[$diskKey])) { + // Parse disk size from format like "local-lvm:vm-100-disk-0,size=32G" + $diskConfig = $config[$diskKey]; + if (preg_match('/size=(\d+)([TGMK]?)/i', $diskConfig, $matches)) { + $size = (int)$matches[1]; + $unit = strtoupper($matches[2] ?? 'G'); + + // Convert to bytes + switch ($unit) { + case 'T': + $totalDiskSize += $size * 1024 * 1024 * 1024 * 1024; + break; + case 'G': + $totalDiskSize += $size * 1024 * 1024 * 1024; + break; + case 'M': + $totalDiskSize += $size * 1024 * 1024; + break; + case 'K': + $totalDiskSize += $size * 1024; + break; + default: + $totalDiskSize += $size * 1024 * 1024 * 1024; // Default to GB + } + } + } + } + + // Use config disk size if we found it, otherwise keep status value + if ($totalDiskSize > 0) { + $diskMax = $totalDiskSize; + } + } + } catch (\Throwable $e) { + // If config fetch fails, continue with status value + } + + $statistic = ServerStatistics::create([ + 'server_id' => $this->server->id, + 'netin' => $status['netin'] ?? 0, + 'netout' => $status['netout'] ?? 0, + 'diskread' => $diskRead, + 'diskwrite' => $diskWrite, + 'diskused' => $diskUsed, + 'diskmax' => $diskMax, + 'memoryused' => $status['mem'] ?? 0, + 'memorymax' => $status['maxmem'] ?? 0, + 'cpuused' => $status['cpu'] ?? 0, + 'cpumax' => 1.0, // CPU max is typically 1.0 (100%) + 'uptime' => $status['uptime'] ?? 0, + 'vmstatus' => $status['status'] ?? null, + 'DateTime' => now(), + 'is_automatic' => false, // Manual statistics are not automatic + ]); + + // Auto-select the newly created statistic + $this->selectedStatisticId = $statistic->id; + $this->dispatch('statistic-selected'); + + $this->notify('Statistics created successfully', 'success'); + } else { + $this->notify('Failed to retrieve server status from Proxmox', 'error'); + } + } catch (\Throwable $e) { + $this->notify('Error creating statistics: ' . $e->getMessage(), 'error'); + } + } + + public function getSelectedStatisticProperty() + { + if (!$this->selectedStatisticId) { + return null; + } + + return ServerStatistics::where('id', $this->selectedStatisticId) + ->where('server_id', $this->server->id) + ->first(); + } + + public function getStatisticsProperty() + { + return ServerStatistics::where('server_id', $this->server->id) + ->orderBy('DateTime', 'desc') + ->paginate($this->statisticsPerPage); + } + + public function deleteStatistic($statisticId) + { + if (!$statisticId) { + return; + } + + try { + $statistic = ServerStatistics::where('id', $statisticId) + ->where('server_id', $this->server->id) + ->first(); + + if (!$statistic) { + $this->notify('Statistics entry not found', 'error'); + return; + } + + // Clear selection if this is the selected statistic + if ($this->selectedStatisticId == $statisticId) { + $this->selectedStatisticId = null; + } + + $statistic->delete(); + $this->notify('Statistics entry deleted successfully', 'success'); + } catch (\Throwable $e) { + $this->notify('Error deleting statistics: ' . $e->getMessage(), 'error'); + } + } + + public function confirmDeleteStatistic() + { + if (!$this->statisticToDeleteId) { + return; + } + + try { + $statistic = ServerStatistics::where('id', $this->statisticToDeleteId) + ->where('server_id', $this->server->id) + ->first(); + + if (!$statistic) { + $this->notify('Statistics entry not found', 'error'); + $this->closeDeleteModal(); + return; + } + + // Clear selection if this is the selected statistic + if ($this->selectedStatisticId == $this->statisticToDeleteId) { + $this->selectedStatisticId = null; + } + + $statistic->delete(); + $this->notify('Statistics entry deleted successfully', 'success'); + $this->closeDeleteModal(); + } catch (\Throwable $e) { + $this->notify('Error deleting statistics: ' . $e->getMessage(), 'error'); + $this->closeDeleteModal(); + } + } + + public function openClearStatisticsModal(): void + { + $this->showClearStatisticsModal = true; + } + + public function closeClearStatisticsModal(): void + { + $this->showClearStatisticsModal = false; + } + + public function clearStatisticsHistory() + { + try { + ServerStatistics::where('server_id', $this->server->id)->delete(); + $this->selectedStatisticId = null; + $this->statisticToDeleteId = null; + $this->showDeleteModal = false; + $this->showClearStatisticsModal = false; + $this->notify('Statistics history cleared successfully', 'success'); + } catch (\Throwable $e) { + $this->notify('Error clearing statistics history: ' . $e->getMessage(), 'error'); + } + } + + public function openClearTaskHistoryModal(): void + { + $this->showClearTaskHistoryModal = true; + } + + public function closeClearTaskHistoryModal(): void + { + $this->showClearTaskHistoryModal = false; + } + + public function clearTaskHistory() + { + try { + // Clear any cached task history + $cacheKey = 'proxmox_task_history_' . $this->server->id; + Cache::forget($cacheKey); + + // Clear expanded task upids + $this->expandedTaskUpids = []; + + $this->showClearTaskHistoryModal = false; + $this->notify('Task history cache cleared successfully', 'success'); + } catch (\Throwable $e) { + $this->notify('Error clearing task history: ' . $e->getMessage(), 'error'); + } + } + + public function getStatisticToDeleteProperty() + { + if (!$this->statisticToDeleteId) { + return null; + } + + return ServerStatistics::where('id', $this->statisticToDeleteId) + ->where('server_id', $this->server->id) + ->first(); + } + + public function getCurrentNetworkStats($statistic) + { + // Get the previous statistic to calculate the difference + $previous = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<', $statistic->DateTime) + ->orderBy('DateTime', 'desc') + ->first(); + + if (!$previous) { + // No previous statistic, return current values as 0 + return [ + 'netin' => 0, + 'netout' => 0, + 'netin_rate' => 0, + 'netout_rate' => 0, + ]; + } + + // Calculate the difference + $netinDiff = max(0, $statistic->netin - $previous->netin); + $netoutDiff = max(0, $statistic->netout - $previous->netout); + + // Calculate time difference in seconds + $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); + if ($timeDiff == 0) { + $timeDiff = 1; // Avoid division by zero + } + + // Calculate rate (bytes per second) + $netinRate = $netinDiff / $timeDiff; + $netoutRate = $netoutDiff / $timeDiff; + + return [ + 'netin' => $netinDiff, + 'netout' => $netoutDiff, + 'netin_rate' => $netinRate, + 'netout_rate' => $netoutRate, + ]; + } + + public function getCurrentDiskStats($statistic) + { + // Get the previous statistic to calculate the difference + $previous = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<', $statistic->DateTime) + ->orderBy('DateTime', 'desc') + ->first(); + + if (!$previous) { + // No previous statistic, return current values as 0 + return [ + 'diskread' => 0, + 'diskwrite' => 0, + 'diskread_rate' => 0, + 'diskwrite_rate' => 0, + ]; + } + + // Calculate the difference (handle null values) + $currentDiskread = $statistic->diskread ?? 0; + $previousDiskread = $previous->diskread ?? 0; + $currentDiskwrite = $statistic->diskwrite ?? 0; + $previousDiskwrite = $previous->diskwrite ?? 0; + + $diskreadDiff = max(0, $currentDiskread - $previousDiskread); + $diskwriteDiff = max(0, $currentDiskwrite - $previousDiskwrite); + + // Calculate time difference in seconds + $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); + if ($timeDiff == 0) { + $timeDiff = 1; // Avoid division by zero + } + + // Calculate rate (bytes per second) + $diskreadRate = $diskreadDiff / $timeDiff; + $diskwriteRate = $diskwriteDiff / $timeDiff; + + return [ + 'diskread' => $diskreadDiff, + 'diskwrite' => $diskwriteDiff, + 'diskread_rate' => $diskreadRate, + 'diskwrite_rate' => $diskwriteRate, + ]; + } + + public function formatUptime($seconds) + { + if ($seconds < 60) { + return $seconds . 's'; + } elseif ($seconds < 3600) { + return floor($seconds / 60) . 'm ' . ($seconds % 60) . 's'; + } elseif ($seconds < 86400) { + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + return $hours . 'h ' . $minutes . 'm'; + } else { + $days = floor($seconds / 86400); + $hours = floor(($seconds % 86400) / 3600); + return $days . 'd ' . $hours . 'h'; + } + } + + public function getLatestStatProperty() + { + return ServerStatistics::where('server_id', $this->server->id) + ->orderBy('DateTime', 'desc') + ->first(); + } + + public function getTaskHistoryProperty() + { + $cacheKey = 'proxmox_task_history_' . $this->server->id; + + try { + return Cache::remember($cacheKey, 30, function () { + return $this->fetchTaskHistoryFromApi(); + }); + } catch (\Throwable $e) { + \Log::error('Error fetching task history for server ' . $this->server->id . ': ' . $e->getMessage()); + return []; + } + } + + /** + * Fetch task history directly from the Proxmox API. + * Separated from the computed property so Cache::remember can wrap it. + */ + protected function fetchTaskHistoryFromApi(): array + { + $proxmox = new Proxmox; + $vmId = (string)$this->server->vm_id; + $allTasks = []; + + // Try cluster/tasks endpoint first (includes all tasks across nodes) + // Note: cluster/tasks doesn't support vmid filter, so we'll filter manually + try { + $clusterResponse = $proxmox->request('/cluster/tasks', 'get', []); + $clusterTasks = $clusterResponse->json()['data'] ?? []; + if (is_array($clusterTasks)) { + $allTasks = array_merge($allTasks, $clusterTasks); + } + } catch (\Throwable $e) { + // Cluster endpoint might not be available, continue with node endpoint + \Log::debug('Cluster tasks endpoint failed: ' . $e->getMessage()); + } + + // Also try node-specific tasks endpoint with vmid filter + try { + $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', [ + 'vmid' => (int)$vmId, // Filter by VM ID + ]); + $nodeTasks = $nodeResponse->json()['data'] ?? []; + if (is_array($nodeTasks)) { + $allTasks = array_merge($allTasks, $nodeTasks); + } + } catch (\Throwable $e) { + // Node endpoint might fail, try without vmid filter as fallback + try { + $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', []); + $nodeTasks = $nodeResponse->json()['data'] ?? []; + if (is_array($nodeTasks)) { + $allTasks = array_merge($allTasks, $nodeTasks); + } + } catch (\Throwable $e2) { + \Log::debug('Node tasks endpoint failed: ' . $e2->getMessage()); + } + } + + // Remove duplicates based on UPID + $uniqueTasks = []; + $seenUpids = []; + foreach ($allTasks as $task) { + $upid = $task['upid'] ?? $task['id'] ?? null; + if ($upid && !in_array($upid, $seenUpids)) { + $seenUpids[] = $upid; + $uniqueTasks[] = $task; + } elseif (!$upid) { + // If no UPID, include it anyway + $uniqueTasks[] = $task; + } + } + $allTasks = $uniqueTasks; + + // If no tasks, return empty + if (empty($allTasks)) { + return []; + } + + // Filter tasks for this VM + // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid + // The VMID is the last component after the last colon + $tasks = []; + + foreach ($allTasks as $task) { + $isVmTask = false; + + // Check if task has vmid field directly (as integer or string) + if (isset($task['vmid'])) { + $taskVmid = (string)$task['vmid']; + if ($taskVmid === $vmId) { + $isVmTask = true; + } + } + + // Check UPID format - VMID is the last component + // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid + if (!$isVmTask && isset($task['upid']) && is_string($task['upid'])) { + $upid = $task['upid']; + // Extract VMID from UPID - it's the last part after the last colon + $lastColonPos = strrpos($upid, ':'); + if ($lastColonPos !== false) { + $taskVmid = substr($upid, $lastColonPos + 1); + if ($taskVmid === $vmId) { + $isVmTask = true; + } + } + + // Also try parsing by splitting (in case format is different) + if (!$isVmTask) { + $parts = explode(':', $upid); + if (count($parts) >= 8) { + // Last part (index count-1) is the vmid + $taskVmid = end($parts); + if ($taskVmid === $vmId) { + $isVmTask = true; + } + } + } + } + + // Check if task id contains the vmid + if (!$isVmTask && isset($task['id'])) { + $taskId = (string)$task['id']; + // Check if ID ends with the VMID or contains it + if (str_ends_with($taskId, ':' . $vmId) || str_contains($taskId, ':' . $vmId . ':') || $taskId === $vmId) { + $isVmTask = true; + } + } + + // Check if node matches and type is qemu (VM-related task) + if (!$isVmTask && isset($task['node']) && $task['node'] === $this->server->node->name) { + $taskType = strtolower($task['type'] ?? ''); + // QEMU tasks are VM-related + if ($taskType === 'qemu' || $taskType === 'qm') { + // For qemu tasks, check UPID for vmid + if (isset($task['upid']) && is_string($task['upid'])) { + $lastColonPos = strrpos($task['upid'], ':'); + if ($lastColonPos !== false) { + $taskVmid = substr($task['upid'], $lastColonPos + 1); + if ($taskVmid === $vmId) { + $isVmTask = true; + } + } + } + } + } + + if ($isVmTask) { + $tasks[] = $task; + } + } + + // Sort by starttime descending (newest first) + usort($tasks, function($a, $b) { + $timeA = $a['starttime'] ?? 0; + $timeB = $b['starttime'] ?? 0; + return $timeB <=> $timeA; + }); + + // Log for debugging if no tasks found + if (empty($tasks)) { + if (!empty($allTasks)) { + \Log::warning('Task history: Found ' . count($allTasks) . ' total tasks, but none matched VM ID ' . $vmId . ' for server ' . $this->server->id, [ + 'vm_id' => $vmId, + 'node' => $this->server->node->name ?? 'N/A', + 'sample_tasks' => array_map(function($task) { + return [ + 'type' => $task['type'] ?? 'N/A', + 'vmid' => $task['vmid'] ?? 'N/A', + 'upid' => $task['upid'] ?? 'N/A', + 'node' => $task['node'] ?? 'N/A', + ]; + }, array_slice($allTasks, 0, 5)), + ]); + } else { + \Log::info('Task history: No tasks found from API for server ' . $this->server->id . ' (VM ID: ' . $vmId . ')'); + } + } else { + \Log::debug('Task history: Found ' . count($tasks) . ' tasks for VM ID ' . $vmId); + } + + // Limit to last 100 tasks + return array_slice($tasks, 0, 100); + } + + public function getTaskDetails($taskId) + { + try { + $proxmox = new Proxmox; + // Use the /log endpoint to get task log details + $logResponse = $proxmox->request( + '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/log' + ); + + $logData = $logResponse->json()['data'] ?? null; + + // Also get status for additional information + $statusData = null; + try { + $statusResponse = $proxmox->request( + '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/status' + ); + $statusData = $statusResponse->json()['data'] ?? null; + } catch (\Throwable $e) { + // Status endpoint might fail, continue with log data only + } + + // Combine log and status data + $taskDetails = []; + if ($logData) { + $taskDetails['log'] = $logData; + } + if ($statusData) { + $taskDetails = array_merge($taskDetails, $statusData); + } + + if (!empty($taskDetails)) { + // Sanitize the task details + $taskDetails = $this->sanitizeTaskData($taskDetails); + } + + return $taskDetails; + } catch (\Throwable $e) { + \Log::error('Error fetching task details: ' . $e->getMessage()); + return null; + } + } + + public function sanitizeTaskData($data) + { + if (is_array($data)) { + $sanitized = []; + foreach ($data as $key => $value) { + $keyLower = strtolower($key); + // Format log output to be human readable + if ($keyLower === 'log' && is_array($value)) { + $sanitized[$key] = $this->formatTaskLog($value); + continue; + } + // Sanitize username fields + if (in_array($keyLower, ['user', 'username'])) { + $sanitized[$key] = '[REDACTED]'; + } + // Sanitize VMID if it matches this server's VMID + elseif ($keyLower === 'vmid' && $value == $this->server->vm_id) { + $sanitized[$key] = '[REDACTED]'; + } + // Recursively sanitize nested arrays + elseif (is_array($value)) { + $sanitized[$key] = $this->sanitizeTaskData($value); + } + // Sanitize VMID in string paths + elseif (is_string($value) && preg_match('/\/qemu\/(\d+)/', $value)) { + $sanitized[$key] = preg_replace('/\/qemu\/\d+/', '/qemu/[REDACTED]', $value); + } + // Sanitize VMID in UPID format (UPID:node:type:pid:user:starttime:extratype:vmid) + elseif (is_string($value) && preg_match('/^UPID:([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):(\d+)$/', $value, $matches)) { + $sanitized[$key] = preg_replace('/:(\d+)$/', ':[REDACTED]', $value); + } + else { + $sanitized[$key] = $value; + } + } + return $sanitized; + } + return $data; + } + + protected function formatTaskLog(array $entries): string + { + $lines = []; + foreach ($entries as $entry) { + if (is_array($entry)) { + $text = isset($entry['t']) ? trim((string) $entry['t']) : trim(json_encode($entry)); + if ($text === '') { + $text = '[no output]'; + } + $lines[] = $text; + } else { + $lines[] = trim((string) $entry); + } + } + + return implode(PHP_EOL, $lines); + } + + public function getChartDataProperty() + { + // Get last 50 statistics for chart display + $stats = ServerStatistics::where('server_id', $this->server->id) + ->orderBy('DateTime', 'asc') + ->limit(50) + ->get(); + + $data = [ + 'dates' => [], + 'cpu' => [], + 'memory' => [], + 'disk' => [], + 'networkIn' => [], + 'networkOut' => [], + ]; + + $previousStat = null; + foreach ($stats as $stat) { + $data['dates'][] = $stat->DateTime->format('M d H:i'); + $data['cpu'][] = round($stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0, 2); + $data['memory'][] = round($stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0, 2); + $data['disk'][] = round(($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0, 2); + + // Calculate network rates + if ($previousStat) { + $netinDiff = max(0, $stat->netin - $previousStat->netin); + $netoutDiff = max(0, $stat->netout - $previousStat->netout); + $timeDiff = $stat->DateTime->diffInSeconds($previousStat->DateTime); + if ($timeDiff == 0) $timeDiff = 1; + $data['networkIn'][] = round($netinDiff / $timeDiff, 2); + $data['networkOut'][] = round($netoutDiff / $timeDiff, 2); + } else { + $data['networkIn'][] = 0; + $data['networkOut'][] = 0; + } + + $previousStat = $stat; + } + + return $data; + } + + public function expandTask($upid) + { + if (!in_array($upid, $this->expandedTaskUpids)) { + $this->expandedTaskUpids[] = $upid; + } + } + + public function collapseTask($upid) + { + $this->expandedTaskUpids = array_values(array_filter($this->expandedTaskUpids, function($id) use ($upid) { + return $id !== $upid; + })); + } + + public function getHistoricalComparisonProperty() + { + try { + // Get current period statistics with its previous record (for rate calculation) + $currentStats = $this->getStatisticsForPeriodWithPrevious($this->currentPeriod, $this->currentCustomDate); + + // Get historical period statistics with its previous record + $historicalStats = $this->getStatisticsForPeriodWithPrevious($this->historicalPeriod, $this->historicalCustomDate); + + // Check if we have both current and historical stats + if (!$currentStats || !$currentStats['stat']) { + return [ + 'error' => 'current', + 'message' => 'No statistics found for the selected current period (' . $this->currentPeriod . ').', + 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), + ]; + } + + if (!$historicalStats || !$historicalStats['stat']) { + return [ + 'error' => 'historical', + 'message' => 'No statistics found for the selected historical period (' . $this->historicalPeriod . '). Try selecting a different time period.', + 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), + ]; + } + + $current = $currentStats['stat']; + $historical = $historicalStats['stat']; + $currentPrevious = $currentStats['previous']; + $historicalPrevious = $historicalStats['previous']; + + // Calculate differences and percentages + $comparison = [ + 'current' => $current, + 'historical' => $historical, + 'differences' => [ + 'cpu' => $this->calculateDifference($current->cpuused, $current->cpumax, $historical->cpuused, $historical->cpumax), + 'memory' => $this->calculateDifference($current->memoryused, $current->memorymax, $historical->memoryused, $historical->memorymax), + 'disk' => $this->calculateDifference($current->diskused ?? 0, $current->diskmax ?? 0, $historical->diskused ?? 0, $historical->diskmax ?? 0), + 'network_in' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netin'), + 'network_out' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netout'), + 'disk_read' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskread'), + 'disk_write' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskwrite'), + ], + ]; + + return $comparison; + } catch (\Throwable $e) { + \Log::error('Error getting historical comparison: ' . $e->getMessage()); + return [ + 'error' => 'exception', + 'message' => 'An error occurred while loading comparison data: ' . $e->getMessage(), + ]; + } + } + + /** + * Get statistics for a period along with the previous record (for rate calculation) + * This reduces queries from 10+ to just 4 total + */ + protected function getStatisticsForPeriodWithPrevious(string $period, ?string $customDate = null): ?array + { + $stat = $this->getStatisticsForPeriod($period, $customDate); + + if (!$stat) { + return null; + } + + // Get the previous record for rate calculation + $previous = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<', $stat->DateTime) + ->orderBy('DateTime', 'desc') + ->first(); + + return [ + 'stat' => $stat, + 'previous' => $previous, + ]; + } + + protected function getStatisticsForPeriod(string $period, ?string $customDate = null) + { + $now = now(); + $targetTime = null; + + switch ($period) { + case 'now': + // Get the most recent statistic + return ServerStatistics::where('server_id', $this->server->id) + ->orderBy('DateTime', 'desc') + ->first(); + + case '1_hour_ago': + $targetTime = $now->copy()->subHour(); + break; + + case '2_hours_ago': + $targetTime = $now->copy()->subHours(2); + break; + + case '6_hours_ago': + $targetTime = $now->copy()->subHours(6); + break; + + case '12_hours_ago': + $targetTime = $now->copy()->subHours(12); + break; + + case '24_hours_ago': + $targetTime = $now->copy()->subDay(); + break; + + case 'custom': + if ($customDate) { + try { + $targetTime = \Carbon\Carbon::parse($customDate); + } catch (\Exception $e) { + return null; + } + } else { + return null; + } + break; + + default: + return null; + } + + if ($targetTime) { + // Find the statistic closest to the target time (within 90 minutes) + // Using 90 minutes to ensure we catch hourly statistics even with timing variations + $minTime = $targetTime->copy()->subMinutes(90); + $maxTime = $targetTime->copy()->addMinutes(90); + + $stats = ServerStatistics::where('server_id', $this->server->id) + ->whereBetween('DateTime', [$minTime, $maxTime]) + ->orderBy('DateTime', 'asc') + ->get(); + + if ($stats->isEmpty()) { + // Fallback: get the closest stat before or after target time + $closestBefore = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<=', $targetTime) + ->orderBy('DateTime', 'desc') + ->first(); + + $closestAfter = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '>=', $targetTime) + ->orderBy('DateTime', 'asc') + ->first(); + + if (!$closestBefore && !$closestAfter) { + return null; + } + + if (!$closestBefore) return $closestAfter; + if (!$closestAfter) return $closestBefore; + + // Return whichever is closer + $diffBefore = abs($closestBefore->DateTime->diffInSeconds($targetTime)); + $diffAfter = abs($closestAfter->DateTime->diffInSeconds($targetTime)); + + return $diffBefore <= $diffAfter ? $closestBefore : $closestAfter; + } + + // Find the closest one to target time (database-agnostic) + $closest = null; + $smallestDiff = PHP_INT_MAX; + + foreach ($stats as $stat) { + $diff = abs($stat->DateTime->diffInSeconds($targetTime)); + if ($diff < $smallestDiff) { + $smallestDiff = $diff; + $closest = $stat; + } + } + + return $closest; + } + + return null; + } + + protected function calculateDifference($currentValue, $currentMax, $historicalValue, $historicalMax) + { + $currentPercent = $currentMax > 0 ? ($currentValue / $currentMax * 100) : 0; + $historicalPercent = $historicalMax > 0 ? ($historicalValue / $historicalMax * 100) : 0; + + $absoluteDiff = $currentValue - $historicalValue; + $percentDiff = $currentPercent - $historicalPercent; + $percentChange = $historicalPercent > 0 ? (($currentPercent - $historicalPercent) / $historicalPercent * 100) : 0; + + return [ + 'current_value' => $currentValue, + 'current_percent' => round($currentPercent, 2), + 'historical_value' => $historicalValue, + 'historical_percent' => round($historicalPercent, 2), + 'absolute_diff' => $absoluteDiff, + 'percent_diff' => round($percentDiff, 2), + 'percent_change' => round($percentChange, 2), + ]; + } + + /** + * Calculate rate difference using pre-fetched previous stats (optimized) + * This eliminates N+1 queries - previous stats are fetched once per period + */ + protected function calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field) + { + $currentRate = 0; + $historicalRate = 0; + + if ($currentPrevious && $currentStat) { + $timeDiff = $currentStat->DateTime->diffInSeconds($currentPrevious->DateTime); + if ($timeDiff > 0) { + $currentDiff = max(0, ($currentStat->$field ?? 0) - ($currentPrevious->$field ?? 0)); + $currentRate = $currentDiff / $timeDiff; + } + } + + if ($historicalPrevious && $historicalStat) { + $timeDiff = $historicalStat->DateTime->diffInSeconds($historicalPrevious->DateTime); + if ($timeDiff > 0) { + $historicalDiff = max(0, ($historicalStat->$field ?? 0) - ($historicalPrevious->$field ?? 0)); + $historicalRate = $historicalDiff / $timeDiff; + } + } + + $absoluteDiff = $currentRate - $historicalRate; + $percentChange = $historicalRate > 0 ? (($currentRate - $historicalRate) / $historicalRate * 100) : ($currentRate > 0 ? 100 : 0); + + return [ + 'current_rate' => $currentRate, + 'historical_rate' => $historicalRate, + 'absolute_diff' => $absoluteDiff, + 'percent_change' => round($percentChange, 2), + ]; + } + + /** + * @deprecated Use calculateRateDifferenceOptimized instead + */ + protected function calculateRateDifference($currentStat, $historicalStat, $field) + { + // Get previous stats to calculate rates + $currentPrevious = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<', $currentStat->DateTime) + ->orderBy('DateTime', 'desc') + ->first(); + + $historicalPrevious = ServerStatistics::where('server_id', $this->server->id) + ->where('DateTime', '<', $historicalStat->DateTime) + ->orderBy('DateTime', 'desc') + ->first(); + + return $this->calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field); + } + + public function getHistoricalComparisonExportDataProperty() + { + $comparison = $this->historicalComparison; + if (!$comparison || isset($comparison['error'])) { + return []; + } + + $current = $comparison['current'] ?? null; + $historical = $comparison['historical'] ?? null; + $diff = $comparison['differences'] ?? null; + + if (!$current || !$historical || !$diff) { + return []; + } + + $historicalDateTime = $historical->DateTime->format('Y-m-d H:i:s'); + $currentDateTime = $current->DateTime->format('Y-m-d H:i:s'); + + return [ + [ + 'metric' => 'CPU Usage', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => round($diff['cpu']['current_percent'], 2) . '%', + 'historical_value' => round($diff['cpu']['historical_percent'], 2) . '%', + 'difference' => round($diff['cpu']['percent_diff'], 2) . '%', + 'percent_change' => round($diff['cpu']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Memory Usage', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => round($diff['memory']['current_percent'], 2) . '%', + 'historical_value' => round($diff['memory']['historical_percent'], 2) . '%', + 'difference' => round($diff['memory']['percent_diff'], 2) . '%', + 'percent_change' => round($diff['memory']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Disk Usage', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => round($diff['disk']['current_percent'], 2) . '%', + 'historical_value' => round($diff['disk']['historical_percent'], 2) . '%', + 'difference' => round($diff['disk']['percent_diff'], 2) . '%', + 'percent_change' => round($diff['disk']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Network In Rate', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => $this->formatBytes($diff['network_in']['current_rate']) . '/s', + 'historical_value' => $this->formatBytes($diff['network_in']['historical_rate']) . '/s', + 'difference' => $this->formatBytes($diff['network_in']['absolute_diff']) . '/s', + 'percent_change' => round($diff['network_in']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Network Out Rate', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => $this->formatBytes($diff['network_out']['current_rate']) . '/s', + 'historical_value' => $this->formatBytes($diff['network_out']['historical_rate']) . '/s', + 'difference' => $this->formatBytes($diff['network_out']['absolute_diff']) . '/s', + 'percent_change' => round($diff['network_out']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Disk Read Rate', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => $this->formatBytes($diff['disk_read']['current_rate']) . '/s', + 'historical_value' => $this->formatBytes($diff['disk_read']['historical_rate']) . '/s', + 'difference' => $this->formatBytes($diff['disk_read']['absolute_diff']) . '/s', + 'percent_change' => round($diff['disk_read']['percent_change'], 2) . '%', + ], + [ + 'metric' => 'Disk Write Rate', + 'historical_datetime' => $historicalDateTime, + 'current_datetime' => $currentDateTime, + 'current_value' => $this->formatBytes($diff['disk_write']['current_rate']) . '/s', + 'historical_value' => $this->formatBytes($diff['disk_write']['historical_rate']) . '/s', + 'difference' => $this->formatBytes($diff['disk_write']['absolute_diff']) . '/s', + 'percent_change' => round($diff['disk_write']['percent_change'], 2) . '%', + ], + ]; + } + + public function exportHistoricalComparison() + { + $data = $this->historicalComparisonExportData; + if (empty($data)) { + $this->showExportInfo('No historical comparison data available to export. Please ensure comparison data is displayed before exporting.'); + return; + } + + $csv = 'Metric,Historical Date/Time,Current Date/Time,Historical Value,Current Value,Difference,Percent Change' . PHP_EOL; + + foreach ($data as $row) { + $csv .= sprintf( + '"%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, + str_replace('"', '""', $row['metric'] ?? ''), + str_replace('"', '""', $row['historical_datetime'] ?? ''), + str_replace('"', '""', $row['current_datetime'] ?? ''), + str_replace('"', '""', $row['historical_value'] ?? ''), + str_replace('"', '""', $row['current_value'] ?? ''), + str_replace('"', '""', $row['difference'] ?? ''), + str_replace('"', '""', $row['percent_change'] ?? '') + ); + } + + $filename = 'historical-comparison-' . date('Y-m-d') . '.csv'; + + return response()->streamDownload(function () use ($csv) { + echo $csv; + }, $filename, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function exportNetworkAnalysis() + { + $cacheKey = 'proxmox_network_analysis_' . $this->server->id; + $cachedAnalysis = Cache::get($cacheKey, []); + $connections = $cachedAnalysis['connections'] ?? []; + + if (empty($connections)) { + $this->showExportInfo('No network analysis data available to export. Please refresh the network analysis first.'); + return; + } + + $csv = 'Direction,Protocol,Source,Source Port,Destination,Destination Port,State,Bytes,Packets,Bytes (Human)' . PHP_EOL; + + foreach ($connections as $connection) { + $csv .= sprintf( + '"%s","%s","%s","%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, + str_replace('"', '""', $connection['direction'] ?? ''), + str_replace('"', '""', $connection['protocol'] ?? ''), + str_replace('"', '""', $connection['source'] ?? ''), + str_replace('"', '""', (string)($connection['source_port'] ?? '')), + str_replace('"', '""', $connection['destination'] ?? ''), + str_replace('"', '""', (string)($connection['destination_port'] ?? '')), + str_replace('"', '""', $connection['state'] ?? ''), + str_replace('"', '""', (string)($connection['bytes'] ?? 0)), + str_replace('"', '""', (string)($connection['packets'] ?? 0)), + str_replace('"', '""', $connection['bytes_human'] ?? '') + ); + } + + $filename = 'network-analysis-' . date('Y-m-d_His') . '.csv'; + + return response()->streamDownload(function () use ($csv) { + echo $csv; + }, $filename, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + public function formatBytes(int|float $bytes): string + { + if ($bytes == 0) return '0 B'; + $prefix = ''; + if ($bytes < 0) { + $prefix = '-'; + $bytes = abs($bytes); + } + $k = 1024; + $sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = floor(log($bytes) / log($k)); + if ($i < 0) $i = 0; + if ($i >= count($sizes)) $i = count($sizes) - 1; + return $prefix . round($bytes / pow($k, $i), 2) . ' ' . $sizes[$i]; + } + + public function getGraphData(string $type): array + { + $fullData = $this->graphPayloads[$type] ?? []; + + // Filter data to only return essential fields for this chart type to reduce response size + $filteredData = []; + foreach ($fullData as $point) { + $filteredPoint = ['time' => $point['time'] ?? 0]; + + switch ($type) { + case 'cpu': + $filteredPoint['cpu'] = $point['cpu'] ?? 0; + break; + case 'memory': + $filteredPoint['mem'] = $point['mem'] ?? 0; + $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; + break; + case 'network': + $filteredPoint['netin'] = $point['netin'] ?? 0; + $filteredPoint['netout'] = $point['netout'] ?? 0; + break; + case 'disk': + $filteredPoint['diskread'] = $point['diskread'] ?? 0; + $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; + break; + } + + $filteredData[] = $filteredPoint; + } + + return $filteredData; + } + + protected function coreRouterEnabled(): bool + { + $baseUrl = config('corerouter.base_url'); + $apiKey = config('corerouter.api_key'); + $username = config('corerouter.username'); + $password = config('corerouter.password'); + + // Check if base_url is configured and not empty + $hasBaseUrl = !empty(trim((string) $baseUrl)); + + // Check if API key is configured (not empty after trimming) + $hasKey = !empty(trim((string) $apiKey)); + + // Check if basic auth credentials are both configured + $hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password)); + + // Configuration is valid if base_url is set and at least one auth method is configured + $configured = $hasBaseUrl && ($hasKey || $hasBasic); + + if (!$configured) { + Log::debug('Core Router configuration check', [ + 'base_url' => $baseUrl ?: '(empty)', + 'base_url_configured' => $hasBaseUrl, + 'api_key_present' => $hasKey, + 'username_present' => !empty(trim((string) $username)), + 'password_present' => !empty(trim((string) $password)), + 'basic_auth_configured' => $hasBasic, + 'configured' => $configured, + ]); + } + + return $configured; + } + + protected function getServerIps(): array + { + if (!empty($this->cachedServerIps)) { + return $this->cachedServerIps; + } + + $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); + + $rawValues = collect(); + + $primaryIpv4Value = $this->server->primaryIpv4?->ip ?? (is_string($this->server->primary_ipv4) ? $this->server->primary_ipv4 : null); + $primaryIpv6Value = $this->server->primaryIpv6?->ip ?? (is_string($this->server->primary_ipv6) ? $this->server->primary_ipv6 : null); + + if ($primaryIpv4Value) { + $rawValues->push($primaryIpv4Value); + } + + if ($primaryIpv6Value) { + $rawValues->push($primaryIpv6Value); + } + + $rawValues = $rawValues->merge($this->server->ipAddresses->pluck('ip')); + + $exactIps = []; + $cidrs = []; + + foreach ($rawValues->filter() as $value) { + foreach ($this->expandIpValue($value) as $parsed) { + if ($parsed['type'] === 'cidr') { + $cidrs[] = $parsed['value']; + } elseif ($parsed['type'] === 'ip') { + // Normalize IP to lowercase for consistent matching + $normalized = $this->normalizeSingleIp($parsed['value']); + if ($normalized) { + $exactIps[] = $normalized; + + // For IPv6 addresses, automatically add the /64 subnet to CIDR list + if (str_contains($normalized, ':')) { + // Extract the /64 network prefix for IPv6 + $ipBinary = @inet_pton($normalized); + if ($ipBinary !== false && strlen($ipBinary) === 16) { + // Zero out the last 64 bits (last 8 bytes) to get the /64 network + $networkBinary = substr($ipBinary, 0, 8) . str_repeat("\0", 8); + $networkIp = @inet_ntop($networkBinary); + if ($networkIp !== false) { + $cidrs[] = $networkIp . '/64'; + } + } + } + // For IPv4 addresses, CIDR ranges are only added if explicitly stored in database + // (e.g., "45.154.99.28/28" format). No auto-generation to avoid false matches. + } + } + } + } + + $this->serverIpCidrs = array_values(array_unique($cidrs)); + $this->cachedServerIps = array_values(array_unique($exactIps)); + + Log::debug('Core Router getServerIps - extracted IPs', [ + 'raw_values' => $rawValues->all(), + 'exact_ips' => $this->cachedServerIps, + 'cidrs' => $this->serverIpCidrs, + 'primary_ipv4' => $primaryIpv4Value, + 'primary_ipv6' => $primaryIpv6Value, + ]); + + return $this->cachedServerIps; + } + + + /** + * Check if a connection's source or destination matches server's assigned IPs + * Used for visual filtering in the blade template + */ + public function isConnectionRelevant(string $source, string $destination): bool + { + $serverIps = $this->getServerIps(); + if (empty($serverIps)) { + return false; + } + + // Normalize for comparison + $sourceNormalized = strtolower(trim($source)); + $destinationNormalized = strtolower(trim($destination)); + + // Check exact matches + foreach ($serverIps as $serverIp) { + $serverIpNormalized = strtolower(trim($serverIp)); + if ($sourceNormalized === $serverIpNormalized || $destinationNormalized === $serverIpNormalized) { + return true; + } + } + + return false; + } + + protected function formatConnections(array $states, array $ips): array + { + // Legacy method - redirect to simple version + return $this->formatConnectionsSimple($states); + } + + protected function aggregateTopConnections(array $connections): array + { + if (empty($connections)) { + return []; + } + + $grouped = []; + foreach ($connections as $connection) { + $key = $connection['direction'] === 'Outbound' + ? $connection['destination'] . ':' . ($connection['destination_port'] ?? '0') + : $connection['source'] . ':' . ($connection['source_port'] ?? '0'); + + if (!isset($grouped[$key])) { + $grouped[$key] = [ + 'endpoint' => $connection['direction'] === 'Outbound' + ? $connection['destination'] + : $connection['source'], + 'port' => $connection['direction'] === 'Outbound' + ? $connection['destination_port'] + : $connection['source_port'], + 'bytes' => 0, + 'packets' => 0, + 'count' => 0, + ]; + } + + $grouped[$key]['bytes'] += $connection['bytes']; + $grouped[$key]['packets'] += $connection['packets']; + $grouped[$key]['count']++; + } + + $results = array_map(function ($item) { + $item['bytes_human'] = $this->formatBytes($item['bytes']); + + return $item; + }, array_values($grouped)); + + usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); + + return $results; + } + + protected function aggregateTopPorts(array $connections): array + { + if (empty($connections)) { + return []; + } + + $grouped = []; + foreach ($connections as $connection) { + $port = $connection['destination_port'] ?? $connection['source_port'] ?? null; + if (!$port) { + continue; + } + + if (!isset($grouped[$port])) { + $grouped[$port] = [ + 'port' => $port, + 'bytes' => 0, + 'packets' => 0, + 'connections' => 0, + ]; + } + + $grouped[$port]['bytes'] += $connection['bytes']; + $grouped[$port]['packets'] += $connection['packets']; + $grouped[$port]['connections']++; + } + + $results = array_map(function ($item) { + $item['bytes_human'] = $this->formatBytes($item['bytes']); + + return $item; + }, array_values($grouped)); + + usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); + + return $results; + } + + /** + * Get gateways from server's network configuration (network tab) + */ + protected function getServerGateways(): array + { + $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); + + $gateways = []; + + // Get gateways from IP addresses + foreach ($this->server->ipAddresses as $ipAddress) { + if (!empty($ipAddress->gateway)) { + $gatewayIp = trim($ipAddress->gateway); + // Avoid duplicates + if (!isset($gateways[$gatewayIp])) { + $gateways[$gatewayIp] = [ + 'ip' => $gatewayIp, + 'name' => $gatewayIp, + 'type' => $ipAddress->type ?? 'ipv4', // ipv4 or ipv6 + 'associated_ip' => $ipAddress->ip, + ]; + } + } + } + + return array_values($gateways); + } + + /** + * Check connections against known malicious IP blocklists + */ + protected function checkSecurityThreats(array $connections): array + { + $warnings = []; + + if (empty($connections)) { + Log::debug('Security check: No connections to check'); + return []; + } + + // Fetch and parse blocklists + $ipv6Blocklist = $this->getIpv6Blocklist(); + $ipv4Blocklist = $this->getIpv4Blocklist(); + + Log::debug('Security check: Blocklists loaded', [ + 'ipv6_count' => count($ipv6Blocklist), + 'ipv4_count' => count($ipv4Blocklist), + 'connections_count' => count($connections), + ]); + + foreach ($connections as $connection) { + $source = $connection['source'] ?? ''; + $destination = $connection['destination'] ?? ''; + + // Check source IP + $sourceThreat = $this->checkIpAgainstBlocklist($source, $ipv4Blocklist, $ipv6Blocklist); + if ($sourceThreat) { + // For source threats (malicious IP attacking us), analyze the destination port (port being targeted) + // Always use destination_port as that's the service port being targeted, not the ephemeral source port + $threatPort = $connection['destination_port'] ?? null; + // If destination_port is null, try to extract from raw destination field + if ($threatPort === null && isset($connection['raw_destination'])) { + $threatPort = $this->extractPortFromField($connection['raw_destination']); + } + // NEVER use source_port for attack analysis - it's just an ephemeral port + // Log for debugging + Log::debug('Security threat detected: Source IP - Port Analysis', [ + 'ip' => $source, + 'threat' => $sourceThreat, + 'destination_port' => $connection['destination_port'] ?? 'null', + 'source_port' => $connection['source_port'] ?? 'null', + 'raw_destination' => $connection['raw_destination'] ?? 'null', + 'extracted_threat_port' => $threatPort, + ]); + $riskAnalysis = $this->analyzeThreatRisk($source, $threatPort, $connection); + $warnings[] = [ + 'ip' => $source, + 'type' => 'source', + 'threat' => $sourceThreat, + 'connection' => $connection, + 'risk_level' => $riskAnalysis['risk_level'], + 'attack_type' => $riskAnalysis['attack_type'], + 'risk_description' => $riskAnalysis['risk_description'], + 'mitigations' => $riskAnalysis['mitigations'] ?? [], + ]; + Log::debug('Security threat detected: Source IP', [ + 'ip' => $source, + 'threat' => $sourceThreat, + 'risk_level' => $riskAnalysis['risk_level'], + 'attack_type' => $riskAnalysis['attack_type'], + 'threat_port' => $threatPort, + ]); + } + + // Check destination IP + $destThreat = $this->checkIpAgainstBlocklist($destination, $ipv4Blocklist, $ipv6Blocklist); + if ($destThreat) { + // For destination threats (we're connecting to malicious IP), analyze the destination port (port on malicious server) + // Always use destination_port as that's the service port on the malicious server + $threatPort = $connection['destination_port'] ?? null; + // If destination_port is null, try to extract from raw destination field + if ($threatPort === null && isset($connection['raw_destination'])) { + $threatPort = $this->extractPortFromField($connection['raw_destination']); + } + // NEVER use source_port for attack analysis - it's just an ephemeral port + $riskAnalysis = $this->analyzeThreatRisk($destination, $threatPort, $connection); + $warnings[] = [ + 'ip' => $destination, + 'type' => 'destination', + 'threat' => $destThreat, + 'connection' => $connection, + 'risk_level' => $riskAnalysis['risk_level'], + 'attack_type' => $riskAnalysis['attack_type'], + 'risk_description' => $riskAnalysis['risk_description'], + 'mitigations' => $riskAnalysis['mitigations'] ?? [], + ]; + Log::debug('Security threat detected: Destination IP', [ + 'ip' => $destination, + 'threat' => $destThreat, + 'risk_level' => $riskAnalysis['risk_level'], + 'attack_type' => $riskAnalysis['attack_type'], + ]); + } + } + + Log::debug('Security check completed', [ + 'warnings_count' => count($warnings), + ]); + + // Group similar threats together + $groupedWarnings = $this->groupSimilarThreats($warnings); + + Log::debug('Security threats grouped', [ + 'original_count' => count($warnings), + 'grouped_count' => count($groupedWarnings), + ]); + + return $groupedWarnings; + } + + /** + * Group similar security threats by source, destination, ports, and protocol + */ + protected function groupSimilarThreats(array $warnings): array + { + if (empty($warnings)) { + return []; + } + + $grouped = []; + + foreach ($warnings as $warning) { + $connection = $warning['connection'] ?? []; + $source = $connection['source'] ?? ''; + $destination = $connection['destination'] ?? ''; + $sourcePort = $connection['source_port'] ?? null; + $destinationPort = $connection['destination_port'] ?? null; + $protocol = $connection['protocol'] ?? 'N/A'; + $threatType = $warning['type'] ?? 'source'; + $threatIp = $warning['ip'] ?? ''; + + // Create a unique key based on source IP, destination IP, destination port, protocol, and threat type + // Exclude source port from grouping since it's often ephemeral and varies between connections + $key = sprintf( + '%s|%s|%s|%s|%s|%s', + $threatType, + $threatIp, + $source, + $destination, + $destinationPort ?? 'null', + $protocol + ); + + if (!isset($grouped[$key])) { + // First occurrence - use this warning as the base + $grouped[$key] = $warning; + $grouped[$key]['quantity'] = 1; + $grouped[$key]['is_repeated'] = false; + } else { + // Duplicate found - increment quantity and mark as repeated + $grouped[$key]['quantity'] = ($grouped[$key]['quantity'] ?? 1) + 1; + $grouped[$key]['is_repeated'] = true; + + // Aggregate connection data (sum bytes and packets) + $existingConn = $grouped[$key]['connection'] ?? []; + $newConn = $connection; + + $grouped[$key]['connection'] = [ + 'source' => $existingConn['source'] ?? $source, + 'destination' => $existingConn['destination'] ?? $destination, + 'source_port' => $existingConn['source_port'] ?? $sourcePort, + 'destination_port' => $existingConn['destination_port'] ?? $destinationPort, + 'protocol' => $existingConn['protocol'] ?? $protocol, + 'bytes' => ($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0), + 'packets' => ($existingConn['packets'] ?? 0) + ($newConn['packets'] ?? 0), + 'bytes_human' => $this->formatBytes(($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0)), + 'state' => $existingConn['state'] ?? $newConn['state'] ?? 'N/A', + 'direction' => $existingConn['direction'] ?? $newConn['direction'] ?? 'N/A', + 'raw_source' => $existingConn['raw_source'] ?? $newConn['raw_source'] ?? '', + 'raw_destination' => $existingConn['raw_destination'] ?? $newConn['raw_destination'] ?? '', + ]; + } + } + + // Convert back to indexed array and sort by quantity (descending), then by risk level + $result = array_values($grouped); + usort($result, function ($a, $b) { + $quantityA = $a['quantity'] ?? 1; + $quantityB = $b['quantity'] ?? 1; + + // First sort by quantity (descending) + if ($quantityA !== $quantityB) { + return $quantityB <=> $quantityA; + } + + // Then by risk level severity + $severityA = $this->getRiskSeverity($a['risk_level'] ?? 'Medium'); + $severityB = $this->getRiskSeverity($b['risk_level'] ?? 'Medium'); + + return $severityB <=> $severityA; + }); + + return $result; + } + + /** + * Check if an IP matches any entry in the blocklists + */ + protected function checkIpAgainstBlocklist(string $ip, array $ipv4Blocklist, array $ipv6Blocklist): ?string + { + if (empty($ip)) { + return null; + } + + $normalizedIp = $this->normalizeSingleIp($ip); + if ($normalizedIp === null) { + Log::debug('Security check: Failed to normalize IP', [ + 'ip' => $ip, + ]); + return null; + } + + $isIPv6 = str_contains($normalizedIp, ':'); + + if ($isIPv6) { + // Check IPv6 blocklist (CIDR ranges) + if (empty($ipv6Blocklist)) { + Log::debug('Security check: IPv6 blocklist is empty', [ + 'ip' => $normalizedIp, + ]); + return null; + } + + foreach ($ipv6Blocklist as $cidr) { + if ($this->ipInCidr($normalizedIp, $cidr)) { + Log::debug('Security check: IPv6 match found', [ + 'ip' => $normalizedIp, + 'cidr' => $cidr, + ]); + return "IPv6 address matches Spamhaus DROPv6 blocklist: {$cidr}"; + } + } + } else { + // Check IPv4 blocklist (exact IPs) + if (empty($ipv4Blocklist)) { + Log::debug('Security check: IPv4 blocklist is empty', [ + 'ip' => $normalizedIp, + ]); + return null; + } + + if (in_array($normalizedIp, $ipv4Blocklist, true)) { + Log::debug('Security check: IPv4 match found', [ + 'ip' => $normalizedIp, + ]); + return "IPv4 address matches blocklist.de malicious IP list"; + } + } + + return null; + } + + /** + * Analyze threat risk level and attack type based on port and IP + */ + protected function analyzeThreatRisk(string $ip, ?int $port, array $connection): array + { + $riskLevel = 'Medium'; + $attackType = 'Unknown Threat'; + $riskDescription = 'Malicious IP detected from blocklist'; + + // Port-based analysis + if ($port !== null) { + $portAnalysis = $this->analyzePortRisk($port, $connection); + $riskLevel = $portAnalysis['risk_level']; + $attackType = $portAnalysis['attack_type']; + $riskDescription = $portAnalysis['description']; + } + + // IP-based risk adjustment + $ipRisk = $this->analyzeIpRisk($ip); + if ($ipRisk['severity'] > $this->getRiskSeverity($riskLevel)) { + $riskLevel = $ipRisk['risk_level']; + if ($attackType === 'Unknown Threat') { + $attackType = $ipRisk['attack_type']; + } + } + + // Direction-based risk adjustment + $direction = $connection['direction'] ?? ''; + if ($direction === 'Inbound' && $riskLevel !== 'Critical') { + // Inbound threats are generally more dangerous + if ($riskLevel === 'Low') { + $riskLevel = 'Medium'; + } elseif ($riskLevel === 'Medium') { + $riskLevel = 'High'; + } + } + + // Get mitigation suggestions based on attack type and port + $mitigations = $this->getMitigationSuggestions($attackType, $port, $connection); + + return [ + 'risk_level' => $riskLevel, + 'attack_type' => $attackType, + 'risk_description' => $riskDescription, + 'mitigations' => $mitigations, + ]; + } + + /** + * Get mitigation suggestions based on attack type and port + */ + protected function getMitigationSuggestions(string $attackType, ?int $port, array $connection): array + { + $mitigations = []; + $direction = $connection['direction'] ?? ''; + $isInbound = $direction === 'Inbound'; + + // Don't provide mitigations for outbound threats (we're connecting to malicious IPs) + if (!$isInbound) { + return [ + [ + 'priority' => 'High', + 'title' => 'Review Outbound Connections', + 'description' => 'This server is initiating connections to a malicious IP. Review the application or service making these connections and ensure it is legitimate and up-to-date.', + 'action' => 'Review application logs and system processes to identify why connections are being made to this malicious IP address.', + ] + ]; + } + + // SSH Brute Force (port 22) + if ($attackType === 'SSH Brute Force' || $port === 22) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Implement fail2ban', + 'description' => 'Automatically ban IPs that fail SSH authentication multiple times. This is highly effective against brute force attacks.', + 'action' => "Install: sudo apt install fail2ban (Ubuntu/Debian) or sudo yum install fail2ban (RHEL/CentOS). Configure /etc/fail2ban/jail.local to enable sshd protection.", + ], + [ + 'priority' => 'Critical', + 'title' => 'Disable password authentication, use SSH keys only', + 'description' => 'Eliminate the attack vector by removing password-based authentication entirely.', + 'action' => 'Edit /etc/ssh/sshd_config: Set PasswordAuthentication no, PubkeyAuthentication yes. Restart SSH: sudo systemctl restart sshd', + ], + [ + 'priority' => 'High', + 'title' => 'Change SSH port', + 'description' => 'Reduce automated attack attempts by moving SSH to a non-standard port.', + 'action' => 'Edit /etc/ssh/sshd_config: Change Port 22 to Port [your chosen port]. Update firewall rules. Restart SSH: sudo systemctl restart sshd', + ], + [ + 'priority' => 'High', + 'title' => 'Disable root login', + 'description' => 'Prevent direct root login attempts via SSH.', + 'action' => 'Edit /etc/ssh/sshd_config: Set PermitRootLogin no. Restart SSH: sudo systemctl restart sshd', + ], + [ + 'priority' => 'Medium', + 'title' => 'Configure iptables/UFW rate limiting', + 'description' => 'Limit connection attempts per IP address to prevent brute force attacks.', + 'action' => 'UFW: sudo ufw limit ssh/tcp. iptables: sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh && sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP', + ], + [ + 'priority' => 'Medium', + 'title' => 'Use TCP Wrappers', + 'description' => 'Additional access control layer for SSH connections.', + 'action' => 'Edit /etc/hosts.allow and /etc/hosts.deny to restrict SSH access by IP address or hostname.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Implement two-factor authentication (2FA)', + 'description' => 'Add an extra layer of security even if credentials are compromised.', + 'action' => 'Use Google Authenticator or similar: Install libpam-google-authenticator, configure PAM for SSH.', + ], + ]; + } + + // RDP Attack (port 3389) + if ($attackType === 'RDP Attack' || $port === 3389) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Disable RDP if not needed', + 'description' => 'RDP is a high-value target for attackers. If not required, disable it completely.', + 'action' => 'Windows: Disable Remote Desktop in System Settings > Remote Desktop. Linux with xrdp: sudo systemctl disable xrdp && sudo systemctl stop xrdp', + ], + [ + 'priority' => 'Critical', + 'title' => 'Use VPN instead of exposing RDP', + 'description' => 'Never expose RDP directly to the internet. Route connections through a VPN.', + 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to RDP through VPN tunnel only.', + ], + [ + 'priority' => 'High', + 'title' => 'Implement fail2ban for RDP', + 'description' => 'Automatically ban IPs that fail RDP authentication multiple times.', + 'action' => 'Install fail2ban and configure a custom jail for RDP/xrdp. Monitor authentication logs and ban repeated failures.', + ], + [ + 'priority' => 'High', + 'title' => 'Change RDP port', + 'description' => 'Reduce automated attack attempts by moving RDP to a non-standard port.', + 'action' => 'Windows: Edit registry key HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp\\PortNumber. Linux xrdp: Edit /etc/xrdp/xrdp.ini, change port=3389', + ], + [ + 'priority' => 'High', + 'title' => 'Enable Network Level Authentication (NLA)', + 'description' => 'Require authentication before establishing RDP session.', + 'action' => 'Windows: Enable NLA in Remote Desktop settings. This adds an extra authentication layer before the session is established.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Configure firewall rules', + 'description' => 'Restrict RDP access to specific trusted IP addresses only.', + 'action' => 'UFW: sudo ufw allow from [trusted-ip] to any port 3389. iptables: sudo iptables -A INPUT -p tcp -s [trusted-ip] --dport 3389 -j ACCEPT && sudo iptables -A INPUT -p tcp --dport 3389 -j DROP', + ], + ]; + } + + // Database attacks (MySQL, PostgreSQL, SQL Server) + if (in_array($attackType, ['Database Attack', 'SQL Injection']) || in_array($port, [1433, 3306, 5432])) { + $dbType = $port === 1433 ? 'SQL Server' : ($port === 3306 ? 'MySQL' : ($port === 5432 ? 'PostgreSQL' : 'Database')); + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Block database port at firewall level', + 'description' => "Never expose {$dbType} directly to the internet. Databases should only be accessible from application servers.", + 'action' => "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP", + ], + [ + 'priority' => 'Critical', + 'title' => 'Use SSH tunnel or VPN for database access', + 'description' => 'If remote database access is needed, route it through an encrypted tunnel.', + 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:[db-port] user@server. Connect database client to localhost:[local-port]', + ], + [ + 'priority' => 'High', + 'title' => 'Bind database to localhost only', + 'description' => "Configure {$dbType} to only listen on localhost interface, not on public interfaces.", + 'action' => "MySQL: Edit my.cnf, set bind-address = 127.0.0.1. PostgreSQL: Edit postgresql.conf, set listen_addresses = 'localhost'. Restart database service.", + ], + [ + 'priority' => 'High', + 'title' => 'Use strong passwords and limit user privileges', + 'description' => 'Implement least-privilege access and strong authentication.', + 'action' => "Change default passwords. Create database users with minimal required privileges. Use {$dbType} user management to restrict access by IP.", + ], + [ + 'priority' => 'High', + 'title' => 'Implement fail2ban for database', + 'description' => 'Automatically ban IPs that fail database authentication multiple times.', + 'action' => 'Configure fail2ban to monitor database authentication logs. Create custom jail for repeated authentication failures.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Enable SSL/TLS for database connections', + 'description' => 'Encrypt database connections to prevent credential theft.', + 'action' => "Configure SSL certificates for {$dbType}. Update connection strings to require SSL. Update firewall rules accordingly.", + ], + ]; + } + + // SMB/NetBIOS attacks + if (in_array($attackType, ['SMB Exploit', 'NetBIOS Attack']) || in_array($port, [445, 139])) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Disable SMB if not needed', + 'description' => 'SMB is a common attack vector for ransomware and lateral movement. Disable if not required.', + 'action' => 'Linux: sudo systemctl disable smbd nmbd && sudo systemctl stop smbd nmbd. Windows: Disable SMB in Windows Features.', + ], + [ + 'priority' => 'High', + 'title' => 'Block SMB ports at firewall', + 'description' => 'Prevent SMB access from the internet. It should only be accessible on local network.', + 'action' => 'UFW: sudo ufw deny 445/tcp && sudo ufw deny 139/tcp. iptables: Block ports 445 and 139 from external interfaces.', + ], + [ + 'priority' => 'High', + 'title' => 'Use VPN for SMB access', + 'description' => 'If SMB access is required remotely, route it through a VPN tunnel.', + 'action' => 'Set up OpenVPN or WireGuard VPN. Only allow SMB access through VPN tunnel. Configure firewall accordingly.', + ], + [ + 'priority' => 'High', + 'title' => 'Update Samba/Windows to latest version', + 'description' => 'Older SMB versions have known vulnerabilities. Keep software updated.', + 'action' => 'Update Samba: sudo apt update && sudo apt upgrade samba (Ubuntu/Debian). Windows: Install latest security updates.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Disable SMBv1 protocol', + 'description' => 'SMBv1 is deprecated and vulnerable. Use SMBv2 or SMBv3 only.', + 'action' => 'Linux: Edit smb.conf, add min protocol = SMB2. Windows: Disable SMBv1 in Windows Features or via PowerShell: Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol', + ], + [ + 'priority' => 'Medium', + 'title' => 'Restrict SMB access by IP', + 'description' => 'Limit SMB access to specific trusted IP addresses only.', + 'action' => 'Configure firewall rules to only allow SMB connections from trusted IPs. Use hosts.allow for additional access control.', + ], + ]; + } + + // Web attacks (HTTP/HTTPS) + if ($attackType === 'Web Attack' || in_array($port, [80, 443, 8080, 8443])) { + $mitigations = [ + [ + 'priority' => 'High', + 'title' => 'Implement Web Application Firewall (WAF)', + 'description' => 'Protect against web application attacks, SQL injection, XSS, and other web-based exploits.', + 'action' => 'Use ModSecurity with Apache/Nginx, Cloudflare WAF, or AWS WAF. Configure rules to block common attack patterns.', + ], + [ + 'priority' => 'High', + 'title' => 'Configure rate limiting', + 'description' => 'Limit requests per IP to prevent DDoS and brute force attacks.', + 'action' => 'Nginx: limit_req_zone and limit_req directives. Apache: mod_evasive or mod_ratelimit. Also configure at firewall level with iptables/UFW.', + ], + [ + 'priority' => 'High', + 'title' => 'Keep web server and applications updated', + 'description' => 'Outdated software has known vulnerabilities that attackers exploit.', + 'action' => 'Regularly update web server (Apache/Nginx), PHP, and all web applications. Subscribe to security advisories.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Implement fail2ban for web services', + 'description' => 'Automatically ban IPs showing malicious web request patterns.', + 'action' => 'Configure fail2ban to monitor web server logs. Create custom filters for repeated 404 errors, SQL injection attempts, etc.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Use HTTPS only (port 443)', + 'description' => 'Encrypt web traffic to prevent man-in-the-middle attacks and credential theft.', + 'action' => 'Install SSL certificate (Let\'s Encrypt). Configure web server to redirect HTTP (80) to HTTPS (443). Enable HSTS headers.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Configure firewall to block unnecessary ports', + 'description' => 'If using standard HTTPS (443), block alternative ports like 8080 and 8443 unless needed.', + 'action' => 'UFW: sudo ufw deny 8080/tcp && sudo ufw deny 8443/tcp (if not needed). Only allow required web ports.', + ], + [ + 'priority' => 'Low', + 'title' => 'Hide server version information', + 'description' => 'Reduce information disclosure that helps attackers identify vulnerabilities.', + 'action' => 'Configure web server to hide version banners. Disable server signature: ServerTokens Prod (Apache), server_tokens off (Nginx).', + ], + ]; + } + + // Email attacks + if (str_contains($attackType, 'Email') || in_array($port, [25, 110, 143, 993, 995])) { + $mitigations = [ + [ + 'priority' => 'High', + 'title' => 'Configure SPF, DKIM, and DMARC records', + 'description' => 'Prevent email spoofing and improve email authentication.', + 'action' => 'Add SPF, DKIM, and DMARC DNS records for your domain. Configure mail server to sign outgoing emails.', + ], + [ + 'priority' => 'High', + 'title' => 'Require strong authentication', + 'description' => 'Use strong passwords and consider implementing two-factor authentication for email.', + 'action' => 'Enforce password complexity policies. Enable 2FA if supported. Use OAuth2 or modern authentication methods.', + ], + [ + 'priority' => 'High', + 'title' => 'Block port 25 (SMTP) from external access', + 'description' => 'Prevent unauthorized email relaying and spam attacks.', + 'action' => 'Configure mail server to only accept SMTP connections from trusted IPs. Use UFW/iptables to restrict port 25 access.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Implement fail2ban for mail services', + 'description' => 'Automatically ban IPs that fail email authentication multiple times.', + 'action' => 'Configure fail2ban to monitor mail server authentication logs. Create jails for POP3, IMAP, and SMTP authentication failures.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Use encrypted email protocols', + 'description' => 'Prefer secure protocols (IMAPS, POP3S) over unencrypted ones.', + 'action' => 'Disable unencrypted POP3 (110) and IMAP (143). Require SSL/TLS for all email connections. Update firewall rules.', + ], + [ + 'priority' => 'Low', + 'title' => 'Change default email ports', + 'description' => 'Reduce automated attack attempts by using non-standard ports.', + 'action' => 'Consider changing to non-standard ports for email services if your email clients support it. However, this reduces compatibility.', + ], + ]; + } + + // FTP attacks + if ($attackType === 'FTP Attack' || $port === 21) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Disable FTP, use SFTP instead', + 'description' => 'FTP is unencrypted and insecure. SFTP (over SSH) is the secure alternative.', + 'action' => 'Disable FTP server: sudo systemctl disable vsftpd (or proftpd). Use SSH/SFTP instead, which uses port 22 and is encrypted.', + ], + [ + 'priority' => 'High', + 'title' => 'If FTP is required, use FTPS only', + 'description' => 'Use FTP over SSL/TLS to encrypt credentials and data.', + 'action' => 'Configure FTP server to require TLS/SSL. Disable plain FTP. Block port 21 for plain FTP connections.', + ], + [ + 'priority' => 'High', + 'title' => 'Restrict FTP access by IP', + 'description' => 'Limit FTP access to specific trusted IP addresses only.', + 'action' => 'Configure firewall to only allow FTP from trusted IPs. Use FTP server configuration to restrict access by IP address.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Implement fail2ban for FTP', + 'description' => 'Automatically ban IPs that fail FTP authentication multiple times.', + 'action' => 'Configure fail2ban to monitor FTP authentication logs. Create custom jail for repeated authentication failures.', + ], + ]; + } + + // Telnet attacks + if ($attackType === 'Telnet Exploit' || $port === 23) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Disable Telnet completely', + 'description' => 'Telnet is completely unencrypted and should never be used. Use SSH instead.', + 'action' => 'Disable Telnet: sudo systemctl disable telnet && sudo systemctl stop telnet. Remove telnet server package. Block port 23 at firewall.', + ], + [ + 'priority' => 'Critical', + 'title' => 'Use SSH instead', + 'description' => 'SSH provides encrypted remote access, replacing the insecure Telnet protocol.', + 'action' => 'Use SSH (port 22) for all remote terminal access. SSH encrypts all data and credentials.', + ], + ]; + } + + // VNC attacks + if ($attackType === 'VNC Attack' || $port === 5900) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Never expose VNC directly to internet', + 'description' => 'VNC is vulnerable to brute force attacks. Always route through VPN or SSH tunnel.', + 'action' => 'Block VNC port at firewall. Use SSH tunnel: ssh -L 5900:localhost:5900 user@server. Connect VNC client to localhost:5900', + ], + [ + 'priority' => 'High', + 'title' => 'Use SSH tunnel for VNC access', + 'description' => 'Encrypt VNC connections by tunneling through SSH.', + 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:5900 user@server. Connect VNC to localhost:[local-port]. Configure firewall to block port 5900.', + ], + [ + 'priority' => 'High', + 'title' => 'Enable VNC password and strong authentication', + 'description' => 'Use strong passwords if VNC must be used without tunneling.', + 'action' => 'Set strong VNC password: vncpasswd. Consider using VNC over SSL if available. Still prefer SSH tunneling.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Change VNC port', + 'description' => 'Reduce automated attack attempts by using non-standard port.', + 'action' => 'Configure VNC to use non-standard port. Update firewall rules. Still route through SSH tunnel for security.', + ], + ]; + } + + // DNS attacks + if ($attackType === 'DNS Attack' || $port === 53) { + $mitigations = [ + [ + 'priority' => 'High', + 'title' => 'Restrict DNS to local network only', + 'description' => 'DNS servers should typically only serve local networks, not be exposed to the internet.', + 'action' => 'Configure DNS server to only listen on local network interface. Block external DNS queries at firewall unless you operate a public DNS server.', + ], + [ + 'priority' => 'High', + 'title' => 'Use DNS over HTTPS (DoH) or DNS over TLS (DoT)', + 'description' => 'Encrypt DNS queries to prevent interception and manipulation.', + 'action' => 'Configure DNS clients to use DoH/DoT. Update applications to use encrypted DNS protocols.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Configure rate limiting for DNS', + 'description' => 'Prevent DNS amplification attacks and excessive queries.', + 'action' => 'Configure DNS server (BIND, unbound) with rate limiting. Limit queries per IP address.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Keep DNS software updated', + 'description' => 'Outdated DNS software has known vulnerabilities.', + 'action' => 'Regularly update DNS server software. Subscribe to security advisories for your DNS server (BIND, unbound, etc.).', + ], + ]; + } + + // Windows RPC attacks + if ($attackType === 'Windows Exploit' || $port === 135) { + $mitigations = [ + [ + 'priority' => 'Critical', + 'title' => 'Block Windows RPC ports at firewall', + 'description' => 'Windows RPC should never be exposed to the internet. It is used for service enumeration and attacks.', + 'action' => 'Block ports 135, 137, 138, 139, 445 at firewall. Windows: Configure Windows Firewall. Linux firewall: Use UFW/iptables to block these ports.', + ], + [ + 'priority' => 'High', + 'title' => 'Use VPN for Windows RPC access', + 'description' => 'If Windows RPC access is needed remotely, route it through a VPN tunnel.', + 'action' => 'Set up VPN server. Only allow Windows RPC access through VPN tunnel. Configure firewall to block direct RPC access.', + ], + [ + 'priority' => 'High', + 'title' => 'Keep Windows updated', + 'description' => 'Windows RPC vulnerabilities are regularly patched. Keep system updated.', + 'action' => 'Install latest Windows security updates. Enable automatic updates for critical security patches.', + ], + ]; + } + + // Generic/unknown attacks or port scans + if (empty($mitigations)) { + $mitigations = [ + [ + 'priority' => 'High', + 'title' => 'Block port at firewall', + 'description' => "If this port ({$port}) is not needed, block it completely at the firewall level.", + 'action' => $port ? "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP" : 'Configure firewall to block this port.', + ], + [ + 'priority' => 'High', + 'title' => 'Implement fail2ban', + 'description' => 'Automatically ban IPs showing malicious connection patterns.', + 'action' => 'Install fail2ban. Create custom filter to monitor logs for this service. Configure jail to ban repeated connection attempts.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Review service necessity', + 'description' => "Evaluate if the service on port {$port} is necessary. Disable unused services to reduce attack surface.", + 'action' => 'Review running services: sudo netstat -tulpn or sudo ss -tulpn. Disable services that are not required.', + ], + [ + 'priority' => 'Medium', + 'title' => 'Restrict access by IP address', + 'description' => 'Limit access to specific trusted IP addresses only.', + 'action' => $port ? "UFW: sudo ufw allow from [trusted-ip] to any port {$port}. iptables: Allow only from trusted IPs." : 'Configure firewall to restrict access by IP.', + ], + [ + 'priority' => 'Low', + 'title' => 'Use VPN for remote access', + 'description' => 'If remote access is needed, route connections through a VPN instead of exposing services directly.', + 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to services through VPN tunnel.', + ], + ]; + } + + // Add general mitigations for all attacks + $generalMitigations = [ + [ + 'priority' => 'Medium', + 'title' => 'Implement IP blocklist at firewall', + 'description' => 'Automatically block known malicious IP addresses at the firewall level.', + 'action' => 'Use ipset with iptables to maintain blocklists. Integrate with threat intelligence feeds. Automatically update blocklists.', + ], + [ + 'priority' => 'Low', + 'title' => 'Monitor and log all connection attempts', + 'description' => 'Maintain comprehensive logs for security analysis and incident response.', + 'action' => 'Configure syslog to centralize logs. Use log analysis tools (ELK stack, Splunk). Set up alerts for suspicious activity patterns.', + ], + ]; + + // Merge general mitigations + $mitigations = array_merge($mitigations, $generalMitigations); + + // Sort by priority (Critical > High > Medium > Low) + $priorityOrder = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1]; + usort($mitigations, function ($a, $b) use ($priorityOrder) { + $priA = $priorityOrder[$a['priority']] ?? 0; + $priB = $priorityOrder[$b['priority']] ?? 0; + return $priB <=> $priA; + }); + + return $mitigations; + } + + /** + * Analyze risk based on port number + */ + protected function analyzePortRisk(int $port, array $connection): array + { + // Critical risk ports (commonly exploited) + $criticalPorts = [ + 22 => ['attack_type' => 'SSH Brute Force', 'description' => 'SSH (port 22) is a common target for brute force attacks attempting to gain unauthorized access'], + 23 => ['attack_type' => 'Telnet Exploit', 'description' => 'Telnet (port 23) is unencrypted and highly vulnerable to credential theft and man-in-the-middle attacks'], + 3389 => ['attack_type' => 'RDP Attack', 'description' => 'RDP (port 3389) is frequently targeted for remote desktop brute force and ransomware attacks'], + 445 => ['attack_type' => 'SMB Exploit', 'description' => 'SMB (port 445) is commonly exploited for ransomware, lateral movement, and data exfiltration'], + 1433 => ['attack_type' => 'SQL Injection', 'description' => 'SQL Server (port 1433) is targeted for SQL injection attacks and database compromise'], + 3306 => ['attack_type' => 'Database Attack', 'description' => 'MySQL (port 3306) is targeted for database brute force and SQL injection attacks'], + 5432 => ['attack_type' => 'Database Attack', 'description' => 'PostgreSQL (port 5432) is targeted for database exploitation and unauthorized access'], + 135 => ['attack_type' => 'Windows Exploit', 'description' => 'Windows RPC (port 135) is used for service enumeration and remote procedure call attacks'], + 139 => ['attack_type' => 'NetBIOS Attack', 'description' => 'NetBIOS (port 139) is exploited for network enumeration and SMB protocol attacks'], + ]; + + // High risk ports + $highRiskPorts = [ + 21 => ['attack_type' => 'FTP Attack', 'description' => 'FTP (port 21) is vulnerable to credential theft and unencrypted data transmission'], + 25 => ['attack_type' => 'Email Spam', 'description' => 'SMTP (port 25) is commonly used for spam, phishing, and email-based attacks'], + 53 => ['attack_type' => 'DNS Attack', 'description' => 'DNS (port 53) can be exploited for DNS amplification, cache poisoning, or data exfiltration'], + 110 => ['attack_type' => 'Email Attack', 'description' => 'POP3 (port 110) is vulnerable to credential theft and email interception'], + 143 => ['attack_type' => 'Email Attack', 'description' => 'IMAP (port 143) is vulnerable to credential theft and email interception'], + 993 => ['attack_type' => 'Email Attack', 'description' => 'IMAPS (port 993) can be targeted for email account compromise'], + 995 => ['attack_type' => 'Email Attack', 'description' => 'POP3S (port 995) can be targeted for email account compromise'], + 8080 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTP (port 8080) is often targeted for web application attacks and exploits'], + 8443 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTPS (port 8443) can be targeted for web application vulnerabilities'], + 5900 => ['attack_type' => 'VNC Attack', 'description' => 'VNC (port 5900) is vulnerable to brute force attacks and unauthorized remote access'], + ]; + + // Medium risk ports + $mediumRiskPorts = [ + 80 => ['attack_type' => 'Web Attack', 'description' => 'HTTP (port 80) can be targeted for web application vulnerabilities and DDoS attacks'], + 443 => ['attack_type' => 'Web Attack', 'description' => 'HTTPS (port 443) can be targeted for SSL/TLS vulnerabilities and web application attacks'], + 161 => ['attack_type' => 'Network Scan', 'description' => 'SNMP (port 161) is used for network monitoring but can be exploited for information gathering'], + 514 => ['attack_type' => 'Log Injection', 'description' => 'Syslog (port 514) can be targeted for log injection and denial of service attacks'], + 1521 => ['attack_type' => 'Database Attack', 'description' => 'Oracle (port 1521) can be targeted for database exploitation'], + 27017 => ['attack_type' => 'Database Attack', 'description' => 'MongoDB (port 27017) can be targeted for database compromise if misconfigured'], + ]; + + // Check critical ports + if (isset($criticalPorts[$port])) { + return [ + 'risk_level' => 'Critical', + 'attack_type' => $criticalPorts[$port]['attack_type'], + 'description' => $criticalPorts[$port]['description'], + ]; + } + + // Check high risk ports + if (isset($highRiskPorts[$port])) { + return [ + 'risk_level' => 'High', + 'attack_type' => $highRiskPorts[$port]['attack_type'], + 'description' => $highRiskPorts[$port]['description'], + ]; + } + + // Check medium risk ports + if (isset($mediumRiskPorts[$port])) { + return [ + 'risk_level' => 'Medium', + 'attack_type' => $mediumRiskPorts[$port]['attack_type'], + 'description' => $mediumRiskPorts[$port]['description'], + ]; + } + + // Low ports (< 1024) are generally more risky + if ($port < 1024) { + return [ + 'risk_level' => 'Medium', + 'attack_type' => 'System Service Exploit', + 'description' => "Low port number ({$port}) indicates a system service that could be targeted for exploitation", + ]; + } + + // High ports (> 49152) are often used for attacks + if ($port > 49152) { + return [ + 'risk_level' => 'Low', + 'attack_type' => 'Ephemeral Port Scan', + 'description' => "High port number ({$port}) suggests ephemeral port usage, possibly part of a port scan or connection attempt", + ]; + } + + // Default for other ports + return [ + 'risk_level' => 'Low', + 'attack_type' => 'Port Scan', + 'description' => "Connection attempt targeting port {$port} (destination service port) from a malicious IP address", + ]; + } + + /** + * Analyze risk based on IP characteristics + */ + protected function analyzeIpRisk(string $ip): array + { + // Check if IP is in private range (less risky if internal) + if (!$this->isPublicIP($ip)) { + return [ + 'risk_level' => 'Low', + 'attack_type' => 'Internal Threat', + 'severity' => 1, + 'description' => 'Malicious IP is in private/internal range', + ]; + } + + // IPv6 addresses from blocklists are generally high risk + if (str_contains($ip, ':')) { + return [ + 'risk_level' => 'High', + 'attack_type' => 'IPv6 Blocklist Match', + 'severity' => 4, + 'description' => 'IPv6 address matches Spamhaus DROPv6 blocklist (known malicious network)', + ]; + } + + // IPv4 addresses from blocklists are high risk + return [ + 'risk_level' => 'High', + 'attack_type' => 'IPv4 Blocklist Match', + 'severity' => 4, + 'description' => 'IPv4 address matches blocklist.de malicious IP list', + ]; + } + + /** + * Get numeric severity for risk level comparison + */ + protected function getRiskSeverity(string $riskLevel): int + { + return match($riskLevel) { + 'Critical' => 5, + 'High' => 4, + 'Medium' => 3, + 'Low' => 2, + default => 1, + }; + } + + /** + * Fetch and cache IPv6 blocklist from Spamhaus DROPv6 + */ + protected function getIpv6Blocklist(): array + { + return Cache::remember('spamhaus-dropv6-blocklist', 3600, function () { + try { + $response = Http::timeout(10)->get('https://www.spamhaus.org/drop/dropv6.txt'); + + if (!$response->successful()) { + Log::warning('Failed to fetch Spamhaus DROPv6 blocklist', [ + 'status' => $response->status(), + ]); + return []; + } + + $lines = explode("\n", $response->body()); + $cidrs = []; + + foreach ($lines as $line) { + $line = trim($line); + + // Skip comments and empty lines + if (empty($line) || str_starts_with($line, ';') || str_starts_with($line, '#')) { + continue; + } + + // Extract CIDR (everything before ;) + $parts = explode(';', $line, 2); + $cidr = trim($parts[0]); + + if (!empty($cidr)) { + // Normalize CIDR if needed + if (str_contains($cidr, '/')) { + [$ip, $prefix] = explode('/', $cidr, 2); + $normalizedIp = $this->normalizeSingleIp($ip); + if ($normalizedIp !== null) { + $normalizedCidr = $this->normalizeCidr($normalizedIp, (int)$prefix); + if ($normalizedCidr) { + $cidrs[] = $normalizedCidr; + } + } + } + } + } + + Log::debug('Fetched Spamhaus DROPv6 blocklist', [ + 'cidr_count' => count($cidrs), + 'sample_cidrs' => array_slice($cidrs, 0, 5), + ]); + + return array_unique($cidrs); + } catch (\Throwable $e) { + Log::error('Error fetching Spamhaus DROPv6 blocklist', [ + 'error' => $e->getMessage(), + ]); + return []; + } + }); + } + + /** + * Fetch and cache IPv4 blocklist from blocklist.de + */ + protected function getIpv4Blocklist(): array + { + return Cache::remember('blocklist-de-ipv4-blocklist', 3600, function () { + try { + $response = Http::timeout(10)->get('https://lists.blocklist.de/lists/all.txt'); + + if (!$response->successful()) { + Log::warning('Failed to fetch blocklist.de IPv4 blocklist', [ + 'status' => $response->status(), + ]); + return []; + } + + $lines = explode("\n", $response->body()); + $ips = []; + + foreach ($lines as $line) { + $line = trim($line); + + // Skip empty lines + if (empty($line)) { + continue; + } + + // Validate and normalize IP + $normalizedIp = $this->normalizeSingleIp($line); + if ($normalizedIp !== null && !str_contains($normalizedIp, ':')) { + // Only IPv4 addresses + $ips[] = $normalizedIp; + } + } + + Log::debug('Fetched blocklist.de IPv4 blocklist', [ + 'ip_count' => count($ips), + 'sample_ips' => array_slice($ips, 0, 5), + ]); + + return array_unique($ips); + } catch (\Throwable $e) { + Log::error('Error fetching blocklist.de IPv4 blocklist', [ + 'error' => $e->getMessage(), + ]); + return []; + } + }); + } + + protected function extractStateIp(array $state, string $direction): string + { + // Core Router API returns source/destination in format: "IP[port]" or separate fields + $candidates = [ + $direction === 'src' ? 'source' : 'destination', + "{$direction}_ip", + "{$direction}ip", + $direction, + $direction === 'src' ? 'source_ip' : 'destination_ip', + ]; + + foreach ($candidates as $candidate) { + if (!empty($state[$candidate])) { + $value = (string) $state[$candidate]; + + // Parse format: "2406:da12:fc3:5300::11[53443]" or "45.154.99.28[22]" -> extract IP part + // Also handle IPv6 format: "2a0b:4e07:8:1::1:7[12844]" + if (preg_match('/^([^\[\]]+)(\[.*\])?$/', $value, $matches)) { + $ip = trim($matches[1]); + // Normalize the extracted IP + $normalized = $this->normalizeSingleIp($ip); + return $normalized ?: $ip; + } + + // Handle IPv4 in "ip:port" format (ensure IPv6 not affected) + $colonPos = strrpos($value, ':'); + if ($colonPos !== false) { + $ipPart = substr($value, 0, $colonPos); + $portPart = substr($value, $colonPos + 1); + + // Only treat as IPv4 if the portion before colon is a valid IPv4 address + if (filter_var($ipPart, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($portPart)) { + $normalized = $this->normalizeSingleIp($ipPart); + return $normalized ?: $ipPart; + } + } + + // If no bracket format, try to normalize anyway + $normalized = $this->normalizeSingleIp($value); + return $normalized ?: $value; + } + } + + return 'N/A'; + } + + protected function extractStatePort(array $state, string $direction): ?int + { + // Core Router API returns source/destination in format: "IP[port]" or separate fields + // Check dedicated port fields first, then fall back to composite IP[port] fields + $portOnlyCandidates = [ + "{$direction}_port", + "{$direction}port", + $direction === 'src' ? 'source_port' : 'destination_port', + $direction === 'src' ? 'sport' : 'dport', + ]; + + foreach ($portOnlyCandidates as $candidate) { + if (isset($state[$candidate]) && is_numeric($state[$candidate])) { + return (int) $state[$candidate]; + } + } + + // Fall back to extracting port from composite "IP[port]" or "IP:port" fields + $compositeCandidates = [ + $direction === 'src' ? 'source' : 'destination', + ]; + + foreach ($compositeCandidates as $candidate) { + if (isset($state[$candidate]) && is_string($state[$candidate])) { + $value = $state[$candidate]; + + // Handle "IP[port]" format + if (preg_match('/\[(\d+)\]$/', $value, $matches)) { + return (int) $matches[1]; + } + + // Handle IPv4 "IP:port" format (not IPv6) + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $value, $matches)) { + return (int) $matches[2]; + } + } + } + + return null; + } + + /** + * Extract port number from a field that may contain IP:port or IP[port] format + */ + protected function extractPortFromField(string $field): ?int + { + if (empty($field)) { + return null; + } + + $field = trim($field); + + // Handle IPv4 "IP:port" format (e.g., "45.154.99.22:22") + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { + return (int)$matches[2]; + } + + // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") + if (preg_match('/\[(\d+)\]$/', $field, $matches)) { + return (int)$matches[1]; + } + + return null; + } + + protected function expandIpValue(?string $value): array + { + $results = []; + if (!$value) { + return $results; + } + + $sanitized = str_replace(["\r", "\n", "\t"], ' ', (string) $value); + $parts = preg_split('/[\s,]+/', $sanitized, -1, PREG_SPLIT_NO_EMPTY); + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + if (str_contains($part, '/')) { + [$ipCandidate, $prefixPart] = explode('/', $part, 2); + $normalizedIp = $this->normalizeSingleIp($ipCandidate); + $prefixPart = trim($prefixPart); + + if ($normalizedIp === null || $prefixPart === '' || !ctype_digit($prefixPart)) { + continue; + } + + $prefix = (int) $prefixPart; + $maxPrefix = str_contains($normalizedIp, ':') ? 128 : 32; + if ($prefix < 0 || $prefix > $maxPrefix) { + continue; + } + + // Normalize CIDR to network address (important for matching) + $networkCidr = $this->normalizeCidr($normalizedIp, $prefix); + if ($networkCidr) { + // Add the base IP as an exact match (important for exact IP matching) + // This ensures IPs stored as "IP/CIDR" can be matched exactly + $results[] = [ + 'type' => 'ip', + 'value' => $normalizedIp, + ]; + // Also add the CIDR network for CIDR matching + $results[] = [ + 'type' => 'cidr', + 'value' => $networkCidr, + ]; + } + + continue; + } + + $normalizedIp = $this->normalizeSingleIp($part); + if ($normalizedIp !== null) { + $results[] = [ + 'type' => 'ip', + 'value' => $normalizedIp, + ]; + } + } + + return $results; + } + + protected function normalizeSingleIp(?string $ip): ?string + { + if ($ip === null) { + return null; + } + + $ip = strtolower(trim($ip)); + if ($ip === '') { + return null; + } + + $ip = trim($ip, "[]'\""); + + if (str_contains($ip, '%')) { + $ip = strstr($ip, '%', true) ?: $ip; + } + + return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null; + } + + /** + * Normalize CIDR to network address (e.g., 45.154.99.28/28 -> 45.154.99.16/28) + */ + protected function normalizeCidr(string $ip, int $prefix): ?string + { + $isIPv6 = str_contains($ip, ':'); + $ipBinary = @inet_pton($ip); + + if ($ipBinary === false) { + return null; + } + + $addressLength = strlen($ipBinary) * 8; + if ($prefix < 0 || $prefix > $addressLength) { + return null; + } + + // Calculate network address by zeroing out host bits + $fullBytes = intdiv($prefix, 8); + $remainingBits = $prefix % 8; + + // Apply mask to the partial byte first (before zeroing), preserving network bits + if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) { + $mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF; + $ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask); + } + + // Zero out all host bytes after the partial byte + for ($i = $fullBytes + ($remainingBits > 0 ? 1 : 0); $i < strlen($ipBinary); $i++) { + $ipBinary[$i] = "\0"; + } + + $networkIp = @inet_ntop($ipBinary); + if ($networkIp === false) { + return null; + } + + return $networkIp . '/' . $prefix; + } + + protected function ipMatchesServer(string $ip, array $exactIps): bool + { + $normalized = $this->normalizeSingleIp($ip); + if ($normalized === null) { + return false; + } + + if (in_array($normalized, $exactIps, true)) { + return true; + } + + foreach ($this->serverIpCidrs as $cidr) { + if ($this->ipInCidr($normalized, $cidr)) { + return true; + } + } + + return false; + } + + protected function ipInCidr(string $ip, string $cidr): bool + { + if (!str_contains($cidr, '/')) { + return $ip === $cidr; + } + + [$network, $prefixLength] = explode('/', $cidr, 2); + $prefixLength = (int) $prefixLength; + + $ipBinary = @inet_pton($ip); + $networkBinary = @inet_pton($network); + + if ($ipBinary === false || $networkBinary === false) { + return false; + } + + if (strlen($ipBinary) !== strlen($networkBinary)) { + return false; + } + + $maxPrefix = strlen($networkBinary) * 8; + if ($prefixLength < 0 || $prefixLength > $maxPrefix) { + return false; + } + + $fullBytes = intdiv($prefixLength, 8); + $remainingBits = $prefixLength % 8; + + if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($networkBinary, 0, $fullBytes)) { + return false; + } + + if ($remainingBits === 0) { + return true; + } + + $mask = (~(0xff >> $remainingBits)) & 0xff; + + return (ord($ipBinary[$fullBytes]) & $mask) === (ord($networkBinary[$fullBytes]) & $mask); + } + + public function getGraphPayloads(): array + { + return $this->graphPayloads; + } + + public function updateChartData(string $chartType): array + { + $allowedTypes = ['cpu', 'memory', 'network', 'disk']; + if (!in_array($chartType, $allowedTypes, true)) { + return []; + } + + $timeRangeProperty = $chartType . 'TimeRange'; + $aggregationProperty = $chartType . 'Aggregation'; + + $timeRange = $this->$timeRangeProperty; + $aggregation = $this->$aggregationProperty; + + $this->fetchGraphData($chartType, $timeRange, $aggregation, true); + + $fullData = $this->graphPayloads[$chartType] ?? []; + + // Filter data to only return essential fields for this chart type to reduce response size + $filteredData = []; + foreach ($fullData as $point) { + $filteredPoint = ['time' => $point['time'] ?? 0]; + + switch ($chartType) { + case 'cpu': + $filteredPoint['cpu'] = $point['cpu'] ?? 0; + break; + case 'memory': + $filteredPoint['mem'] = $point['mem'] ?? 0; + $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; + break; + case 'network': + $filteredPoint['netin'] = $point['netin'] ?? 0; + $filteredPoint['netout'] = $point['netout'] ?? 0; + break; + case 'disk': + $filteredPoint['diskread'] = $point['diskread'] ?? 0; + $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; + break; + } + + $filteredData[] = $filteredPoint; + } + + return $filteredData; + } + + public function render() + { + return view('proxmox::statistics')->layoutData([ + 'sidebar' => true, + ]); + } + + public function exportChart(string $chartKey) + { + $rows = $this->exportRowsFor($chartKey); + + if (empty($rows)) { + $this->showExportInfo('No data available to export for this chart. Please refresh the chart and try again.'); + return; + } + + $headers = array_keys($rows[0]); + $csv = implode(',', $headers) . PHP_EOL; + + foreach ($rows as $row) { + $csv .= implode(',', array_map(function ($value) { + return '"' . str_replace('"', '""', (string)$value) . '"'; + }, $row)) . PHP_EOL; + } + + $filename = sprintf('%s-chart-%s.csv', strtolower($chartKey), now()->format('Y-m-d_Hi')); + + return response()->streamDownload(function () use ($csv) { + echo $csv; + }, $filename, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + protected function exportRowsFor(string $chartKey): array + { + $chartKey = strtolower($chartKey); + $data = $this->graphPayloads[$chartKey] ?? []; + + if (empty($data)) { + return []; + } + + $rows = []; + + foreach ($data as $point) { + if (!isset($point['time'])) { + continue; + } + + $timestamp = Carbon::createFromTimestamp((int)$point['time'])->toDateTimeString(); + + switch ($chartKey) { + case 'cpu': + $rows[] = [ + 'Timestamp' => $timestamp, + 'CPU (%)' => round(($point['cpu'] ?? 0) * 100, 2), + ]; + break; + case 'memory': + $rows[] = [ + 'Timestamp' => $timestamp, + 'Used (GB)' => $this->convertBytes($point['mem'] ?? 0, 'GB'), + 'Max (GB)' => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), + ]; + break; + case 'network': + $rows[] = [ + 'Timestamp' => $timestamp, + 'Download (MB/s)' => $this->convertBytes($point['netin'] ?? 0, 'MB'), + 'Upload (MB/s)' => $this->convertBytes($point['netout'] ?? 0, 'MB'), + ]; + break; + case 'disk': + $rows[] = [ + 'Timestamp' => $timestamp, + 'Read (MB/s)' => $this->convertBytes($point['diskread'] ?? 0, 'MB'), + 'Write (MB/s)' => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), + ]; + break; + } + } + + return $rows; + } + + protected function makeCpuChart() + { + $data = $this->graphPayloads['cpu'] ?? []; + + if (empty($data)) { + return null; + } + + $labels = $this->buildLabels($data, $this->cpuTimeRange); + $values = array_map(fn ($point) => round(($point['cpu'] ?? 0) * 100, 2), $data); + + return Chartjs::build() + ->name('cpuChart_' . $this->server->id . '_' . $this->cpuTimeRange . '_' . $this->cpuAggregation) + ->type('line') + ->size(['height' => 280, 'width' => 400]) + ->labels($labels) + ->datasets([ + [ + 'label' => 'CPU Usage (%)', + 'data' => $values, + 'borderColor' => 'rgb(74, 222, 128)', + 'backgroundColor' => 'rgba(74, 222, 128, 0.15)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + ]) + ->options($this->chartOptions(100, 'CPU %', false)); + } + + protected function makeMemoryChart() + { + $data = $this->graphPayloads['memory'] ?? []; + + if (empty($data)) { + return null; + } + + $labels = $this->buildLabels($data, $this->memoryTimeRange); + $used = array_map(fn ($point) => $this->convertBytes($point['mem'] ?? 0, 'GB'), $data); + $max = array_map(fn ($point) => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), $data); + + return Chartjs::build() + ->name('memoryChart_' . $this->server->id . '_' . $this->memoryTimeRange . '_' . $this->memoryAggregation) + ->type('line') + ->size(['height' => 280, 'width' => 400]) + ->labels($labels) + ->datasets([ + [ + 'label' => 'Used (GB)', + 'data' => $used, + 'borderColor' => 'rgb(96, 165, 250)', + 'backgroundColor' => 'rgba(96, 165, 250, 0.15)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + [ + 'label' => 'Provisioned (GB)', + 'data' => $max, + 'borderColor' => 'rgb(30, 64, 175)', + 'backgroundColor' => 'rgba(37, 99, 235, 0.08)', + 'fill' => false, + 'pointRadius' => 0, + 'borderDash' => [6, 6], + ], + ]) + ->options($this->chartOptions(null, 'GB')); + } + + protected function makeNetworkChart() + { + $data = $this->graphPayloads['network'] ?? []; + + if (empty($data)) { + return null; + } + + $labels = $this->buildLabels($data, $this->networkTimeRange); + $download = array_map(fn ($point) => $this->convertBytes($point['netin'] ?? 0, 'MB'), $data); + $upload = array_map(fn ($point) => $this->convertBytes($point['netout'] ?? 0, 'MB'), $data); + + return Chartjs::build() + ->name('networkChart_' . $this->server->id . '_' . $this->networkTimeRange . '_' . $this->networkAggregation) + ->type('line') + ->size(['height' => 280, 'width' => 400]) + ->labels($labels) + ->datasets([ + [ + 'label' => 'Download (MB/s)', + 'data' => $download, + 'borderColor' => 'rgb(45, 212, 191)', + 'backgroundColor' => 'rgba(45, 212, 191, 0.12)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + [ + 'label' => 'Upload (MB/s)', + 'data' => $upload, + 'borderColor' => 'rgb(14, 165, 233)', + 'backgroundColor' => 'rgba(14, 165, 233, 0.12)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + ]) + ->options($this->chartOptions(null, 'MB/s')); + } + + protected function makeDiskChart() + { + $data = $this->graphPayloads['disk'] ?? []; + + if (empty($data)) { + return null; + } + + $labels = $this->buildLabels($data, $this->diskTimeRange); + $read = array_map(fn ($point) => $this->convertBytes($point['diskread'] ?? 0, 'MB'), $data); + $write = array_map(fn ($point) => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), $data); + + return Chartjs::build() + ->name('diskChart_' . $this->server->id . '_' . $this->diskTimeRange . '_' . $this->diskAggregation) + ->type('line') + ->size(['height' => 280, 'width' => 400]) + ->labels($labels) + ->datasets([ + [ + 'label' => 'Read (MB/s)', + 'data' => $read, + 'borderColor' => 'rgb(147, 51, 234)', + 'backgroundColor' => 'rgba(147, 51, 234, 0.14)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + [ + 'label' => 'Write (MB/s)', + 'data' => $write, + 'borderColor' => 'rgb(236, 72, 153)', + 'backgroundColor' => 'rgba(236, 72, 153, 0.12)', + 'fill' => true, + 'tension' => 0.35, + 'pointRadius' => 0, + ], + ]) + ->options($this->chartOptions(null, 'MB/s')); + } + + protected function chartOptions(?int $suggestedMax = null, ?string $yLabel = null, bool $showLegend = true): array + { + $options = [ + 'responsive' => true, + 'maintainAspectRatio' => false, + 'plugins' => [ + 'legend' => [ + 'display' => $showLegend, + 'labels' => [ + 'color' => 'rgb(148, 163, 184)', + 'usePointStyle' => true, + ], + ], + 'tooltip' => [ + 'backgroundColor' => 'rgba(15, 23, 42, 0.92)', + 'titleColor' => '#fff', + 'bodyColor' => '#fff', + ], + ], + 'elements' => [ + 'point' => [ + 'hitRadius' => 20, + ], + ], + 'scales' => [ + 'x' => [ + 'grid' => [ + 'color' => 'rgba(148, 163, 184, 0.12)', + ], + 'ticks' => [ + 'color' => 'rgb(148, 163, 184)', + 'maxRotation' => 0, + ], + ], + 'y' => [ + 'beginAtZero' => true, + 'grid' => [ + 'color' => 'rgba(148, 163, 184, 0.08)', + ], + 'ticks' => [ + 'color' => 'rgb(148, 163, 184)', + ], + ], + ], + ]; + + if ($suggestedMax !== null) { + $options['scales']['y']['suggestedMax'] = $suggestedMax; + } + + if ($yLabel) { + $options['scales']['y']['title'] = [ + 'display' => true, + 'text' => $yLabel, + 'color' => 'rgb(148, 163, 184)', + 'font' => ['size' => 12], + ]; + } + + return $options; + } + + protected function buildLabels(array $data, string $range): array + { + $format = $this->labelFormatForRange($range); + + return array_map(function ($point) use ($format) { + if (!isset($point['time'])) { + return ''; + } + + return Carbon::createFromTimestamp((int)$point['time'])->format($format); + }, $data); + } + + protected function labelFormatForRange(string $range): string + { + return match ($range) { + 'hour' => 'H:i', + 'day' => 'M d H:i', + 'week' => 'M d', + 'month' => 'M d', + 'year' => 'M Y', + default => 'M d H:i', + }; + } + + protected function convertBytes($bytes, string $unit = 'MB'): float + { + if (!is_numeric($bytes) || $bytes <= 0) { + return 0.0; + } + + $unit = strtoupper($unit); + $divisors = [ + 'B' => 1, + 'KB' => 1024, + 'MB' => 1024 ** 2, + 'GB' => 1024 ** 3, + 'TB' => 1024 ** 4, + ]; + + $divisor = $divisors[$unit] ?? $divisors['MB']; + + return round($bytes / $divisor, 2); + } + + /** + * Extract IP address from a string, handling ports for both IPv4 and IPv6 + */ + protected function extractIPFromString(string $ip): string + { + $ip = trim($ip); + + // Handle IPv4 with port (e.g., "192.168.1.1:8080") + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $ip, $matches)) { + return $matches[1]; + } + + // Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080") + if (preg_match('/^\[([^\]]+)\]:(\d+)$/', $ip, $matches)) { + return $matches[1]; + } + + // Handle IPv6 with port without brackets (less common, but possible) + // For IPv6, we need to check if it's actually an IPv6 address before removing port + if (str_contains($ip, ':')) { + // Check if it's a valid IPv6 address + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + // No port to remove, it's just an IPv6 address + return $ip; + } + // Might be IPv6 with port, try to extract + $parts = explode(']:', $ip); + if (count($parts) === 2) { + return trim($parts[0], '[]'); + } + } + + return $ip; + } + + /** + * Check if an IP address is public (not private/localhost) - public method for Blade templates + */ + public function checkIsPublicIP(string $ip): bool + { + return $this->isPublicIP($ip); + } + + /** + * Check if an IP address is public (not private/localhost) + */ + protected function isPublicIP(string $ip): bool + { + $ip = $this->extractIPFromString($ip); + + if (empty($ip)) { + return false; + } + + // Check for localhost + if (in_array(strtolower($ip), ['localhost', '127.0.0.1', '::1'])) { + return false; + } + + // Validate and check if it's a public IP + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + // IPv4: Check if it's not in private ranges + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + // IPv6: Check if it's not in private ranges + // Private IPv6 ranges: + // - ::1 (localhost) + // - fc00::/7 (Unique Local Address - ULA) + // - fe80::/10 (Link-local) + // - ::ffff:0:0/96 (IPv4-mapped IPv6) + + // Check for link-local (fe80::/10 - addresses starting with fe80 through febf) + if (preg_match('/^fe[89ab][0-9a-fA-F]:/i', $ip)) { + return false; + } + + // Check for ULA (fc00::/7 means fc00:: to fdff:ffff:...) + if (str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) { + return false; + } + + // Check for IPv4-mapped IPv6 + if (str_starts_with($ip, '::ffff:')) { + $ipv4Part = substr($ip, 7); + return $this->isPublicIP($ipv4Part); + } + + // If it's a valid IPv6 and not in private ranges, consider it public + return true; + } + + return false; + } + + /** + * Fetch IP geolocation data from ip-api.com via HTTP (server-side proxy) + * This avoids mixed content issues when the site is served over HTTPS + */ + public function fetchIPLocation($ip): array + { + if (empty($ip)) { + return ['status' => 'fail', 'message' => 'IP address is required']; + } + + // Extract and validate IP address + $cleanIP = $this->extractIPFromString((string)$ip); + + if (!$this->isPublicIP($cleanIP)) { + return ['status' => 'fail', 'message' => 'Not a valid public IP address']; + } + + try { + // Use HTTP protocol as requested (not HTTPS) + $response = Http::timeout(5)->get("http://ip-api.com/json/{$cleanIP}"); + + if ($response->successful()) { + return $response->json(); + } + + return [ + 'status' => 'fail', + 'message' => 'Failed to fetch location data: HTTP ' . $response->status() + ]; + } catch (\Exception $e) { + Log::error('Failed to fetch IP location', [ + 'ip' => $cleanIP, + 'original_ip' => $ip, + 'error' => $e->getMessage() + ]); + + return [ + 'status' => 'fail', + 'message' => 'Failed to fetch location data: ' . $e->getMessage() + ]; + } + } +} + diff --git a/extensions/Servers/Proxmox/views/components/layout.blade.php b/extensions/Servers/Proxmox/views/components/layout.blade.php index 34023c0c6..62b01aa9a 100644 --- a/extensions/Servers/Proxmox/views/components/layout.blade.php +++ b/extensions/Servers/Proxmox/views/components/layout.blade.php @@ -1,3513 +1,3483 @@ -@props([ - 'server', - 'view', - 'status' => null, - 'statusPolling' => false, - 'statusPollingInterval' => '30s', -]) - -@php - $vmStatus = optional($status)->status; - $pollSuffix = $statusPollingInterval ? '.' . $statusPollingInterval : ''; - $pollAttribute = $statusPolling ? 'wire:poll' . $pollSuffix . '="loadVmStatus"' : ''; - $isLxc = $server->node?->is_lxc ?? false; -@endphp - -
-
- @if($server->status === 'active') - -
- @php - $advancedOnlyViews = ['boot-order', 'cloud-init', 'display', 'system', 'subdomain', 'statistics', 'help', 'firewall']; - $isAdvancedOnlyView = in_array($view, $advancedOnlyViews); - @endphp - -
-
user_set_icon . "'" : 'null' }}, - initialColor: {{ $server->user_set_icon_color ? "'" . $server->user_set_icon_color . "'" : 'null' }}, - initialPrefix: {{ $server->user_set_icon_prefix ? "'" . $server->user_set_icon_prefix . "'" : "'ri'" }} - })"> - - - -
-
- -

Customize Server Icon

-
-
-
- -
- -
-
- @php - $iconList = [ - // Remix Icons - ['icon' => 'server-line', 'prefix' => 'ri'], - ['icon' => 'database-2-line', 'prefix' => 'ri'], - ['icon' => 'computer-line', 'prefix' => 'ri'], - ['icon' => 'cpu-line', 'prefix' => 'ri'], - ['icon' => 'hard-drive-2-line', 'prefix' => 'ri'], - ['icon' => 'cloud-line', 'prefix' => 'ri'], - ['icon' => 'terminal-box-line', 'prefix' => 'ri'], - ['icon' => 'code-s-slash-line', 'prefix' => 'ri'], - ['icon' => 'braces-line', 'prefix' => 'ri'], - ['icon' => 'global-line', 'prefix' => 'ri'], - ['icon' => 'earth-line', 'prefix' => 'ri'], - ['icon' => 'wifi-line', 'prefix' => 'ri'], - ['icon' => 'signal-tower-line', 'prefix' => 'ri'], - ['icon' => 'shield-check-line', 'prefix' => 'ri'], - ['icon' => 'shield-keyhole-line', 'prefix' => 'ri'], - ['icon' => 'lock-line', 'prefix' => 'ri'], - ['icon' => 'lock-unlock-line', 'prefix' => 'ri'], - ['icon' => 'key-2-line', 'prefix' => 'ri'], - ['icon' => 'rocket-line', 'prefix' => 'ri'], - ['icon' => 'rocket-2-line', 'prefix' => 'ri'], - ['icon' => 'send-plane-line', 'prefix' => 'ri'], - ['icon' => 'flashlight-line', 'prefix' => 'ri'], - ['icon' => 'fire-line', 'prefix' => 'ri'], - ['icon' => 'sparkling-line', 'prefix' => 'ri'], - ['icon' => 'star-line', 'prefix' => 'ri'], - ['icon' => 'heart-line', 'prefix' => 'ri'], - ['icon' => 'home-line', 'prefix' => 'ri'], - ['icon' => 'home-2-line', 'prefix' => 'ri'], - ['icon' => 'building-line', 'prefix' => 'ri'], - ['icon' => 'building-2-line', 'prefix' => 'ri'], - ['icon' => 'store-line', 'prefix' => 'ri'], - ['icon' => 'box-3-line', 'prefix' => 'ri'], - ['icon' => 'archive-line', 'prefix' => 'ri'], - ['icon' => 'inbox-line', 'prefix' => 'ri'], - ['icon' => 'inbox-2-line', 'prefix' => 'ri'], - ['icon' => 'folder-line', 'prefix' => 'ri'], - ['icon' => 'folder-open-line', 'prefix' => 'ri'], - ['icon' => 'settings-3-line', 'prefix' => 'ri'], - ['icon' => 'settings-4-line', 'prefix' => 'ri'], - ['icon' => 'tools-line', 'prefix' => 'ri'], - ['icon' => 'hammer-line', 'prefix' => 'ri'], - ['icon' => 'flask-line', 'prefix' => 'ri'], - ['icon' => 'bug-line', 'prefix' => 'ri'], - ['icon' => 'puzzle-line', 'prefix' => 'ri'], - ['icon' => 'bar-chart-box-line', 'prefix' => 'ri'], - ['icon' => 'pie-chart-line', 'prefix' => 'ri'], - ['icon' => 'line-chart-line', 'prefix' => 'ri'], - ['icon' => 'money-dollar-circle-line', 'prefix' => 'ri'], - ['icon' => 'money-euro-circle-line', 'prefix' => 'ri'], - ['icon' => 'bank-card-line', 'prefix' => 'ri'], - ['icon' => 'wallet-line', 'prefix' => 'ri'], - ['icon' => 'user-line', 'prefix' => 'ri'], - ['icon' => 'user-3-line', 'prefix' => 'ri'], - ['icon' => 'group-line', 'prefix' => 'ri'], - ['icon' => 'team-line', 'prefix' => 'ri'], - ['icon' => 'mail-line', 'prefix' => 'ri'], - ['icon' => 'mail-open-line', 'prefix' => 'ri'], - ['icon' => 'chat-3-line', 'prefix' => 'ri'], - ['icon' => 'discuss-line', 'prefix' => 'ri'], - ['icon' => 'camera-line', 'prefix' => 'ri'], - ['icon' => 'vidicon-line', 'prefix' => 'ri'], - ['icon' => 'film-line', 'prefix' => 'ri'], - ['icon' => 'music-line', 'prefix' => 'ri'], - ['icon' => 'book-open-line', 'prefix' => 'ri'], - ['icon' => 'file-text-line', 'prefix' => 'ri'], - ['icon' => 'sun-line', 'prefix' => 'ri'], - ['icon' => 'moon-line', 'prefix' => 'ri'], - ['icon' => 'palette-line', 'prefix' => 'ri'], - ['icon' => 'brush-line', 'prefix' => 'ri'], - ['icon' => 'trophy-line', 'prefix' => 'ri'], - ['icon' => 'graduation-cap-line', 'prefix' => 'ri'], - ['icon' => 'lightbulb-line', 'prefix' => 'ri'], - ['icon' => 'fingerprint-line', 'prefix' => 'ri'], - ['icon' => 'eye-line', 'prefix' => 'ri'], - ['icon' => 'map-2-line', 'prefix' => 'ri'], - ['icon' => 'map-pin-line', 'prefix' => 'ri'], - ['icon' => 'time-line', 'prefix' => 'ri'], - ['icon' => 'calendar-line', 'prefix' => 'ri'], - ['icon' => 'gift-line', 'prefix' => 'ri'], - ['icon' => 'cake-2-line', 'prefix' => 'ri'], - ['icon' => 'gamepad-line', 'prefix' => 'ri'], - ['icon' => 'ghost-line', 'prefix' => 'ri'], - ['icon' => 'aliens-line', 'prefix' => 'ri'], - ['icon' => 'space-ship-line', 'prefix' => 'ri'], - ['icon' => 'android-line', 'prefix' => 'ri'], - ['icon' => 'apple-line', 'prefix' => 'ri'], - ['icon' => 'windows-line', 'prefix' => 'ri'], - ['icon' => 'ubuntu-line', 'prefix' => 'ri'], - ['icon' => 'github-line', 'prefix' => 'ri'], - ['icon' => 'gitlab-line', 'prefix' => 'ri'], - ['icon' => 'discord-line', 'prefix' => 'ri'], - ['icon' => 'slack-line', 'prefix' => 'ri'], - ['icon' => 'desktop', 'prefix' => 'vaadin'], - ['icon' => 'laptop', 'prefix' => 'vaadin'], - ['icon' => 'cluster', 'prefix' => 'vaadin'], - ['icon' => 'cloud', 'prefix' => 'vaadin'], - ['icon' => 'cloud-o', 'prefix' => 'vaadin'], - ['icon' => 'cloud-upload', 'prefix' => 'vaadin'], - ['icon' => 'cloud-download', 'prefix' => 'vaadin'], - ['icon' => 'storage', 'prefix' => 'vaadin'], - ['icon' => 'harddrive', 'prefix' => 'vaadin'], - ['icon' => 'compile', 'prefix' => 'vaadin'], - ['icon' => 'code', 'prefix' => 'vaadin'], - ['icon' => 'terminal', 'prefix' => 'vaadin'], - ['icon' => 'globe', 'prefix' => 'vaadin'], - ['icon' => 'globe-wire', 'prefix' => 'vaadin'], - ['icon' => 'connect', 'prefix' => 'vaadin'], - ['icon' => 'sitemap', 'prefix' => 'vaadin'], - ['icon' => 'automation', 'prefix' => 'vaadin'], - ['icon' => 'cogs', 'prefix' => 'vaadin'], - ['icon' => 'cog', 'prefix' => 'vaadin'], - ['icon' => 'wrench', 'prefix' => 'vaadin'], - ['icon' => 'tools', 'prefix' => 'vaadin'], - ['icon' => 'hammer', 'prefix' => 'vaadin'], - ['icon' => 'shield', 'prefix' => 'vaadin'], - ['icon' => 'lock', 'prefix' => 'vaadin'], - ['icon' => 'unlock', 'prefix' => 'vaadin'], - ['icon' => 'key', 'prefix' => 'vaadin'], - ['icon' => 'key-o', 'prefix' => 'vaadin'], - ['icon' => 'rocket', 'prefix' => 'vaadin'], - ['icon' => 'flash', 'prefix' => 'vaadin'], - ['icon' => 'bolt', 'prefix' => 'vaadin'], - ['icon' => 'fire', 'prefix' => 'vaadin'], - ['icon' => 'star', 'prefix' => 'vaadin'], - ['icon' => 'star-o', 'prefix' => 'vaadin'], - ['icon' => 'heart', 'prefix' => 'vaadin'], - ['icon' => 'heart-o', 'prefix' => 'vaadin'], - ['icon' => 'home', 'prefix' => 'vaadin'], - ['icon' => 'home-o', 'prefix' => 'vaadin'], - ['icon' => 'building', 'prefix' => 'vaadin'], - ['icon' => 'building-o', 'prefix' => 'vaadin'], - ['icon' => 'office', 'prefix' => 'vaadin'], - ['icon' => 'server', 'prefix' => 'vaadin'], - ['icon' => 'database', 'prefix' => 'vaadin'], - ['icon' => 'cloud', 'prefix' => 'vaadin'], - ['icon' => 'cloud-o', 'prefix' => 'vaadin'], - ['icon' => 'storage', 'prefix' => 'vaadin'], - ['icon' => 'harddrive', 'prefix' => 'vaadin'], - ['icon' => 'package', 'prefix' => 'vaadin'], - ['icon' => 'archive', 'prefix' => 'vaadin'], - ['icon' => 'inbox', 'prefix' => 'vaadin'], - ['icon' => 'folder', 'prefix' => 'vaadin'], - ['icon' => 'folder-o', 'prefix' => 'vaadin'], - ['icon' => 'folder-open', 'prefix' => 'vaadin'], - ['icon' => 'folder-open-o', 'prefix' => 'vaadin'], - ['icon' => 'bug', 'prefix' => 'vaadin'], - ['icon' => 'chart', 'prefix' => 'vaadin'], - ['icon' => 'bar-chart', 'prefix' => 'vaadin'], - ['icon' => 'line-chart', 'prefix' => 'vaadin'], - ['icon' => 'pie-chart', 'prefix' => 'vaadin'], - ['icon' => 'dashboard', 'prefix' => 'vaadin'], - ['icon' => 'progressbar', 'prefix' => 'vaadin'], - ['icon' => 'money', 'prefix' => 'vaadin'], - ['icon' => 'dollar', 'prefix' => 'vaadin'], - ['icon' => 'euro', 'prefix' => 'vaadin'], - ['icon' => 'credit-card', 'prefix' => 'vaadin'], - ['icon' => 'wallet', 'prefix' => 'vaadin'], - ['icon' => 'user', 'prefix' => 'vaadin'], - ['icon' => 'users', 'prefix' => 'vaadin'], - ['icon' => 'group', 'prefix' => 'vaadin'], - ['icon' => 'user-card', 'prefix' => 'vaadin'], - ['icon' => 'male', 'prefix' => 'vaadin'], - ['icon' => 'female', 'prefix' => 'vaadin'], - ['icon' => 'envelope', 'prefix' => 'vaadin'], - ['icon' => 'envelope-o', 'prefix' => 'vaadin'], - ['icon' => 'envelope-open', 'prefix' => 'vaadin'], - ['icon' => 'mailbox', 'prefix' => 'vaadin'], - ['icon' => 'comment', 'prefix' => 'vaadin'], - ['icon' => 'comments', 'prefix' => 'vaadin'], - ['icon' => 'comments-o', 'prefix' => 'vaadin'], - ['icon' => 'camera', 'prefix' => 'vaadin'], - ['icon' => 'film', 'prefix' => 'vaadin'], - ['icon' => 'music', 'prefix' => 'vaadin'], - ['icon' => 'book', 'prefix' => 'vaadin'], - ['icon' => 'file-text', 'prefix' => 'vaadin'], - ['icon' => 'file-text-o', 'prefix' => 'vaadin'], - ['icon' => 'sun-o', 'prefix' => 'vaadin'], - ['icon' => 'moon-o', 'prefix' => 'vaadin'], - ['icon' => 'palete', 'prefix' => 'vaadin'], - ['icon' => 'paintbrush', 'prefix' => 'vaadin'], - ['icon' => 'trophy', 'prefix' => 'vaadin'], - ['icon' => 'academy-cap', 'prefix' => 'vaadin'], - ['icon' => 'lightbulb', 'prefix' => 'vaadin'], - ['icon' => 'eye', 'prefix' => 'vaadin'], - ['icon' => 'eye-slash', 'prefix' => 'vaadin'], - ['icon' => 'map-marker', 'prefix' => 'vaadin'], - ['icon' => 'location-arrow', 'prefix' => 'vaadin'], - ['icon' => 'clock', 'prefix' => 'vaadin'], - ['icon' => 'calendar', 'prefix' => 'vaadin'], - ['icon' => 'calendar-o', 'prefix' => 'vaadin'], - ['icon' => 'gift', 'prefix' => 'vaadin'], - ['icon' => 'gamepad', 'prefix' => 'vaadin'], - ['icon' => 'controller', 'prefix' => 'vaadin'], - - // File Icons (codeat3/blade-file-icons) - ['icon' => 'docker', 'prefix' => 'fileicon'], - ['icon' => 'kubernetes', 'prefix' => 'fileicon'], - ['icon' => 'nginx', 'prefix' => 'fileicon'], - ['icon' => 'alpine-linux', 'prefix' => 'fileicon'], - ['icon' => 'arch-linux', 'prefix' => 'fileicon'], - ['icon' => 'xubuntu', 'prefix' => 'fileicon'], - ['icon' => 'virtualbox', 'prefix' => 'fileicon'], - ['icon' => 'virtualbox-alt', 'prefix' => 'fileicon'], - ['icon' => 'vmware', 'prefix' => 'fileicon'], - ['icon' => 'terminal', 'prefix' => 'fileicon'], - ['icon' => 'powershell', 'prefix' => 'fileicon'], - ['icon' => 'serverless', 'prefix' => 'fileicon'], - ['icon' => 'devcontainer', 'prefix' => 'fileicon'], - ['icon' => 'config', 'prefix' => 'fileicon'], - ['icon' => 'editorconfig', 'prefix' => 'fileicon'], - ['icon' => 'php', 'prefix' => 'fileicon'], - ['icon' => 'go', 'prefix' => 'fileicon'], - ['icon' => 'kotlin', 'prefix' => 'fileicon'], - ['icon' => 'c', 'prefix' => 'fileicon'], - ['icon' => 'npm', 'prefix' => 'fileicon'], - ['icon' => 'yarn', 'prefix' => 'fileicon'], - ['icon' => 'vscode', 'prefix' => 'fileicon'], - ['icon' => 'nano', 'prefix' => 'fileicon'], - ['icon' => 'gitlab', 'prefix' => 'fileicon'], - ['icon' => 'sqlite', 'prefix' => 'fileicon'], - ['icon' => 'json-1', 'prefix' => 'fileicon'], - ['icon' => 'json-2', 'prefix' => 'fileicon'], - ['icon' => 'yaml', 'prefix' => 'fileicon'], - ['icon' => 'typescript', 'prefix' => 'fileicon'], - ['icon' => 'vue', 'prefix' => 'fileicon'], - ['icon' => 'svelte', 'prefix' => 'fileicon'], - ['icon' => 'laravel', 'prefix' => 'fileicon'], - ['icon' => 'flask', 'prefix' => 'fileicon'], - ['icon' => 'terraform', 'prefix' => 'fileicon'], - ['icon' => 'ansible', 'prefix' => 'fileicon'], - ['icon' => 'ansible-alt', 'prefix' => 'fileicon'], - ['icon' => 'puppet', 'prefix' => 'fileicon'], - ['icon' => 'jenkins', 'prefix' => 'fileicon'], - ['icon' => 'jsx', 'prefix' => 'fileicon'], - ['icon' => 'tsx', 'prefix' => 'fileicon'], - ['icon' => 'perl6', 'prefix' => 'fileicon'], - ['icon' => 'rubygems', 'prefix' => 'fileicon'], - ['icon' => 'toml', 'prefix' => 'fileicon'], - ['icon' => 'tailwind', 'prefix' => 'fileicon'], - ['icon' => 'webpack', 'prefix' => 'fileicon'], - ['icon' => 'vite', 'prefix' => 'fileicon'], - ['icon' => 'nextjs', 'prefix' => 'fileicon'], - ['icon' => 'nestjs', 'prefix' => 'fileicon'], - ['icon' => 'nodemon', 'prefix' => 'fileicon'], - ['icon' => 'vagrant', 'prefix' => 'fileicon'], - ['icon' => 'saltstack', 'prefix' => 'fileicon'], - ['icon' => 'chef', 'prefix' => 'fileicon'], - ['icon' => 'circleci', 'prefix' => 'fileicon'], - ['icon' => 'r', 'prefix' => 'fileicon'], - ['icon' => 'apple', 'prefix' => 'fileicon'], - ['icon' => 'arduino', 'prefix' => 'fileicon'], - ['icon' => 'babel', 'prefix' => 'fileicon'], - ['icon' => 'composer', 'prefix' => 'fileicon'], - ['icon' => 'snyk', 'prefix' => 'fileicon'], - ['icon' => 'storybook', 'prefix' => 'fileicon'], - - // Dev Icons (codeat3/blade-devicons) - Technology logos - ['icon' => 'docker', 'prefix' => 'devicon'], - ['icon' => 'kubernetes', 'prefix' => 'devicon'], - ['icon' => 'linux', 'prefix' => 'devicon'], - ['icon' => 'ubuntu', 'prefix' => 'devicon'], - ['icon' => 'debian', 'prefix' => 'devicon'], - ['icon' => 'centos', 'prefix' => 'devicon'], - ['icon' => 'redhat', 'prefix' => 'devicon'], - ['icon' => 'fedora', 'prefix' => 'devicon'], - ['icon' => 'opensuse', 'prefix' => 'devicon'], - ['icon' => 'nixos', 'prefix' => 'devicon'], - ['icon' => 'gentoo', 'prefix' => 'devicon'], - ['icon' => 'android', 'prefix' => 'devicon'], - ['icon' => 'apache', 'prefix' => 'devicon'], - ['icon' => 'mysql', 'prefix' => 'devicon'], - ['icon' => 'postgresql', 'prefix' => 'devicon'], - ['icon' => 'mongodb', 'prefix' => 'devicon'], - ['icon' => 'redis', 'prefix' => 'devicon'], - ['icon' => 'sqlite', 'prefix' => 'devicon'], - ['icon' => 'neo4j', 'prefix' => 'devicon'], - ['icon' => 'couchdb', 'prefix' => 'devicon'], - ['icon' => 'microsoftsqlserver', 'prefix' => 'devicon'], - ['icon' => 'python', 'prefix' => 'devicon'], - ['icon' => 'php', 'prefix' => 'devicon'], - ['icon' => 'java', 'prefix' => 'devicon'], - ['icon' => 'javascript', 'prefix' => 'devicon'], - ['icon' => 'typescript', 'prefix' => 'devicon'], - ['icon' => 'nodejs', 'prefix' => 'devicon'], - ['icon' => 'go', 'prefix' => 'devicon'], - ['icon' => 'rust', 'prefix' => 'devicon'], - ['icon' => 'c', 'prefix' => 'devicon'], - ['icon' => 'cplusplus', 'prefix' => 'devicon'], - ['icon' => 'csharp', 'prefix' => 'devicon'], - ['icon' => 'ruby', 'prefix' => 'devicon'], - ['icon' => 'perl', 'prefix' => 'devicon'], - ['icon' => 'swift', 'prefix' => 'devicon'], - ['icon' => 'kotlin', 'prefix' => 'devicon'], - ['icon' => 'scala', 'prefix' => 'devicon'], - ['icon' => 'r', 'prefix' => 'devicon'], - ['icon' => 'lua', 'prefix' => 'devicon'], - ['icon' => 'dart', 'prefix' => 'devicon'], - ['icon' => 'elixir', 'prefix' => 'devicon'], - ['icon' => 'erlang', 'prefix' => 'devicon'], - ['icon' => 'haskell', 'prefix' => 'devicon'], - ['icon' => 'julia', 'prefix' => 'devicon'], - ['icon' => 'bash', 'prefix' => 'devicon'], - ['icon' => 'html5', 'prefix' => 'devicon'], - ['icon' => 'css3', 'prefix' => 'devicon'], - ['icon' => 'laravel', 'prefix' => 'devicon'], - ['icon' => 'django', 'prefix' => 'devicon'], - ['icon' => 'rails', 'prefix' => 'devicon'], - ['icon' => 'spring', 'prefix' => 'devicon'], - ['icon' => 'fastapi', 'prefix' => 'devicon'], - ['icon' => 'nestjs', 'prefix' => 'devicon'], - ['icon' => 'vuejs', 'prefix' => 'devicon'], - ['icon' => 'angularjs', 'prefix' => 'devicon'], - ['icon' => 'svelte', 'prefix' => 'devicon'], - ['icon' => 'nuxtjs', 'prefix' => 'devicon'], - ['icon' => 'gatsby', 'prefix' => 'devicon'], - ['icon' => 'bootstrap', 'prefix' => 'devicon'], - ['icon' => 'tailwindcss', 'prefix' => 'devicon'], - ['icon' => 'materialui', 'prefix' => 'devicon'], - ['icon' => 'git', 'prefix' => 'devicon'], - ['icon' => 'gitlab', 'prefix' => 'devicon'], - ['icon' => 'azure', 'prefix' => 'devicon'], - ['icon' => 'googlecloud', 'prefix' => 'devicon'], - ['icon' => 'digitalocean', 'prefix' => 'devicon'], - ['icon' => 'heroku', 'prefix' => 'devicon'], - ['icon' => 'firebase', 'prefix' => 'devicon'], - ['icon' => 'terraform', 'prefix' => 'devicon'], - ['icon' => 'ansible', 'prefix' => 'devicon'], - ['icon' => 'jenkins', 'prefix' => 'devicon'], - ['icon' => 'circleci', 'prefix' => 'devicon'], - ['icon' => 'travis', 'prefix' => 'devicon'], - ['icon' => 'argocd', 'prefix' => 'devicon'], - ['icon' => 'vagrant', 'prefix' => 'devicon'], - ['icon' => 'podman', 'prefix' => 'devicon'], - ['icon' => 'webpack', 'prefix' => 'devicon'], - ['icon' => 'babel', 'prefix' => 'devicon'], - ['icon' => 'gradle', 'prefix' => 'devicon'], - ['icon' => 'vscode', 'prefix' => 'devicon'], - ['icon' => 'visualstudio', 'prefix' => 'devicon'], - ['icon' => 'intellij', 'prefix' => 'devicon'], - ['icon' => 'pycharm', 'prefix' => 'devicon'], - ['icon' => 'phpstorm', 'prefix' => 'devicon'], - ['icon' => 'webstorm', 'prefix' => 'devicon'], - ['icon' => 'vim', 'prefix' => 'devicon'], - ['icon' => 'xcode', 'prefix' => 'devicon'], - ['icon' => 'androidstudio', 'prefix' => 'devicon'], - ['icon' => 'jupyter', 'prefix' => 'devicon'], - ['icon' => 'chrome', 'prefix' => 'devicon'], - ['icon' => 'firefox', 'prefix' => 'devicon'], - ['icon' => 'safari', 'prefix' => 'devicon'], - ['icon' => 'opera', 'prefix' => 'devicon'], - ['icon' => 'wordpress', 'prefix' => 'devicon'], - ['icon' => 'drupal', 'prefix' => 'devicon'], - ['icon' => 'woocommerce', 'prefix' => 'devicon'], - ['icon' => 'graphql', 'prefix' => 'devicon'], - ['icon' => 'arduino', 'prefix' => 'devicon'], - ['icon' => 'flutter', 'prefix' => 'devicon'], - ['icon' => 'figma', 'prefix' => 'devicon'], - ['icon' => 'photoshop', 'prefix' => 'devicon'], - ['icon' => 'illustrator', 'prefix' => 'devicon'], - ['icon' => 'premierepro', 'prefix' => 'devicon'], - ['icon' => 'aftereffects', 'prefix' => 'devicon'], - ['icon' => 'slack', 'prefix' => 'devicon'], - ['icon' => 'trello', 'prefix' => 'devicon'], - ['icon' => 'jira', 'prefix' => 'devicon'], - ['icon' => 'putty', 'prefix' => 'devicon'], - ['icon' => 'solidity', 'prefix' => 'devicon'], - - // Coolicons (codeat3/blade-coolicons) - ['icon' => 'globe', 'prefix' => 'coolicon'], - ['icon' => 'planet', 'prefix' => 'coolicon'], - ['icon' => 'desktop', 'prefix' => 'coolicon'], - ['icon' => 'desktop-tower', 'prefix' => 'coolicon'], - ['icon' => 'laptop', 'prefix' => 'coolicon'], - ['icon' => 'monitor', 'prefix' => 'coolicon'], - ['icon' => 'monitor-play', 'prefix' => 'coolicon'], - ['icon' => 'tablet', 'prefix' => 'coolicon'], - ['icon' => 'tablet-button', 'prefix' => 'coolicon'], - ['icon' => 'mobile', 'prefix' => 'coolicon'], - ['icon' => 'mobile-button', 'prefix' => 'coolicon'], - ['icon' => 'devices', 'prefix' => 'coolicon'], - ['icon' => 'cloud', 'prefix' => 'coolicon'], - ['icon' => 'cloud-add', 'prefix' => 'coolicon'], - ['icon' => 'cloud-check', 'prefix' => 'coolicon'], - ['icon' => 'cloud-close', 'prefix' => 'coolicon'], - ['icon' => 'cloud-download', 'prefix' => 'coolicon'], - ['icon' => 'cloud-upload', 'prefix' => 'coolicon'], - ['icon' => 'cloud-off', 'prefix' => 'coolicon'], - ['icon' => 'cloud-remove', 'prefix' => 'coolicon'], - ['icon' => 'data', 'prefix' => 'coolicon'], - ['icon' => 'cylinder', 'prefix' => 'coolicon'], - ['icon' => 'instance', 'prefix' => 'coolicon'], - ['icon' => 'layers', 'prefix' => 'coolicon'], - ['icon' => 'layer', 'prefix' => 'coolicon'], - ['icon' => 'terminal', 'prefix' => 'coolicon'], - ['icon' => 'code', 'prefix' => 'coolicon'], - ['icon' => 'window', 'prefix' => 'coolicon'], - ['icon' => 'window-check', 'prefix' => 'coolicon'], - ['icon' => 'window-close', 'prefix' => 'coolicon'], - ['icon' => 'window-code-block', 'prefix' => 'coolicon'], - ['icon' => 'window-sidebar', 'prefix' => 'coolicon'], - ['icon' => 'window-terminal', 'prefix' => 'coolicon'], - ['icon' => 'keyboard', 'prefix' => 'coolicon'], - ['icon' => 'mouse', 'prefix' => 'coolicon'], - ['icon' => 'wifi-high', 'prefix' => 'coolicon'], - ['icon' => 'wifi-medium', 'prefix' => 'coolicon'], - ['icon' => 'wifi-low', 'prefix' => 'coolicon'], - ['icon' => 'wifi-none', 'prefix' => 'coolicon'], - ['icon' => 'wifi-off', 'prefix' => 'coolicon'], - ['icon' => 'wifi-problem', 'prefix' => 'coolicon'], - ['icon' => 'shield', 'prefix' => 'coolicon'], - ['icon' => 'shield-check', 'prefix' => 'coolicon'], - ['icon' => 'shield-warning', 'prefix' => 'coolicon'], - ['icon' => 'lock', 'prefix' => 'coolicon'], - ['icon' => 'lock-open', 'prefix' => 'coolicon'], - ['icon' => 'settings', 'prefix' => 'coolicon'], - ['icon' => 'settings-future', 'prefix' => 'coolicon'], - ['icon' => 'slider-01', 'prefix' => 'coolicon'], - ['icon' => 'slider-02', 'prefix' => 'coolicon'], - ['icon' => 'slider-03', 'prefix' => 'coolicon'], - ['icon' => 'filter', 'prefix' => 'coolicon'], - ['icon' => 'filter-off', 'prefix' => 'coolicon'], - ['icon' => 'chart-bar-horizontal-01', 'prefix' => 'coolicon'], - ['icon' => 'chart-bar-vertical-01', 'prefix' => 'coolicon'], - ['icon' => 'chart-line', 'prefix' => 'coolicon'], - ['icon' => 'chart-pie', 'prefix' => 'coolicon'], - ['icon' => 'trending-up', 'prefix' => 'coolicon'], - ['icon' => 'trending-down', 'prefix' => 'coolicon'], - ['icon' => 'bell', 'prefix' => 'coolicon'], - ['icon' => 'bell-add', 'prefix' => 'coolicon'], - ['icon' => 'bell-notification', 'prefix' => 'coolicon'], - ['icon' => 'bell-off', 'prefix' => 'coolicon'], - ['icon' => 'bell-ring', 'prefix' => 'coolicon'], - ['icon' => 'mail', 'prefix' => 'coolicon'], - ['icon' => 'mail-open', 'prefix' => 'coolicon'], - ['icon' => 'chat', 'prefix' => 'coolicon'], - ['icon' => 'chat-add', 'prefix' => 'coolicon'], - ['icon' => 'chat-check', 'prefix' => 'coolicon'], - ['icon' => 'chat-close', 'prefix' => 'coolicon'], - ['icon' => 'chat-dots', 'prefix' => 'coolicon'], - ['icon' => 'chat-conversation', 'prefix' => 'coolicon'], - ['icon' => 'folder', 'prefix' => 'coolicon'], - ['icon' => 'folder-add', 'prefix' => 'coolicon'], - ['icon' => 'folder-check', 'prefix' => 'coolicon'], - ['icon' => 'folder-close', 'prefix' => 'coolicon'], - ['icon' => 'folder-code', 'prefix' => 'coolicon'], - ['icon' => 'folder-document', 'prefix' => 'coolicon'], - ['icon' => 'folder-download', 'prefix' => 'coolicon'], - ['icon' => 'folder-open', 'prefix' => 'coolicon'], - ['icon' => 'folder-upload', 'prefix' => 'coolicon'], - ['icon' => 'folders', 'prefix' => 'coolicon'], - ['icon' => 'file-blank', 'prefix' => 'coolicon'], - ['icon' => 'file-add', 'prefix' => 'coolicon'], - ['icon' => 'file-check', 'prefix' => 'coolicon'], - ['icon' => 'file-close', 'prefix' => 'coolicon'], - ['icon' => 'file-code', 'prefix' => 'coolicon'], - ['icon' => 'file-document', 'prefix' => 'coolicon'], - ['icon' => 'file-download', 'prefix' => 'coolicon'], - ['icon' => 'file-upload', 'prefix' => 'coolicon'], - ['icon' => 'files', 'prefix' => 'coolicon'], - ['icon' => 'user-01', 'prefix' => 'coolicon'], - ['icon' => 'user-02', 'prefix' => 'coolicon'], - ['icon' => 'user-03', 'prefix' => 'coolicon'], - ['icon' => 'user-add', 'prefix' => 'coolicon'], - ['icon' => 'user-check', 'prefix' => 'coolicon'], - ['icon' => 'user-circle', 'prefix' => 'coolicon'], - ['icon' => 'user-close', 'prefix' => 'coolicon'], - ['icon' => 'user-remove', 'prefix' => 'coolicon'], - ['icon' => 'user-square', 'prefix' => 'coolicon'], - ['icon' => 'users', 'prefix' => 'coolicon'], - ['icon' => 'users-group', 'prefix' => 'coolicon'], - ['icon' => 'star', 'prefix' => 'coolicon'], - ['icon' => 'heart-01', 'prefix' => 'coolicon'], - ['icon' => 'heart-02', 'prefix' => 'coolicon'], - ['icon' => 'bookmark', 'prefix' => 'coolicon'], - ['icon' => 'flag', 'prefix' => 'coolicon'], - ['icon' => 'tag', 'prefix' => 'coolicon'], - ['icon' => 'label', 'prefix' => 'coolicon'], - ['icon' => 'house-01', 'prefix' => 'coolicon'], - ['icon' => 'house-02', 'prefix' => 'coolicon'], - ['icon' => 'house-03', 'prefix' => 'coolicon'], - ['icon' => 'house-add', 'prefix' => 'coolicon'], - ['icon' => 'house-check', 'prefix' => 'coolicon'], - ['icon' => 'building-01', 'prefix' => 'coolicon'], - ['icon' => 'building-02', 'prefix' => 'coolicon'], - ['icon' => 'building-03', 'prefix' => 'coolicon'], - ['icon' => 'building-04', 'prefix' => 'coolicon'], - ['icon' => 'sun', 'prefix' => 'coolicon'], - ['icon' => 'moon', 'prefix' => 'coolicon'], - ['icon' => 'bulb', 'prefix' => 'coolicon'], - ['icon' => 'compass', 'prefix' => 'coolicon'], - ['icon' => 'map', 'prefix' => 'coolicon'], - ['icon' => 'map-pin', 'prefix' => 'coolicon'], - ['icon' => 'navigation', 'prefix' => 'coolicon'], - ['icon' => 'clock', 'prefix' => 'coolicon'], - ['icon' => 'timer', 'prefix' => 'coolicon'], - ['icon' => 'timer-add', 'prefix' => 'coolicon'], - ['icon' => 'calendar', 'prefix' => 'coolicon'], - ['icon' => 'calendar-add', 'prefix' => 'coolicon'], - ['icon' => 'calendar-check', 'prefix' => 'coolicon'], - ['icon' => 'calendar-event', 'prefix' => 'coolicon'], - ['icon' => 'gift', 'prefix' => 'coolicon'], - ['icon' => 'credit-card-01', 'prefix' => 'coolicon'], - ['icon' => 'credit-card-02', 'prefix' => 'coolicon'], - ['icon' => 'shopping-bag-01', 'prefix' => 'coolicon'], - ['icon' => 'shopping-bag-02', 'prefix' => 'coolicon'], - ['icon' => 'shopping-cart-01', 'prefix' => 'coolicon'], - ['icon' => 'shopping-cart-02', 'prefix' => 'coolicon'], - ['icon' => 'suitcase', 'prefix' => 'coolicon'], - ['icon' => 'archive', 'prefix' => 'coolicon'], - ['icon' => 'book', 'prefix' => 'coolicon'], - ['icon' => 'book-open', 'prefix' => 'coolicon'], - ['icon' => 'notebook', 'prefix' => 'coolicon'], - ['icon' => 'note', 'prefix' => 'coolicon'], - ['icon' => 'note-edit', 'prefix' => 'coolicon'], - ['icon' => 'note-search', 'prefix' => 'coolicon'], - ['icon' => 'camera', 'prefix' => 'coolicon'], - ['icon' => 'image-01', 'prefix' => 'coolicon'], - ['icon' => 'image-02', 'prefix' => 'coolicon'], - ['icon' => 'headphones', 'prefix' => 'coolicon'], - ['icon' => 'printer', 'prefix' => 'coolicon'], - ['icon' => 'airplay', 'prefix' => 'coolicon'], - ['icon' => 'chromecast', 'prefix' => 'coolicon'], - ['icon' => 'download', 'prefix' => 'coolicon'], - ['icon' => 'download-package', 'prefix' => 'coolicon'], - ['icon' => 'cloud-upload', 'prefix' => 'coolicon'], - ['icon' => 'link', 'prefix' => 'coolicon'], - ['icon' => 'link-horizontal', 'prefix' => 'coolicon'], - ['icon' => 'link-vertical', 'prefix' => 'coolicon'], - ['icon' => 'link-break', 'prefix' => 'coolicon'], - ['icon' => 'external-link', 'prefix' => 'coolicon'], - ['icon' => 'share-android', 'prefix' => 'coolicon'], - ['icon' => 'share-ios-export', 'prefix' => 'coolicon'], - ['icon' => 'qr-code', 'prefix' => 'coolicon'], - ['icon' => 'copy', 'prefix' => 'coolicon'], - ['icon' => 'save', 'prefix' => 'coolicon'], - ['icon' => 'trash-empty', 'prefix' => 'coolicon'], - ['icon' => 'trash-full', 'prefix' => 'coolicon'], - ['icon' => 'puzzle', 'prefix' => 'coolicon'], - ['icon' => 'command', 'prefix' => 'coolicon'], - ['icon' => 'option', 'prefix' => 'coolicon'], - ['icon' => 'rainbow', 'prefix' => 'coolicon'], - ['icon' => 'leaf', 'prefix' => 'coolicon'], - ['icon' => 'water-drop', 'prefix' => 'coolicon'], - ['icon' => 'cookie', 'prefix' => 'coolicon'], - ['icon' => 'coffee', 'prefix' => 'coolicon'], - ['icon' => 'coffe-to-go', 'prefix' => 'coolicon'], - ['icon' => 'cupcake', 'prefix' => 'coolicon'], - ['icon' => 'first-aid', 'prefix' => 'coolicon'], - ['icon' => 'car-auto', 'prefix' => 'coolicon'], - ['icon' => 'phone', 'prefix' => 'coolicon'], - ['icon' => 'alarm', 'prefix' => 'coolicon'], - ['icon' => 'help', 'prefix' => 'coolicon'], - ['icon' => 'info', 'prefix' => 'coolicon'], - ['icon' => 'warning', 'prefix' => 'coolicon'], - ['icon' => 'circle-check', 'prefix' => 'coolicon'], - ['icon' => 'circle-warning', 'prefix' => 'coolicon'], - ['icon' => 'circle-help', 'prefix' => 'coolicon'], - ['icon' => 'square-check', 'prefix' => 'coolicon'], - ['icon' => 'square-warning', 'prefix' => 'coolicon'], - ['icon' => 'square-help', 'prefix' => 'coolicon'], - ['icon' => 'triangle-check', 'prefix' => 'coolicon'], - ['icon' => 'triangle-warning', 'prefix' => 'coolicon'], - ['icon' => 'octagon-check', 'prefix' => 'coolicon'], - ['icon' => 'octagon-warning', 'prefix' => 'coolicon'], - ['icon' => 'wavy-check', 'prefix' => 'coolicon'], - ['icon' => 'wavy-warning', 'prefix' => 'coolicon'], - ['icon' => 'wavy-help', 'prefix' => 'coolicon'], - ['icon' => 'play', 'prefix' => 'coolicon'], - ['icon' => 'play-circle', 'prefix' => 'coolicon'], - ['icon' => 'pause', 'prefix' => 'coolicon'], - ['icon' => 'pause-circle', 'prefix' => 'coolicon'], - ['icon' => 'stop', 'prefix' => 'coolicon'], - ['icon' => 'stop-circle', 'prefix' => 'coolicon'], - ['icon' => 'stop-sign', 'prefix' => 'coolicon'], - ['icon' => 'forward', 'prefix' => 'coolicon'], - ['icon' => 'rewind', 'prefix' => 'coolicon'], - ['icon' => 'skip-forward', 'prefix' => 'coolicon'], - ['icon' => 'skip-back', 'prefix' => 'coolicon'], - ['icon' => 'shuffle', 'prefix' => 'coolicon'], - ['icon' => 'volume-max', 'prefix' => 'coolicon'], - ['icon' => 'volume-min', 'prefix' => 'coolicon'], - ['icon' => 'volume-off', 'prefix' => 'coolicon'], - ['icon' => 'expand', 'prefix' => 'coolicon'], - ['icon' => 'shrink', 'prefix' => 'coolicon'], - ['icon' => 'crop', 'prefix' => 'coolicon'], - ['icon' => 'move', 'prefix' => 'coolicon'], - ['icon' => 'show', 'prefix' => 'coolicon'], - ['icon' => 'hide', 'prefix' => 'coolicon'], - ['icon' => 'search-magnifying-glass', 'prefix' => 'coolicon'], - ['icon' => 'magnifying-glass-plus', 'prefix' => 'coolicon'], - ['icon' => 'magnifying-glass-minus', 'prefix' => 'coolicon'], - ['icon' => 'edit-pencil-01', 'prefix' => 'coolicon'], - ['icon' => 'edit-pencil-02', 'prefix' => 'coolicon'], - ['icon' => 'edit-pencil-line-01', 'prefix' => 'coolicon'], - ['icon' => 'edit-pencil-line-02', 'prefix' => 'coolicon'], - ['icon' => 'check', 'prefix' => 'coolicon'], - ['icon' => 'check-big', 'prefix' => 'coolicon'], - ['icon' => 'check-all', 'prefix' => 'coolicon'], - ['icon' => 'check-all-big', 'prefix' => 'coolicon'], - ['icon' => 'close-lg', 'prefix' => 'coolicon'], - ['icon' => 'close-md', 'prefix' => 'coolicon'], - ['icon' => 'close-sm', 'prefix' => 'coolicon'], - ['icon' => 'add-plus', 'prefix' => 'coolicon'], - ['icon' => 'add-plus-circle', 'prefix' => 'coolicon'], - ['icon' => 'add-plus-square', 'prefix' => 'coolicon'], - ['icon' => 'remove-minus', 'prefix' => 'coolicon'], - ['icon' => 'remove-minus-circle', 'prefix' => 'coolicon'], - ['icon' => 'more-horizontal', 'prefix' => 'coolicon'], - ['icon' => 'more-vertical', 'prefix' => 'coolicon'], - ['icon' => 'more-grid-big', 'prefix' => 'coolicon'], - ['icon' => 'more-grid-small', 'prefix' => 'coolicon'], - ['icon' => 'hamburger-lg', 'prefix' => 'coolicon'], - ['icon' => 'hamburger-md', 'prefix' => 'coolicon'], - ['icon' => 'menu-alt-01', 'prefix' => 'coolicon'], - ['icon' => 'menu-alt-02', 'prefix' => 'coolicon'], - ['icon' => 'menu-alt-03', 'prefix' => 'coolicon'], - ['icon' => 'menu-alt-04', 'prefix' => 'coolicon'], - ['icon' => 'menu-alt-05', 'prefix' => 'coolicon'], - ['icon' => 'list-checklist', 'prefix' => 'coolicon'], - ['icon' => 'list-ordered', 'prefix' => 'coolicon'], - ['icon' => 'list-unordered', 'prefix' => 'coolicon'], - ['icon' => 'columns', 'prefix' => 'coolicon'], - ['icon' => 'rows', 'prefix' => 'coolicon'], - ['icon' => 'table', 'prefix' => 'coolicon'], - ['icon' => 'table-add', 'prefix' => 'coolicon'], - ['icon' => 'table-remove', 'prefix' => 'coolicon'], - ['icon' => 'figma', 'prefix' => 'coolicon'], - ['icon' => 'swatches-palette', 'prefix' => 'coolicon'], - ['icon' => 'ruler', 'prefix' => 'coolicon'], - ['icon' => 'path', 'prefix' => 'coolicon'], - ['icon' => 'text', 'prefix' => 'coolicon'], - ['icon' => 'font', 'prefix' => 'coolicon'], - ['icon' => 'bold', 'prefix' => 'coolicon'], - ['icon' => 'italic', 'prefix' => 'coolicon'], - ['icon' => 'underline', 'prefix' => 'coolicon'], - ['icon' => 'strikethrough', 'prefix' => 'coolicon'], - ['icon' => 'paragraph', 'prefix' => 'coolicon'], - ['icon' => 'heading', 'prefix' => 'coolicon'], - ['icon' => 'text-align-left', 'prefix' => 'coolicon'], - ['icon' => 'text-align-center', 'prefix' => 'coolicon'], - ['icon' => 'text-align-right', 'prefix' => 'coolicon'], - ['icon' => 'text-align-justify', 'prefix' => 'coolicon'], - ['icon' => 'undo', 'prefix' => 'coolicon'], - ['icon' => 'redo', 'prefix' => 'coolicon'], - ['icon' => 'log-out', 'prefix' => 'coolicon'], - ['icon' => 'exit', 'prefix' => 'coolicon'], - ['icon' => 'paper-plane', 'prefix' => 'coolicon'], - ['icon' => 'loading', 'prefix' => 'coolicon'], - ['icon' => 'arrows-reload-01', 'prefix' => 'coolicon'], - ['icon' => 'arrow-reload-02', 'prefix' => 'coolicon'], - - // Material Design Icons (postare/blade-mdi) - // Server & Infrastructure - ['icon' => 'server', 'prefix' => 'mdi'], - ['icon' => 'server-network', 'prefix' => 'mdi'], - ['icon' => 'server-network-off', 'prefix' => 'mdi'], - ['icon' => 'server-off', 'prefix' => 'mdi'], - ['icon' => 'server-security', 'prefix' => 'mdi'], - ['icon' => 'database', 'prefix' => 'mdi'], - ['icon' => 'database-outline', 'prefix' => 'mdi'], - ['icon' => 'database-cog', 'prefix' => 'mdi'], - ['icon' => 'database-sync', 'prefix' => 'mdi'], - ['icon' => 'database-lock', 'prefix' => 'mdi'], - ['icon' => 'database-check', 'prefix' => 'mdi'], - ['icon' => 'database-search', 'prefix' => 'mdi'], - ['icon' => 'database-export', 'prefix' => 'mdi'], - ['icon' => 'database-import', 'prefix' => 'mdi'], - ['icon' => 'harddisk', 'prefix' => 'mdi'], - ['icon' => 'harddisk-plus', 'prefix' => 'mdi'], - ['icon' => 'harddisk-remove', 'prefix' => 'mdi'], - ['icon' => 'memory', 'prefix' => 'mdi'], - ['icon' => 'chip', 'prefix' => 'mdi'], - ['icon' => 'cpu-32-bit', 'prefix' => 'mdi'], - ['icon' => 'cpu-64-bit', 'prefix' => 'mdi'], - // Cloud - ['icon' => 'cloud', 'prefix' => 'mdi'], - ['icon' => 'cloud-outline', 'prefix' => 'mdi'], - ['icon' => 'cloud-check', 'prefix' => 'mdi'], - ['icon' => 'cloud-sync', 'prefix' => 'mdi'], - ['icon' => 'cloud-download', 'prefix' => 'mdi'], - ['icon' => 'cloud-upload', 'prefix' => 'mdi'], - ['icon' => 'cloud-lock', 'prefix' => 'mdi'], - ['icon' => 'cloud-cog', 'prefix' => 'mdi'], - ['icon' => 'cloud-refresh', 'prefix' => 'mdi'], - ['icon' => 'cloud-off', 'prefix' => 'mdi'], - ['icon' => 'cloud-braces', 'prefix' => 'mdi'], - ['icon' => 'clouds', 'prefix' => 'mdi'], - // Network - ['icon' => 'lan', 'prefix' => 'mdi'], - ['icon' => 'lan-connect', 'prefix' => 'mdi'], - ['icon' => 'lan-disconnect', 'prefix' => 'mdi'], - ['icon' => 'lan-check', 'prefix' => 'mdi'], - ['icon' => 'lan-pending', 'prefix' => 'mdi'], - ['icon' => 'ethernet', 'prefix' => 'mdi'], - ['icon' => 'ethernet-cable', 'prefix' => 'mdi'], - ['icon' => 'ethernet-off', 'prefix' => 'mdi'], - ['icon' => 'wifi', 'prefix' => 'mdi'], - ['icon' => 'wifi-off', 'prefix' => 'mdi'], - ['icon' => 'wifi-strength-4', 'prefix' => 'mdi'], - ['icon' => 'wifi-strength-3', 'prefix' => 'mdi'], - ['icon' => 'wifi-strength-2', 'prefix' => 'mdi'], - ['icon' => 'wifi-strength-1', 'prefix' => 'mdi'], - ['icon' => 'router', 'prefix' => 'mdi'], - ['icon' => 'router-network', 'prefix' => 'mdi'], - ['icon' => 'router-wireless', 'prefix' => 'mdi'], - ['icon' => 'router-wireless-settings', 'prefix' => 'mdi'], - ['icon' => 'ip', 'prefix' => 'mdi'], - ['icon' => 'ip-network', 'prefix' => 'mdi'], - ['icon' => 'ip-outline', 'prefix' => 'mdi'], - ['icon' => 'dns', 'prefix' => 'mdi'], - ['icon' => 'dns-outline', 'prefix' => 'mdi'], - ['icon' => 'antenna', 'prefix' => 'mdi'], - ['icon' => 'access-point', 'prefix' => 'mdi'], - ['icon' => 'access-point-network', 'prefix' => 'mdi'], - ['icon' => 'broadcast', 'prefix' => 'mdi'], - ['icon' => 'broadcast-off', 'prefix' => 'mdi'], - ['icon' => 'network', 'prefix' => 'mdi'], - ['icon' => 'network-outline', 'prefix' => 'mdi'], - ['icon' => 'network-off', 'prefix' => 'mdi'], - ['icon' => 'network-off-outline', 'prefix' => 'mdi'], - // Security - ['icon' => 'shield', 'prefix' => 'mdi'], - ['icon' => 'shield-outline', 'prefix' => 'mdi'], - ['icon' => 'shield-check', 'prefix' => 'mdi'], - ['icon' => 'shield-lock', 'prefix' => 'mdi'], - ['icon' => 'shield-key', 'prefix' => 'mdi'], - ['icon' => 'shield-account', 'prefix' => 'mdi'], - ['icon' => 'lock', 'prefix' => 'mdi'], - ['icon' => 'lock-outline', 'prefix' => 'mdi'], - ['icon' => 'lock-open', 'prefix' => 'mdi'], - ['icon' => 'lock-open-outline', 'prefix' => 'mdi'], - ['icon' => 'key', 'prefix' => 'mdi'], - ['icon' => 'key-outline', 'prefix' => 'mdi'], - ['icon' => 'key-variant', 'prefix' => 'mdi'], - ['icon' => 'key-chain', 'prefix' => 'mdi'], - ['icon' => 'certificate', 'prefix' => 'mdi'], - ['icon' => 'certificate-outline', 'prefix' => 'mdi'], - ['icon' => 'security', 'prefix' => 'mdi'], - ['icon' => 'security-network', 'prefix' => 'mdi'], - ['icon' => 'fingerprint', 'prefix' => 'mdi'], - ['icon' => 'two-factor-authentication', 'prefix' => 'mdi'], - // Development & Code - ['icon' => 'code-braces', 'prefix' => 'mdi'], - ['icon' => 'code-brackets', 'prefix' => 'mdi'], - ['icon' => 'code-tags', 'prefix' => 'mdi'], - ['icon' => 'code-json', 'prefix' => 'mdi'], - ['icon' => 'code-string', 'prefix' => 'mdi'], - ['icon' => 'code-array', 'prefix' => 'mdi'], - ['icon' => 'console', 'prefix' => 'mdi'], - ['icon' => 'console-line', 'prefix' => 'mdi'], - ['icon' => 'console-network', 'prefix' => 'mdi'], - ['icon' => 'powershell', 'prefix' => 'mdi'], - ['icon' => 'bash', 'prefix' => 'mdi'], - ['icon' => 'api', 'prefix' => 'mdi'], - ['icon' => 'api-off', 'prefix' => 'mdi'], - ['icon' => 'webhook', 'prefix' => 'mdi'], - ['icon' => 'function', 'prefix' => 'mdi'], - ['icon' => 'function-variant', 'prefix' => 'mdi'], - ['icon' => 'variable', 'prefix' => 'mdi'], - ['icon' => 'variable-box', 'prefix' => 'mdi'], - ['icon' => 'xml', 'prefix' => 'mdi'], - ['icon' => 'graphql', 'prefix' => 'mdi'], - // Containers & DevOps - ['icon' => 'docker', 'prefix' => 'mdi'], - ['icon' => 'kubernetes', 'prefix' => 'mdi'], - ['icon' => 'package', 'prefix' => 'mdi'], - ['icon' => 'package-variant', 'prefix' => 'mdi'], - ['icon' => 'package-variant-closed', 'prefix' => 'mdi'], - ['icon' => 'cube', 'prefix' => 'mdi'], - ['icon' => 'cube-outline', 'prefix' => 'mdi'], - ['icon' => 'cube-scan', 'prefix' => 'mdi'], - ['icon' => 'cube-send', 'prefix' => 'mdi'], - ['icon' => 'layers', 'prefix' => 'mdi'], - ['icon' => 'layers-outline', 'prefix' => 'mdi'], - ['icon' => 'layers-triple', 'prefix' => 'mdi'], - ['icon' => 'layers-triple-outline', 'prefix' => 'mdi'], - // Operating Systems & Platforms - ['icon' => 'linux', 'prefix' => 'mdi'], - ['icon' => 'ubuntu', 'prefix' => 'mdi'], - ['icon' => 'debian', 'prefix' => 'mdi'], - ['icon' => 'fedora', 'prefix' => 'mdi'], - ['icon' => 'redhat', 'prefix' => 'mdi'], - ['icon' => 'centos', 'prefix' => 'mdi'], - ['icon' => 'microsoft-windows', 'prefix' => 'mdi'], - ['icon' => 'apple', 'prefix' => 'mdi'], - ['icon' => 'android', 'prefix' => 'mdi'], - ['icon' => 'freebsd', 'prefix' => 'mdi'], - // Version Control - ['icon' => 'git', 'prefix' => 'mdi'], - ['icon' => 'github', 'prefix' => 'mdi'], - ['icon' => 'gitlab', 'prefix' => 'mdi'], - ['icon' => 'source-branch', 'prefix' => 'mdi'], - ['icon' => 'source-commit', 'prefix' => 'mdi'], - ['icon' => 'source-merge', 'prefix' => 'mdi'], - ['icon' => 'source-pull', 'prefix' => 'mdi'], - ['icon' => 'source-fork', 'prefix' => 'mdi'], - // Devices - ['icon' => 'monitor', 'prefix' => 'mdi'], - ['icon' => 'monitor-multiple', 'prefix' => 'mdi'], - ['icon' => 'monitor-dashboard', 'prefix' => 'mdi'], - ['icon' => 'desktop-tower', 'prefix' => 'mdi'], - ['icon' => 'desktop-tower-monitor', 'prefix' => 'mdi'], - ['icon' => 'desktop-classic', 'prefix' => 'mdi'], - ['icon' => 'laptop', 'prefix' => 'mdi'], - ['icon' => 'cellphone', 'prefix' => 'mdi'], - ['icon' => 'tablet', 'prefix' => 'mdi'], - ['icon' => 'television', 'prefix' => 'mdi'], - // Power & Energy - ['icon' => 'power', 'prefix' => 'mdi'], - ['icon' => 'power-plug', 'prefix' => 'mdi'], - ['icon' => 'power-plug-off', 'prefix' => 'mdi'], - ['icon' => 'power-cycle', 'prefix' => 'mdi'], - ['icon' => 'power-on', 'prefix' => 'mdi'], - ['icon' => 'power-off', 'prefix' => 'mdi'], - ['icon' => 'power-standby', 'prefix' => 'mdi'], - ['icon' => 'bolt', 'prefix' => 'mdi'], - ['icon' => 'lightning-bolt', 'prefix' => 'mdi'], - ['icon' => 'lightning-bolt-circle', 'prefix' => 'mdi'], - ['icon' => 'battery', 'prefix' => 'mdi'], - ['icon' => 'battery-charging', 'prefix' => 'mdi'], - ['icon' => 'battery-outline', 'prefix' => 'mdi'], - // Actions & Operations - ['icon' => 'sync', 'prefix' => 'mdi'], - ['icon' => 'sync-circle', 'prefix' => 'mdi'], - ['icon' => 'sync-off', 'prefix' => 'mdi'], - ['icon' => 'refresh', 'prefix' => 'mdi'], - ['icon' => 'refresh-circle', 'prefix' => 'mdi'], - ['icon' => 'reload', 'prefix' => 'mdi'], - ['icon' => 'restart', 'prefix' => 'mdi'], - ['icon' => 'backup-restore', 'prefix' => 'mdi'], - ['icon' => 'download', 'prefix' => 'mdi'], - ['icon' => 'download-outline', 'prefix' => 'mdi'], - ['icon' => 'download-circle', 'prefix' => 'mdi'], - ['icon' => 'upload', 'prefix' => 'mdi'], - ['icon' => 'upload-outline', 'prefix' => 'mdi'], - ['icon' => 'upload-circle', 'prefix' => 'mdi'], - ['icon' => 'content-copy', 'prefix' => 'mdi'], - ['icon' => 'content-paste', 'prefix' => 'mdi'], - ['icon' => 'content-cut', 'prefix' => 'mdi'], - // Settings & Configuration - ['icon' => 'cog', 'prefix' => 'mdi'], - ['icon' => 'cog-outline', 'prefix' => 'mdi'], - ['icon' => 'cogs', 'prefix' => 'mdi'], - ['icon' => 'cog-transfer', 'prefix' => 'mdi'], - ['icon' => 'cog-sync', 'prefix' => 'mdi'], - ['icon' => 'wrench', 'prefix' => 'mdi'], - ['icon' => 'wrench-outline', 'prefix' => 'mdi'], - ['icon' => 'hammer-wrench', 'prefix' => 'mdi'], - ['icon' => 'toolbox', 'prefix' => 'mdi'], - ['icon' => 'toolbox-outline', 'prefix' => 'mdi'], - ['icon' => 'tune', 'prefix' => 'mdi'], - ['icon' => 'tune-vertical', 'prefix' => 'mdi'], - // Files & Folders - ['icon' => 'folder', 'prefix' => 'mdi'], - ['icon' => 'folder-outline', 'prefix' => 'mdi'], - ['icon' => 'folder-open', 'prefix' => 'mdi'], - ['icon' => 'folder-open-outline', 'prefix' => 'mdi'], - ['icon' => 'folder-network', 'prefix' => 'mdi'], - ['icon' => 'folder-network-outline', 'prefix' => 'mdi'], - ['icon' => 'folder-cog', 'prefix' => 'mdi'], - ['icon' => 'folder-lock', 'prefix' => 'mdi'], - ['icon' => 'folder-zip', 'prefix' => 'mdi'], - ['icon' => 'file', 'prefix' => 'mdi'], - ['icon' => 'file-outline', 'prefix' => 'mdi'], - ['icon' => 'file-document', 'prefix' => 'mdi'], - ['icon' => 'file-document-outline', 'prefix' => 'mdi'], - ['icon' => 'file-code', 'prefix' => 'mdi'], - ['icon' => 'file-code-outline', 'prefix' => 'mdi'], - ['icon' => 'file-cog', 'prefix' => 'mdi'], - ['icon' => 'file-lock', 'prefix' => 'mdi'], - // Charts & Analytics - ['icon' => 'chart-line', 'prefix' => 'mdi'], - ['icon' => 'chart-bar', 'prefix' => 'mdi'], - ['icon' => 'chart-pie', 'prefix' => 'mdi'], - ['icon' => 'chart-donut', 'prefix' => 'mdi'], - ['icon' => 'chart-areaspline', 'prefix' => 'mdi'], - ['icon' => 'chart-box', 'prefix' => 'mdi'], - ['icon' => 'chart-timeline', 'prefix' => 'mdi'], - ['icon' => 'chart-gantt', 'prefix' => 'mdi'], - ['icon' => 'poll', 'prefix' => 'mdi'], - ['icon' => 'finance', 'prefix' => 'mdi'], - ['icon' => 'trending-up', 'prefix' => 'mdi'], - ['icon' => 'trending-down', 'prefix' => 'mdi'], - ['icon' => 'trending-neutral', 'prefix' => 'mdi'], - // Users & Accounts - ['icon' => 'account', 'prefix' => 'mdi'], - ['icon' => 'account-outline', 'prefix' => 'mdi'], - ['icon' => 'account-circle', 'prefix' => 'mdi'], - ['icon' => 'account-circle-outline', 'prefix' => 'mdi'], - ['icon' => 'account-group', 'prefix' => 'mdi'], - ['icon' => 'account-group-outline', 'prefix' => 'mdi'], - ['icon' => 'account-multiple', 'prefix' => 'mdi'], - ['icon' => 'account-key', 'prefix' => 'mdi'], - ['icon' => 'account-lock', 'prefix' => 'mdi'], - ['icon' => 'account-cog', 'prefix' => 'mdi'], - // Communication - ['icon' => 'email', 'prefix' => 'mdi'], - ['icon' => 'email-outline', 'prefix' => 'mdi'], - ['icon' => 'email-open', 'prefix' => 'mdi'], - ['icon' => 'message', 'prefix' => 'mdi'], - ['icon' => 'message-outline', 'prefix' => 'mdi'], - ['icon' => 'chat', 'prefix' => 'mdi'], - ['icon' => 'chat-outline', 'prefix' => 'mdi'], - ['icon' => 'forum', 'prefix' => 'mdi'], - ['icon' => 'forum-outline', 'prefix' => 'mdi'], - // Notifications - ['icon' => 'bell', 'prefix' => 'mdi'], - ['icon' => 'bell-outline', 'prefix' => 'mdi'], - ['icon' => 'bell-ring', 'prefix' => 'mdi'], - ['icon' => 'bell-ring-outline', 'prefix' => 'mdi'], - ['icon' => 'bell-badge', 'prefix' => 'mdi'], - ['icon' => 'bell-off', 'prefix' => 'mdi'], - ['icon' => 'bell-alert', 'prefix' => 'mdi'], - // Status & Alerts - ['icon' => 'check', 'prefix' => 'mdi'], - ['icon' => 'check-bold', 'prefix' => 'mdi'], - ['icon' => 'check-circle', 'prefix' => 'mdi'], - ['icon' => 'check-circle-outline', 'prefix' => 'mdi'], - ['icon' => 'check-all', 'prefix' => 'mdi'], - ['icon' => 'check-decagram', 'prefix' => 'mdi'], - ['icon' => 'close', 'prefix' => 'mdi'], - ['icon' => 'close-circle', 'prefix' => 'mdi'], - ['icon' => 'close-circle-outline', 'prefix' => 'mdi'], - ['icon' => 'alert', 'prefix' => 'mdi'], - ['icon' => 'alert-circle', 'prefix' => 'mdi'], - ['icon' => 'alert-circle-outline', 'prefix' => 'mdi'], - ['icon' => 'alert-octagon', 'prefix' => 'mdi'], - ['icon' => 'alert-decagram', 'prefix' => 'mdi'], - ['icon' => 'information', 'prefix' => 'mdi'], - ['icon' => 'information-outline', 'prefix' => 'mdi'], - ['icon' => 'help-circle', 'prefix' => 'mdi'], - ['icon' => 'help-circle-outline', 'prefix' => 'mdi'], - // Time & Calendar - ['icon' => 'clock', 'prefix' => 'mdi'], - ['icon' => 'clock-outline', 'prefix' => 'mdi'], - ['icon' => 'clock-fast', 'prefix' => 'mdi'], - ['icon' => 'clock-alert', 'prefix' => 'mdi'], - ['icon' => 'timer', 'prefix' => 'mdi'], - ['icon' => 'timer-outline', 'prefix' => 'mdi'], - ['icon' => 'calendar', 'prefix' => 'mdi'], - ['icon' => 'calendar-outline', 'prefix' => 'mdi'], - ['icon' => 'calendar-clock', 'prefix' => 'mdi'], - ['icon' => 'calendar-check', 'prefix' => 'mdi'], - ['icon' => 'history', 'prefix' => 'mdi'], - // Navigation & Search - ['icon' => 'magnify', 'prefix' => 'mdi'], - ['icon' => 'magnify-plus', 'prefix' => 'mdi'], - ['icon' => 'magnify-minus', 'prefix' => 'mdi'], - ['icon' => 'magnify-scan', 'prefix' => 'mdi'], - ['icon' => 'eye', 'prefix' => 'mdi'], - ['icon' => 'eye-outline', 'prefix' => 'mdi'], - ['icon' => 'eye-off', 'prefix' => 'mdi'], - ['icon' => 'eye-off-outline', 'prefix' => 'mdi'], - ['icon' => 'filter', 'prefix' => 'mdi'], - ['icon' => 'filter-outline', 'prefix' => 'mdi'], - ['icon' => 'sort', 'prefix' => 'mdi'], - ['icon' => 'sort-ascending', 'prefix' => 'mdi'], - ['icon' => 'sort-descending', 'prefix' => 'mdi'], - // World & Globe - ['icon' => 'earth', 'prefix' => 'mdi'], - ['icon' => 'earth-arrow-right', 'prefix' => 'mdi'], - ['icon' => 'earth-box', 'prefix' => 'mdi'], - ['icon' => 'earth-off', 'prefix' => 'mdi'], - ['icon' => 'web', 'prefix' => 'mdi'], - ['icon' => 'web-box', 'prefix' => 'mdi'], - ['icon' => 'web-clock', 'prefix' => 'mdi'], - ['icon' => 'domain', 'prefix' => 'mdi'], - // Home & Building - ['icon' => 'home', 'prefix' => 'mdi'], - ['icon' => 'home-outline', 'prefix' => 'mdi'], - ['icon' => 'home-city', 'prefix' => 'mdi'], - ['icon' => 'home-city-outline', 'prefix' => 'mdi'], - ['icon' => 'office-building', 'prefix' => 'mdi'], - ['icon' => 'office-building-outline', 'prefix' => 'mdi'], - ['icon' => 'office-building-cog', 'prefix' => 'mdi'], - ['icon' => 'domain', 'prefix' => 'mdi'], - // Favorites & Ratings - ['icon' => 'star', 'prefix' => 'mdi'], - ['icon' => 'star-outline', 'prefix' => 'mdi'], - ['icon' => 'star-circle', 'prefix' => 'mdi'], - ['icon' => 'star-circle-outline', 'prefix' => 'mdi'], - ['icon' => 'heart', 'prefix' => 'mdi'], - ['icon' => 'heart-outline', 'prefix' => 'mdi'], - ['icon' => 'thumb-up', 'prefix' => 'mdi'], - ['icon' => 'thumb-up-outline', 'prefix' => 'mdi'], - ['icon' => 'thumb-down', 'prefix' => 'mdi'], - ['icon' => 'thumb-down-outline', 'prefix' => 'mdi'], - // Misc - ['icon' => 'rocket', 'prefix' => 'mdi'], - ['icon' => 'rocket-launch', 'prefix' => 'mdi'], - ['icon' => 'rocket-launch-outline', 'prefix' => 'mdi'], - ['icon' => 'fire', 'prefix' => 'mdi'], - ['icon' => 'fire-circle', 'prefix' => 'mdi'], - ['icon' => 'bug', 'prefix' => 'mdi'], - ['icon' => 'bug-outline', 'prefix' => 'mdi'], - ['icon' => 'bug-check', 'prefix' => 'mdi'], - ['icon' => 'puzzle', 'prefix' => 'mdi'], - ['icon' => 'puzzle-outline', 'prefix' => 'mdi'], - ['icon' => 'tag', 'prefix' => 'mdi'], - ['icon' => 'tag-outline', 'prefix' => 'mdi'], - ['icon' => 'tag-multiple', 'prefix' => 'mdi'], - ['icon' => 'bookmark', 'prefix' => 'mdi'], - ['icon' => 'bookmark-outline', 'prefix' => 'mdi'], - ['icon' => 'link', 'prefix' => 'mdi'], - ['icon' => 'link-variant', 'prefix' => 'mdi'], - ['icon' => 'link-off', 'prefix' => 'mdi'], - ['icon' => 'view-dashboard', 'prefix' => 'mdi'], - ['icon' => 'view-dashboard-outline', 'prefix' => 'mdi'], - ['icon' => 'view-grid', 'prefix' => 'mdi'], - ['icon' => 'view-grid-outline', 'prefix' => 'mdi'], - ['icon' => 'view-list', 'prefix' => 'mdi'], - ['icon' => 'view-list-outline', 'prefix' => 'mdi'], - ['icon' => 'menu', 'prefix' => 'mdi'], - ['icon' => 'dots-horizontal', 'prefix' => 'mdi'], - ['icon' => 'dots-vertical', 'prefix' => 'mdi'], - ['icon' => 'apps', 'prefix' => 'mdi'], - ['icon' => 'apps-box', 'prefix' => 'mdi'], - ['icon' => 'application', 'prefix' => 'mdi'], - ['icon' => 'application-outline', 'prefix' => 'mdi'], - ['icon' => 'window-maximize', 'prefix' => 'mdi'], - ['icon' => 'window-minimize', 'prefix' => 'mdi'], - ['icon' => 'window-restore', 'prefix' => 'mdi'], - ['icon' => 'monitor-cellphone', 'prefix' => 'mdi'], - ['icon' => 'cellphone-link', 'prefix' => 'mdi'], - ['icon' => 'responsive', 'prefix' => 'mdi'], - ['icon' => 'table', 'prefix' => 'mdi'], - ['icon' => 'table-large', 'prefix' => 'mdi'], - ['icon' => 'table-cog', 'prefix' => 'mdi'], - ['icon' => 'archive', 'prefix' => 'mdi'], - ['icon' => 'archive-outline', 'prefix' => 'mdi'], - ['icon' => 'archive-cog', 'prefix' => 'mdi'], - ['icon' => 'sun-wireless', 'prefix' => 'mdi'], - ['icon' => 'white-balance-sunny', 'prefix' => 'mdi'], - ['icon' => 'weather-night', 'prefix' => 'mdi'], - ['icon' => 'moon-waning-crescent', 'prefix' => 'mdi'], - ['icon' => 'camera', 'prefix' => 'mdi'], - ['icon' => 'camera-outline', 'prefix' => 'mdi'], - ['icon' => 'image', 'prefix' => 'mdi'], - ['icon' => 'image-outline', 'prefix' => 'mdi'], - ['icon' => 'image-multiple', 'prefix' => 'mdi'], - ['icon' => 'video', 'prefix' => 'mdi'], - ['icon' => 'video-outline', 'prefix' => 'mdi'], - ['icon' => 'music', 'prefix' => 'mdi'], - ['icon' => 'music-note', 'prefix' => 'mdi'], - ['icon' => 'microphone', 'prefix' => 'mdi'], - ['icon' => 'microphone-outline', 'prefix' => 'mdi'], - ['icon' => 'headphones', 'prefix' => 'mdi'], - ['icon' => 'speaker', 'prefix' => 'mdi'], - ['icon' => 'play', 'prefix' => 'mdi'], - ['icon' => 'pause', 'prefix' => 'mdi'], - ['icon' => 'stop', 'prefix' => 'mdi'], - ['icon' => 'skip-next', 'prefix' => 'mdi'], - ['icon' => 'skip-previous', 'prefix' => 'mdi'], - ['icon' => 'fast-forward', 'prefix' => 'mdi'], - ['icon' => 'rewind', 'prefix' => 'mdi'], - ]; - @endphp - @foreach($iconList as $iconData) - - @endforeach -
-
- -
-
- - -
- -
-
- - -
- - -
- -
- - - -
- - - - - -
-
- -

Manage Server Tags

-

Add up to 3 tags to organize and identify your server.

- - -
-
- Tag 1: - - -
- - -
- Tag 2: - - -
- - -
- Tag 3: - - -
-
- - - - -
- - -
- - -
-
-
- - - - -
-
- - - -
- Start -
-
-
- - - -
- Stop (ACPI) -
-
-
-
- - - -
- Reboot (ACPI) -
-
-
- - - -
- Power Off -
-
-
-
-
- - @if ($view !== 'statistics') - - - @endif - - @php - $tabs = [ - ['label' => 'Overview', 'view' => 'overview', 'route' => route('proxmox.server.overview', ['server' => $server->id])], - ['label' => 'Statistics', 'view' => 'statistics', 'route' => route('proxmox.server.statistics', ['server' => $server->id]), 'advanced' => true], - [ - 'label' => 'Hardware', - 'dropdown' => true, - 'items' => [ - ['label' => 'Boot Settings', 'view' => 'boot-order', 'route' => route('proxmox.server.boot-order', ['server' => $server->id]), 'advanced' => true], - ['label' => 'Display', 'view' => 'display', 'route' => route('proxmox.server.display', ['server' => $server->id]), 'advanced' => true], - ['label' => 'Storage', 'view' => 'storage', 'route' => route('proxmox.server.storage', ['server' => $server->id]), 'advanced' => true], - ['label' => 'System', 'view' => 'system', 'route' => route('proxmox.server.system', ['server' => $server->id]), 'advanced' => true], - ] - ], - [ - 'label' => 'Networking', - 'dropdown' => true, - 'items' => [ - ['label' => 'Configuration', 'view' => 'network', 'route' => route('proxmox.server.network', ['server' => $server->id])], - ['label' => 'Firewall', 'view' => 'firewall', 'route' => route('proxmox.server.firewall', ['server' => $server->id]), 'advanced' => true], - ['label' => 'Subdomain', 'view' => 'subdomain', 'route' => route('proxmox.server.subdomain', ['server' => $server->id]), 'advanced' => true], - ] - ], - ['label' => 'Cloud-init', 'view' => 'cloud-init', 'route' => route('proxmox.server.cloud-init', ['server' => $server->id]), 'advanced' => true], - ]; - if($server->service->product->settings()->where('key', 'backups')->first()?->value > 0) { - $tabs[] = ['label' => 'Backups', 'view' => 'backups', 'route' => route('proxmox.server.backups', ['server' => $server->id])]; - } - if($server->node?->vnc_enabled) { - $tabs[] = [ - 'label' => 'Console', - 'dropdown' => true, - 'items' => [ - ['label' => 'NoVNC', 'view' => 'vnc', 'route' => route('proxmox.server.vnc', ['server' => $server->id])], - ['label' => 'Xterm.js', 'view' => 'xterm', 'route' => route('proxmox.server.xterm', ['server' => $server->id])], - ] - ]; - } - $tabs[] = ['label' => 'Help', 'view' => 'help', 'route' => route('proxmox.server.help', ['server' => $server->id]), 'advanced' => true]; - - // For LXC containers, remove all advanced-only and KVM-specific tabs - if ($isLxc) { - $tabs = array_values(array_filter($tabs, function ($tab) { - // Remove top-level advanced tabs - if (!empty($tab['advanced'])) return false; - // Remove Hardware dropdown entirely (Boot, Display, Storage, System are KVM-only) - if (($tab['label'] ?? '') === 'Hardware') return false; - // For Networking dropdown, keep only Configuration (remove Firewall, Subdomain) - if (isset($tab['dropdown']) && $tab['dropdown'] && isset($tab['items'])) { - $tab['items'] = array_values(array_filter($tab['items'], fn($item) => empty($item['advanced']))); - return count($tab['items']) > 0; - } - return true; - })); - // Re-filter Networking dropdown items in-place - foreach ($tabs as &$tab) { - if (isset($tab['dropdown']) && $tab['dropdown'] && isset($tab['items'])) { - $tab['items'] = array_values(array_filter($tab['items'], fn($item) => empty($item['advanced']))); - } - } - unset($tab); - } - @endphp - -
- - - - -
{ - if (open) { - isAdvancedView = localStorage.getItem('proxmox-view-mode') === 'advanced'; - } - }); - " - @proxmox-view-mode-changed.window="isAdvancedView = $event.detail.isAdvancedView" - > - - -
- @php - // Find the current tab label, checking both regular tabs and dropdown items - $currentLabel = 'Menu'; - foreach ($tabs as $tab) { - if (isset($tab['view']) && $tab['view'] === $view) { - $currentLabel = $tab['label']; - break; - } - if (isset($tab['dropdown']) && $tab['dropdown'] && isset($tab['items'])) { - foreach ($tab['items'] as $item) { - if ($item['view'] === $view) { - $currentLabel = $item['label']; - break 2; - } - } - } - } - @endphp - -
- - Std - -
-
-
- - Adv - -
-
- -
-
- @foreach($tabs as $tab) - @php - $isAdvancedTab = $tab['advanced'] ?? false; - $isDropdown = $tab['dropdown'] ?? false; - @endphp - - @if($isDropdown) - {{-- Render dropdown items as individual menu items for mobile --}} -
- {{ $tab['label'] }} -
- @foreach($tab['items'] as $item) - @php - $itemAdvanced = $item['advanced'] ?? false; - @endphp - - @endforeach - @else - - @endif - @endforeach -
-
-
-
- {{-- Fallback x-data provides default values for all page-scoped Alpine variables. - This prevents "not defined" errors during Livewire wire:navigate morphdom - transitions between tabs. Inner x-data scopes on each page override these. - Must be ABOVE the wire:key element so scope persists during key-change replacements. --}} -
-
- {{ $slot }} -
-
-
- - @else - @php - $statuses = [ - 'suspended' => [ - 'color' => 'text-yellow-500', - 'message' => 'This server is suspended. Please contact support for more information.' - ], - 'installing' => [ - 'color' => 'text-blue-500', - 'message' => '' - ], - ]; - @endphp - -
- - @if($server->status === 'installing') - - - - - -
-
-
-
- -
- Virtnet.bond -

Creating Server

-
-
- - - -
-
    -
  • Telling the memory to try harder
  • -
  • Begging the network to behave (again)
  • -
  • Pretending the storage is organized
  • -
  • Asking the CPU to stop being dramatic
  • -
  • Informing telemetry it missed everything
  • -
  • Encouraging the firewall to chill out
  • -
  • Reminding the logs nobody reads them
  • -
  • Lecturing the cluster to stop gossiping
  • -
  • Asking the API politely to behave today
  • -
  • Calming down the swap space (it is stressed)
  • -
-
- - - GLaDOS r4.32-03 - -
-
-
- - -
-
-
-

-
- GLaDOS - "Still Alive" -
-

- -
-
-
-

- Congrats! You found a hidden Easter egg! Enjoy the tune and the same love Virtnet.bond has for the Portal series and all video games! We might be serious about work, but we're still gamers at heart. -

-
-
- Portal - - "Still Alive" by Ellen McLain (voice of GLaDOS) -
- - - -
- - Virtnet.bond claims no rights to the "GLaDOS" name or the "Still Alive" audio content. This content is used under the doctrine of fair use (17 U.S. Code § 107 – Limitations on exclusive rights: Fair use). It is provided for entertainment and cultural reference purposes only. All rights to the aforementioned content are retained by their respective copyright holders. - -
-
-
-
- @else -
-

Oops!

-

- @php - $message = $statuses[$server->status]['message'] ?? "This server is currently inactive. Please view the Services tab in the Dashboard to see more information about the current state of this server. If more information is required, please contact support via the support portal under Tickets. This message can be displayed after a service is suspended, cancelled, revoked, or potentially in error. If you believe this message has been displayed in error, please contact us via a ticket and be sure to include the following information: VM{$server->vm_id}-ID{$server->id}-{$server->status}."; -@endphp - - {{ $message }} -

-
- @endif - -
- @endif -
- - - - - - - -
- -
- - -
- -
-
+@props([ + 'server', + 'view', + 'status' => null, + 'statusPolling' => false, + 'statusPollingInterval' => '30s', +]) + +@php + $vmStatus = optional($status)->status; + $pollSuffix = $statusPollingInterval ? '.' . $statusPollingInterval : ''; + $pollAttribute = $statusPolling ? 'wire:poll' . $pollSuffix . '="loadVmStatus"' : ''; +@endphp + +
+ @if($server->status === 'active') + +
+ @php + $advancedOnlyViews = ['boot-order', 'cloud-init', 'display', 'system', 'subdomain', 'statistics', 'help', 'firewall']; + $isAdvancedOnlyView = in_array($view, $advancedOnlyViews); + @endphp + +
+
user_set_icon . "'" : 'null' }}, + initialColor: {{ $server->user_set_icon_color ? "'" . $server->user_set_icon_color . "'" : 'null' }}, + initialPrefix: {{ $server->user_set_icon_prefix ? "'" . $server->user_set_icon_prefix . "'" : "'ri'" }} + })"> + + + +
+
+ +

Customize Server Icon

+
+
+
+ +
+ +
+
+ @php + $iconList = [ + // Remix Icons + ['icon' => 'server-line', 'prefix' => 'ri'], + ['icon' => 'database-2-line', 'prefix' => 'ri'], + ['icon' => 'computer-line', 'prefix' => 'ri'], + ['icon' => 'cpu-line', 'prefix' => 'ri'], + ['icon' => 'hard-drive-2-line', 'prefix' => 'ri'], + ['icon' => 'cloud-line', 'prefix' => 'ri'], + ['icon' => 'terminal-box-line', 'prefix' => 'ri'], + ['icon' => 'code-s-slash-line', 'prefix' => 'ri'], + ['icon' => 'braces-line', 'prefix' => 'ri'], + ['icon' => 'global-line', 'prefix' => 'ri'], + ['icon' => 'earth-line', 'prefix' => 'ri'], + ['icon' => 'wifi-line', 'prefix' => 'ri'], + ['icon' => 'signal-tower-line', 'prefix' => 'ri'], + ['icon' => 'shield-check-line', 'prefix' => 'ri'], + ['icon' => 'shield-keyhole-line', 'prefix' => 'ri'], + ['icon' => 'lock-line', 'prefix' => 'ri'], + ['icon' => 'lock-unlock-line', 'prefix' => 'ri'], + ['icon' => 'key-2-line', 'prefix' => 'ri'], + ['icon' => 'rocket-line', 'prefix' => 'ri'], + ['icon' => 'rocket-2-line', 'prefix' => 'ri'], + ['icon' => 'send-plane-line', 'prefix' => 'ri'], + ['icon' => 'flashlight-line', 'prefix' => 'ri'], + ['icon' => 'fire-line', 'prefix' => 'ri'], + ['icon' => 'sparkling-line', 'prefix' => 'ri'], + ['icon' => 'star-line', 'prefix' => 'ri'], + ['icon' => 'heart-line', 'prefix' => 'ri'], + ['icon' => 'home-line', 'prefix' => 'ri'], + ['icon' => 'home-2-line', 'prefix' => 'ri'], + ['icon' => 'building-line', 'prefix' => 'ri'], + ['icon' => 'building-2-line', 'prefix' => 'ri'], + ['icon' => 'store-line', 'prefix' => 'ri'], + ['icon' => 'box-3-line', 'prefix' => 'ri'], + ['icon' => 'archive-line', 'prefix' => 'ri'], + ['icon' => 'inbox-line', 'prefix' => 'ri'], + ['icon' => 'inbox-2-line', 'prefix' => 'ri'], + ['icon' => 'folder-line', 'prefix' => 'ri'], + ['icon' => 'folder-open-line', 'prefix' => 'ri'], + ['icon' => 'settings-3-line', 'prefix' => 'ri'], + ['icon' => 'settings-4-line', 'prefix' => 'ri'], + ['icon' => 'tools-line', 'prefix' => 'ri'], + ['icon' => 'hammer-line', 'prefix' => 'ri'], + ['icon' => 'flask-line', 'prefix' => 'ri'], + ['icon' => 'bug-line', 'prefix' => 'ri'], + ['icon' => 'puzzle-line', 'prefix' => 'ri'], + ['icon' => 'bar-chart-box-line', 'prefix' => 'ri'], + ['icon' => 'pie-chart-line', 'prefix' => 'ri'], + ['icon' => 'line-chart-line', 'prefix' => 'ri'], + ['icon' => 'money-dollar-circle-line', 'prefix' => 'ri'], + ['icon' => 'money-euro-circle-line', 'prefix' => 'ri'], + ['icon' => 'bank-card-line', 'prefix' => 'ri'], + ['icon' => 'wallet-line', 'prefix' => 'ri'], + ['icon' => 'user-line', 'prefix' => 'ri'], + ['icon' => 'user-3-line', 'prefix' => 'ri'], + ['icon' => 'group-line', 'prefix' => 'ri'], + ['icon' => 'team-line', 'prefix' => 'ri'], + ['icon' => 'mail-line', 'prefix' => 'ri'], + ['icon' => 'mail-open-line', 'prefix' => 'ri'], + ['icon' => 'chat-3-line', 'prefix' => 'ri'], + ['icon' => 'discuss-line', 'prefix' => 'ri'], + ['icon' => 'camera-line', 'prefix' => 'ri'], + ['icon' => 'vidicon-line', 'prefix' => 'ri'], + ['icon' => 'film-line', 'prefix' => 'ri'], + ['icon' => 'music-line', 'prefix' => 'ri'], + ['icon' => 'book-open-line', 'prefix' => 'ri'], + ['icon' => 'file-text-line', 'prefix' => 'ri'], + ['icon' => 'sun-line', 'prefix' => 'ri'], + ['icon' => 'moon-line', 'prefix' => 'ri'], + ['icon' => 'palette-line', 'prefix' => 'ri'], + ['icon' => 'brush-line', 'prefix' => 'ri'], + ['icon' => 'trophy-line', 'prefix' => 'ri'], + ['icon' => 'graduation-cap-line', 'prefix' => 'ri'], + ['icon' => 'lightbulb-line', 'prefix' => 'ri'], + ['icon' => 'fingerprint-line', 'prefix' => 'ri'], + ['icon' => 'eye-line', 'prefix' => 'ri'], + ['icon' => 'map-2-line', 'prefix' => 'ri'], + ['icon' => 'map-pin-line', 'prefix' => 'ri'], + ['icon' => 'time-line', 'prefix' => 'ri'], + ['icon' => 'calendar-line', 'prefix' => 'ri'], + ['icon' => 'gift-line', 'prefix' => 'ri'], + ['icon' => 'cake-2-line', 'prefix' => 'ri'], + ['icon' => 'gamepad-line', 'prefix' => 'ri'], + ['icon' => 'ghost-line', 'prefix' => 'ri'], + ['icon' => 'aliens-line', 'prefix' => 'ri'], + ['icon' => 'space-ship-line', 'prefix' => 'ri'], + ['icon' => 'android-line', 'prefix' => 'ri'], + ['icon' => 'apple-line', 'prefix' => 'ri'], + ['icon' => 'windows-line', 'prefix' => 'ri'], + ['icon' => 'ubuntu-line', 'prefix' => 'ri'], + ['icon' => 'github-line', 'prefix' => 'ri'], + ['icon' => 'gitlab-line', 'prefix' => 'ri'], + ['icon' => 'discord-line', 'prefix' => 'ri'], + ['icon' => 'slack-line', 'prefix' => 'ri'], + ['icon' => 'desktop', 'prefix' => 'vaadin'], + ['icon' => 'laptop', 'prefix' => 'vaadin'], + ['icon' => 'cluster', 'prefix' => 'vaadin'], + ['icon' => 'cloud', 'prefix' => 'vaadin'], + ['icon' => 'cloud-o', 'prefix' => 'vaadin'], + ['icon' => 'cloud-upload', 'prefix' => 'vaadin'], + ['icon' => 'cloud-download', 'prefix' => 'vaadin'], + ['icon' => 'storage', 'prefix' => 'vaadin'], + ['icon' => 'harddrive', 'prefix' => 'vaadin'], + ['icon' => 'compile', 'prefix' => 'vaadin'], + ['icon' => 'code', 'prefix' => 'vaadin'], + ['icon' => 'terminal', 'prefix' => 'vaadin'], + ['icon' => 'globe', 'prefix' => 'vaadin'], + ['icon' => 'globe-wire', 'prefix' => 'vaadin'], + ['icon' => 'connect', 'prefix' => 'vaadin'], + ['icon' => 'sitemap', 'prefix' => 'vaadin'], + ['icon' => 'automation', 'prefix' => 'vaadin'], + ['icon' => 'cogs', 'prefix' => 'vaadin'], + ['icon' => 'cog', 'prefix' => 'vaadin'], + ['icon' => 'wrench', 'prefix' => 'vaadin'], + ['icon' => 'tools', 'prefix' => 'vaadin'], + ['icon' => 'hammer', 'prefix' => 'vaadin'], + ['icon' => 'shield', 'prefix' => 'vaadin'], + ['icon' => 'lock', 'prefix' => 'vaadin'], + ['icon' => 'unlock', 'prefix' => 'vaadin'], + ['icon' => 'key', 'prefix' => 'vaadin'], + ['icon' => 'key-o', 'prefix' => 'vaadin'], + ['icon' => 'rocket', 'prefix' => 'vaadin'], + ['icon' => 'flash', 'prefix' => 'vaadin'], + ['icon' => 'bolt', 'prefix' => 'vaadin'], + ['icon' => 'fire', 'prefix' => 'vaadin'], + ['icon' => 'star', 'prefix' => 'vaadin'], + ['icon' => 'star-o', 'prefix' => 'vaadin'], + ['icon' => 'heart', 'prefix' => 'vaadin'], + ['icon' => 'heart-o', 'prefix' => 'vaadin'], + ['icon' => 'home', 'prefix' => 'vaadin'], + ['icon' => 'home-o', 'prefix' => 'vaadin'], + ['icon' => 'building', 'prefix' => 'vaadin'], + ['icon' => 'building-o', 'prefix' => 'vaadin'], + ['icon' => 'office', 'prefix' => 'vaadin'], + ['icon' => 'server', 'prefix' => 'vaadin'], + ['icon' => 'database', 'prefix' => 'vaadin'], + ['icon' => 'cloud', 'prefix' => 'vaadin'], + ['icon' => 'cloud-o', 'prefix' => 'vaadin'], + ['icon' => 'storage', 'prefix' => 'vaadin'], + ['icon' => 'harddrive', 'prefix' => 'vaadin'], + ['icon' => 'package', 'prefix' => 'vaadin'], + ['icon' => 'archive', 'prefix' => 'vaadin'], + ['icon' => 'inbox', 'prefix' => 'vaadin'], + ['icon' => 'folder', 'prefix' => 'vaadin'], + ['icon' => 'folder-o', 'prefix' => 'vaadin'], + ['icon' => 'folder-open', 'prefix' => 'vaadin'], + ['icon' => 'folder-open-o', 'prefix' => 'vaadin'], + ['icon' => 'bug', 'prefix' => 'vaadin'], + ['icon' => 'chart', 'prefix' => 'vaadin'], + ['icon' => 'bar-chart', 'prefix' => 'vaadin'], + ['icon' => 'line-chart', 'prefix' => 'vaadin'], + ['icon' => 'pie-chart', 'prefix' => 'vaadin'], + ['icon' => 'dashboard', 'prefix' => 'vaadin'], + ['icon' => 'progressbar', 'prefix' => 'vaadin'], + ['icon' => 'money', 'prefix' => 'vaadin'], + ['icon' => 'dollar', 'prefix' => 'vaadin'], + ['icon' => 'euro', 'prefix' => 'vaadin'], + ['icon' => 'credit-card', 'prefix' => 'vaadin'], + ['icon' => 'wallet', 'prefix' => 'vaadin'], + ['icon' => 'user', 'prefix' => 'vaadin'], + ['icon' => 'users', 'prefix' => 'vaadin'], + ['icon' => 'group', 'prefix' => 'vaadin'], + ['icon' => 'user-card', 'prefix' => 'vaadin'], + ['icon' => 'male', 'prefix' => 'vaadin'], + ['icon' => 'female', 'prefix' => 'vaadin'], + ['icon' => 'envelope', 'prefix' => 'vaadin'], + ['icon' => 'envelope-o', 'prefix' => 'vaadin'], + ['icon' => 'envelope-open', 'prefix' => 'vaadin'], + ['icon' => 'mailbox', 'prefix' => 'vaadin'], + ['icon' => 'comment', 'prefix' => 'vaadin'], + ['icon' => 'comments', 'prefix' => 'vaadin'], + ['icon' => 'comments-o', 'prefix' => 'vaadin'], + ['icon' => 'camera', 'prefix' => 'vaadin'], + ['icon' => 'film', 'prefix' => 'vaadin'], + ['icon' => 'music', 'prefix' => 'vaadin'], + ['icon' => 'book', 'prefix' => 'vaadin'], + ['icon' => 'file-text', 'prefix' => 'vaadin'], + ['icon' => 'file-text-o', 'prefix' => 'vaadin'], + ['icon' => 'sun-o', 'prefix' => 'vaadin'], + ['icon' => 'moon-o', 'prefix' => 'vaadin'], + ['icon' => 'palete', 'prefix' => 'vaadin'], + ['icon' => 'paintbrush', 'prefix' => 'vaadin'], + ['icon' => 'trophy', 'prefix' => 'vaadin'], + ['icon' => 'academy-cap', 'prefix' => 'vaadin'], + ['icon' => 'lightbulb', 'prefix' => 'vaadin'], + ['icon' => 'eye', 'prefix' => 'vaadin'], + ['icon' => 'eye-slash', 'prefix' => 'vaadin'], + ['icon' => 'map-marker', 'prefix' => 'vaadin'], + ['icon' => 'location-arrow', 'prefix' => 'vaadin'], + ['icon' => 'clock', 'prefix' => 'vaadin'], + ['icon' => 'calendar', 'prefix' => 'vaadin'], + ['icon' => 'calendar-o', 'prefix' => 'vaadin'], + ['icon' => 'gift', 'prefix' => 'vaadin'], + ['icon' => 'gamepad', 'prefix' => 'vaadin'], + ['icon' => 'controller', 'prefix' => 'vaadin'], + + // File Icons (codeat3/blade-file-icons) + ['icon' => 'docker', 'prefix' => 'fileicon'], + ['icon' => 'kubernetes', 'prefix' => 'fileicon'], + ['icon' => 'nginx', 'prefix' => 'fileicon'], + ['icon' => 'alpine-linux', 'prefix' => 'fileicon'], + ['icon' => 'arch-linux', 'prefix' => 'fileicon'], + ['icon' => 'xubuntu', 'prefix' => 'fileicon'], + ['icon' => 'virtualbox', 'prefix' => 'fileicon'], + ['icon' => 'virtualbox-alt', 'prefix' => 'fileicon'], + ['icon' => 'vmware', 'prefix' => 'fileicon'], + ['icon' => 'terminal', 'prefix' => 'fileicon'], + ['icon' => 'powershell', 'prefix' => 'fileicon'], + ['icon' => 'serverless', 'prefix' => 'fileicon'], + ['icon' => 'devcontainer', 'prefix' => 'fileicon'], + ['icon' => 'config', 'prefix' => 'fileicon'], + ['icon' => 'editorconfig', 'prefix' => 'fileicon'], + ['icon' => 'php', 'prefix' => 'fileicon'], + ['icon' => 'go', 'prefix' => 'fileicon'], + ['icon' => 'kotlin', 'prefix' => 'fileicon'], + ['icon' => 'c', 'prefix' => 'fileicon'], + ['icon' => 'npm', 'prefix' => 'fileicon'], + ['icon' => 'yarn', 'prefix' => 'fileicon'], + ['icon' => 'vscode', 'prefix' => 'fileicon'], + ['icon' => 'nano', 'prefix' => 'fileicon'], + ['icon' => 'gitlab', 'prefix' => 'fileicon'], + ['icon' => 'sqlite', 'prefix' => 'fileicon'], + ['icon' => 'json-1', 'prefix' => 'fileicon'], + ['icon' => 'json-2', 'prefix' => 'fileicon'], + ['icon' => 'yaml', 'prefix' => 'fileicon'], + ['icon' => 'typescript', 'prefix' => 'fileicon'], + ['icon' => 'vue', 'prefix' => 'fileicon'], + ['icon' => 'svelte', 'prefix' => 'fileicon'], + ['icon' => 'laravel', 'prefix' => 'fileicon'], + ['icon' => 'flask', 'prefix' => 'fileicon'], + ['icon' => 'terraform', 'prefix' => 'fileicon'], + ['icon' => 'ansible', 'prefix' => 'fileicon'], + ['icon' => 'ansible-alt', 'prefix' => 'fileicon'], + ['icon' => 'puppet', 'prefix' => 'fileicon'], + ['icon' => 'jenkins', 'prefix' => 'fileicon'], + ['icon' => 'jsx', 'prefix' => 'fileicon'], + ['icon' => 'tsx', 'prefix' => 'fileicon'], + ['icon' => 'perl6', 'prefix' => 'fileicon'], + ['icon' => 'rubygems', 'prefix' => 'fileicon'], + ['icon' => 'toml', 'prefix' => 'fileicon'], + ['icon' => 'tailwind', 'prefix' => 'fileicon'], + ['icon' => 'webpack', 'prefix' => 'fileicon'], + ['icon' => 'vite', 'prefix' => 'fileicon'], + ['icon' => 'nextjs', 'prefix' => 'fileicon'], + ['icon' => 'nestjs', 'prefix' => 'fileicon'], + ['icon' => 'nodemon', 'prefix' => 'fileicon'], + ['icon' => 'vagrant', 'prefix' => 'fileicon'], + ['icon' => 'saltstack', 'prefix' => 'fileicon'], + ['icon' => 'chef', 'prefix' => 'fileicon'], + ['icon' => 'circleci', 'prefix' => 'fileicon'], + ['icon' => 'r', 'prefix' => 'fileicon'], + ['icon' => 'apple', 'prefix' => 'fileicon'], + ['icon' => 'arduino', 'prefix' => 'fileicon'], + ['icon' => 'babel', 'prefix' => 'fileicon'], + ['icon' => 'composer', 'prefix' => 'fileicon'], + ['icon' => 'snyk', 'prefix' => 'fileicon'], + ['icon' => 'storybook', 'prefix' => 'fileicon'], + + // Dev Icons (codeat3/blade-devicons) - Technology logos + ['icon' => 'docker', 'prefix' => 'devicon'], + ['icon' => 'kubernetes', 'prefix' => 'devicon'], + ['icon' => 'linux', 'prefix' => 'devicon'], + ['icon' => 'ubuntu', 'prefix' => 'devicon'], + ['icon' => 'debian', 'prefix' => 'devicon'], + ['icon' => 'centos', 'prefix' => 'devicon'], + ['icon' => 'redhat', 'prefix' => 'devicon'], + ['icon' => 'fedora', 'prefix' => 'devicon'], + ['icon' => 'opensuse', 'prefix' => 'devicon'], + ['icon' => 'nixos', 'prefix' => 'devicon'], + ['icon' => 'gentoo', 'prefix' => 'devicon'], + ['icon' => 'android', 'prefix' => 'devicon'], + ['icon' => 'apache', 'prefix' => 'devicon'], + ['icon' => 'mysql', 'prefix' => 'devicon'], + ['icon' => 'postgresql', 'prefix' => 'devicon'], + ['icon' => 'mongodb', 'prefix' => 'devicon'], + ['icon' => 'redis', 'prefix' => 'devicon'], + ['icon' => 'sqlite', 'prefix' => 'devicon'], + ['icon' => 'neo4j', 'prefix' => 'devicon'], + ['icon' => 'couchdb', 'prefix' => 'devicon'], + ['icon' => 'microsoftsqlserver', 'prefix' => 'devicon'], + ['icon' => 'python', 'prefix' => 'devicon'], + ['icon' => 'php', 'prefix' => 'devicon'], + ['icon' => 'java', 'prefix' => 'devicon'], + ['icon' => 'javascript', 'prefix' => 'devicon'], + ['icon' => 'typescript', 'prefix' => 'devicon'], + ['icon' => 'nodejs', 'prefix' => 'devicon'], + ['icon' => 'go', 'prefix' => 'devicon'], + ['icon' => 'rust', 'prefix' => 'devicon'], + ['icon' => 'c', 'prefix' => 'devicon'], + ['icon' => 'cplusplus', 'prefix' => 'devicon'], + ['icon' => 'csharp', 'prefix' => 'devicon'], + ['icon' => 'ruby', 'prefix' => 'devicon'], + ['icon' => 'perl', 'prefix' => 'devicon'], + ['icon' => 'swift', 'prefix' => 'devicon'], + ['icon' => 'kotlin', 'prefix' => 'devicon'], + ['icon' => 'scala', 'prefix' => 'devicon'], + ['icon' => 'r', 'prefix' => 'devicon'], + ['icon' => 'lua', 'prefix' => 'devicon'], + ['icon' => 'dart', 'prefix' => 'devicon'], + ['icon' => 'elixir', 'prefix' => 'devicon'], + ['icon' => 'erlang', 'prefix' => 'devicon'], + ['icon' => 'haskell', 'prefix' => 'devicon'], + ['icon' => 'julia', 'prefix' => 'devicon'], + ['icon' => 'bash', 'prefix' => 'devicon'], + ['icon' => 'html5', 'prefix' => 'devicon'], + ['icon' => 'css3', 'prefix' => 'devicon'], + ['icon' => 'laravel', 'prefix' => 'devicon'], + ['icon' => 'django', 'prefix' => 'devicon'], + ['icon' => 'rails', 'prefix' => 'devicon'], + ['icon' => 'spring', 'prefix' => 'devicon'], + ['icon' => 'fastapi', 'prefix' => 'devicon'], + ['icon' => 'nestjs', 'prefix' => 'devicon'], + ['icon' => 'vuejs', 'prefix' => 'devicon'], + ['icon' => 'angularjs', 'prefix' => 'devicon'], + ['icon' => 'svelte', 'prefix' => 'devicon'], + ['icon' => 'nuxtjs', 'prefix' => 'devicon'], + ['icon' => 'gatsby', 'prefix' => 'devicon'], + ['icon' => 'bootstrap', 'prefix' => 'devicon'], + ['icon' => 'tailwindcss', 'prefix' => 'devicon'], + ['icon' => 'materialui', 'prefix' => 'devicon'], + ['icon' => 'git', 'prefix' => 'devicon'], + ['icon' => 'gitlab', 'prefix' => 'devicon'], + ['icon' => 'azure', 'prefix' => 'devicon'], + ['icon' => 'googlecloud', 'prefix' => 'devicon'], + ['icon' => 'digitalocean', 'prefix' => 'devicon'], + ['icon' => 'heroku', 'prefix' => 'devicon'], + ['icon' => 'firebase', 'prefix' => 'devicon'], + ['icon' => 'terraform', 'prefix' => 'devicon'], + ['icon' => 'ansible', 'prefix' => 'devicon'], + ['icon' => 'jenkins', 'prefix' => 'devicon'], + ['icon' => 'circleci', 'prefix' => 'devicon'], + ['icon' => 'travis', 'prefix' => 'devicon'], + ['icon' => 'argocd', 'prefix' => 'devicon'], + ['icon' => 'vagrant', 'prefix' => 'devicon'], + ['icon' => 'podman', 'prefix' => 'devicon'], + ['icon' => 'webpack', 'prefix' => 'devicon'], + ['icon' => 'babel', 'prefix' => 'devicon'], + ['icon' => 'gradle', 'prefix' => 'devicon'], + ['icon' => 'vscode', 'prefix' => 'devicon'], + ['icon' => 'visualstudio', 'prefix' => 'devicon'], + ['icon' => 'intellij', 'prefix' => 'devicon'], + ['icon' => 'pycharm', 'prefix' => 'devicon'], + ['icon' => 'phpstorm', 'prefix' => 'devicon'], + ['icon' => 'webstorm', 'prefix' => 'devicon'], + ['icon' => 'vim', 'prefix' => 'devicon'], + ['icon' => 'xcode', 'prefix' => 'devicon'], + ['icon' => 'androidstudio', 'prefix' => 'devicon'], + ['icon' => 'jupyter', 'prefix' => 'devicon'], + ['icon' => 'chrome', 'prefix' => 'devicon'], + ['icon' => 'firefox', 'prefix' => 'devicon'], + ['icon' => 'safari', 'prefix' => 'devicon'], + ['icon' => 'opera', 'prefix' => 'devicon'], + ['icon' => 'wordpress', 'prefix' => 'devicon'], + ['icon' => 'drupal', 'prefix' => 'devicon'], + ['icon' => 'woocommerce', 'prefix' => 'devicon'], + ['icon' => 'graphql', 'prefix' => 'devicon'], + ['icon' => 'arduino', 'prefix' => 'devicon'], + ['icon' => 'flutter', 'prefix' => 'devicon'], + ['icon' => 'figma', 'prefix' => 'devicon'], + ['icon' => 'photoshop', 'prefix' => 'devicon'], + ['icon' => 'illustrator', 'prefix' => 'devicon'], + ['icon' => 'premierepro', 'prefix' => 'devicon'], + ['icon' => 'aftereffects', 'prefix' => 'devicon'], + ['icon' => 'slack', 'prefix' => 'devicon'], + ['icon' => 'trello', 'prefix' => 'devicon'], + ['icon' => 'jira', 'prefix' => 'devicon'], + ['icon' => 'putty', 'prefix' => 'devicon'], + ['icon' => 'solidity', 'prefix' => 'devicon'], + + // Coolicons (codeat3/blade-coolicons) + ['icon' => 'globe', 'prefix' => 'coolicon'], + ['icon' => 'planet', 'prefix' => 'coolicon'], + ['icon' => 'desktop', 'prefix' => 'coolicon'], + ['icon' => 'desktop-tower', 'prefix' => 'coolicon'], + ['icon' => 'laptop', 'prefix' => 'coolicon'], + ['icon' => 'monitor', 'prefix' => 'coolicon'], + ['icon' => 'monitor-play', 'prefix' => 'coolicon'], + ['icon' => 'tablet', 'prefix' => 'coolicon'], + ['icon' => 'tablet-button', 'prefix' => 'coolicon'], + ['icon' => 'mobile', 'prefix' => 'coolicon'], + ['icon' => 'mobile-button', 'prefix' => 'coolicon'], + ['icon' => 'devices', 'prefix' => 'coolicon'], + ['icon' => 'cloud', 'prefix' => 'coolicon'], + ['icon' => 'cloud-add', 'prefix' => 'coolicon'], + ['icon' => 'cloud-check', 'prefix' => 'coolicon'], + ['icon' => 'cloud-close', 'prefix' => 'coolicon'], + ['icon' => 'cloud-download', 'prefix' => 'coolicon'], + ['icon' => 'cloud-upload', 'prefix' => 'coolicon'], + ['icon' => 'cloud-off', 'prefix' => 'coolicon'], + ['icon' => 'cloud-remove', 'prefix' => 'coolicon'], + ['icon' => 'data', 'prefix' => 'coolicon'], + ['icon' => 'cylinder', 'prefix' => 'coolicon'], + ['icon' => 'instance', 'prefix' => 'coolicon'], + ['icon' => 'layers', 'prefix' => 'coolicon'], + ['icon' => 'layer', 'prefix' => 'coolicon'], + ['icon' => 'terminal', 'prefix' => 'coolicon'], + ['icon' => 'code', 'prefix' => 'coolicon'], + ['icon' => 'window', 'prefix' => 'coolicon'], + ['icon' => 'window-check', 'prefix' => 'coolicon'], + ['icon' => 'window-close', 'prefix' => 'coolicon'], + ['icon' => 'window-code-block', 'prefix' => 'coolicon'], + ['icon' => 'window-sidebar', 'prefix' => 'coolicon'], + ['icon' => 'window-terminal', 'prefix' => 'coolicon'], + ['icon' => 'keyboard', 'prefix' => 'coolicon'], + ['icon' => 'mouse', 'prefix' => 'coolicon'], + ['icon' => 'wifi-high', 'prefix' => 'coolicon'], + ['icon' => 'wifi-medium', 'prefix' => 'coolicon'], + ['icon' => 'wifi-low', 'prefix' => 'coolicon'], + ['icon' => 'wifi-none', 'prefix' => 'coolicon'], + ['icon' => 'wifi-off', 'prefix' => 'coolicon'], + ['icon' => 'wifi-problem', 'prefix' => 'coolicon'], + ['icon' => 'shield', 'prefix' => 'coolicon'], + ['icon' => 'shield-check', 'prefix' => 'coolicon'], + ['icon' => 'shield-warning', 'prefix' => 'coolicon'], + ['icon' => 'lock', 'prefix' => 'coolicon'], + ['icon' => 'lock-open', 'prefix' => 'coolicon'], + ['icon' => 'settings', 'prefix' => 'coolicon'], + ['icon' => 'settings-future', 'prefix' => 'coolicon'], + ['icon' => 'slider-01', 'prefix' => 'coolicon'], + ['icon' => 'slider-02', 'prefix' => 'coolicon'], + ['icon' => 'slider-03', 'prefix' => 'coolicon'], + ['icon' => 'filter', 'prefix' => 'coolicon'], + ['icon' => 'filter-off', 'prefix' => 'coolicon'], + ['icon' => 'chart-bar-horizontal-01', 'prefix' => 'coolicon'], + ['icon' => 'chart-bar-vertical-01', 'prefix' => 'coolicon'], + ['icon' => 'chart-line', 'prefix' => 'coolicon'], + ['icon' => 'chart-pie', 'prefix' => 'coolicon'], + ['icon' => 'trending-up', 'prefix' => 'coolicon'], + ['icon' => 'trending-down', 'prefix' => 'coolicon'], + ['icon' => 'bell', 'prefix' => 'coolicon'], + ['icon' => 'bell-add', 'prefix' => 'coolicon'], + ['icon' => 'bell-notification', 'prefix' => 'coolicon'], + ['icon' => 'bell-off', 'prefix' => 'coolicon'], + ['icon' => 'bell-ring', 'prefix' => 'coolicon'], + ['icon' => 'mail', 'prefix' => 'coolicon'], + ['icon' => 'mail-open', 'prefix' => 'coolicon'], + ['icon' => 'chat', 'prefix' => 'coolicon'], + ['icon' => 'chat-add', 'prefix' => 'coolicon'], + ['icon' => 'chat-check', 'prefix' => 'coolicon'], + ['icon' => 'chat-close', 'prefix' => 'coolicon'], + ['icon' => 'chat-dots', 'prefix' => 'coolicon'], + ['icon' => 'chat-conversation', 'prefix' => 'coolicon'], + ['icon' => 'folder', 'prefix' => 'coolicon'], + ['icon' => 'folder-add', 'prefix' => 'coolicon'], + ['icon' => 'folder-check', 'prefix' => 'coolicon'], + ['icon' => 'folder-close', 'prefix' => 'coolicon'], + ['icon' => 'folder-code', 'prefix' => 'coolicon'], + ['icon' => 'folder-document', 'prefix' => 'coolicon'], + ['icon' => 'folder-download', 'prefix' => 'coolicon'], + ['icon' => 'folder-open', 'prefix' => 'coolicon'], + ['icon' => 'folder-upload', 'prefix' => 'coolicon'], + ['icon' => 'folders', 'prefix' => 'coolicon'], + ['icon' => 'file-blank', 'prefix' => 'coolicon'], + ['icon' => 'file-add', 'prefix' => 'coolicon'], + ['icon' => 'file-check', 'prefix' => 'coolicon'], + ['icon' => 'file-close', 'prefix' => 'coolicon'], + ['icon' => 'file-code', 'prefix' => 'coolicon'], + ['icon' => 'file-document', 'prefix' => 'coolicon'], + ['icon' => 'file-download', 'prefix' => 'coolicon'], + ['icon' => 'file-upload', 'prefix' => 'coolicon'], + ['icon' => 'files', 'prefix' => 'coolicon'], + ['icon' => 'user-01', 'prefix' => 'coolicon'], + ['icon' => 'user-02', 'prefix' => 'coolicon'], + ['icon' => 'user-03', 'prefix' => 'coolicon'], + ['icon' => 'user-add', 'prefix' => 'coolicon'], + ['icon' => 'user-check', 'prefix' => 'coolicon'], + ['icon' => 'user-circle', 'prefix' => 'coolicon'], + ['icon' => 'user-close', 'prefix' => 'coolicon'], + ['icon' => 'user-remove', 'prefix' => 'coolicon'], + ['icon' => 'user-square', 'prefix' => 'coolicon'], + ['icon' => 'users', 'prefix' => 'coolicon'], + ['icon' => 'users-group', 'prefix' => 'coolicon'], + ['icon' => 'star', 'prefix' => 'coolicon'], + ['icon' => 'heart-01', 'prefix' => 'coolicon'], + ['icon' => 'heart-02', 'prefix' => 'coolicon'], + ['icon' => 'bookmark', 'prefix' => 'coolicon'], + ['icon' => 'flag', 'prefix' => 'coolicon'], + ['icon' => 'tag', 'prefix' => 'coolicon'], + ['icon' => 'label', 'prefix' => 'coolicon'], + ['icon' => 'house-01', 'prefix' => 'coolicon'], + ['icon' => 'house-02', 'prefix' => 'coolicon'], + ['icon' => 'house-03', 'prefix' => 'coolicon'], + ['icon' => 'house-add', 'prefix' => 'coolicon'], + ['icon' => 'house-check', 'prefix' => 'coolicon'], + ['icon' => 'building-01', 'prefix' => 'coolicon'], + ['icon' => 'building-02', 'prefix' => 'coolicon'], + ['icon' => 'building-03', 'prefix' => 'coolicon'], + ['icon' => 'building-04', 'prefix' => 'coolicon'], + ['icon' => 'sun', 'prefix' => 'coolicon'], + ['icon' => 'moon', 'prefix' => 'coolicon'], + ['icon' => 'bulb', 'prefix' => 'coolicon'], + ['icon' => 'compass', 'prefix' => 'coolicon'], + ['icon' => 'map', 'prefix' => 'coolicon'], + ['icon' => 'map-pin', 'prefix' => 'coolicon'], + ['icon' => 'navigation', 'prefix' => 'coolicon'], + ['icon' => 'clock', 'prefix' => 'coolicon'], + ['icon' => 'timer', 'prefix' => 'coolicon'], + ['icon' => 'timer-add', 'prefix' => 'coolicon'], + ['icon' => 'calendar', 'prefix' => 'coolicon'], + ['icon' => 'calendar-add', 'prefix' => 'coolicon'], + ['icon' => 'calendar-check', 'prefix' => 'coolicon'], + ['icon' => 'calendar-event', 'prefix' => 'coolicon'], + ['icon' => 'gift', 'prefix' => 'coolicon'], + ['icon' => 'credit-card-01', 'prefix' => 'coolicon'], + ['icon' => 'credit-card-02', 'prefix' => 'coolicon'], + ['icon' => 'shopping-bag-01', 'prefix' => 'coolicon'], + ['icon' => 'shopping-bag-02', 'prefix' => 'coolicon'], + ['icon' => 'shopping-cart-01', 'prefix' => 'coolicon'], + ['icon' => 'shopping-cart-02', 'prefix' => 'coolicon'], + ['icon' => 'suitcase', 'prefix' => 'coolicon'], + ['icon' => 'archive', 'prefix' => 'coolicon'], + ['icon' => 'book', 'prefix' => 'coolicon'], + ['icon' => 'book-open', 'prefix' => 'coolicon'], + ['icon' => 'notebook', 'prefix' => 'coolicon'], + ['icon' => 'note', 'prefix' => 'coolicon'], + ['icon' => 'note-edit', 'prefix' => 'coolicon'], + ['icon' => 'note-search', 'prefix' => 'coolicon'], + ['icon' => 'camera', 'prefix' => 'coolicon'], + ['icon' => 'image-01', 'prefix' => 'coolicon'], + ['icon' => 'image-02', 'prefix' => 'coolicon'], + ['icon' => 'headphones', 'prefix' => 'coolicon'], + ['icon' => 'printer', 'prefix' => 'coolicon'], + ['icon' => 'airplay', 'prefix' => 'coolicon'], + ['icon' => 'chromecast', 'prefix' => 'coolicon'], + ['icon' => 'download', 'prefix' => 'coolicon'], + ['icon' => 'download-package', 'prefix' => 'coolicon'], + ['icon' => 'cloud-upload', 'prefix' => 'coolicon'], + ['icon' => 'link', 'prefix' => 'coolicon'], + ['icon' => 'link-horizontal', 'prefix' => 'coolicon'], + ['icon' => 'link-vertical', 'prefix' => 'coolicon'], + ['icon' => 'link-break', 'prefix' => 'coolicon'], + ['icon' => 'external-link', 'prefix' => 'coolicon'], + ['icon' => 'share-android', 'prefix' => 'coolicon'], + ['icon' => 'share-ios-export', 'prefix' => 'coolicon'], + ['icon' => 'qr-code', 'prefix' => 'coolicon'], + ['icon' => 'copy', 'prefix' => 'coolicon'], + ['icon' => 'save', 'prefix' => 'coolicon'], + ['icon' => 'trash-empty', 'prefix' => 'coolicon'], + ['icon' => 'trash-full', 'prefix' => 'coolicon'], + ['icon' => 'puzzle', 'prefix' => 'coolicon'], + ['icon' => 'command', 'prefix' => 'coolicon'], + ['icon' => 'option', 'prefix' => 'coolicon'], + ['icon' => 'rainbow', 'prefix' => 'coolicon'], + ['icon' => 'leaf', 'prefix' => 'coolicon'], + ['icon' => 'water-drop', 'prefix' => 'coolicon'], + ['icon' => 'cookie', 'prefix' => 'coolicon'], + ['icon' => 'coffee', 'prefix' => 'coolicon'], + ['icon' => 'coffe-to-go', 'prefix' => 'coolicon'], + ['icon' => 'cupcake', 'prefix' => 'coolicon'], + ['icon' => 'first-aid', 'prefix' => 'coolicon'], + ['icon' => 'car-auto', 'prefix' => 'coolicon'], + ['icon' => 'phone', 'prefix' => 'coolicon'], + ['icon' => 'alarm', 'prefix' => 'coolicon'], + ['icon' => 'help', 'prefix' => 'coolicon'], + ['icon' => 'info', 'prefix' => 'coolicon'], + ['icon' => 'warning', 'prefix' => 'coolicon'], + ['icon' => 'circle-check', 'prefix' => 'coolicon'], + ['icon' => 'circle-warning', 'prefix' => 'coolicon'], + ['icon' => 'circle-help', 'prefix' => 'coolicon'], + ['icon' => 'square-check', 'prefix' => 'coolicon'], + ['icon' => 'square-warning', 'prefix' => 'coolicon'], + ['icon' => 'square-help', 'prefix' => 'coolicon'], + ['icon' => 'triangle-check', 'prefix' => 'coolicon'], + ['icon' => 'triangle-warning', 'prefix' => 'coolicon'], + ['icon' => 'octagon-check', 'prefix' => 'coolicon'], + ['icon' => 'octagon-warning', 'prefix' => 'coolicon'], + ['icon' => 'wavy-check', 'prefix' => 'coolicon'], + ['icon' => 'wavy-warning', 'prefix' => 'coolicon'], + ['icon' => 'wavy-help', 'prefix' => 'coolicon'], + ['icon' => 'play', 'prefix' => 'coolicon'], + ['icon' => 'play-circle', 'prefix' => 'coolicon'], + ['icon' => 'pause', 'prefix' => 'coolicon'], + ['icon' => 'pause-circle', 'prefix' => 'coolicon'], + ['icon' => 'stop', 'prefix' => 'coolicon'], + ['icon' => 'stop-circle', 'prefix' => 'coolicon'], + ['icon' => 'stop-sign', 'prefix' => 'coolicon'], + ['icon' => 'forward', 'prefix' => 'coolicon'], + ['icon' => 'rewind', 'prefix' => 'coolicon'], + ['icon' => 'skip-forward', 'prefix' => 'coolicon'], + ['icon' => 'skip-back', 'prefix' => 'coolicon'], + ['icon' => 'shuffle', 'prefix' => 'coolicon'], + ['icon' => 'volume-max', 'prefix' => 'coolicon'], + ['icon' => 'volume-min', 'prefix' => 'coolicon'], + ['icon' => 'volume-off', 'prefix' => 'coolicon'], + ['icon' => 'expand', 'prefix' => 'coolicon'], + ['icon' => 'shrink', 'prefix' => 'coolicon'], + ['icon' => 'crop', 'prefix' => 'coolicon'], + ['icon' => 'move', 'prefix' => 'coolicon'], + ['icon' => 'show', 'prefix' => 'coolicon'], + ['icon' => 'hide', 'prefix' => 'coolicon'], + ['icon' => 'search-magnifying-glass', 'prefix' => 'coolicon'], + ['icon' => 'magnifying-glass-plus', 'prefix' => 'coolicon'], + ['icon' => 'magnifying-glass-minus', 'prefix' => 'coolicon'], + ['icon' => 'edit-pencil-01', 'prefix' => 'coolicon'], + ['icon' => 'edit-pencil-02', 'prefix' => 'coolicon'], + ['icon' => 'edit-pencil-line-01', 'prefix' => 'coolicon'], + ['icon' => 'edit-pencil-line-02', 'prefix' => 'coolicon'], + ['icon' => 'check', 'prefix' => 'coolicon'], + ['icon' => 'check-big', 'prefix' => 'coolicon'], + ['icon' => 'check-all', 'prefix' => 'coolicon'], + ['icon' => 'check-all-big', 'prefix' => 'coolicon'], + ['icon' => 'close-lg', 'prefix' => 'coolicon'], + ['icon' => 'close-md', 'prefix' => 'coolicon'], + ['icon' => 'close-sm', 'prefix' => 'coolicon'], + ['icon' => 'add-plus', 'prefix' => 'coolicon'], + ['icon' => 'add-plus-circle', 'prefix' => 'coolicon'], + ['icon' => 'add-plus-square', 'prefix' => 'coolicon'], + ['icon' => 'remove-minus', 'prefix' => 'coolicon'], + ['icon' => 'remove-minus-circle', 'prefix' => 'coolicon'], + ['icon' => 'more-horizontal', 'prefix' => 'coolicon'], + ['icon' => 'more-vertical', 'prefix' => 'coolicon'], + ['icon' => 'more-grid-big', 'prefix' => 'coolicon'], + ['icon' => 'more-grid-small', 'prefix' => 'coolicon'], + ['icon' => 'hamburger-lg', 'prefix' => 'coolicon'], + ['icon' => 'hamburger-md', 'prefix' => 'coolicon'], + ['icon' => 'menu-alt-01', 'prefix' => 'coolicon'], + ['icon' => 'menu-alt-02', 'prefix' => 'coolicon'], + ['icon' => 'menu-alt-03', 'prefix' => 'coolicon'], + ['icon' => 'menu-alt-04', 'prefix' => 'coolicon'], + ['icon' => 'menu-alt-05', 'prefix' => 'coolicon'], + ['icon' => 'list-checklist', 'prefix' => 'coolicon'], + ['icon' => 'list-ordered', 'prefix' => 'coolicon'], + ['icon' => 'list-unordered', 'prefix' => 'coolicon'], + ['icon' => 'columns', 'prefix' => 'coolicon'], + ['icon' => 'rows', 'prefix' => 'coolicon'], + ['icon' => 'table', 'prefix' => 'coolicon'], + ['icon' => 'table-add', 'prefix' => 'coolicon'], + ['icon' => 'table-remove', 'prefix' => 'coolicon'], + ['icon' => 'figma', 'prefix' => 'coolicon'], + ['icon' => 'swatches-palette', 'prefix' => 'coolicon'], + ['icon' => 'ruler', 'prefix' => 'coolicon'], + ['icon' => 'path', 'prefix' => 'coolicon'], + ['icon' => 'text', 'prefix' => 'coolicon'], + ['icon' => 'font', 'prefix' => 'coolicon'], + ['icon' => 'bold', 'prefix' => 'coolicon'], + ['icon' => 'italic', 'prefix' => 'coolicon'], + ['icon' => 'underline', 'prefix' => 'coolicon'], + ['icon' => 'strikethrough', 'prefix' => 'coolicon'], + ['icon' => 'paragraph', 'prefix' => 'coolicon'], + ['icon' => 'heading', 'prefix' => 'coolicon'], + ['icon' => 'text-align-left', 'prefix' => 'coolicon'], + ['icon' => 'text-align-center', 'prefix' => 'coolicon'], + ['icon' => 'text-align-right', 'prefix' => 'coolicon'], + ['icon' => 'text-align-justify', 'prefix' => 'coolicon'], + ['icon' => 'undo', 'prefix' => 'coolicon'], + ['icon' => 'redo', 'prefix' => 'coolicon'], + ['icon' => 'log-out', 'prefix' => 'coolicon'], + ['icon' => 'exit', 'prefix' => 'coolicon'], + ['icon' => 'paper-plane', 'prefix' => 'coolicon'], + ['icon' => 'loading', 'prefix' => 'coolicon'], + ['icon' => 'arrows-reload-01', 'prefix' => 'coolicon'], + ['icon' => 'arrow-reload-02', 'prefix' => 'coolicon'], + + // Material Design Icons (postare/blade-mdi) + // Server & Infrastructure + ['icon' => 'server', 'prefix' => 'mdi'], + ['icon' => 'server-network', 'prefix' => 'mdi'], + ['icon' => 'server-network-off', 'prefix' => 'mdi'], + ['icon' => 'server-off', 'prefix' => 'mdi'], + ['icon' => 'server-security', 'prefix' => 'mdi'], + ['icon' => 'database', 'prefix' => 'mdi'], + ['icon' => 'database-outline', 'prefix' => 'mdi'], + ['icon' => 'database-cog', 'prefix' => 'mdi'], + ['icon' => 'database-sync', 'prefix' => 'mdi'], + ['icon' => 'database-lock', 'prefix' => 'mdi'], + ['icon' => 'database-check', 'prefix' => 'mdi'], + ['icon' => 'database-search', 'prefix' => 'mdi'], + ['icon' => 'database-export', 'prefix' => 'mdi'], + ['icon' => 'database-import', 'prefix' => 'mdi'], + ['icon' => 'harddisk', 'prefix' => 'mdi'], + ['icon' => 'harddisk-plus', 'prefix' => 'mdi'], + ['icon' => 'harddisk-remove', 'prefix' => 'mdi'], + ['icon' => 'memory', 'prefix' => 'mdi'], + ['icon' => 'chip', 'prefix' => 'mdi'], + ['icon' => 'cpu-32-bit', 'prefix' => 'mdi'], + ['icon' => 'cpu-64-bit', 'prefix' => 'mdi'], + // Cloud + ['icon' => 'cloud', 'prefix' => 'mdi'], + ['icon' => 'cloud-outline', 'prefix' => 'mdi'], + ['icon' => 'cloud-check', 'prefix' => 'mdi'], + ['icon' => 'cloud-sync', 'prefix' => 'mdi'], + ['icon' => 'cloud-download', 'prefix' => 'mdi'], + ['icon' => 'cloud-upload', 'prefix' => 'mdi'], + ['icon' => 'cloud-lock', 'prefix' => 'mdi'], + ['icon' => 'cloud-cog', 'prefix' => 'mdi'], + ['icon' => 'cloud-refresh', 'prefix' => 'mdi'], + ['icon' => 'cloud-off', 'prefix' => 'mdi'], + ['icon' => 'cloud-braces', 'prefix' => 'mdi'], + ['icon' => 'clouds', 'prefix' => 'mdi'], + // Network + ['icon' => 'lan', 'prefix' => 'mdi'], + ['icon' => 'lan-connect', 'prefix' => 'mdi'], + ['icon' => 'lan-disconnect', 'prefix' => 'mdi'], + ['icon' => 'lan-check', 'prefix' => 'mdi'], + ['icon' => 'lan-pending', 'prefix' => 'mdi'], + ['icon' => 'ethernet', 'prefix' => 'mdi'], + ['icon' => 'ethernet-cable', 'prefix' => 'mdi'], + ['icon' => 'ethernet-off', 'prefix' => 'mdi'], + ['icon' => 'wifi', 'prefix' => 'mdi'], + ['icon' => 'wifi-off', 'prefix' => 'mdi'], + ['icon' => 'wifi-strength-4', 'prefix' => 'mdi'], + ['icon' => 'wifi-strength-3', 'prefix' => 'mdi'], + ['icon' => 'wifi-strength-2', 'prefix' => 'mdi'], + ['icon' => 'wifi-strength-1', 'prefix' => 'mdi'], + ['icon' => 'router', 'prefix' => 'mdi'], + ['icon' => 'router-network', 'prefix' => 'mdi'], + ['icon' => 'router-wireless', 'prefix' => 'mdi'], + ['icon' => 'router-wireless-settings', 'prefix' => 'mdi'], + ['icon' => 'ip', 'prefix' => 'mdi'], + ['icon' => 'ip-network', 'prefix' => 'mdi'], + ['icon' => 'ip-outline', 'prefix' => 'mdi'], + ['icon' => 'dns', 'prefix' => 'mdi'], + ['icon' => 'dns-outline', 'prefix' => 'mdi'], + ['icon' => 'antenna', 'prefix' => 'mdi'], + ['icon' => 'access-point', 'prefix' => 'mdi'], + ['icon' => 'access-point-network', 'prefix' => 'mdi'], + ['icon' => 'broadcast', 'prefix' => 'mdi'], + ['icon' => 'broadcast-off', 'prefix' => 'mdi'], + ['icon' => 'network', 'prefix' => 'mdi'], + ['icon' => 'network-outline', 'prefix' => 'mdi'], + ['icon' => 'network-off', 'prefix' => 'mdi'], + ['icon' => 'network-off-outline', 'prefix' => 'mdi'], + // Security + ['icon' => 'shield', 'prefix' => 'mdi'], + ['icon' => 'shield-outline', 'prefix' => 'mdi'], + ['icon' => 'shield-check', 'prefix' => 'mdi'], + ['icon' => 'shield-lock', 'prefix' => 'mdi'], + ['icon' => 'shield-key', 'prefix' => 'mdi'], + ['icon' => 'shield-account', 'prefix' => 'mdi'], + ['icon' => 'lock', 'prefix' => 'mdi'], + ['icon' => 'lock-outline', 'prefix' => 'mdi'], + ['icon' => 'lock-open', 'prefix' => 'mdi'], + ['icon' => 'lock-open-outline', 'prefix' => 'mdi'], + ['icon' => 'key', 'prefix' => 'mdi'], + ['icon' => 'key-outline', 'prefix' => 'mdi'], + ['icon' => 'key-variant', 'prefix' => 'mdi'], + ['icon' => 'key-chain', 'prefix' => 'mdi'], + ['icon' => 'certificate', 'prefix' => 'mdi'], + ['icon' => 'certificate-outline', 'prefix' => 'mdi'], + ['icon' => 'security', 'prefix' => 'mdi'], + ['icon' => 'security-network', 'prefix' => 'mdi'], + ['icon' => 'fingerprint', 'prefix' => 'mdi'], + ['icon' => 'two-factor-authentication', 'prefix' => 'mdi'], + // Development & Code + ['icon' => 'code-braces', 'prefix' => 'mdi'], + ['icon' => 'code-brackets', 'prefix' => 'mdi'], + ['icon' => 'code-tags', 'prefix' => 'mdi'], + ['icon' => 'code-json', 'prefix' => 'mdi'], + ['icon' => 'code-string', 'prefix' => 'mdi'], + ['icon' => 'code-array', 'prefix' => 'mdi'], + ['icon' => 'console', 'prefix' => 'mdi'], + ['icon' => 'console-line', 'prefix' => 'mdi'], + ['icon' => 'console-network', 'prefix' => 'mdi'], + ['icon' => 'powershell', 'prefix' => 'mdi'], + ['icon' => 'bash', 'prefix' => 'mdi'], + ['icon' => 'api', 'prefix' => 'mdi'], + ['icon' => 'api-off', 'prefix' => 'mdi'], + ['icon' => 'webhook', 'prefix' => 'mdi'], + ['icon' => 'function', 'prefix' => 'mdi'], + ['icon' => 'function-variant', 'prefix' => 'mdi'], + ['icon' => 'variable', 'prefix' => 'mdi'], + ['icon' => 'variable-box', 'prefix' => 'mdi'], + ['icon' => 'xml', 'prefix' => 'mdi'], + ['icon' => 'graphql', 'prefix' => 'mdi'], + // Containers & DevOps + ['icon' => 'docker', 'prefix' => 'mdi'], + ['icon' => 'kubernetes', 'prefix' => 'mdi'], + ['icon' => 'package', 'prefix' => 'mdi'], + ['icon' => 'package-variant', 'prefix' => 'mdi'], + ['icon' => 'package-variant-closed', 'prefix' => 'mdi'], + ['icon' => 'cube', 'prefix' => 'mdi'], + ['icon' => 'cube-outline', 'prefix' => 'mdi'], + ['icon' => 'cube-scan', 'prefix' => 'mdi'], + ['icon' => 'cube-send', 'prefix' => 'mdi'], + ['icon' => 'layers', 'prefix' => 'mdi'], + ['icon' => 'layers-outline', 'prefix' => 'mdi'], + ['icon' => 'layers-triple', 'prefix' => 'mdi'], + ['icon' => 'layers-triple-outline', 'prefix' => 'mdi'], + // Operating Systems & Platforms + ['icon' => 'linux', 'prefix' => 'mdi'], + ['icon' => 'ubuntu', 'prefix' => 'mdi'], + ['icon' => 'debian', 'prefix' => 'mdi'], + ['icon' => 'fedora', 'prefix' => 'mdi'], + ['icon' => 'redhat', 'prefix' => 'mdi'], + ['icon' => 'centos', 'prefix' => 'mdi'], + ['icon' => 'microsoft-windows', 'prefix' => 'mdi'], + ['icon' => 'apple', 'prefix' => 'mdi'], + ['icon' => 'android', 'prefix' => 'mdi'], + ['icon' => 'freebsd', 'prefix' => 'mdi'], + // Version Control + ['icon' => 'git', 'prefix' => 'mdi'], + ['icon' => 'github', 'prefix' => 'mdi'], + ['icon' => 'gitlab', 'prefix' => 'mdi'], + ['icon' => 'source-branch', 'prefix' => 'mdi'], + ['icon' => 'source-commit', 'prefix' => 'mdi'], + ['icon' => 'source-merge', 'prefix' => 'mdi'], + ['icon' => 'source-pull', 'prefix' => 'mdi'], + ['icon' => 'source-fork', 'prefix' => 'mdi'], + // Devices + ['icon' => 'monitor', 'prefix' => 'mdi'], + ['icon' => 'monitor-multiple', 'prefix' => 'mdi'], + ['icon' => 'monitor-dashboard', 'prefix' => 'mdi'], + ['icon' => 'desktop-tower', 'prefix' => 'mdi'], + ['icon' => 'desktop-tower-monitor', 'prefix' => 'mdi'], + ['icon' => 'desktop-classic', 'prefix' => 'mdi'], + ['icon' => 'laptop', 'prefix' => 'mdi'], + ['icon' => 'cellphone', 'prefix' => 'mdi'], + ['icon' => 'tablet', 'prefix' => 'mdi'], + ['icon' => 'television', 'prefix' => 'mdi'], + // Power & Energy + ['icon' => 'power', 'prefix' => 'mdi'], + ['icon' => 'power-plug', 'prefix' => 'mdi'], + ['icon' => 'power-plug-off', 'prefix' => 'mdi'], + ['icon' => 'power-cycle', 'prefix' => 'mdi'], + ['icon' => 'power-on', 'prefix' => 'mdi'], + ['icon' => 'power-off', 'prefix' => 'mdi'], + ['icon' => 'power-standby', 'prefix' => 'mdi'], + ['icon' => 'bolt', 'prefix' => 'mdi'], + ['icon' => 'lightning-bolt', 'prefix' => 'mdi'], + ['icon' => 'lightning-bolt-circle', 'prefix' => 'mdi'], + ['icon' => 'battery', 'prefix' => 'mdi'], + ['icon' => 'battery-charging', 'prefix' => 'mdi'], + ['icon' => 'battery-outline', 'prefix' => 'mdi'], + // Actions & Operations + ['icon' => 'sync', 'prefix' => 'mdi'], + ['icon' => 'sync-circle', 'prefix' => 'mdi'], + ['icon' => 'sync-off', 'prefix' => 'mdi'], + ['icon' => 'refresh', 'prefix' => 'mdi'], + ['icon' => 'refresh-circle', 'prefix' => 'mdi'], + ['icon' => 'reload', 'prefix' => 'mdi'], + ['icon' => 'restart', 'prefix' => 'mdi'], + ['icon' => 'backup-restore', 'prefix' => 'mdi'], + ['icon' => 'download', 'prefix' => 'mdi'], + ['icon' => 'download-outline', 'prefix' => 'mdi'], + ['icon' => 'download-circle', 'prefix' => 'mdi'], + ['icon' => 'upload', 'prefix' => 'mdi'], + ['icon' => 'upload-outline', 'prefix' => 'mdi'], + ['icon' => 'upload-circle', 'prefix' => 'mdi'], + ['icon' => 'content-copy', 'prefix' => 'mdi'], + ['icon' => 'content-paste', 'prefix' => 'mdi'], + ['icon' => 'content-cut', 'prefix' => 'mdi'], + // Settings & Configuration + ['icon' => 'cog', 'prefix' => 'mdi'], + ['icon' => 'cog-outline', 'prefix' => 'mdi'], + ['icon' => 'cogs', 'prefix' => 'mdi'], + ['icon' => 'cog-transfer', 'prefix' => 'mdi'], + ['icon' => 'cog-sync', 'prefix' => 'mdi'], + ['icon' => 'wrench', 'prefix' => 'mdi'], + ['icon' => 'wrench-outline', 'prefix' => 'mdi'], + ['icon' => 'hammer-wrench', 'prefix' => 'mdi'], + ['icon' => 'toolbox', 'prefix' => 'mdi'], + ['icon' => 'toolbox-outline', 'prefix' => 'mdi'], + ['icon' => 'tune', 'prefix' => 'mdi'], + ['icon' => 'tune-vertical', 'prefix' => 'mdi'], + // Files & Folders + ['icon' => 'folder', 'prefix' => 'mdi'], + ['icon' => 'folder-outline', 'prefix' => 'mdi'], + ['icon' => 'folder-open', 'prefix' => 'mdi'], + ['icon' => 'folder-open-outline', 'prefix' => 'mdi'], + ['icon' => 'folder-network', 'prefix' => 'mdi'], + ['icon' => 'folder-network-outline', 'prefix' => 'mdi'], + ['icon' => 'folder-cog', 'prefix' => 'mdi'], + ['icon' => 'folder-lock', 'prefix' => 'mdi'], + ['icon' => 'folder-zip', 'prefix' => 'mdi'], + ['icon' => 'file', 'prefix' => 'mdi'], + ['icon' => 'file-outline', 'prefix' => 'mdi'], + ['icon' => 'file-document', 'prefix' => 'mdi'], + ['icon' => 'file-document-outline', 'prefix' => 'mdi'], + ['icon' => 'file-code', 'prefix' => 'mdi'], + ['icon' => 'file-code-outline', 'prefix' => 'mdi'], + ['icon' => 'file-cog', 'prefix' => 'mdi'], + ['icon' => 'file-lock', 'prefix' => 'mdi'], + // Charts & Analytics + ['icon' => 'chart-line', 'prefix' => 'mdi'], + ['icon' => 'chart-bar', 'prefix' => 'mdi'], + ['icon' => 'chart-pie', 'prefix' => 'mdi'], + ['icon' => 'chart-donut', 'prefix' => 'mdi'], + ['icon' => 'chart-areaspline', 'prefix' => 'mdi'], + ['icon' => 'chart-box', 'prefix' => 'mdi'], + ['icon' => 'chart-timeline', 'prefix' => 'mdi'], + ['icon' => 'chart-gantt', 'prefix' => 'mdi'], + ['icon' => 'poll', 'prefix' => 'mdi'], + ['icon' => 'finance', 'prefix' => 'mdi'], + ['icon' => 'trending-up', 'prefix' => 'mdi'], + ['icon' => 'trending-down', 'prefix' => 'mdi'], + ['icon' => 'trending-neutral', 'prefix' => 'mdi'], + // Users & Accounts + ['icon' => 'account', 'prefix' => 'mdi'], + ['icon' => 'account-outline', 'prefix' => 'mdi'], + ['icon' => 'account-circle', 'prefix' => 'mdi'], + ['icon' => 'account-circle-outline', 'prefix' => 'mdi'], + ['icon' => 'account-group', 'prefix' => 'mdi'], + ['icon' => 'account-group-outline', 'prefix' => 'mdi'], + ['icon' => 'account-multiple', 'prefix' => 'mdi'], + ['icon' => 'account-key', 'prefix' => 'mdi'], + ['icon' => 'account-lock', 'prefix' => 'mdi'], + ['icon' => 'account-cog', 'prefix' => 'mdi'], + // Communication + ['icon' => 'email', 'prefix' => 'mdi'], + ['icon' => 'email-outline', 'prefix' => 'mdi'], + ['icon' => 'email-open', 'prefix' => 'mdi'], + ['icon' => 'message', 'prefix' => 'mdi'], + ['icon' => 'message-outline', 'prefix' => 'mdi'], + ['icon' => 'chat', 'prefix' => 'mdi'], + ['icon' => 'chat-outline', 'prefix' => 'mdi'], + ['icon' => 'forum', 'prefix' => 'mdi'], + ['icon' => 'forum-outline', 'prefix' => 'mdi'], + // Notifications + ['icon' => 'bell', 'prefix' => 'mdi'], + ['icon' => 'bell-outline', 'prefix' => 'mdi'], + ['icon' => 'bell-ring', 'prefix' => 'mdi'], + ['icon' => 'bell-ring-outline', 'prefix' => 'mdi'], + ['icon' => 'bell-badge', 'prefix' => 'mdi'], + ['icon' => 'bell-off', 'prefix' => 'mdi'], + ['icon' => 'bell-alert', 'prefix' => 'mdi'], + // Status & Alerts + ['icon' => 'check', 'prefix' => 'mdi'], + ['icon' => 'check-bold', 'prefix' => 'mdi'], + ['icon' => 'check-circle', 'prefix' => 'mdi'], + ['icon' => 'check-circle-outline', 'prefix' => 'mdi'], + ['icon' => 'check-all', 'prefix' => 'mdi'], + ['icon' => 'check-decagram', 'prefix' => 'mdi'], + ['icon' => 'close', 'prefix' => 'mdi'], + ['icon' => 'close-circle', 'prefix' => 'mdi'], + ['icon' => 'close-circle-outline', 'prefix' => 'mdi'], + ['icon' => 'alert', 'prefix' => 'mdi'], + ['icon' => 'alert-circle', 'prefix' => 'mdi'], + ['icon' => 'alert-circle-outline', 'prefix' => 'mdi'], + ['icon' => 'alert-octagon', 'prefix' => 'mdi'], + ['icon' => 'alert-decagram', 'prefix' => 'mdi'], + ['icon' => 'information', 'prefix' => 'mdi'], + ['icon' => 'information-outline', 'prefix' => 'mdi'], + ['icon' => 'help-circle', 'prefix' => 'mdi'], + ['icon' => 'help-circle-outline', 'prefix' => 'mdi'], + // Time & Calendar + ['icon' => 'clock', 'prefix' => 'mdi'], + ['icon' => 'clock-outline', 'prefix' => 'mdi'], + ['icon' => 'clock-fast', 'prefix' => 'mdi'], + ['icon' => 'clock-alert', 'prefix' => 'mdi'], + ['icon' => 'timer', 'prefix' => 'mdi'], + ['icon' => 'timer-outline', 'prefix' => 'mdi'], + ['icon' => 'calendar', 'prefix' => 'mdi'], + ['icon' => 'calendar-outline', 'prefix' => 'mdi'], + ['icon' => 'calendar-clock', 'prefix' => 'mdi'], + ['icon' => 'calendar-check', 'prefix' => 'mdi'], + ['icon' => 'history', 'prefix' => 'mdi'], + // Navigation & Search + ['icon' => 'magnify', 'prefix' => 'mdi'], + ['icon' => 'magnify-plus', 'prefix' => 'mdi'], + ['icon' => 'magnify-minus', 'prefix' => 'mdi'], + ['icon' => 'magnify-scan', 'prefix' => 'mdi'], + ['icon' => 'eye', 'prefix' => 'mdi'], + ['icon' => 'eye-outline', 'prefix' => 'mdi'], + ['icon' => 'eye-off', 'prefix' => 'mdi'], + ['icon' => 'eye-off-outline', 'prefix' => 'mdi'], + ['icon' => 'filter', 'prefix' => 'mdi'], + ['icon' => 'filter-outline', 'prefix' => 'mdi'], + ['icon' => 'sort', 'prefix' => 'mdi'], + ['icon' => 'sort-ascending', 'prefix' => 'mdi'], + ['icon' => 'sort-descending', 'prefix' => 'mdi'], + // World & Globe + ['icon' => 'earth', 'prefix' => 'mdi'], + ['icon' => 'earth-arrow-right', 'prefix' => 'mdi'], + ['icon' => 'earth-box', 'prefix' => 'mdi'], + ['icon' => 'earth-off', 'prefix' => 'mdi'], + ['icon' => 'web', 'prefix' => 'mdi'], + ['icon' => 'web-box', 'prefix' => 'mdi'], + ['icon' => 'web-clock', 'prefix' => 'mdi'], + ['icon' => 'domain', 'prefix' => 'mdi'], + // Home & Building + ['icon' => 'home', 'prefix' => 'mdi'], + ['icon' => 'home-outline', 'prefix' => 'mdi'], + ['icon' => 'home-city', 'prefix' => 'mdi'], + ['icon' => 'home-city-outline', 'prefix' => 'mdi'], + ['icon' => 'office-building', 'prefix' => 'mdi'], + ['icon' => 'office-building-outline', 'prefix' => 'mdi'], + ['icon' => 'office-building-cog', 'prefix' => 'mdi'], + ['icon' => 'domain', 'prefix' => 'mdi'], + // Favorites & Ratings + ['icon' => 'star', 'prefix' => 'mdi'], + ['icon' => 'star-outline', 'prefix' => 'mdi'], + ['icon' => 'star-circle', 'prefix' => 'mdi'], + ['icon' => 'star-circle-outline', 'prefix' => 'mdi'], + ['icon' => 'heart', 'prefix' => 'mdi'], + ['icon' => 'heart-outline', 'prefix' => 'mdi'], + ['icon' => 'thumb-up', 'prefix' => 'mdi'], + ['icon' => 'thumb-up-outline', 'prefix' => 'mdi'], + ['icon' => 'thumb-down', 'prefix' => 'mdi'], + ['icon' => 'thumb-down-outline', 'prefix' => 'mdi'], + // Misc + ['icon' => 'rocket', 'prefix' => 'mdi'], + ['icon' => 'rocket-launch', 'prefix' => 'mdi'], + ['icon' => 'rocket-launch-outline', 'prefix' => 'mdi'], + ['icon' => 'fire', 'prefix' => 'mdi'], + ['icon' => 'fire-circle', 'prefix' => 'mdi'], + ['icon' => 'bug', 'prefix' => 'mdi'], + ['icon' => 'bug-outline', 'prefix' => 'mdi'], + ['icon' => 'bug-check', 'prefix' => 'mdi'], + ['icon' => 'puzzle', 'prefix' => 'mdi'], + ['icon' => 'puzzle-outline', 'prefix' => 'mdi'], + ['icon' => 'tag', 'prefix' => 'mdi'], + ['icon' => 'tag-outline', 'prefix' => 'mdi'], + ['icon' => 'tag-multiple', 'prefix' => 'mdi'], + ['icon' => 'bookmark', 'prefix' => 'mdi'], + ['icon' => 'bookmark-outline', 'prefix' => 'mdi'], + ['icon' => 'link', 'prefix' => 'mdi'], + ['icon' => 'link-variant', 'prefix' => 'mdi'], + ['icon' => 'link-off', 'prefix' => 'mdi'], + ['icon' => 'view-dashboard', 'prefix' => 'mdi'], + ['icon' => 'view-dashboard-outline', 'prefix' => 'mdi'], + ['icon' => 'view-grid', 'prefix' => 'mdi'], + ['icon' => 'view-grid-outline', 'prefix' => 'mdi'], + ['icon' => 'view-list', 'prefix' => 'mdi'], + ['icon' => 'view-list-outline', 'prefix' => 'mdi'], + ['icon' => 'menu', 'prefix' => 'mdi'], + ['icon' => 'dots-horizontal', 'prefix' => 'mdi'], + ['icon' => 'dots-vertical', 'prefix' => 'mdi'], + ['icon' => 'apps', 'prefix' => 'mdi'], + ['icon' => 'apps-box', 'prefix' => 'mdi'], + ['icon' => 'application', 'prefix' => 'mdi'], + ['icon' => 'application-outline', 'prefix' => 'mdi'], + ['icon' => 'window-maximize', 'prefix' => 'mdi'], + ['icon' => 'window-minimize', 'prefix' => 'mdi'], + ['icon' => 'window-restore', 'prefix' => 'mdi'], + ['icon' => 'monitor-cellphone', 'prefix' => 'mdi'], + ['icon' => 'cellphone-link', 'prefix' => 'mdi'], + ['icon' => 'responsive', 'prefix' => 'mdi'], + ['icon' => 'table', 'prefix' => 'mdi'], + ['icon' => 'table-large', 'prefix' => 'mdi'], + ['icon' => 'table-cog', 'prefix' => 'mdi'], + ['icon' => 'archive', 'prefix' => 'mdi'], + ['icon' => 'archive-outline', 'prefix' => 'mdi'], + ['icon' => 'archive-cog', 'prefix' => 'mdi'], + ['icon' => 'sun-wireless', 'prefix' => 'mdi'], + ['icon' => 'white-balance-sunny', 'prefix' => 'mdi'], + ['icon' => 'weather-night', 'prefix' => 'mdi'], + ['icon' => 'moon-waning-crescent', 'prefix' => 'mdi'], + ['icon' => 'camera', 'prefix' => 'mdi'], + ['icon' => 'camera-outline', 'prefix' => 'mdi'], + ['icon' => 'image', 'prefix' => 'mdi'], + ['icon' => 'image-outline', 'prefix' => 'mdi'], + ['icon' => 'image-multiple', 'prefix' => 'mdi'], + ['icon' => 'video', 'prefix' => 'mdi'], + ['icon' => 'video-outline', 'prefix' => 'mdi'], + ['icon' => 'music', 'prefix' => 'mdi'], + ['icon' => 'music-note', 'prefix' => 'mdi'], + ['icon' => 'microphone', 'prefix' => 'mdi'], + ['icon' => 'microphone-outline', 'prefix' => 'mdi'], + ['icon' => 'headphones', 'prefix' => 'mdi'], + ['icon' => 'speaker', 'prefix' => 'mdi'], + ['icon' => 'play', 'prefix' => 'mdi'], + ['icon' => 'pause', 'prefix' => 'mdi'], + ['icon' => 'stop', 'prefix' => 'mdi'], + ['icon' => 'skip-next', 'prefix' => 'mdi'], + ['icon' => 'skip-previous', 'prefix' => 'mdi'], + ['icon' => 'fast-forward', 'prefix' => 'mdi'], + ['icon' => 'rewind', 'prefix' => 'mdi'], + ]; + @endphp + @foreach($iconList as $iconData) + + @endforeach +
+
+ +
+
+ + +
+ +
+
+ + +
+ + +
+ +
+ + + +
+ + + + + +
+
+ +

Manage Server Tags

+

Add up to 3 tags to organize and identify your server.

+ + +
+
+ Tag 1: + + +
+ + +
+ Tag 2: + + +
+ + +
+ Tag 3: + + +
+
+ + + + +
+ + +
+ + +
+
+
+ + + + +
+
+ + + +
+ Start +
+
+
+ + + +
+ Stop (ACPI) +
+
+
+
+ + + +
+ Reboot (ACPI) +
+
+
+ + + +
+ Power Off +
+
+
+
+
+ + @if ($view !== 'statistics') + + + @endif + + @php + $tabs = [ + ['label' => 'Overview', 'view' => 'overview', 'route' => route('proxmox.server.overview', ['server' => $server->id])], + ['label' => 'Statistics', 'view' => 'statistics', 'route' => route('proxmox.server.statistics', ['server' => $server->id]), 'advanced' => true], + [ + 'label' => 'Hardware', + 'dropdown' => true, + 'items' => [ + ['label' => 'Boot Settings', 'view' => 'boot-order', 'route' => route('proxmox.server.boot-order', ['server' => $server->id]), 'advanced' => true], + ['label' => 'Display', 'view' => 'display', 'route' => route('proxmox.server.display', ['server' => $server->id]), 'advanced' => true], + ['label' => 'Storage', 'view' => 'storage', 'route' => route('proxmox.server.storage', ['server' => $server->id]), 'advanced' => true], + ['label' => 'System', 'view' => 'system', 'route' => route('proxmox.server.system', ['server' => $server->id]), 'advanced' => true], + ] + ], + [ + 'label' => 'Networking', + 'dropdown' => true, + 'items' => [ + ['label' => 'Configuration', 'view' => 'network', 'route' => route('proxmox.server.network', ['server' => $server->id])], + ['label' => 'Firewall', 'view' => 'firewall', 'route' => route('proxmox.server.firewall', ['server' => $server->id]), 'advanced' => true], + ['label' => 'Subdomain', 'view' => 'subdomain', 'route' => route('proxmox.server.subdomain', ['server' => $server->id]), 'advanced' => true], + ] + ], + ['label' => 'Cloud-init', 'view' => 'cloud-init', 'route' => route('proxmox.server.cloud-init', ['server' => $server->id]), 'advanced' => true], + ]; + if($server->service->product->settings()->where('key', 'backups')->first()?->value > 0) { + $tabs[] = ['label' => 'Backups', 'view' => 'backups', 'route' => route('proxmox.server.backups', ['server' => $server->id])]; + } + if($server->node?->vnc_enabled) { + $tabs[] = [ + 'label' => 'Console', + 'dropdown' => true, + 'items' => [ + ['label' => 'NoVNC', 'view' => 'vnc', 'route' => route('proxmox.server.vnc', ['server' => $server->id])], + ['label' => 'Xterm.js', 'view' => 'xterm', 'route' => route('proxmox.server.xterm', ['server' => $server->id])], + ] + ]; + } + $tabs[] = ['label' => 'Help', 'view' => 'help', 'route' => route('proxmox.server.help', ['server' => $server->id]), 'advanced' => true]; + @endphp + +
+ + + + +
+ + +
+ @php + // Find the current tab label, checking both regular tabs and dropdown items + $currentLabel = 'Menu'; + foreach ($tabs as $tab) { + if (isset($tab['view']) && $tab['view'] === $view) { + $currentLabel = $tab['label']; + break; + } + if (isset($tab['dropdown']) && $tab['dropdown'] && isset($tab['items'])) { + foreach ($tab['items'] as $item) { + if ($item['view'] === $view) { + $currentLabel = $item['label']; + break 2; + } + } + } + } + @endphp + +
+ + Std + +
+
+
+ + Adv + +
+
+ +
+
+ @foreach($tabs as $tab) + @php + $isAdvancedTab = $tab['advanced'] ?? false; + $isDropdown = $tab['dropdown'] ?? false; + @endphp + + @if($isDropdown) + {{-- Render dropdown items as individual menu items for mobile --}} +
+ {{ $tab['label'] }} +
+ @foreach($tab['items'] as $item) + @php + $itemAdvanced = $item['advanced'] ?? false; + @endphp + + @endforeach + @else + + @endif + @endforeach +
+
+
+
+ {{-- Fallback x-data provides default values for all page-scoped Alpine variables. + This prevents "not defined" errors during Livewire wire:navigate morphdom + transitions between tabs. Inner x-data scopes on each page override these. + Must be ABOVE the wire:key element so scope persists during key-change replacements. --}} +
+
+ {{ $slot }} +
+
+
+ + @else + @php + $statuses = [ + 'suspended' => [ + 'color' => 'text-yellow-500', + 'message' => 'This server is suspended. Please contact support for more information.' + ], + 'installing' => [ + 'color' => 'text-blue-500', + 'message' => '' + ], + ]; + @endphp + +
+ + @if($server->status === 'installing') + + + + + +
+
+
+
+ +
+ Virtnet.bond +

Creating Server

+
+
+ + + +
+
    +
  • Telling the memory to try harder
  • +
  • Begging the network to behave (again)
  • +
  • Pretending the storage is organized
  • +
  • Asking the CPU to stop being dramatic
  • +
  • Informing telemetry it missed everything
  • +
  • Encouraging the firewall to chill out
  • +
  • Reminding the logs nobody reads them
  • +
  • Lecturing the cluster to stop gossiping
  • +
  • Asking the API politely to behave today
  • +
  • Calming down the swap space (it is stressed)
  • +
+
+ + + GLaDOS r4.32-03 + +
+
+
+ + +
+
+
+

+
+ GLaDOS - "Still Alive" +
+

+ +
+
+
+

+ Congrats! You found a hidden Easter egg! Enjoy the tune and the same love Virtnet.bond has for the Portal series and all video games! We might be serious about work, but we're still gamers at heart. +

+
+
+ Portal - + "Still Alive" by Ellen McLain (voice of GLaDOS) +
+ + + +
+ + Virtnet.bond claims no rights to the "GLaDOS" name or the "Still Alive" audio content. This content is used under the doctrine of fair use (17 U.S. Code § 107 – Limitations on exclusive rights: Fair use). It is provided for entertainment and cultural reference purposes only. All rights to the aforementioned content are retained by their respective copyright holders. + +
+
+
+
+ @else +
+

Oops!

+

+ @php + $message = $statuses[$server->status]['message'] ?? "This server is currently inactive. Please view the Services tab in the Dashboard to see more information about the current state of this server. If more information is required, please contact support via the support portal under Tickets. This message can be displayed after a service is suspended, cancelled, revoked, or potentially in error. If you believe this message has been displayed in error, please contact us via a ticket and be sure to include the following information: VM{$server->vm_id}-ID{$server->id}-{$server->status}."; +@endphp + + {{ $message }} +

+
+ @endif + +
+ @endif + + + + + + + +
+ +
+ + +
+ +
+
diff --git a/extensions/Servers/Proxmox/views/overview.blade.php b/extensions/Servers/Proxmox/views/overview.blade.php index ad2b311a4..cbd3ad306 100644 --- a/extensions/Servers/Proxmox/views/overview.blade.php +++ b/extensions/Servers/Proxmox/views/overview.blade.php @@ -1,2203 +1,2203 @@ - - - -
-
- -
-
-

Cloud-init Configuration Notice

- -
-
-

- Hostname, password, and networking settings are configured automatically via cloud-init. - This applies to standard operating system installations that support cloud-init. However, if you are: -

-
    -
  • Installing from an ISO image
  • -
  • Configuring the operating system manually during installation
  • -
  • Using Windows (which does not support cloud-init)
  • -
-

- You will need to manually configure networking, security settings, and hostname within the operating system, as cloud-init will not be available in these scenarios. -

-
-
-
-
- -
-
-
-

Server Details

-
- - - - - -
-
-
- - -
- - @php - $cpuCores = max(1, (int)($status?->cpus ?? 1)); - $cpuUsagePercent = round(max(0, ($status?->cpu ?? 0) * 100), 1); - $memoryTotal = (int)($status?->maxmem ?? 0); - $memoryUsed = (int)($status?->mem ?? 0); - $memoryPercent = $memoryTotal > 0 ? round(($memoryUsed / $memoryTotal) * 100, 1) : 0; - $manualSnapshotLimit = 5; - $manualSnapshotUsed = $server->statistics()->where('is_automatic', false)->count(); - $manualSnapshotPercent = $manualSnapshotLimit > 0 - ? round(($manualSnapshotUsed / $manualSnapshotLimit) * 100, 1) - : 0; - - $badgeForPercent = function (float $value) { - if ($value >= 100) { - return ['label' => 'Over entitlement', 'class' => 'text-danger bg-danger/15']; - } - - if ($value >= 85) { - return ['label' => 'Watchlist', 'class' => 'text-warning bg-warning/15']; - } - - return ['label' => 'Healthy', 'class' => 'text-success bg-success/15']; - }; - - $cpuBadge = $badgeForPercent($cpuUsagePercent); - $memoryBadge = $badgeForPercent($memoryPercent); - $reportedUptime = $this->status && isset($this->status->uptime) - ? $this->formatSeconds($this->status->uptime) - : 'N/A'; - - $powerStats = $this->powerEventStats ?? [ - 'starts' => 0, - 'shutdowns' => 0, - 'reboots' => 0, - 'uptime_percent' => 0, - 'samples' => 0, - ]; - - $resourceAllocations = [ - [ - 'label' => 'CPU Allocation', - 'used_percent' => $cpuUsagePercent, - 'usage_summary' => number_format(($status?->cpu ?? 0) * $cpuCores, 2) . ' / ' . $cpuCores . ' vCPU', - 'entitlement_label' => $cpuCores . ' vCPU entitlement', - 'description' => 'Live hypervisor usage sampled directly from the cluster.', - 'gradient' => 'linear-gradient(90deg, #38bdf8 0%, #6366f1 100%)', - 'badge' => $cpuBadge, - ], - [ - 'label' => 'Memory Allocation', - 'used_percent' => $memoryPercent, - 'usage_summary' => ($memoryTotal > 0 - ? \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / ' . \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) - : \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / N/A'), - 'entitlement_label' => $memoryTotal > 0 - ? \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) . ' entitlement' - : 'Entitlement unavailable', - 'description' => 'Calculated from the VPS\'s provisioned RAM.', - 'gradient' => 'linear-gradient(90deg, #34d399 0%, #10b981 100%)', - 'badge' => $memoryBadge, - ], - [ - 'label' => 'Hypervisor Uptime', - 'used_percent' => null, - 'usage_summary' => $reportedUptime, - 'entitlement_label' => ($powerStats['starts'] ?? 0) . ' starts • ' . ($powerStats['shutdowns'] ?? 0) . ' shutdowns • ' . ($powerStats['reboots'] ?? 0) . ' reboots', - 'description' => 'Shows the live uptime reported by the hypervisor plus counts from the task history.', - 'gradient' => 'linear-gradient(90deg, #a855f7 0%, #ec4899 100%)', - 'badge' => ['label' => 'Live', 'class' => 'text-info bg-info/15'], - ], - ]; - - $networkRates = $this->networkRateStats ?? ['in_rate' => 0, 'out_rate' => 0]; - $formatRate = function ($bytesPerSecond) { - if ($bytesPerSecond >= 1073741824) { - return number_format($bytesPerSecond / 1073741824, 2) . ' GiB/s'; - } - if ($bytesPerSecond >= 1048576) { - return number_format($bytesPerSecond / 1048576, 2) . ' MiB/s'; - } - if ($bytesPerSecond >= 1024) { - return number_format($bytesPerSecond / 1024, 2) . ' KiB/s'; - } - - return number_format($bytesPerSecond, 0) . ' B/s'; - }; - $rateScale = 5 * 1024 * 1024; // 5 MiB/s caps the bar width - $aggregateRate = ($networkRates['in_rate'] ?? 0) + ($networkRates['out_rate'] ?? 0); - $totalNetworkBytes = (int) (($status?->netin ?? 0) + ($status?->netout ?? 0)); - $totalNetworkFormatted = number_format($totalNetworkBytes / 1024 / 1024, 2); - $lastSampleTimestamp = now()->toIso8601String(); - @endphp - -
-
-
-
-

Resource Allocation vs Entitlement

-
- - - - - -
-
-

Instantly compare what this service is entitled to with how much it is consuming right now.

-
-

Values refresh alongside the live status poll.

-
-
- @foreach($resourceAllocations as $resource) -
-
-
-

{{ $resource['label'] }}

-

{{ $resource['usage_summary'] }}

-

{{ $resource['entitlement_label'] }}

-
- - {{ $resource['badge']['label'] }} - -
-
- @if(!is_null($resource['used_percent'])) -
- Usage - {{ $resource['used_percent'] }}% -
-
-
-
-
- @endif -

{{ $resource['description'] }}

-
-
- @endforeach -
-
- -
- -
- -
- -
-
-
- -
-
-

Server Details

-

Configuration and access information

-
-
- - -
- -
- -
-
- @if ($status && $status->status === 'running') - - - - - @else - - - - @endif -
- - {{ $status && $status->status === 'running' ? 'Running' : 'Stopped' }} - -
-
- - -
- -
- - {{ $server->os?->name ?? 'Unknown' }} - -
-
- - -
- -
-
- - {{ $server->hostname ?? $status?->name ?? 'N/A' }} - - -
-
- - - - - - -
- - - @if($server->primaryIpv4) -
- -
-
- - {{ $server->primaryIpv4->ip }} - - -
-
-
- @endif - - - @if($server->primaryIpv6) -
- -
-
- - {{ $server->primaryIpv6->ip }} - - -
-
-
- @endif - - -
- -
-
- - @if(isset($server->password) && $server->password) - •••••••••••• - @else - Password is sent via email on install. - @endif - - @if(isset($server->password) && $server->password) - - @endif - -
-
- - - - - - - - - - - - -
-
-
- -
- -
- -
- - -
-
-
-
-

- - - - Live Network Traffic -

-
- - - - - -
-
-

Real-time bandwidth monitoring • Updates every 30 seconds

-
-
- -
-
- - -
- -
-

Loading live network statistics…

-
-
-
- -
- -
- - @if($server->primaryIpv6) -
-
-
- -
-
-

Domain Name System (DNS)

-

IPv6 DNS records for your server

-
-
-
-
-
-
- - - - - -
-
- - -

IPv6-based DNS records for {{ $server->primaryIpv6->ip }}

- -
- @foreach([ - ['label' => 'Virtnet DNS', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host', 'provider' => 'Internally Provided'], - ['label' => 'nip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io', 'provider' => 'nip.io', 'url' => 'https://nip.io'], - ['label' => 'sslip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io', 'provider' => 'sslip.io', 'url' => 'https://sslip.io'], - ['label' => 'backname.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io', 'provider' => 'backname.io', 'url' => 'https://backname.io'], - ] as $dns) -
-
-
- {{ $dns['host'] }} -
- - @if(isset($dns['url'])) - {{ $dns['provider'] }} ↗ - @else - {{ $dns['provider'] }} - @endif - -
- -
- @endforeach -
-
- - - -
-

- @ points the root domain directly to the server IP ({{ $server->primaryIpv6->ip }})
- *.@ points all subdomains (wildcard) to the same IP ({{ $server->primaryIpv6->ip }})

- - @ — Represents the root domain (also called the apex domain). - This means the base domain name (e.g. example.com) will point directly to the server IP.

- - *.@ — Represents a wildcard record. - This ensures that any subdomain also points to the same server IP. -

- - Together, using both @ and *.@ ensures that your server will respond to requests for the root domain and all possible subdomains.

- - rDNS PTR Records: Please make note that at no time will PTR records be generated for any IP address via the RIR systems. No exceptions will be made other than for direct contract business customers due to the security risks involved with allowing arbitrary PTR records to be set and the potential for abuse.

- -

- - - @endif - - -
-
-
-
-

Notes

-
- - - - - -
-
-

Store notes about this server, Markdown is supported. (These do not persist reinstalls)

-
-
- -
-
- - - - - -
- @if($isEditingNotes) -
- - @if($notesError) -
{{ $notesError }}
- @endif -
- - -
Save
-
Saving...
-
- - Cancel - -
-
- @else -
-
- @if($notes) - {!! \Illuminate\Support\Str::markdown(e($notes), [ - 'allow_unsafe_links' => false, - 'renderer' => [ - 'soft_break' => "
" - ] - ]) !!} - @else -

No notes stored. Click Edit to add notes.

- @endif -
-
- - - - Edit - -
-
- @endif -
-
- - @if(stripos($server->os?->name ?? '', 'Windows') !== false) -
-
-
- -
-
-

Windows Server

-

Installation and configuration notes

-
-
-
- -
-
- - - - - -
-
- - - - -
-

- Windows Server Networking (IPv4/IPv6 Setup): -

-

- Cloud-init does not automatically configure IPv4 or IPv6 networking on Windows Server installations. Please configure the addresses manually as assigned in the control panel of your installation. -

-
-
-

- Windows Server Activation (Licensing): -

-

- No Windows Server license key is provided by Virtnet.bond. It is the responsibility of the account holder - to provide and maintain active and compliant licensing with all Windows Server products. -

-
-
-

- Periodic Licensing Audits and User Compliance: -

-

- Virtnet.bond reserves the right to perform periodic audits of Windows Server installations to ensure compliance with Microsoft's licensing terms. Non-compliant installations may be subject to suspension or termination. If you have any questions regarding licensing compliance, please contact our support team. -

-
- -
-
- - @endif -
-
- - - -@once - - - - -@endonce - -
\ No newline at end of file + + + +
+
+ +
+
+

Cloud-init Configuration Notice

+ +
+
+

+ Hostname, password, and networking settings are configured automatically via cloud-init. + This applies to standard operating system installations that support cloud-init. However, if you are: +

+
    +
  • Installing from an ISO image
  • +
  • Configuring the operating system manually during installation
  • +
  • Using Windows (which does not support cloud-init)
  • +
+

+ You will need to manually configure networking, security settings, and hostname within the operating system, as cloud-init will not be available in these scenarios. +

+
+
+
+
+ +
+
+
+

Server Details

+
+ + + + + +
+
+
+ + +
+ + @php + $cpuCores = max(1, (int)($status?->cpus ?? 1)); + $cpuUsagePercent = round(max(0, ($status?->cpu ?? 0) * 100), 1); + $memoryTotal = (int)($status?->maxmem ?? 0); + $memoryUsed = (int)($status?->mem ?? 0); + $memoryPercent = $memoryTotal > 0 ? round(($memoryUsed / $memoryTotal) * 100, 1) : 0; + $manualSnapshotLimit = 5; + $manualSnapshotUsed = $server->statistics()->where('is_automatic', false)->count(); + $manualSnapshotPercent = $manualSnapshotLimit > 0 + ? round(($manualSnapshotUsed / $manualSnapshotLimit) * 100, 1) + : 0; + + $badgeForPercent = function (float $value) { + if ($value >= 100) { + return ['label' => 'Over entitlement', 'class' => 'text-danger bg-danger/15']; + } + + if ($value >= 85) { + return ['label' => 'Watchlist', 'class' => 'text-warning bg-warning/15']; + } + + return ['label' => 'Healthy', 'class' => 'text-success bg-success/15']; + }; + + $cpuBadge = $badgeForPercent($cpuUsagePercent); + $memoryBadge = $badgeForPercent($memoryPercent); + $reportedUptime = $this->status && isset($this->status->uptime) + ? $this->formatSeconds($this->status->uptime) + : 'N/A'; + + $powerStats = $this->powerEventStats ?? [ + 'starts' => 0, + 'shutdowns' => 0, + 'reboots' => 0, + 'uptime_percent' => 0, + 'samples' => 0, + ]; + + $resourceAllocations = [ + [ + 'label' => 'CPU Allocation', + 'used_percent' => $cpuUsagePercent, + 'usage_summary' => number_format(($status?->cpu ?? 0) * $cpuCores, 2) . ' / ' . $cpuCores . ' vCPU', + 'entitlement_label' => $cpuCores . ' vCPU entitlement', + 'description' => 'Live hypervisor usage sampled directly from the cluster.', + 'gradient' => 'linear-gradient(90deg, #38bdf8 0%, #6366f1 100%)', + 'badge' => $cpuBadge, + ], + [ + 'label' => 'Memory Allocation', + 'used_percent' => $memoryPercent, + 'usage_summary' => ($memoryTotal > 0 + ? \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / ' . \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) + : \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / N/A'), + 'entitlement_label' => $memoryTotal > 0 + ? \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) . ' entitlement' + : 'Entitlement unavailable', + 'description' => 'Calculated from the VPS\'s provisioned RAM.', + 'gradient' => 'linear-gradient(90deg, #34d399 0%, #10b981 100%)', + 'badge' => $memoryBadge, + ], + [ + 'label' => 'Hypervisor Uptime', + 'used_percent' => null, + 'usage_summary' => $reportedUptime, + 'entitlement_label' => ($powerStats['starts'] ?? 0) . ' starts • ' . ($powerStats['shutdowns'] ?? 0) . ' shutdowns • ' . ($powerStats['reboots'] ?? 0) . ' reboots', + 'description' => 'Shows the live uptime reported by the hypervisor plus counts from the task history.', + 'gradient' => 'linear-gradient(90deg, #a855f7 0%, #ec4899 100%)', + 'badge' => ['label' => 'Live', 'class' => 'text-info bg-info/15'], + ], + ]; + + $networkRates = $this->networkRateStats ?? ['in_rate' => 0, 'out_rate' => 0]; + $formatRate = function ($bytesPerSecond) { + if ($bytesPerSecond >= 1073741824) { + return number_format($bytesPerSecond / 1073741824, 2) . ' GiB/s'; + } + if ($bytesPerSecond >= 1048576) { + return number_format($bytesPerSecond / 1048576, 2) . ' MiB/s'; + } + if ($bytesPerSecond >= 1024) { + return number_format($bytesPerSecond / 1024, 2) . ' KiB/s'; + } + + return number_format($bytesPerSecond, 0) . ' B/s'; + }; + $rateScale = 5 * 1024 * 1024; // 5 MiB/s caps the bar width + $aggregateRate = ($networkRates['in_rate'] ?? 0) + ($networkRates['out_rate'] ?? 0); + $totalNetworkBytes = (int) (($status?->netin ?? 0) + ($status?->netout ?? 0)); + $totalNetworkFormatted = number_format($totalNetworkBytes / 1024 / 1024, 2); + $lastSampleTimestamp = now()->toIso8601String(); + @endphp + +
+
+
+
+

Resource Allocation vs Entitlement

+
+ + + + + +
+
+

Instantly compare what this service is entitled to with how much it is consuming right now.

+
+

Values refresh alongside the live status poll.

+
+
+ @foreach($resourceAllocations as $resource) +
+
+
+

{{ $resource['label'] }}

+

{{ $resource['usage_summary'] }}

+

{{ $resource['entitlement_label'] }}

+
+ + {{ $resource['badge']['label'] }} + +
+
+ @if(!is_null($resource['used_percent'])) +
+ Usage + {{ $resource['used_percent'] }}% +
+
+
+
+
+ @endif +

{{ $resource['description'] }}

+
+
+ @endforeach +
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+

Server Details

+

Configuration and access information

+
+
+ + +
+ +
+ +
+
+ @if ($status && $status->status === 'running') + + + + + @else + + + + @endif +
+ + {{ $status && $status->status === 'running' ? 'Running' : 'Stopped' }} + +
+
+ + +
+ +
+ + {{ $server->os?->name ?? 'Unknown' }} + +
+
+ + +
+ +
+
+ + {{ $server->hostname ?? $status?->name ?? 'N/A' }} + + +
+
+ + + + + + +
+ + + @if($server->primaryIpv4) +
+ +
+
+ + {{ $server->primaryIpv4->ip }} + + +
+
+
+ @endif + + + @if($server->primaryIpv6) +
+ +
+
+ + {{ $server->primaryIpv6->ip }} + + +
+
+
+ @endif + + +
+ +
+
+ + @if(isset($server->password) && $server->password) + •••••••••••• + @else + Password is sent via email on install. + @endif + + @if(isset($server->password) && $server->password) + + @endif + +
+
+ + + + + + + + + + + + +
+
+
+ +
+ +
+ +
+ + +
+
+
+
+

+ + + + Live Network Traffic +

+
+ + + + + +
+
+

Real-time bandwidth monitoring • Updates every 30 seconds

+
+
+ +
+
+ + +
+ +
+

Loading live network statistics…

+
+
+
+ +
+ +
+ + @if($server->primaryIpv6) +
+
+
+ +
+
+

Domain Name System (DNS)

+

IPv6 DNS records for your server

+
+
+
+
+
+
+ + + + + +
+
+ + +

IPv6-based DNS records for {{ $server->primaryIpv6->ip }}

+ +
+ @foreach([ + ['label' => 'Virtnet DNS', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host', 'provider' => 'Internally Provided'], + ['label' => 'nip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io', 'provider' => 'nip.io', 'url' => 'https://nip.io'], + ['label' => 'sslip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io', 'provider' => 'sslip.io', 'url' => 'https://sslip.io'], + ['label' => 'backname.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io', 'provider' => 'backname.io', 'url' => 'https://backname.io'], + ] as $dns) +
+
+
+ {{ $dns['host'] }} +
+ + @if(isset($dns['url'])) + {{ $dns['provider'] }} ↗ + @else + {{ $dns['provider'] }} + @endif + +
+ +
+ @endforeach +
+
+ + + +
+

+ @ points the root domain directly to the server IP ({{ $server->primaryIpv6->ip }})
+ *.@ points all subdomains (wildcard) to the same IP ({{ $server->primaryIpv6->ip }})

+ + @ — Represents the root domain (also called the apex domain). + This means the base domain name (e.g. example.com) will point directly to the server IP.

+ + *.@ — Represents a wildcard record. + This ensures that any subdomain also points to the same server IP. +

+ + Together, using both @ and *.@ ensures that your server will respond to requests for the root domain and all possible subdomains.

+ + rDNS PTR Records: Please make note that at no time will PTR records be generated for any IP address via the RIR systems. No exceptions will be made other than for direct contract business customers due to the security risks involved with allowing arbitrary PTR records to be set and the potential for abuse.

+ +

+ + + @endif + + +
+
+
+
+

Notes

+
+ + + + + +
+
+

Store notes about this server, Markdown is supported. (These do not persist reinstalls)

+
+
+ +
+
+ + + + + +
+ @if($isEditingNotes) +
+ + @if($notesError) +
{{ $notesError }}
+ @endif +
+ + +
Save
+
Saving...
+
+ + Cancel + +
+
+ @else +
+
+ @if($notes) + {!! \Illuminate\Support\Str::markdown(e($notes), [ + 'allow_unsafe_links' => false, + 'renderer' => [ + 'soft_break' => "
" + ] + ]) !!} + @else +

No notes stored. Click Edit to add notes.

+ @endif +
+
+ + + + Edit + +
+
+ @endif +
+
+ + @if(stripos($server->os?->name ?? '', 'Windows') !== false) +
+
+
+ +
+
+

Windows Server

+

Installation and configuration notes

+
+
+
+ +
+
+ + + + + +
+
+ + + + +
+

+ Windows Server Networking (IPv4/IPv6 Setup): +

+

+ Cloud-init does not automatically configure IPv4 or IPv6 networking on Windows Server installations. Please configure the addresses manually as assigned in the control panel of your installation. +

+
+
+

+ Windows Server Activation (Licensing): +

+

+ No Windows Server license key is provided by Virtnet.bond. It is the responsibility of the account holder + to provide and maintain active and compliant licensing with all Windows Server products. +

+
+
+

+ Periodic Licensing Audits and User Compliance: +

+

+ Virtnet.bond reserves the right to perform periodic audits of Windows Server installations to ensure compliance with Microsoft's licensing terms. Non-compliant installations may be subject to suspension or termination. If you have any questions regarding licensing compliance, please contact our support team. +

+
+ +
+
+ + @endif +
+
+ + + +@once + + + + +@endonce + +
diff --git a/extensions/Servers/Proxmox/views/statistics.blade.php b/extensions/Servers/Proxmox/views/statistics.blade.php index 07a2cc4f4..f1ff9fe13 100644 --- a/extensions/Servers/Proxmox/views/statistics.blade.php +++ b/extensions/Servers/Proxmox/views/statistics.blade.php @@ -1,6027 +1,6027 @@ - - @once - - @endonce -
- - -
-
- Overview - - Statistics -
-
-
-
-

Statistics

-
- - - - - -
-
-

- Monitor your server's performance metrics and resource utilization over time. -

-
-
-
- - - -
- -
-
-
- Loading Telemetry... -
-
- -
-
-
-

- - Live Telemetry -
- - - - - -
-

-
-
- -
- -
-
-
-
-
-

- - Processor Utilization -
- - - - - -
-

-
-
- - - -
-
-
-
- Loading chart... -
-
-
-
-
-
- - -
-
-
-
-
-

- - Network Traffic -
- - - - - -
-

-
-
- - - -
-
-
-
- Loading chart... -
-
-
-
-
-
- - -
-
-
-
-
-

- - Memory Utilization -
- - - - - -
-

-
-
- - - -
-
-
-
- Loading chart... -
-
-
-
-
-
- - -
-
-
-
-
-

- - Storage I/O -
- - - - - -
-

-
-
- - - -
-
-
-
- Loading chart... -
-
-
-
-
-
-
-
- - - @php - $statisticsExportRows = []; - @endphp -
-
-

- - Statistics History -
- - - - - -
-

-
- - - -
- New Report -
-
- Creating... -
-
- - -
-
-
-
-
-
- -
- -
-
-
- - - - - - - - - - - - - - - - - @forelse($this->statistics as $stat) - @php - $networkStats = $this->getCurrentNetworkStats($stat); - $diskStats = $this->getCurrentDiskStats($stat); - $cpuPercent = $stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0; - $memPercent = $stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0; - $diskPercent = ($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0; - $statisticsExportRows[] = [ - 'date_time' => $stat->DateTime ? $stat->DateTime->format('M d, Y H:i:s') : '-', - 'status' => $stat->vmstatus ? ucfirst($stat->vmstatus) : '-', - 'cpu' => number_format($cpuPercent, 1) . '%', - 'memory' => number_format($memPercent, 1) . '%', - 'disk' => (($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0) ? number_format($diskPercent, 1) . '%' : 'N/A', - 'network_in' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']), - 'network_out' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']), - 'disk_io' => sprintf( - '%s / %s', - Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0), - Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0) - ), - 'uptime' => $stat->uptime > 0 ? $this->formatUptime($stat->uptime) : '-', - ]; - @endphp - - - - - - - - - - - - - @empty - - - - @endforelse - -
-
- Date & Time - -
-
-
- Status - -
-
-
- CPU - -
-
-
- Memory - -
-
-
- Disk - -
-
-
- Network In - -
-
-
- Network Out - -
-
-
- Disk I/O - -
-
-
- Uptime - -
-
Actions
-
{{ $stat->DateTime?->format('M d, Y') ?? '-' }}
-
{{ $stat->DateTime?->format('H:i:s') ?? '' }}
-
- @if($stat->vmstatus) - - {{ ucfirst($stat->vmstatus) }} - - @else - - - @endif - -
-
-
-
- {{ number_format($cpuPercent, 1) }}% -
-
-
-
-
-
- {{ number_format($memPercent, 1) }}% -
-
- @if(($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0) -
-
-
-
- {{ number_format($diskPercent, 1) }}% -
- @else - N/A - @endif -
-
- - {{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']) }} -
- @if($networkStats['netin_rate'] > 0) -
{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netin_rate']) }}/s
- @endif -
-
- - {{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']) }} -
- @if($networkStats['netout_rate'] > 0) -
{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netout_rate']) }}/s
- @endif -
-
-
- - {{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0) }} -
-
- - {{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0) }} -
-
-
- @if($stat->uptime > 0) - {{ $this->formatUptime($stat->uptime) }} - @else - - - @endif - - -
-
-
- -
-
-

No statistics available

-

Statistics are collected every 5 minutes automatically.

-
-
-
-
-
- - - @if($this->statistics->total() > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} -
- - Showing {{ (($this->statistics->currentPage() - 1) * $this->statistics->perPage()) + 1 }} - {{ min($this->statistics->currentPage() * $this->statistics->perPage(), $this->statistics->total()) }} of {{ $this->statistics->total() }} entries - - - @if($this->statistics->hasPages()) -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $statsTotalPages = $this->statistics->lastPage(); - $statsCurrentPage = $this->statistics->currentPage(); - $statsStartPage = max(1, $statsCurrentPage - 2); - $statsEndPage = min($statsTotalPages, $statsCurrentPage + 2); - @endphp - - @if($statsStartPage > 1) - - @if($statsStartPage > 2) - ... - @endif - @endif - - @for($i = $statsStartPage; $i <= $statsEndPage; $i++) - - @endfor - - @if($statsEndPage < $statsTotalPages) - @if($statsEndPage < $statsTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
- @endif -
-
- @endif -
- - - @php - $taskHistoryExportRows = []; - @endphp -
-
-

- - Task History -
- - - - - -
-

-
- -
-
-
-
-
-
- -
- -
-
-
- - - - - - - - - - - - @forelse($this->paginatedTaskHistory as $task) - @php - $startTime = isset($task['starttime']) ? \Carbon\Carbon::createFromTimestamp($task['starttime']) : null; - $endTime = isset($task['endtime']) && $task['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($task['endtime']) : null; - $duration = null; - if ($startTime && $endTime) { - $duration = $startTime->diffInSeconds($endTime); - } elseif ($startTime && isset($task['status']) && $task['status'] === 'running') { - $duration = $startTime->diffInSeconds(now()); - } - $taskUpid = $task['upid'] ?? $task['id'] ?? ''; - $taskKey = md5($taskUpid . ($task['starttime'] ?? '')); - $isExpanded = in_array($taskUpid, $this->expandedTaskUpids ?? []); - $taskHistoryExportRows[] = [ - 'type' => $task['type'] ?? 'N/A', - 'status' => $task['status'] ?? '-', - 'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-', - 'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-', - 'duration' => $duration !== null ? $this->formatUptime($duration) : '-', - ]; - @endphp - - - - - - - - - - - - - - - @empty - - - - - - @endforelse -
-
- Type - -
-
-
- Status - -
-
-
- Start Time - -
-
-
- End Time - -
-
-
- Duration - -
-
- - - {{ $task['type'] ?? 'N/A' }} - - @if(isset($task['status'])) - @if($task['status'] === 'OK') - - Success - - @else - - Failure - - @endif - @else - - - @endif - - @if($startTime) -
{{ $startTime->format('M d, Y') }}
-
{{ $startTime->format('H:i:s') }}
- @else - - - @endif -
- @if($endTime) -
{{ $endTime->format('M d, Y') }}
-
{{ $endTime->format('H:i:s') }}
- @else - - - @endif -
- @if($duration !== null) - {{ $this->formatUptime($duration) }} - @else - - - @endif -
-
-

- - Task Information -

- - -
-
- - - - - Loading task details... -
-
- - -
- - -
- -
- - - - - -
- - -
-

No detailed information available for this task.

-
-
-
-
-
- -
-
-

No task history available

-

Task history will appear here as tasks are executed on the server.

-
-
-
-
-
- - - @if(count($this->taskHistory) > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} - @php - $taskTotalPages = $this->taskHistoryTotalPages; - $taskCurrentPage = $taskHistoryCurrentPage; - $taskTotalCount = count($this->taskHistory); - @endphp -
- - Showing {{ (($taskCurrentPage - 1) * $taskHistoryPerPage) + 1 }} - {{ min($taskCurrentPage * $taskHistoryPerPage, $taskTotalCount) }} of {{ $taskTotalCount }} tasks - - - @if($taskTotalPages > 1) -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $taskStartPage = max(1, $taskCurrentPage - 2); - $taskEndPage = min($taskTotalPages, $taskCurrentPage + 2); - @endphp - - @if($taskStartPage > 1) - - @if($taskStartPage > 2) - ... - @endif - @endif - - @for($i = $taskStartPage; $i <= $taskEndPage; $i++) - - @endfor - - @if($taskEndPage < $taskTotalPages) - @if($taskEndPage < $taskTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
- @endif -
-
- @endif -
- - - @php - $lifecycleEventsExportRows = []; - @endphp -
-
-

- - Recent Lifecycle Events -
- - - - - -
-

-
- -
-
-
-
-
-
- -
- -
-
-
- - - - - - - - - - - - @forelse($this->paginatedLifecycleEvents as $index => $event) - @php - $startTime = isset($event['starttime']) ? \Carbon\Carbon::createFromTimestamp($event['starttime']) : null; - $endTime = isset($event['endtime']) && $event['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($event['endtime']) : null; - $duration = $event['duration'] ?? null; - $eventUpid = $event['upid'] ?? $event['timestamp'] ?? ''; - $eventKey = md5($eventUpid . ($event['starttime'] ?? '')); - $isExpanded = in_array($eventUpid, $this->expandedTaskUpids ?? []); - - $eventColorMap = [ - 'start' => 'text-success', - 'shutdown' => 'text-danger', - 'reboot' => 'text-info', - 'reset' => 'text-warning', - 'backup' => 'text-purple-200', - ]; - $eventColor = $eventColorMap[$event['type']] ?? 'text-base/70'; - - $lifecycleEventsExportRows[] = [ - 'type' => $event['label'] ?? 'N/A', - 'status' => ucfirst($event['status'] ?? '-'), - 'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-', - 'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-', - 'duration' => $duration !== null ? $this->formatUptime($duration) : '-', - ]; - @endphp - - - - - - - - - - - - - - - @empty - - - - - - @endforelse -
-
- Type - -
-
-
- Status - -
-
-
- Start Time - -
-
-
- End Time - -
-
-
- Duration - -
-
- - - {{ $event['label'] ?? 'N/A' }} - - @if(isset($event['status'])) - @if(strtolower($event['status']) === 'ok') - - Success - - @elseif(strtolower($event['status']) === 'running') - - Running - - @else - - Failure - - @endif - @else - - - @endif - - @if($startTime) -
{{ $startTime->format('M d, Y') }}
-
{{ $startTime->format('H:i:s') }}
- @else - - - @endif -
- @if($endTime) -
{{ $endTime->format('M d, Y') }}
-
{{ $endTime->format('H:i:s') }}
- @else - - - @endif -
- @if($duration !== null) - {{ $this->formatUptime($duration) }} - @else - - - @endif -
-
-

- - Event Information -

- -
-
- Type - {{ $event['original_type'] ?? 'N/A' }} -
-
- Status - {{ ucfirst($event['status'] ?? 'Unknown') }} -
-
- Event Type - {{ $event['label'] ?? 'N/A' }} -
-
- -
- @if(isset($event['upid'])) -
- Task ID - {{ str_replace([':root@pam!paymenter:', ':root@pam:'], '', $event['upid']) }} -
- @endif -
-
-
-
-
- -
-
-

No lifecycle events available

-

Lifecycle events will appear here as operations are performed on the server.

-
-
-
-
-
- - - @php - $lifecycleTotalCount = count($this->chartEvents); - @endphp - @if($lifecycleTotalCount > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} - @php - $lifecycleTotalPages = $this->lifecycleEventsTotalPages; - $lifecycleCurrentPage = $lifecycleEventsCurrentPage; - @endphp -
- - Showing {{ (($lifecycleCurrentPage - 1) * $lifecycleEventsPerPage) + 1 }} - {{ min($lifecycleCurrentPage * $lifecycleEventsPerPage, $lifecycleTotalCount) }} of {{ $lifecycleTotalCount }} events - - - @if($lifecycleTotalPages > 1) -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $lifecycleStartPage = max(1, $lifecycleCurrentPage - 2); - $lifecycleEndPage = min($lifecycleTotalPages, $lifecycleCurrentPage + 2); - @endphp - - @if($lifecycleStartPage > 1) - - @if($lifecycleStartPage > 2) - ... - @endif - @endif - - @for($i = $lifecycleStartPage; $i <= $lifecycleEndPage; $i++) - - @endfor - - @if($lifecycleEndPage < $lifecycleTotalPages) - @if($lifecycleEndPage < $lifecycleTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
- @endif -
-
- @endif -
-
- - - @php - $historicalComparison = $this->historicalComparison; - @endphp -
-
-

- - Historical Data Comparison -
- - - - - -
-

-
- -
-
- -
- -
-
- - - @if($historicalPeriod === 'custom') - - @endif -
- -
- - - @if($currentPeriod === 'custom') - - @endif -
-
-
- - @if($historicalComparison && !isset($historicalComparison['error'])) - - @php - $current = $historicalComparison['current']; - $historical = $historicalComparison['historical']; - $diff = $historicalComparison['differences']; - @endphp - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Metric -
- - Historical - ({{ $historical->DateTime->format('M d, Y H:i') }}) -
-
-
- - Current - ({{ $current->DateTime->format('M d, Y H:i') }}) -
-
Change
-
- - Processor Utilization -
-
- {{ number_format($diff['cpu']['historical_percent'], 1) }}% - - {{ number_format($diff['cpu']['current_percent'], 1) }}% - - - {{ $diff['cpu']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['cpu']['percent_change'], 1) }}% - -
-
- - Memory Utilization -
-
- {{ number_format($diff['memory']['historical_percent'], 1) }}% - - {{ number_format($diff['memory']['current_percent'], 1) }}% - - - {{ $diff['memory']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['memory']['percent_change'], 1) }}% - -
-
- - Disk Read Rate -
-
- {{ $this->formatBytes($diff['disk_read']['historical_rate']) }}/s - - {{ $this->formatBytes($diff['disk_read']['current_rate']) }}/s - - - {{ $diff['disk_read']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_read']['percent_change'], 1) }}% - -
-
- - Disk Write Rate -
-
- {{ $this->formatBytes($diff['disk_write']['historical_rate']) }}/s - - {{ $this->formatBytes($diff['disk_write']['current_rate']) }}/s - - - {{ $diff['disk_write']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_write']['percent_change'], 1) }}% - -
-
- - Network Download Rate -
-
- {{ $this->formatBytes($diff['network_in']['historical_rate']) }}/s - - {{ $this->formatBytes($diff['network_in']['current_rate']) }}/s - - - {{ $diff['network_in']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_in']['percent_change'], 1) }}% - -
-
- - Network Upload Rate -
-
- {{ $this->formatBytes($diff['network_out']['historical_rate']) }}/s - - {{ $this->formatBytes($diff['network_out']['current_rate']) }}/s - - - {{ $diff['network_out']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_out']['percent_change'], 1) }}% - -
-
- @elseif($historicalComparison && isset($historicalComparison['error'])) -
-
-
-
- -
-
-

{{ $historicalComparison['message'] }}

- @if(isset($historicalComparison['total_stats'])) -

- Total statistics in database: {{ $historicalComparison['total_stats'] }} - @if($historicalComparison['total_stats'] == 0) - - Statistics are collected automatically every hour. You can also create manual statistics using the "Create Statistics" button above. - @elseif($historicalComparison['total_stats'] < 2) - - At least 2 statistics are needed for comparison. Wait for more data to be collected or create a manual statistic. - @endif -

- @endif -
-
-
-
- @else -
-
-
- -
-
-

No comparison data available

-

Please select periods with available statistics data.

-
-
-
- @endif -
- - - -
-
-
- -
-

- Network Analysis -
- - - - - -
-

-

- Deep visibility into firewall sessions, security threats, and top consumers of bandwidth powered by the Core Router API. -

-
-
-
- -
-
-
-
-
- - - - -
-
- - @if($networkAnalysisLoading || ($networkAnalysis['lastUpdated'] === null && $networkAnalysis['error'] === null)) -
- - - - - - -

- Fetching data from Core Router... - reload page -

-
- @elseif($networkAnalysis['error']) -
-
-

{{ $networkAnalysis['error'] }}

-

Ensure the Core Router API credentials are configured in your environment.

-
-
- @else -
-
- @if(($networkAnalysis['connectionCount'] ?? 0) == 0) -
No matching connections tracked for this server.
- @else -
-
-
-
- -
- -
-
-
- - - - - - - - - - - - - @foreach($this->paginatedConnections as $index => $connection) - @php - // Use raw bytes value from backend for reliable numeric sorting - $bytesForSort = (int)($connection['bytes'] ?? 0); - @endphp - - - - - - - - - @endforeach - -
-
- Direction - -
-
-
- Protocol - -
-
-
- Source - -
-
-
- Destination - -
-
-
- State - -
-
-
- Traffic - -
-
- @php - $direction = $connection['direction'] ?? '—'; - @endphp - @if($direction !== '—') -
- {{ $direction }} -
- - - - - -
-
- @else - — - @endif -
- @php - $protocol = $connection['protocol'] ?? '—'; - @endphp - @if($protocol !== '—') -
- {{ $protocol }} -
- - - - - -
-
- @else - — - @endif -
- @php - $sourceIP = $connection['source'] ?? '—'; - $isSourcePublic = $sourceIP !== '—' && $this->checkIsPublicIP($sourceIP); - @endphp -
- {{ $sourceIP }} - @if($isSourcePublic) -
- - - -
- @endif -
- @if(isset($connection['source_port'])) - {{ $connection['source_port'] }} - @endif -
- @php - $destIP = $connection['destination'] ?? '—'; - $isDestPublic = $destIP !== '—' && $this->checkIsPublicIP($destIP); - @endphp -
- {{ $destIP }} - @if($isDestPublic) -
- - - -
- @endif -
- @if(isset($connection['destination_port'])) - {{ $connection['destination_port'] }} - @endif -
- @php - $state = $connection['state'] ?? '—'; - @endphp - @if($state !== '—') -
- - {{ $state }} - -
- - - - - -
-
- @else - — - @endif -
-
- {{ $connection['bytes_human'] ?? '—' }} - @if(isset($connection['packets'])) - {{ number_format($connection['packets']) }} packets - @endif -
-
-
- - {{-- Connections Pagination Controls --}} - @if(($networkAnalysis['connectionCount'] ?? 0) > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} -
- - Showing {{ (($connectionsCurrentPage - 1) * $connectionsPerPage) + 1 }} - {{ min($connectionsCurrentPage * $connectionsPerPage, $networkAnalysis['connectionCount'] ?? 0) }} of {{ $networkAnalysis['connectionCount'] ?? 0 }} connections - - -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $connectionsTotalPages = $this->connectionsTotalPages; - $startPage = max(1, $connectionsCurrentPage - 2); - $endPage = min($connectionsTotalPages, $connectionsCurrentPage + 2); - @endphp - - @if($startPage > 1) - - @if($startPage > 2) - ... - @endif - @endif - - @for($i = $startPage; $i <= $endPage; $i++) - - @endfor - - @if($endPage < $connectionsTotalPages) - @if($endPage < $connectionsTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
-
-
- @endif -
- @endif -
- -
- @if(($networkAnalysis['topConnectionCount'] ?? 0) == 0) -
No connection data available.
- @else -
-
-
-
- -
- -
-
-
- - - - - - - - - - - @foreach($this->paginatedTopConnections as $index => $row) - @php - $endpointIP = $row['endpoint'] ?? '—'; - $port = $row['port'] ?? 0; - $connections = $row['count'] ?? 0; - $bytes = $row['bytes'] ?? 0; - $bytesForSort = $bytes; - @endphp - - - - - - - @endforeach - -
-
- Endpoint - -
-
-
- Port - -
-
-
- Connections - -
-
-
- Traffic - -
-
- @php - $isEndpointPublic = $endpointIP !== '—' && $this->checkIsPublicIP($endpointIP); - @endphp -
- {{ $endpointIP }} - @if($isEndpointPublic) -
- - - -
- @endif -
-
- @if(isset($row['port']) && $row['port'] !== '—') -
- {{ $row['port'] }} -
- - - - - -
-
- @else - — - @endif -
{{ number_format($row['count']) }} -
- {{ $row['bytes_human'] }} - {{ number_format($row['packets']) }} packets -
-
-
- - {{-- Top Connections Pagination Controls --}} - @if(($networkAnalysis['topConnectionCount'] ?? 0) > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} -
- - Showing {{ (($topConnectionsCurrentPage - 1) * $topConnectionsPerPage) + 1 }} - {{ min($topConnectionsCurrentPage * $topConnectionsPerPage, $networkAnalysis['topConnectionCount'] ?? 0) }} of {{ $networkAnalysis['topConnectionCount'] ?? 0 }} peers - - -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $topConnectionsTotalPages = $this->topConnectionsTotalPages; - $startPage = max(1, $topConnectionsCurrentPage - 2); - $endPage = min($topConnectionsTotalPages, $topConnectionsCurrentPage + 2); - @endphp - - @if($startPage > 1) - - @if($startPage > 2) - ... - @endif - @endif - - @for($i = $startPage; $i <= $endPage; $i++) - - @endfor - - @if($endPage < $topConnectionsTotalPages) - @if($endPage < $topConnectionsTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
-
-
- @endif -
- @endif -
- -
- @if(($networkAnalysis['topPortCount'] ?? 0) == 0) -
No port statistics are available.
- @else -
-
-
-
- -
- -
-
-
- - - - - - - - - - @foreach($this->paginatedTopPorts as $index => $row) - @php - $port = $row['port'] ?? 0; - $connections = $row['connections'] ?? 0; - $bytes = $row['bytes'] ?? 0; - $bytesForSort = $bytes; - @endphp - - - - - - @endforeach - -
-
- Port - -
-
-
- Connections - -
-
-
- Traffic - -
-
-
- {{ $row['port'] }} -
- - - - - -
-
-
{{ number_format($row['connections']) }} -
- {{ $row['bytes_human'] }} - {{ number_format($row['packets']) }} packets -
-
-
- - {{-- Top Ports Pagination Controls --}} - @if(($networkAnalysis['topPortCount'] ?? 0) > 0) -
- {{-- Records per page selector --}} -
- - - per page -
- - {{-- Page info and navigation --}} -
- - Showing {{ (($topPortsCurrentPage - 1) * $topPortsPerPage) + 1 }} - {{ min($topPortsCurrentPage * $topPortsPerPage, $networkAnalysis['topPortCount'] ?? 0) }} of {{ $networkAnalysis['topPortCount'] ?? 0 }} ports - - -
- {{-- First page --}} - - - {{-- Previous page --}} - - - {{-- Page numbers --}} - @php - $topPortsTotalPages = $this->topPortsTotalPages; - $startPage = max(1, $topPortsCurrentPage - 2); - $endPage = min($topPortsTotalPages, $topPortsCurrentPage + 2); - @endphp - - @if($startPage > 1) - - @if($startPage > 2) - ... - @endif - @endif - - @for($i = $startPage; $i <= $endPage; $i++) - - @endfor - - @if($endPage < $topPortsTotalPages) - @if($endPage < $topPortsTotalPages - 1) - ... - @endif - - @endif - - {{-- Next page --}} - - - {{-- Last page --}} - -
-
-
- @endif -
- @endif -
- -
- @if(empty($networkAnalysis['security'])) -
No security threats detected. All connections appear to be safe.
- @else -
- @foreach($networkAnalysis['security'] as $warning) - @php - $conn = $warning['connection'] ?? []; - @endphp - @php - $riskLevel = $warning['risk_level'] ?? 'Medium'; - $attackType = $warning['attack_type'] ?? 'Unknown Threat'; - $riskDescription = $warning['risk_description'] ?? 'Malicious IP detected from blocklist'; - $riskBadgeClass = match($riskLevel) { - 'Critical' => 'bg-danger/20 text-danger border-danger/30', - 'High' => 'bg-warning/20 text-warning border-warning/30', - 'Medium' => 'bg-warning/10 text-warning border-warning/20', - 'Low' => 'bg-base/10 text-base/70 border-base/20', - default => 'bg-warning/10 text-warning border-warning/20', - }; - @endphp -
-
- -
-
-
- Security Threat Detected - - {{ $warning['type'] }} - - - {{ $riskLevel }} Risk - - - {{ $attackType }} - - @if(isset($warning['quantity']) && $warning['quantity'] > 1) - - {{ $warning['quantity'] }}x Repeated - - @endif -
- -
-

- {{ $warning['threat'] }} - @if(isset($warning['quantity']) && $warning['quantity'] > 1) - ({{ $warning['quantity'] }} similar connection{{ $warning['quantity'] > 1 ? 's' : '' }} detected) - @endif -

- @php - $threatIP = $warning['ip'] ?? '—'; - $isThreatPublic = $threatIP !== '—' && $this->checkIsPublicIP($threatIP); - @endphp -
- IP Address: - {{ $threatIP }} - @if($isThreatPublic) -
- - - -
- @endif -
- -
-
-
- @endforeach -
- @endif -
-
- @endif -
-
- - -
- - - - -
- - -
- - - - -
- - -
- - - - -
- - @script - - @endscript - - - -
\ No newline at end of file + + @once + + @endonce +
+ + +
+
+ Overview + + Statistics +
+
+
+
+

Statistics

+
+ + + + + +
+
+

+ Monitor your server's performance metrics and resource utilization over time. +

+
+
+
+ + + +
+ +
+
+
+ Loading Telemetry... +
+
+ +
+
+
+

+ + Live Telemetry +
+ + + + + +
+

+
+
+ +
+ +
+
+
+
+
+

+ + Processor Utilization +
+ + + + + +
+

+
+
+ + + +
+
+
+
+ Loading chart... +
+
+
+
+
+
+ + +
+
+
+
+
+

+ + Network Traffic +
+ + + + + +
+

+
+
+ + + +
+
+
+
+ Loading chart... +
+
+
+
+
+
+ + +
+
+
+
+
+

+ + Memory Utilization +
+ + + + + +
+

+
+
+ + + +
+
+
+
+ Loading chart... +
+
+
+
+
+
+ + +
+
+
+
+
+

+ + Storage I/O +
+ + + + + +
+

+
+
+ + + +
+
+
+
+ Loading chart... +
+
+
+
+
+
+
+
+ + + @php + $statisticsExportRows = []; + @endphp +
+
+

+ + Statistics History +
+ + + + + +
+

+
+ + + +
+ New Report +
+
+ Creating... +
+
+ + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + @forelse($this->statistics as $stat) + @php + $networkStats = $this->getCurrentNetworkStats($stat); + $diskStats = $this->getCurrentDiskStats($stat); + $cpuPercent = $stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0; + $memPercent = $stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0; + $diskPercent = ($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0; + $statisticsExportRows[] = [ + 'date_time' => $stat->DateTime ? $stat->DateTime->format('M d, Y H:i:s') : '-', + 'status' => $stat->vmstatus ? ucfirst($stat->vmstatus) : '-', + 'cpu' => number_format($cpuPercent, 1) . '%', + 'memory' => number_format($memPercent, 1) . '%', + 'disk' => (($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0) ? number_format($diskPercent, 1) . '%' : 'N/A', + 'network_in' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']), + 'network_out' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']), + 'disk_io' => sprintf( + '%s / %s', + Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0), + Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0) + ), + 'uptime' => $stat->uptime > 0 ? $this->formatUptime($stat->uptime) : '-', + ]; + @endphp + + + + + + + + + + + + + @empty + + + + @endforelse + +
+
+ Date & Time + +
+
+
+ Status + +
+
+
+ CPU + +
+
+
+ Memory + +
+
+
+ Disk + +
+
+
+ Network In + +
+
+
+ Network Out + +
+
+
+ Disk I/O + +
+
+
+ Uptime + +
+
Actions
+
{{ $stat->DateTime?->format('M d, Y') ?? '-' }}
+
{{ $stat->DateTime?->format('H:i:s') ?? '' }}
+
+ @if($stat->vmstatus) + + {{ ucfirst($stat->vmstatus) }} + + @else + - + @endif + +
+
+
+
+ {{ number_format($cpuPercent, 1) }}% +
+
+
+
+
+
+ {{ number_format($memPercent, 1) }}% +
+
+ @if(($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0) +
+
+
+
+ {{ number_format($diskPercent, 1) }}% +
+ @else + N/A + @endif +
+
+ + {{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']) }} +
+ @if($networkStats['netin_rate'] > 0) +
{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netin_rate']) }}/s
+ @endif +
+
+ + {{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']) }} +
+ @if($networkStats['netout_rate'] > 0) +
{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netout_rate']) }}/s
+ @endif +
+
+
+ + {{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0) }} +
+
+ + {{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0) }} +
+
+
+ @if($stat->uptime > 0) + {{ $this->formatUptime($stat->uptime) }} + @else + - + @endif + + +
+
+
+ +
+
+

No statistics available

+

Statistics are collected every 5 minutes automatically.

+
+
+
+
+
+ + + @if($this->statistics->total() > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} +
+ + Showing {{ (($this->statistics->currentPage() - 1) * $this->statistics->perPage()) + 1 }} - {{ min($this->statistics->currentPage() * $this->statistics->perPage(), $this->statistics->total()) }} of {{ $this->statistics->total() }} entries + + + @if($this->statistics->hasPages()) +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $statsTotalPages = $this->statistics->lastPage(); + $statsCurrentPage = $this->statistics->currentPage(); + $statsStartPage = max(1, $statsCurrentPage - 2); + $statsEndPage = min($statsTotalPages, $statsCurrentPage + 2); + @endphp + + @if($statsStartPage > 1) + + @if($statsStartPage > 2) + ... + @endif + @endif + + @for($i = $statsStartPage; $i <= $statsEndPage; $i++) + + @endfor + + @if($statsEndPage < $statsTotalPages) + @if($statsEndPage < $statsTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+ @endif +
+
+ @endif +
+ + + @php + $taskHistoryExportRows = []; + @endphp +
+
+

+ + Task History +
+ + + + + +
+

+
+ +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + @forelse($this->paginatedTaskHistory as $task) + @php + $startTime = isset($task['starttime']) ? \Carbon\Carbon::createFromTimestamp($task['starttime']) : null; + $endTime = isset($task['endtime']) && $task['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($task['endtime']) : null; + $duration = null; + if ($startTime && $endTime) { + $duration = $startTime->diffInSeconds($endTime); + } elseif ($startTime && isset($task['status']) && $task['status'] === 'running') { + $duration = $startTime->diffInSeconds(now()); + } + $taskUpid = $task['upid'] ?? $task['id'] ?? ''; + $taskKey = md5($taskUpid . ($task['starttime'] ?? '')); + $isExpanded = in_array($taskUpid, $this->expandedTaskUpids ?? []); + $taskHistoryExportRows[] = [ + 'type' => $task['type'] ?? 'N/A', + 'status' => $task['status'] ?? '-', + 'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-', + 'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-', + 'duration' => $duration !== null ? $this->formatUptime($duration) : '-', + ]; + @endphp + + + + + + + + + + + + + + + @empty + + + + + + @endforelse +
+
+ Type + +
+
+
+ Status + +
+
+
+ Start Time + +
+
+
+ End Time + +
+
+
+ Duration + +
+
+ + + {{ $task['type'] ?? 'N/A' }} + + @if(isset($task['status'])) + @if($task['status'] === 'OK') + + Success + + @else + + Failure + + @endif + @else + - + @endif + + @if($startTime) +
{{ $startTime->format('M d, Y') }}
+
{{ $startTime->format('H:i:s') }}
+ @else + - + @endif +
+ @if($endTime) +
{{ $endTime->format('M d, Y') }}
+
{{ $endTime->format('H:i:s') }}
+ @else + - + @endif +
+ @if($duration !== null) + {{ $this->formatUptime($duration) }} + @else + - + @endif +
+
+

+ + Task Information +

+ + +
+
+ + + + + Loading task details... +
+
+ + +
+ + +
+ +
+ + + + + +
+ + +
+

No detailed information available for this task.

+
+
+
+
+
+ +
+
+

No task history available

+

Task history will appear here as tasks are executed on the server.

+
+
+
+
+
+ + + @if(count($this->taskHistory) > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} + @php + $taskTotalPages = $this->taskHistoryTotalPages; + $taskCurrentPage = $taskHistoryCurrentPage; + $taskTotalCount = count($this->taskHistory); + @endphp +
+ + Showing {{ (($taskCurrentPage - 1) * $taskHistoryPerPage) + 1 }} - {{ min($taskCurrentPage * $taskHistoryPerPage, $taskTotalCount) }} of {{ $taskTotalCount }} tasks + + + @if($taskTotalPages > 1) +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $taskStartPage = max(1, $taskCurrentPage - 2); + $taskEndPage = min($taskTotalPages, $taskCurrentPage + 2); + @endphp + + @if($taskStartPage > 1) + + @if($taskStartPage > 2) + ... + @endif + @endif + + @for($i = $taskStartPage; $i <= $taskEndPage; $i++) + + @endfor + + @if($taskEndPage < $taskTotalPages) + @if($taskEndPage < $taskTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+ @endif +
+
+ @endif +
+ + + @php + $lifecycleEventsExportRows = []; + @endphp +
+
+

+ + Recent Lifecycle Events +
+ + + + + +
+

+
+ +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + @forelse($this->paginatedLifecycleEvents as $index => $event) + @php + $startTime = isset($event['starttime']) ? \Carbon\Carbon::createFromTimestamp($event['starttime']) : null; + $endTime = isset($event['endtime']) && $event['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($event['endtime']) : null; + $duration = $event['duration'] ?? null; + $eventUpid = $event['upid'] ?? $event['timestamp'] ?? ''; + $eventKey = md5($eventUpid . ($event['starttime'] ?? '')); + $isExpanded = in_array($eventUpid, $this->expandedTaskUpids ?? []); + + $eventColorMap = [ + 'start' => 'text-success', + 'shutdown' => 'text-danger', + 'reboot' => 'text-info', + 'reset' => 'text-warning', + 'backup' => 'text-purple-200', + ]; + $eventColor = $eventColorMap[$event['type']] ?? 'text-base/70'; + + $lifecycleEventsExportRows[] = [ + 'type' => $event['label'] ?? 'N/A', + 'status' => ucfirst($event['status'] ?? '-'), + 'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-', + 'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-', + 'duration' => $duration !== null ? $this->formatUptime($duration) : '-', + ]; + @endphp + + + + + + + + + + + + + + + @empty + + + + + + @endforelse +
+
+ Type + +
+
+
+ Status + +
+
+
+ Start Time + +
+
+
+ End Time + +
+
+
+ Duration + +
+
+ + + {{ $event['label'] ?? 'N/A' }} + + @if(isset($event['status'])) + @if(strtolower($event['status']) === 'ok') + + Success + + @elseif(strtolower($event['status']) === 'running') + + Running + + @else + + Failure + + @endif + @else + - + @endif + + @if($startTime) +
{{ $startTime->format('M d, Y') }}
+
{{ $startTime->format('H:i:s') }}
+ @else + - + @endif +
+ @if($endTime) +
{{ $endTime->format('M d, Y') }}
+
{{ $endTime->format('H:i:s') }}
+ @else + - + @endif +
+ @if($duration !== null) + {{ $this->formatUptime($duration) }} + @else + - + @endif +
+
+

+ + Event Information +

+ +
+
+ Type + {{ $event['original_type'] ?? 'N/A' }} +
+
+ Status + {{ ucfirst($event['status'] ?? 'Unknown') }} +
+
+ Event Type + {{ $event['label'] ?? 'N/A' }} +
+
+ +
+ @if(isset($event['upid'])) +
+ Task ID + {{ str_replace([':root@pam!paymenter:', ':root@pam:'], '', $event['upid']) }} +
+ @endif +
+
+
+
+
+ +
+
+

No lifecycle events available

+

Lifecycle events will appear here as operations are performed on the server.

+
+
+
+
+
+ + + @php + $lifecycleTotalCount = count($this->chartEvents); + @endphp + @if($lifecycleTotalCount > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} + @php + $lifecycleTotalPages = $this->lifecycleEventsTotalPages; + $lifecycleCurrentPage = $lifecycleEventsCurrentPage; + @endphp +
+ + Showing {{ (($lifecycleCurrentPage - 1) * $lifecycleEventsPerPage) + 1 }} - {{ min($lifecycleCurrentPage * $lifecycleEventsPerPage, $lifecycleTotalCount) }} of {{ $lifecycleTotalCount }} events + + + @if($lifecycleTotalPages > 1) +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $lifecycleStartPage = max(1, $lifecycleCurrentPage - 2); + $lifecycleEndPage = min($lifecycleTotalPages, $lifecycleCurrentPage + 2); + @endphp + + @if($lifecycleStartPage > 1) + + @if($lifecycleStartPage > 2) + ... + @endif + @endif + + @for($i = $lifecycleStartPage; $i <= $lifecycleEndPage; $i++) + + @endfor + + @if($lifecycleEndPage < $lifecycleTotalPages) + @if($lifecycleEndPage < $lifecycleTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+ @endif +
+
+ @endif +
+
+ + + @php + $historicalComparison = $this->historicalComparison; + @endphp +
+
+

+ + Historical Data Comparison +
+ + + + + +
+

+
+ +
+
+ +
+ +
+
+ + + @if($historicalPeriod === 'custom') + + @endif +
+ +
+ + + @if($currentPeriod === 'custom') + + @endif +
+
+
+ + @if($historicalComparison && !isset($historicalComparison['error'])) + + @php + $current = $historicalComparison['current']; + $historical = $historicalComparison['historical']; + $diff = $historicalComparison['differences']; + @endphp + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Metric +
+ + Historical + ({{ $historical->DateTime->format('M d, Y H:i') }}) +
+
+
+ + Current + ({{ $current->DateTime->format('M d, Y H:i') }}) +
+
Change
+
+ + Processor Utilization +
+
+ {{ number_format($diff['cpu']['historical_percent'], 1) }}% + + {{ number_format($diff['cpu']['current_percent'], 1) }}% + + + {{ $diff['cpu']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['cpu']['percent_change'], 1) }}% + +
+
+ + Memory Utilization +
+
+ {{ number_format($diff['memory']['historical_percent'], 1) }}% + + {{ number_format($diff['memory']['current_percent'], 1) }}% + + + {{ $diff['memory']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['memory']['percent_change'], 1) }}% + +
+
+ + Disk Read Rate +
+
+ {{ $this->formatBytes($diff['disk_read']['historical_rate']) }}/s + + {{ $this->formatBytes($diff['disk_read']['current_rate']) }}/s + + + {{ $diff['disk_read']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_read']['percent_change'], 1) }}% + +
+
+ + Disk Write Rate +
+
+ {{ $this->formatBytes($diff['disk_write']['historical_rate']) }}/s + + {{ $this->formatBytes($diff['disk_write']['current_rate']) }}/s + + + {{ $diff['disk_write']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_write']['percent_change'], 1) }}% + +
+
+ + Network Download Rate +
+
+ {{ $this->formatBytes($diff['network_in']['historical_rate']) }}/s + + {{ $this->formatBytes($diff['network_in']['current_rate']) }}/s + + + {{ $diff['network_in']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_in']['percent_change'], 1) }}% + +
+
+ + Network Upload Rate +
+
+ {{ $this->formatBytes($diff['network_out']['historical_rate']) }}/s + + {{ $this->formatBytes($diff['network_out']['current_rate']) }}/s + + + {{ $diff['network_out']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_out']['percent_change'], 1) }}% + +
+
+ @elseif($historicalComparison && isset($historicalComparison['error'])) +
+
+
+
+ +
+
+

{{ $historicalComparison['message'] }}

+ @if(isset($historicalComparison['total_stats'])) +

+ Total statistics in database: {{ $historicalComparison['total_stats'] }} + @if($historicalComparison['total_stats'] == 0) + - Statistics are collected automatically every hour. You can also create manual statistics using the "Create Statistics" button above. + @elseif($historicalComparison['total_stats'] < 2) + - At least 2 statistics are needed for comparison. Wait for more data to be collected or create a manual statistic. + @endif +

+ @endif +
+
+
+
+ @else +
+
+
+ +
+
+

No comparison data available

+

Please select periods with available statistics data.

+
+
+
+ @endif +
+ + + +
+
+
+ +
+

+ Network Analysis +
+ + + + + +
+

+

+ Deep visibility into firewall sessions, security threats, and top consumers of bandwidth powered by the Core Router API. +

+
+
+
+ +
+
+
+
+
+ + + + +
+
+ + @if($networkAnalysisLoading || ($networkAnalysis['lastUpdated'] === null && $networkAnalysis['error'] === null)) +
+ + + + + + +

+ Fetching data from Core Router... + reload page +

+
+ @elseif($networkAnalysis['error']) +
+
+

{{ $networkAnalysis['error'] }}

+

Ensure the Core Router API credentials are configured in your environment.

+
+
+ @else +
+
+ @if(($networkAnalysis['connectionCount'] ?? 0) == 0) +
No matching connections tracked for this server.
+ @else +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + @foreach($this->paginatedConnections as $index => $connection) + @php + // Use raw bytes value from backend for reliable numeric sorting + $bytesForSort = (int)($connection['bytes'] ?? 0); + @endphp + + + + + + + + + @endforeach + +
+
+ Direction + +
+
+
+ Protocol + +
+
+
+ Source + +
+
+
+ Destination + +
+
+
+ State + +
+
+
+ Traffic + +
+
+ @php + $direction = $connection['direction'] ?? '—'; + @endphp + @if($direction !== '—') +
+ {{ $direction }} +
+ + + + + +
+
+ @else + — + @endif +
+ @php + $protocol = $connection['protocol'] ?? '—'; + @endphp + @if($protocol !== '—') +
+ {{ $protocol }} +
+ + + + + +
+
+ @else + — + @endif +
+ @php + $sourceIP = $connection['source'] ?? '—'; + $isSourcePublic = $sourceIP !== '—' && $this->checkIsPublicIP($sourceIP); + @endphp +
+ {{ $sourceIP }} + @if($isSourcePublic) +
+ + + +
+ @endif +
+ @if(isset($connection['source_port'])) + {{ $connection['source_port'] }} + @endif +
+ @php + $destIP = $connection['destination'] ?? '—'; + $isDestPublic = $destIP !== '—' && $this->checkIsPublicIP($destIP); + @endphp +
+ {{ $destIP }} + @if($isDestPublic) +
+ + + +
+ @endif +
+ @if(isset($connection['destination_port'])) + {{ $connection['destination_port'] }} + @endif +
+ @php + $state = $connection['state'] ?? '—'; + @endphp + @if($state !== '—') +
+ + {{ $state }} + +
+ + + + + +
+
+ @else + — + @endif +
+
+ {{ $connection['bytes_human'] ?? '—' }} + @if(isset($connection['packets'])) + {{ number_format($connection['packets']) }} packets + @endif +
+
+
+ + {{-- Connections Pagination Controls --}} + @if(($networkAnalysis['connectionCount'] ?? 0) > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} +
+ + Showing {{ (($connectionsCurrentPage - 1) * $connectionsPerPage) + 1 }} - {{ min($connectionsCurrentPage * $connectionsPerPage, $networkAnalysis['connectionCount'] ?? 0) }} of {{ $networkAnalysis['connectionCount'] ?? 0 }} connections + + +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $connectionsTotalPages = $this->connectionsTotalPages; + $startPage = max(1, $connectionsCurrentPage - 2); + $endPage = min($connectionsTotalPages, $connectionsCurrentPage + 2); + @endphp + + @if($startPage > 1) + + @if($startPage > 2) + ... + @endif + @endif + + @for($i = $startPage; $i <= $endPage; $i++) + + @endfor + + @if($endPage < $connectionsTotalPages) + @if($endPage < $connectionsTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+
+
+ @endif +
+ @endif +
+ +
+ @if(($networkAnalysis['topConnectionCount'] ?? 0) == 0) +
No connection data available.
+ @else +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + @foreach($this->paginatedTopConnections as $index => $row) + @php + $endpointIP = $row['endpoint'] ?? '—'; + $port = $row['port'] ?? 0; + $connections = $row['count'] ?? 0; + $bytes = $row['bytes'] ?? 0; + $bytesForSort = $bytes; + @endphp + + + + + + + @endforeach + +
+
+ Endpoint + +
+
+
+ Port + +
+
+
+ Connections + +
+
+
+ Traffic + +
+
+ @php + $isEndpointPublic = $endpointIP !== '—' && $this->checkIsPublicIP($endpointIP); + @endphp +
+ {{ $endpointIP }} + @if($isEndpointPublic) +
+ + + +
+ @endif +
+
+ @if(isset($row['port']) && $row['port'] !== '—') +
+ {{ $row['port'] }} +
+ + + + + +
+
+ @else + — + @endif +
{{ number_format($row['count']) }} +
+ {{ $row['bytes_human'] }} + {{ number_format($row['packets']) }} packets +
+
+
+ + {{-- Top Connections Pagination Controls --}} + @if(($networkAnalysis['topConnectionCount'] ?? 0) > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} +
+ + Showing {{ (($topConnectionsCurrentPage - 1) * $topConnectionsPerPage) + 1 }} - {{ min($topConnectionsCurrentPage * $topConnectionsPerPage, $networkAnalysis['topConnectionCount'] ?? 0) }} of {{ $networkAnalysis['topConnectionCount'] ?? 0 }} peers + + +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $topConnectionsTotalPages = $this->topConnectionsTotalPages; + $startPage = max(1, $topConnectionsCurrentPage - 2); + $endPage = min($topConnectionsTotalPages, $topConnectionsCurrentPage + 2); + @endphp + + @if($startPage > 1) + + @if($startPage > 2) + ... + @endif + @endif + + @for($i = $startPage; $i <= $endPage; $i++) + + @endfor + + @if($endPage < $topConnectionsTotalPages) + @if($endPage < $topConnectionsTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+
+
+ @endif +
+ @endif +
+ +
+ @if(($networkAnalysis['topPortCount'] ?? 0) == 0) +
No port statistics are available.
+ @else +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + @foreach($this->paginatedTopPorts as $index => $row) + @php + $port = $row['port'] ?? 0; + $connections = $row['connections'] ?? 0; + $bytes = $row['bytes'] ?? 0; + $bytesForSort = $bytes; + @endphp + + + + + + @endforeach + +
+
+ Port + +
+
+
+ Connections + +
+
+
+ Traffic + +
+
+
+ {{ $row['port'] }} +
+ + + + + +
+
+
{{ number_format($row['connections']) }} +
+ {{ $row['bytes_human'] }} + {{ number_format($row['packets']) }} packets +
+
+
+ + {{-- Top Ports Pagination Controls --}} + @if(($networkAnalysis['topPortCount'] ?? 0) > 0) +
+ {{-- Records per page selector --}} +
+ + + per page +
+ + {{-- Page info and navigation --}} +
+ + Showing {{ (($topPortsCurrentPage - 1) * $topPortsPerPage) + 1 }} - {{ min($topPortsCurrentPage * $topPortsPerPage, $networkAnalysis['topPortCount'] ?? 0) }} of {{ $networkAnalysis['topPortCount'] ?? 0 }} ports + + +
+ {{-- First page --}} + + + {{-- Previous page --}} + + + {{-- Page numbers --}} + @php + $topPortsTotalPages = $this->topPortsTotalPages; + $startPage = max(1, $topPortsCurrentPage - 2); + $endPage = min($topPortsTotalPages, $topPortsCurrentPage + 2); + @endphp + + @if($startPage > 1) + + @if($startPage > 2) + ... + @endif + @endif + + @for($i = $startPage; $i <= $endPage; $i++) + + @endfor + + @if($endPage < $topPortsTotalPages) + @if($endPage < $topPortsTotalPages - 1) + ... + @endif + + @endif + + {{-- Next page --}} + + + {{-- Last page --}} + +
+
+
+ @endif +
+ @endif +
+ +
+ @if(empty($networkAnalysis['security'])) +
No security threats detected. All connections appear to be safe.
+ @else +
+ @foreach($networkAnalysis['security'] as $warning) + @php + $conn = $warning['connection'] ?? []; + @endphp + @php + $riskLevel = $warning['risk_level'] ?? 'Medium'; + $attackType = $warning['attack_type'] ?? 'Unknown Threat'; + $riskDescription = $warning['risk_description'] ?? 'Malicious IP detected from blocklist'; + $riskBadgeClass = match($riskLevel) { + 'Critical' => 'bg-danger/20 text-danger border-danger/30', + 'High' => 'bg-warning/20 text-warning border-warning/30', + 'Medium' => 'bg-warning/10 text-warning border-warning/20', + 'Low' => 'bg-base/10 text-base/70 border-base/20', + default => 'bg-warning/10 text-warning border-warning/20', + }; + @endphp +
+
+ +
+
+
+ Security Threat Detected + + {{ $warning['type'] }} + + + {{ $riskLevel }} Risk + + + {{ $attackType }} + + @if(isset($warning['quantity']) && $warning['quantity'] > 1) + + {{ $warning['quantity'] }}x Repeated + + @endif +
+ +
+

+ {{ $warning['threat'] }} + @if(isset($warning['quantity']) && $warning['quantity'] > 1) + ({{ $warning['quantity'] }} similar connection{{ $warning['quantity'] > 1 ? 's' : '' }} detected) + @endif +

+ @php + $threatIP = $warning['ip'] ?? '—'; + $isThreatPublic = $threatIP !== '—' && $this->checkIsPublicIP($threatIP); + @endphp +
+ IP Address: + {{ $threatIP }} + @if($isThreatPublic) +
+ + + +
+ @endif +
+ +
+
+
+ @endforeach +
+ @endif +
+
+ @endif +
+
+ + +
+ + + + +
+ + +
+ + + + +
+ + +
+ + + + +
+ + @script + + @endscript + + + +
diff --git a/gitapplyfast.sh b/gitapplyfast.sh old mode 100644 new mode 100755 index 8bc0f17ee..1ab28561b --- a/gitapplyfast.sh +++ b/gitapplyfast.sh @@ -32,36 +32,36 @@ PULSE=('○' '◎' '●' '◉' '●' '◎') # Node list NODES=( "10.0.254.100" - "10.0.254.101" - "10.0.254.102" +# "10.0.254.101" +# "10.0.254.102" # "10.0.254.103" - "10.0.254.104" - "10.0.254.105" - "10.0.254.106" - "10.0.254.107" - "10.0.254.108" - "10.0.254.109" - "10.0.254.110" - "10.0.254.111" - "10.0.254.112" - "10.0.254.113" - "10.0.254.114" - "10.0.254.115" - "10.0.254.116" - "10.0.254.117" - "10.0.254.118" - "10.0.254.119" - "10.0.254.120" - "10.0.254.121" - "10.0.254.122" +# "10.0.254.104" +# "10.0.254.105" +# "10.0.254.106" +# "10.0.254.107" +# "10.0.254.108" +# "10.0.254.109" +# "10.0.254.110" +# "10.0.254.111" +# "10.0.254.112" +# "10.0.254.113" +# "10.0.254.114" +# "10.0.254.115" +# "10.0.254.116" +# "10.0.254.117" +# "10.0.254.118" +# "10.0.254.119" +# "10.0.254.120" +# "10.0.254.121" +# "10.0.254.122" # "10.0.254.123" - "10.0.254.124" +# "10.0.254.124" # "10.0.254.125" - "10.0.254.126" - "10.0.254.127" - "10.0.254.128" - "10.0.254.129" - "10.0.254.130" +# "10.0.254.126" +# "10.0.254.127" +# "10.0.254.128" +# "10.0.254.129" +# "10.0.254.130" # "10.0.254.131" ) diff --git a/package-lock.json b/package-lock.json index dca127e98..943bfc059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -479,9 +479,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", - "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", "cpu": [ "arm" ], @@ -492,9 +492,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", - "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", "cpu": [ "arm64" ], @@ -505,9 +505,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", - "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", "cpu": [ "arm64" ], @@ -518,9 +518,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", - "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", "cpu": [ "x64" ], @@ -531,9 +531,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", - "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", "cpu": [ "arm64" ], @@ -544,9 +544,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", - "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", "cpu": [ "x64" ], @@ -557,9 +557,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", - "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", "cpu": [ "arm" ], @@ -570,9 +570,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", - "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", "cpu": [ "arm" ], @@ -583,9 +583,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", - "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", "cpu": [ "arm64" ], @@ -596,9 +596,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", - "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", "cpu": [ "arm64" ], @@ -609,9 +609,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", - "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", "cpu": [ "loong64" ], @@ -622,9 +622,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", - "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", "cpu": [ "loong64" ], @@ -635,9 +635,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", - "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", "cpu": [ "ppc64" ], @@ -648,9 +648,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", - "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", "cpu": [ "ppc64" ], @@ -661,9 +661,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", - "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", "cpu": [ "riscv64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", - "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", "cpu": [ "riscv64" ], @@ -687,9 +687,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", - "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", "cpu": [ "s390x" ], @@ -700,9 +700,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", - "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", "cpu": [ "x64" ], @@ -713,9 +713,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", - "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", "cpu": [ "x64" ], @@ -726,9 +726,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", - "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", "cpu": [ "x64" ], @@ -739,9 +739,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", - "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", "cpu": [ "arm64" ], @@ -752,9 +752,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", - "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", "cpu": [ "arm64" ], @@ -765,9 +765,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", - "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", "cpu": [ "ia32" ], @@ -778,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", - "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", "cpu": [ "x64" ], @@ -791,9 +791,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", - "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", "cpu": [ "x64" ], @@ -816,47 +816,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", + "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", "dev": true, "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.0.tgz", + "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", "dev": true, "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-x64": "4.2.0", + "@tailwindcss/oxide-freebsd-x64": "4.2.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-x64-musl": "4.2.0", + "@tailwindcss/oxide-wasm32-wasi": "4.2.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", + "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", "cpu": [ "arm64" ], @@ -866,13 +866,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", + "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", "cpu": [ "arm64" ], @@ -882,13 +882,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", + "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", "cpu": [ "x64" ], @@ -898,13 +898,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", + "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", "cpu": [ "x64" ], @@ -914,13 +914,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", + "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", "cpu": [ "arm" ], @@ -930,13 +930,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", + "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", "cpu": [ "arm64" ], @@ -946,13 +946,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", + "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", "cpu": [ "arm64" ], @@ -962,13 +962,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", + "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", "cpu": [ "x64" ], @@ -978,13 +978,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", + "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", "cpu": [ "x64" ], @@ -994,13 +994,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", + "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1015,21 +1015,21 @@ "dev": true, "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", + "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", "cpu": [ "arm64" ], @@ -1039,13 +1039,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", + "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", "cpu": [ "x64" ], @@ -1055,7 +1055,7 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/typography": { @@ -1071,14 +1071,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.0.tgz", + "integrity": "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==", "dev": true, "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.0", + "@tailwindcss/oxide": "4.2.0", + "tailwindcss": "4.2.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -1226,13 +1226,13 @@ "dev": true }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -1381,9 +1381,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "dev": true, "dependencies": { "detect-libc": "^2.0.3" @@ -1396,23 +1396,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], @@ -1430,9 +1430,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], @@ -1450,9 +1450,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], @@ -1470,9 +1470,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], @@ -1490,9 +1490,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], @@ -1510,9 +1510,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], @@ -1530,9 +1530,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], @@ -1550,9 +1550,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], @@ -1570,9 +1570,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], @@ -1590,9 +1590,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], @@ -1610,9 +1610,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], @@ -1725,9 +1725,9 @@ } }, "node_modules/remixicon": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.8.0.tgz", - "integrity": "sha512-8qTM/bWkmsAWitvcL9XrVPVdqHRrdmnNp4zCFBdmIHBygxfHWwoK6NzitbiMyRzjcXKBMlS/ab5M65/ZlxJTVA==" + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.9.1.tgz", + "integrity": "sha512-36gLSoujkabnCFZFDyP17VNh9piuBA/rsXUb4auSJWLGsHVXtmxLj/EM5FjaEAGnk8oIAj1Azob/DZ2N+90lAQ==" }, "node_modules/require-directory": { "version": "2.1.1", @@ -1739,9 +1739,9 @@ } }, "node_modules/rollup": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", - "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", "dev": true, "dependencies": { "@types/estree": "1.0.8" @@ -1754,31 +1754,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.56.0", - "@rollup/rollup-android-arm64": "4.56.0", - "@rollup/rollup-darwin-arm64": "4.56.0", - "@rollup/rollup-darwin-x64": "4.56.0", - "@rollup/rollup-freebsd-arm64": "4.56.0", - "@rollup/rollup-freebsd-x64": "4.56.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", - "@rollup/rollup-linux-arm-musleabihf": "4.56.0", - "@rollup/rollup-linux-arm64-gnu": "4.56.0", - "@rollup/rollup-linux-arm64-musl": "4.56.0", - "@rollup/rollup-linux-loong64-gnu": "4.56.0", - "@rollup/rollup-linux-loong64-musl": "4.56.0", - "@rollup/rollup-linux-ppc64-gnu": "4.56.0", - "@rollup/rollup-linux-ppc64-musl": "4.56.0", - "@rollup/rollup-linux-riscv64-gnu": "4.56.0", - "@rollup/rollup-linux-riscv64-musl": "4.56.0", - "@rollup/rollup-linux-s390x-gnu": "4.56.0", - "@rollup/rollup-linux-x64-gnu": "4.56.0", - "@rollup/rollup-linux-x64-musl": "4.56.0", - "@rollup/rollup-openbsd-x64": "4.56.0", - "@rollup/rollup-openharmony-arm64": "4.56.0", - "@rollup/rollup-win32-arm64-msvc": "4.56.0", - "@rollup/rollup-win32-ia32-msvc": "4.56.0", - "@rollup/rollup-win32-x64-gnu": "4.56.0", - "@rollup/rollup-win32-x64-msvc": "4.56.0", + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", "fsevents": "~2.3.2" } }, @@ -1854,9 +1854,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", "dev": true }, "node_modules/tapable": { diff --git a/themes/lucentui/views/home.blade.php b/themes/lucentui/views/home.blade.php index 9c30a4b9f..96bc1e77f 100644 --- a/themes/lucentui/views/home.blade.php +++ b/themes/lucentui/views/home.blade.php @@ -1,870 +1,871 @@ -
- - - @if(theme('custom_homepage_html')) -
- {!! theme('custom_homepage_html') !!} -
- @endif - - {{-- ═══════════════════════════════════════════════════════════════ - SECTION 1 — HERO (full viewport, always visible) - ═══════════════════════════════════════════════════════════════ --}} -
- -
-
-
-
-
- -
-
- -
- -
- @foreach([ - ['homepage_badge_1', 'VPS Hosting'], - ['homepage_badge_2', 'Website Hosting'], - ['homepage_badge_3', 'BGP Transit'], - ['homepage_badge_4', 'Contracted Support'], - ] as $i => $badge) -
- - - - - {{ theme($badge[0], $badge[1]) }} -
- @endforeach -
- - -

- {{ theme('homepage_hero_title1', 'Powerful') }} - - {{ theme('homepage_hero_title2', 'VPS Hosting') }} - -

- - -

- {{ theme('homepage_hero_desc', 'Experience enterprise-grade virtual private servers with our powerful control panel. Real-time monitoring, advanced firewall management, automated backups, and full console access.') }} -

- - - -
- - -
-
- -
-
-
-
-
- control-panel -
-
-
-
-
- -
-
-
VPS-001
-
Running • 4 vCPU • 8GB RAM
-
-
- Online -
-
-
-
CPU Usage
-
- 23% -
- @for($i = 0; $i < 12; $i++) - - @endfor -
-
-
-
-
Memory
-
- 4.2GB - / 8GB -
-
-
-
-
-
-
-
- -
- -
-
- -
-
-
-
-
- - -
- Scroll -
-
-
-
-
- - {{-- ═══════════════════════════════════════════════════════════════ - SECTION 2 — SERVICES - ═══════════════════════════════════════════════════════════════ --}} -
-
-
-

What We Offer

-

- {{ theme('services_title', 'Our Services') }} -

-

- {{ theme('services_subtitle', 'Choose the perfect option for your needs — all with full control panel access for all services we offer to give you total control') }} -

-
- -
- @foreach ($categories as $index => $category) -
-
-
- -
-

- {{ $category->name }} -

- @if(theme('show_category_description', true)) -
- {!! strip_tags($category->description) !!} -
- @endif - - {{ __('general.view') }} - - -
-
- @endforeach -
-
-
- - {{-- ═══════════════════════════════════════════════════════════════ - SECTION 3 — CONTROL PANEL FEATURES (Bento) - ═══════════════════════════════════════════════════════════════ --}} -
- -
- -
- -
-

Control Panel

-

- {{ theme('panel_title', 'Complete Server Management') }} -

-

- {{ theme('panel_subtitle', 'Take full control of your VPS with our comprehensive management dashboard. Every tool you need, right at your fingertips.') }} -

-
- - -
- -
-
-
-
-
-
- -
-
- CPU - RAM - Network -
-
-

Live Telemetry

-

Monitor CPU, memory, network, and disk I/O with interactive charts. Track performance over time.

-
- @for($i = 0; $i < 18; $i++) - - @endfor -
-
-
-
- - -
-
-
-
-
- -
-

VNC Console

-

Direct web-based access to your VPS. Perfect for OS installation and recovery mode.

-
- root@vps:~$ █ -
-
-
-
-
- - - - - -
- -
-
-
-
-
-
- -
-
- Inbound - Outbound -
-
-

Firewall Manager

-

Create custom rules, manage IP aliases, and monitor real-time traffic logs to protect your server.

-
-
-
- Allow SSH (22/tcp) - Active -
-
-
- Allow HTTP/S (80,443) - Active -
-
-
- Block 192.168.1.0/24 - Denied -
-
-
-
-
- - -
-
-
-
-
-
- -
-
- Auto-enabled -
-
-

Backup & Snapshots

-

Automated daily snapshots with instant one-click restore. Keep your data safe with point-in-time recovery.

-
-
-
- - Daily Backup -
- 2h ago -
-
-
- - Weekly Snapshot -
- 3 days ago -
-
-
- - Next backup -
- in 22h -
-
-
-
-
-
- - -
- -
-
-
-
-
-
- -
-
-
65 GB
-
of 100 GB used
-
-
-

Storage Manager

-

Add, resize, or remove virtual disks on the fly. Expand storage without downtime.

-
-
-
- System (sda1) - 45 GB -
-
-
-
-
-
-
- Data (sdb1) - 20 GB -
-
-
-
-
-
- Available - 35 GB free -
-
-
-
-
- - -
-
-
-
-
-
- -
-
-
- Running -
-
-

Power Controls

-

Full control over your server's power state. Start, stop, reboot, or force power cycle instantly.

-
-
- - Start -
-
- - Reboot -
-
- - Stop -
-
-
-
- - Uptime -
- 14d 6h 32m -
-
-
-
-
- - -
- @php - $quickFeatures = [ - ['icon' => 'ri-refresh-line', 'title' => 'OS Reinstall', 'desc' => '50+ templates', 'color' => 'primary'], - ['icon' => 'ri-history-line', 'title' => 'Task History', 'desc' => 'Full audit logs', 'color' => 'secondary'], - ['icon' => 'ri-global-line', 'title' => 'Network', 'desc' => 'IPv4/IPv6 & rDNS', 'color' => 'success'], - ['icon' => 'ri-disc-line', 'title' => 'ISO Mount', 'desc' => 'Custom images', 'color' => 'info'], - ]; - @endphp - @foreach($quickFeatures as $fi => $feat) -
-
-
- -
-
-

{{ $feat['title'] }}

-

{{ $feat['desc'] }}

-
-
-
- @endforeach -
-
-
- - {{-- ═══════════════════════════════════════════════════════════════ - SECTION 4 — INFRASTRUCTURE FEATURES - ═══════════════════════════════════════════════════════════════ --}} -
-
- -
-

Infrastructure

-

- {{ theme('features_title', 'Enterprise Infrastructure') }} -

-

- {{ theme('features_subtitle', 'We\'ve built a platform that is advanced enough to compete with the large players of the industry at a fraction of the cost.') }} -

-
- - @php - $infra = [ - ['icon' => 'ri-hard-drive-3-line', 'color' => 'primary', 'title' => 'NVMe & RAM Cached Storage', 'desc' => 'Each VPS caches RAM for performance beyond SSDs, while dedicated NVMe drives provide consistent IOPS for storage.'], - ['icon' => 'ri-global-line', 'color' => 'secondary', 'title' => 'IPv6 Public Address', 'desc' => 'Each VPS receives a static IPv6 address for public services, plus a secondary RA-based MAC address to ensure connectivity.'], - ['icon' => 'ri-terminal-box-line', 'color' => 'success', 'title' => 'Xterm.js/NoVNC Access', 'desc' => 'Each VPS comes with Xterm.js and NoVNC via our server manager for easy access and control, anytime.'], - ['icon' => 'ri-database-2-line', 'color' => 'warning', 'title' => 'Ceph Storage Cluster', 'desc' => 'Our redundant data distribution delivers IOPS beyond typical caching, fetching only unique data for a fast, smooth, and secure experience.'], - ['icon' => 'ri-route-line', 'color' => 'info', 'title' => 'BGP IPv6 Networking', 'desc' => 'We own our network, giving VPSs priority, a unique IPv6 address, and rotating IPv4 NAT for seamless inbound access.'], - ['icon' => 'ri-customer-service-2-line', 'color' => 'primary', 'title' => '24/7 Expert Support', 'desc' => 'Our knowledgeable support team is available around the clock to assist with technical issues, configurations, and any questions you may have.'], - ]; - @endphp - -
- @foreach($infra as $ii => $item) -
-
-
- -
-

- {{ $item['title'] }} -

-

{{ $item['desc'] }}

-
-
- @endforeach -
-
-
- - {{-- ═══════════════════════════════════════════════════════════════ - SECTION 5 — CTA - ═══════════════════════════════════════════════════════════════ --}} -
-
- -
-
-
-
- -
-
-
- -
-

Get Started

-

- {{ theme('cta_title', 'Ready to Deploy Your VPS?') }} -

-

- {{ theme('cta_subtitle', 'Join thousands of users who trust our platform for reliable, high-performance virtual servers with full control panel access') }} -

- -
- @foreach(['Instant Deployment', 'Full Root Access', '99.9% Uptime SLA', '24/7 Expert Support'] as $feat) -
-
- -
- {{ $feat }} -
- @endforeach -
- - -
-
- - - -
-
-
-
-
- - {{-- Hook --}} -
- {!! hook('pages.home') !!} -
- -
-
- -{{-- ═══════════════════════════════════════════════════════════════════ - SCROLL-REVEAL ENGINE + STYLES -═══════════════════════════════════════════════════════════════════ --}} - - - +
+ + + @if(theme('custom_homepage_html')) +
+ {!! theme('custom_homepage_html') !!} +
+ @endif + + {{-- ═══════════════════════════════════════════════════════════════ + SECTION 1 — HERO (full viewport, always visible) + ═══════════════════════════════════════════════════════════════ --}} +
+ +
+
+
+
+
+ +
+
+ +
+ +
+ @foreach([ + ['homepage_badge_1', 'VPS Hosting'], + ['homepage_badge_2', 'Website Hosting'], + ['homepage_badge_3', 'BGP Transit'], + ['homepage_badge_4', 'Contracted Support'], + ] as $i => $badge) +
+ + + + + {{ theme($badge[0], $badge[1]) }} +
+ @endforeach +
+ + +

+ {{ theme('homepage_hero_title1', 'Powerful') }} + + {{ theme('homepage_hero_title2', 'VPS Hosting') }} + +

+ + +

+ {{ theme('homepage_hero_desc', 'Experience enterprise-grade virtual private servers with our powerful control panel. Real-time monitoring, advanced firewall management, automated backups, and full console access.') }} +

+ + + +
+ + +
+
+ +
+
+
+
+
+ control-panel +
+
+
+
+
+ +
+
+
VPS-001
+
Running • 4 vCPU • 8GB RAM
+
+
+ Online +
+
+
+
CPU Usage
+
+ 23% +
+ @for($i = 0; $i < 12; $i++) + + @endfor +
+
+
+
+
Memory
+
+ 4.2GB + / 8GB +
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+ + +
+ Scroll +
+
+
+
+
+ + {{-- ═══════════════════════════════════════════════════════════════ + SECTION 2 — SERVICES + ═══════════════════════════════════════════════════════════════ --}} +
+
+
+

What We Offer

+

+ {{ theme('services_title', 'Our Services') }} +

+

+ {{ theme('services_subtitle', 'Choose the perfect option for your needs — all with full control panel access for all services we offer to give you total control') }} +

+
+ +
+ @foreach ($categories as $index => $category) +
+
+
+ +
+

+ {{ $category->name }} +

+ @if(theme('show_category_description', true)) +
+ {!! strip_tags($category->description) !!} +
+ @endif + + {{ __('general.view') }} + + +
+
+ @endforeach +
+
+
+ + {{-- ═══════════════════════════════════════════════════════════════ + SECTION 3 — CONTROL PANEL FEATURES (Bento) + ═══════════════════════════════════════════════════════════════ --}} +
+ +
+ +
+ +
+

Control Panel

+

+ {{ theme('panel_title', 'Complete Server Management') }} +

+

+ {{ theme('panel_subtitle', 'Take full control of your VPS with our comprehensive management dashboard. Every tool you need, right at your fingertips.') }} +

+
+ + +
+ +
+
+
+
+
+
+ +
+
+ CPU + RAM + Network +
+
+

Live Telemetry

+

Monitor CPU, memory, network, and disk I/O with interactive charts. Track performance over time.

+
+ @for($i = 0; $i < 18; $i++) + + @endfor +
+
+
+
+ + +
+
+
+
+
+ +
+

VNC Console

+

Direct web-based access to your VPS. Perfect for OS installation and recovery mode.

+
+ root@vps:~$ █ +
+
+
+
+
+ + + + + +
+ +
+
+
+
+
+
+ +
+
+ Inbound + Outbound +
+
+

Firewall Manager

+

Create custom rules, manage IP aliases, and monitor real-time traffic logs to protect your server.

+
+
+
+ Allow SSH (22/tcp) + Active +
+
+
+ Allow HTTP/S (80,443) + Active +
+
+
+ Block 192.168.1.0/24 + Denied +
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+ Auto-enabled +
+
+

Backup & Snapshots

+

Automated daily snapshots with instant one-click restore. Keep your data safe with point-in-time recovery.

+
+
+
+ + Daily Backup +
+ 2h ago +
+
+
+ + Weekly Snapshot +
+ 3 days ago +
+
+
+ + Next backup +
+ in 22h +
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+ +
+
+
65 GB
+
of 100 GB used
+
+
+

Storage Manager

+

Add, resize, or remove virtual disks on the fly. Expand storage without downtime.

+
+
+
+ System (sda1) + 45 GB +
+
+
+
+
+
+
+ Data (sdb1) + 20 GB +
+
+
+
+
+
+ Available + 35 GB free +
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+ Running +
+
+

Power Controls

+

Full control over your server's power state. Start, stop, reboot, or force power cycle instantly.

+
+
+ + Start +
+
+ + Reboot +
+
+ + Stop +
+
+
+
+ + Uptime +
+ 14d 6h 32m +
+
+
+
+
+ + +
+ @php + $quickFeatures = [ + ['icon' => 'ri-refresh-line', 'title' => 'OS Reinstall', 'desc' => '50+ templates', 'color' => 'primary'], + ['icon' => 'ri-history-line', 'title' => 'Task History', 'desc' => 'Full audit logs', 'color' => 'secondary'], + ['icon' => 'ri-global-line', 'title' => 'Network', 'desc' => 'IPv4/IPv6 & rDNS', 'color' => 'success'], + ['icon' => 'ri-disc-line', 'title' => 'ISO Mount', 'desc' => 'Custom images', 'color' => 'info'], + ]; + @endphp + @foreach($quickFeatures as $fi => $feat) +
+
+
+ +
+
+

{{ $feat['title'] }}

+

{{ $feat['desc'] }}

+
+
+
+ @endforeach +
+
+
+ + {{-- ═══════════════════════════════════════════════════════════════ + SECTION 4 — INFRASTRUCTURE FEATURES + ═══════════════════════════════════════════════════════════════ --}} +
+
+ +
+

Infrastructure

+

+ {{ theme('features_title', 'Enterprise Infrastructure') }} +

+

+ {{ theme('features_subtitle', 'We\'ve built a platform that is advanced enough to compete with the large players of the industry at a fraction of the cost.') }} +

+
+ + @php + $infra = [ + ['icon' => 'ri-hard-drive-3-line', 'color' => 'primary', 'title' => 'NVMe & RAM Cached Storage', 'desc' => 'Each VPS caches RAM for performance beyond SSDs, while dedicated NVMe drives provide consistent IOPS for storage.'], + ['icon' => 'ri-global-line', 'color' => 'secondary', 'title' => 'IPv6 Public Address', 'desc' => 'Each VPS receives a static IPv6 address for public services, plus a secondary RA-based MAC address to ensure connectivity.'], + ['icon' => 'ri-terminal-box-line', 'color' => 'success', 'title' => 'Xterm.js/NoVNC Access', 'desc' => 'Each VPS comes with Xterm.js and NoVNC via our server manager for easy access and control, anytime.'], + ['icon' => 'ri-database-2-line', 'color' => 'warning', 'title' => 'Ceph Storage Cluster', 'desc' => 'Our redundant data distribution delivers IOPS beyond typical caching, fetching only unique data for a fast, smooth, and secure experience.'], + ['icon' => 'ri-route-line', 'color' => 'info', 'title' => 'BGP IPv6 Networking', 'desc' => 'We own our network, giving VPSs priority, a unique IPv6 address, and rotating IPv4 NAT for seamless inbound access.'], + ['icon' => 'ri-customer-service-2-line', 'color' => 'primary', 'title' => '24/7 Expert Support', 'desc' => 'Our knowledgeable support team is available around the clock to assist with technical issues, configurations, and any questions you may have.'], + ]; + @endphp + +
+ @foreach($infra as $ii => $item) +
+
+
+ +
+

+ {{ $item['title'] }} +

+

{{ $item['desc'] }}

+
+
+ @endforeach +
+
+
+ + {{-- ═══════════════════════════════════════════════════════════════ + SECTION 5 — CTA + ═══════════════════════════════════════════════════════════════ --}} +
+
+ +
+
+
+
+ +
+
+
+ +
+

Get Started

+

+ {{ theme('cta_title', 'Ready to Deploy Your VPS?') }} +

+

+ {{ theme('cta_subtitle', 'Join thousands of users who trust our platform for reliable, high-performance virtual servers with full control panel access') }} +

+ +
+ @foreach(['Instant Deployment', 'Full Root Access', '99.9% Uptime SLA', '24/7 Expert Support'] as $feat) +
+
+ +
+ {{ $feat }} +
+ @endforeach +
+ + +
+
+ + + +
+
+
+
+
+ + {{-- Hook --}} +
+ {!! hook('pages.home') !!} +
+ +
+ +{{-- ═══════════════════════════════════════════════════════════════════ + SCROLL-REVEAL ENGINE + STYLES +═══════════════════════════════════════════════════════════════════ --}} + + + + +