From 77022511330e09a72b6bebe659b482807fad020e Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:01 +1030 Subject: [PATCH 01/46] Allow link comments to follow link curves --- CHANGELOG.md | 2 + docs/pages/config-reference.html | 11 +++ lib/WeatherMap.class.php | 1 + lib/WeatherMap.keywords.inc.php | 3 + lib/WeatherMapLink.class.php | 117 +++++++++++++++++++++++++++ lib/editor.actions.php | 2 +- lib/editor.comment-style.php | 7 +- locales/po/cacti.pot | 26 ++++-- tests/Unit/CurvedLinkCommentTest.php | 79 ++++++++++++++++++ 9 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 tests/Unit/CurvedLinkCommentTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..230f578c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* feature: Add default-on COMMENTCURVE and a Map Style option for comments following link curves, with straight-text fallbacks + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/docs/pages/config-reference.html b/docs/pages/config-reference.html index f395459f..d4aa339e 100644 --- a/docs/pages/config-reference.html +++ b/docs/pages/config-reference.html @@ -84,6 +84,7 @@

Link-specific Configuration Directives

COMMENTFONT COMMENTPOS COMMENTSTYLE + COMMENTCURVE DUPLEX INBWFORMAT INCOMMENT @@ -1710,6 +1711,16 @@

Change History

+
+ +
COMMENTCURVE 0|1
+

Use 1 to draw link comments along the link's curve, + including curved VIA points. The default is 1 (follow link curve); use 0 for straight comments. This requires + a TrueType COMMENTFONT. Text stays upright and uses COMMENTPOS and COMMENTSTYLE; + tight bends, multiline comments, unsupported fonts and text longer than the link + fall back to straight comments. In the editor, use Map Style > Link Defaults + > Follow link curve.

