diff --git a/LibreNMS/Data/Graphing/AbstractGraph.php b/LibreNMS/Data/Graphing/AbstractGraph.php new file mode 100644 index 00000000000..69c1db450f5 --- /dev/null +++ b/LibreNMS/Data/Graphing/AbstractGraph.php @@ -0,0 +1,83 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Data\Graphing; + +use App\Facades\DeviceCache; +use App\Facades\PortCache; +use App\Models\Device; +use App\Models\Port; +use LibreNMS\Interfaces\Data\Graphing\GraphInterface; + +abstract class AbstractGraph implements GraphInterface +{ + protected Device $device; + protected ?Port $port; + + public function __construct( + protected readonly GraphParameters $params, + protected readonly array $vars = [], + ) { + $device_id = $this->vars['device'] ?? ($this->params->type == 'device' ? ($this->vars['id'] ?? null) : null); + $this->device = DeviceCache::get($device_id); + + $port_id = $this->vars['port'] ?? ($this->params->type == 'port' ? ($this->vars['id'] ?? null) : null); + $this->port = PortCache::get($port_id); + + $this->init(); + } + + public function getParams(): GraphParameters + { + return $this->params; + } + + public function validation(): array + { + return [ + 'type' => ['required', 'string', 'regex:/^[a-z][a-z0-9]*_[a-zA-Z0-9_]+$/'], + 'id' => ['nullable', 'regex:/^[A-Za-z0-9,._-]+$/'], + 'from' => ['nullable', 'regex:/^(-?\d+|-?\d+[smhdwMy]|now|end)$/'], + 'to' => ['nullable', 'regex:/^(-?\d+|-?\d+[smhdwMy]|now|end)$/'], + 'width' => ['nullable', 'integer', 'min:10', 'max:10000'], + 'height' => ['nullable', 'integer', 'min:10', 'max:8000'], + 'legend' => ['nullable', 'in:yes,no,0,1'], + 'bg' => ['nullable', 'regex:/^[0-9A-Fa-f]{6}$/'], + 'title' => ['nullable', 'string', 'max:255'], + 'output' => ['nullable', 'in:png,svg,json'], + ]; + } + + public function getPageTitle(): string + { + return $this->getGraphTitle(); + } + + protected function init(): void + { + // for child init + } +} diff --git a/LibreNMS/Data/Graphing/Builders/MultiLineGraphBuilder.php b/LibreNMS/Data/Graphing/Builders/MultiLineGraphBuilder.php new file mode 100644 index 00000000000..591558b083f --- /dev/null +++ b/LibreNMS/Data/Graphing/Builders/MultiLineGraphBuilder.php @@ -0,0 +1,187 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Data\Graphing\Builders; + +use App\Facades\LibrenmsConfig; +use LibreNMS\Data\Graphing\GraphParameters; +use LibreNMS\Data\Store\Rrd; + +class MultiLineGraphBuilder +{ + private string $unitText = ''; + private string $units = ''; + private string $colours = 'mixed'; + private ?float $scaleMin = null; + private ?float $scaleMax = null; + private bool $nototal = false; + private int $descrLen = 12; + + private array $datasets = []; + + public function unitText(string $unitText): self + { + $this->unitText = $unitText; + + return $this; + } + + public function units(string $units): self + { + $this->units = $units; + + return $this; + } + + public function colours(string $colours): self + { + $this->colours = $colours; + + return $this; + } + + public function scaleMin(float $scaleMin): self + { + $this->scaleMin = $scaleMin; + + return $this; + } + + public function scaleMax(float $scaleMax): self + { + $this->scaleMax = $scaleMax; + + return $this; + } + + public function noTotal(bool $noTotal = true): self + { + $this->nototal = $noTotal; + + return $this; + } + + public function descrLen(int $descrLen): self + { + $this->descrLen = $descrLen; + + return $this; + } + + public function addDataset( + string $filename, + string $ds, + string $description, + ?string $colour = null, + bool $area = false, + ?string $areaColour = null, + bool $invert = false + ): self { + $this->datasets[] = [ + 'filename' => $filename, + 'ds' => $ds, + 'descr' => $description, + 'colour' => $colour, + 'area' => $area, + 'areacolour' => $areaColour, + 'invert' => $invert, + ]; + + return $this; + } + + public function build(GraphParameters $graph_params): array + { + $float_precision = $graph_params->float_precision; + + if ($this->scaleMin !== null) { + $graph_params->scale_min = (int) $this->scaleMin; + } + if ($this->scaleMax !== null) { + $graph_params->scale_max = (int) $this->scaleMax; + } + + $descr_len = $this->descrLen; + if ($this->nototal) { + $descr_len += 2; + } + + $rrd_options = []; + $rrd_options[] = 'COMMENT:' . Rrd::fixedSafeDescr($this->unitText, $descr_len) . " Now Min Max Avg\l"; + + $stackedVal = LibrenmsConfig::get('webui.graph_stacked') ? '1' : '-1'; + $rrd_optionsb = []; + $colour_iter = 0; + + foreach ($this->datasets as $i => $rrd) { + $colour = $rrd['colour']; + if ($colour === null) { + if (! LibrenmsConfig::get("graph_colours.{$this->colours}.{$colour_iter}")) { + $colour_iter = 0; + } + $colour = LibrenmsConfig::get("graph_colours.{$this->colours}.{$colour_iter}"); + $colour_iter++; + } + + $areacolour = $rrd['areacolour']; + if ($rrd['area'] && empty($areacolour)) { + $areacolour = $colour . '20'; + } + + $ds = $rrd['ds']; + $filename = $rrd['filename']; + $descr = Rrd::fixedSafeDescr($rrd['descr'], $descr_len); + $id = 'ds' . $i; + + $rrd_options[] = 'DEF:' . $id . "=$filename:$ds:AVERAGE"; + $rrd_options[] = 'DEF:' . $id . "min=$filename:$ds:MIN"; + $rrd_options[] = 'DEF:' . $id . "max=$filename:$ds:MAX"; + + if ($rrd['invert']) { + $rrd_options[] = 'CDEF:' . $id . 'i=' . $id . ',' . $stackedVal . ',*'; + $rrd_optionsb[] = 'LINE1.25:' . $id . 'i#' . $colour . ":$descr"; + if (! empty($areacolour)) { + $rrd_optionsb[] = 'AREA:' . $id . 'i#' . $areacolour; + } + } else { + $rrd_optionsb[] = 'LINE1.25:' . $id . '#' . $colour . ":$descr"; + if (! empty($areacolour)) { + $rrd_optionsb[] = 'AREA:' . $id . '#' . $areacolour; + } + } + + $rrd_optionsb[] = 'GPRINT:' . $id . ':LAST:%5.' . $float_precision . 'lf%s' . $this->units; + $rrd_optionsb[] = 'GPRINT:' . $id . 'min:MIN:%5.' . $float_precision . 'lf%s' . $this->units; + $rrd_optionsb[] = 'GPRINT:' . $id . 'max:MAX:%5.' . $float_precision . 'lf%s' . $this->units; + $rrd_optionsb[] = 'GPRINT:' . $id . ':AVERAGE:%5.' . $float_precision . "lf%s{$this->units}\\n"; + } + + array_push($rrd_options, ...$rrd_optionsb); + $rrd_options[] = 'HRULE:0#555555'; + + return $rrd_options; + } +} diff --git a/LibreNMS/Data/Graphing/Builders/MultiSimplexSeparatedGraphBuilder.php b/LibreNMS/Data/Graphing/Builders/MultiSimplexSeparatedGraphBuilder.php new file mode 100644 index 00000000000..22f3ce50b50 --- /dev/null +++ b/LibreNMS/Data/Graphing/Builders/MultiSimplexSeparatedGraphBuilder.php @@ -0,0 +1,255 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Data\Graphing\Builders; + +use App\Facades\LibrenmsConfig; +use LibreNMS\Data\Graphing\GraphParameters; +use LibreNMS\Data\Store\Rrd; + +class MultiSimplexSeparatedGraphBuilder +{ + private string $unitText = ''; + private string $totalUnits = ''; + private string $colours = 'mixed'; + private ?float $scaleMin = null; + private ?float $scaleMax = null; + private ?float $divider = null; + private ?float $multiplier = null; + private bool $textOrig = false; + private bool $nototal = false; + private int $descrLen = 12; + + private array $datasets = []; + + public function unitText(string $unitText): self + { + $this->unitText = $unitText; + + return $this; + } + + public function totalUnits(string $totalUnits): self + { + $this->totalUnits = $totalUnits; + + return $this; + } + + public function colours(string $colours): self + { + $this->colours = $colours; + + return $this; + } + + public function scaleMin(float $scaleMin): self + { + $this->scaleMin = $scaleMin; + + return $this; + } + + public function scaleMax(float $scaleMax): self + { + $this->scaleMax = $scaleMax; + + return $this; + } + + public function divider(float $divider): self + { + $this->divider = $divider; + + return $this; + } + + public function multiplier(float $multiplier): self + { + $this->multiplier = $multiplier; + + return $this; + } + + public function textOrig(bool $textOrig = true): self + { + $this->textOrig = $textOrig; + + return $this; + } + + public function noTotal(bool $noTotal = true): self + { + $this->nototal = $noTotal; + + return $this; + } + + public function descrLen(int $descrLen): self + { + $this->descrLen = $descrLen; + + return $this; + } + + public function addDataset( + string $filename, + string $ds, + string $description, + ?string $colour = null + ): self { + $this->datasets[] = [ + 'filename' => $filename, + 'ds' => $ds, + 'descr' => $description, + 'colour' => $colour, + ]; + + return $this; + } + + public function build(GraphParameters $graph_params): array + { + $float_precision = $graph_params->float_precision; + + if ($this->scaleMin !== null) { + $graph_params->scale_min = (int) $this->scaleMin; + } + if ($this->scaleMax !== null) { + $graph_params->scale_max = (int) $this->scaleMax; + } + + $previous = $graph_params->visible('previous'); + $prev_from = $graph_params->prev_from; + $from = $graph_params->from; + $period = $graph_params->period; + + $descr_len = $this->descrLen; + if ($this->nototal) { + $descr_len += 2; + } + + $unitlen = 10; + if ($this->nototal) { + $unitlen += 2; + } + + $unit_text = Rrd::fixedSafeDescr($this->unitText, $unitlen); + + $rrd_options = []; + $rrd_options[] = 'COMMENT:' . Rrd::fixedSafeDescr($this->unitText, $descr_len) . " Now Min Max Avg\l"; + + $seperatorX = ''; + $thingX = ''; + $plusX = ''; + $plusesX = ''; + $stack = ''; + + $colour_iter = 0; + foreach ($this->datasets as $i => $rrd) { + $colour = $rrd['colour']; + if ($colour === null) { + if (! LibrenmsConfig::get("graph_colours.{$this->colours}.{$colour_iter}")) { + $colour_iter = 0; + } + $colour = LibrenmsConfig::get("graph_colours.{$this->colours}.{$colour_iter}"); + $colour_iter++; + } + + $descr = Rrd::fixedSafeDescr($rrd['descr'], $descr_len); + $ds = $rrd['ds']; + $filename = $rrd['filename']; + + $rrd_options[] = 'DEF:' . $ds . $i . '=' . $filename . ':' . $ds . ':AVERAGE'; + $rrd_options[] = 'DEF:' . $ds . $i . 'min=' . $filename . ':' . $ds . ':MIN'; + $rrd_options[] = 'DEF:' . $ds . $i . 'max=' . $filename . ':' . $ds . ':MAX'; + + if ($previous) { + $rrd_options[] = 'DEF:' . $i . 'X=' . $filename . ':' . $ds . ':AVERAGE:start=' . $prev_from . ':end=' . $from; + $rrd_options[] = 'SHIFT:' . $i . "X:$period"; + $thingX .= $seperatorX . $i . 'X,UN,0,' . $i . 'X,IF'; + $plusesX .= $plusX; + $seperatorX = ','; + $plusX = ',+'; + } + + if (! $this->nototal) { + $rrd_options[] = 'VDEF:tot' . $ds . $i . '=' . $ds . $i . ',TOTAL'; + } + + if ($i > 0) { + $stack = ':STACK'; + } + + $g_defname = $ds; + if ($this->multiplier !== null) { + $g_defname = $ds . '_cdef'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . '=' . $ds . $i . ',' . $this->multiplier . ',*'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . 'min=' . $ds . $i . 'min,' . $this->multiplier . ',*'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . 'max=' . $ds . $i . 'max,' . $this->multiplier . ',*'; + } elseif ($this->divider !== null) { + $g_defname = $ds . '_cdef'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . '=' . $ds . $i . ',' . $this->divider . ',/'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . 'min=' . $ds . $i . 'min,' . $this->divider . ',/'; + $rrd_options[] = 'CDEF:' . $g_defname . $i . 'max=' . $ds . $i . 'max,' . $this->divider . ',/'; + } + + if ($this->textOrig) { + $t_defname = $ds; + } else { + $t_defname = $g_defname; + } + + $rrd_options[] = 'AREA:' . $g_defname . $i . '#' . $colour . ':' . $descr . "$stack"; + + $rrd_options[] = 'GPRINT:' . $t_defname . $i . ':LAST:%5.' . $float_precision . 'lf%s'; + $rrd_options[] = 'GPRINT:' . $t_defname . $i . 'min:MIN:%5.' . $float_precision . 'lf%s'; + $rrd_options[] = 'GPRINT:' . $t_defname . $i . 'max:MAX:%5.' . $float_precision . 'lf%s'; + $rrd_options[] = 'GPRINT:' . $t_defname . $i . ':AVERAGE:%5.' . $float_precision . 'lf%s\\n'; + + if (! $this->nototal) { + $rrd_options[] = 'GPRINT:tot' . $ds . $i . ':%6.' . $float_precision . 'lf%s' . Rrd::safeDescr($this->totalUnits); + } + + $rrd_options[] = 'COMMENT:\\n'; + } + + if ($previous) { + if ($this->multiplier !== null) { + $rrd_options[] = 'CDEF:X=' . $thingX . $plusesX . ',' . $this->multiplier . ',*'; + } elseif ($this->divider !== null) { + $rrd_options[] = 'CDEF:X=' . $thingX . $plusesX . ',' . $this->divider . ',/'; + } else { + $rrd_options[] = 'CDEF:X=' . $thingX . $plusesX; + } + + $rrd_options[] = 'AREA:X#99999999:'; + $rrd_options[] = 'LINE1.25:X#666666:'; + } + + return $rrd_options; + } +} diff --git a/LibreNMS/Data/Graphing/GraphFactory.php b/LibreNMS/Data/Graphing/GraphFactory.php new file mode 100644 index 00000000000..d4215a9c51f --- /dev/null +++ b/LibreNMS/Data/Graphing/GraphFactory.php @@ -0,0 +1,55 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Data\Graphing; + +use Illuminate\Support\Str; +use LibreNMS\Exceptions\InvalidGraph; +use LibreNMS\Interfaces\Data\Graphing\GraphInterface; + +class GraphFactory +{ + /** + * @throws InvalidGraph + */ + public function graphFor(string $name, array $vars = []): GraphInterface + { + $vars['type'] ??= $name; + $params = new GraphParameters($vars); + + if (empty($params->type) || empty($params->subtype)) { + throw new InvalidGraph; + } + + // Look for a modern class, e.g. LibreNMS\Graphs\Device\ProcessorGraph + $className = 'LibreNMS\\Graphs\\' . ucfirst($params->type) . '\\' . Str::studly($params->subtype) . 'Graph'; + if (class_exists($className)) { + return app($className, ['vars' => $vars, 'params' => $params]); + } + + return new LegacyGraph($params, $vars); + } +} diff --git a/LibreNMS/Data/Graphing/GraphParameters.php b/LibreNMS/Data/Graphing/GraphParameters.php index b6f84c1dd48..161bc477d84 100644 --- a/LibreNMS/Data/Graphing/GraphParameters.php +++ b/LibreNMS/Data/Graphing/GraphParameters.php @@ -284,8 +284,8 @@ private function graphColors(): array private function extractType(string $type): array { preg_match('/^(?P[A-Za-z0-9]+)_(?P.+)/', $type, $graphtype); - $type = basename($graphtype['type']); - $subtype = basename($graphtype['subtype']); + $type = basename($graphtype['type'] ?? ''); + $subtype = basename($graphtype['subtype'] ?? ''); return [$type, $subtype]; } diff --git a/LibreNMS/Data/Graphing/LegacyGraph.php b/LibreNMS/Data/Graphing/LegacyGraph.php new file mode 100644 index 00000000000..b4a687f5d94 --- /dev/null +++ b/LibreNMS/Data/Graphing/LegacyGraph.php @@ -0,0 +1,192 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Data\Graphing; + +use App\Facades\DeviceCache; +use LibreNMS\Exceptions\InvalidGraph; +use LibreNMS\Exceptions\RrdNotFoundException; + +class LegacyGraph extends AbstractGraph +{ + private readonly string $auth_file; + private readonly string $graph_file; + + private ?string $pageTitle = null; + private ?string $graphTitle = null; + private ?bool $authorized = null; + private array $rrdFiles = []; + private bool $loaded = false; + private array $rrdOptions = []; + + /** + * @param array $vars + * + * @throws InvalidGraph + */ + public function __construct( + GraphParameters $params, + array $vars = [], + ) { + parent::__construct($params, $vars); + + $this->auth_file = base_path("includes/html/graphs/$params->type/auth.inc.php"); + if (! file_exists($this->auth_file)) { + throw new InvalidGraph; + } + + $graph_file = base_path("includes/html/graphs/$params->type/$params->subtype.inc.php"); + if (! file_exists($graph_file)) { + $graph_file = base_path("includes/html/graphs/$params->type/generic.inc.php"); + } + $this->graph_file = $graph_file; + if (! file_exists($this->graph_file)) { + throw new InvalidGraph; + } + } + + private function load(): void + { + if ($this->loaded) { + return; + } + + $previousCwd = getcwd(); + chdir(base_path()); + + try { + include_once base_path('includes/common.php'); + include_once base_path('includes/html/functions.inc.php'); + include_once base_path('includes/dbFacile.php'); + include_once base_path('includes/rewrites.php'); + + if ($this->device->exists) { + DeviceCache::setPrimary($this->device->device_id); + } + + // Local scope variables for the included files + $device = $this->device; + $port = $this->port; + $vars = $this->vars; + + $auth = auth()->guest(); + @include $this->auth_file; + + $this->authorized = $auth; + $this->graphTitle = $graph_title ?? $this->graphTitle; + $this->pageTitle = $title ?? $this->graphTitle; + + if (! $auth) { + $this->loaded = true; + + return; + } + + $graph_params = $this->params; + $type = $graph_params->type; + $subtype = $graph_params->subtype; + $height = $graph_params->height; + $width = $graph_params->width; + $from = $graph_params->from; + $to = $graph_params->to; + $period = $graph_params->period; + $prev_from = $graph_params->prev_from; + $inverse = $graph_params->inverse; + $in = $graph_params->in; + $out = $graph_params->out; + $float_precision = $graph_params->float_precision; + $title = $graph_params->visible('title'); + $nototal = ! $graph_params->visible('total'); + $nodetails = ! $graph_params->visible('details'); + $noagg = ! $graph_params->visible('aggregate'); + + $rrd_options = []; + + @include $this->graph_file; + + $this->rrdOptions = $rrd_options; + + if (isset($rrd_list) && is_array($rrd_list)) { + $this->rrdFiles = array_column($rrd_list, 'filename'); + } elseif (isset($rrd_filenames) && is_array($rrd_filenames)) { + $this->rrdFiles = $rrd_filenames; + } else { + $this->rrdFiles = isset($rrd_filename) ? [$rrd_filename] : []; + } + + $this->loaded = true; + } catch (RrdNotFoundException) { + // + } finally { + if ($previousCwd !== false) { + chdir($previousCwd); + } + } + } + + public function authorize(): bool + { + $this->load(); + + return $this->authorized; + } + + public function rrdDefinition(): array + { + $this->load(); + + return $this->rrdOptions; + } + + public function getPageTitle(): string + { + $this->load(); + + return $this->pageTitle ?? $this->getGraphTitle(); + } + + public function getGraphTitle(): string + { + $this->load(); + + if ($this->graphTitle !== null) { + return $this->graphTitle; + } + + if ($this->port) { + return $this->port->device?->display . ' :: ' . $this->port->getDescription(); + } + + return $this->device->display ?? ''; + } + + public function getRrdFiles(): array + { + $this->load(); + + return $this->rrdFiles; + } +} diff --git a/LibreNMS/Exceptions/InvalidGraph.php b/LibreNMS/Exceptions/InvalidGraph.php new file mode 100644 index 00000000000..99bde5b5423 --- /dev/null +++ b/LibreNMS/Exceptions/InvalidGraph.php @@ -0,0 +1,37 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Exceptions; + +use Throwable; + +class InvalidGraph extends RrdException +{ + public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message ?: 'Invalid Graph', $code, $previous); + } +} diff --git a/LibreNMS/Graphs/Device/NetstatIpGraph.php b/LibreNMS/Graphs/Device/NetstatIpGraph.php new file mode 100644 index 00000000000..149375a8b23 --- /dev/null +++ b/LibreNMS/Graphs/Device/NetstatIpGraph.php @@ -0,0 +1,44 @@ +device); + } + + public function rrdDefinition(): array + { + $rrd_file = Rrd::name($this->device->hostname, 'netstats-ip'); + + return (new MultiLineGraphBuilder()) + ->scaleMin(0) + ->noTotal() + ->colours('mixed') + ->addDataset($rrd_file, 'ipForwDatagrams', 'Fwd Datagrams') + ->addDataset($rrd_file, 'ipInDelivers', 'In Delivers') + ->addDataset($rrd_file, 'ipInReceives', 'In Receives') + ->addDataset($rrd_file, 'ipOutRequests', 'Out Requests', invert: true) + ->addDataset($rrd_file, 'ipInDiscards', 'In Discards') + ->addDataset($rrd_file, 'ipOutDiscards', 'Out Discards', invert: true) + ->addDataset($rrd_file, 'ipOutNoRoutes', 'Out No Routes', invert: true) + ->build($this->params); + } + + public function getGraphTitle(): string + { + return $this->device->display . ' :: IP NetStats'; + } + + public function getRrdFiles(): array + { + return [Rrd::name($this->device->hostname, 'netstats-ip')]; + } +} diff --git a/LibreNMS/Graphs/Device/ProcessorGraph.php b/LibreNMS/Graphs/Device/ProcessorGraph.php new file mode 100644 index 00000000000..d17e5ed7fe2 --- /dev/null +++ b/LibreNMS/Graphs/Device/ProcessorGraph.php @@ -0,0 +1,127 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Graphs\Device; + +use App\Facades\LibrenmsConfig; +use App\Facades\Rrd; +use App\Models\Processor; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; +use LibreNMS\Data\Graphing\AbstractGraph; +use LibreNMS\Data\Graphing\Builders\MultiLineGraphBuilder; +use LibreNMS\Data\Graphing\Builders\MultiSimplexSeparatedGraphBuilder; + +class ProcessorGraph extends AbstractGraph +{ + public string $type = 'device'; + public string $subtype = 'processor'; + + /** @var Collection */ + private Collection $processors; + + protected function init(): void + { + $this->processors = $this->device->exists ? $this->device->processors : new Collection; + } + + public function authorize(): bool + { + if ($processor = $this->processors->first()) { + return Gate::allows('view', $processor); + } + + return $this->device->exists && Gate::allows('view', $this->device); + } + + public function getGraphTitle(): string + { + return $this->device->display ?? ''; + } + + public function getRrdFiles(): array + { + $files = []; + foreach ($this->processors as $proc) { + $files[] = Rrd::name($this->device->hostname, ['processor', $proc->processor_type, $proc->processor_index]); + } + + return $files; + } + + public function rrdDefinition(): array + { + if ($this->processors->isEmpty()) { + throw new \LibreNMS\Exceptions\RrdGraphException('No Processors'); + } + + // Filter valid datasets and run checkRrdExists exactly once per processor + $valid_datasets = []; + foreach ($this->processors as $proc) { + $rrd_filename = Rrd::name($this->device->hostname, ['processor', $proc->processor_type, $proc->processor_index]); + if (Rrd::checkRrdExists($rrd_filename)) { + $valid_datasets[] = [ + 'filename' => $rrd_filename, + 'descr' => $proc->getFormattedDescription(), + ]; + } + } + + $rrd_count = count($valid_datasets); + + if (LibrenmsConfig::getOsSetting($this->device->os, 'processor_stacked')) { + $builder = (new MultiSimplexSeparatedGraphBuilder()) + ->unitText('Load %') + ->totalUnits('%') + ->colours('oranges') + ->scaleMin(0) + ->scaleMax(100) + ->divider(max(1, $rrd_count)) + ->textOrig() + ->noTotal(); + + foreach ($valid_datasets as $dataset) { + $builder->addDataset($dataset['filename'], 'usage', $dataset['descr']); + } + + return $builder->build($this->params); + } + + $builder = (new MultiLineGraphBuilder()) + ->unitText('Load %') + ->units('') + ->colours('mixed') + ->scaleMin(0) + ->scaleMax(100) + ->noTotal(); + + foreach ($valid_datasets as $dataset) { + $builder->addDataset($dataset['filename'], 'usage', $dataset['descr'], area: true); + } + + return $builder->build($this->params); + } +} diff --git a/LibreNMS/Interfaces/Data/Graphing/GraphInterface.php b/LibreNMS/Interfaces/Data/Graphing/GraphInterface.php new file mode 100644 index 00000000000..b67c8cb4b92 --- /dev/null +++ b/LibreNMS/Interfaces/Data/Graphing/GraphInterface.php @@ -0,0 +1,46 @@ +. + * + * @link https://www.librenms.org + * + * @copyright 2026 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Interfaces\Data\Graphing; + +use LibreNMS\Data\Graphing\GraphParameters; + +interface GraphInterface +{ + public function authorize(): bool; + + public function validation(): array; + + public function rrdDefinition(): array; + + public function getParams(): GraphParameters; + + public function getPageTitle(): string; + + public function getGraphTitle(): string; + + public function getRrdFiles(): array; +} diff --git a/LibreNMS/Util/Graph.php b/LibreNMS/Util/Graph.php index 65a812c82ac..255efa94cc4 100644 --- a/LibreNMS/Util/Graph.php +++ b/LibreNMS/Util/Graph.php @@ -26,14 +26,14 @@ namespace LibreNMS\Util; -use App\Facades\DeviceCache; use App\Facades\LibrenmsConfig; use App\Models\Device; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Validator; +use LibreNMS\Data\Graphing\GraphFactory; use LibreNMS\Data\Graphing\GraphImage; -use LibreNMS\Data\Graphing\GraphParameters; use LibreNMS\Enum\ImageFormat; +use LibreNMS\Exceptions\InvalidGraph; use LibreNMS\Exceptions\RrdGraphException; use Rrd; @@ -99,28 +99,32 @@ public static function getImage($vars): GraphImage * @return GraphImage * * @throws RrdGraphException + * @throws InvalidGraph */ - public static function get($vars): GraphImage + public static function get(array|string $vars): GraphImage { - $graph_params = new GraphParameters(is_string($vars) ? Url::parseLegacyPathVars($vars) : $vars); + $vars = is_string($vars) ? Url::parseLegacyPathVars($vars) : $vars; + + $graph = app(GraphFactory::class)->graphFor($vars['type'] ?? '', $vars); + $params = $graph->getParams(); + $rrd_options = self::getRrdOptions($vars, $rrd_filename); // Generating the graph! try { - $image_data = Rrd::graph($rrd_options); - - return new GraphImage($graph_params->imageFormat, $graph_params->getTitle(), $image_data); + return new GraphImage($params->imageFormat, $graph->getGraphTitle(), Rrd::graph($rrd_options)); } catch (RrdGraphException $e) { - // preserve original error if debug is enabled, otherwise make it a little more user friendly if (Debug::isEnabled()) { throw $e; } - if (isset($rrd_filename) && ! Rrd::checkRrdExists($rrd_filename)) { - throw new RrdGraphException('No Data file' . basename($rrd_filename), 'No Data', $graph_params->width, $graph_params->height, $e->getCode(), $e->getImage()); + foreach ($graph->getRrdFiles() as $filename) { + if (! Rrd::checkRrdExists($filename)) { + throw new RrdGraphException('No Data file' . basename($filename), 'No Data', $params->width, $params->height, $e->getCode(), $e->getImage()); + } } - throw new RrdGraphException('Error: ' . $e->getMessage(), 'Draw Error', $graph_params->width, $graph_params->height, $e->getCode(), $e->getImage()); + throw new RrdGraphException('Error: ' . $e->getMessage(), 'Draw Error', $params->width, $params->height, $e->getCode(), $e->getImage()); } } @@ -132,82 +136,33 @@ public static function get($vars): GraphImage * @return array * * @throws RrdGraphException + * @throws InvalidGraph */ - public static function getRrdOptions($vars, ?string &$rrd_filename = null): array + public static function getRrdOptions(array|string $vars, ?string &$rrd_filename = null): array { - if (! defined('IGNORE_ERRORS')) { - define('IGNORE_ERRORS', true); - } + $vars = is_string($vars) ? Url::parseLegacyPathVars($vars) : $vars; - $previousCwd = getcwd(); - chdir(base_path()); + $graph = app(GraphFactory::class)->graphFor($vars['type'] ?? '', $vars); + $params = $graph->getParams(); - try { - include_once base_path('includes/dbFacile.php'); - include_once base_path('includes/common.php'); - include_once base_path('includes/html/functions.inc.php'); - include_once base_path('includes/rewrites.php'); - - // handle possible graph url input - if (is_string($vars)) { - $vars = Url::parseLegacyPathVars($vars); - } - - // variables for included graphs - $graph_params = new GraphParameters($vars); - $type = $graph_params->type; - $subtype = $graph_params->subtype; + if ($rules = $graph->validation()) { + Validator::validate($vars, $rules); + } - $deviceId = $vars['device'] ?? ($type === 'device' ? ($vars['id'] ?? null) : null); - if ($deviceId) { - $device = DeviceCache::get($deviceId); - DeviceCache::setPrimary($device->device_id); - } + if (! $graph->authorize()) { + throw new RrdGraphException('No Authorization', 'No Auth', $params->width, $params->height); + } - $height = $graph_params->height; - $width = $graph_params->width; - $from = $graph_params->from; - $to = $graph_params->to; - $period = $graph_params->period; - $prev_from = $graph_params->prev_from; - $inverse = $graph_params->inverse; - $in = $graph_params->in; - $out = $graph_params->out; - $float_precision = $graph_params->float_precision; - $title = $graph_params->visible('title'); - $nototal = ! $graph_params->visible('total'); - $nodetails = ! $graph_params->visible('details'); - $noagg = ! $graph_params->visible('aggregate'); - - $rrd_options = []; - $rrd_filename = null; - - $auth = Auth::guest(); // if user not logged in, assume we authenticated via signed url, allow_unauth_graphs or allow_unauth_graphs_cidr - require base_path("/includes/html/graphs/$type/auth.inc.php"); - if (! $auth) { - // We are unauthenticated :( - throw new RrdGraphException('No Authorization', 'No Auth', $width, $height); - } + $rrd_options = $graph->rrdDefinition(); - if (is_file(base_path("/includes/html/graphs/$type/$subtype.inc.php"))) { - require base_path("/includes/html/graphs/$type/$subtype.inc.php"); - } elseif (is_file(base_path("/includes/html/graphs/$type/generic.inc.php"))) { - require base_path("/includes/html/graphs/$type/generic.inc.php"); - } else { - throw new RrdGraphException("{$type}_$subtype template missing", "{$type}_$subtype missing", $width, $height); - } + if (empty($rrd_options)) { + throw new RrdGraphException('Graph Definition Error', 'Def Error', $params->width, $params->height); + } - if (empty($rrd_options)) { // @phpstan-ignore empty.variable ($rrd_options is populated by included graph templates) - throw new RrdGraphException('Graph Definition Error', 'Def Error', $width, $height); - } + $rrdFiles = $graph->getRrdFiles(); + $rrd_filename = reset($rrdFiles) ?: null; - // @phpstan-ignore deadCode.unreachable ($rrd_options is populated by included graph templates, so this is reachable) - return [...$graph_params->toRrdOptions(), ...$rrd_options]; - } finally { - if ($previousCwd !== false) { - chdir($previousCwd); - } - } + return [...$params->toRrdOptions(), ...$rrd_options]; } public static function getTypes(): array diff --git a/app/Console/Commands/GraphDetail.php b/app/Console/Commands/GraphDetail.php new file mode 100644 index 00000000000..6a61141b221 --- /dev/null +++ b/app/Console/Commands/GraphDetail.php @@ -0,0 +1,36 @@ +option('device') ?: Device::limit(1)->value('device_id')); + + $graph = $graphs->graphFor($this->argument('name'), [ + 'type' => $this->argument('name'), + 'device' => $device->device_id, + ]); + + $trim = $this->option('full') ? null : 80; + + $this->line("Type: {$graph->getParams()->title}"); + $this->line("Subtype: {$graph->getParams()->subtype}"); + $this->line('Authorized: ' . ($graph->authorize() ? 'true' : 'false')); + $this->line('Graph Title: ' . $graph->getGraphTitle()); + $this->line('Page Title: ' . substr($graph->getPageTitle(), 0, $trim)); + $this->line('Rrd Files: ' . implode(', ', array_map(fn ($f) => basename($f), $graph->getRrdFiles()))); + $this->line('Definition: ' . substr(implode(' ', $graph->rrdDefinition()), $trim)); + + return 0; + } +} diff --git a/app/Models/Processor.php b/app/Models/Processor.php index 6c539cdd3f8..26d9502d8eb 100644 --- a/app/Models/Processor.php +++ b/app/Models/Processor.php @@ -18,7 +18,7 @@ class Processor extends DeviceRelatedModel * * @return string */ - public function getFormattedDescription() + public function getFormattedDescription(): string { $bad_descr = [ 'GenuineIntel:', @@ -32,8 +32,6 @@ public function getFormattedDescription() $descr = str_replace($bad_descr, '', $this->processor_descr); // reduce extra spaces - $descr = str_replace(' ', ' ', $descr); - - return $descr; + return str_replace(' ', ' ', $descr); } }