From 6bc8a504bb76fa657fbdf0d02699517b442614cf Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Sun, 29 Oct 2023 22:45:23 -0500 Subject: [PATCH] Plugin update (breaking) (#15498) * Plugin update (breaking) A couple breaking changes regarding property types and method arguments. Add a setting to allow plugin errors to be shown instead of automatically disabling the plugin. All default hooks now use Dependency Injection to make it easy to get access to whatever you need (such as settings) Add a ton of comments and examples in the PHP code. Expand a bit on the documentation, it could still use more help Fix a bug in the settings and page view where the included blade file was output before the page headers, etc * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot --- app/Plugins/ExamplePlugin/DeviceOverview.php | 27 +++++++++ app/Plugins/ExamplePlugin/Menu.php | 27 +++++++++ app/Plugins/ExamplePlugin/Page.php | 34 +++++++++++ app/Plugins/ExamplePlugin/PortTab.php | 22 +++++++ app/Plugins/ExamplePlugin/Settings.php | 23 ++++++++ .../resources/views/device-overview.blade.php | 4 +- .../resources/views/menu.blade.php | 2 +- app/Plugins/Hooks/DeviceOverviewHook.php | 13 ++-- app/Plugins/Hooks/MenuEntryHook.php | 12 ++-- app/Plugins/Hooks/PageHook.php | 13 ++-- app/Plugins/Hooks/PortTabHook.php | 10 +++- app/Plugins/Hooks/SettingsHook.php | 12 ++-- app/Plugins/PluginManager.php | 8 ++- doc/Extensions/Plugin-System.md | 59 ++++++++++++++++--- misc/config_definitions.json | 4 ++ resources/views/plugins/settings.blade.php | 4 +- 16 files changed, 238 insertions(+), 36 deletions(-) diff --git a/app/Plugins/ExamplePlugin/DeviceOverview.php b/app/Plugins/ExamplePlugin/DeviceOverview.php index 28c3f11179..92d8f2dd6d 100644 --- a/app/Plugins/ExamplePlugin/DeviceOverview.php +++ b/app/Plugins/ExamplePlugin/DeviceOverview.php @@ -29,4 +29,31 @@ use App\Plugins\Hooks\DeviceOverviewHook; class DeviceOverview extends DeviceOverviewHook { + // point to the view for your plugin's settings + // this is the default name so you can create the blade file as in this plugin + // by ommitting the variable, or point to another one + +// public string $view = 'resources.views.device-overview'; + + public function authorize(\App\Models\User $user, \App\Models\Device $device): bool + { + // In this example, we check if the user has a custom role/permission and if it is member of any device groups +// return $user->can('view-extra-port-info') && $device->has('groups'); + + return true; + } + + // override the data function to add additional data to be accessed in the view + // title is a required attribute and will be shown above your returned html from your blade file + // inside the blade, all variables will be named based on the key in the returned array + public function data(\App\Models\Device $device): array + { + // here we pass a title string, url to notes, and the device to the blade view for display + + return [ + 'title' => 'Example Plugin: Device Notes', + 'device' => $device, + 'url' => url('device/' . $device->device_id . '/notes'), + ]; + } } diff --git a/app/Plugins/ExamplePlugin/Menu.php b/app/Plugins/ExamplePlugin/Menu.php index b45ab75be4..7a2101f7ad 100644 --- a/app/Plugins/ExamplePlugin/Menu.php +++ b/app/Plugins/ExamplePlugin/Menu.php @@ -4,6 +4,33 @@ namespace App\Plugins\ExamplePlugin; use App\Plugins\Hooks\MenuEntryHook; +// this will create a menu entry in the plugin menu +// it should generally just be a class Menu extends MenuEntryHook { + // point to the view for your plugin's settings + // this is the default name so you can create the blade file as in this plugin + // by ommitting the variable, or point to another one + +// public string $view = 'resources.views.menu'; + + // this will determine if the menu entry should be shown to the user + public function authorize(\App\Models\User $user, array $settings = []): bool + { + // menu entry shown if users has the global-read role and there is a setting that has > one entries in it +// return $user->can('global-read') && isset($settings['some_data']) && count($settings['some_data']) > 0; + + return true; // allow every logged in user + } + + // override the data function to add additional data to be accessed in the view + // inside the blade, all variables will be named based on the key in the returned array + public function data(array $settings = []): array + { + // inject settings and count how many we have so we can display it in the menu + + return [ + 'count' => count($settings), + ]; + } } diff --git a/app/Plugins/ExamplePlugin/Page.php b/app/Plugins/ExamplePlugin/Page.php index c356e145d3..57b06fb956 100644 --- a/app/Plugins/ExamplePlugin/Page.php +++ b/app/Plugins/ExamplePlugin/Page.php @@ -27,6 +27,40 @@ namespace App\Plugins\ExamplePlugin; use App\Plugins\Hooks\PageHook; +// this page will be shown when the user clicks on the plugin from the plugins menu. +// This allows you to output a full screen of whatever you want to the user class Page extends PageHook { + // point to the view for your plugin's settings + // this is the default name so you can create the blade file as in this plugin + // by ommitting the variable, or point to another one + +// public string $view = 'resources.views.page'; + + // The authorize method will determine if the user has access to this page. + // if you want all users to be able to access this page simple return true + public function authorize(\App\Models\User $user): bool + { + // you can check user's roles like this: +// return $user->can('admin'); + + // or use whatever you like +// return \Carbon\Carbon::now()->dayOfWeek == Carbon::THURSDAY; // only allowed access on Thursdays! + + return true; // allow every logged in user to access + } + + // override the data function to add additional data to be accessed in the view + // default just passes the stored data through + // inside the blade, all variables will be named based on the key in the returned array + public function data(): array + { + // run any calculations here + $username = auth()->user()->username; + + return [ + 'something' => 'this is a variable and can be accessed with {{ $something }}', + 'hello' => 'Hello: ' . $username, + ]; + } } diff --git a/app/Plugins/ExamplePlugin/PortTab.php b/app/Plugins/ExamplePlugin/PortTab.php index ed47d71b4d..aa91728f60 100644 --- a/app/Plugins/ExamplePlugin/PortTab.php +++ b/app/Plugins/ExamplePlugin/PortTab.php @@ -4,6 +4,28 @@ namespace App\Plugins\ExamplePlugin; use App\Plugins\Hooks\PortTabHook; +// this will insert a tab into every port view class PortTab extends PortTabHook { + // point to the view for your plugin's port plugin + // this is the default name so you can create the blade file as in this plugin + // by ommitting the variable, or point to another one + +// public string $view = 'resources.views.port-tab'; + + // override the data function to add additional data to be accessed in the view + // title is a required attribute and will be shown above your returned html from your blade file + // inside the blade, all variables will be named based on the key in the returned array + public function data(\App\Models\Port $port): array + { + // run any calculations here + $total_delta = $port->ifOutOctets_delta + $port->ifInOctets_delta; // nonsense calculation :) + + return [ + 'title' => 'Example Plugin', + 'port' => $port, + 'something' => 'this is a variable and can be accessed with {{ $something }}', + 'total' => $total_delta, + ]; + } } diff --git a/app/Plugins/ExamplePlugin/Settings.php b/app/Plugins/ExamplePlugin/Settings.php index 0ad995e75a..04b6ffbfe8 100644 --- a/app/Plugins/ExamplePlugin/Settings.php +++ b/app/Plugins/ExamplePlugin/Settings.php @@ -27,6 +27,29 @@ namespace App\Plugins\ExamplePlugin; use App\Plugins\Hooks\SettingsHook; +// In the plugins admin page, there will be a settings button if you implement this hook +// To save settings in your settings page, you should have a form that returns all variables +// you want to save in the database. class Settings extends SettingsHook { + // point to the view for your plugin's settings + // this is the default name so you can create the blade file as in this plugin + // by ommitting the variable, or point to another one + +// public string $view = 'resources.views.settings'; + + // override the data function to add additional data to be accessed in the view + // default just passes the stored data through + // inside the blade, all variables will be named based on the key in the returned array + public function data(array $settings = []): array + { + // run any calculations here + $total = array_sum([1, 2, 3, 4]); + + return [ + 'settings' => $settings, // this is an array of all the settings stored in the database + 'something' => 'this is a variable and can be accessed with {{ $something }}', + 'total' => $total, + ]; + } } diff --git a/app/Plugins/ExamplePlugin/resources/views/device-overview.blade.php b/app/Plugins/ExamplePlugin/resources/views/device-overview.blade.php index af279c6344..977bec1d4e 100644 --- a/app/Plugins/ExamplePlugin/resources/views/device-overview.blade.php +++ b/app/Plugins/ExamplePlugin/resources/views/device-overview.blade.php @@ -2,11 +2,13 @@
- {{ $title }} [EDIT] + {{ $title }} [EDIT]
+ {{-- This is a comment. Below we output the markdown output unescaped because we want the raw html + to be output to the page. Be careful with unescaped output as it can lead to security issues. --}} {!! Str::markdown($device->notes ?? '') !!}
diff --git a/app/Plugins/ExamplePlugin/resources/views/menu.blade.php b/app/Plugins/ExamplePlugin/resources/views/menu.blade.php index 57d47b922c..c52c5daeb3 100644 --- a/app/Plugins/ExamplePlugin/resources/views/menu.blade.php +++ b/app/Plugins/ExamplePlugin/resources/views/menu.blade.php @@ -1 +1 @@ - Example Menu + Example Menu @if($count)({{ $count }})@endif diff --git a/app/Plugins/Hooks/DeviceOverviewHook.php b/app/Plugins/Hooks/DeviceOverviewHook.php index 941519eeb6..44a255fa64 100644 --- a/app/Plugins/Hooks/DeviceOverviewHook.php +++ b/app/Plugins/Hooks/DeviceOverviewHook.php @@ -27,14 +27,14 @@ namespace App\Plugins\Hooks; use App\Models\Device; use App\Models\User; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Str; abstract class DeviceOverviewHook { - /** @var string */ - public $view = 'resources.views.device-overview'; + public string $view = 'resources.views.device-overview'; - public function authorize(User $user, Device $device, array $settings): bool + public function authorize(User $user, Device $device): bool { return true; } @@ -47,8 +47,11 @@ abstract class DeviceOverviewHook ]; } - final public function handle(string $pluginName, Device $device): \Illuminate\Contracts\View\View + final public function handle(string $pluginName, array $settings, Device $device, Application $app): \Illuminate\Contracts\View\View { - return view(Str::start($this->view, "$pluginName::"), $this->data($device)); + return view(Str::start($this->view, "$pluginName::"), $app->call([$this, 'data'], [ + 'device' => $device, + 'settings' => $settings, + ])); } } diff --git a/app/Plugins/Hooks/MenuEntryHook.php b/app/Plugins/Hooks/MenuEntryHook.php index 3390ce09db..fd164526c9 100644 --- a/app/Plugins/Hooks/MenuEntryHook.php +++ b/app/Plugins/Hooks/MenuEntryHook.php @@ -26,14 +26,14 @@ namespace App\Plugins\Hooks; use App\Models\User; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Str; abstract class MenuEntryHook { - /** @var string */ - public $view = 'resources.views.menu'; + public string $view = 'resources.views.menu'; - public function authorize(User $user, array $settings): bool + public function authorize(User $user): bool { return true; } @@ -43,8 +43,10 @@ abstract class MenuEntryHook return []; } - final public function handle(string $pluginName): array + final public function handle(string $pluginName, array $settings, Application $app): array { - return [Str::start($this->view, "$pluginName::"), $this->data()]; + return [Str::start($this->view, "$pluginName::"), $app->call([$this, 'data'], [ + 'settings' => $settings, + ])]; } } diff --git a/app/Plugins/Hooks/PageHook.php b/app/Plugins/Hooks/PageHook.php index d0152cf47e..a3e8f99753 100644 --- a/app/Plugins/Hooks/PageHook.php +++ b/app/Plugins/Hooks/PageHook.php @@ -26,12 +26,12 @@ namespace App\Plugins\Hooks; use App\Models\User; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Str; abstract class PageHook { - /** @var string */ - public $view = 'resources.views.page'; + public string $view = 'resources.views.page'; public function authorize(User $user): bool { @@ -40,14 +40,15 @@ abstract class PageHook public function data(): array { - return [ - ]; + return []; } - final public function handle(string $pluginName): array + final public function handle(string $pluginName, array $settings, Application $app): array { return array_merge([ 'settings_view' => Str::start($this->view, "$pluginName::"), - ], $this->data()); + ], $app->call([$this, 'data'], [ + 'settings' => $settings, + ])); } } diff --git a/app/Plugins/Hooks/PortTabHook.php b/app/Plugins/Hooks/PortTabHook.php index 34109c22ec..af7b6541d0 100644 --- a/app/Plugins/Hooks/PortTabHook.php +++ b/app/Plugins/Hooks/PortTabHook.php @@ -28,6 +28,7 @@ namespace App\Plugins\Hooks; use App\Models\Port; use App\Models\User; use App\Plugins\Hook; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Str; abstract class PortTabHook implements Hook @@ -35,7 +36,7 @@ abstract class PortTabHook implements Hook /** @var string */ public $view = 'resources.views.port-tab'; - public function authorize(User $user, Port $port, array $settings): bool + public function authorize(User $user, Port $port): bool { return true; } @@ -48,8 +49,11 @@ abstract class PortTabHook implements Hook ]; } - final public function handle(string $pluginName, Port $port): \Illuminate\Contracts\View\View + final public function handle(string $pluginName, Port $port, array $settings, Application $app): \Illuminate\Contracts\View\View { - return view(Str::start($this->view, "$pluginName::"), $this->data($port)); + return view(Str::start($this->view, "$pluginName::"), $app->call([$this, 'data'], [ + 'port' => $port, + 'settings' => $settings, + ])); } } diff --git a/app/Plugins/Hooks/SettingsHook.php b/app/Plugins/Hooks/SettingsHook.php index 2f0fe9573e..aa0c0c8196 100644 --- a/app/Plugins/Hooks/SettingsHook.php +++ b/app/Plugins/Hooks/SettingsHook.php @@ -26,14 +26,14 @@ namespace App\Plugins\Hooks; use App\Models\User; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Str; abstract class SettingsHook { - /** @var string */ - public $view = 'resources.views.settings'; + public string $view = 'resources.views.settings'; - public function authorize(User $user, array $settings): bool + public function authorize(User $user): bool { return true; } @@ -45,10 +45,12 @@ abstract class SettingsHook ]; } - final public function handle(string $pluginName, array $settings): array + final public function handle(string $pluginName, array $settings, Application $app): array { return array_merge([ 'settings_view' => Str::start($this->view, "$pluginName::"), - ], $this->data($settings)); + ], $this->data($app->call([$this, 'data'], [ + 'settings' => $settings, + ]))); } } diff --git a/app/Plugins/PluginManager.php b/app/Plugins/PluginManager.php index 6969cea122..a9280df125 100644 --- a/app/Plugins/PluginManager.php +++ b/app/Plugins/PluginManager.php @@ -114,7 +114,11 @@ class PluginManager return app()->call([$hook['instance'], 'handle'], $this->fillArgs($args, $hook['plugin_name'])); } catch (Exception|\Error $e) { $name = $hook['plugin_name']; - Log::error("Error calling hook $hookType for $name: " . $e->getMessage()); + Log::error("Error calling hook $hookType for $name: " . $e->getMessage() . PHP_EOL . $e->getTraceAsString()); + + if (\LibreNMS\Config::get('plugins.show_errors')) { + throw $e; + } Notifications::create("Plugin $name disabled", "$name caused an error and was disabled, please check with the plugin creator to fix the error. The error can be found in logs/librenms.log", 'plugins', 2); Plugin::where('plugin_name', $name)->update(['plugin_active' => 0]); @@ -174,7 +178,7 @@ class PluginManager */ public function pluginEnabled(string $pluginName): bool { - return (bool) optional($this->getPlugin($pluginName))->plugin_active; + return (bool) $this->getPlugin($pluginName)?->plugin_active; } /** diff --git a/doc/Extensions/Plugin-System.md b/doc/Extensions/Plugin-System.md index ec4b8586e0..b861be0a82 100644 --- a/doc/Extensions/Plugin-System.md +++ b/doc/Extensions/Plugin-System.md @@ -14,6 +14,8 @@ LibreNMS. An example plugin is included in the LibreNMS distribution. Plugins in version 2 need to be installed into app/Plugins +>Note: Plugins are disabled when the have an error, to show errors instead set plugins.show_errors + The structure of a plugin is follows: ``` @@ -78,20 +80,63 @@ class in 'app/Plugins/PluginName' and overload the hook methods. - settings.blade.php :: If you need your own settings and variables, you can have a look in the ExamplePlugin. +### PHP Hooks customization -If you want to change the behavior, you can customize the hooks methods. Just as an example, you could imagine that the device-overview.blade.php should only be displayed when the device is in maintanence mode. Of course the method is more for a permission concept but it gives you the idea. +PHP code should run inside your hooks method and not your blade view. +The built in hooks support authorize and data methods. -``` -abstract class DeviceOverviewHook +These methods are called with [Dependency Injection](https://laravel.com/docs/container#method-invocation-and-injection) +Hooks with relevant database models will include them in these calls. +Additionally, the settings argument may be included to inject the plugin settings into the method. + +#### Data + +You can overrid the data method to supply data to your view. You should also do any processing here. +You can do things like access the database or configuration settings and more. + +In the data method we are injecting settings here to count how many we have for display in the menu entry blade view. +Note that you must specify a default value (`= []` here) for any arguments that don't exist on the parent method. + +```php +class Menu extends MenuEntryHook { - ... - public function authorize(User $user, Device $device, array $settings): bool + public function data(array $settings = []): array { - return $device->isUnderMaintenance(); + return [ + 'count' => count($settings), + ]; } - ... +} ``` +#### Authorize + +By default hooks are always shown, but you may control when the user is authorized to view the hook content. + +As an example, you could imagine that the device-overview.blade.php should only be displayed when the +device is in maintanence mode and the current user has the admin role. + +```php +class DeviceOverview extends DeviceOverviewHook +{ + public function authorize(User $user, Device $device): bool + { + return $user->can('admin') && $device->isUnderMaintenance(); + } +} +``` + + +### Full plugin + +You may create a full plugin that can publish multiple routes, views, database migrations and more. +Create a package according to the Laravel documentation you may call any of the supported hooks to tie into LibreNMS. + +https://laravel.com/docs/packages + +> This is untested, please come to discord and share any expriences and update this documentation! + + ## Version 1 Plugin System structure (legacy verion) Plugins need to be installed into html/plugins diff --git a/misc/config_definitions.json b/misc/config_definitions.json index 4947909205..fa8e7b48ea 100644 --- a/misc/config_definitions.json +++ b/misc/config_definitions.json @@ -4712,6 +4712,10 @@ "plugin_dir": { "type": "directory" }, + "plugins.show_errors": { + "type": "boolean", + "default": false + }, "poller_modules.unix-agent": { "order": 420, "group": "poller", diff --git a/resources/views/plugins/settings.blade.php b/resources/views/plugins/settings.blade.php index 0b7a83b0b1..b2f895a568 100644 --- a/resources/views/plugins/settings.blade.php +++ b/resources/views/plugins/settings.blade.php @@ -2,4 +2,6 @@ @section('title', $title) -@include($settings_view, $settings) +@section('content') + @include($settings_view, $settings) +@endsection