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 <bot@styleci.io>
This commit is contained in:
Tony Murray
2023-10-29 22:45:23 -05:00
committed by GitHub
parent 37334e9abc
commit 6bc8a504bb
16 changed files with 238 additions and 36 deletions

View File

@@ -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'),
];
}
}

View File

@@ -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),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -2,11 +2,13 @@
<div class="col-md-12">
<div class="panel panel-default panel-condensed">
<div class="panel-heading">
<strong>{{ $title }}</strong> <a href="{{ url('device/' . $device->device_id . '/notes') }}">[EDIT]</a>
<strong>{{ $title }}</strong> <a href="{{ $url }}">[EDIT]</a>
</div>
<div class="panel-body">
<div class="row">
<div class="col-sm-12">
{{-- 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 ?? '') !!}
</div>
</div>

View File

@@ -1 +1 @@
<a href="{{ url('plugin/ExamplePlugin') }}"><i class="fa fa-coffee fa-fw fa-lg" aria-hidden="true"></i> Example Menu</a>
<a href="{{ url('plugin/ExamplePlugin') }}"><i class="fa fa-coffee fa-fw fa-lg" aria-hidden="true"></i> Example Menu @if($count)({{ $count }})@endif</a>

View File

@@ -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,
]));
}
}

View File

@@ -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,
])];
}
}

View File

@@ -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,
]));
}
}

View File

@@ -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,
]));
}
}

View File

@@ -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,
])));
}
}

View File

@@ -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;
}
/**

View File

@@ -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

View File

@@ -4712,6 +4712,10 @@
"plugin_dir": {
"type": "directory"
},
"plugins.show_errors": {
"type": "boolean",
"default": false
},
"poller_modules.unix-agent": {
"order": 420,
"group": "poller",

View File

@@ -2,4 +2,6 @@
@section('title', $title)
@include($settings_view, $settings)
@section('content')
@include($settings_view, $settings)
@endsection