New command: lnms report:devices (#15539)

* New report:devices command
Print out a list of devices with user specified fields, optionally in csv format

* Polish

* Apply fixes from StyleCI

* Use spaces instead of tab for none type

* Fix method call

* other commands use whereDeviceSpec

* Apply fixes from StyleCI

* update command help and back to tab for separator

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
This commit is contained in:
Tony Murray 2023-11-01 13:52:21 -05:00 committed by GitHub
parent 44521ab2f1
commit 183f9559f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 279 additions and 39 deletions

View File

@ -32,10 +32,8 @@ use App\Models\Eventlog;
use App\Polling\Measure\Measurement;
use App\Polling\Measure\MeasurementManager;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use LibreNMS\Enum\Severity;
use LibreNMS\Exceptions\PollerException;
use LibreNMS\Polling\ConnectivityHelper;
use LibreNMS\Polling\Result;
use LibreNMS\RRD\RrdDefinition;
@ -89,7 +87,7 @@ class Poller
$this->logger->info("Starting polling run:\n");
foreach ($this->buildDeviceQuery()->pluck('device_id') as $device_id) {
foreach (Device::whereDeviceSpec($this->device_spec)->pluck('device_id') as $device_id) {
$results->markAttempted();
$this->initDevice($device_id);
PollingDevice::dispatch($this->device);
@ -226,27 +224,6 @@ class Poller
return false;
}
private function buildDeviceQuery(): Builder
{
$query = Device::query();
if (empty($this->device_spec)) {
throw new PollerException('Invalid device spec');
} elseif ($this->device_spec == 'all') {
return $query;
} elseif ($this->device_spec == 'even') {
return $query->whereRaw('device_id % 2 = 0');
} elseif ($this->device_spec == 'odd') {
return $query->whereRaw('device_id % 2 = 1');
} elseif (is_numeric($this->device_spec)) {
return $query->where('device_id', $this->device_spec);
} elseif (Str::contains($this->device_spec, '*')) {
return $query->where('hostname', 'like', str_replace('*', '%', $this->device_spec));
}
return $query->where('hostname', $this->device_spec);
}
private function initDevice(int $device_id): void
{
\DeviceCache::setPrimary($device_id);

View File

@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Console\LnmsCommand;
use App\Models\Device;
use Illuminate\Database\Eloquent\Builder;
use LibreNMS\Config;
use LibreNMS\Polling\ConnectivityHelper;
use Symfony\Component\Console\Input\InputArgument;
@ -32,12 +31,7 @@ class DevicePing extends LnmsCommand
public function handle(): int
{
$spec = $this->argument('device spec');
$devices = Device::query()->when($spec !== 'all', function (Builder $query) use ($spec) {
/** @phpstan-var Builder<Device> $query */
return $query->where('device_id', $spec)
->orWhere('hostname', $spec)
->limit(1);
})->get();
$devices = Device::whereDeviceSpec($spec)->get();
if ($devices->isEmpty()) {
$devices = [new Device(['hostname' => $spec])];

View File

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands;
use App\Console\LnmsCommand;
use App\Console\SyntheticDeviceField;
use App\Models\Device;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
class ReportDevices extends LnmsCommand
{
protected $name = 'report:devices';
const NONE_SEPERATOR = "\t";
public function __construct()
{
parent::__construct();
$this->addArgument('device spec', InputArgument::OPTIONAL);
$this->addOption('fields', 'f', InputOption::VALUE_REQUIRED, default: 'hostname,ip');
$this->addOption('output', 'o', InputOption::VALUE_REQUIRED, __('commands.report:devices.options.output', ['types' => '[table, csv, none]']), 'table');
$this->addOption('list-fields');
}
/**
* Execute the console command.
*/
public function handle(): int
{
if ($this->option('list-fields')) {
$this->printFields();
return 0;
}
try {
$fields = collect(explode(',', $this->option('fields')))->map(fn ($field) => $this->getField($field));
} catch (\Exception $e) {
$this->error($e->getMessage());
return 1;
}
$headers = $fields->map->headerName()->all();
$devices = $this->fetchDeviceData($fields);
$this->printReport($headers, $devices);
return 0;
}
protected function fetchDeviceData($fields): Collection
{
$columns = $fields->pluck('columns')->flatten()->all();
$query = Device::whereDeviceSpec($this->argument('device spec'))->select($columns);
// apply any field query modifications
foreach ($fields as $field) {
$field->modifyQuery($query);
}
// fetch data and call the toString method for each field.
return $query->get()->map(function (Device $device) use ($fields) {
$data = [];
foreach ($fields as $field) {
$data[$field->name] = $field->toString($device);
}
return $data;
});
}
protected function getField(string $field): SyntheticDeviceField
{
// relationship counts
if (str_ends_with($field, '_count')) {
[$relationship] = explode('_', $field, -1);
if (! (new Device)->isRelation($relationship)) {
throw new \Exception("Invalid field: $field");
}
return new SyntheticDeviceField(
$field,
modifyQuery: fn (Builder $query) => $query->withCount($relationship),
headerName: "$relationship count");
}
// misc synthetic fields
$syntheticFields = $this->getSyntheticFields();
if (isset($syntheticFields[$field])) {
return $syntheticFields[$field];
}
// just a regular column, check that it exists
if (! Schema::hasColumn('devices', $field)) {
throw new \Exception("Invalid field: $field");
}
return new SyntheticDeviceField($field, [$field]);
}
protected function getSyntheticFields(): array
{
return [
'displayName' => new SyntheticDeviceField('displayName', ['hostname', 'sysName', 'ip', 'display'], fn (Device $device) => $device->displayName(), headerName: 'display name'),
'location' => new SyntheticDeviceField('location', ['location_id'], fn (Device $device) => $device->location->location, fn (Builder $q) => $q->with('location')),
];
}
protected function printReport(array $headers, array|Collection $rows): void
{
$output = $this->option('output');
if ($output == 'csv') {
$out = fopen('php://output', 'w');
fputcsv($out, $headers);
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
return;
}
if ($output == 'none') {
foreach ($rows as $row) {
$this->line(implode(self::NONE_SEPERATOR, $row));
}
return;
}
// print table
$this->table($headers, $rows);
}
protected function printFields(): void
{
$this->info(__('commands.report:devices.columns'));
$columns = Schema::getColumnListing('devices');
foreach ($columns as $column) {
$this->line($column);
}
$this->info(__('commands.report:devices.synthetic'));
$synthetic = array_keys($this->getSyntheticFields());
foreach ($synthetic as $field) {
$this->line($field);
}
$this->info(__('commands.report:devices.counts'));
$relationships = Device::definedRelations();
foreach ($relationships as $relationship) {
$this->line($relationship . '_count');
}
}
}

View File

@ -5,7 +5,6 @@ namespace App\Console\Commands;
use App\Console\LnmsCommand;
use App\Models\Device;
use DeviceCache;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use LibreNMS\Data\Source\SnmpResponse;
@ -149,12 +148,8 @@ abstract class SnmpFetch extends LnmsCommand
protected function getDevices(): \Illuminate\Support\Collection
{
return Device::query()->when($this->deviceSpec !== 'all', function (Builder $query) {
return $query->where('device_id', $this->deviceSpec)
->orWhere('hostname', 'regexp', "^$this->deviceSpec$");
})->pluck('device_id')->map(function ($device_id) {
return DeviceCache::get($device_id);
});
return Device::whereDeviceSpec($this->deviceSpec)->pluck('device_id')
->map(fn ($device_id) => DeviceCache::get($device_id));
}
protected function fetchData(): SnmpResponse

View File

@ -0,0 +1,65 @@
<?php
/*
* SyntheticDeviceField.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2023 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace App\Console;
use App\Models\Device;
use Closure;
use Illuminate\Database\Eloquent\Builder;
class SyntheticDeviceField
{
public function __construct(
public readonly string $name,
public readonly array $columns = [],
public readonly ?Closure $displayFunction = null,
public readonly ?Closure $modifyQuery = null,
public readonly ?string $headerName = null,
) {
}
public function headerName(): string
{
return $this->headerName ?? $this->name;
}
public function modifyQuery(Builder $query): Builder
{
if ($this->modifyQuery) {
return call_user_func($this->modifyQuery, $query);
}
return $query;
}
public function toString(Device $device): string
{
if ($this->displayFunction) {
return (string) call_user_func($this->displayFunction, $device);
}
return (string) $device->getAttributeValue($this->name);
}
}

View File

@ -96,4 +96,20 @@ abstract class BaseModel extends Model
->orWhereIntegerInRaw("$table.device_id", \Permissions::devicesForUser($user));
});
}
public static function definedRelations(): array
{
$reflector = new \ReflectionClass(get_called_class());
return collect($reflector->getMethods())
->filter(
fn ($method) => ! empty($method->getReturnType()) &&
str_contains(
$method->getReturnType(),
'Illuminate\Database\Eloquent\Relations'
)
)
->pluck('name')
->all();
}
}

View File

@ -628,6 +628,25 @@ class Device extends BaseModel
);
}
public function scopeWhereDeviceSpec(Builder $query, ?string $deviceSpec): Builder
{
if (empty($deviceSpec)) {
return $query;
} elseif ($deviceSpec == 'all') {
return $query;
} elseif ($deviceSpec == 'even') {
return $query->whereRaw('device_id % 2 = 0');
} elseif ($deviceSpec == 'odd') {
return $query->whereRaw('device_id % 2 = 1');
} elseif (is_numeric($deviceSpec)) {
return $query->where('device_id', $deviceSpec);
} elseif (str_contains($deviceSpec, '*')) {
return $query->where('hostname', 'like', str_replace('*', '%', $deviceSpec));
}
return $query->where('hostname', $deviceSpec);
}
// ---- Define Relationships ----
public function accessPoints(): HasMany

View File

@ -194,6 +194,20 @@ return [
'enabled' => ':count plugin enabled|:count plugins enabled',
'failed' => 'Failed to enable plugin(s)',
],
'report:devices' => [
'description' => 'Print out data from devices',
'columns' => 'Database columns:',
'synthetic' => 'Additional fields:',
'counts' => 'Relationship counts:',
'arguments' => [
'device spec' => 'Device spec to poll: device_id, hostname, wildcard (*), odd, even, all',
],
'options' => [
'list-fields' => 'Print out a list of valid fields',
'fields' => 'A comma seperated list of fields to display. Valid options: device column names from the database, relationship counts (ports_count), and/or displayName',
'output' => 'Output format to display the data :types',
],
],
'smokeping:generate' => [
'args-nonsense' => 'Use one of --probes and --targets',
'config-insufficient' => 'In order to generate a smokeping configuration, you must have set "smokeping.probes", "fping", and "fping6" set in your configuration',
@ -216,7 +230,7 @@ return [
'snmp:fetch' => [
'description' => 'Run snmp query against a device',
'arguments' => [
'device spec' => 'Device to query: device_id, hostname/ip, hostname regex, or all',
'device spec' => 'Device spec to poll: device_id, hostname, wildcard (*), odd, even, all',
'oid(s)' => 'One or more SNMP OID to fetch. Should be either MIB::oid or a numeric oid',
],
'failed' => 'SNMP command failed!',