diff --git a/LibreNMS/Poller.php b/LibreNMS/Poller.php index 699a2d0283..516d1971ff 100644 --- a/LibreNMS/Poller.php +++ b/LibreNMS/Poller.php @@ -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); diff --git a/app/Console/Commands/DevicePing.php b/app/Console/Commands/DevicePing.php index 86d4a892e5..53653f0f65 100644 --- a/app/Console/Commands/DevicePing.php +++ b/app/Console/Commands/DevicePing.php @@ -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 $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])]; diff --git a/app/Console/Commands/ReportDevices.php b/app/Console/Commands/ReportDevices.php new file mode 100644 index 0000000000..7554165520 --- /dev/null +++ b/app/Console/Commands/ReportDevices.php @@ -0,0 +1,160 @@ +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'); + } + } +} diff --git a/app/Console/Commands/SnmpFetch.php b/app/Console/Commands/SnmpFetch.php index 0b9d73af03..5db3c1287e 100644 --- a/app/Console/Commands/SnmpFetch.php +++ b/app/Console/Commands/SnmpFetch.php @@ -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 diff --git a/app/Console/SyntheticDeviceField.php b/app/Console/SyntheticDeviceField.php new file mode 100644 index 0000000000..77375a41a2 --- /dev/null +++ b/app/Console/SyntheticDeviceField.php @@ -0,0 +1,65 @@ +. + * + * @package LibreNMS + * @link http://librenms.org + * @copyright 2023 Tony Murray + * @author Tony Murray + */ + +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); + } +} diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 98f52aae70..1230b63abb 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -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(); + } } diff --git a/app/Models/Device.php b/app/Models/Device.php index 8d1d880078..123b16cf00 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -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 diff --git a/lang/en/commands.php b/lang/en/commands.php index cb9dc43f57..cad4182594 100644 --- a/lang/en/commands.php +++ b/lang/en/commands.php @@ -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!',