+
COMMENTSTYLE edge diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index b7e5c672..b280a9ae 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -2763,6 +2763,7 @@ function ReadConfig($input, $is_include = false) { ['LINK', '/^\s*OUTCOMMENT\s+(.*)\s*$/i', ['comments[OUT]' => 1]], ['LINK', '/^\s*BWFONT\s+(\d+)\s*$/i', ['bwfont' => 1]], ['LINK', '/^\s*COMMENTFONT\s+(\d+)\s*$/i', ['commentfont' => 1]], + ['LINK', '/^\s*COMMENTCURVE\s+([01])\s*$/i', ['commentcurve' => 1]], ['LINK', '/^\s*COMMENTSTYLE\s+(edge|center)\s*$/i', ['commentstyle' => 1]], ['LINK', '/^\s*DUPLEX\s+(full|half)\s*$/i', ['duplex' => 1]], ['LINK', '/^\s*BWSTYLE\s+(classic|angled)\s*$/i', ['labelboxstyle' => 1]], diff --git a/lib/WeatherMap.keywords.inc.php b/lib/WeatherMap.keywords.inc.php index 24d77dbe..fe0dc303 100644 --- a/lib/WeatherMap.keywords.inc.php +++ b/lib/WeatherMap.keywords.inc.php @@ -757,6 +757,9 @@ ['linkstyle' => 1] ], ], + 'COMMENTCURVE' => [ + ['LINK', '/^COMMENTCURVE\s+([01])\s*$/i', ['commentcurve' => 1]], + ], 'COMMENTSTYLE' => [ [ 'LINK', diff --git a/lib/WeatherMapLink.class.php b/lib/WeatherMapLink.class.php index 4dcf2796..8d89c48c 100644 --- a/lib/WeatherMapLink.class.php +++ b/lib/WeatherMapLink.class.php @@ -166,6 +166,7 @@ class WeatherMapLink extends WeatherMapItem { var $commentfontcolour; /** @var mixed */ var $commentstyle; + var $commentcurve; /** @var mixed */ var $bwfontcolour; @@ -204,6 +205,7 @@ function __construct() { 'commentoffset_out' => 5, 'commentoffset_in' => 95, 'commentstyle' => 'edge', + 'commentcurve' => 1, 'arrowstyle' => 'classic', 'viastyle' => 'curved', 'usescale' => 'DEFAULT', @@ -307,6 +309,116 @@ function CopyFrom(&$source) { } } + /** + * Draw a comment along the sampled link spine. Return false before drawing + * anything when the font or geometry requires the ordinary straight label. + */ + function DrawCurvedComment($image, $comment, $colour, $width, $percent) { + $font = $this->owner->fonts[$this->commentfont] ?? null; + if (!$font || $font->type !== 'truetype' || strpbrk($comment, "\r\n") !== false) { + return false; + } + $glyphs = []; + if (!preg_match_all('/\X/u', $comment, $matches) || count($matches[0]) > 512) { + return false; + } + // Rebuild distances, discarding repeated VIA points to avoid zero tangents. + $points = []; + $total = 0.0; + foreach ($this->curvepoints as $point) { + if ($points) { + $last = $points[count($points) - 1]; + $step = hypot($point[0] - $last[0], $point[1] - $last[1]); + if ($step < 0.001) { + continue; + } + $total += $step; + } + $points[] = [$point[0], $point[1], $total]; + } + if (count($points) < 2 || $total < 1) { + return false; + } + $at = static function ($distance) use ($points, $total) { + $distance = max(0, min($total, $distance)); + for ($i = 1; $i < count($points); $i++) { + if ($distance <= $points[$i][2]) { + $a = $points[$i - 1]; + $b = $points[$i]; + $t = ($distance - $a[2]) / ($b[2] - $a[2]); + return [$a[0] + $t * ($b[0] - $a[0]), $a[1] + $t * ($b[1] - $a[1])]; + } + } + return array_slice($points[count($points) - 1], 0, 2); + }; + $file = wm_font_file($font->file); + // A trailing marker includes space advances, which a glyph's ink box omits. + $marker = imagettfbbox($font->size, 0, $file, '|'); + if ($marker === false) { + return false; + } + $prefix = ''; + $length = 0.0; + foreach ($matches[0] as $glyph) { + $prefix .= $glyph; + $box = imagettfbbox($font->size, 0, $file, $prefix . '|'); + if ($box === false) { + return false; + } + $next = $box[2] - $marker[2]; + $advance = $next - $length; + if ($advance <= 0) { + return false; + } + $glyphs[] = [$glyph, $length, $advance]; + $length = $next; + } + if ($length > $total || $length < 1) { + return false; + } + $anchor = max(0, min($total, $total * $percent / 100)); + $a = $at($anchor - 1); + $b = $at($anchor + 1); + $reverse = $b[0] < $a[0]; + $sign = $reverse ? -1 : 1; + $anchor += $sign * (int)$this->get_hint('comment_nudgealong'); + $anchor = $reverse ? max($length, min($total, $anchor)) : max(0, min($total - $length, $anchor)); + [, $height] = $this->owner->myimagestringsize($this->commentfont, $comment); + $offset = (int)$this->get_hint('comment_nudgeout'); + $offset += $this->commentstyle === 'center' ? -$height / 2 : $width + 4; + $layout = []; + $previous = null; + foreach ($glyphs as [$glyph, $start, $advance]) { + $distance = $anchor + $sign * ($start + $advance / 2); + $p = $at($distance); + $a = $at($distance - max(1, $advance / 2)); + $b = $at($distance + max(1, $advance / 2)); + $dx = ($b[0] - $a[0]) * $sign; + $dy = ($b[1] - $a[1]) * $sign; + $norm = hypot($dx, $dy); + if ($norm < 0.001) { + return false; + } + $dx /= $norm; + $dy /= $norm; + // Keep the whole word upright; reject abrupt bends between letters. + if ($dx < -0.05 || ($previous && $dx * $previous[0] + $dy * $previous[1] < cos(deg2rad(35)))) { + return false; + } + $x = $p[0] + $dy * $offset; + $y = $p[1] - $dx * $offset; + if ($previous && hypot($x - $previous[2], $y - $previous[3]) < 0.65 * ($advance + $previous[4]) / 2) { + return false; + } + $layout[] = [$x - $dx * $advance / 2, $y - $dy * $advance / 2, $glyph, rad2deg(atan2(-$dy, $dx))]; + $previous = [$dx, $dy, $x, $y, $advance]; + } + foreach ($layout as [$x, $y, $glyph, $angle]) { + $this->owner->myimagestring($image, $this->commentfont, $x, $y, $glyph, $colour, $angle); + } + return true; + } + /** * image = GD image references * col = array of Colour objects @@ -351,6 +463,10 @@ function DrawComments($image, $col, $widths) { if ($comment != '') { // print "\n\n----------------------------------------------------------------\nComment $dir for ".$this->name."\n"; + if ($this->commentcurve && $this->DrawCurvedComment($image, $comment, $col[$dir], $widths[$dir], $commentpos[$dir])) { + continue; + } + [$textlength, $textheight] = $this->owner->myimagestringsize($this->commentfont, $comment); $extra_percent = $commentpos[$dir]; @@ -714,6 +830,7 @@ function WriteConfig() { ['splitpos', 'SPLITPOS', CONFIG_TYPE_LITERAL], ['duplex', 'DUPLEX', CONFIG_TYPE_LITERAL], ['commentstyle', 'COMMENTSTYLE', CONFIG_TYPE_LITERAL], + ['commentcurve', 'COMMENTCURVE', CONFIG_TYPE_LITERAL], ['labelboxstyle', 'BWSTYLE', CONFIG_TYPE_LITERAL], // ['usescale', 'USESCALE', CONFIG_TYPE_LITERAL], diff --git a/lib/editor.actions.php b/lib/editor.actions.php index a315941a..6cccd520 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -613,7 +613,7 @@ function setMapStyle($mapfile) { handle_inheritance($map, $inheritables); wm_comment_update_default($map, 'bwfont', wm_comment_resolve_font($map, get_nfilter_request_var('mapstyle_linkfont'))); - wm_comment_apply_style($map, get_nfilter_request_var('mapstyle_commentfont'), get_nfilter_request_var('mapstyle_commentcolour')); + wm_comment_apply_style($map, get_nfilter_request_var('mapstyle_commentfont'), get_nfilter_request_var('mapstyle_commentcolour'), isset($_POST['mapstyle_commentcurve']) ? get_nfilter_request_var('mapstyle_commentcurve') : '0'); if (!wm_editor_write_config($map, $mapfile)) return; } diff --git a/lib/editor.comment-style.php b/lib/editor.comment-style.php index c93be069..c67e4da6 100644 --- a/lib/editor.comment-style.php +++ b/lib/editor.comment-style.php @@ -162,6 +162,8 @@ function wm_comment_style_fields($map) { } $out .= ''; + $out .= '
' . html_escape(__('Uses a TrueType font. Tight bends or text longer than the link use straight text.', 'weathermap')) . ''; + return $out; } /** @@ -270,7 +272,10 @@ function wm_comment_update_default($map, $field, $new) { * * @return void */ -function wm_comment_apply_style($map, $font, $colour) { +function wm_comment_apply_style($map, $font, $colour, $curve = null) { + if ($curve === '0' || $curve === '1') { + wm_comment_update_default($map, 'commentcurve', (int)$curve); + } wm_comment_update_default($map, 'commentfont', wm_comment_resolve_font($map, $font)); wm_comment_update_default($map, 'commentfontcolour', wm_comment_resolve_colour($colour)); } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18edb16a..b33cca6b 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR The Cacti Group -# This file is distributed under the same license as the Cacti package. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti 1.7\n" -"Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 10:19+1030\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2684,3 +2684,15 @@ msgstr "" #: weathermap-cacti-plugin.php msgid "Paused" msgstr "" + +#: lib/editor.comment-style.php +msgid "Link Comments" +msgstr "" + +#: lib/editor.comment-style.php +msgid "Follow link curve" +msgstr "" + +#: lib/editor.comment-style.php +msgid "Uses a TrueType font. Tight bends or text longer than the link use straight text." +msgstr "" diff --git a/tests/Unit/CurvedLinkCommentTest.php b/tests/Unit/CurvedLinkCommentTest.php new file mode 100644 index 00000000..5088992d --- /dev/null +++ b/tests/Unit/CurvedLinkCommentTest.php @@ -0,0 +1,79 @@ +drawn[] = [$x, $y, $string, $angle]; + } + }; + self::assertEquals(1, $map->links['DEFAULT']->commentcurve); + self::assertStringContainsString('value="1" checked', wm_comment_style_fields($map)); + wm_comment_apply_style($map, 'bundled:Vera.ttf:13', '0 0 255', '1'); + $link = new WeatherMapLink(); + $link->Reset($map); + $link->curvepoints = []; + for ($i = 0; $i <= 120; $i++) { + $t = $i / 120 * M_PI / 2; + $link->curvepoints[] = [100 + 220 * sin($t), 320 - 220 * cos($t), 220 * $t]; + } + self::assertTrue($link->DrawCurvedComment(null, '5G (Backup)', 0, 7, 20)); + self::assertSame('5G (Backup)', implode('', array_column($map->drawn, 2))); + $angles = array_column($map->drawn, 3); + self::assertGreaterThan(20, max($angles) - min($angles)); + foreach ($angles as $angle) { + self::assertLessThanOrEqual(93, abs($angle)); + } + // Reversing the spine must still produce an upright, complete label. + $link->curvepoints = array_reverse($link->curvepoints); + $map->drawn = []; + self::assertTrue($link->DrawCurvedComment(null, '5G (Backup)', 0, 7, 60)); + self::assertSame('5G (Backup)', implode('', array_column($map->drawn, 2))); + self::assertLessThanOrEqual(93, max(array_map('abs', array_column($map->drawn, 3)))); + // Unsupported fonts, multiline and short geometry fall back atomically. + $map->drawn = []; + self::assertFalse($link->DrawCurvedComment(null, "two\nlines", 0, 7, 30)); + $link->commentfont = 2; + self::assertFalse($link->DrawCurvedComment(null, '5G', 0, 7, 30)); + $link->commentfont = $map->links['DEFAULT']->commentfont; + $link->curvepoints = [[0, 0, 0], [0, 0, 0], [1, 0, 1]]; + self::assertFalse($link->DrawCurvedComment(null, 'too long', 0, 7, 0)); + self::assertSame([], $map->drawn); + $link->curvepoints = [[0, 0, 0], [50, 0, 50], [50, 100, 150]]; + self::assertFalse($link->DrawCurvedComment(null, '5G (Backup)', 0, 7, 0)); + self::assertSame([], $map->drawn); + wm_comment_apply_style($map, null, null, 'invalid'); + self::assertSame(1, $map->links['DEFAULT']->commentcurve); + $file = sys_get_temp_dir() . '/wm-curve-' . bin2hex(random_bytes(8)) . '.conf'; + try { + $map->WriteConfig($file); + self::assertStringNotContainsString('COMMENTCURVE', file_get_contents($file)); + $loaded = new WeatherMap(); + $loaded->ReadConfig($file); + self::assertEquals(1, $loaded->links['DEFAULT']->commentcurve); + self::assertStringContainsString('value="1" checked', wm_comment_style_fields($loaded)); + wm_comment_apply_style($loaded, null, null, '0'); + self::assertSame(0, $loaded->links['DEFAULT']->commentcurve); + $loaded->WriteConfig($file); + self::assertStringContainsString('COMMENTCURVE 0', file_get_contents($file)); + $disabled = new WeatherMap(); + $disabled->ReadConfig($file); + self::assertEquals(0, $disabled->links['DEFAULT']->commentcurve); + self::assertStringNotContainsString('value="1" checked', wm_comment_style_fields($disabled)); + } finally { + @unlink($file); + } + } +} From c5a530cd7e97e22e36e37f617756fa4f53164d63 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:01 +1030 Subject: [PATCH 02/46] Use 1000M as the inherited link bandwidth default --- CHANGELOG.md | 2 ++ lib/WeatherMapLink.class.php | 8 ++++---- locales/po/cacti.pot | 14 +++++++------- tests/Unit/DefaultLinkBandwidthTest.php | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/DefaultLinkBandwidthTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..1df424f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* feature: Default implicit link capacities to 1000M in both directions, while preserving explicitly configured capacities + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/lib/WeatherMapLink.class.php b/lib/WeatherMapLink.class.php index 4dcf2796..daef31c5 100644 --- a/lib/WeatherMapLink.class.php +++ b/lib/WeatherMapLink.class.php @@ -239,10 +239,10 @@ function __construct() { // 'outcomment' => '', 'zorder' => 300, 'overlibcaption' => ['', ''], - 'max_bandwidth_in' => 100000000, - 'max_bandwidth_out' => 100000000, - 'max_bandwidth_in_cfg' => '100M', - 'max_bandwidth_out_cfg' => '100M' + 'max_bandwidth_in' => 1000000000, + 'max_bandwidth_out' => 1000000000, + 'max_bandwidth_in_cfg' => '1000M', + 'max_bandwidth_out_cfg' => '1000M' ]; // $this->a_offset = 'C'; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18edb16a..a89d7aa5 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR The Cacti Group -# This file is distributed under the same license as the Cacti package. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti 1.7\n" -"Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 10:19+1030\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/tests/Unit/DefaultLinkBandwidthTest.php b/tests/Unit/DefaultLinkBandwidthTest.php new file mode 100644 index 00000000..21518392 --- /dev/null +++ b/tests/Unit/DefaultLinkBandwidthTest.php @@ -0,0 +1,14 @@ +Reset($map); + foreach([$map->links['DEFAULT'],$link] as $item){ + self::assertSame(1000000000,$item->max_bandwidth_in);self::assertSame(1000000000,$item->max_bandwidth_out); + self::assertSame('1000M',$item->max_bandwidth_in_cfg);self::assertSame('1000M',$item->max_bandwidth_out_cfg); + } + } +} From 306dabcd9f210f0d3798d1e7616ab16723998365 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:01 +1030 Subject: [PATCH 03/46] Clarify link workflows and add node display-name positioning --- CHANGELOG.md | 2 + css/editor.css | 160 ++++++ js/editor.js | 462 ++++++++++++++---- lib/WeatherMapNode.class.php | 6 + lib/editor.actions.php | 9 +- lib/editor.colours.php | 5 + lib/editor.comment-style.php | 15 +- lib/editor.inc.php | 62 ++- locales/po/cacti.pot | 246 +++++++++- tests/Browser/EditorContextMenuTest.cjs | 3 +- tests/Browser/EditorWorkflowTest.cjs | 230 ++++++++- tests/Browser/LinkSourcePickerTest.cjs | 33 ++ tests/Browser/NodeLabelPositionTest.cjs | 9 + tests/Browser/PickerPaginationTest.cjs | 48 +- .../CommentColourLocationRegression.php | 20 + tests/Support/HoverGraphFilesRegression.php | 17 + tests/Support/NodeLabelPositionRegression.php | 23 + tests/Unit/MapStyleDefaultsTest.php | 6 +- .../Unit/PropertiesWorkflowRegressionTest.php | 29 ++ weathermap-cacti-plugin-editor.php | 42 +- 20 files changed, 1280 insertions(+), 147 deletions(-) create mode 100644 tests/Browser/LinkSourcePickerTest.cjs create mode 100644 tests/Browser/NodeLabelPositionTest.cjs create mode 100644 tests/Support/CommentColourLocationRegression.php create mode 100644 tests/Support/HoverGraphFilesRegression.php create mode 100644 tests/Support/NodeLabelPositionRegression.php create mode 100644 tests/Unit/PropertiesWorkflowRegressionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..98ca09d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* feature: Simplify link interface/source/hover workflows, limit the form to two previews, add node display-name positioning, move comment colours to Manage Colours and clarify raw-edit controls + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/css/editor.css b/css/editor.css index 15c23ebd..e5b0ebf0 100644 --- a/css/editor.css +++ b/css/editor.css @@ -418,3 +418,163 @@ body#mainView .wm-icon-choice[aria-pressed="true"] { background: var(--background-lowlight, #eee); border-color: var(--text-color-highlight-a, #2874aa); } + +/* Link properties has one scrolling body and a persistent action row. */ +#dlgLinkProperties { + overflow: hidden !important; +} +#dlgLinkProperties > .cactiTable { + display: flex; + flex-direction: column; + max-height: calc(85vh - 65px); + min-height: 0; +} +#dlgLinkProperties .dlgBody { + display: block; + overflow-y: auto; + min-height: 0; + padding: 12px; +} +#dlgLinkProperties .dlgButtons { + display: block; + flex: 0 0 auto; + border-top: 1px solid var(--border-color, #888); +} +#dlgLinkProperties .dlgSubButtons { + margin: 10px; + white-space: normal; +} +#dlgLinkProperties .wm-link-group { + margin: 8px 0; + border: 1px solid var(--border-color, #888); + border-radius: 3px; +} +#dlgLinkProperties .wm-link-group > summary { + display: list-item; + list-style-position: inside; + height: auto !important; + min-height: 0; + line-height: 1.4; + margin: 0; + cursor: pointer; + padding: 8px; +} +#dlgLinkProperties .wm-link-group > table { + width: 100%; + table-layout: fixed; +} +#dlgLinkProperties .wm-link-group td:first-child { + width: 30%; +} +#dlgLinkProperties textarea { + box-sizing: border-box; + width: 100%; + min-width: 0; +} +#dlgLinkProperties .wm-link-group-hint, +#dlgLinkProperties .wm-link-source-actions { + margin: 10px; +} +#dlgLinkProperties .dlgHelp { + display: block; + height: auto; + line-height: 1.4; + border: 0; +} +#dlgLinkProperties #wm-link-purpose { + color: inherit !important; + line-height: 1.4; +} + +#dlgLinkProperties .wm-link-field-hint { + display: block; + margin-top: 3px; + font-size: 0.9em; + line-height: 1.3; + color: inherit; +} +#dlgLinkProperties #wm-link-help-button { + min-width: 2em; + font-weight: bold; +} +#dlgLinkProperties #wm-link-full-help { + margin: 10px 0 0; + padding: 8px; + border-top: 1px solid var(--border-color, #888); +} +#dlgLinkProperties #wm-link-rrd { + display: block; + overflow-wrap: anywhere; + color: inherit !important; +} +#dlgLinkProperties #wm-link-graph { display: block; margin: 8px 10px; } + +#dlgLinkProperties .wm-link-source-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +#dlgLinkProperties #wm-link-source-picker { + flex: 1 1 200px; + min-width: 0; + max-width: 100%; + padding: 6px; + font: inherit; + box-sizing: border-box; +} + +#dlgLinkProperties .wm-link-extra-item { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; } +#dlgLinkProperties .wm-link-extra-details { flex: 1 1 180px; min-width: 0; overflow-wrap: anywhere; } +#dlgLinkProperties .wm-link-extra-name { font: inherit; } +#dlgLinkProperties .wm-link-extra-rrds { display: block; margin-top: 3px; font-size: .85em; } +#dlgLinkProperties #wm-link-hover-status { margin: 6px 0; } +#dlgLinkProperties #wm-link-hover-status:empty { display: none; } +#dlgLinkProperties #wm-link-hovers .wm-link-group-hint { font-size: .9em; line-height: 1.4; } + +#dlgLinkProperties .wm-link-source-actions { margin: 0; } +#dlgLinkProperties #wm-link-source-status:empty { display: none; } + +#dlgLinkProperties .wm-link-section-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +#dlgLinkProperties .wm-link-group[hidden] { display: none !important; } + +#dlgLinkProperties .wm-link-source-item { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 6px 0; } +#dlgLinkProperties .wm-link-source-item code { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; } + +#dlgLinkProperties #wm-link-source-status { margin: 6px 0; padding: 6px 8px; border-left: 3px solid currentColor; font-weight: bold; } + +/* Keep node and link action controls the same size while retaining theme colours. */ +#dlgNodeProperties a.ui-button, +#dlgNodeProperties button.ui-button:not(.wm-icon-choice), +#dlgNodeProperties input[type="button"].ui-button, +#dlgLinkProperties a.ui-button, +#dlgLinkProperties button.ui-button, +#dlgLinkProperties input[type="button"].ui-button { + font-family: inherit; + font-size: 11px; + line-height: 1.2; + padding: .4em 1em; + box-sizing: border-box; +} +#dlgLinkProperties .wm-link-section-buttons { align-items: center; } +#dlgLinkProperties .wm-link-section-buttons .ui-button { margin: 0; } + +/* Keep the graph chooser and its actions in a predictable two-line layout. */ +#dlgLinkProperties #wm-link-hovers td { vertical-align: top; padding-top: 6px; padding-bottom: 6px; } +#dlgLinkProperties #wm-link-hovers td:first-child { padding-top: 12px; } +#dlgLinkProperties .wm-link-hover-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +#dlgLinkProperties .wm-link-hover-actions .ui-button { margin: 0; } +#dlgLinkProperties #wm-link-extra-list .wm-link-extra-item { margin: 0; align-items: flex-start; } +#dlgLinkProperties #wm-link-extra-list .wm-link-extra-details { padding-top: 6px; } + +#dlgNodeProperties .dlgSubButtons, +#dlgLinkProperties .dlgSubButtons { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 4px; + margin: 10px; +} +#dlgNodeProperties .dlgSubButtons .ui-button, +#dlgLinkProperties .dlgSubButtons .ui-button { margin-left: 0; } diff --git a/js/editor.js b/js/editor.js index 8a49f886..4bd105c9 100644 --- a/js/editor.js +++ b/js/editor.js @@ -174,53 +174,28 @@ function graphPicker() { dialogForm += ""; dialogForm += " "; dialogForm += " "; - dialogForm += ""; + if (id !== 'link_target_picker') dialogForm += ""; $(this).after(dialogForm); $(this).hide(); + $('#' + id + '_add').val($(this).attr('data-add-label') || 'Add'); + $('#' + id + '_rep').val($(this).attr('data-replace-label') || 'Replace'); $('#' + id + '_add').off('click').on('click', function() { var hover = 'graph_image.php?local_graph_id=';; var infourl = infoUrlTarget; if (id == 'link_target_picker') { - var target = $('#' + id).val(); - var existing = $('#link_target').val(); - - $('#link_target').val(existing + (existing != '' ? ' ':'') + target); - - // Add the graph hovers if possible - var ehover = $('#link_hover').val(); - var einfo = $('#link_infourl').val(); - - if (local_graph_id > 0) { - if (ehover == '') { - $('#link_hover').val(hover + local_graph_id); - } - - if (einfo == '' || infoUrlStyle == 1) { - $('#link_infourl').val(infourl + local_graph_id); - } - } + wmApplyLinkTrafficSource(); } else { var hover = 'graph_image.php?local_graph_id=';; var infourl = infoUrlTarget; if (id == 'link_picker') { - var target = $('#' + id).val(); - var ehover = $('#link_hover').val(); - var einfo = $('#link_infourl').val(); - - if (einfo == '') { - einfo = infourl; - } - - $('#link_hover').val(ehover + (ehover != '' ? ' ':'') + hover + target); - if (infoUrlStyle == 0) { - $('#link_infourl').val(einfo + (einfo != '' ? ',':'') + target); - } else { - $('#link_infourl').val(infourl + target); - } + var target = String($('#' + id).val() || ''); + if (!/^[1-9][0-9]*$/.test(target)) return; + var graphUrl = new URL(hover + target, new URL('../../', window.location.href)); + wmSetLinkExtraHover(wmLinkCompactHoverUrl(graphUrl.pathname + graphUrl.search), false); } else if (id == 'node_picker') { var target = $('#' + id).val(); var ehover = $('#node_hover').val(); @@ -243,13 +218,13 @@ function graphPicker() { $('#' + id + '_rep').off('click').on('click', function() { if (id == 'link_picker') { - $('#link_hover').val('graph_image.php?local_graph_id=' + $('#' + id).val()); - $('#link_infourl').val(infoUrlTarget + $('#' + id).val()); + var target = String($('#' + id).val() || ''); + if (!/^[1-9][0-9]*$/.test(target)) return; + var graphUrl = new URL('graph_image.php?local_graph_id=' + target, new URL('../../', window.location.href)); + wmSetLinkExtraHover(wmLinkCompactHoverUrl(graphUrl.pathname + graphUrl.search), true); } else if (id == 'node_picker') { $('#node_hover').val('graph_image.php?local_graph_id=' + $('#' + id).val()); $('#node_infourl').val(infoUrlTarget + $('#' + id).val()); - } else if (id == 'link_target_picker') { - $('#link_target').val($('#' + id).val()); } }); @@ -274,7 +249,7 @@ function graphPicker() { '&action=' + action + '&term=' + encodeURIComponent(request.term) + '&target=' + id + - '&graph_template_id='+template + '&paged=1&offset=' + pickerOffset; + '&graph_template_id='+template + '&paged=1&include_rrds=' + (id === 'link_picker' ? '1' : '0') + '&offset=' + pickerOffset; $.getJSON(url, function(data) { var items = data.items; @@ -299,6 +274,7 @@ function graphPicker() { return false; } $('#' + id + '_input').val(ui.item.label); + if (id === 'link_picker') $(this).data('wm-graph-choice', ui.item); if (ui.item.id) { $('#' + id).val(ui.item.id); @@ -975,6 +951,13 @@ function form_submit(event) { var noteAction = $('#action').val(); var noteTarget = noteAction === 'set_note_properties' ? $('#note_name').val() : noteAction === 'move_node' ? $('#node_name').val() : $('#param').val(); var savingNote = noteAction === 'set_note_properties' || (['move_node', 'delete_node'].indexOf(noteAction) !== -1 && typeof WMNotes !== 'undefined' && WMNotes[noteTarget]); + if ($('#action').val() === 'set_link_properties') { + wmSyncLinkHoverParts(); + if (wmLinkHoverEntries($('#wm-link-extra-hover').val()).length > 1 || wmLinkHoverEntries($('#link_hover').val()).length > 2) { + window.alert(wmEditorText.hoverLimit); + return; + } + } var data = $('input, select, textarea').serialize(); if (typeof WMConfigRevision !== 'undefined') data += '&config_revision=' + encodeURIComponent(WMConfigRevision); @@ -1106,6 +1089,16 @@ function show_itemtext(itemtype,name) { }); } +// Use existing relative LABELOFFSET syntax so positions scale with the icon. +function wmPrimeNodeLabelPosition(node) { + var offset = String(node.labeloffset || '').toUpperCase(); + var position = (!offset || offset === 'C') && !Number(node.labeloffsetx || 0) && !Number(node.labeloffsety || 0) ? 'C' : + (['N60', 'S60'].includes(offset) && !Number(node.labeloffsetx || 0) && !Number(node.labeloffsety || 0) ? offset : 'keep'); + $('#node_labelposition option[value="keep"]').prop('hidden', position !== 'keep'); + $('#node_labelposition').val(position).trigger('change.select2'); + if ($('#node_labelposition').data('ui-selectmenu')) $('#node_labelposition').selectmenu('refresh'); +} + function prime_node_form(name) { var mynode = Nodes[name]; @@ -1119,6 +1112,7 @@ function prime_node_form(name) { $('#node_name').val(mynode.name); $('#node_new_name').val(mynode.name); $('#node_label').val(mynode.label); + wmPrimeNodeLabelPosition(mynode); $('#node_infourl').val(wmDecodeEditorUrl(mynode.infourl)); $('#node_hover').val(wmDecodeEditorUrl(mynode.overliburl)); @@ -1426,28 +1420,318 @@ function attach_help_events() { } -// Compact link workflow; existing fields remain available under Advanced. +// Compact link workflow; custom configuration fields remain submitted unchanged. +function wmLinkHoverEntries(value) { + return String(value || '').trim().split(/\s+/).filter(Boolean); +} +function wmLinkCompactHoverUrl(value) { + if (!wmLocalGraphIds(value, ['graph_image.php']).length) return value; + var root = new URL('../../', window.location.href); + var url = new URL(value, /^graph_image\.php/.test(value) ? root : window.location.href); + [['rra_id', '0'], ['graph_nolegend', 'true'], ['graph_height', '100'], ['graph_width', '300']].forEach(function(pair) { + if (!url.searchParams.has(pair[0])) url.searchParams.set(pair[0], pair[1]); + }); + return url.pathname + url.search + url.hash; +} +function wmSyncLinkHoverParts() { + if (!$('#wm-link-extra-hover').length) return; + var main = $('#wm-link-primary-hover').val(); + var entries = wmLinkHoverEntries(main).concat(wmLinkHoverEntries($('#wm-link-extra-hover').val())); + $('#link_hover').val(Array.from(new Set(entries)).join(' ')); + wmRenderLinkExtraGraphs(); +} +function wmRenderLinkExtraGraphs() { + var list = $('#wm-link-extra-list'); + if (!list.length) return; + list.empty(); + var count = wmLinkHoverEntries($('#wm-link-extra-hover').val()).length; + $('#link_picker_add').prop('disabled', count >= 1); + $('#wm-link-hover-status').text(count >= 1 ? wmEditorText.hoverLimit : ''); + var table = $('
').appendTo(list); + wmLinkHoverEntries($('#wm-link-extra-hover').val()).forEach(function(url) { + var row = $('').appendTo(table); + $('').text(wmEditorText.additionalGraph).appendTo(row); + var item = $('').appendTo($('').appendTo(row)); + var details = $('').appendTo(item); + var ids = wmLocalGraphIds(url, ['graph_image.php']); + var label = $('').appendTo(details); + var files = $('').appendTo(details); + item.attr('data-hover-url', url); + if (ids.length) { + var selected = String($('#link_picker').val()) === ids[0] ? $('#link_picker_input').val() : ''; + label.text(selected || wmEditorText.currentGraph.replace('%s', ids[0])); + var choice = $('#link_picker_input').data('wm-graph-choice'); + if (choice && String(choice.id) === ids[0]) files.text((choice.rrds || []).join(', ') || wmEditorText.noGraphRrd); + } else label.text(wmEditorText.customHoverGraph); + $('').text(wmEditorText.removeHoverGraph) + .appendTo(item).on('click', function() { + $('#wm-link-extra-hover').val(wmLinkHoverEntries($('#wm-link-extra-hover').val()).filter(function(entry) { return entry !== url; }).join('\n')); + wmSyncLinkHoverParts(); + }); + }); +} +function wmInitLinkHoverParts() { + if (!$('#wm-link-extra-hover').length) return; + var entries = wmLinkHoverEntries($('#link_hover').val()).map(wmLinkCompactHoverUrl); + $('#wm-link-primary-hover').val(entries.shift() || ''); + $('#wm-link-extra-hover').val(entries.join('\n')).off('.wmHovers').on('input.wmHovers change.wmHovers', wmSyncLinkHoverParts); + wmSyncLinkHoverParts(); +} +function wmSetLinkPrimaryHover(value) { + if (!$('#wm-link-extra-hover').length) { + $('#link_hover').val(value); + return; + } + $('#wm-link-primary-hover').val(value); + var ids = wmLocalGraphIds(value, ['graph_image.php']); + var extras = wmLinkHoverEntries($('#wm-link-extra-hover').val()).filter(function(entry) { + return !ids.some(function(id) { return wmLocalGraphIds(entry, ['graph_image.php']).includes(id); }); + }); + $('#wm-link-extra-hover').val(extras.join('\n')); + wmSyncLinkHoverParts(); +} +function wmPromoteLinkTrafficHover(graphId) { + if (!$('#wm-link-extra-hover').length || !/^[1-9][0-9]*$/.test(String(graphId))) return; + var main = $('#wm-link-primary-hover').val(); + var extras = wmLinkHoverEntries($('#wm-link-extra-hover').val()); + if (!main && !extras.length) return; + if (wmLocalGraphIds(main, ['graph_image.php']).includes(String(graphId))) return; + var traffic = extras.find(function(entry) { + return wmLocalGraphIds(entry, ['graph_image.php']).includes(String(graphId)); + }); + if (main) extras.unshift(main); + $('#wm-link-extra-hover').val(extras.join('\n')); + wmSetLinkPrimaryHover(traffic || wmLinkCompactHoverUrl(new URL('../../', window.location.href).pathname + 'graph_image.php?local_graph_id=' + graphId)); +} +function wmSetLinkExtraHover(value, replace) { + var field = $('#wm-link-extra-hover'); + if (!field.length) field = $('#link_hover'); + var ids = wmLocalGraphIds(value, ['graph_image.php']); + var primary = wmLocalGraphIds($('#wm-link-primary-hover').val() || '', ['graph_image.php']); + if (ids.some(function(id) { return primary.includes(id); })) return; + var entries = replace ? [] : wmLinkHoverEntries(field.val()); + if (!entries.some(function(entry) { + return entry === value || ids.some(function(id) { return wmLocalGraphIds(entry, ['graph_image.php']).includes(id); }); + })) { + if (entries.length >= 1) { + $('#wm-link-hover-status').text(wmEditorText.hoverLimit); + return; + } + entries.push(value); + } + field.val(entries.join('\n')); + wmSyncLinkHoverParts(); +} + +function wmRenderLinkTrafficSources() { + var list = $('#wm-link-measured-sources'); + if (!list.length) return; + var entries = wmLinkHoverEntries($('#link_target').val()); + list.empty(); + if (!$('#wm-link-source-count').length) $('').insertAfter('#wm-link-current'); + $('#wm-link-source-count').text(entries.length > 1 ? wmEditorText.combinedSources.replace('%s', String(entries.length)) : ''); + $('#wm-link-rrd').text((entries[0] || '').split('/').pop()); + if (!entries.length) $('').text(wmEditorText.noMeasuredSources).appendTo(list); + entries.forEach(function(source, index) { + var row = $('').appendTo(list); + $('').text(source).appendTo(row); + if (index === 0) { + $('').text(wmEditorText.primarySource).appendTo(row); + return; + } + $('') + .text(wmEditorText.removeSource).attr('aria-label', wmEditorText.removeSource + ': ' + source) + .appendTo(row).on('click', function() { + var current = wmLinkHoverEntries($('#link_target').val()); + if (current.join(' ') !== entries.join(' ')) { wmRenderLinkTrafficSources(); return; } + current.splice(index, 1); + $('#link_target').val(current.join(' ')); + wmRenderLinkTrafficSources(); + $('#wm-link-source-status').text(wmEditorText.sourceRemoved); + var next = list.find('button').eq(Math.min(index - 1, current.length - 2)); + if (next.length) next.trigger('focus'); + else $('#link_target').trigger('focus'); + }); + }); +} + +function wmApplyLinkTrafficSource() { + var entries = wmLinkHoverEntries($('#link_target').val()); + if (!entries.length) { + $('#wm-link-source-status').text(wmEditorText.choosePrimaryFirst); + return; + } + var item = $('#wm-link-source-picker').data('wm-choice'); + if (!item || !item.id || !(item.local_graph_id > 0)) { + $('#wm-link-source-status').text(wmEditorText.chooseSourceFirst); + return; + } + var duplicate = entries.includes(String(item.id)); + if (!duplicate) entries.push(String(item.id)); + $('#link_target').val(entries.join(' ')); + wmRenderLinkTrafficSources(); + $('#wm-link-source-status').text(duplicate ? wmEditorText.sourceAlreadyAdded : wmEditorText.sourceAdded); +} +function wmInitLinkSourcePicker(container) { + var picker = $('') + .attr({'aria-label': wmEditorText.chooseSource, placeholder: wmEditorText.searchInterface}).prependTo(container); + var offset = 0, term = '', generation = 0; + var buttons = $('#link_target_picker_add'); + picker.autocomplete({ + minLength: 0, autoFocus: false, + source: function(request, response) { + if (request.term !== term) offset = 0; + term = request.term; + var ticket = ++generation, link = $('#link_name').val(), map = $('#mapname').val(); + $.getJSON(window.location.pathname, {action:'datasources', target:'link_target_picker', mapname:map, graph_template_id:-1, paged:1, offset:offset, term:term}) + .done(function(data) { + if (ticket !== generation || $('#link_name').val() !== link || $('#mapname').val() !== map) return response([]); + var items = data.items.slice(); + if (data.offset > 0) items.unshift({label:data.previous_label, value:term, wmOffset:Math.max(0,data.offset-100)}); + if (data.next_offset != null) items.push({label:data.next_label, value:term, wmOffset:data.next_offset}); + response(items); + }).fail(function() { response([]); }); + }, + focus: function(event, ui) { if (ui.item.wmOffset !== undefined) return false; }, + select: function(event, ui) { + if (ui.item.wmOffset !== undefined) { + offset = ui.item.wmOffset; + setTimeout(function() { picker.autocomplete('search', term); }, 0); + return false; + } + var valid = !!(ui.item.id && ui.item.local_graph_id > 0); + picker.val(valid ? ui.item.label : '').data('wm-choice', valid ? ui.item : null); + buttons.prop('disabled', !valid); + $('#wm-link-source-status').text(valid ? wmEditorText.sourceReady : wmEditorText.chooseSourceFirst); + return false; + } + }); + picker.on('input.wmSource', function() { + picker.removeData('wm-choice'); buttons.prop('disabled', true); $('#wm-link-source-status').text(wmEditorText.chooseSourceFirst); + }).on('focus.wmSource', function() { picker.autocomplete('search', picker.data('wm-choice') ? '' : picker.val()); }); +} + +function wmRestoreLinkGraphChoice() { + var picker = $('#link_picker_input'); + if (!picker.length) return; + var extras = String($('#wm-link-extra-hover').val() || ''); + var link = $('#link_name').val(); + $('#link_picker').val(''); + picker.val('').attr('placeholder', wmEditorText.searchGraph); + var entries = wmLinkHoverEntries(extras); + var ids = entries.length === 1 ? wmLocalGraphIds(entries[0], ['graph_image.php']) : []; + if (ids.length !== 1) return; + wmGetGraphSummaries(ids).done(function(items) { + if ($('#link_name').val() !== link || String($('#wm-link-extra-hover').val() || '') !== extras || picker.val()) return; + var item = items.find(function(item) { return String(item.id) === ids[0]; }); + if (!item) return; + $('#link_picker').val(String(item.id)); + picker.val(item.label).data('wm-graph-choice', item); + var row = $('#wm-link-extra-list .wm-link-extra-item').filter(function() { return $(this).attr('data-hover-url') === entries[0]; }); + row.find('.wm-link-extra-name').text(item.label); + row.find('.wm-link-extra-rrds').text((item.rrds || []).join(', ') || wmEditorText.noGraphRrd); + }); +} + +// Keep advanced link tasks separate without duplicating submitted fields or pickers. +function wmBuildLinkSections() { + var dlg = $('#dlgLinkProperties'); + if ($('#wm-link-sections').length) return; + var body = dlg.find('.dlgBody'); + if (!body.length) body = dlg; + var sections = $('').appendTo(body); + var controls = $('').appendTo(sections); + function group(id, title, fields, hint) { + var panel = $('') + .attr({id:id, 'aria-labelledby':id+'-button'}).appendTo(sections); + $('') + .attr({id:id+'-button', 'aria-controls':id}).text(title).appendTo(controls) + .on('click', function() { + var open = $(this).attr('aria-expanded') !== 'true'; + controls.find('.wm-link-section-toggle').attr('aria-expanded', 'false').removeClass('ui-state-active'); + sections.find('.wm-link-group').prop('hidden', true); + $(this).attr('aria-expanded', String(open)).toggleClass('ui-state-active', open); + panel.prop('hidden', !open); + if (dlg.dialog('instance')) dlg.dialog('option', 'position', {my:'center', at:'center', of:window}); + }); + if (hint) $('').text(hint).appendTo(panel); + var table = $('
').appendTo(panel); + dlg.find(fields).closest('tr').appendTo(table); + return panel; + } + var hovers = group('wm-link-hovers', wmEditorText.additionalGraphs, '#link_template,#link_picker'); + dlg.find('.wm-link-section p').addClass('wm-link-group-hint').prependTo(hovers); + dlg.find('.wm-link-section').remove(); + $('').append($('#link_picker_add,#link_picker_rep')).appendTo($('#link_picker').closest('td')); + $('').appendTo($('#link_picker').closest('td')); + $('').appendTo(hovers); + wmRenderLinkExtraGraphs(); + $('#link_target_picker_rep').remove(); + var advanced = group('wm-link-advanced', wmEditorText.advanced, + '#link_target,#link_infourl,#wm-link-primary-hover,#wm-link-extra-hover', wmEditorText.linkAdvancedHint); + var sourceRow = $('').insertBefore($('#link_target').closest('tr')); + $('').text(wmEditorText.chooseSource).appendTo(sourceRow); + var sourceActions = $('').appendTo($('').appendTo(sourceRow)) + .append($('#link_target_picker_add')); + wmInitLinkSourcePicker(sourceActions); + $('').text(wmEditorText.trafficSourcesHint).appendTo(sourceActions.parent()); + $('').insertBefore('#link_target'); + $('').attr('aria-label', wmEditorText.trafficSources).insertBefore('#link_target'); + $('#link_target').off('.wmExpert').on('input.wmExpert', function() { + ++wmInterfaceLookupGeneration; + $('#wm-link-current').text(wmEditorText.customInterface); + $('#wm-link-graph').text(wmEditorText.customConfig); + $('#link_target_picker').val('').removeData('wm-choice'); + $('#link_target_picker_input').val(''); + $('#wm-link-use').prop('disabled', true); + wmRenderLinkTrafficSources(); + $('#wm-link-source-status').text(wmEditorText.customSourcesChanged); + }); + $('#link_infourl,#wm-link-primary-hover,#wm-link-extra-hover').off('.wmExpert').on('input.wmExpert change.wmExpert', function() { + ++wmInterfaceLookupGeneration; + wmSyncLinkHoverParts(); + }); + dlg.find('#viastyle,#link_width,#link_commentin,#link_commentout').closest('tr') + .insertAfter($('#link_target_picker').closest('tr')); + $('#link_edit').appendTo(controls); + var help = dlg.find('.dlgHelp').attr('id', 'wm-link-full-help').insertAfter(sections).hide(); + $('') + .attr({'aria-label': wmEditorText.linkHelp, title: wmEditorText.linkHelp}).appendTo(controls) + .on('click', function() { + var open = $(this).attr('aria-expanded') !== 'true'; + $(this).attr('aria-expanded', String(open)); + help.toggle(open); + if (dlg.dialog('instance')) dlg.dialog('option', 'position', {my:'center', at:'center', of:window}); + }); + function hint(id, field, text) { + $('').attr('id', id).text(text).appendTo(dlg.find(field).closest('td')); + var input = dlg.find(field), described = String(input.attr('aria-describedby') || '').split(/\s+/).filter(Boolean); + if (!described.includes(id)) described.push(id); + input.attr('aria-describedby', described.join(' ')); + } + hint('wm-link-bandwidth-hint', '#link_bandwidth_in', wmEditorText.bandwidthHint); + hint('wm-link-comments-hint', '#link_commentout', wmEditorText.commentsHint); + hint('wm-link-destination-hint', '#link_infourl', wmEditorText.clickDestinationHint); + hint('wm-link-extra-url-hint', '#wm-link-extra-hover', wmEditorText.extraUrlsHint); +} + function compactLinkEditorBase() { + wmInitLinkHoverParts(); var dlg = $('#dlgLinkProperties'), - table = $('#link_target').closest('table'); - var fields = '#viastyle,#link_target,#link_infourl,#link_hover,#link_template,#link_picker'; - var rows = dlg.find(fields).closest('tr').addClass('wm-link-advanced'); + table = $('#link_bandwidth_in').closest('table'); + wmBuildLinkSections(); + wmRenderLinkTrafficSources(); + wmRestoreLinkGraphChoice(); + $('#wm-link-source-status').text(wmEditorText.chooseSourceFirst); + $('#wm-link-source-picker').val('').removeData('wm-choice'); + $('#link_target_picker_add').prop('disabled', true); + $('#wm-link-full-help').hide(); + $('#wm-link-help-button').attr('aria-expanded', 'false'); + $('#wm-link-primary-hover').prop('readonly', false).next('small').text(wmEditorText.trafficHoverHint); if (!$('#wm-link-summary').length) { $( '' ).prependTo(table).find('td:first').text(wmEditorText.currentInterface); - $('') - .text(wmEditorText.advanced) - .insertBefore(dlg.find('.dlgButtons')) - .on('click', function () { - var expanded = $(this).attr('aria-expanded') !== 'true'; - $(this) - .attr('aria-expanded', String(expanded)) - .text(expanded ? wmEditorText.hideAdvanced : wmEditorText.advanced); - rows.toggle(expanded); - dlg.find('.dlgHelp,#link_edit').toggle(expanded); - $('#link_target_picker_add,#link_target_picker_rep').toggle(expanded); - }); $( '' ) @@ -1457,10 +1741,12 @@ function compactLinkEditorBase() { var item = $('#link_target_picker').data('wm-choice'); if (!item || !item.id || !(item.local_graph_id > 0)) return; ++wmInterfaceLookupGeneration; - $('#link_target').val(item.id); + var extras = wmLinkHoverEntries($('#link_target').val()).slice(1).filter(function(source) { return source !== String(item.id); }); + $('#link_target').val([String(item.id)].concat(extras).join(' ')); + wmRenderLinkTrafficSources(); var destination = new URL(infoUrlTarget + item.local_graph_id, new URL('../../', window.location.href)); $('#link_infourl').val(destination.pathname + destination.search + destination.hash); - $('#link_hover').val( + wmSetLinkPrimaryHover( new URL('../../', window.location.href).pathname + 'graph_image.php?local_graph_id=' + item.local_graph_id + @@ -1473,15 +1759,17 @@ function compactLinkEditorBase() { $(this).prop('disabled', true); }); } - rows.hide(); - dlg.find('.dlgHelp,#link_edit').hide(); - $('#wm-link-toggle').attr('aria-expanded', 'false').text(wmEditorText.advanced); - $('#link_target_picker_add,#link_target_picker_rep').hide(); + $('#wm-link-graph').prependTo('#wm-link-advanced'); + wmRenderLinkTrafficSources(); + $('#wm-link-sections').show().find('.wm-link-group').prop('hidden', true); + $('#wm-link-sections .wm-link-section-toggle').attr('aria-expanded', 'false').removeClass('ui-state-active'); + $('#link_edit').show(); + $('#link_target_picker_add').show(); $('#link_target_picker').closest('tr').find('td:first').text(wmEditorText.changeInterface); $('#link_target_picker_input').val('').attr('placeholder', wmEditorText.searchInterface); $('#link_target_picker').val('').removeData('wm-choice'); local_graph_id = 0; - $('#link_target_picker_add,#link_target_picker_rep,#wm-link-use').prop('disabled', true); + $('#wm-link-use').prop('disabled', true); var picker = $('#link_target_picker_input'); if (!picker.data('wm-compact-hook')) { var original = picker.autocomplete('option', 'select'); @@ -1491,12 +1779,12 @@ function compactLinkEditorBase() { var valid = !!(ui.item.id && ui.item.local_graph_id > 0); $('#link_target_picker').val(valid ? ui.item.id : '').data('wm-choice', valid ? ui.item : null); local_graph_id = valid ? ui.item.local_graph_id : 0; - $('#link_target_picker_add,#link_target_picker_rep,#wm-link-use').prop('disabled', !valid); + $('#wm-link-use').prop('disabled', !valid); }) .on('input.wmCompact', function () { $('#link_target_picker').val('').removeData('wm-choice'); local_graph_id = 0; - $('#link_target_picker_add,#link_target_picker_rep,#wm-link-use').prop('disabled', true); + $('#wm-link-use').prop('disabled', true); }); picker.data('wm-compact-hook', true); } @@ -1563,15 +1851,11 @@ function compactLinkEditor() { var generation = ++wmInterfaceLookupGeneration; compactLinkEditorBase(); var dlg = $('#dlgLinkProperties'); - dlg.find('#link_commentin,#link_commentout').closest('tr').removeClass('wm-link-advanced').show(); if (!$('#wm-link-rrd').length) $('').insertAfter( '#wm-link-current' ); - if (!$('#wm-link-purpose').length) - $( - '' - ).text(wmEditorText.linkPurpose).insertAfter('#wm-link-toggle'); + var buttons = dlg.find('.dlgSubButtons'); $('#tb_link_submit').prependTo(buttons).css({ 'font-weight': 'bold' }); $('#tb_link_cancel').insertAfter('#tb_link_submit'); @@ -1610,8 +1894,9 @@ function compactLinkEditor() { if (describedBy.indexOf('wm-link-apply-hint') === -1) describedBy.push('wm-link-apply-hint'); picker.attr('aria-describedby', describedBy.join(' ')); var target = String($('#link_target').val() || '').trim(); - var file = target.split('/').pop(); - $('#wm-link-rrd').text(target); + var primary = wmLinkHoverEntries(target)[0] || ''; + var file = primary.split('/').pop(); + $('#wm-link-rrd').text(primary.split('/').pop()); $('#wm-link-current').text(target ? wmEditorText.lookingUp : wmEditorText.noInterface); var graphIds = wmLocalGraphIds(String($('#link_infourl').val() || ''), ['graph.php', 'graph_view.php']); @@ -1626,9 +1911,9 @@ function compactLinkEditor() { wmGetInterfaceCandidates(graphIds) .done(function (data) { var items = data.items; - if (generation !== wmInterfaceLookupGeneration || String($('#link_target').val() || '').trim() !== target) return; + if (generation !== wmInterfaceLookupGeneration || (wmLinkHoverEntries($('#link_target').val())[0] || '') !== primary) return; var matches = items.filter(function (item) { - return item.id === target; + return item.id === primary; }); if (!matches.length) { matches = items.filter(function(item) { return item.id.split('/').pop() === file; }); @@ -1641,17 +1926,20 @@ function compactLinkEditor() { ? wmEditorText.customInterface : wmEditorText.noInterface ); - if (matches.length && !picker.val() && !$('#link_target_picker').data('wm-choice')) - picker.val(matches[0].label); + if (matches.length) wmPromoteLinkTrafficHover(matches[0].local_graph_id); + if (matches.length && !$('#link_target_picker').data('wm-choice')) { + if (!picker.val()) picker.val(matches[0].label); + $('#link_target_picker').val(matches[0].id).data('wm-choice', matches[0]); + } }) .fail(function () { - if (generation !== wmInterfaceLookupGeneration || String($('#link_target').val() || '').trim() !== target) return; + if (generation !== wmInterfaceLookupGeneration || (wmLinkHoverEntries($('#link_target').val())[0] || '') !== primary) return; $('#wm-link-current').text(wmEditorText.unavailableInterface); }); if (!$('#wm-link-use').data('wm-detail-hook')) { $('#wm-link-use') .on('click.wmDetails', function () { - $('#wm-link-rrd').text($('#link_target').val()); + $('#wm-link-rrd').text((wmLinkHoverEntries($('#link_target').val())[0] || '').split('/').pop()); }) .data('wm-detail-hook', true); } @@ -1666,7 +1954,8 @@ function compactNodeEditor() { $('#node_template').closest('tr').find('td:first').text(wmEditorText.graphType); $('#node_picker').closest('tr').find('td:first').text(wmEditorText.hoverGraphs); $('#node_label').closest('tr').prependTo(table); - $('#node_iconfilename').closest('tr').insertAfter($('#node_label').closest('tr')); + $('#node_labelposition').closest('tr').insertAfter($('#node_label').closest('tr')); + $('#node_iconfilename').closest('tr').insertAfter($('#node_labelposition').length ? $('#node_labelposition').closest('tr') : $('#node_label').closest('tr')); if (!$('#wm-node-delete-bar').length) $('
').insertBefore(table); $('#node_delete').text(wmEditorText.deleteNode).appendTo('#wm-node-delete-bar').css('color', '#9b2525'); @@ -1788,7 +2077,7 @@ function wmGetGraphSummaries(ids) { } $.getJSON(window.location.pathname, { action: 'graphs', term: '', mapname: $('#mapname').val(), - target: 'node_picker', graph_template_id: -1, paged: 1, + target: 'node_picker', graph_template_id: -1, paged: 1, include_rrds: '1', graph_ids: unique.slice(offset, offset + 100).join(',') }).done(function(data) { labels = labels.concat(data.items); @@ -1818,16 +2107,11 @@ function wmFriendlyLinkNames() { .text(wmEditorText.bandwidthOut.replace('%s', function() { return wmNodeDisplayName(link.a); })); if (!$('#wm-link-internal-id').length) $('').insertBefore( - $('#link_target').closest('table') + $('#wm-link-advanced').find('table') ); $('#wm-link-internal-id') - .text(wmEditorText.internalLink.replace('%s', function() { return link.name; })) - .hide(); - $('#wm-link-toggle') - .off('click.wmNames') - .on('click.wmNames', function () { - $('#wm-link-internal-id').toggle($(this).attr('aria-expanded') === 'true'); - }); + .text(wmEditorText.internalLink.replace('%s', function() { return link.name; })); + } // Map JavaScript HTML-escapes URL values; undo that when populating text fields. @@ -1995,6 +2279,10 @@ function wmOpenColours() { wmColourRequest('colour_data', {}, function(data) { $('#wm-traffic-pulse').prop('checked', data.colours.pulse === true); $('#wm-outline-colour').empty().append(wmAssetColour(data.colours.outline, false, true, false, wmEditorText.borderColour)); + var comment = $('' . wm_style_font_options($map, $current) . ''; } /** - * Render the link comment font and colour controls. + * Render the link comment font control. * * @param WeatherMap $map * @@ -148,19 +148,6 @@ function wm_comment_style_fields($map) { $out = ''; - $rgb = $map->links['DEFAULT']->commentfontcolour; - $currentColour = $rgb == [-3, -3, -3] ? 'contrast' : implode(' ', $rgb); - $colours = wm_comment_colour_choices(); - - if (!isset($colours[$currentColour])) { - $colours[$currentColour] = __('Current custom colour (%s)', $currentColour, 'weathermap'); - } - $out .= ''; return $out; } diff --git a/lib/editor.inc.php b/lib/editor.inc.php index ed154775..6556acaf 100644 --- a/lib/editor.inc.php +++ b/lib/editor.inc.php @@ -116,6 +116,26 @@ function display_graphs() { } } + // Fetch filenames only for graph IDs already authorised above. + if (get_nfilter_request_var('include_rrds') === '1' && $return) { + $paths = db_fetch_assoc_prepared('SELECT DISTINCT gti.local_graph_id AS id, dtd.data_source_path AS path + FROM graph_templates_item AS gti + INNER JOIN data_template_rrd AS dtr ON dtr.id = gti.task_item_id + INNER JOIN data_template_data AS dtd ON dtd.local_data_id = dtr.local_data_id + WHERE gti.local_graph_id IN (' . implode(',', array_fill(0, count($return), '?')) . ') + ORDER BY gti.local_graph_id, dtd.data_source_path', array_column($return, 'id')); + foreach ($return as &$item) { + $item['rrds'] = []; + foreach ($paths as $path) { + if ((int) $path['id'] === (int) $item['id'] && $path['path'] !== '') { + $item['rrds'][] = basename(str_replace('\\', '/', $path['path'])); + } + } + $item['rrds'] = array_values(array_unique($item['rrds'])); + } + unset($item); + } + print json_encode(get_nfilter_request_var('paged') == '1' ? [ 'items' => $return, 'offset' => $offset, @@ -1065,7 +1085,7 @@ function getEditorJs() { 'advanced' => __('Advanced settings', 'weathermap'), 'hideAdvanced' => __('Hide advanced settings', 'weathermap'), 'useInterface' => __('Use interface', 'weathermap'), - 'changeInterface' => __('Change interface', 'weathermap'), + 'changeInterface' => __('Traffic interface', 'weathermap'), 'searchInterface' => __('Search device or interface', 'weathermap'), 'noInterface' => __('No interface selected', 'weathermap'), 'addNoteHelp' => __('Click on the map where you would like to add a note.', 'weathermap'), @@ -1075,15 +1095,45 @@ function getEditorJs() { 'noteDelete' => __('Delete note "%s"?', '%s', 'weathermap'), 'noteError' => __('Unable to save changes. Please try again.', 'weathermap'), 'currentGraph' => __('Current graph: %s', '%s', 'weathermap'), - 'customConfig' => __('Custom configuration — details under Advanced', 'weathermap'), + 'customConfig' => __('Custom configuration — see Advanced settings', 'weathermap'), 'chooseInterface' => __('Choose an interface below', 'weathermap'), 'bandwidthIn' => __('Bandwidth into %s', '%s', 'weathermap'), 'bandwidthOut' => __('Bandwidth out of %s', '%s', 'weathermap'), 'graphSelected' => __('Graph %s — data, click destination and hover graph selected. Save to apply.', '%s', 'weathermap'), - 'linkPurpose' => __('For a standard interface traffic link, the main controls are enough. Use Advanced to combine traffic sources, show multiple hover graphs, choose a different click destination or customise the line. Weathermap also supports other measurements, such as CPU and memory, commonly displayed on nodes.', 'weathermap'), + 'trafficHoverHint' => __('Shown first on hover. Use interface sets this automatically; change it only for a custom preview URL.', 'weathermap'), + 'combinedSources' => __('Combined traffic from %s sources', '%s', 'weathermap'), + 'sourceAdded' => __('Traffic source added to the map total. Hover and click graphs are unchanged. Save to apply.', 'weathermap'), + 'sourceAlreadyAdded' => __('This source is already included. Select a different source in this search box to add more.', 'weathermap'), + 'removeSource' => __('Remove source', 'weathermap'), + 'sourceRemoved' => __('Measured source removed. Hover and click graphs are unchanged. Save to apply.', 'weathermap'), + 'noMeasuredSources' => __('No measured traffic sources selected.', 'weathermap'), + 'chooseSourceFirst' => __('Choose an additional interface from the search results above, then click Add traffic source.', 'weathermap'), + 'sourceReady' => __('Additional interface selected. Add traffic source keeps the primary interface and other sources.', 'weathermap'), + 'primarySource' => __('Primary source — use "Use interface" at the top to change', 'weathermap'), + 'choosePrimaryFirst' => __('Choose the primary traffic interface in the main form first.', 'weathermap'), + 'chooseSource' => __('Add additional traffic sources', 'weathermap'), + 'linkAdvancedHint' => __('Custom sources and URLs override the automatic interface setup. Leave these unchanged for a normal traffic link.', 'weathermap'), + 'customSourcesChanged' => __('Custom traffic sources changed. Save to apply. Hover graphs and click destination are unchanged.', 'weathermap'), + 'extraUrlsHint' => __('One additional image URL maximum. This is the same preview managed under Additional hover graphs.', 'weathermap'), + 'trafficSources' => __('Traffic sources', 'weathermap'), + 'trafficSourcesHint' => __('Optional: add other interfaces to the map total. Change the primary interface in the main form; remove extra sources below. Hover and click graphs stay unchanged. A matching aggregate graph must be configured separately in Cacti. Save applies changes.', 'weathermap'), + 'additionalGraph' => __('Additional graph', 'weathermap'), + 'noGraphRrd' => __('No RRD file listed for this graph', 'weathermap'), + 'customHoverGraph' => __('Custom hover graph', 'weathermap'), + 'removeHoverGraph' => __('Remove graph', 'weathermap'), + 'hoverLimit' => __('One additional graph maximum. Replace or remove it to choose another.', 'weathermap'), + 'additionalGraphs' => __('Additional hover graphs', 'weathermap'), + 'clickDestination' => __('Click destination', 'weathermap'), + 'clickDestinationHint' => __('Usually leave this set to the traffic graph. Change it only to open a different graph or page when clicking the link. This does not change measured traffic or hover graphs.', 'weathermap'), + 'appearance' => __('Appearance', 'weathermap'), + 'bandwidthHint' => __('Capacity, for example 100M or 1G.', 'weathermap'), + 'commentsHint' => __('Text along the link; the percentage sets its position.', 'weathermap'), + 'appearanceComments' => __('Appearance and comments', 'weathermap'), + 'linkHelp' => __('Help', 'weathermap'), + 'linkPurpose' => __('Choose a traffic interface and capacity, then Save. Additional hover graphs adds optional previews. Advanced settings contains custom traffic sources and URLs. Edit Raw Configuration edits map syntax directly.', 'weathermap'), 'browseInterface' => __('Type a device or interface, or browse all', 'weathermap'), 'browseAll' => __('Browse all', 'weathermap'), - 'applyInterfaceHint' => __('Select an interface, then click "Use interface" to apply it.', 'weathermap'), + 'applyInterfaceHint' => __('Choose a primary interface, then click "Use interface". Additional sources and hover graphs are kept. Save applies changes.', 'weathermap'), 'lookingUp' => __('Looking up device and interface...', 'weathermap'), 'customInterface' => __('Custom or unavailable interface', 'weathermap'), 'unavailableInterface' => __('Interface name unavailable', 'weathermap'), @@ -1093,7 +1143,7 @@ function getEditorJs() { 'hoverGraphs' => __('Hover graphs', 'weathermap'), 'deleteNode' => __('Delete Node', 'weathermap'), 'nodePurpose' => __('For a node used as a label or icon at the end of a link, the main controls are enough. Advanced adds hover graphs such as CPU, memory or traffic, custom click destinations and the internal node ID.', 'weathermap'), - 'nodeDisplayHelp' => __('Display name and icon: Choose what appears on the map. Position sets the X and Y coordinates; Move lets you place the node on the map.', 'weathermap'), + 'nodeDisplayHelp' => __('Display name and icon: Choose what appears on the map. Display name position places the name over the icon. Map position (X, Y) sets the node coordinates; Move lets you place the node on the map.', 'weathermap'), 'nodeHoverHelp' => __('Hover graphs: Filter by graph type and search for a graph. Add includes another graph; Replace replaces the existing hover graphs. Save applies your changes.', 'weathermap'), 'nodeAdvancedHelp' => __('Advanced: The internal name identifies the node in the map configuration. Info URLs set the click destination; Hover Graph URLs set the images shown on hover. Use these for custom destinations or external graphs.', 'weathermap'), 'searchGraph' => __('Search device or graph', 'weathermap'), @@ -1123,7 +1173,7 @@ function getEditorJs() { var txtLinkActions = ''; var txtMove = ''; var txtClone = ''; - var txtEdit = ''; + var txtEdit = ''; var txtDelete = ''; var txtVia = ''; var txtTidy = ''; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18edb16a..4e75505b 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR The Cacti Group -# This file is distributed under the same license as the Cacti package. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti 1.7\n" -"Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 10:19+1030\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -224,7 +224,7 @@ msgstr "" msgid "Current custom colour (%s)" msgstr "" -#: lib/editor.comment-style.php +#: lib/editor.comment-style.php weathermap-cacti-plugin-editor.php msgid "Link Comment Colour" msgstr "" @@ -2684,3 +2684,233 @@ msgstr "" #: weathermap-cacti-plugin.php msgid "Paused" msgstr "" + +#: lib/editor.inc.php +msgid "Traffic interface" +msgstr "" + +#: lib/editor.inc.php +msgid "Custom configuration — see Advanced settings" +msgstr "" + +#: lib/editor.inc.php +msgid "Shown first on hover. Use interface sets this automatically; change it only for a custom preview URL." +msgstr "" + +#: lib/editor.inc.php +#, php-format +msgid "Combined traffic from %s sources" +msgstr "" + +#: lib/editor.inc.php +msgid "Traffic source added to the map total. Hover and click graphs are unchanged. Save to apply." +msgstr "" + +#: lib/editor.inc.php +msgid "This source is already included. Select a different source in this search box to add more." +msgstr "" + +#: lib/editor.inc.php +msgid "Remove source" +msgstr "" + +#: lib/editor.inc.php +msgid "Measured source removed. Hover and click graphs are unchanged. Save to apply." +msgstr "" + +#: lib/editor.inc.php +msgid "No measured traffic sources selected." +msgstr "" + +#: lib/editor.inc.php +msgid "Choose an additional interface from the search results above, then click Add traffic source." +msgstr "" + +#: lib/editor.inc.php +msgid "Additional interface selected. Add traffic source keeps the primary interface and other sources." +msgstr "" + +#: lib/editor.inc.php +msgid "Primary source — use \"Use interface\" at the top to change" +msgstr "" + +#: lib/editor.inc.php +msgid "Choose the primary traffic interface in the main form first." +msgstr "" + +#: lib/editor.inc.php +msgid "Add additional traffic sources" +msgstr "" + +#: lib/editor.inc.php +msgid "Custom sources and URLs override the automatic interface setup. Leave these unchanged for a normal traffic link." +msgstr "" + +#: lib/editor.inc.php +msgid "Custom traffic sources changed. Save to apply. Hover graphs and click destination are unchanged." +msgstr "" + +#: lib/editor.inc.php +msgid "One additional image URL maximum. This is the same preview managed under Additional hover graphs." +msgstr "" + +#: lib/editor.inc.php +msgid "Traffic sources" +msgstr "" + +#: lib/editor.inc.php +msgid "Optional: add other interfaces to the map total. Change the primary interface in the main form; remove extra sources below. Hover and click graphs stay unchanged. A matching aggregate graph must be configured separately in Cacti. Save applies changes." +msgstr "" + +#: lib/editor.inc.php +msgid "Additional graph" +msgstr "" + +#: lib/editor.inc.php +msgid "No RRD file listed for this graph" +msgstr "" + +#: lib/editor.inc.php +msgid "Custom hover graph" +msgstr "" + +#: lib/editor.inc.php +msgid "Remove graph" +msgstr "" + +#: lib/editor.inc.php +msgid "One additional graph maximum. Replace or remove it to choose another." +msgstr "" + +#: lib/editor.inc.php weathermap-cacti-plugin-editor.php +msgid "Additional hover graphs" +msgstr "" + +#: lib/editor.inc.php +msgid "Click destination" +msgstr "" + +#: lib/editor.inc.php +msgid "Usually leave this set to the traffic graph. Change it only to open a different graph or page when clicking the link. This does not change measured traffic or hover graphs." +msgstr "" + +#: lib/editor.inc.php +msgid "Appearance" +msgstr "" + +#: lib/editor.inc.php +msgid "Capacity, for example 100M or 1G." +msgstr "" + +#: lib/editor.inc.php +msgid "Text along the link; the percentage sets its position." +msgstr "" + +#: lib/editor.inc.php +msgid "Appearance and comments" +msgstr "" + +#: lib/editor.inc.php +msgid "Help" +msgstr "" + +#: lib/editor.inc.php +msgid "Choose a traffic interface and capacity, then Save. Additional hover graphs adds optional previews. Advanced settings contains custom traffic sources and URLs. Edit Raw Configuration edits map syntax directly." +msgstr "" + +#: lib/editor.inc.php +msgid "Choose a primary interface, then click \"Use interface\". Additional sources and hover graphs are kept. Save applies changes." +msgstr "" + +#: lib/editor.inc.php +msgid "Display name and icon: Choose what appears on the map. Display name position places the name over the icon. Map position (X, Y) sets the node coordinates; Move lets you place the node on the map." +msgstr "" + +#: lib/editor.inc.php weathermap-cacti-plugin-editor.php +msgid "Edit Raw Configuration" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Map position (X, Y)" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Display name position" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Top" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Middle" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Bottom" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Custom (raw configuration)" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Measured traffic source(s)" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Add traffic source" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Click destination URL" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Main hover graph URL" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Shown first. Set by Use interface; additional graphs appear below it." +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Show up to two hover graphs: the main traffic graph and one additional CPU, latency or other preview. Replace changes only the additional graph." +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Additional graph URL(s)" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Additional hover graph URLs" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Filter by graph type" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Choose hover graph" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Add hover graph" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "Replace hover graphs" +msgstr "" + +#: weathermap-cacti-plugin-editor.php +msgid "" +"

Bandwidth: Set the capacity used to calculate utilisation, for example 100M or 1G. K, M, G and T are supported. Select Same As "In" when both directions have equal capacity.

\n" +"

Traffic interface: Search by device, port or description, or select Browse all. Choose an interface, then click Use interface to set the primary measured source, click destination and main hover graph. Additional measured sources and hover graphs are kept.

\n" +"

Appearance and comments: Via Style controls curved or angled bends; Link Width controls arrow thickness. IN Comment and OUT Comment add text along each direction. The percentage sets the text position along the link.

\n" +"

Additional hover graphs: Show a maximum of two hover graphs: the main traffic graph and one extra, such as CPU, latency or another interface. Filter by graph type narrows the choices. Add hover graph adds the extra; Replace hover graphs changes it; Remove graph removes it. The main traffic graph, measured traffic and link click destination stay unchanged. In the viewer, each Cacti preview opens its own full graph when clicked.

\n" +"

Advanced settings: Optional controls for combining measured sources, changing the click destination and entering custom hover image URLs. A normal traffic link usually needs no changes here.

\n" +"

Combining traffic sources: Add traffic source adds another interface to the map total, separately for IN and OUT. Remove source removes an additional source. Change the primary source using Use interface at the top. The combined traffic is compared with the configured bandwidth to calculate utilisation. No combined Cacti graph is created automatically; create a matching aggregate graph in Cacti and enter its click and hover URLs if needed. Separate map links are usually clearer for primary and backup connections.

\n" +"

Manual sources and URLs: The source text box edits the complete measured-source list, including the primary source. Click destination URL controls where clicking the map link goes. Main hover graph URL sets the first preview; Additional graph URL(s) accepts one extra image URL. This extra is also managed under Additional hover graphs.

\n" +"

Tidy and Via: Tidy adjusts the link layout. Via lets you place a bend point on the map.

\n" +"

Edit Raw Configuration: Edit the underlying map syntax for settings beyond this form.

" +msgstr "" diff --git a/tests/Browser/EditorContextMenuTest.cjs b/tests/Browser/EditorContextMenuTest.cjs index a770e7d5..e5dd108c 100644 --- a/tests/Browser/EditorContextMenuTest.cjs +++ b/tests/Browser/EditorContextMenuTest.cjs @@ -68,9 +68,10 @@ for (const action of ['set_node_properties','set_link_properties']) { w.eval(src.slice(src.indexOf('function click_execute('),src.indexOf('function show_context_help('))); $('#action').val('add_link');w.mapmode('existing');assert.ok($('#existingdata').hasClass('wm-placement-mode')); w.click_execute({},'LINK:1:0');assert.equal($('#action').val(),'');assert.ok(!$('#existingdata').hasClass('wm-placement-mode'));assert.equal($('area').css('cursor'),'pointer'); +w.eval(src.slice(src.indexOf('function wmLinkHoverEntries('),src.indexOf('function wmLinkCompactHoverUrl('))); w.eval(src.slice(src.indexOf('function form_submit('),src.indexOf('function map_properties('))); $('body').append('
'); -let saveRequest;$.ajax=opts=>saveRequest=opts;w.editor_url='/editor'; +let saveRequest;$.ajax=opts=>saveRequest=opts;w.editor_url='/editor';w.wmSyncLinkHoverParts=()=>{}; for (const kind of ['node','link']) for (const exit of ['cancel','close']) { const dialog=$('#dlg'+(kind==='node'?'Node':'Link')+'Properties');dialog.dialog({modal:false}); $('#action').val('add_link2');$('#param').val('first-endpoint');w.mapmode('existing'); diff --git a/tests/Browser/EditorWorkflowTest.cjs b/tests/Browser/EditorWorkflowTest.cjs index 75913807..9df6437e 100644 --- a/tests/Browser/EditorWorkflowTest.cjs +++ b/tests/Browser/EditorWorkflowTest.cjs @@ -5,18 +5,20 @@ const { JSDOM } = require('jsdom'), assert = require('node:assert/strict'); const row = (id) => `${id}`; const dom = new JSDOM( - `

${['viastyle', 'link_target', 'link_infourl', 'link_hover', 'link_template', 'link_picker', 'link_bandwidth_in', 'link_bandwidth_out', 'link_width', 'link_commentin', 'link_commentout'].map(row).join('')}
Picker
${['link_delete', 'link_tidy', 'link_via', 'link_edit', 'tb_link_cancel', 'tb_link_submit'].map((id) => `${id}`).join('')}
Help
`, + `

${['viastyle', 'link_target', 'link_infourl', 'link_hover', 'wm-link-primary-hover', 'wm-link-extra-hover', 'link_template', 'link_picker', 'link_bandwidth_in', 'link_bandwidth_out', 'link_width', 'link_commentin', 'link_commentout'].map(row).join('')}
Picker
${['link_delete', 'link_tidy', 'link_via', 'link_edit', 'tb_link_cancel', 'tb_link_submit'].map((id) => `${id}`).join('')}
Help
`, { url: 'https://example.test/cacti/plugins/weathermap/editor.php', runScripts: 'outside-only' } ); const w = dom.window, $ = jquery(w); w.$ = $; +$.fn.dialog = function(method) { return method === 'instance' ? null : this; }; const phpStrings = fs.readFileSync(require('node:path').join(__dirname, '../../lib/editor.inc.php'), 'utf8'); w.wmEditorText = Object.fromEntries(Array.from(phpStrings.matchAll(/'([a-zA-Z]+)'\s*=> __\('([^']*)', (?:'%s', )?'weathermap'\)/g), match => [match[1], match[2]])); w.infoUrlTarget = 'graph_view.php?action=preview&reset=true&style=selective&graph_list='; w.Nodes = { a: { label: 'Switch A' }, b: { label: 'Router B' } }; w.Links = { 'a-b': { a: 'a', b: 'b', name: 'a-b' } }; $.fn.autocomplete = function (method, key, value) { + if (typeof method === 'object') { this.data('options',method); return this; } const o = this.data('options') || {}; if (method === 'option') { if (arguments.length === 2) return o[key]; @@ -38,7 +40,40 @@ vm.runInContext(editor.slice(editor.indexOf('// Compact link workflow;')), dom.g $('#link_target').val('/rrd/interface.rrd'); $('#link_infourl').val('/cacti/graph.php?rra_id=all&local_graph_id=10'); $('#link_target_picker_input').attr('aria-describedby', 'existing-description'); +$('#dlgLinkProperties table').append('Additional hover graphs'); w.compactLinkEditor(); +assert.notEqual($('#wm-link-sections').css('display'), 'none'); +assert.equal($('#wm-link-sections .wm-link-section-toggle').length, 2); +assert.equal($('#wm-link-sections .wm-link-group:not([hidden])').length, 0); +assert.equal($('#dlgLinkProperties .dlgHelp').closest('details,#wm-link-sections').length,0); +assert.equal($('#dlgLinkProperties .dlgHelp').css('display'),'none'); +assert.equal($('#wm-link-appearance').length,0); +assert.equal($('#link_width').closest('.wm-link-group').length,0); +$('#wm-link-help-button').trigger('click'); +assert.notEqual($('#wm-link-full-help').css('display'),'none'); +assert.equal($('#wm-link-help-button').attr('aria-expanded'),'true'); +$('#wm-link-help-button').trigger('click'); +assert.equal($('#wm-link-full-help').css('display'),'none'); +assert.equal($('#link_target_picker_add').closest('.wm-link-group').attr('id'), 'wm-link-advanced'); +assert.equal($('#link_target_picker_rep').length, 0); +assert.equal($('#wm-link-source-picker').length, 1); +assert.equal($('#link_target').closest('.wm-link-group').attr('id'), 'wm-link-advanced'); +assert.equal($('#link_infourl').closest('.wm-link-group').attr('id'), 'wm-link-advanced'); +assert.equal($('#link_target').prop('disabled'), false); +assert.equal($('#link_infourl').prop('disabled'), false); +assert.equal($('#link_edit').css('display') === 'none', false); +assert.equal($('#wm-link-graph').closest('#wm-link-advanced').length,1); +assert.equal($('#wm-link-summary #wm-link-graph').length,0); +assert.equal($('#wm-link-rrd').text(),'interface.rrd'); +assert.equal($('#wm-link-advanced-button').next().attr('id'),'link_edit'); +assert.equal($('#link_edit').next().attr('id'),'wm-link-help-button'); +assert.equal($('#dlgLinkProperties .dlgSubButtons #link_edit,#dlgLinkProperties .dlgSubButtons #wm-link-help-button').length,0); +assert.equal($('#link_picker').closest('.wm-link-group').attr('id'), 'wm-link-hovers'); +assert.equal($('#link_commentin').closest('.wm-link-group').length, 0); +assert.equal($('#link_target_picker').closest('tr').next().find('#viastyle').length, 1); +assert.equal($('#link_commentin').closest('table')[0], $('#link_target_picker').closest('table')[0]); +assert.equal($('#link_target_picker').closest('tr').find('td:first').text(), 'Traffic interface'); +assert.equal($('#wm-link-apply-hint').text(), 'Choose a primary interface, then click "Use interface". Additional sources and hover graphs are kept. Save applies changes.'); assert.equal($('#link_target_picker_input').attr('aria-describedby'), 'existing-description wm-link-apply-hint'); w.compactLinkEditor(); assert.equal($('#link_target_picker_input').attr('aria-describedby'), 'existing-description wm-link-apply-hint'); @@ -50,12 +85,27 @@ const items = [ request.resolve({items}); assert.equal($('#link_target_picker_input').val(), items[0].label); assert.equal($('#wm-link-current').text(), items[0].label); -assert.equal($('#link_target').closest('tr').css('display'), 'none'); +assert.equal($('#link_target').closest('.wm-link-group').attr('id'), 'wm-link-advanced'); assert.notEqual($('#link_width').closest('tr').css('display'), 'none'); assert.notEqual($('#link_commentin').closest('tr').css('display'), 'none'); -$('#wm-link-toggle').trigger('click'); -assert.notEqual($('#link_target').closest('tr').css('display'), 'none'); +$('#wm-link-hovers-button').trigger('click'); +assert.equal($('#wm-link-hovers').prop('hidden'),false); +assert.equal($('#wm-link-hovers-button').attr('aria-expanded'),'true'); +assert.equal($('#wm-link-advanced').prop('hidden'),true); +$('#wm-link-advanced-button').trigger('click'); +assert.equal($('#wm-link-advanced').prop('hidden'),false); +assert.equal($('#wm-link-hovers').prop('hidden'),true); +assert.equal($('#wm-link-hovers-button').attr('aria-expanded'),'false'); +$('#wm-link-hovers-button').trigger('click'); +assert.equal($('#wm-link-hovers').prop('hidden'),false); +assert.equal($('#wm-link-hovers-button').attr('aria-expanded'),'true'); +assert.equal($('#wm-link-advanced').prop('hidden'),true); +assert.equal($('#wm-link-advanced-button').attr('aria-expanded'),'false'); +$('#wm-link-hovers-button').trigger('click'); +assert.equal($('#wm-link-hovers').prop('hidden'),true); +assert.equal($('#wm-link-advanced').prop('hidden'),true); assert.equal($('#wm-link-internal-id').text(), 'Internal link ID: a-b'); +assert.equal($('#wm-link-internal-id').closest('.wm-link-group').attr('id'), 'wm-link-advanced'); let results; $('#link_target_picker_input').autocomplete('option', 'source')({ term: 'backup' }, (v) => (results = v)); assert.deepEqual( @@ -190,14 +240,17 @@ w.wmEditorText.browseAll = 'Translated browse'; $('#wm-link-summary,#wm-link-toggle,#wm-link-browse').remove(); w.compactLinkEditor(); assert.equal($('#wm-link-summary td:first').text(), 'Translated current'); -assert.equal($('#wm-link-toggle').text(), 'Translated advanced'); +assert.equal($('#wm-link-toggle').length,0); assert.equal($('#wm-link-browse').text(), 'Translated browse'); $('body').append('
' + ['node_label','node_iconfilename','node_template','node_picker','node_new_name','node_infourl','node_hover'].map(row).join('') + '
'); +$('#node_label').closest('tr').after('Display name position'); w.wmEditorText.displayName = 'Translated display'; w.wmEditorText.nodeDisplayHelp = ''; w.compactNodeEditor(); w.refineNodePicker(); assert.equal($('#node_label').closest('tr').find('td:first').text(), 'Translated display'); +assert.equal($('#node_label').closest('tr').next().find('select').attr('id'),'node_labelposition'); +assert.equal($('#node_labelposition').closest('tr').next().find('input').attr('id'),'node_iconfilename'); assert.equal($('#wm-node-toggle').text(), 'Translated advanced'); assert.equal($('#wm-node-browse').text(), 'Translated browse'); assert.equal($('#dlgNodeProperties .dlgHelp p:first').text(), ''); @@ -239,17 +292,17 @@ w.compactLinkEditorBase(); assert.equal($('#link_target_picker').val(), ''); assert.equal(w.local_graph_id, 0); assert.equal($('#link_target_picker_add').prop('disabled'), true); -assert.equal($('#link_target_picker_rep').prop('disabled'), true); +assert.equal($('#link_target_picker_rep').length, 0); $('#link_target_picker_input').autocomplete('option', 'select').call($('#link_target_picker_input')[0], {}, {item:items[0]}); assert.equal($('#link_target_picker').val(), items[0].id); assert.equal(w.local_graph_id, 10); -assert.equal($('#link_target_picker_add').prop('disabled'), false); -assert.equal($('#link_target_picker_rep').prop('disabled'), false); +assert.equal($('#link_target_picker_add').prop('disabled'), true); // Main interface does not select an advanced source. +assert.equal($('#link_target_picker_rep').length, 0); $('#link_target_picker_input').trigger('input'); assert.equal($('#link_target_picker').val(), ''); assert.equal(w.local_graph_id, 0); assert.equal($('#link_target_picker_add').prop('disabled'), true); -assert.equal($('#link_target_picker_rep').prop('disabled'), true); +assert.equal($('#link_target_picker_rep').length, 0); // An exact full path wins even if a same-basename candidate arrives first. w.wmInterfaceSummaries = {}; $('#link_target').val('/rrd/expected/interface.rrd'); @@ -327,6 +380,165 @@ for (const kind of ['node','link']) { if(kind==='node')w.delete_node();else w.delete_link(); assert.equal($('#action').val(),kind==='node'?'set_node_properties':'set_link_properties');$('.dlgConfirm').dialog('close'); } +$('#link_hover').val('/cacti/graph_image.php?local_graph_id=10 /cacti/graph_image.php?local_graph_id=112'); +w.wmInitLinkHoverParts(); +const extrasBeforeInterfaceChange = $('#wm-link-extra-hover').val(); +$('#link_target_picker').data('wm-choice', items[1]); +$('#wm-link-use').trigger('click'); +assert.equal($('#wm-link-extra-hover').val(), extrasBeforeInterfaceChange, 'Changing traffic interface preserves extras'); +assert.equal(new URL($('#wm-link-primary-hover').val(), w.location.href).searchParams.get('local_graph_id'), '20'); +assert.equal($('#link_hover').val().split(/\s+/).length, 2); +// Opening a newly created link must clear previews from the previously edited link. +w.eval(editor.slice(editor.indexOf('function prime_link_form('), editor.indexOf('function show_link('))); +w.Links.fresh = {name:'fresh', target:'', width:7, bw_in:'1000M', bw_out:'1000M', infourl:'', overliburl:'', viastyle:'curved', commentin:'', commentout:'', commentposin:95, commentposout:5, a:'a', b:'b'}; +$('#link_nodename1').text('a');$('#link_nodename2').text('b'); +assert.equal(w.prime_link_form('fresh'),true); +w.compactLinkEditor(); +assert.equal($('#wm-link-extra-hover').val(),''); +assert.equal($('#wm-link-primary-hover').val(),''); +$('#link_target_picker').data('wm-choice',items[0]);$('#wm-link-use').trigger('click'); +assert.equal($('#wm-link-extra-hover').val(),''); +assert.equal($('#link_hover').val().split(/\s+/).length,1); +// Opening/simplifying the form preserves custom sources and destinations. +$('#link_target').val('/rrd/one.rrd /rrd/two.rrd'); +$('#link_infourl').val('/custom/destination'); +w.compactLinkEditor(); +assert.equal($('#link_target').val(), '/rrd/one.rrd /rrd/two.rrd'); +assert.equal($('#link_infourl').val(), '/custom/destination'); +assert.equal($('#link_target').serialize(), ''); // Fixture input has no name. +$('#link_target').attr('name','link_target'); +$('#link_infourl').attr('name','link_infourl'); +const serialized = $('#dlgLinkProperties :input').serialize(); +assert.match(serialized,/link_target=%2Frrd%2Fone.rrd%20%2Frrd%2Ftwo.rrd/); +assert.match(serialized,/link_infourl=%2Fcustom%2Fdestination/); +// The independent advanced source picker changes only TARGET, not click or hover settings. +const clickBeforeSources = $('#link_infourl').val(), hoverBeforeSources = $('#link_hover').val(); +$('#link_target').val(items[0].id); +const sourceOptions = $('#wm-link-source-picker').data('options'); +const mainChoice = $('#link_target_picker').data('wm-choice'); +sourceOptions.select({}, {item:items[1]}); +assert.equal($('#link_target_picker_add').prop('disabled'),false); +assert.equal($('#link_target_picker').data('wm-choice'),mainChoice); +w.wmApplyLinkTrafficSource(); +assert.equal($('#link_target').val(),items[0].id+' '+items[1].id); +assert.match($('#wm-link-source-status').text(),/added/); +w.wmApplyLinkTrafficSource(); +assert.equal($('#link_target').val(),items[0].id+' '+items[1].id); +assert.match($('#wm-link-source-status').text(),/already included/); +assert.equal($('#link_infourl').val(),clickBeforeSources); +assert.equal($('#link_hover').val(),hoverBeforeSources); +// Switching the primary preserves extras, without duplicating an extra promoted to primary. +$('#link_target_picker').data('wm-choice',items[1]); +$('#wm-link-use').trigger('click'); +assert.equal($('#link_target').val(),items[1].id); +$('#link_infourl').val(clickBeforeSources);$('#link_hover').val(hoverBeforeSources);w.wmInitLinkHoverParts(); +$('#wm-link-source-picker').val('New source search').trigger('input'); +assert.equal($('#link_target_picker_add').prop('disabled'),true); +graphRequests.length=0; +sourceOptions.source({term:'Backup'},items=>{w.sourceResults=items;}); +assert.equal(graphRequests[0].data.target,'link_target_picker'); +graphRequests[0].deferred.resolve({items:[items[1]],offset:0,next_offset:100,next_label:'Next'}); +assert.equal(w.sourceResults.length,2); +assert.equal(w.sourceResults[1].wmOffset,100); +// Missing and unchanged selections explain why replacement cannot change the list. +$('#link_target').val(items[1].id); +$('#wm-link-source-picker').data('wm-choice',items[1]); +w.wmApplyLinkTrafficSource(); +assert.equal($('#wm-link-source-status').text(),w.wmEditorText.sourceAlreadyAdded); +assert.equal($('#link_target').val(),items[1].id); +$('#wm-link-source-picker').removeData('wm-choice'); +w.wmApplyLinkTrafficSource(); +assert.equal($('#wm-link-source-status').text(),w.wmEditorText.chooseSourceFirst); +assert.equal($('#link_target').val(),items[1].id); +// Removing a measured source changes only that entry, including duplicate/custom targets. +$('#link_target').val('first.rrd:in:out custom:second third.rrd').trigger('input'); +assert.equal($('#wm-link-measured-sources button').length,2); +$('#wm-link-measured-sources button').eq(0).trigger('click'); +assert.equal($('#link_target').val(),'first.rrd:in:out third.rrd'); +assert.equal($('#link_infourl').val(),clickBeforeSources); +assert.equal($('#link_hover').val(),hoverBeforeSources); +assert.match($('#wm-link-source-status').text(),/removed/); +$('#link_target').val('same.rrd same.rrd').trigger('input'); +$('#wm-link-measured-sources button').eq(0).trigger('click'); +assert.equal($('#link_target').val(),'same.rrd','Remove one occurrence, not every duplicate'); +$('#wm-link-measured-sources button').trigger('click'); +assert.equal($('#link_target').val(),'same.rrd','Primary source cannot be removed using the extra-source controls'); +assert.equal($('#wm-link-measured-sources button').length,0); +assert.match($('#wm-link-measured-sources').text(),/Primary/); +$('#link_target').val('').trigger('input'); +$('#wm-link-source-picker').data('wm-choice',items[1]); +w.wmApplyLinkTrafficSource(); +assert.equal($('#link_target').val(),''); +assert.equal($('#wm-link-source-status').text(),w.wmEditorText.choosePrimaryFirst); +assert.equal($('#link_hover').val(),hoverBeforeSources); +assert.equal($('#link_infourl').val(),clickBeforeSources); +$('#link_target').val('custom.rrd').trigger('input'); +assert.equal($('#wm-link-measured-sources img').length,0); +assert.equal($('#wm-link-measured-sources code').text(),'custom.rrd'); +$('#link_target_picker').data('wm-choice',items[0]);$('#wm-link-use').trigger('click'); +assert.equal($('#wm-link-measured-sources code').text(),items[0].id,'Use interface refreshes list'); +$('#link_target').val(items[0].id+' custom:backup third.rrd'); +$('#link_target_picker').data('wm-choice',items[1]);$('#wm-link-use').trigger('click'); +assert.equal($('#link_target').val(),items[1].id+' custom:backup third.rrd'); +assert.equal($('#wm-link-measured-sources button').length,2); +assert.match($('#wm-link-source-count').text(),/3/); +const primaryChoice = $('#link_target_picker').data('wm-choice'); +$('#wm-link-measured-sources button').first().trigger('click'); +assert.equal($('#link_target_picker').data('wm-choice'),primaryChoice); +assert.equal($('#link_target').val(),items[1].id+' third.rrd'); +// Restore the custom click/hover setup for the independent editor assertions below. +$('#link_infourl').val(clickBeforeSources);$('#link_hover').val(hoverBeforeSources);w.wmInitLinkHoverParts(); +// Manual expert edits remain serializable and cannot be overwritten by pending automatic lookups. +$('#link_target').val('/custom/source.rrd').trigger('input'); +assert.equal($('#wm-link-use').prop('disabled'),true); +assert.match($('#wm-link-source-status').text(),/Custom traffic sources changed/); +assert.equal($('#wm-link-primary-hover').prop('readonly'),false); +$('#wm-link-primary-hover').val('/cacti/graph_image.php?local_graph_id=51').trigger('input'); +$('#wm-link-extra-hover').val('/cacti/graph_image.php?local_graph_id=52').trigger('input'); +assert.equal($('#link_hover').val(),'/cacti/graph_image.php?local_graph_id=51 /cacti/graph_image.php?local_graph_id=52'); +assert.equal($('#link_infourl').val(),clickBeforeSources); +w.compactLinkEditor(); +assert.equal($('#wm-link-sections .wm-link-group:not([hidden])').length,0); +assert.equal($('#wm-link-sections .wm-link-section-toggle[aria-expanded=true]').length,0); +assert.equal($('#wm-link-source-picker').val(),''); +assert.equal($('#link_target_picker_add').prop('disabled'),true); +assert.equal($('#link_target').val(),'/custom/source.rrd'); +// The preview list removes only the selected extra, preserving traffic and click URLs. +const savedClick = $('#link_infourl').val(); +const savedPrimary = $('#wm-link-primary-hover').val(); +w.wmSetLinkExtraHover('/cacti/graph_image.php?local_graph_id=112',true); +assert.equal($('#wm-link-extra-list img').length,0); +assert.equal($('#wm-link-extra-list a').length,0); +assert.equal($('#wm-link-extra-list tr td:first').text(),'Additional graph'); +w.wmSetLinkExtraHover('/cacti/graph_image.php?local_graph_id=113',false); +assert.match($('#wm-link-extra-hover').val(),/local_graph_id=112/); +assert.doesNotMatch($('#wm-link-extra-hover').val(),/local_graph_id=113/); +assert.match($('#wm-link-hover-status').text(),/One additional graph maximum/); +w.wmSetLinkExtraHover('/cacti/graph_image.php?local_graph_id=113',true); +assert.match($('#wm-link-extra-hover').val(),/local_graph_id=113/); +assert.doesNotMatch($('#wm-link-extra-hover').val(),/local_graph_id=112/); +assert.equal($('#wm-link-primary-hover').val(),savedPrimary); +assert.equal($('#link_infourl').val(),savedClick); +$('#wm-link-extra-list button').trigger('click'); +assert.equal($('#wm-link-extra-hover').val(),''); +assert.equal($('#wm-link-primary-hover').val(),savedPrimary); +assert.equal($('#link_infourl').val(),savedClick); +// Reopening a single extra graph restores its readable picker selection. +$('').appendTo(w.document.body); +$('#wm-link-extra-hover').val('/cacti/graph_image.php?local_graph_id=112'); +w.wmSyncLinkHoverParts(); +graphRequests.length=0; +w.wmRestoreLinkGraphChoice(); +graphRequests[0].deferred.resolve({items:[{id:112,label:'Router CPU Usage',rrds:['router_cpu.rrd','router_cpu2.rrd']}]}); +assert.equal($('#link_picker_input').val(),'Router CPU Usage'); +assert.equal($('#link_picker').val(),'112'); +assert.equal($('#wm-link-extra-list .wm-link-extra-name').text(),'Router CPU Usage'); +assert.equal($('#wm-link-extra-list .wm-link-extra-rrds').text(),'router_cpu.rrd, router_cpu2.rrd'); +graphRequests.length=0; +w.wmRestoreLinkGraphChoice(); +$('#link_picker_input').val('New search'); +graphRequests[0].deferred.resolve({items:[{id:112,label:'Old selection'}]}); +assert.equal($('#link_picker_input').val(),'New search'); console.log( 'PASS: current interface, retained selection, explicit apply, advanced fields, action names, URL decoding and failed-request retry' ); diff --git a/tests/Browser/LinkSourcePickerTest.cjs b/tests/Browser/LinkSourcePickerTest.cjs new file mode 100644 index 00000000..d735b0da --- /dev/null +++ b/tests/Browser/LinkSourcePickerTest.cjs @@ -0,0 +1,33 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('fs'),path=require('path'),assert=require('assert/strict'); +(async()=>{ +const root=path.resolve(__dirname,'../..'); +const ids=['viastyle','link_target','link_infourl','link_hover','wm-link-primary-hover','wm-link-extra-hover','link_template','link_picker','link_bandwidth_in','link_bandwidth_out','link_width','link_commentin','link_commentout']; +const dom=new JSDOM('
'+ids.map(id=>'').join('')+'
'+id+'
Interface
Help
',{runScripts:'outside-only',pretendToBeVisual:true,url:'https://example.test/cacti/plugins/weathermap/editor.php'}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$; +w.eval(fs.readFileSync(path.join(process.env.CACTI_JS_DIR,'jquery-ui.js'),'utf8')); +$.fn.textBoxWidth=()=>200; +const item={id:'replacement.rrd',label:'Router — Replacement',local_graph_id:25,value:'Router — Replacement'}; +$.getJSON=(url,data,cb)=>{if(typeof data==='function')cb=data;let d=$.Deferred();let result={items:[item],offset:0,next_offset:null};if(cb)cb(result);d.resolve(result);return d.promise()}; +w.wmEditorText=Object.fromEntries([...fs.readFileSync(root+'/lib/editor.inc.php','utf8').matchAll(/'([a-zA-Z]+)'\s*=> __\('([^']*)', (?:'%s', )?'weathermap'\)/g)].map(m=>[m[1],m[2]])); +w.infoUrlTarget='graph.php?rra_id=all&local_graph_id=';w.Nodes={};w.Links={};w.local_graph_id=0; +const src=fs.readFileSync(root+'/js/editor.js','utf8'); +w.eval(src.slice(src.indexOf('function graphPicker()'),src.indexOf("$(document).on('unload'"))); +w.eval(src.slice(src.indexOf('// Compact link workflow;'))); +w.graphPicker();$('#link_target').val('one.rrd two.rrd');$('#link_infourl').val('/original');$('#link_hover').val('/hover'); +w.compactLinkEditorBase();$('#dlgLinkProperties').dialog({width:650});$('#wm-link-advanced-button').trigger('click'); +const picker=$('#wm-link-source-picker');picker.val('Replacement');picker.autocomplete('search','Replacement'); +assert.equal($('#link_target_picker_rep').length,0); +assert.equal($('#link_target_picker_add').prop('disabled'),true); +picker.autocomplete('widget').find('.ui-menu-item-wrapper').first().trigger('mouseenter').trigger('click'); +assert.equal($('#link_target_picker_add').prop('disabled'),false); +$('#link_target_picker_add').trigger('click'); +assert.equal($('#link_target').val(),'one.rrd two.rrd replacement.rrd'); +assert.equal($('#link_infourl').val(),'/original'); +assert.equal($('#link_hover').val(),'/hover'); +assert.equal($('#wm-link-measured-sources button').length,2); +$('#wm-link-measured-sources button').first().trigger('click'); +assert.equal($('#link_target').val(),'one.rrd replacement.rrd'); +assert.equal($('#wm-link-measured-sources .wm-link-source-item').first().find('button').length,0); +console.log('PASS: real source picker adds extras, preserves primary and graph URLs, and offers no replace-all control'); +w.close(); +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/tests/Browser/NodeLabelPositionTest.cjs b/tests/Browser/NodeLabelPositionTest.cjs new file mode 100644 index 00000000..3fb7e69e --- /dev/null +++ b/tests/Browser/NodeLabelPositionTest.cjs @@ -0,0 +1,9 @@ +const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),assert=require('node:assert/strict'); +const dom=new JSDOM('',{runScripts:'outside-only'}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$; +const source=fs.readFileSync(require('node:path').join(__dirname,'../../js/editor.js'),'utf8'); +w.eval(source.slice(source.indexOf('function wmPrimeNodeLabelPosition('),source.indexOf('function prime_node_form('))); +for(const [node,want] of [[{},'C'],[{labeloffset:'C'},'C'],[{labeloffset:'N60'},'N60'],[{labeloffset:'S60'},'S60'],[{labeloffset:'N'},'keep'],[{labeloffsetx:12,labeloffsety:-9},'keep']]){ + w.wmPrimeNodeLabelPosition(node);assert.equal($('#node_labelposition').val(),want);assert.equal($('#node_labelposition option[value=keep]').prop('hidden'),want!=='keep'); +} +console.log('PASS: default/Top/Middle/Bottom and preservation of custom positions');w.close(); diff --git a/tests/Browser/PickerPaginationTest.cjs b/tests/Browser/PickerPaginationTest.cjs index 89466952..de5d0e54 100644 --- a/tests/Browser/PickerPaginationTest.cjs +++ b/tests/Browser/PickerPaginationTest.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const vm = require('vm'); const assert = require('node:assert/strict'); (async () => { - const dom = new JSDOM('', {runScripts: 'outside-only'}); + const dom = new JSDOM('', {runScripts: 'outside-only', url: 'https://example.test/cacti/plugins/weathermap/editor.php'}); const w = dom.window, $ = jquery(w); w.$ = $; let options, requestUrl, responseItems, failure; @@ -23,6 +23,8 @@ const assert = require('node:assert/strict'); vm.runInContext(fs.readFileSync(require('node:path').join(__dirname, '../../js/editor.js'), 'utf8'), dom.getInternalVMContext()); w.initJS = () => {}; w.graphPicker(); + assert.equal($('#link_picker_add').val(), 'Add hover graph'); + assert.equal($('#link_picker_rep').val(), 'Replace hover graphs'); options.source({term: 'A&B'}, data => { responseItems = data; }); assert.equal(requestUrl.searchParams.get('term'), 'A&B'); assert.equal(requestUrl.searchParams.get('paged'), '1'); @@ -47,6 +49,50 @@ const assert = require('node:assert/strict'); options.select({}, {item: {label: 'Chosen', id: 115, local_graph_id: 115}}); assert.equal($('#link_picker').val(), '115'); assert.equal($('#link_picker_input').val(), 'Chosen'); + $('').appendTo(w.document.body); + $('#link_target').val('traffic.rrd'); + for (const style of [0, 1]) { + w.infoUrlStyle = style; + w.infoUrlTarget = 'graph.php?rra_id=all&local_graph_id='; + const destination = '/cacti/graph.php?rra_id=all&local_graph_id=108'; + const originalHover = '/cacti/graph_image.php?local_graph_id=108&graph_width=300'; + $('#link_infourl').val(destination); + $('#link_hover').val(originalHover); + w.wmInitLinkHoverParts(); + const main = $('#wm-link-primary-hover').val(); + $('#link_picker').val('112'); + $('#link_picker_add').trigger('click'); + assert.equal($('#link_infourl').val(), destination, 'Add preserves the traffic click destination'); + assert.equal($('#link_target').val(), 'traffic.rrd', 'Add preserves the traffic data source'); + assert.equal($('#wm-link-primary-hover').val(), main); + assert.equal($('#link_hover').val().split(/\s+/).length, 2); + const cpuUrl = new URL($('#wm-link-extra-hover').val(), w.location.href); + assert.equal(cpuUrl.searchParams.get('local_graph_id'), '112'); + assert.equal(cpuUrl.searchParams.get('graph_width'), '300'); + assert.equal(cpuUrl.searchParams.get('graph_height'), '100'); + $('#link_picker').val(''); + $('#link_picker_add').trigger('click'); + assert.equal($('#link_hover').val().split(/\s+/).length, 2, 'No selection leaves hovers untouched'); + $('#link_picker').val('115'); + $('#link_picker_rep').trigger('click'); + assert.equal($('#wm-link-primary-hover').val(), main, 'Replace extras keeps the traffic graph'); + assert.equal($('#link_hover').val().split(/\s+/).length, 2); + assert.equal(new URL($('#wm-link-extra-hover').val(), w.location.href).searchParams.get('local_graph_id'), '115'); + assert.equal(new URL($('#link_hover').val().split(/\s+/)[0], w.location.href).searchParams.get('local_graph_id'), '108'); + $('#link_picker').val('108'); + $('#link_picker_add').trigger('click'); + assert.equal($('#link_hover').val().split(/\s+/).length, 2, 'Do not duplicate the protected traffic graph'); + $('#wm-link-extra-hover').val('').trigger('input'); + assert.equal($('#link_hover').val(), main, 'Clearing extras keeps traffic'); + assert.equal($('#link_infourl').val(), destination, 'Replace hover graphs preserves click destination'); + assert.equal($('#link_target').val(), 'traffic.rrd', 'Replace hover graphs preserves measured traffic'); + } + $('#link_hover').val('/cacti/graph_image.php?local_graph_id=112 /cacti/graph_image.php?local_graph_id=108'); + w.wmInitLinkHoverParts(); + w.wmPromoteLinkTrafficHover(108); + assert.equal(new URL($('#wm-link-primary-hover').val(), w.location.href).searchParams.get('local_graph_id'), '108'); + assert.equal(new URL($('#wm-link-extra-hover').val(), w.location.href).searchParams.get('local_graph_id'), '112'); + assert.equal($('#link_hover').val().split(/\s+/).length, 2); console.log('PASS: encoded searches, paging controls, selection preservation, filter resets, and request failure handling'); w.close(); })().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/tests/Support/CommentColourLocationRegression.php b/tests/Support/CommentColourLocationRegression.php new file mode 100644 index 00000000..872601b6 --- /dev/null +++ b/tests/Support/CommentColourLocationRegression.php @@ -0,0 +1,20 @@ +ReadConfig($file);$data=wm_asset_colour_data($m);$data['commentcolour']='12 34 56';wm_asset_save_colours($m,$data); + colour_check($m->links['DEFAULT']->commentfontcolour===[12,34,56],'Default changes'); + colour_check($m->links['normal']->commentfontcolour===[12,34,56],'Matching links change'); + colour_check($m->links['custom']->commentfontcolour==[255,0,0],'Custom link retained'); + wm_comment_apply_style($m,null,null,'1');colour_check($m->links['DEFAULT']->commentfontcolour===[12,34,56],'Map Style retains colour'); + $m->WriteConfig($file);$n=new WeatherMap();$n->ReadConfig($file);colour_check(wm_asset_colour_data($n)['commentcolour']==='12 34 56','Custom round trip'); + $data=wm_asset_colour_data($n);$data['commentcolour']='contrast';wm_asset_save_colours($n,$data);colour_check($n->links['DEFAULT']->commentfontcolour===[-3,-3,-3],'Contrast supported'); + unset($data['commentcolour']);wm_asset_save_colours($n,$data);colour_check($n->links['DEFAULT']->commentfontcolour===[-3,-3,-3],'Omitted value retained'); + $data['commentcolour']='999 0 0';try{wm_asset_save_colours($n,$data);throw new Exception('Invalid colour accepted');}catch(InvalidArgumentException $e){} + colour_check(strpos(wm_comment_style_fields($n),'mapstyle_commentcolour')===false,'Removed from Map Style'); + echo "PASS: comment colour in Manage Colours, custom/contrast persistence and Map Style isolation\n"; +} finally {if(is_file($file))unlink($file);} diff --git a/tests/Support/HoverGraphFilesRegression.php b/tests/Support/HoverGraphFilesRegression.php new file mode 100644 index 00000000..0e2d5be1 --- /dev/null +++ b/tests/Support/HoverGraphFilesRegression.php @@ -0,0 +1,17 @@ +'','graph_ids'=>'1,2','include_rrds'=>'1','paged'=>'1'][$key] ?? ''; } +function get_filter_request_var($key) { return 0; } +function cacti_sizeof($array) { return count($array); } +function is_graph_allowed($id) { return $id === 1; } +function __($text,$domain) { return $text; } +function db_fetch_assoc_prepared($query,$params) { + if (strpos($query,'SELECT DISTINCT gtg.') !== false) return [['id'=>1,'title'=>'Allowed'],['id'=>2,'title'=>'Denied']]; + if ($params !== [1]) throw new Exception('RRD query includes unauthorised graph'); + return [['id'=>1,'path'=>'/router_ping.rrd'],['id'=>1,'path'=>'/router_ping.rrd'],['id'=>1,'path'=>'/rra/other.rrd']]; +} +$source=file_get_contents(__DIR__.'/../../lib/editor.inc.php');$start=strpos($source,'function display_graphs()');$end=strpos($source,'function display_datasources()'); +// Evaluate just this declaration, excluding the following function's docblock. +eval(substr($source,$start,$end-$start)); +ob_start();display_graphs();$data=json_decode(ob_get_clean(),true); +if (count($data['items'])!==1 || $data['items'][0]['rrds']!==['router_ping.rrd','other.rrd']) throw new Exception('Incorrect RRD metadata'); +echo "PASS: graph RRD filenames are deduplicated and restricted to permitted graph IDs\n"; diff --git a/tests/Support/NodeLabelPositionRegression.php b/tests/Support/NodeLabelPositionRegression.php new file mode 100644 index 00000000..d92c14c8 --- /dev/null +++ b/tests/Support/NodeLabelPositionRegression.php @@ -0,0 +1,23 @@ +$position){$x=90+$i*150;$cfg.="NODE n$i\n LABEL NAS\n ICON images/objects/corporate-building.png\n POSITION $x 100\n LABELOFFSET $position\n\n";} + file_put_contents($file,$cfg);$map=new WeatherMap();$map->ReadConfig($file);$map->DrawMap($dir.'/positions.png'); + foreach(['N60','C','S60'] as $i=>$position){ + $node=$map->nodes['n'.$i];$boxes=$node->boundingboxes;$label=end($boxes);$y=($label[1]+$label[3])/2; + label_check($i===0?$y<90:($i===1?abs($y-100)<2:$y>110),'Rendered position '.$position); + label_check($label[1]>=$boxes[0][1] && $label[3]<=$boxes[0][3],'Label stays over icon'); + label_check(strpos($node->asJS(),'labeloffset:')!==false,'JS metadata'); + label_check(strpos($node->asJSON(),'"labeloffset":')!==false,'JSON metadata'); + } + $map->WriteConfig($file);$loaded=new WeatherMap();$loaded->ReadConfig($file); + foreach(['N60','C','S60'] as $i=>$position)label_check($loaded->nodes['n'.$i]->labeloffset===$position,'Round trip '.$position); + echo "PASS: Top/Middle/Bottom render over icons and survive configuration round trip\n"; +} finally {foreach(new DirectoryIterator($dir) as $p){if(!$p->isDot())unlink($p->getPathname());}rmdir($dir);} diff --git a/tests/Unit/MapStyleDefaultsTest.php b/tests/Unit/MapStyleDefaultsTest.php index 35df53ad..769bd9c6 100644 --- a/tests/Unit/MapStyleDefaultsTest.php +++ b/tests/Unit/MapStyleDefaultsTest.php @@ -66,9 +66,9 @@ function wm_style_test_map() { $map->fonts[102]->file = '/missing/custom.ttf'; expect(wm_editor_font_label(102,$map->fonts[102]))->toContain('custom.ttf'); $map->links['DEFAULT']->commentfontcolour = [12, 34, 56]; - expect(wm_comment_style_fields($map))->toContain('Current custom colour (12 34 56)','value="12 34 56" selected'); + expect(wm_comment_style_fields($map))->not->toContain('mapstyle_commentcolour'); $map->links['DEFAULT']->commentfontcolour = [-3, -3, -3]; - expect(wm_comment_style_fields($map))->toContain('value="contrast" selected'); + expect(wm_comment_style_fields($map))->not->toContain('mapstyle_commentcolour'); }); it('RejectsInvalidFontSelections', function ($value) {expect(wm_comment_resolve_font(wm_style_test_map(),$value))->toBeNull(); })->with([null, 123, '999', 'bundled:../Vera.ttf:9', 'bundled:Missing.ttf:9', 'bundled:Vera.ttf:99']); it('ReusesExistingFontsAndAllocatesAnUnusedNumber', function () { @@ -121,7 +121,7 @@ function wm_style_test_map() { $document = new DOMDocument(); $document->loadHTML('' . wm_comment_style_fields(wm_style_test_map()) . '
', LIBXML_NOERROR | LIBXML_NOWARNING); $xpath = new DOMXPath($document); - foreach (['mapstyle_commentfont' => 'Link Comment Font', 'mapstyle_commentcolour' => 'Link Comment Colour'] as $id => $text) { + foreach (['mapstyle_commentfont' => 'Link Comment Font'] as $id => $text) { $label = $xpath->query('//label[@for="' . $id . '"]')->item(0); expect($label)->not->toBeNull()->and($label->textContent)->toBe($text) ->and($xpath->query('//select[@id="' . $id . '"]')->length)->toBe(1); diff --git a/tests/Unit/PropertiesWorkflowRegressionTest.php b/tests/Unit/PropertiesWorkflowRegressionTest.php new file mode 100644 index 00000000..58e69ea3 --- /dev/null +++ b/tests/Unit/PropertiesWorkflowRegressionTest.php @@ -0,0 +1,29 @@ +'','graph_ids'=>'1,2','include_rrds'=>'1','paged'=>'1']; + $GLOBALS['__test_db_fetch_assoc_prepared']=static function($sql,$params){ + if(strpos($sql,'SELECT DISTINCT gtg.')!==false)return [['id'=>1,'title'=>'Allowed'],['id'=>2,'title'=>'Denied']]; + self::assertSame([1],$params); + return [['id'=>1,'path'=>'/router_ping.rrd'],['id'=>1,'path'=>'/router_ping.rrd'],['id'=>1,'path'=>'C:\\rra\\other.rrd'],['id'=>2,'path'=>'denied.rrd'],['id'=>1,'path'=>'']]; + }; + ob_start();display_graphs();$data=json_decode(ob_get_clean(),true); + self::assertCount(1,$data['items']);self::assertSame(['router_ping.rrd','other.rrd'],$data['items'][0]['rrds']); + } +} diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index c5dbe434..53b197aa 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -432,7 +432,7 @@ - + , @@ -443,6 +443,7 @@ + @@ -513,7 +514,7 @@ - +
@@ -558,7 +559,7 @@ - + @@ -566,7 +567,7 @@ - + ' /> @@ -574,19 +575,21 @@ pixels - + + +

- + - + - + + ' data-replace-label='' /> @@ -642,7 +645,7 @@
- + @@ -650,10 +653,15 @@
- Bandwidth: Set the capacity used to calculate link utilisation, for example 100M or 1G. K, M, G and T are supported. Use the same value for both directions unless their capacities differ.

-

Interface: Search by device, port or description, then select Use interface to fill the traffic source, click destination and hover graph together. Save applies the changes.

-

Comments: Add text to display along each direction of the link. The percentage controls its position along the line.

-

Advanced: Use custom data sources or combine multiple sources, choose different graphs, change the click destination, or adjust line shape. Data Source(s) controls the measured traffic; Info URL(s) controls where a click goes; Hover Graph URL(s) controls the images shown on hover.

', 'weathermap'); ?> + Bandwidth: Set the capacity used to calculate utilisation, for example 100M or 1G. K, M, G and T are supported. Select Same As "In" when both directions have equal capacity.

+

Traffic interface: Search by device, port or description, or select Browse all. Choose an interface, then click Use interface to set the primary measured source, click destination and main hover graph. Additional measured sources and hover graphs are kept.

+

Appearance and comments: Via Style controls curved or angled bends; Link Width controls arrow thickness. IN Comment and OUT Comment add text along each direction. The percentage sets the text position along the link.

+

Additional hover graphs: Show a maximum of two hover graphs: the main traffic graph and one extra, such as CPU, latency or another interface. Filter by graph type narrows the choices. Add hover graph adds the extra; Replace hover graphs changes it; Remove graph removes it. The main traffic graph, measured traffic and link click destination stay unchanged. In the viewer, each Cacti preview opens its own full graph when clicked.

+

Advanced settings: Optional controls for combining measured sources, changing the click destination and entering custom hover image URLs. A normal traffic link usually needs no changes here.

+

Combining traffic sources: Add traffic source adds another interface to the map total, separately for IN and OUT. Remove source removes an additional source. Change the primary source using Use interface at the top. The combined traffic is compared with the configured bandwidth to calculate utilisation. No combined Cacti graph is created automatically; create a matching aggregate graph in Cacti and enter its click and hover URLs if needed. Separate map links are usually clearer for primary and backup connections.

+

Manual sources and URLs: The source text box edits the complete measured-source list, including the primary source. Click destination URL controls where clicking the map link goes. Main hover graph URL sets the first preview; Additional graph URL(s) accepts one extra image URL. This extra is also managed under Additional hover graphs.

+

Tidy and Via: Tidy adjusts the link layout. Via lets you place a bend point on the map.

+

Edit Raw Configuration: Edit the underlying map syntax for settings beyond this form.

', 'weathermap'); ?>
@@ -886,7 +894,7 @@
-
+

@@ -926,7 +934,7 @@ -
'> +
'>

From 747a70c34d96602ea57377bc87a2a719578fa5ac Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:02 +1030 Subject: [PATCH 04/46] Make hover graphs clickable and sharper on high-density displays --- CHANGELOG.md | 2 + js/weathermap.js | 88 +++++++++++++++++++++++- locales/po/cacti.pot | 14 ++-- setup.php | 22 ++++++ tests/Browser/ClickableHoverTest.cjs | 55 +++++++++++++++ tests/Browser/HoverDensityTest.cjs | 10 +++ tests/Browser/HoverInteractionTest.cjs | 9 ++- tests/Browser/TrafficGlowTest.cjs | 2 +- tests/Support/HoverDensityRegression.php | 14 ++++ tests/Unit/HoverDensityOptionsTest.php | 20 ++++++ 10 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 tests/Browser/ClickableHoverTest.cjs create mode 100644 tests/Browser/HoverDensityTest.cjs create mode 100644 tests/Support/HoverDensityRegression.php create mode 100644 tests/Unit/HoverDensityOptionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..2cc4c824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* feature: Make Cacti hover previews independently clickable and sharper on high-density displays; hide generated clone captions and preserve popup pointer/keyboard interaction + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/js/weathermap.js b/js/weathermap.js index b9a8efb9..56b8aaf3 100644 --- a/js/weathermap.js +++ b/js/weathermap.js @@ -1,3 +1,53 @@ +// Link local Cacti previews to their corresponding full graph, independently of INFOURL. +function wmHoverGraphDestination(source) { + try { + var url = new URL(source, window.location.href); + if (url.origin !== window.location.origin || !/\/graph_image\.php$/.test(url.pathname)) return null; + var id = url.searchParams.get('local_graph_id'); + if (!/^[1-9][0-9]*$/.test(id || '')) return null; + url.pathname = url.pathname.replace(/graph_image\.php$/, 'graph.php'); + url.search = ''; url.hash = ''; + url.searchParams.set('rra_id', 'all'); + url.searchParams.set('local_graph_id', id); + return url.pathname + url.search; + } catch (error) { return null; } +} + +// Density descriptors keep the preview's CSS size and text proportions unchanged. +function wmSharpHoverGraph(image) { + var original = image.attr('src'); + if (!wmHoverGraphDestination(original) || image.attr('srcset')) return; + var url = new URL(original, window.location.href); + if (url.searchParams.has('image_format') && url.searchParams.get('image_format').toLowerCase() !== 'png') return; + var width = url.searchParams.get('graph_width'), height = url.searchParams.get('graph_height'); + if (!/^[1-9][0-9]*$/.test(width || '') || Number(width) > 1000 || !/^[1-9][0-9]*$/.test(height || '') || Number(height) > 400) return; + url.searchParams.set('wm_hidpi', '2'); + url.searchParams.set('image_format', 'png'); + image.one('error.wmHiDpi', function() { $(this).removeAttr('srcset'); }); + image.attr('srcset', url.href + ' 2x'); +} + +function wmInteractiveHover(map) { + var instance = map.tooltip('instance'); + if (!instance || typeof instance.close !== 'function' || instance.wmInteractiveHover) return; + var state = instance.wmInteractiveHover = {timer: null, close: instance.close}; + instance.close = function(event) { + clearTimeout(state.timer); + if (event && (event.type === 'mouseleave' || event.type === 'focusout')) { + var target = $(event.currentTarget), data = instance._find(target); + var tip = data && data.tooltip; + if (tip && event.relatedTarget && (tip[0].contains(event.relatedTarget) || target[0] === event.relatedTarget)) return; + var delayed = $.Event(event); delayed.currentTarget = target[0]; + state.timer = setTimeout(function() { + if (tip && (tip.data('wm-pointer-inside') || tip[0].contains(document.activeElement))) return; + state.close.call(instance, delayed); + }, 300); + return; + } + return state.close.apply(instance, arguments); + }; +} + function initWeatherMapView() { if (typeof $.fn.tooltip === 'function') $('map').tooltip({ items: 'area', @@ -15,9 +65,33 @@ function initWeatherMapView() { $('.ui-helper-hidden-accessible').hide(); var id = $(ui.tooltip).attr('id'); + $('map').each(function() { + var widget = $(this).tooltip('instance'); + if (!widget || !widget.wmInteractiveHover) return; + clearTimeout(widget.wmInteractiveHover.timer); + $.each(widget.tooltips, function(otherId, item) { + if (otherId !== id) widget.wmInteractiveHover.close.call(widget, $.Event('mouseleave', {currentTarget:item.element[0]})); + }); + }); $('div.ui-tooltip').not('#'+ id).remove(); - ui.tooltip.css({width: 'max-content', maxWidth: 'calc(100vw - 48px)', pointerEvents: 'none'}); + ui.tooltip.css({width: 'max-content', maxWidth: 'calc(100vw - 48px)', pointerEvents: 'auto'}); + var area = $(event.originalEvent.target).closest('area'), instance = $(this).tooltip('instance'); + if (instance && instance.wmInteractiveHover) { + var state = instance.wmInteractiveHover; + clearTimeout(state.timer); + area.off('.wmGraphHover').on('mouseenter.wmGraphHover', function() { clearTimeout(state.timer); }); + ui.tooltip.on('mouseenter.wmGraphHover focusin.wmGraphHover', function(e) { + clearTimeout(state.timer); + if (e.type === 'mouseenter') $(this).data('wm-pointer-inside', true); + }).on('mouseleave.wmGraphHover focusout.wmGraphHover', function(e) { + $(this).data('wm-pointer-inside', false); + if (e.relatedTarget && this.contains(e.relatedTarget)) return; + instance.close($.Event(e.type, {currentTarget:area[0], relatedTarget:e.relatedTarget})); + }).on('keydown.wmGraphHover', function(e) { + if (e.key === 'Escape') state.close.call(instance, $.Event('keyup', {currentTarget:area[0]})); + }); + } ui.tooltip.position({ my: 'left top+5%', at: 'right+15 center', @@ -43,17 +117,25 @@ function initWeatherMapView() { // Fit the popup to its graphs instead of inheriting full-width table styles. var caption = ($(this).attr('data-caption') || '').trim(); var data = $('
', {id: 'wm_hover'}).css({display: 'inline-block'}); - if (caption && !/^node[0-9]+[a-z]*(?:-node[0-9]+[a-z]*)?$/i.test(caption)) { + // Generated node/link names can include repeated clone suffixes and collision letters. + if (caption && !/^node[0-9]+[a-z]*(?:_copy)*(?:-node[0-9]+[a-z]*(?:_copy)*)?[a-z]*$/i.test(caption)) { $('
').text(caption).css({fontSize: '12px', padding: '0 0 4px'}).appendTo(data); } object.find('img').css({ display: 'block', width: 'auto', height: 'auto', maxWidth: Math.min((parseInt(width, 10) || 800) * 1.1, window.innerWidth - 48) + 'px' }); + object.find('img').each(function() { + wmSharpHoverGraph($(this)); + var destination = wmHoverGraphDestination($(this).attr('src')); + if (!destination || $(this).closest('a').length) return; + var label = $(this).attr('alt') || 'Open graph ' + new URL(destination, window.location.href).searchParams.get('local_graph_id'); + $(this).wrap($('').attr({href:destination, 'aria-label':label, title:label})); + }); $('
', {class: 'wmcontent'}).append(object).appendTo(data); callback(data); } - }); + }).each(function() { wmInteractiveHover($(this)); }); waitForFinalEvent(function() { $('.cactiGraphContentArea').removeClass('cactiGraphContentArea').addClass('wm_scroll'); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18edb16a..4a0b082b 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR The Cacti Group -# This file is distributed under the same license as the Cacti package. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti 1.7\n" -"Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 10:19+1030\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-16 20:01+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/setup.php b/setup.php index 3033d3cc..e244202f 100644 --- a/setup.php +++ b/setup.php @@ -72,6 +72,7 @@ function plugin_weathermap_csp_nonce(): string { * @return void */ function plugin_weathermap_install() { + api_plugin_register_hook('weathermap', 'rrd_graph_graph_options', 'weathermap_hover_graph_options', 'setup.php'); api_plugin_register_hook('weathermap', 'config_arrays', 'weathermap_config_arrays', 'setup.php'); api_plugin_register_hook('weathermap', 'config_settings', 'weathermap_config_settings', 'setup.php'); @@ -93,6 +94,22 @@ function plugin_weathermap_install() { weathermap_setup_table(); } +/** Render only explicitly marked, compact Weathermap previews at double density. */ +function weathermap_hover_graph_options($graph) { + if (get_current_page() !== 'graph_image.php' || get_nfilter_request_var('wm_hidpi') !== '2') { + return $graph; + } + $width = get_filter_request_var('graph_width'); + $height = get_filter_request_var('graph_height'); + if ($width < 1 || $width > 1000 || $height < 1 || $height > 400 || !is_array($graph) || !isset($graph['graph_opts'])) { + return $graph; + } + if (!preg_match('/(?:^|\s)--zoom(?:=|\s)/', $graph['graph_opts'])) { + $graph['graph_opts'] .= '--zoom 2' . RRD_NL; + } + return $graph; +} + /** * Plugin uninstall hook: clears the recorded plugin version and drops * this plugin's core database tables (auth, data, maps, groups, @@ -187,6 +204,11 @@ function plugin_weathermap_upgrade() { return null; } + // Also register for existing installations running the same development version. + if (!db_fetch_cell("SELECT id FROM plugin_hooks WHERE name = 'weathermap' AND hook = 'rrd_graph_graph_options' AND `function` = 'weathermap_hover_graph_options'")) { + api_plugin_register_hook('weathermap', 'rrd_graph_graph_options', 'weathermap_hover_graph_options', 'setup.php', true); + } + include_once($config['base_path'] . '/plugins/weathermap/lib/poller-common.php'); $current = plugin_weathermap_version(); diff --git a/tests/Browser/ClickableHoverTest.cjs b/tests/Browser/ClickableHoverTest.cjs new file mode 100644 index 00000000..eb582e5c --- /dev/null +++ b/tests/Browser/ClickableHoverTest.cjs @@ -0,0 +1,55 @@ +const {JSDOM}=require('jsdom'), jquery=require('jquery'), fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +(async()=>{ + const dom=new JSDOM('',{url:'https://cacti.example/cacti/plugins/weathermap/view.php',runScripts:'outside-only',pretendToBeVisual:true}); + const w=dom.window,$=jquery(w);w.$=w.jQuery=$; + w.eval(fs.readFileSync(path.join(process.env.CACTI_JS_DIR,'jquery-ui.js'),'utf8')); + $('area').attr('data-hover',Buffer.from('

').toString('base64')); + w.eval(fs.readFileSync(path.join(__dirname,'../../js/weathermap.js'),'utf8')); + await new Promise(r=>$(r));await new Promise(r=>setImmediate(r)); + const area=$('area'),map=$('map'),instance=map.tooltip('instance'); + function open(){area[0].dispatchEvent(new w.MouseEvent('mouseover',{bubbles:true}));} + const delay=()=>new Promise(r=>w.setTimeout(r,350)); + open();let tip=$('.ui-tooltip'); + assert.equal(tip.length,1);assert.equal(tip.css('pointer-events'),'auto'); + assert.equal(tip.find('a').length,2); + assert.equal(tip.find('a').eq(1).attr('href'),'/cacti/graph.php?rra_id=all&local_graph_id=159'); + assert.equal(area.attr('href'),'/cacti/graph.php?local_graph_id=108'); + area.trigger($.Event('mouseleave',{relatedTarget:tip[0]})); + tip.trigger('mouseenter');await delay(); + assert.equal($('.ui-tooltip').length,1,'Popup remains while entering preview'); + tip.trigger($.Event('mouseleave',{relatedTarget:w.document.body}));await delay(); + assert.equal($('.ui-tooltip').length,0,'Popup closes after leaving both link and previews'); + open();tip=$('.ui-tooltip');area.trigger('mouseleave'); + tip.find('a')[0].focus();await delay(); + assert.equal($('.ui-tooltip').length,1,'Keyboard focus keeps previews open'); + tip.find('a').trigger($.Event('keydown',{key:'Escape'})); + assert.equal($('.ui-tooltip').length,0,'Escape closes focused preview'); + open();area.trigger('mouseleave');await delay(); + assert.equal($('.ui-tooltip').length,0,'Ordinary mouse leave closes without sticking'); + // Switching quickly between hotspots must not leave the first area marked open. + const other=area.clone().appendTo(map); + open();area.trigger('mouseleave'); + other[0].dispatchEvent(new w.MouseEvent('mouseover',{bubbles:true})); + assert.equal($('.ui-tooltip').length,1); + assert.equal(area.data('ui-tooltip-open'),undefined); + other.trigger('mouseleave');open(); + assert.equal($('.ui-tooltip').length,1,'First hotspot can reopen after switching'); + instance.close($.Event('keyup',{currentTarget:area[0]})); + // Generated IDs stay hidden for cloned nodes, cloned endpoints and parallel links. + for (const caption of ['node08121', 'node08121_copy', 'node08121_copy_copy', + 'node07486-node08121_copy', 'node07486_copy_copy-node08121_copya', + 'node07486a-node08121_copy_copyaa']) { + area.attr('data-caption',caption);open(); + assert.equal($('#wm_hover').children().length,1,'No generated caption: '+caption); + instance.close($.Event('keyup',{currentTarget:area[0]})); + } + for (const caption of ['Grand AP traffic', 'WAN backup', 'Custom caption']) { + area.attr('data-caption',caption);open(); + assert.equal($('#wm_hover').children().first().text(),caption,'Keep custom caption'); + assert.equal($('#wm_hover').find('b').length,0,'Caption remains plain text'); + instance.close($.Event('keyup',{currentTarget:area[0]})); + } + // Reinitialising after AJAX must not wrap close repeatedly. + const close=instance.close;w.initWeatherMapView();assert.equal(instance.close,close); + console.log('PASS: real jQuery UI graph links, pointer transfer, focus, Escape and cleanup');w.close(); +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/tests/Browser/HoverDensityTest.cjs b/tests/Browser/HoverDensityTest.cjs new file mode 100644 index 00000000..8485c64f --- /dev/null +++ b/tests/Browser/HoverDensityTest.cjs @@ -0,0 +1,10 @@ +const {JSDOM}=require('jsdom'), jquery=require('jquery'), fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const dom=new JSDOM('',{url:'https://cacti.example/cacti/plugins/weathermap/view.php',runScripts:'outside-only'}),w=dom.window,$=jquery(w);w.$=$; +const source=fs.readFileSync(path.join(__dirname,'../../js/weathermap.js'),'utf8');w.eval(source.slice(0,source.indexOf('function wmInteractiveHover'))); +const url='/cacti/graph_image.php?local_graph_id=108&graph_width=300&graph_height=100&graph_start=123'; +const image=$('').attr('src',url);w.wmSharpHoverGraph(image); +assert.equal(image.attr('src'),url);const density=image.attr('srcset');assert.match(density,/ 2x$/); +const request=new URL(density.slice(0,-3));assert.equal(request.searchParams.get('wm_hidpi'),'2');assert.equal(request.searchParams.get('graph_width'),'300');assert.equal(request.searchParams.get('graph_height'),'100');assert.equal(request.searchParams.get('graph_start'),'123'); +image.trigger('error');assert.equal(image.attr('srcset'),undefined);assert.equal(image.attr('src'),url); +for(const src of ['https://external.example'+url,'/cacti/graph_image.php?local_graph_id=108',url+'&image_format=svg',url.replace('300','1500'),'/custom.png']){const img=$('').attr('src',src);w.wmSharpHoverGraph(img);assert.equal(img.attr('srcset'),undefined,src);} +console.log('PASS: local compact previews request double density with unchanged dimensions and original-image fallback');w.close(); diff --git a/tests/Browser/HoverInteractionTest.cjs b/tests/Browser/HoverInteractionTest.cjs index 4deeb2c8..bc8fb236 100644 --- a/tests/Browser/HoverInteractionTest.cjs +++ b/tests/Browser/HoverInteractionTest.cjs @@ -4,12 +4,13 @@ const { JSDOM } = require('jsdom'), vm = require('vm'), assert = require('node:assert/strict'); (async () => { - const dom = new JSDOM('', { runScripts: 'outside-only' }), + const dom = new JSDOM('', { runScripts: 'outside-only', url:'https://cacti.example/cacti/plugins/weathermap/view.php' }), w = dom.window, $ = jquery(w); w.$ = $; let opts; $.fn.tooltip = function (options) { + if (options === 'instance') return undefined; opts = options; return this; }; @@ -35,6 +36,12 @@ const { JSDOM } = require('jsdom'), opts.content.call(area[0], (html) => (output = html)); assert.equal($(output).children().not('.wmcontent').text(), 'Uplink '); assert.equal($(output).find('example').length, 0); + area.attr('data-hover', Buffer.from('
').toString('base64')); + opts.content.call(area[0], html => output=html); + assert.deepEqual($(output).find('a').map(function(){return $(this).attr('href');}).get(), ['/cacti/graph.php?rra_id=all&local_graph_id=108','/cacti/graph.php?rra_id=all&local_graph_id=159']); + assert.equal($(output).find('a img').length,2); + assert.equal(w.wmHoverGraphDestination('/cacti/graph_image.php?local_graph_id=-1'),null); + assert.equal(w.wmHoverGraphDestination('javascript:alert(1)'),null); assert.equal(opts.show, false); assert.equal(opts.hide, false); const tip = $('
').appendTo('body'); diff --git a/tests/Browser/TrafficGlowTest.cjs b/tests/Browser/TrafficGlowTest.cjs index 5c573ccb..a6c4a4e5 100644 --- a/tests/Browser/TrafficGlowTest.cjs +++ b/tests/Browser/TrafficGlowTest.cjs @@ -21,7 +21,7 @@ const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),pa assert.equal(active.querySelectorAll('mask polygon').length,1);assert.equal(active.querySelector('area').getAttribute('href'),'graph.php'); assert.equal(disabled.querySelector('svg'),null);assert.equal(quiet.querySelector('svg'),null); $(w.document).trigger('ajaxComplete');assert.equal(active.querySelectorAll('svg').length,1); - let tooltipCalls=0;$.fn.tooltip=function(){tooltipCalls++;return this};w.initWeatherMapView();assert.equal(tooltipCalls,1,'tooltip still initialises when UI exists');assert.equal(observers.length,1); + let tooltipCalls=0;$.fn.tooltip=function(options){if(options==='instance')return undefined;tooltipCalls++;return this};w.initWeatherMapView();assert.equal(tooltipCalls,1,'tooltip still initialises when UI exists');assert.equal(observers.length,1); media.matches=true;media.listener();assert.ok(active.querySelector('svg').classList.contains('wm-glow-paused')); active.remove();$(w.document).trigger('ajaxComplete');assert.ok(observers[0].disconnected);assert.equal(media.listener,null); console.log('Traffic glow colour, masking, reduced motion and cleanup PASS');w.close(); diff --git a/tests/Support/HoverDensityRegression.php b/tests/Support/HoverDensityRegression.php new file mode 100644 index 00000000..27a79ef1 --- /dev/null +++ b/tests/Support/HoverDensityRegression.php @@ -0,0 +1,14 @@ +"--width=300\n",'graph_id'=>108,'graph_defs'=>'keep']; +$GLOBALS['page']='graph_image.php';$GLOBALS['request']=['wm_hidpi'=>'2','graph_width'=>'300','graph_height'=>'100']; +$result=weathermap_hover_graph_options($graph);if($result['graph_opts']!==$graph['graph_opts']."--zoom 2\n" || $result['graph_defs']!=='keep')throw new Exception('Density request failed'); +if(weathermap_hover_graph_options($result)!==$result)throw new Exception('Double zoom'); +foreach([['wm_hidpi'=>'3'],['wm_hidpi'=>['2']],['graph_width'=>3000],['graph_height'=>0]] as $change){$GLOBALS['request']=array_merge(['wm_hidpi'=>'2','graph_width'=>'300','graph_height'=>'100'],$change);if(weathermap_hover_graph_options($graph)!==$graph)throw new Exception('Invalid request changed options');} +$GLOBALS['request']=[];if(weathermap_hover_graph_options($graph)!==$graph)throw new Exception('Normal graph changed'); +$GLOBALS['page']='graph.php';$GLOBALS['request']=['wm_hidpi'=>'2','graph_width'=>300,'graph_height'=>100];if(weathermap_hover_graph_options($graph)!==$graph)throw new Exception('Other page changed'); +echo "PASS: marked compact requests only, bounded scaling, no duplicate zoom and unchanged normal graphs\n"; diff --git a/tests/Unit/HoverDensityOptionsTest.php b/tests/Unit/HoverDensityOptionsTest.php new file mode 100644 index 00000000..b17ae93d --- /dev/null +++ b/tests/Unit/HoverDensityOptionsTest.php @@ -0,0 +1,20 @@ +"--width=300\n",'graph_defs'=>'keep']; + $request=['wm_hidpi'=>'2','graph_width'=>300,'graph_height'=>100]; + test_set_current_page('graph_image.php');$GLOBALS['__test_nfilter_request']=$request; + $scaled=weathermap_hover_graph_options($graph);self::assertSame($graph['graph_opts']."--zoom 2\n",$scaled['graph_opts']);self::assertSame('keep',$scaled['graph_defs']); + self::assertSame($scaled,weathermap_hover_graph_options($scaled)); + foreach([['wm_hidpi'=>'3'],['wm_hidpi'=>['2']],['graph_width'=>0],['graph_width'=>1001],['graph_height'=>0],['graph_height'=>401]] as $override){$GLOBALS['__test_nfilter_request']=array_merge($request,$override);self::assertSame($graph,weathermap_hover_graph_options($graph));} + $GLOBALS['__test_nfilter_request']=$request;test_set_current_page('graph.php');self::assertSame($graph,weathermap_hover_graph_options($graph)); + test_set_current_page('graph_image.php');self::assertSame([],weathermap_hover_graph_options([]));self::assertSame(null,weathermap_hover_graph_options(null)); + } +} From adda0ba29e1eb5f34a597b0e827388c59b67381a Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:02 +1030 Subject: [PATCH 05/46] Bundle additional 96px network-device node icons --- CHANGELOG.md | 2 ++ README.md | 9 ++++++--- images/objects/AP.png | Bin 0 -> 7427 bytes images/objects/CCTV-Camera.png | Bin 0 -> 8627 bytes images/objects/CCTV-NVR.png | Bin 0 -> 6886 bytes images/objects/NAS.png | Bin 0 -> 9414 bytes images/objects/Printer.png | Bin 0 -> 10661 bytes images/objects/Server.png | Bin 0 -> 8716 bytes images/objects/UPS.png | Bin 0 -> 8997 bytes 9 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 images/objects/AP.png create mode 100644 images/objects/CCTV-Camera.png create mode 100644 images/objects/CCTV-NVR.png create mode 100644 images/objects/NAS.png create mode 100644 images/objects/Printer.png create mode 100644 images/objects/Server.png create mode 100644 images/objects/UPS.png diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..75fdce4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* feature: Bundle transparent 96px access-point, NAS, server, printer, CCTV recorder, UPS and CCTV camera node icons + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/README.md b/README.md index db6d0f00..8e5c15a1 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,16 @@ See CHANGELOG.md for the most recent updates, listed by version. See COPYING for the license under which php-weathermap is released. -## Bundled building icons +## Bundled node icons The node icon library in `images/objects/` includes modern corporate building, -branch office, hospital, clinic, data centre and warehouse icons. These -transparent PNGs are 96 × 96 pixels and can be selected from the editor's node +branch office, hospital, clinic, data centre, warehouse and wireless access-point +(`AP.png`) icons, plus NAS (`NAS.png`), server (`Server.png`), printer +(`Printer.png`), CCTV recorder (`CCTV-NVR.png`), UPS (`UPS.png`) and CCTV camera +(`CCTV-Camera.png`) icons. These transparent PNGs are 96 × 96 pixels and can be selected from the editor's node icon picker without uploading additional files. + ## Compatibility This version only works with Cacti 1.2.x onwards. A re-write of the user interface diff --git a/images/objects/AP.png b/images/objects/AP.png new file mode 100644 index 0000000000000000000000000000000000000000..9c477e0b08ef7cb679651b5d34ef539b5fd10a24 GIT binary patch literal 7427 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 LQMyjbH2i-6NjeL+ literal 0 HcmV?d00001 diff --git a/images/objects/CCTV-Camera.png b/images/objects/CCTV-Camera.png new file mode 100644 index 0000000000000000000000000000000000000000..5af393f404d584f1ffb8f56874428b0eb8376eae GIT binary patch literal 8627 zcmV;kAxz$hP)iHBl%_PLDNSk0i$TOQaZ)K|baZrUOH0c?rPJxnw(U@@ zR&^Cc@l{n7j^m0_xh$&HDp{6IuIu?y`ohq4EuG0wy3wkH#*MeW*jIvhD$1Xl zn)&Z7E$Pqdx~^2KRR?)M*L2~yF6Hxinx8KS+p)=)5(n|eNGZwnT`7%7Bsr>15; z*wxYTF?c;ZT2X|mX{wx%N6IHZ z^(m)bul~br`}d!ZF!W+H!V?+*;bGghZSLUU=+@TO%opl)3(8l8Afc+N92p&>{Ol|| zTcGShxbcI!fWwaTeZp$NOL%M;1|2(gOwKJ7#KA*{Qx4-C$Hswj+ ze0ku&flMZoyh#W}IIg1v-mlQ)(;F=Dg*r1LJXdWgw#EwQJVUhyLb+)Y{rg#bS{X$s|cq&U=#A%;61xckkXU z7KJYPe5|g;6QCY`J zZJ*3DK_(_9I&GUerRKK?mxS-zv-jS6@8Gs=+wAaIv2Wi#b>F^y{?45{{pXzlBA%ze zf8ab-Q8ra86-0K$&<%1NhYlY(LjU&N@6n1g&Ypl=4Cnw1J408>Zi)sfI!(k>hC2mWy6BG%_}^r|wwa7D60|2*8k?70*=y@RSCC zx8ko9?xkidCd%b9ya>>fCMKuoz}@$dVaDmU+xO8$TeiqEmUmO7TBV_(VS4Q!ZABQO zYOOBe-GE1`qDo)!Q8Y;3^Q3faTDr80Iy%}2xgA6IGoYDZfTrLa)^m6b#;C7H}r8MRE_&5!Zjnnk>G_7B|j>8OM`QXqnbuU{= z*=&|d3RwYeK$c2Rh=ZRvm1QL&C3xyeY==5~{>R8PDD^*o4 zF=H_TpJdy%=Qs{LxRDbSp+NU2u91VJ>w2DTmtYJvkx0H$QPfu!i^|^*kBsm0iN0A{ znB6Ocm_I3Bc+d8#c$@)3FXXCJ`u@6ltp-I3UDpsUSizp_a3X#4)mKq07Nhxv1u7Iv zv}xl;z*AN>PKCUoA~yhzY8q*pii9jjM#t&yU*AK&*mnmF4i2hkZdfnRJ@=(sa=HB8 zY&Lr-@=2vybuHUQc2|LWqec&m5MF0+KX{0Hs=((+g27xVrLSpPGLcAJ;rad*ibCTf zqvPNAJ?ERNSFiphipxd024C8LPN1OB0P>DI?&!?4WFIk2v(@)J&oE6%CDO_L{~tv_h4I8_K@xTh#eo8vfXd@n3@RJ)$*k>l7>*A0fA zv$Jy)ikk>89%q24xN%*PFo;ywbKyb4Gfnm_RI?uV9nT|G zQ(4)ydYv-qG*zp$5JTd4m*FSE7pi4EzURLC>BoEb(tml!+v#m@?IY9$SSiQ>a6ANd zgef?`4|RJ!$9Hf`6s%0>njhmYd;%&&cS+&mGtO%Jk&e_qJ@=Qu{-Nr{7u!SW-&E30k>OJQRr7%pofH#;kn23S?5N z)kU>dM|R}3&tKGa5%-}(S|}9h$3Nan7hkl6uDJX%0()Ak)~KVS9Ss^JYt9{@ITS@m zRiNlr7SS9`>V=;lD4W%b$BKd%q9>tpp}+`XVB*VVbKyB0q9UiLAWiDEnx`m=8jHtX zLnL0KYueGFp^=*lh59W*h_TQR3LNv9Ku?b|K*UJa>ZO`4lCH*tKKVdG4P{`h2J|x4 z1S@HwP?R0jYM^+>MaY8k(FJ3u^1y=!`TbYE;R?#<^1NzH)8Il4uC1}LG>NJi=YS#L z!3yePHUuJ3gLC5ez9>HgdJ0#9h>|o1B>WM)Sb~Zj(F)4}tS!R5(87Y!0aYH08D|pp<1m*^}0oM%ND2+`i~x^O)uHV=RorY(U;CkC3AfDj;mJB`* zE6$ZZhbQ1pMIt-_jYS4hLLMs6J0LFCT)aZ~HGUAjC%oZZY$O5Wg?U3LfLbc@kLP(_ ztyZ;FP0hBnw7xfC#O@g$8UOczf#KJ%*@WQS5RTf2r`-UNWd{ZZ@{a3`!H*o*m6l}% z$OCCCL?F09S)Co7-2a`Jo~BZ{3|PvL6^5wRYO-9ZQn_3P{me8)u~;G`bX8Rm=Q*cv zf_EKZ+m5g-8!b&~Sq|Aj(S`66Kq0uSrU4c+tW}UW8cJpzOGQP^5v(9i-vLq11_GHV z6v6!ogi9E9v26f(k%|yuYt;&<4K0&NzbT%K|7>J*?9PFKp=%BuI%ELGE;5AjG*b~$ z?ducXzkTof55;1!vmD3uYr;}YGuBW%1b;>l-(2%m+FPx_PIYCr)XA!-x0r(khp#dkw?<)4F8|RJEyOGI|RaSqMk) zBD}V0)hdG54-XAfYfB4tF6p8~JRxnn$TW0kR~H>Qa+s-TFs7?kt&EDP0IqOI3zXwa z4{Mzb!3fOt$b+}5D%TW`%@ORujnf3QxE};kIA*YHcnwI@7ol|+6h-$#qcDh#fR#cL zk7NVNN`zq;LYk&|mSLEmFKf!RgTte@kni5q)6>)cREEz^xpa2y*veL%&5i$XW+pf5 zdA^Fd9$eD}gUltX{QBtXQ#v3$VcxCurdKagO!5T%I zrHKdbzn}K(`4J5b4N;|1LEqRv|79-&y-_aAFC0(A6WlB2xzfPbgV5H1IKd77BDx!} z7CFI&5duRX2Z0BYcn=N<6~&GJfFriQ)I$b=O@>V2CIr{kB%??`p`19Xoc2Iigfg z`;y-z6UlXTt8TCDS*4+JwH*{%zHl8E$&))?fR;cpciwR)jZch&x2N7qdTHIdwHSra zHBGCJkB`6Zum0k_@mM^zw^FINi^E``9IX1#JJ~}YovsKJC5V$O!i6FLUAdSG;VZmb zgs(UH)5s5vmT9=^*hBrGvJSs*NM?kOAESzJrG~=>{3Om9I!suYGp3El<6ve1I`WDT z-!+WbH!_*TosmvY4okl^dq;1)e&^2O+(MzRSSlBZgfToi?qCEltTMUX7`~sC5s$@a zc6JtwDy>|((qFf3otsD|b)hH|3-j}r{h!Z%>dsQBQpCk50m&93`bT3lw>HCM93r1k z6TwGv4dQzop1{`D!>|M=z(APmxJcrmIysa?L|_z}8W1(e4-Kym+L{4S4Wgm%3pP}! zcoWGa=A=}elEsie>~9S~NTp2@ifA3E$DK76F(!G{k0`2z5hh*zMc5gG2W|dfj#ZDV5IN5{t!q;-*P!R;fuIsu%EDP}d zW1Ij{ARye>!5ZEf+;5V5q9lTclY&qfA(h|=v=*i<^OU$@0ulDogILlrlB*>gTg7o>`(*}b}pAY zSX?N6ZpX(z{I7vAcd6UA2T<)ByS}%%Guv`-)yfrK%rs%VQ0F10=P-ff7(C+T9=?w1 zH{^#x_JOf|7|#QRsajA`N2m!aAn@`>>1b2Ki*QLhQS8KX<@vrGW_*1@@j**!Zy`r$p` z^`d-&=X~oRvK_6|`AVXu{Dr!NZ_>!16}d zQCyg#*0v5L99o#mQCmkBX}ZBI0pz{!`E&W&ZLhv~!-mHi;7RWf2$hFX5T&oLZ-$7z z9p3mnz1z3PhGu5m;r;vTzhxf*>uyK*{Os&}vAv_!e#x0J{n)?>zfdfSp4F>_=lVc2 zVa4>AWEg2aiWi}U!sN*dA}FXJTssJlt$_p#l!Vm)4+ly^EXS&VaRvs5=zIV1ecH2k zFAbbHL6u6C&x&LUxD0gH*4FW811QQ`Vfe!NHQnUZj|CPct_A{Y&1Tj1w(LZ`UOM`? zuR~8kLXT#AyJN?W$BcnQubp+)S+cLMFQ7|LIu;jo-+lLWW?EVwF?6%l^;~aoXo%U= zb!*p9A|As$QK;TWFEn5?gU5)?(YT}XnA{M(JJ1p{rAVFu%gTLeC^BZ6OjrE$XFsQZ zy5Wm-@S#JjXv`?Z5IT6~iIa7Z4>0cFTt*%G3 zwx>tT|0pf#>XIwYSk62Xyb6ti04xO))~tArtPKBxLNJIujZS6cr2*6oiao$n zP$$S+m1>Q?_KjQUhA(}INyCng4h|Wrsxk3ct&|y+;TU*2LQX1`Cf$fHngR?c@<=c> zB0ZPiiynw>7~Ehw_nggEwzKW?Cq>b~k?83W_*#I6pFqM25!|}%dR`7+7{__OFS|NB z#Kto>(Cpm2cB$E;35!3q^=q4@rKSd2s;Lb z2oUPWEzIp zeD)?ZYy{veW{;LG>B4FdJY*dg+2=aW(XgU?wAy4(^#~#8%nj>l zdS-?WKito;)-=rsv@gCjim6E1(RmsrUT}ys&6wQtlb_O!U;Z*>G8r;saS@9rWvRG; z7$#Lsr~LFJRmvq^I~rm-!iw~1J~u_f14l{K460Ph9Q}|dG&PuymGFEsCN#q|;)$f* z*4`1@wzbz@+S&Qe3ock*4KDNXh1j!VfUR4%@`YHI^FZCIL*WWMZ7~M~BY?_2>m?g0 znM}}w4;`Y3$w`i_NT`vw2Ru(e7lh{s`8?PvSRDv&rfJf{M~~6<|9k^`bUcydFi|cp zFcQK+#f3SU&a_Z#Hp{cCEtxb3L^5J=ils8-IW|pCjIy08r4q+_gfJh>fTDt~F6oAL zLnfWsd+z4Vx2Dsn*I#nUEAI{moln_UezrmoU(C$R!acg7mkmQtLha1qHk$7N2L#0+ z3}Kw{#NZH*#IIkwM#d8f4iS;jbRBGm!h{`QW|WOVOr!OQVX^DK_(htY$x%mVC&zZn zs!Pl`X|V)VD(E|%H;yZ5-EOPtyUr3&^goKGhkrU z7p7@W96NmQ{rGOb{^jkDtDjCK&&mpj*w)J~>zynV3j0-66TasIjUsk(*&8sWL8u2E zje=(LS)0(@p$85=Bt}L?c{Kp1Dm(NT=sJ6Is5Ij^o<{*`R+|*SBex*q)0Oi zu5xSDN`Qip)M0eTmfQo^jbR{OBGN(O;5$CiaPYIW0$8M!;?`UD><~g+mQJTYw)>jU z6rcQXzQ^T)2}^=IRg&cL<=vvKt(A@s3{ozi=hcCE)z5tHb2KzOOl#JxrS9%!a(Q>R zShjR2Bb$Bu_tOIh56N^oLzo`2qRIgf6dInXT9OVlII{(7U>GBU< zMgRB>jmkHDf;us5pc@=^JkJeAbEHqIP*A~3!;Fy`Gf`-Hy}i9CSUt?4K9M|Y1^^!< zrBtrE>WX`B`OfZlbuU}~Rm5BjQR|w(yrMf5G8FjoKrLsVwTa0v&;^L?X!@jE zvQ(+n>FBWm>VITF0v6`;Ihq6{H0 z(?cg7r2PK%R521{wl5>CG851b8jcAC077`&C`x5Alt`uzpROLB)fFIw@Pm!o+rRep zZ+?4j+0vz7Yj1DM`ku6PUDMFIL?Q)2959-}aS=D)+1V+(mo4Kt)gSHIL!_vb&9(`{ zG$oV`nECMG!&EI58JkC(5YyO>Lx#aq7TEE_MW12BD4vLspG}fActG}lvnIUN%PEa% z41^!w;;I|93up~o4^IKQwhCh#aq|eSudA!;Nsp{N>-(|#`ucby$UCli>kp0%9=~L2 zYUYsd`vy92!5F&RD87Qhtl+qC_D4kc8@vsu1flSKfyWA}TGwHP;W@-_G;e&@4SEmZ zXi6}5sGbOrCQ3};Sx+iL_j!}@_Ygfj(= zVPKs-omo)yuYHVAYM`f?17fAQZd5e!dtprEhH^1a!;}3a>J|2Ou`o@_=uv7nHJ-J@ zY5^{Y#glv-#I_yoF~<{$;T<3P%bC;tjil#<0U{%WsA1tvfAWSmo){YXOtDnfYxTOY z92-cFhqU-F&x>LzHmWWnB&^M_2pe*9C_(KS(KtFUE@|-ciqQndguUzw% zt3Nz6Ja*08{Cvf>Y^`3eyDp;-k86df&x>0_xgkRub;Xz!1kUGV8*oBskRTLAlyR+A z<>pCOR|nS4N~R4mZS)l29% z_uNIXWRkr<9*+khQF`RmEwv@nN~u)KZ%#J>Kj&7!sq(@H-t(^e9=PwGi-tzWZbe?G z*Xurhe+Y3rS^*mNA{YiwzH#M-!e0@>#V@vi&StKfy9N?T9pMCzkT1~0$1csI43$yA1{dYzN%L{gL<0H#R1`Pz(^|1@I}z3-5Sa-^WHqN8UP3^91YC?SSFZ^-m^hS9XVFimnq|@a{5;P#p^gC3LQ)43LPFrzI;iCOhJO&w%e6(W4Dr#?UM>Wgn2)jDXum_AhpGqXT)rfNjyL5t{aIIFu z#zm#2HG6o|>gCwL!Tb-skPWanFWm6yPtU&nE!(erWZ=Z#&CM_9D^{F=31$!5op^dK zivOr`naGQ#r1^J^f)yeq_99R`o}|vMWt2^&$zHySaz=vUn#S-Fl{DZl2*RlDK0rk2UH zh$TzAQ6JoS|ATkGdHTeOTzDx@Y0ubmt`Iw2f%31r?mBJz8!tb&XU~BxFWo%&?q44G z)$4pIFI@_BluA3}V`D02bil5nsUPKm2rL1-y@6C*G#HPUoi|*OSYycmPiCd zob>CC=iECnKKh+s-u9y#iO4zCsL6|J1w?Xk>u>$pFMI!zB}+aNOC;iR`Mfb3g+%jc#K3>>+i8P8CDFXcxk zc)^I2Qf%G2L+$E1B7sQOZrpr+x;1-aSJ%=D&|F6~%XI+{?P@&Y!aHyr$MbzJ?|JTs z?KqFPj&pytUU^_PKXqiJzn_1~V37zU-2MCaH^wc0A9+C;V6pV}_G)2bRM)=b+^e(g zotLFjspY}ci=UIy8?V)^5yz?z78d4*hmTE65Y6-NTAxbcxU+x%R-fqU{Q;;KtN}tf z9Cl!hgxk!I-y>5}da>5xuoSW3E^C6uKb36btxQuyelTaM>H zIOk&Cxtoi3o|$KU&%~&!D&XT#-~a#sd?iI$&41bKe*+8SpKgEi5e5J#U`n#m+P=9b zhW2U3I_Zf0wXH6_Do_<&6&@i@n1Sc_h_7L^Bt%3Vl&?yQg)LTVGM~{FFBD#o3WlZ5 zZU-|oRv+L^jXc7HBpFi>ABJQsX1qZ{sMMY|22@+qqigH+ey+)e#C|s$7Go?5)oE0w zzt={`?b_o>SC4oe{U}qWk^8UzlW1{;{q2;!y1k8q8Mee0710IVcpWW(=y~f`@uv&K zM%XKVc+`CA@P-9_D13Z)=zJ6Sw;I9moE3t1aCF3;t}_LKx3FRjFu4(fbOe0Xznq=C z;RB*1`HsszxrMAT*r5}N-_ian4SoYdo#F-wA$%>jkox?3AUNyC>nyN(%R=?)Z8)D! zHEF5R3VDPyNQsyY7+b2{F8Xm^FX?#oAulvEw6v$kbX%lGx9f?b;)>2RPENI{nwJ~v!Dg))C>|_g4=gBO)N^h8)OKJvTI`Ay;|)W7v2X?=SnS{56b>DD6+NYP38O_m!R0R zsb+}%Nu-zoF19RwKIgy~z)n}YOtT2$7lt!Ptiw*?U~ga4*vR8x_pPj?65@Q~5$KG^ zf444NwH9!@BJjmz{0=D!>V3=bcGqKqNZhd5YCoTFSM;=XpyMTl)0y|XN9&Y?KH;V`2%&6}$$@#ST58V`xVRmNe~KZb_O%y#zxMNizsDA3?h$( zV<)B{+{xgv`Jtx%K|Xanz;rB1G}FLgNVO)$MsFVWlVcMa`S4qP+`ITMF!tWt&UP?A zVZ9{%%LKO%@s`PPf2?-)%t#~MO>CuZnMGBoyh8NsM4Pd>qa&qG&Ac(=e+5C`_f}i- z4)2hfI1N5e#23HRf^1Z$406x18Vt$djL8UMVw*-f1K_Z|-rJ}j(gf!ErN3f$a0ZgY zXssNz*yiQ22Fv5*BGSNE=J(msLkjZjQ-YS-nCtzbftF9y_B}s^J1R!f*s&y+zZ|=% zLp4>j^yk^eXEOHvp3=|Hc~XB82?z>k!3@fV_H5q9oIe9-F$??nl@^%n+YfCe?~*V- zYr4Aj^th~7Z?H>rmSWbLbhY0d4Y%ZqcEJB6Fm?)63ybq9rSd(~8cE5XNN?hb`4SJ0 z4ewZ%1YrKD!ajKnt=6ewW(9_%*&XGwK+68@vbg-lhRtB$YEVYE;eUKP=txqT&#h&i zw(ASkD6EMxey8meivzdk?>ezg@umct97$LrfH*jG^lA{@uW2*1j$uMePD-{d1M$O6 zwBn{!X^b(esf^E0E2}+lXZb=^wHQ>1=Yz?K=cAgJLo$Wv;fd8IKU_cF&FGKm<$>WH zAglm!3BhW}AQY7Odl$1inO42qxUUa7@XoNR^q(c*?8U%6N>){R+^42rSb{PkRa67k z1ZUk=oXa%BkL|rP=oMMD#58{{y1v9@$whRjz2dH+WgM0XVPHz_X%Im)T@ju9pW%hl9Utk z3q(YUV_^}XLM7rW6%ma}90SDRBrLpN;vV<#|hK_1xbt~-Kv+qa+pYC|Tl7E9SQ(fgQ(Eaa0P zp0&6ja)051-0m|SdvE@6e7`2e-^L?Dwa<&%;Mm^VKuEFglwIIuUsp~KF87@=Zrcv< z^Y|#`M8UVW!AqBcFWmL5{YJihhW4|2uqKZ)T3hTvi zAPzdl7y%B(@P|cJ`p{Yi9UNwi>i4qQ_X?ruMj02VzlY!0(3tZTJaqBy>ZZ&}(LGun z*B0wb=VPvH?PNu_#+`TRAJVagxanrxQWY%+ZxRV_91vimVX~LD>6%gmkn|tHXOhTE zBwF?)UE9buPL4VT3_HC4C_O6w^Z2m)Xu|*|-!m;gBCI;rw%?tl+x=odL0M2ZobQ{h z)vQ@WI||R1P98kd2SbTOKKcn5ZqjDD@o#+x2Bg694p2!a=TtPMczEO5yj%(Ud8J7) zk%8rmIr(9e%VQ@3rJJ>*^m}O*B-wtE9_S(asO^TIqg<^$)2(%>I!%#o&lH91PcCUZ zE4N~w1?$@1Ukflvtz+GesBU$(zp(&2* zGCs`G3TG$oUiv@wrn{zxb`D@q=R*cJ4La<4!!y+vWFVr7a@KVi?n+mjAfNm*&#zm- zjf%mskR5SJ<9}VMx$E)p*1dpvdOZUI@*H!RB zeKx!S^U_i7>ZOYPFC)!_$dmcpmL10s2A!bI$6^B*6^9?+9M5|75nUs0KS zCjyoxq^)t2?=@Dq(Prghul`o!oS`wPeia|4&Rq zgo!D}F^;}SM4FrRgQbP|`CplD9=ZEnXtf0GnD+3!aWzbuBymeHpBTB1dRd7gCDLuOGQxO%LXH{H5d;)_ixGN^Z6c%jpp6%RDQKruOagB3efsG& z%TF}#Fi%M>VW13c6_(&ZD9f^FNCA1kf^bcK&s{=z(}WG=LxWGnr_TmLEo5fr1Y!+| zH>c#KJ#AYhp7&*E?*1EGtDgr);GI33zK^b_S&BPiqn|4I&`N_OgN!x=2epR}krF}FScP^od5@E## z_d~0dg&-x@OtmaMYJ}gn;^v##2*A~~wBDRZHWax0ozeRbZP8dc6W01QKfneT|MUwD z31891AUOf1(e7SG=8r4mj_7M6LQ6C$JPpCeMrUDZ8bhu-#1J+R5yolEC%jyUUeB+A zr=ndmXUOE1JD1~TT4pHQRa#h8G^D;lZspUzF0VxwL0CNzFMP9b7(WFn03!_ zC;yy!ia2c`|MWW;WfUh)HebO!ySD^A6h*ZhV8x4dVcAaj+d|$Ao@cWZ>LQ)3bXttnG9o-+l$GE_@N+U zfYA^_hGP@)abaZ`1RBKh*7!JAy!2BeLE6T#wr`t*^)BxRKgP1A+(4~7KNig0d9}|U zAZ#qhFY*D>N>eKuOP}cTl*LD3qzt}CCV~~2KmNKEzX%WEA2#xq;dM)>{>aJS{Y0&u zpyd!8ATBUh4Rj22NB^0wA5nDjvIOLFJHYBR4q+*xnqA|~@jh9CT(*-=*AB^J912ki zO(FWEA6C@@;t}v|djF@G3VSQ3uo}(Yu{SlfYT>xg|;+k^d(ZCVdtqzY_!C ze4|tjdTzdUjnl7cU51^$LVit0;vcIZkQy|zmr^*c5O`}^HHExque{#E9N`d2g`%&Q z3Q9uQII>gO%ygA&sKou>a+&ovNHyGKEPSX$A7LO!uMeG1N_riak+F<1Th`cUp561A zM?mPFpPygW^yxzH;CZn#sm}M-y2*KwhGNa9w-@pqj0FVLYdE#>4BA7|9nu}6Xp9sTzuu!W*T&C;c6lB{*(-Uf)dyr=l(AQM?Q7MlLq@Iq)-I=PFU&sK z8xD(o0;%Cl;;H}#0h+RIvgSp-Jg*mG>=*uaiB@)%#MuHGN(CgJh{Ri*MFTIQk~CLy zm#=aZX!Z+%EZFH4Y(P;VHQ};wFLyoY{rV*uO=5#JuD#PRw01@tVo%IBrGa11wiVQS z2@p;=@glclL=_&!w=8`BYQ8bW9M8zk?RJ^JqHCQ3bv8!r-`OVshmRLEo-48G)s&W( zD;fteF^T$UM&b<)5~l}0)83a1@ISelG7>G0yrMhcZ#2f6D!8g_b!5MniT+iua`Ei< zzL)$N_U5$}SL^P@5w7`Gj^?NhU0JLH$p;>ZsA9aAOC|FnyL&xQ5NilxX>J>cmULZd zlwW+4`1)bfYjGyhRW#|e{V~{XHseH_4aF$4pT{^Pj6(-tH3Qn<4~e+8km|2DNC>C@ z4{IZwcvd)Df;;EgopxM=VU2ais~?;q{+h?a^NzJ*zA)?Or<0#|!p2|5;Z3{8LQ|WY zw9mU0jvof&HIfJ3R50^+%k*T+zP*4)V#*ALn0f@Trc*3cdP3Ew;K@*f1iPzm&>+IM zsX>ozw)qcp*y14%kAp)xH9-weVaoX+Amo-LuB`bz$Iy@crpLk(0~i<|KS4V``zKXM zM+ra@vQ4g2&1urABQ6G(?Aa&te6WxEk5A|?_48q9^nXPOXqAQ7p9Zb`a_c{NXomt5 zL1>+L1X}@A%Xpf6dUtc8cFRxF$z&17;#8JShzWk19i`DWKOh5TimW|&e4I0YOANCS zWe$aD{kG=O?yNho$z#}CuZA$PzkH$?KFEv)KZ}!vhm^+&>C`}5cByzVZ&E%4 z0PF>`fS1Rq>V*FY>x;jCe`vx{LDu@T*W=SYt|Xri0+m2;N~D7`FBfUmCKl6HwRMc4 zV^O_?b9ia8xENVRSBMsJR@`|;vF2r%=$s`3*zG0int4jb37v6f@Ezy~Rf1BH;!CBalOW99-SN-6En~RCQ zZIZuy#s5VhK5lle_W7XBu~45lZJ}D1JpT+s-em!wLE2xmHRtEgw;-1utMSBCP)Ib= z(6Tt_gTLpzq)H-BDG~hP5Rfw*VN@13%cbApMKb+5G+pE^fZ(LV;e`6QYkzH$aJph zVql-$f;J;n?TsFn3f?~TLfLsh2dQiVPy!qj=k_f3h0Px{M3>L2U`;gsnL?t{Ubp@< z2GptA^35wI?ZJsyibHlo{1p+I)K~^$YU2iAq73~mJP4QIn>Tvml6|&|@8PtmtG>7S zSyw`2)C-RFXA{C#ZicHy1e`dMSj%40l;qFC-k%Zg#pnBPzX|yCstjRmuXYWkT&JAJ>F;?&V$F?8VWhiMq1IZl6vC5m# z9K^#(Ug5Zhc#z;6p%pH#i?WsYJ5qh@tlg^hDX4cms|QH-Xt4a=r59WMZLQVXJR%hF=UWYE9vHHa2a<_{qnCGPhq_l$5 z2fIf+t>>`fgJT_aaKb}&!gZ>zo34)*XNv|D>=x_R`2~2SiyKo$TY|=$e{KEgR->7^ z4tVn*F~b!upp|}+oEfpOiP?>c948(d%MkQsiH01W1(Gy;#L>89%V3H)^WfZ=CS^eli2!_T zFSkoG>pLZ~iC5DJ;QkJ@TXFr^xnn)=2$2Exsm zL17@qom;D0fx(t)>FAVNoY>+gNAq3_&|OkmwBB@3eaY>=_%ZVRv5!prE75OG^$7Q7 z%HO=V;bE;0Ui=nRLP9M~?sTaqR+}FGa8s`UfAqtkXWu#-npSNR zB$dWslo<;LU^wSMqMbqWh1en?PCWNFd)YRl1gqjPpTQZAfp|>mRsDS#&{qtr|(ckm=f-Ar4 zV)|pkoAX9KlFKM49!Xn{2|AB?Th4j7LBi>?MBa;uiTR z-@)w4MCZq)wE6 zAa>Xo=?mYqH3U#`hdn$nZmAV5@XRcmyhDWUBz9Tc2a>2)FjhpY%U(_VA?v(kFN>O~ zNagQahyYq;pL>kX|3KHJl!0HAo=k>R7!SXZ3NM(Jz($Q{7Pm__yfXk*rClUDFl)@0 zb-Of;bb0W}f{z6ly(vlx=Bp5O@yj~X@coL{)D#kZ@*t(Iu8iZwMgg@RlffQJ(fMFH z20kCrTj#EKpmd6ECdEmIoLQO{FVBaQ6X&_mw{w07~pG`w36CH{(7L> z{#x4xBk>|xY#A#a*i*wVsc2?JMx7Ka=8AVhWYx?L^QoS5QcGBQ^RfJqh52=St{H4G zQt+?ct15?^?)~xo;Df-$z@@9e|K8aA%E`!@sFSl%9TuDAt=JL4OICHKBfw$mkNZ4+ z5cTq|t&^01F6Lo+4Y$;^l@71}QaK?ZviI_J8hiTV?>7uDnSu2!EqVqHLCb>I2!y>DM8@q9xq#dFSirR3Bq)pMkQnYECWJtFu zTBL|$BdzPWKnx{KQ;k!kiH#Pu6=`G$wq-4pM2e!glM-it`+dvZzT$Vj@4N3&kz8mj zjVNV4Xv~@S-d)~Ve&?LuIo~zW=Gt7FYjbU`&9%8U*XG(>n`?7zt~Xmk|Gcg1Z9m~n zumnj-GsliuL^E{o;Awf{#EIzLzNz=?60@_j*38U|Jv}|^;NLg<;E#Ln-CW{5CT#Ke zs=lW-0b(Lo`s-HAk^kVqs+2#SQT ze&}}G*|~3gCKXKv>&-vF)0fJ^{#mq}D2?%?9d|b}W%|S4KWzEd|Qpuxe zDv;6bB;*kx#o;KD2Z(5EBIzWBut`ALQACIV!nVVN?L@X?+xdLavTTbyulq+A&RzJ; z?|o$kR|LRQqUnOtzWyJ7mx#Pa z9(lw*c5Egh67v0`08ZCtW@gx`e;kDT^iTiOR3}>8AsjkDl8y+XJEWAi2@<<4OO!0j zCSZt+Bt5fG$+fB_?rIG_d}mAHKf*m0cx-!|Td&@CuHSQb3P zD8nWvk&ww`D$M2bNnsIrz24W)o_+rKcmMoPu1hJ!@#DwEMy9PlkphqczR}Ho{Npp* zB8&EgA~-}+9+omXL{YR?3Yq{cu&qjM)e^r^XjxXIEZPz*J*5N|9I%}&TnPRfUSVz@ z)^9rroDYJ=@e=2^9b52qgpi3uQV5)ugk(=}9CDmwluRZ?u~f2sueaE&SHJlCUq1d- zoK?ZnF?Kn+X%#R#Yw2_@_uxZ6`(P9XcZHEW5=nWhplG*cSp`@g#3-Z41jmrb23AXe z20UiB80WFM2T23FhvN7%T+7JZeY3+Ebs4`d*ZjA z`R-gDj&=O_aeAj<>8&eZmMnQ(%G>XHVDI1ig@5q;op;`u{r(Sr=$$@wifWZL(Qdab zfH5qP)c}#Pxx<|yTpcVqSG1Hxv(Nz$>NVm+%^fP(Q3_H*)Cy8r4!lL3T;gxU16B|p zs;nPEHpe>-D@9nABUOA+NFkWM61c4??=`JQ*)t=E3-OaJay|8yWozwNby^6j^< zrXQb$wB*?6#OEVvXBQTiy7%65cj6PD_=K~2&n@=Q@TlcDHqb`hD61i2Yew40|B|H0 z0J#K2;cZqM>xCkSQyHB04CObGQ_d)AXceV>1l*_5EEFj#=-cCKs6~#e&Q6q-!ot}$`5cn(0D)6s z6a>czrj(&AUU>(nqdGA2VLA>dID!``NK)|v>af@=3?jnIR9xT#Sm{c+9Q!%5B@qe| zk&q-Dx7`j}tyYjLl>YYA{{7GV^DqA1ZxB&xZthr!AkCV58bHEyw^$ zgGH-2px)8Yq_R>5G+;koX2{cUWMTvZpo;d&3}X1=xQ~mVWQJAnXO*Ey3ImiHmKE3$ zyunYVlEsP1ZD0I1zw_|VEiWwm>ak+PPyp z?VZ|9Lqlcp{PU;f)hm|}8z=~Tb(9vML3yE128)EC*(})u4E-*+=u+XqSUkrCJ_S-) z3p50TsZHLx;d3l>t`k_-mgM_Z*(@AGJE$OvB4koHQAGToA5dfo#}^dV>b0<3Dt};V zf8j5G>&yS`_n-dJ_kQEpv17|XI=&GuO*T*gCX}+g!jP7i7OB(eh(^68n=O}i?b z6Gch^5nsX78A^T}jAcY&AZ65Pv(Atup=L;DvQ{RWr$jRC?V6a}asLA!`h$-?_~Fky z{I&o755N8Guhx{Eyfsf)w*oA(qJET90TPZL_#Q31c8yxCCe>?I+C8;L?%1)7j!u7+ zUOat<&cE^szwY&VMuMDvs;bvESgoVC94x++j^fM+uPN?_6~c9b&B58=ybAxS8s^so zia?db=({Ev`Q!(lBK8C_FPoCdB&E}7N@WU^$z~~+N%P+c$03p-bvv$e?ZQ%U{-u-Q zuBkmg{|lf0Z1J9dcI;Ecz(pIQ08^DH2vCAjmqxB5sadPh#a>r58Vy-%G-=yqd7C=5d@ zilp_#6Hj;_yzhaZ+P!PfeL{#Q-)dl9zXGDjg4@HQ3=H+v0;?tRJ)ag{y-LkSotpIq z?c2APc5L5DrBabjojOBTu3Vyer9wdvaM6S#4dK?WsFkh8G2;ehAZS~X8Bdh~aa;hz z;dDXZ@$WbWin5)AOe9j2%@-)0&QKpS*5|DhsRBYSpRJZd0e*C9m66 z-%n8-~iuY zWsQ1`&b@q!T(?2h`X1V|YX{wb-#s)uJWMaY{4%YrEK#@JHgy=od7R&Kk)W&{e?tf{ z?nfchOJUGgmZ79sSji;ias|p{a)1ztY8NmW1wMJb7FCv(soi#2*u7pCR;}(^Sd>gA zgySSBoy{_V$1x)c!52~6ZBiks@h_dLI^;EzHLQg~_eF$9< zd`3x5B1x%qnlhOTTXr&$Bvj)#JoJ6?ycSjJODybOw@be7QOJT;9?U@&=SU$wm=u_( z?Y5-bYH*6(YBku{a4aAw;UwtD-5(~9c=~&9tOEF=L{uo1aHCYMtWc>u99OI=_$hN{ z1m?ssqSfUk{vyECsj0oRd-o3d$VWaz&p&^Pu3ouJ%_iL6WeYacv*B`>w{a32BvYw0 zrBW&W9m0n11woH3yISv1+jXhe>rog4x>ism93Ui>$;J$86h`FxJp!4C>q5BrE^e!- z6E#7}WQtPhER}}GXv>!Ew6?NHC!cT zva(3!@(7_BuDO22zi1+qIeTHTa4wxc%h1$lHEHjjDZ2mu57O}P5Iy_!Q#3q0LhW{k zeBWowhOm>#1f`NmMZU?9Ez$3`s8wrI*I0EBsvZJ2%53xjD49yJupwwbCOn|sZj!O= zPPa|I*W=FsRv^?up-A`K`w?ooZT>x<&vQ0}0G-Pfsj{|2=gyu|l{zy~lKep9;6^Ec z6E?@8^6(gc5hsygMbzsR##~_UAVG#60RJu#AZ^aKmlj?lx7AVs`j&lUlSe=EnV+HM zwI;(wvsNLuRc8VB+I6b0bvS+Xd_RUD1tH2aQmL$N%!d*RftR)0EzVw$0W}*nR+Qra z-xD&G$V48x=2RIjbEE02s!P@_?$T6K*I#WK6SsTWLUqp3N8;0mE!zHpYV zzj}oZ9l4XHc2Cic?LIyF=%e)V>F3xBxS7MtS9v!3APQpxe&S4FuHHDE!8_NX=D?|mkwz@*26I-cPS><3= z8XlomqsCc9zF1ayIFFHV-V|30C~}0+3Er2ImKLwm%P*awSFc{6bSBN`XfZjsRE_K9^Vh3jB5xT@Rip(RN1zV{6vbPr|p;$SJjEiIoKfA$%+0%9$?uH z<%$KWtibArsoQN+vtFZOX^5?~QC*|*$XHwq3CSG25~ATj1r*Yc6_v^g3kTlS>A3XD zODAb!%MQvF%N$o!0fiWoqe8L7iU)+@^J8NZLox&|Qyx9 z>Cln8IUC|pLrt8)?Smks^6)sr4AN1+26!eXnWFmW6y@9srMh)a7Idk`aK_~c3F>Bs zs69GHzDQBZP^_k)L*m0dm$M^eJf=Y7nIBZIgc?E#op~8jrydGs4!?#ke z7f_?wqT13jRhJey8v%}YI&J*dUCw68SS+8}PHbv_L~6 z?sZ z#$9FlSeM~941ut>^jyl;uaQL%v|^o1*V(i(xsS~JxNi8PR}iYS7%K{Oe#cF>4Q)v( z%gf|)AhSfXUX`QclUx@-2}l7^c^Yfs3YF08;6W9pup<;@2+f#O&?K0mkNP7xCOC5@ zC2Yd1Ma(ndjPiBy1KF+U(2;LHOty$90+FT$l6WYqZwOq8phv*sjY4e>T8t6;lTjpt zFhrN_26&0P71~Srj&z@4!xUgTSja-M*}Tk_hG=bhk@BS>W=6s3fc7;))bi2)!jiO| z3)M=DM@lptsf(!pQA350cSMsfzewro8vjnR@4&GaaFYd!Mb}LoAIBBI6EmoqG-R*A zZ#G*#2)fu{%z%dK-kDy%nzk^cqrOX4!aDQN^|PVc^-ySmM+fc~%EQ#GS16syK`G3! zAUjb)H4;@&8nN43_}U7}Vj-yj9jAHvZ&WCEk!O})5KuC!Tqv%R!@`T|Iw}Bd9 zm@8AvKxA?Su2hxnYs(=34(E{)9dEWkHN0htWF>4JJk`atRU)H7EP!}It-#n4Ii{%5 zE3Iv~6%3g(FRdU}eQd!(KtRo)lV*9sLhrp5zWs!BGB;E6f?o`C{G zb3|b%xv9=S@io-30w&YPcoyp*cfpz0IrTgtW~S9 z)pJM5_b68=Gp~*UEMF)ior|*`GYX{SD1iNnj~NicK+x9zYrel8X;cB4L0Q!m{hC6x zF7bN*vwBpK;YHoIQXWrcL=ob!8cj9TudW4@r>K+SP{aqr`y;t9hNI9)5rrG702mOn zhJ`X!R+lJWD6*j84H(_P&4I^(mnd=cgRzbRL=jy+VjJJr~YvAz*-(5*bdOq% zDvgY9p;~1{BvToNA9TEu$)rpq^DI0w2WPT=b{|!K5x=217!HVmN?rR?7DgxiDpeO$ zD*Mo^h7urKX54^NSqleYp6ya*qjuo1GWF$|ARM5}4cvhIgOPz91r4%r{VHMZ6(pmn z7u29H6c=oKOA=Kg_@7#3g|hjQ?6#Yn8)S2N0a6YWFJMT`hAQ6Bm^H$z;HJHS{65Rb z1({Z|N`Q10Vp^dFqqx@=J4&3%s0O})Zxw{YFyL%!ph2W+88y|x@gNTNB$?b>UVN3I z3f}_+W4GH;-WYou!;*j)vBPIQY}`*U6#>f%sn=;!Hdo^Gvotg!K}B>Mb(zlQ438go z=wqUb&!Bt8aX?k#k86MdVHig+(81Y{_8J2zJU40#Ll;?t^^-<WNeb@XIL4U z8X)8FCDp`WEPXZaq$MoPOM|JTv3^d|4K>^+zy=95bCk)+c|?ToN$)`mL(arZ@HA8i zMoZ4Wa*ErPYpaX=Idu2bwfgsAhJXjI$$JXSW#f4zJ^itv3J^j>zOUMTfSI+G1uB+D zF$N+acr-d=!G=HN;lJ3#!Ci8fH*xg6hZt2mEfh4U5x#)*tIoj>{4STyaB^eG=m`SIWXDB z=T*fXu>bn2m)Yu(7OQP&H>QUs4^TT|w4jak4%7pNGtxU~^d=q+i8lsoWd-ZpH?#gA z93bio$`^2f!)1_6)b|&+ zD-8(<_;EHEf)JNXAruB4CDUmp;aZKV#7rdySUYZ0@44oiMn2B>Y&aP?fa!fm(z?=L z)=VGZU!;a+$D|tkl5bd)#T9U0pEoeFil5?(X-HAWa6>4rzWXsM?qA$MeHg+n;tFrW zvmn5$MhW$$gMj-0Xr3b`;AS(~g6@cEoH36f;2%Xr^t(&OqO=9a@AV5f!&Jtf)tpUS z@!m)iYuM=19Q~}ce_ivQiA&~O%4?M8VD1P-Cq`|Q0+q0#NV7xdt!~U%Hw2jna0n@C zKy=&!+R@4tdCZ*gAJ*^tXib*1%ks^}&Z{9>u)XOiw|+IM1+Rq_*9nGJ&|8fR&oc;H zF=m_o^XrssiaMhlRfwCcrmq}h`ItK`4~_5u7p5?h$uw8oW(u5-&>p@a3YZ@s=Q|C< z;4hp+@(Y3QTcNQo6VQklju@UauHLZBW=B%;H;*&WyTk@C*91)UHuOa^cjUwd$3xW%7B)5?0jDE^bWKG%VbZZ1%ZMX{2PtIKb84$ic_(qb=4@ z!-mXZg>Y)w?{;xe);as@11ZK_Fk3V-I!+^_6Ev}PyGmV+9qYN%c=Ad&h0tWek6Grv zBDp>YT3-w}aYEvG5$Df5`;S(xU;Az*o3(5w8Jf^Mxpy3etm_j@KVOY*V|3|nM$1G-M|UO=i(hWn#LYX7bD5zFqrY&zGFAn2-)5H znP!N^rGqAnL=6tBQ5EiI=yTeR%{0Xe&;5Ws2t*zZLzAa8G$JrxI_UALV3QlF0HSA} zVKcCjTff}xx*rRI;8rlBAmQ4rW?*S0^n1bm3`1?cHbT}S*BrY^4GpIp=kmG{!jo-L zM7dm^r|{4;R|^|7Jeu6Ha{!~&IeE5c0M#&Y4f2hL$6SoBH<-5Ad*BX^1sFrusj5t6 zve@{?RV0p4m9;Vmy}wxhedFZBJFfseW4m_!`q}*CNrQP1o0 z_9A$Jdg@I?7tX!Jt$Qp}gmiS2)TGzzAW3L?)W8tKifMc+xB<(F zSyKqzOfTc#KJ&>Z;{CpjMvYsd7}7O+yoN_7#OT;$T8fnp%CI1_v}BiR2Ulmmu|BWAb-a5-D2SjBXIqE zAT4x_=DPc3^FDzK=gz>2^)r6dKHzLFFO!L+nqm$@%H}dWqlCR*I4+e;2@DM9a~Klq z#+yYZC${huwcqo|ZK!=@nCfo1P0AOCX?)90YPl|zhK6Y(w@YpG(-{-`#BI01p9ktW ziDatNb{88fjql^+b8~aiyR85$ehQL)GVQ=n*j%;e%Of$$S3BSpY$P0;v!kb;{4VX>bAWbE-NH(Pu&`SP`xO8q z5PY}S<6WFQP@K(Cp-^JW<|YqYwi34I^)TFpG#N}6o-1eBih^YYgy_vgLF9RUHw?qa z)>c-2du8F`le(yV%YDgr$^*=j5dQS^v@<_H|IME7Esby4@~62%c?@Z)O}4|$bUjq1 z)?jL=@^}PrjDs*4mP&CzRb8!sRu->wBV~EvRgM7=JWoj54hP%f&?pmv7@6mUDZ-v! zkAY$nx^!eS#ISGs}ER*jKR{X`WQHye2TS+5%e zo`2O3f)_*IKe@8>+RsgF-Enwmc${NJ7>0`-w{@mcSw8-$hd%b?+}s=v){g_bnH9im z+98@pgJAsD!}tErbSD4dT6K-nR6L6Z`;C!Sy3HELfVI_SdiCO2+PP;x3kN?CEQY_# z9yCCZdR~ws;(*)TUI#G5{ZFRL)q}mU@Pt=Y!0Gb^zPIRy!I{ACJrnf2r>c$B7n*Bp zm$5@i-*j>7_C3Fx%@^HftN!%TtC!9p%X_;dvcU@A1=B>J%Iv%So zBjMGfFgWk~-bvr@{m^rJCl?o4ptaZEB*;cQ=^YUICQl8#NJy!@Slo9 zBmdZK)ciuJY!ypGLS-Eu7hvghj;UjQN(uG~dmf;OnRwn^qc(dx{GWQju6m9HV@Y@+ z2>cge-JN#l*`?~@8EUr|-c;Dg;pgTC`kcH3O;6j?>M8K-JU13G?@a~7|9=7}PMnCQ z4jlQ{*<9h@rZX8l)f_~bP;;LB?MnQ(OUpuSVI%)DSqYjdUauF0VRTi>;B?UQo@@8I z&s1tlFZAm5%Wq_*NEgxBdnW|?UQ$48T?o!2j9xc9h>3=tBYo8u$>Kr|K)MoZ+em;6H2>WJMKpdD0<&nQN zGP>nI=SxG$UZ;I74E>Ysmiq$=-T!moz=7(#BK&TO{|BV^;Fw3=<$L*SyR1Q& z0ga&wxSPsb-}g&w3Vd^IuFbW%HrM9bT$^iiZLZC=xi;74qW9tYe{nTF%D8Bk{cK_iA5y@9Ir0b+=?|w`56{EgMV5F?a_IGLVoH3}H<|7BYlnhFAgy4j~I+ z!X%S{Z~)`jYOoEAH(}Y5@NUbtBx~IZ8S6o3`Z}v6)ryf#DA*EEMl$w-M`49fySu7ei6?Eg9rfKcZSC~qLz<*6>Qp%{V>l=05{6w*6e7{gMkLC+TK~{`x&iK_U9iNnf zx5;?RSc{ah(KNBvvaGFA+LsC;)&pWmAV5Y9*b>l-CcQKyq_k}*q7lFE{=EnO>FeM6 zXQiRGW^<`j+;3*a|N4Ok9?(e%yLa!VIPsPqZ;ArQ;&okLE`)ZW4Y>_Awo2fJrigkY&VZi)Wt%oZsn{C>ZjnN6v`{|A2x)fYe{5`}3Rk4xMC{_Y?A?fBb4 z2@5t=_w3oj{yCYPxlGrMUR$b1{C@S0pkMo3FsN-+6t$*MG|ha$FbW03uuRhvyas?C z3dmJS`iv|`pin4^L_@W4^^R?&ryo0Ds;W|q$E&x6BkIq0{^_5F=r?rUJ$u;IIaBF3 zaRzX3+_@9oH{Wczf_Ti9*s2%JqJ&fgVM$j&bV0IwSrrJA1Yh}r=7OYe3MeXY^|d!( zcx(dieD@7TAQ%ehh5Y~e?zg}C>6c#%DdgsxZ?=9TUC?K5y*2vm!6RKC{q37R{i%
< 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 LNkvXXu0mjfhFqy& literal 0 HcmV?d00001 diff --git a/images/objects/Server.png b/images/objects/Server.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9e7fb2f1126a4f75ff6661da4f27423f4e22db GIT binary patch literal 8716 zcmV+nBJ4U1Qt#E;)$8<3&rZ$^Iow3jS{%rkktj-u$L3fAWi( zA3*|SL!873kOYVk*odeFu?!ip5h)~~gp#PuB1O)S9L~}+)7{mz?^SQP!FTR^RW&0U z@tYRJe`ObHi0`9GQ>)yJz?yaBKrLf1=v(G+jz542_>PuhxQpDfS zJ^k^AUElu=Ks}Mo=Il(y-;BrOAC02mCxc=4%0{Jr?b1u%sQpEM@4|%(cBOJw0ng+7 z`SbC`ix)Kj{_M(9dr9yBaPi{Bn3wPS&?g?UUGH<2jZfzDbH#kF0Nc&LQWlib7>&jl zkB6bw@pc#nH^OoFW)ugn24Q$<7>wSm)^6Oo^wLXzu41&->I+}^f_?Gg7a~CEpKAqN zxNyNfd-kkO1^?GS@wa~=lgWKPj^k&-AjsI3ggkhi@$5obP=_t}#Lo3_1ZOc+h zQ-n%E$1!vqVK^Eh494{+j<4%Dd?SjYSHdW|G#(6ITRDFJ%|HCTf76^*eEQlyR|>dr zm*7A0iC_ICFXR8iSm}>OVPwZ~ti6mE_Bw5=+ife)&%$+G6$F9ib@8+OaGh z$6CXJR=fe)wk)b<_Ty^=<567Q-r9!NuRQ+v6W{vb%P;;CKK4KV z=qo?osgt07mIVK$=bnp^3jU*?{u|kR={GHf4+dd?IEx#S915DI zg{In?oPaovU|Ck81RHT2BMbsqwk5v@!5F@u#med$f*?S%QI)@y@qDbV9?-dLHV($) zsJyV~*Q%BEZ~XC>-?6^F-bhZ2-d75^K*1-7hU+6A|IEj;`TXaU<(#K*;wTI~FH5sx zQ;6g7Km=i|Ax9oMDFK@^Tf;1paGg|KZ0Q5>UKnnSTv#$eDxFdoCr z_@bOB2rwKDQ7jd4;`j-@wY8yMd-auQ`_`=ox3|}?0H9XmeW8H6j;kH;g>L~(a_+f67-$$h;Ro&@!Y^~$|IP9O@ymsySPAI^^+pwN} z_F3oR#fu@YfB0jc`DC$N{zta${6ZYX=yqEHPrBz9Yz@N6p`aIK*&=vxtXhlEc>*k= z8y$-<6*O$eMJ`{I=g@SO$_t322(gaE{`a~aWZVpLg(AkIk(>geq

r&vQ|$ZsXR? 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

}mXni!PVLD+>9J!HyR4DYRA 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 Date: Fri, 16 Oct 2026 23:28:02 +1030 Subject: [PATCH 06/46] Prepare Weathermap 2.0 identification and release notes --- CHANGELOG.md | 40 ++++++++---------------------- INFO | 2 +- locales/po/cacti.pot | 14 +++++------ weathermap-cacti-plugin-editor.php | 2 +- 4 files changed, 20 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f01..719a3950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,56 +14,38 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! ## Changes ---- develop --- - -* Add traffic colour presets and optional high-load link pulsing - -* Fix Safari rendering of high-traffic link glows using native SVG filters -* Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves - -* Add separate icon and background image upload and management libraries -* Add editable map notes with alignment and sharp text at logical map dimensions -* Fix editor placement reset, destructive action controls and theme contrast -* Reduce gallery preview cards by 20% while preserving generated thumbnail resolution -* Add a searchable node icon picker and bundled enterprise site icons - +--- 2.0-dev --- + +* feature#302: Add traffic colour presets and optional high-load link pulsing +* issue#302: Fix Safari rendering of high-traffic link glows using native SVG filters +* issue#303: Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves +* feature#303: Add separate icon and background image upload and management libraries +* feature#301: Add editable map notes with alignment and sharp text at logical map dimensions +* issue#300: Fix editor placement reset, destructive action controls and theme contrast +* feature#305: Reduce gallery preview cards by 20% while preserving generated thumbnail resolution +* feature#304: Add a searchable node icon picker and bundled enterprise site icons +* dev: Update plugin version identification to 2.0 * issue#299: Keep editor picker results open when browsing and allow changing an existing selection - * feature#298: Fit or stretch background images within the map canvas - * feature#297: Allow setting a map title when creating configuration files - * feature#296: Delete unused configuration files with confirmation and protect registered maps - * feature#295: Use a readable landscape preset for new blank maps - * feature#294: Expose bundled fonts and link comment colours in Map Style - * feature#293: Default new graph assignments to Time Graph View - * feature#292: Generate sharper gallery thumbnails by default without enlarging small maps - * issue#291: Improve map selector placement and themed viewer controls - * issue#290: Match editor toolbar colours to Cacti themes and keep pickers visible - * issue#289: Load viewer helpers explicitly before rendering maps - * issue#288: Preserve dynamic editor labels and cycle countdowns - * issue#278: Preserve map and editor navigation context across graph, viewer and management pages - * feature#279: Show larger responsive map preview cards with separated borders and centered titles - * issue#196: Restore cycle countdown, persistent fullscreen exit and reload fallback - * issue#276: Preserve query separators when saving node and link click URLs * feature#276: Simplify node and link editing with readable names, common controls, searchable interface assignment, advanced controls, and clear deletion warnings * issue#277: Allow map duplication when the debug column is absent while preserving its default when present * issue#151: Keep standalone editor controls visible and use theme-aware dialog contrast * issue#275: Load permitted editor picker results in bounded pages, search interface descriptions, and encode graph search terms * issue#274: Fit hover popups to their graph content and close them immediately when leaving map links, preserving graph proportions and meaningful captions - * refactor: On Cacti 1.2.32 and above, defer the editor's jQuery UI dialog (`ui.dialog`) styling to the core theme CSS; the plugin's `.ui-dialog-title`/`.ui-dialog-buttonset` font overrides have been split out of `css/editor.css` into a separate `css/editor-ui-dialog-legacy.css` that is only loaded on earlier Cacti releases * refactor: Relocate the plugin's database schema provisioning (`weathermap_setup_table()`) out of `setup.php` into a dedicated `includes/database.php` module (function move only, no behaviour change) * feature: Add a root `manifest.json` file manifest plus an upgrade-time `weathermap_prune_files()` that removes tombstoned and dev-only paths while protecting whitelisted user data (`configs/`, `output/`) and `.git*`, backed by a `tests/bin/validate-manifest.php` CI gate that fails on manifest drift diff --git a/INFO b/INFO index 696c609e..ec02abd7 100644 --- a/INFO +++ b/INFO @@ -1,6 +1,6 @@ [info] name = weathermap -version = 1.7 +version = 2.0 longname = Weathermap Plugin author = The Cacti Group, Howard Jones email = developers@cacti.net diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 18edb16a..a89d7aa5 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR The Cacti Group -# This file is distributed under the same license as the Cacti package. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: Cacti 1.7\n" -"Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 10:19+1030\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/weathermap-cacti-plugin-editor.php b/weathermap-cacti-plugin-editor.php index c5dbe434..98f1922c 100644 --- a/weathermap-cacti-plugin-editor.php +++ b/weathermap-cacti-plugin-editor.php @@ -310,7 +310,7 @@ - <?php print __('PHP Weathermap Editor %s', $weathermap_version, 'flowview'); ?> + <?php print __('PHP Weathermap Editor %s', $weathermap_version, 'weathermap'); ?> Date: Fri, 16 Oct 2026 23:30:15 +1030 Subject: [PATCH 07/46] Reference PR #309 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 230f578c..218d1486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- -* feature: Add default-on COMMENTCURVE and a Map Style option for comments following link curves, with straight-text fallbacks +* feature#309: Add default-on COMMENTCURVE and a Map Style option for comments following link curves, with straight-text fallbacks * Add traffic colour presets and optional high-load link pulsing From f6536b01a30649702eca4aeb76c010ccc36bfc54 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:17 +1030 Subject: [PATCH 08/46] Reference PR #310 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df424f4..29eb1840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- -* feature: Default implicit link capacities to 1000M in both directions, while preserving explicitly configured capacities +* feature#310: Default implicit link capacities to 1000M in both directions, while preserving explicitly configured capacities * Add traffic colour presets and optional high-load link pulsing From ea57958b6c8a3783b6142fe148c50536f84c2c9f Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:19 +1030 Subject: [PATCH 09/46] Reference PR #311 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ca09d9..7995340e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- -* feature: Simplify link interface/source/hover workflows, limit the form to two previews, add node display-name positioning, move comment colours to Manage Colours and clarify raw-edit controls +* feature#311: Simplify link interface/source/hover workflows, limit the form to two previews, add node display-name positioning, move comment colours to Manage Colours and clarify raw-edit controls * Add traffic colour presets and optional high-load link pulsing From 793adffd98c740d9631d5124074a2c886cc1129d Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:22 +1030 Subject: [PATCH 10/46] Reference PR #312 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc4c824..43dd67e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- -* feature: Make Cacti hover previews independently clickable and sharper on high-density displays; hide generated clone captions and preserve popup pointer/keyboard interaction +* feature#312: Make Cacti hover previews independently clickable and sharper on high-density displays; hide generated clone captions and preserve popup pointer/keyboard interaction * Add traffic colour presets and optional high-load link pulsing From d0233b7e32d76149fcb47bd26504bb0f00e3422b Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:24 +1030 Subject: [PATCH 11/46] Reference PR #313 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fdce4f..c034da20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- -* feature: Bundle transparent 96px access-point, NAS, server, printer, CCTV recorder, UPS and CCTV camera node icons +* feature#313: Bundle transparent 96px access-point, NAS, server, printer, CCTV recorder, UPS and CCTV camera node icons * Add traffic colour presets and optional high-load link pulsing From 0a2151e683f1b3d1303d62b566eeaf3509ebcb17 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:26 +1030 Subject: [PATCH 12/46] Reference PR #314 in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 719a3950..c3171125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * issue#300: Fix editor placement reset, destructive action controls and theme contrast * feature#305: Reduce gallery preview cards by 20% while preserving generated thumbnail resolution * feature#304: Add a searchable node icon picker and bundled enterprise site icons -* dev: Update plugin version identification to 2.0 +* dev#314: Update plugin version identification to 2.0 * issue#299: Keep editor picker results open when browsing and allow changing an existing selection * feature#298: Fit or stretch background images within the map canvas * feature#297: Allow setting a map title when creating configuration files From 281dca90beadb5890eaf6ac0c2c7747597c2a837 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:47 +1030 Subject: [PATCH 13/46] Preserve translation template project metadata --- locales/po/cacti.pot | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index b33cca6b..bd53f1eb 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# Copyright (C) YEAR The Cacti Group +# This file is distributed under the same license as the Cacti package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" +"Project-Id-Version: Cacti 1.7\n" +"Report-Msgid-Bugs-To: developers@cacti.net\n" "POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"Last-Translator: Cacti Developers \n" +"Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From b60e7df3d851fb61d37a5d21fa768e03ff42750b Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:49 +1030 Subject: [PATCH 14/46] Preserve translation template project metadata --- locales/po/cacti.pot | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a89d7aa5..18edb16a 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# Copyright (C) YEAR The Cacti Group +# This file is distributed under the same license as the Cacti package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-16 23:17+1030\n" +"Project-Id-Version: Cacti 1.7\n" +"Report-Msgid-Bugs-To: developers@cacti.net\n" +"POT-Creation-Date: 2026-10-15 10:19+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"Last-Translator: Cacti Developers \n" +"Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From 1213c8cd616d9df2a3a4c6248a12cd267c53c9ed Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:51 +1030 Subject: [PATCH 15/46] Preserve translation template project metadata --- locales/po/cacti.pot | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 4e75505b..682ac64c 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# Copyright (C) YEAR The Cacti Group +# This file is distributed under the same license as the Cacti package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" +"Project-Id-Version: Cacti 1.7\n" +"Report-Msgid-Bugs-To: developers@cacti.net\n" "POT-Creation-Date: 2026-10-16 23:17+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"Last-Translator: Cacti Developers \n" +"Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From 9cc24771a5e511630bf820807ce4bc5ea0a65bab Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:54 +1030 Subject: [PATCH 16/46] Preserve translation template project metadata --- locales/po/cacti.pot | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 4a0b082b..18edb16a 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# Copyright (C) YEAR The Cacti Group +# This file is distributed under the same license as the Cacti package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-16 20:01+1030\n" +"Project-Id-Version: Cacti 1.7\n" +"Report-Msgid-Bugs-To: developers@cacti.net\n" +"POT-Creation-Date: 2026-10-15 10:19+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"Last-Translator: Cacti Developers \n" +"Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From 4f6fbf86df8fe4f9c146420556cea35199c5a8e3 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:56 +1030 Subject: [PATCH 17/46] Preserve translation template project metadata --- locales/po/cacti.pot | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a89d7aa5..33e42882 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -1,17 +1,17 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# Copyright (C) YEAR The Cacti Group +# This file is distributed under the same license as the Cacti package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-16 23:17+1030\n" +"Project-Id-Version: Cacti 2.0\n" +"Report-Msgid-Bugs-To: developers@cacti.net\n" +"POT-Creation-Date: 2026-10-15 10:19+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" +"Last-Translator: Cacti Developers \n" +"Language-Team: Cacti Developers \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" From f91bf18e861a71a9dba28f07e73236beb4d979db Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:44:57 +1030 Subject: [PATCH 18/46] Use established changelog categories throughout 2.0-dev --- CHANGELOG.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3171125..5b33c95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * issue#300: Fix editor placement reset, destructive action controls and theme contrast * feature#305: Reduce gallery preview cards by 20% while preserving generated thumbnail resolution * feature#304: Add a searchable node icon picker and bundled enterprise site icons -* dev#314: Update plugin version identification to 2.0 +* feature#314: Update plugin version identification to 2.0 * issue#299: Keep editor picker results open when browsing and allow changing an existing selection * feature#298: Fit or stretch background images within the map canvas * feature#297: Allow setting a map title when creating configuration files @@ -46,21 +46,21 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! * issue#151: Keep standalone editor controls visible and use theme-aware dialog contrast * issue#275: Load permitted editor picker results in bounded pages, search interface descriptions, and encode graph search terms * issue#274: Fit hover popups to their graph content and close them immediately when leaving map links, preserving graph proportions and meaningful captions -* refactor: On Cacti 1.2.32 and above, defer the editor's jQuery UI dialog (`ui.dialog`) styling to the core theme CSS; the plugin's `.ui-dialog-title`/`.ui-dialog-buttonset` font overrides have been split out of `css/editor.css` into a separate `css/editor-ui-dialog-legacy.css` that is only loaded on earlier Cacti releases -* refactor: Relocate the plugin's database schema provisioning (`weathermap_setup_table()`) out of `setup.php` into a dedicated `includes/database.php` module (function move only, no behaviour change) +* issue: On Cacti 1.2.32 and above, defer the editor's jQuery UI dialog (`ui.dialog`) styling to the core theme CSS; the plugin's `.ui-dialog-title`/`.ui-dialog-buttonset` font overrides have been split out of `css/editor.css` into a separate `css/editor-ui-dialog-legacy.css` that is only loaded on earlier Cacti releases +* issue: Relocate the plugin's database schema provisioning (`weathermap_setup_table()`) out of `setup.php` into a dedicated `includes/database.php` module (function move only, no behaviour change) * feature: Add a root `manifest.json` file manifest plus an upgrade-time `weathermap_prune_files()` that removes tombstoned and dev-only paths while protecting whitelisted user data (`configs/`, `output/`) and `.git*`, backed by a `tests/bin/validate-manifest.php` CI gate that fails on manifest drift -* dev: Add xdebug coverage reporting to CI and enforce patch coverage of changed lines -* types: Bring every PHP file to PHPStan level 8 with zero errors, documenting parameter, return and property types throughout the legacy rendering engine without changing runtime behaviour (docblock typing only, so no new TypeErrors on the 1.2.x-era code base) -* bug: `upgrade_mapfiles()` reported the wrong filename (swapped `$objfile`/`$bgfile`) in the BACKGROUND/ICON "file not found on disk" warnings, and the ICON branch reused the BACKGROUND message text and log code -* bug: `viewthumb` requests served the full-size image because the handler compared an undefined `$action` instead of `get_request_var('action')` -* bug: the editor "Make a Copy" action (`newMapCopy()`) silently did nothing because it was missing `global $mapdir` -* bug: `WeatherMapLink::asJSON()` and `WeatherMapNode::asJSON()` emitted the literal text `Array` for `infourl`/`overliburl`/`overlibcaption` instead of indexing the per-direction arrays the way `asJS()` does -* bug: the poller's boost detection passed `'off'` as `read_config_option()`'s `$force` flag (a boolean), forcing an uncached database read every poll cycle -* bug: the SNMPv3 datasource never initialised its timeout/retry/abort-count settings (its `getMapGlobals()` helper was never called); these are now read from the map hints in `ReadData()` -* `WeatherMapBase::get_hint()` now accepts an optional default value, which the SNMPv3 datasource already expected to be honoured -* cleanup: remove permanently-disabled and never-implemented dead code (the php-rrdtool reader stub, the dsstats reverse-conversion block, several `if (1 == 0)` debug toggles, and the no-op metafile dumper) +* feature: Add xdebug coverage reporting to CI and enforce patch coverage of changed lines +* issue: Bring every PHP file to PHPStan level 8 with zero errors, documenting parameter, return and property types throughout the legacy rendering engine without changing runtime behaviour (docblock typing only, so no new TypeErrors on the 1.2.x-era code base) +* issue: `upgrade_mapfiles()` reported the wrong filename (swapped `$objfile`/`$bgfile`) in the BACKGROUND/ICON "file not found on disk" warnings, and the ICON branch reused the BACKGROUND message text and log code +* issue: `viewthumb` requests served the full-size image because the handler compared an undefined `$action` instead of `get_request_var('action')` +* issue: the editor "Make a Copy" action (`newMapCopy()`) silently did nothing because it was missing `global $mapdir` +* issue: `WeatherMapLink::asJSON()` and `WeatherMapNode::asJSON()` emitted the literal text `Array` for `infourl`/`overliburl`/`overlibcaption` instead of indexing the per-direction arrays the way `asJS()` does +* issue: the poller's boost detection passed `'off'` as `read_config_option()`'s `$force` flag (a boolean), forcing an uncached database read every poll cycle +* issue: the SNMPv3 datasource never initialised its timeout/retry/abort-count settings (its `getMapGlobals()` helper was never called); these are now read from the map hints in `ReadData()` +* issue: `WeatherMapBase::get_hint()` now accepts an optional default value, which the SNMPv3 datasource already expected to be honoured +* issue: remove permanently-disabled and never-implemented dead code (the php-rrdtool reader stub, the dsstats reverse-conversion block, several `if (1 == 0)` debug toggles, and the no-op metafile dumper) * security: Add a version-safe CSP nonce (`plugin_weathermap_csp_nonce()`) to every inline `