diff --git a/CHANGELOG.md b/CHANGELOG.md
index fde7b01..293140d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,35 +2,35 @@
--- develop ---
-* fix: Make bundled Net_DNS2 constructor nullability explicit for PHP 8.4 without changing accepted arguments
-
-* issue: Shorten the graph drilldown button title from 'View NetFlow Traffic In Range' to 'NetFlow' so it reads cleanly as a label in Cacti's graph action menu
-* dev: Create the plugin's config.php from config.php.dist for the flowview install step in the integration-test workflow (removed immediately afterwards so the unit tests still run with no config file), so the install no longer fails with "needs configuration"
-* feature: Add an optional FlowView Query Service (a long-running Go service under service/flowview-query) as a selectable Parallel Query Backend under FlowView Settings (default Legacy PHP Runner); when enabled, the parallel query parent runner (flowview_runner.php) delegates the whole query to the service over localhost instead of forking a PHP worker per shard, running the same cache-aware map-reduce (sharing parallel_database_query_shard_cache) and writing results back, with automatic fallback to the legacy runner when the service is unreachable
-
-* bug: Load the pre-1.3 report compatibility shims (reports_run/reports_queue/reports_log_and_notify) only when the running Cacti is older than 1.3, decided with cacti_version_compare(CACTI_VERSION, '1.3', ...) rather than a function_exists() probe; because the plugin's functions.php is included during plugin init before core loads lib/reports.php, the function_exists() check ran while the now-native helpers were still absent, declared the shims, and core then fatally redeclared reports_log_and_notify() on 1.3.0 develop builds (falls back to function_exists() only when the Cacti version helpers are unavailable, e.g. the unit tests)
-* security: Move the plugin's remaining inline event handlers to CSP-safe bindings so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive: confirmation Cancel buttons use the `cactiReturnTo` class, the database/filter selects are bound via change() in their ready blocks, and the main filter's sort/report selects and time-shift icons are bound in flowview_display_filter's ready block
-* dev: Replace the translation-template CI check that regenerated locales/po/cacti.pot and compared it with a diff-based gate (tests/bin/check-i18n-pot.php) requiring cacti.pot to be updated only when a pull request adds, removes, or modifies an i18n function call
-* issue: Prevent flow-capture from fatally erroring (TypeError in spl_object_hash) when flowview_db_check_reconnect() held a null shared-database handle; normalize a non-object connection to false so db_check_reconnect() checks and reconnects the default connection instead
-* dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure)
-* dev: Enforce patch coverage of changed lines in CI and remove the inert COMPOSER_ROOT_VERSION env from the Pest step
-* dev: Pin the CI database image and shared test dependencies, disable persisted checkout credentials, and limit workflow permissions and runtime
+* security: Move the plugin's remaining inline event handlers to CSP-safe bindings
+* security: Add a version-safe CSP nonce (`plugin_flowview_csp_nonce()`) to every inline `';
diff --git a/nbar_count.php b/nbar_count.php
new file mode 100644
index 0000000..a71e7d5
--- /dev/null
+++ b/nbar_count.php
@@ -0,0 +1,164 @@
+ $entry) {
+ if ($entry['expires'] < time()) {
+ unset($cache[$k]);
+ }
+ }
+ if (!isset($cache[$key])) {
+ while (cacti_sizeof($cache) >= 12) {
+ array_shift($cache);
+ }
+ $cache[$key] = ['sql' => $sql, 'args' => $args, 'expires' => time() + 3600, 'ttl' => $end >= time() - 120 ? 60 : 600];
+ }
+ $_SESSION['fv_nbar_counts'] = $cache;
+ return $key;
+}
+/**
+ * Send a JSON count response and terminate the HTTP request.
+ *
+ * @param array $payload JSON response fields.
+ *
+ * @return never
+ */
+function fv_nbar_count_response($payload) {
+ header('Content-Type: application/json');
+ header('Cache-Control: no-store');
+ echo json_encode($payload);
+ exit;
+}
+/**
+ * Handle an authenticated count request without blocking account navigation.
+ *
+ * @return never
+ */
+function fv_nbar_count_request() {
+ $key = get_nfilter_request_var('key');
+ if (!is_string($key) || !preg_match('/^[a-f0-9]{64}$/D', $key)) {
+ fv_nbar_count_response(['status' => 'expired']);
+ }
+ $entry = $_SESSION['fv_nbar_counts'][$key] ?? null;
+ if (!$entry || $entry['expires'] < time()) {
+ fv_nbar_count_response(['status' => 'expired']);
+ }
+ if (($entry['failed_until'] ?? 0) > time()) {
+ fv_nbar_count_response(['status' => 'unavailable']);
+ }
+ if (isset($entry['total']) && $entry['until'] > time()) {
+ fv_nbar_count_response(['status' => 'ready', 'pages' => (int) ceil($entry['total'] / 100), 'total' => $entry['total']]);
+ }
+ // Release the account session lock so navigation remains available during counting.
+ session_write_close();
+ $locked = flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_page_count:',LEFT(SHA2(DATABASE(),256),16)),0)");
+ if ((int) $locked !== 1) {
+ fv_nbar_count_response(['status' => 'busy']);
+ }
+ // Return promptly; subsequent polls read the completed session cache.
+ // PHP-FPM can finish this bounded calculation after closing the response.
+ ini_set('session.use_cookies', '0');
+ session_cache_limiter('');
+ $background = function_exists('fastcgi_finish_request');
+ if ($background) {
+ header('Content-Type: application/json');
+ header('Cache-Control: no-store');
+ echo json_encode(['status' => 'busy']);
+ fastcgi_finish_request();
+ }
+ $row = null;
+ try {
+ set_time_limit(100);
+ $row = fv_nbar_count_query($entry['sql'], $entry['args']);
+ } finally {
+ flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_page_count:',LEFT(SHA2(DATABASE(),256),16)))");
+ }
+ if (!is_array($row) || !isset($row['total'])) {
+ session_start();
+ if (isset($_SESSION['fv_nbar_counts'][$key])) {
+ $_SESSION['fv_nbar_counts'][$key]['failed_until'] = time() + 60;
+ }
+ session_write_close();
+ if ($background) {
+ exit;
+ }
+ fv_nbar_count_response(['status' => 'unavailable']);
+ }
+ $total = (int) $row['total'];
+ session_start();
+ if (isset($_SESSION['fv_nbar_counts'][$key])) {
+ $_SESSION['fv_nbar_counts'][$key]['total'] = $total;
+ $_SESSION['fv_nbar_counts'][$key]['until'] = time() + $entry['ttl'];
+ }
+ session_write_close();
+ if ($background) {
+ exit;
+ }
+ fv_nbar_count_response(['status' => 'ready', 'pages' => (int) ceil($total / 100), 'total' => $total]);
+}
+
+/** Bound a SELECT on the actual FlowView server rather than Cacti's server. */
+function fv_nbar_count_query(string $sql, array $args) {
+ $version = flowview_db_fetch_cell('SELECT VERSION()');
+ if (is_string($version) && stripos($version, 'MariaDB') !== false) {
+ return flowview_db_fetch_row_prepared('SET STATEMENT max_statement_time=90 FOR ' . $sql, $args);
+ }
+ if (is_string($version) && preg_match('/^8[.]/', $version)) {
+ return flowview_db_fetch_row_prepared(preg_replace('/^SELECT /', 'SELECT /*+ MAX_EXECUTION_TIME(90000) */ ', $sql), $args);
+ }
+ return false;
+}
diff --git a/nbar_daily.php b/nbar_daily.php
new file mode 100644
index 0000000..133ba28
--- /dev/null
+++ b/nbar_daily.php
@@ -0,0 +1,296 @@
+getTimestamp(), $day->modify('+1 day')->getTimestamp()];
+}
+/**
+ * Require every overlapping hour to match its completed raw revision.
+ *
+ * @param int $listener Capture identifier.
+ * @param int $from Inclusive boundary in Unix seconds.
+ * @param int $to Exclusive boundary in Unix seconds.
+ *
+ * @return string|null
+ */
+function fv_nbar_daily_signature($listener, $from, $to) {
+ $lo = (int) (floor($from / 3600) * 3600);
+ $hi = (int) (ceil($to / 3600) * 3600);
+ $r = flowview_db_fetch_row_prepared('SELECT COUNT(*) n, COALESCE(SUM(revision),0) rev,
+ COALESCE(SUM(built=revision AND connection_built=revision AND built>=0),0) ready
+ FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour>=? AND hour', [$listener, $lo, $hi]);
+ return (int) ($r['n'] ?? 0) === (int) (($hi - $lo) / 3600) && (int) ($r['ready'] ?? 0) === (int) $r['n'] ? (string) $r['rev'] : null;
+}
+/**
+ * Build a SQL guard that rejects days invalidated by late records.
+ *
+ * @param int $listener Capture identifier.
+ * @param int $day Local calendar-day start in Unix seconds.
+ * @param int $to Exclusive boundary in Unix seconds.
+ * @param string $revision Expected summary revision.
+ *
+ * @return string
+ */
+function fv_nbar_daily_guard($listener, $day, $to, $revision) {
+ $listener = (int) $listener;
+ $lo = (int) (floor($day / 3600) * 3600);
+ $hi = (int) (ceil($to / 3600) * 3600);
+ $count = (int) (($hi - $lo) / 3600);
+ $revision = preg_match('/^[0-9]+$/', (string) $revision) ? $revision : '-1';
+ return "EXISTS (SELECT 1 FROM plugin_flowview_nbar_days WHERE listener_id=$listener AND day=$day AND covered_to=$to AND revision=$revision) AND (SELECT COUNT(*)=$count AND COALESCE(SUM(revision),0)=$revision AND
+ COALESCE(SUM(built=revision AND connection_built=revision AND built>=0),0)=$count
+ FROM plugin_flowview_nbar_hours WHERE listener_id=$listener AND hour>=$lo AND hour<$hi)";
+}
+/**
+ * Add one hour atomically or restart a day invalidated by late records.
+ *
+ * @param int $listener Capture identifier.
+ * @param int $day Local calendar-day start in Unix seconds.
+ * @param int $target Requested exclusive summary boundary.
+ *
+ * @return int
+ */
+function fv_nbar_daily_build($listener, $day, $target) {
+ $listener = (int) $listener;
+ $day = (int) $day;
+ $target = (int) $target;
+ if (!flowview_db_execute('START TRANSACTION WITH CONSISTENT SNAPSHOT')) {
+ return 0;
+ }
+ $old = flowview_db_fetch_row_prepared('SELECT * FROM plugin_flowview_nbar_days WHERE listener_id=? AND day=?', [$listener, $day]);
+ $valid = $old && $old['timezone'] === date_default_timezone_get() && (int) $old['covered_to'] <= $target &&
+ fv_nbar_daily_signature($listener, $day, (int) $old['covered_to']) === (string) $old['revision'];
+ $from = $valid ? (int) $old['covered_to'] : $day;
+ if ($from >= $target) {
+ flowview_db_execute('ROLLBACK');
+ return 0;
+ }
+ $to = min($target, (int) (floor($from / 3600) * 3600 + 3600));
+ $revision = fv_nbar_daily_signature($listener, $day, $to);
+ if ($revision === null) {
+ flowview_db_execute('ROLLBACK');
+ return 0;
+ }
+ $statement_timeout = fv_nbar_worker_timeout(20);
+ if ($statement_timeout === null) {
+ flowview_db_execute('ROLLBACK');
+ return 0;
+ }
+ try {
+ foreach (['apps' => 'app_id,LENGTH(src)', 'pairs' => 'app_id,src,dst', 'connections' => 'app_id,src,dst,sport,dport,protocol'] as $kind => $fields) {
+ if (!$valid && !flowview_db_execute_prepared("DELETE FROM plugin_flowview_nbar_daily_$kind WHERE listener_id=? AND hour=?", [$listener, $day])) {
+ throw new RuntimeException('Daily reset failed');
+ }
+ $source = fv_nbar_hourly_source($kind, [$listener], $from, $to);
+ $sql = "INSERT INTO plugin_flowview_nbar_daily_$kind SELECT $listener,$day,$fields,SUM(bytes),SUM(packets),SUM(flows),IF(COUNT(first_seen)=COUNT(*),MIN(first_seen),NULL),IF(COUNT(last_seen)=COUNT(*),MAX(last_seen),NULL),router
+ FROM $source d WHERE d.listener_id=$listener AND d.minute>=$from AND d.minute<$to GROUP BY $fields,router
+ ON DUPLICATE KEY UPDATE bytes=bytes+VALUES(bytes),packets=packets+VALUES(packets),flows=flows+VALUES(flows),first_seen=LEAST(first_seen,VALUES(first_seen)),last_seen=GREATEST(last_seen,VALUES(last_seen))";
+ if (!flowview_db_execute($sql)) {
+ throw new RuntimeException('Daily aggregate failed');
+ }
+ }
+ if (!flowview_db_execute_prepared(
+ 'INSERT INTO plugin_flowview_nbar_days
+ (listener_id,day,covered_to,revision,timezone,updated) VALUES (?,?,?,?,?,?)
+ ON DUPLICATE KEY UPDATE covered_to=VALUES(covered_to),revision=VALUES(revision),timezone=VALUES(timezone),updated=VALUES(updated)',
+ [$listener, $day, $to, $revision, date_default_timezone_get(), time()]
+ )) {
+ throw new RuntimeException('Daily checkpoint failed');
+ }
+ if (!flowview_db_execute('COMMIT')) {
+ throw new RuntimeException('Daily commit failed');
+ }
+ return 1;
+ } catch (Throwable $e) {
+ flowview_db_execute('ROLLBACK');
+ cacti_log('NBAR daily summary: ' . $e->getMessage(), false, 'FLOWVIEW');
+ return 0;
+ } finally {
+ flowview_db_execute('SET SESSION max_statement_time=' . $statement_timeout);
+ }
+}
+/**
+ * Maintain reusable calendar-day summaries within a worker budget.
+ *
+ * @param int $budget Worker time budget in seconds.
+ *
+ * @return int
+ */
+function fv_nbar_daily_refresh($budget = 20) {
+ if ((int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)),0)") !== 1) {
+ return 0;
+ }
+ $deadline = microtime(true) + max(1, (int) $budget);
+ $done = 0;
+ try {
+ $cutoff = time() - fv_nbar_retention_days() * 86400;
+ $closed = (int) (floor((time() - 300) / 3600) * 3600);
+ $listeners = flowview_db_fetch_assoc('SELECT listener_id,MIN(hour) first_hour FROM plugin_flowview_nbar_hours GROUP BY listener_id');
+ $candidates = [];
+ foreach ($listeners as $row) {
+ $first_record = flowview_db_fetch_cell_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [(int) $row['listener_id']]);
+ if (!$first_record) {
+ continue;
+ }
+ $row['first_hour'] = (int) (floor((int) $first_record / 3600) * 3600);
+ [$first] = fv_nbar_daily_bounds(max($cutoff, (int) $first_record));
+ if ($first >= $cutoff) {
+ for ($empty = (int) (floor($first / 3600) * 3600);$empty < (int) $row['first_hour'];$empty += 3600) {
+ flowview_db_execute_prepared('INSERT IGNORE INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built,connection_built) VALUES (?,?,0,0,0)', [(int) $row['listener_id'], $empty]);
+ }
+ }
+ [$today] = fv_nbar_daily_bounds($closed);
+ for ($day = $first;$day <= $today;$day = $next) {
+ [, $next] = fv_nbar_daily_bounds($day);
+ if ($day < $cutoff) {
+ continue;
+ }
+ $candidates[] = [(int) $row['listener_id'], $day, min($next, $closed)];
+ }
+ }
+ // Finish older days first; each checkpoint appends only the next hour.
+ usort($candidates, static function ($a, $b) {return $a[1] <=> $b[1] ?: $a[0] <=> $b[0];});
+ do {
+ $progress = 0;
+ foreach ($candidates as [$listener,$day,$target]) {
+ if (microtime(true) >= $deadline) {
+ break 2;
+ }
+ $progress += fv_nbar_daily_build($listener, $day, $target);
+ }
+ $done += $progress;
+ } while ($progress && microtime(true) < $deadline);
+ } finally {
+ flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)))");
+ }
+ return $done;
+}
+/**
+ * Expire daily summary rows and checkpoints in bounded batches.
+ *
+ * @return void
+ */
+function fv_nbar_daily_cleanup() {
+ fv_nbar_cleanup_batches(fv_nbar_daily_cleanup_queries(), microtime(true) + 10);
+}
+/** Return allowlisted daily retention batches for the shared cleanup worker. */
+function fv_nbar_daily_cleanup_queries(): array {
+ $cutoff = time() - fv_nbar_retention_days() * 86400;
+ $queries = [];
+ foreach (['apps', 'pairs', 'connections'] as $kind) {
+ $queries[] = ["DELETE FROM plugin_flowview_nbar_daily_$kind WHERE hour<$cutoff LIMIT 5000", 5000];
+ }
+ $queries[] = ["DELETE FROM plugin_flowview_nbar_days WHERE day<$cutoff LIMIT 100", 100];
+ return $queries;
+}
+/**
+ * Use valid full-day summaries and hourly or raw boundary records.
+ *
+ * @param string $kind Allowlisted summary type.
+ * @param array $listeners Selected capture identifiers.
+ * @param int $start Inclusive range start in Unix seconds.
+ * @param int $end Exclusive range end in Unix seconds.
+ * @param int|null $now Current Unix time, or null to read the clock.
+ *
+ * @return string
+ */
+function fv_nbar_summary_source($kind, $listeners, $start, $end, $now = null) {
+ if (!in_array($kind, ['apps', 'pairs', 'connections'], true)) {
+ throw new InvalidArgumentException('Unknown summary kind');
+ }
+ $listeners = array_values(array_unique(array_filter(array_map('intval', $listeners), static function ($id) {return $id > 0;})));
+ if (!$listeners) {
+ return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)';
+ }
+ $start = (int) $start;
+ $end = (int) $end;
+ $now = $now ?? time();
+ if ($end - $start < 3600) {
+ return fv_nbar_hourly_source($kind, $listeners, $start, $end, $now);
+ }
+ $parts = [];
+ foreach ($listeners as $listener) {
+ $days = flowview_db_fetch_assoc_prepared('SELECT * FROM plugin_flowview_nbar_days WHERE listener_id=? AND day>=? AND covered_to<=? AND covered_to>day ORDER BY day', [$listener, $start, $end]);
+ $cursor = $start;
+ foreach ($days as $row) {
+ $day = (int) $row['day'];
+ $to = (int) $row['covered_to'];
+ if ($row['timezone'] !== date_default_timezone_get() || $day < $cursor || $to > (int) (floor(($now - 300) / 3600) * 3600)) {
+ continue;
+ }
+ if ($day > $cursor) {
+ $s = fv_nbar_hourly_source($kind, [$listener], $cursor, $day, $now);
+ $parts[] = "SELECT * FROM $s fallback_before WHERE listener_id=$listener AND minute>=$cursor AND minute<$day";
+ }
+ $ready = fv_nbar_daily_guard($listener, $day, $to, $row['revision']);
+ if ($kind === 'apps') {
+ $fields = 'listener_id,hour minute,app_id,REPEAT(CHAR(0),family) src,REPEAT(CHAR(0),family) dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router';
+ } elseif ($kind === 'pairs') {
+ $fields = 'listener_id,hour minute,app_id,src,dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router';
+ } else {
+ $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen,router';
+ }
+ $parts[] = "SELECT $fields FROM plugin_flowview_nbar_daily_$kind WHERE listener_id=$listener AND hour=$day AND ($ready)";
+ $s = fv_nbar_hourly_source($kind, [$listener], $day, $to, $now);
+ $parts[] = "SELECT * FROM $s fallback_day WHERE listener_id=$listener AND minute>=$day AND minute<$to AND NOT ($ready)";
+ $cursor = $to;
+ }
+ if ($cursor < $end) {
+ $s = fv_nbar_hourly_source($kind, [$listener], $cursor, $end, $now);
+ $parts[] = "SELECT * FROM $s fallback_after WHERE listener_id=$listener AND minute>=$cursor AND minute<$end";
+ }
+ }
+ return '(' . implode(' UNION ALL ', $parts) . ')';
+}
diff --git a/nbar_dns.php b/nbar_dns.php
new file mode 100644
index 0000000..5f359e0
--- /dev/null
+++ b/nbar_dns.php
@@ -0,0 +1,101 @@
+ time()) {
+ return $names[$ip] = $negative[$ip]['name'];
+ }
+ if ($started === null) {
+ $started = microtime(true);
+ }
+ if (microtime(true) - $started > 1) {
+ return $names[$ip] = __('Not resolved', 'flowview');
+ }
+ if ($resolver === null) {
+ set_include_path($config['base_path'] . '/plugins/flowview' . PATH_SEPARATOR . get_include_path());
+ include_once ($config['base_path'] . '/plugins/flowview/Net/DNS2.php');
+ $servers = array_filter([read_config_option('settings_dns_primary'), read_config_option('settings_dns_secondary')]);
+ $resolver = new Net_DNS2_Resolver($servers ? ['nameservers' => array_values($servers)] : []);
+ $resolver->timeout = 1;
+ }
+ try {
+ $response = $resolver->query($ip, 'PTR');
+ foreach ($response->answer as $answer) {
+ if (!empty($answer->ptrdname)) {
+ $name = rtrim($answer->ptrdname, '.');
+ flowview_db_execute_prepared('INSERT INTO plugin_flowview_dnscache (ip,host,source,arin_verified,arin_id,time) VALUES (?,?,?,0,0,?) ON DUPLICATE KEY UPDATE host=VALUES(host),source=VALUES(source),time=VALUES(time)', [$ip, $name, 'DNS', time()]);
+ return $names[$ip] = $name;
+ }
+ }
+ return $names[$ip] = fv_nbar_dns_negative($ip, __('No reverse DNS', 'flowview'), 300);
+ } catch (Exception $e) {
+ return $names[$ip] = fv_nbar_dns_negative($ip, $e->getCode() === 3 ? __('No reverse DNS', 'flowview') : __('Not resolved', 'flowview'), $e->getCode() === 3 ? 300 : 30);
+ }
+}
+
+/**
+ * Cache a negative DNS result with bounded account-session storage.
+ *
+ * @param string $ip Endpoint IP address.
+ * @param string $name Cached DNS result label.
+ * @param int $ttl Cache lifetime in seconds.
+ *
+ * @return string
+ */
+function fv_nbar_dns_negative($ip, $name, $ttl) {
+ $cache = $_SESSION['fv_nbar_dns_negative'] ?? [];
+ foreach ($cache as $key => $entry) {
+ if ($entry['until'] <= time()) {
+ unset($cache[$key]);
+ }
+ }
+ $cache[$ip] = ['name' => $name, 'until' => time() + $ttl];
+ if (cacti_sizeof($cache) > 512) {
+ $cache = array_slice($cache, -512, null, true);
+ }
+ $_SESSION['fv_nbar_dns_negative'] = $cache;
+ return $name;
+}
diff --git a/nbar_feed_status.php b/nbar_feed_status.php
new file mode 100644
index 0000000..2d5e297
--- /dev/null
+++ b/nbar_feed_status.php
@@ -0,0 +1,62 @@
+' . fv_nbar_h(__('STATUS:', 'flowview')) . ' ';
+ $items = [];
+ foreach ($devices as $device) {
+ $id = (int) $device['id'];
+ if (!in_array($id, $capture_ids, true)) {
+ continue;
+ }
+ // Indexed lookup independent of historical report filters.
+ $latest = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute DESC LIMIT 1', [$id]);
+ if (!is_array($latest)) {
+ $items[] = '' . fv_nbar_h($device['name']) . ': ' . fv_nbar_h(__('Unable to load NBAR status. Please try again.', 'flowview')) . ' ';
+ continue;
+ }
+ $last = isset($latest['minute']) ? (int) $latest['minute'] : 0;
+ $receiving = $last > 0 && $last >= $now - 300 && $last <= $now + 60;
+ $label = $receiving ? __('Receiving', 'flowview') : __('No recent NBAR', 'flowview');
+ $seen = $last > 0 ? __('Last NBAR record: %s', date('Y-m-d H:i', $last), 'flowview') : __('No retained NBAR records', 'flowview');
+ $icon = $receiving ? 'fa-check-circle' : 'fa-times-circle';
+ $color = $receiving ? '#27852b' : '#d32f2f';
+ $items[] = ' ' . fv_nbar_h($device['name']) . ': ' . fv_nbar_h($label) . ' (' . fv_nbar_h($seen) . ') ';
+ }
+ echo implode(' | ', $items);
+ if (!$items) {
+ echo fv_nbar_h(__('No captures selected', 'flowview'));
+ }
+ echo '';
+}
diff --git a/nbar_preferences.php b/nbar_preferences.php
new file mode 100644
index 0000000..096fe87
--- /dev/null
+++ b/nbar_preferences.php
@@ -0,0 +1,186 @@
+ strlen($packed) * 8) {
+ return false;
+ }
+ $out[] = inet_ntop($packed) . '/' . $bits;
+ if (cacti_sizeof($out) > 32) {
+ return false;
+ }
+ }
+ return array_values(array_unique($out));
+}
+/**
+ * Load account preferences and validate a protected settings submission.
+ *
+ * @param array $devices Available capture definitions.
+ *
+ * @return array
+ */
+function fv_nbar_preferences_init($devices) {
+ $defaults = ['prefixes' => [], 'listeners' => [], 'data_unit' => 'GB', 'rate_unit' => 'Mbps', 'dns' => '1'];
+ $saved = json_decode((string) read_user_setting('flowview_nbar_preferences', ''), true);
+ $p = array_replace($defaults, is_array($saved) ? $saved : ($_SESSION['fv_nbar_preferences'] ?? []));
+ if (!is_array($p['prefixes']) || !fv_nbar_cidrs(implode("\n", $p['prefixes']))) {
+ $p['prefixes'] = $defaults['prefixes'];
+ }
+ $p['listeners'] = is_array($p['listeners']) ? array_values(array_intersect(array_map('intval', $p['listeners']), array_map('intval', array_column($devices, 'id')))) : $defaults['listeners'];
+ if (!$p['listeners']) {
+ $p['listeners'] = $defaults['listeners'];
+ }
+ $message = '';
+ $GLOBALS['fv_nbar_preferences_saved'] = false;
+ if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && get_nfilter_request_var('prefs_apply') === '1') {
+ if (!function_exists('csrf_check') || !csrf_check(false)) {
+ http_response_code(403);
+ exit;
+ }
+ $candidate = $p;
+ $prefixes = is_string(get_nfilter_request_var('local_prefixes', null)) ? fv_nbar_cidrs(get_nfilter_request_var('local_prefixes')) : false;
+ $ids = is_array(get_nfilter_request_var('site_listeners', null)) ? get_nfilter_request_var('site_listeners') : [];
+ $ids = array_filter($ids, 'is_scalar');
+ $ids = array_values(array_intersect(array_map('intval', $ids), array_map('intval', array_column($devices, 'id'))));
+ if ($prefixes === false) {
+ $message = __('Settings were not saved. Enter valid IPv4/IPv6 networks, or leave the list blank.', 'flowview');
+ } elseif (cacti_sizeof(array_unique($ids)) > 3 || (isset_request_var('combine_apply') && cacti_sizeof(array_unique($ids)) === 1)) {
+ $message = __('Settings were not saved. Choose two or three captures to combine, or clear all selections to save Internal Networks only.', 'flowview');
+ } else {
+ $candidate['prefixes'] = $prefixes;
+ $candidate['listeners'] = array_values(array_unique($ids));
+ foreach (['data_unit' => ['Auto', 'MB', 'GB'], 'rate_unit' => ['Auto', 'Kbps', 'Mbps'], 'dns' => ['0', '1']] as $key => $allowed) {
+ if (is_string(get_nfilter_request_var($key, null)) && in_array(get_nfilter_request_var($key), $allowed, true)) {
+ $candidate[$key] = get_nfilter_request_var($key);
+ }
+ }
+ $_SESSION['fv_nbar_preferences'] = $p = $candidate;
+ $GLOBALS['fv_nbar_preferences_saved'] = true;
+ set_user_setting('flowview_nbar_preferences', json_encode($p));
+ $message = '';
+ }
+ }
+ $GLOBALS['fv_nbar_preferences'] = $p;
+ $GLOBALS['fv_nbar_preferences_message'] = $message;
+ return $p;
+}
+/**
+ * Build the internal-network predicate for a permitted address column.
+ *
+ * @param string $f Permitted address-column name.
+ *
+ * @return string
+ */
+function fv_nbar_local_sql($f) {
+ if (!in_array($f, ['d.src', 'd.dst'], true)) {
+ throw new InvalidArgumentException('Invalid address field');
+ }
+ $clauses = [];
+ foreach ($GLOBALS['fv_nbar_preferences']['prefixes'] as $cidr) {
+ [$ip,$bits] = explode('/', $cidr);
+ $packed = inet_pton($ip);
+ $bits = (int) $bits;
+ $whole = intdiv($bits, 8);
+ $rest = $bits % 8;
+ $checks = ['LENGTH(' . $f . ')=' . strlen($packed)];
+ if ($whole) {
+ $checks[] = "LEFT(HEX($f)," . ($whole * 2) . ")='" . strtoupper(bin2hex(substr($packed, 0, $whole))) . "'";
+ }
+ if ($rest) {
+ $mask = (255 << (8 - $rest)) & 255;
+ $value = ord($packed[$whole]) & $mask;
+ $checks[] = "(ORD(SUBSTRING($f," . ($whole + 1) . ",1)) & $mask)=$value";
+ }
+ $clauses[] = '(' . implode(' AND ', $checks) . ')';
+ }
+ return $clauses ? '(' . implode(' OR ', $clauses) . ')' : '(0)';
+}
+/**
+ * Render protected capture options, networks and display preferences.
+ *
+ * @param array $devices Available capture definitions.
+ * @param string $base Report base URL.
+ * @param string $mode Current report mode.
+ * @param string $aid Current application identifier.
+ * @param string $parent_app Parent application drilldown identifier.
+ *
+ * @return void
+ */
+function fv_nbar_preferences_render($devices, $base, $mode, $aid, $parent_app) {
+ global $config;
+ $p = $GLOBALS['fv_nbar_preferences'];
+ echo '
';
+ if ($GLOBALS['fv_nbar_preferences_message'] !== '') {
+ echo '
' . fv_nbar_h($GLOBALS['fv_nbar_preferences_message']) . '
';
+ }
+ echo '
';
+ foreach (['data_unit' => [__('Data units', 'flowview'), ['Auto', 'MB', 'GB']], 'rate_unit' => [__('Bandwidth units', 'flowview'), ['Auto', 'Kbps', 'Mbps']], 'dns' => [__('DNS names', 'flowview'), ['1' => __('Show', 'flowview'), '0' => __('Hide', 'flowview')]]] as $key => $definition) {
+ echo '
' . html_escape($definition[0]) . ' ';
+ foreach ($definition[1] as $v => $label) {
+ if ($key !== 'dns') {
+ $v = $label;
+ }
+ echo '' . html_escape($label === 'Auto' ? __('Auto', 'flowview') : $label) . ' ';
+ }
+ echo '
';
+ }
+ echo '
' . html_escape(__('Combine Options', 'flowview')) . '
';
+ foreach ($devices as $d) {
+ echo ' ' . fv_nbar_h($d['name']) . ' ';
+ }
+ echo '' . html_escape(__('Choose two or three captures to combine, normally one LAN capture per router. Leave all captures unticked to save Internal Networks only. Overlapping LAN and WAN captures are added together and can count traffic twice.', 'flowview')) . '
';
+ echo '
' . html_escape(__('Internal Networks', 'flowview')) . ' ' . html_escape(__('One internal IPv4 or IPv6 network per line. Used to identify internal hosts and upload/download direction in Conversations. Update delegated IPv6 prefixes when they change, and keep old prefixes for retained history. Exported flows do not automatically identify your site networks.', 'flowview')) . '
' . html_escape(__('Save', 'flowview')) . ' ' . html_escape(__('Cancel', 'flowview')) . ' ';
+ echo '';
+ echo '';
+
+}
+
+/** Exclude multicast endpoints from internal/external conversations. */
+function fv_nbar_unicast_sql(string $field): string {
+ if (!in_array($field, ['d.src', 'd.dst'], true)) {
+ throw new InvalidArgumentException('Invalid address field');
+ }
+ return "NOT ((LENGTH($field)=4 AND (ORD(SUBSTRING($field,1,1)) & 240)=224) OR (LENGTH($field)=16 AND ORD(SUBSTRING($field,1,1))=255))";
+}
diff --git a/nbar_query.php b/nbar_query.php
new file mode 100644
index 0000000..7f11e8e
--- /dev/null
+++ b/nbar_query.php
@@ -0,0 +1,88 @@
+ $entry) {
+ if ($entry['until'] <= $now) {
+ unset($cache[$id]);
+ }
+ }
+ if (isset($cache[$key])) {
+ $_SESSION['fv_nbar_query_cache'] = $cache;
+ return $slice === null ? $cache[$key]['rows'] : array_slice($cache[$key]['rows'], $slice, 101);
+ }
+ $rows = flowview_db_fetch_assoc_prepared($sql, $args);
+ // Cache successful, compact result sets only; never retain query failures.
+ if (is_array($rows) && strlen(serialize($rows)) <= 524288) {
+ while (cacti_sizeof($cache) >= 12) {
+ array_shift($cache);
+ }
+ $cache[$key] = ['until' => time() + ($slice === null ? 30 : 120), 'rows' => $rows];
+ }
+ $_SESSION['fv_nbar_query_cache'] = $cache;
+ return $slice === null || !is_array($rows) ? $rows : array_slice($rows, $slice, 101);
+}
+
+/**
+ * Build an exact grouped connection-page query over a trusted source.
+ *
+ * @param string $source Trusted summary or raw SQL source.
+ * @param string $where Validated filter predicate.
+ * @param int $page One-based connection page.
+ *
+ * @return string
+ */
+function fv_nbar_connections_sql($source, $where, $page) {
+ // Use compact name ranks while grouping; resolve display names after paging.
+ $map = '(SELECT router,app_id,DENSE_RANK() OVER (ORDER BY name) name_id FROM plugin_flowview_nbar_names)';
+ $group = 'd.app_id,COALESCE(n.name_id,0),d.src,d.dst,d.sport,d.dport,d.protocol,d.router';
+ $inner = 'SELECT d.router,d.app_id,COALESCE(n.name_id,0) name_id,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen,d.src,d.dst,d.sport,d.dport,d.protocol FROM ' . $source . ' d JOIN plugin_flowview_devices l ON l.id=d.listener_id LEFT JOIN ' . $map . ' n ON n.router=COALESCE(NULLIF(d.router,SPACE(0)),l.allowfrom) AND n.app_id=d.app_id WHERE ' . $where . ' GROUP BY ' . $group . ' ORDER BY bytes DESC,' . $group . ' LIMIT 101 OFFSET ' . (($page - 1) * 100);
+ return 'SELECT d.router,d.app_id,n.name,d.bytes,d.packets,d.records,d.first_seen,d.last_seen,INET6_NTOA(d.src) src,INET6_NTOA(d.dst) dst,d.sport,d.dport,d.protocol FROM (' . $inner . ') d LEFT JOIN (SELECT name,DENSE_RANK() OVER (ORDER BY name) name_id FROM plugin_flowview_nbar_names GROUP BY name) n ON n.name_id=d.name_id ORDER BY d.bytes DESC,d.app_id,d.name_id,d.src,d.dst,d.sport,d.dport,d.protocol,d.router';
+}
diff --git a/nbar_report.php b/nbar_report.php
new file mode 100644
index 0000000..b2ba035
--- /dev/null
+++ b/nbar_report.php
@@ -0,0 +1,378 @@
+' . html_escape(__('NBAR history is being upgraded. Refresh this report shortly.', 'flowview')) . '';
+ bottom_footer();
+ exit;
+}
+// Report controls use GET links or a protected POST form.
+$nbar_request = [];
+foreach (['listener', 'period', 'family', 'mode', 'app', 'host', 'page', 'parent_app', 'context', 'predefined_timespan', 'date1', 'date2', 'lock_range', 'internal', 'external'] as $key) {
+ $value = get_nfilter_request_var($key, null);
+ if (is_scalar($value)) {
+ $nbar_request[$key] = (string) $value;
+ }
+}
+require_once __DIR__ . '/nbar_common.php';
+
+require_once __DIR__ . '/nbar_dns.php';
+
+include __DIR__ . '/includes/arrays.php';
+$devices = flowview_db_fetch_assoc("SELECT id,name FROM plugin_flowview_devices WHERE enabled='on' ORDER BY id");
+include_once __DIR__ . '/nbar_preferences.php';
+$nbar_preferences = fv_nbar_preferences_init($devices);
+$lid = (int) ($nbar_request['listener'] ?? ($devices[0]['id'] ?? 0));
+$valid = array_column($devices, 'id');
+if ($lid !== 0 && !in_array($lid, $valid)) {
+ $lid = (int) ($valid[0] ?? 0);
+}
+$seconds = (int) ($nbar_request['period'] ?? 300);
+if (!in_array($seconds, [300, 900, 3600, 86400, 604800], true)) {
+ $seconds = 300;
+}
+$family = (string) ($nbar_request['family'] ?? 'all');
+if (!in_array($family, ['all', '4', '6'], true)) {
+ $family = 'all';
+}
+$mode = (string) ($nbar_request['mode'] ?? 'conversations');
+if (!in_array($mode, ['apps', 'conversations', 'connections'], true)) {
+ $mode = 'conversations';
+}
+$combined_lids = array_values(array_intersect($nbar_preferences['listeners'], array_map('intval', $valid)));
+if (!empty($GLOBALS['fv_nbar_preferences_saved']) && isset_request_var('combine_apply') && cacti_sizeof($combined_lids) >= 2) {
+ $lid = 0;
+}
+if ($lid === 0 && !$combined_lids) {
+ $lid = (int) ($valid[0] ?? 0);
+}
+$listener_scope = $lid === 0 ? 'd.listener_id IN (' . implode(',', $combined_lids) . ')' : 'd.listener_id=?';
+$aid = (string) ($nbar_request['app'] ?? '');
+if (!preg_match('/^[a-f0-9]{2,32}$/D', $aid)) {
+ $aid = '';
+}
+$host = (string) ($nbar_request['host'] ?? '');
+if (!filter_var($host, FILTER_VALIDATE_IP)) {
+ $host = '';
+}
+$page = max(1, min(1000000, (int) ($nbar_request['page'] ?? 1)));
+$parent_app = (string) ($nbar_request['parent_app'] ?? '');
+if (!preg_match('/^[a-f0-9]{2,32}$/D', $parent_app)) {
+ $parent_app = '';
+}
+$end = (int) (floor(time() / 60) * 60) - 60;
+$start = $end - $seconds;
+include_once $config['base_path'] . '/lib/time.php';
+$timespan_context = json_decode((string) ($nbar_request['context'] ?? ''), true);
+$timespan = (int) ($nbar_request['predefined_timespan'] ?? ($timespan_context['predefined_timespan'] ?? 0));
+$custom_start = strtotime((string) ($nbar_request['date1'] ?? ''));
+$custom_end = strtotime((string) ($nbar_request['date2'] ?? ''));
+$has_dates = $custom_start && $custom_end && $custom_end > $custom_start;
+$custom = $has_dates;
+if (!isset($nbar_request['predefined_timespan']) && !isset($timespan_context['predefined_timespan']) && !$custom) {
+ $legacy = [3600 => GT_LAST_HOUR, 86400 => GT_LAST_DAY, 604800 => GT_LAST_WEEK];
+ if (isset($nbar_request['period'])) {
+ $timespan = $legacy[$seconds] ?? 0;
+ } else {
+ $timespan = GT_LAST_HALF_HOUR;
+ }
+}
+if ($timespan > 0 && $timespan !== 1000 && !isset($graph_timespans[$timespan])) {
+ $timespan = GT_LAST_HOUR;
+}
+include_once __DIR__ . '/nbar_timespan.php';
+$nbar_now = (int) (floor(time() / 60) * 60);
+$nbar_retention_days = fv_nbar_retention_days();
+$nbar_timespans = fv_nbar_timespan_options($graph_timespans, $nbar_now, $nbar_retention_days, read_user_setting('first_weekdayid'));
+$nbar_range_notice = '';
+if ($timespan > 0 && !isset($nbar_timespans[$timespan])) {
+ $timespan = GT_LAST_HALF_HOUR;
+ $nbar_range_notice = __('That period is outside the %s-day NBAR retention setting. Showing the last half hour.', $nbar_retention_days, 'flowview');
+ unset($nbar_request['lock_range']);
+}
+if ($timespan === 1000) {
+ $end = $nbar_now;
+ $start = $end - $nbar_retention_days * 86400;
+ $custom = false;
+} elseif ($timespan > 0) {
+ get_timespan($nbar_span, $nbar_now, $timespan, read_user_setting('first_weekdayid'));
+ $start = strtotime($nbar_span['current_value_date1']);
+ $end = strtotime($nbar_span['current_value_date2']);
+ $custom = false;
+} else {
+ if ($custom) {
+ $start = $custom_start;
+ $end = $custom_end;
+ } else {
+ $end = (int) (floor(time() / 60) * 60);
+ $start = $end - $seconds;
+ }
+ $timespan = 0;
+ $custom = true;
+}
+if ($timespan === 0 && $has_dates) {
+ $start = $custom_start;
+ $end = $custom_end;
+}
+if (!empty($nbar_request['lock_range']) && $has_dates) {
+ $start = $custom_start;
+ $end = $custom_end;
+}
+[$start, $end, $nbar_dates_adjusted] = fv_nbar_bound_dates($start, $end, $nbar_now, $nbar_retention_days);
+if ($nbar_dates_adjusted) {
+ $timespan = 0;
+ $custom = true;
+ $nbar_range_notice = __('Dates adjusted to the %s-day NBAR retention window.', $nbar_retention_days, 'flowview');
+}
+// The primary key starts with capture/minute, so each lookup reads its first record.
+$nbar_earliest = null;
+foreach ($lid === 0 ? $combined_lids : [$lid] as $nbar_capture_id) {
+ $nbar_first = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [$nbar_capture_id]);
+ if (isset($nbar_first['minute'])) {
+ $nbar_first_minute = max($nbar_now - $nbar_retention_days * 86400, (int) $nbar_first['minute']);
+ $nbar_earliest = $nbar_earliest === null ? $nbar_first_minute : min($nbar_earliest, $nbar_first_minute);
+ }
+}
+$seconds = max(1, $end - $start);
+$context = json_decode((string) ($nbar_request['context'] ?? ''), true);
+if (!is_array($context)) {
+ $context = [];
+}
+$allowed = ['query', 'report', 'predefined_timespan', 'ex_addr', 'sortfield', 'cutofflines', 'cutoffoctets', 'exclude', 'graph_type', 'graph_height', 'date1', 'date2', 'domains'];
+$context = array_intersect_key($context, array_flip($allowed));
+foreach ($context as $key => $value) {
+ if (!is_scalar($value)) {
+ unset($context[$key]);
+ }
+}
+if (!preg_match('/^[sp][0-9]+$/', (string) ($context['report'] ?? ''))) {
+ $context['report'] = 's10';
+}
+$report_key = (int) substr($context['report'], 1);
+if (!isset(($context['report'][0] === 's' ? $stat_report_array : $print_report_array)[$report_key])) {
+ $context['report'] = 's10';
+}
+$context_json = json_encode($context);
+$base = 'flowview.php?' . http_build_query(['action' => 'view', 'report' => 'nbar', 'listener' => $lid, 'predefined_timespan' => $timespan, 'lock_range' => 1, 'family' => $family, 'date1' => date('Y-m-d H:i', $start), 'date2' => date('Y-m-d H:i', $end), 'context' => $context_json, 'host' => $host]);
+$return = $context;
+$return['action'] = 'view';
+$return['device_id'] = $lid;
+$return['date1'] = date('Y-m-d H:i', $start);
+$return['date2'] = date('Y-m-d H:i', $end);
+$return['predefined_timespan'] = $timespan;
+$return['section'] = 'flows';
+$return_url = 'flowview.php?' . http_build_query($return);
+parse_str(parse_url($base, PHP_URL_QUERY), $section_params);
+$section_params['mode'] = $mode;
+fv_section_save('nbar', $section_params);
+fv_section_navigation('nbar');
+html_start_box(__('Application Report: Cisco Apps Recognition (NBAR)', 'flowview'), '100%', '', '3', 'center', '');
+echo '';
+include_once __DIR__ . '/nbar_feed_status.php';
+fv_nbar_feed_status($devices, array_map('intval', $lid === 0 ? $combined_lids : [$lid]));
+echo ' ';
+echo '';
+include_once __DIR__ . '/nbar_query.php';
+include_once __DIR__ . '/nbar_chart.php';
+fv_nbar_chart($listener_scope, $lid === 0 ? [$start, $end] : [$lid, $start, $end], $family, $host);
+echo '
';
+html_end_box();
+include __DIR__ . '/nbar_setup_guide.php';
+
+
+
+
+echo '';
+echo '';
+echo '';
+if ($mode === 'conversations') {
+ include __DIR__ . '/nbar_conversations.php';
+ bottom_footer();
+ exit;
+}
+if ($mode === 'connections' && $aid !== '' && !filter_var($nbar_request['internal'] ?? '', FILTER_VALIDATE_IP)) {
+ echo '' . html_escape(__('← Back to top applications', 'flowview')) . '
';
+}
+$where = $listener_scope . ' AND d.minute>=? AND d.minute';
+$args = $lid === 0 ? [$start, $end] : [$lid, $start, $end];
+if ($family !== 'all') {
+ $where .= ' AND LENGTH(d.src)=' . ($family === '6' ? 16 : 4);
+}
+if ($aid !== '') {
+ $where .= ' AND d.app_id=?';
+ $args[] = $aid;
+}
+if ($host !== '') {
+ $where .= ' AND (d.src=INET6_ATON(?) OR d.dst=INET6_ATON(?))';
+ array_push($args, $host, $host);
+}
+
+$internal = (string) ($nbar_request['internal'] ?? '');
+$external = (string) ($nbar_request['external'] ?? '');
+if ($mode === 'connections' && filter_var($internal, FILTER_VALIDATE_IP) && filter_var($external, FILTER_VALIDATE_IP)) {
+ $where .= ' AND ((d.src=INET6_ATON(?) AND d.dst=INET6_ATON(?)) OR (d.src=INET6_ATON(?) AND d.dst=INET6_ATON(?)))';
+ array_push($args, $internal, $external, $external, $internal);
+ echo '' . html_escape(__('← Back to conversations', 'flowview')) . ' ' . fv_nbar_h($internal) . ' ↔ ' . fv_nbar_h($external) . '
';
+}
+$fields = 'd.app_id,n.name,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen';
+$group = 'd.app_id,n.name';
+if ($mode === 'connections') {
+ $fields .= ',INET6_NTOA(d.src) src,INET6_NTOA(d.dst) dst,d.sport,d.dport,d.protocol';
+ $group .= ',d.src,d.dst,d.sport,d.dport,d.protocol';
+}
+$nbar_source = $mode === 'apps' ? fv_nbar_summary_source($mode === 'apps' && $host === '' ? 'apps' : ($mode === 'apps' ? 'pairs' : 'connections'), $lid === 0 ? $combined_lids : [$lid], $start, $end) : null;
+if ($mode === 'apps') {
+ // Resolve names after reducing raw records to one row per capture/application.
+ $sql = 'SELECT d.app_id,n.name,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) records,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen FROM (SELECT d.listener_id,d.app_id,d.router,SUM(d.bytes) bytes,SUM(d.packets) packets,SUM(d.flows) flows,IF(COUNT(d.first_seen)=COUNT(*),MIN(d.first_seen),NULL) first_seen,IF(COUNT(d.last_seen)=COUNT(*),MAX(d.last_seen),NULL) last_seen FROM ' . $nbar_source . ' d WHERE ' . $where . ' GROUP BY d.listener_id,d.app_id,d.router) d JOIN plugin_flowview_devices l ON l.id=d.listener_id LEFT JOIN plugin_flowview_nbar_names n ON n.router=COALESCE(NULLIF(d.router,SPACE(0)),l.allowfrom) AND n.app_id=d.app_id GROUP BY d.app_id,n.name ORDER BY bytes DESC LIMIT 100';
+}
+if ($mode === 'connections') {
+ require_once __DIR__ . '/nbar_count.php';
+ $nbar_count_key = fv_nbar_count_context('', $where, $args, $end, $lid === 0 ? $combined_lids : [$lid], $start);
+ require_once __DIR__ . '/nbar_connections.php';
+ try {
+ $rows = fv_nbar_connections_cached($lid === 0 ? $combined_lids : [$lid], $start, $end, $where, $args, $page);
+ } catch (Throwable $e) {
+ cacti_log('NBAR connection page: ' . $e->getMessage(), false, 'FLOWVIEW');
+ echo '' . fv_nbar_h(__('Unable to load connections. Try a shorter timespan or filter by IP.', 'flowview')) . '
';
+ bottom_footer();
+ return;
+ }
+} else {
+ $rows = fv_nbar_query_cached($sql, $args);
+ if (!is_array($rows)) {
+ echo '' . fv_nbar_h(__('Unable to load applications. Please try again.', 'flowview')) . '
';
+ bottom_footer();
+ return;
+ }
+}
+$has_next = $mode === 'connections' && cacti_sizeof($rows) > 100;
+if ($has_next) {
+ $rows = array_slice($rows, 0, 100);
+}
+$nbar_pagination = '';
+if ($mode === 'connections') {
+ $page_params = ['mode' => 'connections', 'app' => $aid, 'parent_app' => $parent_app, 'host' => $host, 'internal' => $internal, 'external' => $external];
+ $nbar_page_key = hash('sha256', $base . '&' . http_build_query($page_params));
+ $nbar_pagination = '';
+ echo $nbar_pagination;
+}
+if ($mode === 'connections' && $aid !== '') {
+ $selected_name = $rows[0]['name'] ?? __('Application %s', $aid, 'flowview');
+ echo '' . fv_nbar_h(strtoupper($selected_name)) . ' — Connections
';
+}
+echo '';
+$nbar_row = 0;
+foreach ($rows as $r) {
+ $name = $r['name'] ?? __('Awaiting name (%s)', $r['app_id'], 'flowview');
+ echo '';
+ if ($mode === 'connections') {
+ echo fv_nbar_h($name);
+ } else {
+ echo '' . fv_nbar_h($name) . ' ';
+ }
+ echo ' ';
+
+ if ($mode === 'connections') {
+ foreach (['src', 'sport', 'dst', 'dport'] as $f) {
+ echo '' . fv_nbar_h($r[$f]) . ' ';
+ if ($f === 'src' || $f === 'dst') {
+ echo '' . fv_nbar_h(fv_nbar_dns($r[$f])) . ' ';
+ }
+ }
+ echo '' . fv_nbar_h([6 => 'TCP', 17 => 'UDP', 1 => 'ICMP', 58 => 'ICMPv6'][$r['protocol']] ?? $r['protocol']) . ' ';
+ }
+
+ echo '' . ($r['bytes'] > 0 && $r['bytes'] < 100000 ? '<0.0001' : number_format($r['bytes'] / 1e9, 4)) . ' ' . ($r['bytes'] > 0 && $r['bytes'] * 8 / $seconds / 1e6 < 0.001 ? '<0.001' : number_format($r['bytes'] * 8 / $seconds / 1e6, 3)) . ' ' . number_format($r['packets']) . ' ' . number_format($r['records']) . ' ' . fv_nbar_seen_cells($r) . ' ';
+}
+if (!$rows) {
+ echo '' . html_escape(__('No application records in this view yet. Try a longer period or another traffic view.', 'flowview')) . ' ';
+}
+echo '
';
+echo $nbar_pagination;
+if ($mode === 'connections') {
+ echo '';
+}
+
+echo '' . html_escape(__('Report information', 'flowview')) . ' ' . html_escape(__('First seen and Last seen show the earliest and latest FlowView observations within the selected period, at minute precision. They do not identify continuous activity or exact flow start/end times. Unavailable means the retained summary has no complete observation dates.', 'flowview')) . '
' . html_escape(__('Results are ordered by data volume; Connections shows up to 100 per page. Column sorting applies to the displayed page. NBAR history uses the configured retention setting. Daily summaries are reused across report selections; partial days use hourly summaries, and partial or changed hours use raw records. Connections pages are read from shared daily history, with nearby pages briefly cached for navigation. Average Mbps covers the selected period; allow 1–2 minutes for exports. Capture visibility depends on the device exporter and observation point. NAT can hide internal IPv4 addresses on a WAN capture. Select one observation point per traffic path to avoid duplicate totals. Cisco NBAR may classify encrypted traffic as SSL, QUIC or Unknown. History starts when export is enabled.', 'flowview')) . '
';
+echo '';
+bottom_footer();
diff --git a/nbar_seen.php b/nbar_seen.php
new file mode 100644
index 0000000..653b62d
--- /dev/null
+++ b/nbar_seen.php
@@ -0,0 +1,220 @@
+ 0 ? date('Y-m-d H:i', $stamp) : __('Unavailable', 'flowview');
+ $hint = __('FlowView observation within the selected period; minute precision. Time zone: %s', date_default_timezone_get(), 'flowview');
+ $html .= '' . fv_nbar_h($text) . ' ';
+ }
+ return $html;
+}
+/**
+ * Merge observation dates while preserving missing-date information.
+ *
+ * @param array $total Accumulated dates and missing-date marker.
+ * @param array $row Exported tuple or observation fields.
+ *
+ * @return void
+ */
+function fv_nbar_seen_merge(array &$total, array $row) {
+ if (!isset($row['first_seen'],$row['last_seen'])) {
+ $total['_seen_missing'] = true;
+ return;
+ }
+ $first = (int) $row['first_seen'];
+ $last = (int) $row['last_seen'];
+ $total['first_seen'] = $total['first_seen'] === null ? $first : min($total['first_seen'], $first);
+ $total['last_seen'] = $total['last_seen'] === null ? $last : max($total['last_seen'], $last);
+}
+/**
+ * Install observation columns and resumable backfill checkpoints.
+ *
+ * @return bool
+ */
+function fv_nbar_seen_schema() {
+ foreach (['', 'daily_'] as $prefix) {
+ foreach (['apps', 'pairs', 'connections'] as $kind) {
+ $table = 'plugin_flowview_nbar_' . $prefix . $kind;
+ foreach (['first_seen', 'last_seen'] as $field) {
+ if (!flowview_db_fetch_row("SHOW COLUMNS FROM $table LIKE '$field'")) {
+ if (flowview_db_add_column($table, ['name' => $field, 'type' => 'int', 'unsigned' => true, 'NULL' => true]) === false) {
+ return false;
+ }
+ }
+ }
+ }
+ }
+ $exists = flowview_db_fetch_cell("SHOW TABLES LIKE 'plugin_flowview_nbar_seen_upgrade'");
+ if (!$exists) {
+ if (fv_nbar_schema_table('seen_upgrade') === false) {
+ return false;
+ }
+ }
+ // Always retry idempotent seeds, including after an interrupted installation.
+ foreach (['0' => 'hours', '1' => 'days'] as $kind => $table) {
+ $field = $kind === 0 ? 'hour' : 'day';
+ if (flowview_db_execute("INSERT IGNORE INTO plugin_flowview_nbar_seen_upgrade (kind,listener_id,period) SELECT $kind,listener_id,$field FROM plugin_flowview_nbar_$table") === false) {
+ return false;
+ }
+ }
+ foreach (['phase' => ['name' => 'phase', 'type' => 'tinyint', 'unsigned' => true, 'NULL' => false, 'default' => 0], 'app_cursor' => ['name' => 'app_cursor', 'type' => 'varchar(32)', 'NULL' => false, 'default' => '']] as $field => $definition) {
+ if (!flowview_db_fetch_row("SHOW COLUMNS FROM plugin_flowview_nbar_seen_upgrade LIKE '$field'") && flowview_db_add_column('plugin_flowview_nbar_seen_upgrade', $definition) === false) {
+ return false;
+ }
+ }
+ return true;
+}
+/**
+ * Backfill dates only where retained counters exactly match source totals.
+ *
+ * @param int $budget Worker time budget in seconds.
+ *
+ * @return int
+ */
+function fv_nbar_seen_backfill($budget = 10) {
+ if ((int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)),0)") !== 1) {
+ return 0;
+ }
+ $daily = false;
+ $done = 0;
+ $began = microtime(true);
+ $old = null;
+ $isolation = flowview_db_fetch_cell('SELECT @@session.transaction_isolation', '', false);
+ if (!in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) {
+ $isolation = flowview_db_fetch_cell('SELECT @@session.tx_isolation', '', false);
+ }
+ try {
+ $daily = (int) flowview_db_fetch_cell("SELECT GET_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)),0)") === 1;
+ if (!$daily) {
+ return 0;
+ }
+ if (!in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) {
+ return 0;
+ }
+ $old = fv_nbar_worker_timeout(20);
+ if ($old === null || !flowview_db_execute('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED')) {
+ return 0;
+ }
+ $jobs = flowview_db_fetch_assoc('SELECT kind,listener_id,period,phase,app_cursor FROM plugin_flowview_nbar_seen_upgrade WHERE done=0 ORDER BY kind,period DESC,listener_id LIMIT 100');
+ foreach ($jobs as $job) {
+ if (microtime(true) - $began >= $budget) {
+ break;
+ }
+ $listener = (int) $job['listener_id'];
+ $period = (int) $job['period'];
+ $isDay = (int) $job['kind'] === 1;
+ $end = $period + 3600;
+ $prefix = '';
+ if ($isDay) {
+ $prefix = 'daily_';
+ $end = (int) flowview_db_fetch_cell_prepared('SELECT covered_to FROM plugin_flowview_nbar_days WHERE listener_id=? AND day=?', [$listener, $period]);
+ }
+ $ok = true;
+ $phase = 0;
+ foreach (['apps' => 'app_id,LENGTH(src) family', 'pairs' => 'app_id,src,dst', 'connections' => 'app_id,src,dst,sport,dport,protocol'] as $kind => $fields) {
+ if ($isDay && $phase < (int) $job['phase']) {
+ $phase++;
+ continue;
+ }
+ $group = ($kind === 'apps' ? 'app_id,LENGTH(src)' : $fields) . ',router';
+ $keys = array_merge($kind === 'apps' ? ['app_id', 'family'] : explode(',', $fields), ['router']);
+ $column = flowview_db_fetch_row("SHOW FULL COLUMNS FROM plugin_flowview_nbar_$prefix$kind LIKE 'app_id'");
+ $collation = $column['Collation'];
+ if (!preg_match('/^[a-zA-Z0-9_]+$/D', $collation)) {
+ throw new RuntimeException('Invalid application collation');
+ }
+ $join = implode(' AND ', array_map(static function ($k) use ($collation) {return $k === 'app_id' ? "s.$k=r.$k COLLATE $collation" : "s.$k=r.$k";}, $keys));
+ $source = $isDay ? fv_nbar_hourly_source($kind, [$listener], $period, $end) : '(SELECT *,minute first_seen,minute last_seen FROM plugin_flowview_nbar_data)';
+ $dates = 'IF(COUNT(first_seen)=COUNT(*),MIN(first_seen),NULL) first_seen,IF(COUNT(last_seen)=COUNT(*),MAX(last_seen),NULL) last_seen';
+ $sql = "UPDATE plugin_flowview_nbar_$prefix$kind s JOIN (SELECT $fields,router,SUM(bytes) bytes,SUM(packets) packets,SUM(flows) flows,$dates FROM $source d WHERE listener_id=$listener AND minute>=$period AND minute<$end GROUP BY $group) r ON $join SET s.first_seen=r.first_seen,s.last_seen=r.last_seen WHERE s.listener_id=$listener AND s.hour=$period AND s.bytes=r.bytes AND s.packets=r.packets AND s.flows=r.flows AND (s.first_seen IS NULL OR s.last_seen IS NULL)";
+ $applications = [null];
+ if ($isDay && $kind === 'connections') {
+ $applications = array_column(flowview_db_fetch_assoc_prepared('SELECT DISTINCT app_id FROM plugin_flowview_nbar_daily_apps WHERE listener_id=? AND hour=? AND app_id>? ORDER BY app_id', [$listener, $period, ($job['app_cursor'] ?? '')]), 'app_id');
+ }
+ foreach ($applications as $application) {
+ if (microtime(true) - $began >= $budget) {
+ $ok = false;
+ break;
+ }
+ $statement = $sql;
+ if ($application !== null) {
+ $value = 'CONVERT(0x' . bin2hex($application) . ' USING utf8mb4) COLLATE ' . $collation;
+ $statement = str_replace(' GROUP BY ', " AND app_id=$value GROUP BY ", $statement);
+ $statement .= " AND s.app_id=$value";
+ }
+ if (flowview_db_execute($statement) === false) {
+ $ok = false;
+ break;
+ }
+ if ($application !== null) {
+ flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET app_cursor=? WHERE kind=? AND listener_id=? AND period=?', [$application, $job['kind'], $listener, $period]);
+ }
+ }
+ if (!$ok) {
+ break;
+ }
+ $phase++;
+ if ($isDay) {
+ flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET phase=? WHERE kind=? AND listener_id=? AND period=?', [$phase, $job['kind'], $listener, $period]);
+ }
+ }
+ if (!$ok) {
+ break;
+ }
+ if (flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_seen_upgrade SET done=1 WHERE kind=? AND listener_id=? AND period=?', [$job['kind'], $listener, $period]) === false) {
+ break;
+ }
+ $done++;
+ }
+ } catch (Throwable $e) {
+ cacti_log('NBAR observation upgrade: ' . $e->getMessage(), false, 'FLOWVIEW');
+ } finally {
+ if ($old !== null) {
+ flowview_db_execute('SET SESSION max_statement_time=' . $old);
+ }
+ if (in_array($isolation, ['READ-UNCOMMITTED', 'READ-COMMITTED', 'REPEATABLE-READ', 'SERIALIZABLE'], true)) {
+ flowview_db_execute('SET SESSION TRANSACTION ISOLATION LEVEL ' . str_replace('-', ' ', $isolation));
+ }
+ if ($daily) {
+ flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_daily:',LEFT(SHA2(DATABASE(),256),16)))");
+ }
+ flowview_db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)))");
+ }
+ return $done;
+}
diff --git a/nbar_setup_guide.php b/nbar_setup_guide.php
new file mode 100644
index 0000000..6ad50e1
--- /dev/null
+++ b/nbar_setup_guide.php
@@ -0,0 +1,98 @@
+
+
COLLECTOR_IP LISTENER_PORT EXPORT_SOURCE VlanXX
flow record CACTI_NBAR4
+ match ipv4 tos
+ match ipv4 protocol
+ match ipv4 source address
+ match ipv4 destination address
+ match transport source-port
+ match transport destination-port
+ match interface input
+ match application name
+ collect transport tcp flags
+ collect interface output
+ collect counter bytes long
+ collect counter packets long
+ collect timestamp sys-uptime first
+ collect timestamp sys-uptime last
+!
+flow record CACTI_NBAR6
+ match ipv6 traffic-class
+ match ipv6 flow-label
+ match ipv6 protocol
+ match ipv6 extension map
+ match ipv6 source address
+ match ipv6 destination address
+ match transport source-port
+ match transport destination-port
+ match interface input
+ match flow direction
+ match application name
+ collect transport tcp flags
+ collect interface output
+ collect counter bytes long
+ collect counter packets long
+ collect timestamp sys-uptime first
+ collect timestamp sys-uptime last
+!
+flow exporter CACTI_LAN
+ destination COLLECTOR_IP
+ source EXPORT_SOURCE
+ transport udp LISTENER_PORT
+ template data timeout 60
+ option application-table timeout 60
+!
+flow monitor CACTI_APP4_LAN
+ exporter CACTI_LAN
+ cache timeout active 60
+ record CACTI_NBAR4
+!
+flow monitor CACTI_APP6_LAN
+ exporter CACTI_LAN
+ cache timeout active 60
+ record CACTI_NBAR6
+!
+interface VlanXX
+ ip flow monitor CACTI_APP4_LAN input
+ ip flow monitor CACTI_APP4_LAN output
+ ipv6 flow monitor CACTI_APP6_LAN input
+ ipv6 flow monitor CACTI_APP6_LAN output VlanXX Vlan1
show ip nbar version
+show ip nbar protocol-pack active
+show flow interface
+show flow exporter CACTI_LAN statistics
+show flow monitor CACTI_APP4_LAN statistics
+show flow monitor CACTI_APP6_LAN statistics
+show flow monitor CACTI_APP6_LAN cache format table
+show platform hardware qfp active datapath utilization
+
Copyright (c) 2004-2026 The Cacti Group.
+';
diff --git a/nbar_summary.php b/nbar_summary.php
new file mode 100644
index 0000000..bbdd15a
--- /dev/null
+++ b/nbar_summary.php
@@ -0,0 +1,238 @@
+ 'connection_built', 'type' => 'bigint', 'NULL' => true, 'default' => -1]) === false) {
+ return false;
+ }
+ return fv_nbar_daily_schema() && fv_nbar_seen_schema();
+}
+
+/**
+ * Write raw records and invalidate affected hours in one transaction.
+ *
+ * @param string $sql Trusted SQL statement.
+ * @param int $listener Capture identifier.
+ * @param array $hours Affected UTC hour boundaries.
+ * @param array $args Prepared statement values.
+ *
+ * @return bool
+ */
+function fv_nbar_summary_store($sql, $listener, $hours, $args = []) {
+ if (flowview_db_execute('START TRANSACTION') === false) {
+ return false;
+ }
+ $ok = flowview_db_execute_prepared($sql, $args) !== false;
+ if ($ok) {
+ $values = [];
+ foreach (array_keys($hours) as $hour) {
+ $values[] = '(' . (int) $listener . ',' . (int) $hour . ',1,-1)';
+ }
+ $ok = flowview_db_execute('INSERT INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built) VALUES ' . implode(',', $values) . ' ON DUPLICATE KEY UPDATE revision=revision+1') !== false;
+ }
+ if (!$ok) {
+ flowview_db_execute('ROLLBACK');
+ return false;
+ }
+ return flowview_db_execute('COMMIT') !== false;
+}
+
+/**
+ * Maintain closed-hour summaries under a database-scoped worker lock.
+ *
+ * @param int $budget Worker time budget in seconds.
+ *
+ * @return int
+ */
+function fv_nbar_summary_refresh($budget = 20, $supervised = false) {
+ $lock = flowview_db_fetch_row("SELECT GET_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16)),0) acquired");
+ if ((int) ($lock['acquired'] ?? 0) !== 1) {
+ return 0;
+ }
+ $done = 0;
+ $started = microtime(true);
+ try {
+ $cutoff = time() - fv_nbar_retention_days() * 86400;
+ $closed = (int) (floor((time() - 300) / 3600) * 3600);
+ $listeners = flowview_db_fetch_assoc('SELECT id FROM plugin_flowview_devices');
+ foreach ($listeners as $listener) {
+ $id = (int) $listener['id'];
+ $first = flowview_db_fetch_row_prepared('SELECT minute FROM plugin_flowview_nbar_data WHERE listener_id=? ORDER BY minute LIMIT 1', [$id]);
+ if (!isset($first['minute'])) {
+ continue;
+ }
+ $values = [];
+ for ($h = (int) (floor(max($cutoff, (int) $first['minute']) / 3600) * 3600); $h < $closed; $h += 3600) {
+ $values[] = "($id,$h,0,-1)";
+ }
+ if ($values && flowview_db_execute('INSERT IGNORE INTO plugin_flowview_nbar_hours (listener_id,hour,revision,built) VALUES ' . implode(',', $values)) === false) {
+ return $done;
+ }
+ }
+ $pending = flowview_db_fetch_assoc("SELECT listener_id,hour,revision FROM plugin_flowview_nbar_hours WHERE hour>=" . (int) (ceil($cutoff / 3600) * 3600) . " AND hour<$closed AND (built<0 OR built<>revision OR connection_built<0 OR connection_built<>revision) ORDER BY hour DESC,listener_id LIMIT 100");
+ foreach ($pending as $item) {
+ if (microtime(true) - $started >= max(1, (int) $budget)) {
+ break;
+ }
+ if (fv_nbar_summary_build((int) $item['listener_id'], (int) $item['hour'], $supervised)) {
+ $done++;
+ }
+ }
+ } finally {
+ flowview_db_fetch_row("SELECT RELEASE_LOCK(CONCAT('flowview_nbar_summary:',LEFT(SHA2(DATABASE(),256),16))) released");
+ }
+ return $done + fv_nbar_daily_refresh() + fv_nbar_seen_backfill(5);
+}
+
+/**
+ * Publish an hourly summary only if its raw revision remains unchanged.
+ *
+ * @param int $listener Capture identifier.
+ * @param int $hour UTC hour boundary in Unix seconds.
+ *
+ * @return bool
+ */
+function fv_nbar_summary_build($listener, $hour, $supervised = false) {
+ // A MySQL build without DML timeouts is allowed only in the supervised child.
+ $old_timeout = fv_nbar_worker_timeout(20);
+ if ($old_timeout === null && !$supervised) {
+ return false;
+ }
+ try {
+ // READ COMMITTED avoids holding raw-record locks during INSERT ... SELECT.
+ if (flowview_db_execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED') === false || flowview_db_execute('START TRANSACTION') === false) {
+ return false;
+ }
+ $state = flowview_db_fetch_row_prepared('SELECT revision FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour=?', [$listener, $hour]);
+ if (!isset($state['revision'])) {
+ throw new RuntimeException('Missing summary state');
+ }
+ $where = ' FROM plugin_flowview_nbar_data WHERE listener_id=? AND minute>=? AND minute';
+ $args = [$listener, $hour, $hour + 3600];
+ $specs = [
+ 'apps' => 'app_id,LENGTH(src)',
+ 'pairs' => 'app_id,src,dst',
+ 'connections' => 'app_id,src,dst,sport,dport,protocol'
+ ];
+ foreach ($specs as $table => $fields) {
+ if (flowview_db_execute_prepared("DELETE FROM plugin_flowview_nbar_$table WHERE listener_id=? AND hour=?", [$listener, $hour]) === false) {
+ throw new RuntimeException('Summary delete failed');
+ }
+ $sql = "INSERT INTO plugin_flowview_nbar_$table SELECT $listener,$hour,$fields,SUM(bytes),SUM(packets),SUM(flows),MIN(minute),MAX(minute),router$where GROUP BY $fields,router";
+ if (flowview_db_execute_prepared($sql, $args) === false) {
+ throw new RuntimeException('Summary aggregate failed');
+ }
+ }
+ $latest = flowview_db_fetch_row_prepared('SELECT revision FROM plugin_flowview_nbar_hours WHERE listener_id=? AND hour=?', [$listener, $hour]);
+ if (!isset($latest['revision']) || (string) $latest['revision'] !== (string) $state['revision']) {
+ throw new RuntimeException('Hour changed during summary build');
+ }
+ if (flowview_db_execute_prepared('UPDATE plugin_flowview_nbar_hours SET built=?,connection_built=? WHERE listener_id=? AND hour=?', [$state['revision'], $state['revision'], $listener, $hour]) === false) {
+ throw new RuntimeException('Summary publish failed');
+ }
+ if (flowview_db_execute('COMMIT') === false) {
+ throw new RuntimeException('Summary commit failed');
+ }
+ return true;
+ } catch (Throwable $e) {
+ flowview_db_execute('ROLLBACK');
+ return false;
+ } finally {
+ if ($old_timeout !== null) {
+ flowview_db_execute('SET SESSION max_statement_time=' . $old_timeout);
+ }
+ }
+}
+
+/**
+ * Choose mutually exclusive ready-summary or raw branches for each block.
+ *
+ * @param string $kind Allowlisted summary type.
+ * @param array $listeners Selected capture identifiers.
+ * @param int $start Inclusive range start in Unix seconds.
+ * @param int $end Exclusive range end in Unix seconds.
+ * @param int|null $now Current Unix time, or null to read the clock.
+ *
+ * @return string
+ */
+function fv_nbar_hourly_source($kind, $listeners, $start, $end, $now = null) {
+ if (!in_array($kind, ['apps', 'pairs', 'connections'], true)) {
+ throw new InvalidArgumentException('Unknown summary kind');
+ }
+ $listeners = array_values(array_unique(array_filter(array_map('intval', $listeners), static function ($id) {return $id > 0;})));
+ if (!$listeners) {
+ return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)';
+ }
+ $start = (int) $start;
+ $end = (int) $end;
+ $full_start = (int) (ceil($start / 3600) * 3600);
+ $full_end = min((int) (floor($end / 3600) * 3600), (int) (floor((($now ?? time()) - 300) / 3600) * 3600));
+ if ($full_end <= $full_start) {
+ return '(SELECT listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router FROM plugin_flowview_nbar_data)';
+ }
+ $built_column = $kind === 'connections' ? 'connection_built' : 'built';
+ $parts = [];
+ foreach ($listeners as $listener) {
+ $raw = 'listener_id,minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,minute first_seen,minute last_seen,router';
+ if ($start < $full_start) {
+ $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$start AND minute<$full_start";
+ }
+ if ($full_end < $end) {
+ $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$full_end AND minute<$end";
+ }
+ for ($h = $full_start; $h < $full_end; $h = $to) {
+ $to = min($h + 21600, $full_end);
+ $count = (int) (($to - $h) / 3600);
+ $ready = "(SELECT COUNT(*) FROM plugin_flowview_nbar_hours WHERE listener_id=$listener AND hour>=$h AND hour<$to AND $built_column>=0 AND $built_column=revision)=$count";
+ if ($kind === 'apps') {
+ $fields = "listener_id,hour minute,app_id,REPEAT(CHAR(0),family) src,REPEAT(CHAR(0),family) dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router";
+ } elseif ($kind === 'pairs') {
+ $fields = 'listener_id,hour minute,app_id,src,dst,0 sport,0 dport,0 protocol,bytes,packets,flows,first_seen,last_seen,router';
+ } else {
+ $fields = 'listener_id,hour minute,app_id,src,dst,sport,dport,protocol,bytes,packets,flows,first_seen,last_seen,router';
+ }
+ $parts[] = "SELECT $fields FROM plugin_flowview_nbar_$kind WHERE listener_id=$listener AND hour>=$h AND hour<$to AND ($ready)";
+ $parts[] = "SELECT $raw FROM plugin_flowview_nbar_data WHERE listener_id=$listener AND minute>=$h AND minute<$to AND NOT ($ready)";
+ }
+ }
+ return '(' . implode(' UNION ALL ', $parts) . ')';
+}
diff --git a/nbar_timespan.php b/nbar_timespan.php
new file mode 100644
index 0000000..85ebbc6
--- /dev/null
+++ b/nbar_timespan.php
@@ -0,0 +1,92 @@
+ $label) {
+ $span = [];
+ get_timespan($span, $now, $id, $weekday);
+ $from = strtotime($span['current_value_date1']);
+ $to = strtotime($span['current_value_date2']);
+ if ($from >= $cutoff && $to > $from && $to <= $now) {
+ // The exact retention option below replaces duplicate rolling periods.
+ if ($from === $cutoff && $to === $now) {
+ continue;
+ }
+ $options[$id] = $label;
+ }
+ }
+ $options[1000] = $days === 1 ? __('Last Day', 'flowview') : __('Last %s Days', $days, 'flowview');
+ return $options;
+}
+/**
+ * Clamp a requested range to retained history and the current time.
+ *
+ * @param int $start Inclusive range start in Unix seconds.
+ * @param int $end Exclusive range end in Unix seconds.
+ * @param int $now Current Unix time, or null to read the clock.
+ * @param int $days Configured retention in days.
+ *
+ * @return array
+ */
+function fv_nbar_bound_dates($start, $end, $now, $days) {
+ $cutoff = $now - $days * 86400;
+ $from = max($cutoff, $start);
+ $to = min($now, $end);
+ if ($to <= $from) {
+ return [$now - 1800, $now, true];
+ }
+ return [$from, $to, $from !== $start || $to !== $end];
+}
+
+/** Handle NBAR timespans without mutating legacy Flow Reports session state. */
+function fv_nbar_timespan_request(): bool {
+ if (get_nfilter_request_var('report') !== 'nbar') {
+ return false;
+ }
+ header('Content-Type: application/json');
+ if ((int) get_filter_request_var('predefined_timespan') === 1000) {
+ $end = (int) (floor(time() / 60) * 60);
+ $start = $end - fv_nbar_retention_days() * 86400;
+ print json_encode(['current_value_date1' => date('Y-m-d H:i', $start), 'current_value_date2' => date('Y-m-d H:i', $end), 'begin_now' => $start, 'end_now' => $end]);
+ } else {
+ flowview_gettimespan();
+ }
+ return true;
+}
diff --git a/phpunit.xml b/phpunit.xml
index 2ec8407..03f0a9b 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -39,6 +39,24 @@
Net/DNS2/Updater.php
includes/functions.php
includes/database.php
+ includes/nbar_database.php
+ nbar_chart.php
+ nbar_collector.php
+ nbar_common.php
+ nbar_connections.php
+ nbar_conversations.php
+ nbar_count.php
+ nbar_daily.php
+ nbar_dns.php
+ nbar_feed_status.php
+ nbar_preferences.php
+ nbar_query.php
+ nbar_report.php
+ nbar_seen.php
+ nbar_setup_guide.php
+ nbar_summary.php
+ nbar_timespan.php
+ flowview_sections.php
diff --git a/setup.php b/setup.php
index 8726c0f..9a78d93 100644
--- a/setup.php
+++ b/setup.php
@@ -39,6 +39,21 @@ function plugin_flowview_csp_nonce(): string {
return '';
}
+/**
+ * Escape an HTML attribute on supported Cacti 1.2 and newer installations.
+ *
+ * @param mixed $value Attribute value.
+ *
+ * @return string Escaped attribute value.
+ */
+function plugin_flowview_escape_attr($value): string {
+ if (function_exists('html_escape_attr')) {
+ return html_escape_attr($value);
+ }
+
+ return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
/**
* @return bool
*/
@@ -146,7 +161,7 @@ function plugin_flowview_check_upgrade(bool $force = false): void {
FROM plugin_config
WHERE directory = ?', array('flowview'));
- if ($current != $old || $force) {
+ if ($current != $old || $force || (int) read_config_option('flowview_nbar_schema_version') < 2) {
$php_binary = read_config_option('path_php_binary');
exec_background($php_binary, '-q ' .$config['base_path'] . '/plugins/flowview/flowview_upgrade.php');
@@ -229,6 +244,8 @@ function flowview_draw_navigation_text($nav) {
'level' => '0'
);
+ $nav['flowview.php:applications'] = $nav['flowview.php:'];
+
$nav['flowview.php:view'] = array(
'title' => __('Dashboard', 'flowview'),
'mapping' => 'flowview.php:',
@@ -620,6 +637,12 @@ function flowview_config_settings(): void {
),
'default' => 30
),
+ 'flowview_nbar_retention_days' => array(
+ 'friendly_name' => __('Cisco Apps (NBAR) History Retention', 'flowview'),
+ 'description' => __('Days of application history to retain. Reducing this removes older history during scheduled cleanup; increasing it cannot restore expired data.', 'flowview'),
+ 'method' => 'drop_array', 'default' => '7',
+ 'array' => array(1 => __('1 Day', 'flowview'), 3 => __('3 Days', 'flowview'), 7 => __('7 Days', 'flowview'), 14 => __('14 Days', 'flowview'), 30 => __('30 Days', 'flowview'))
+ ),
'flowview_partition' => array(
'friendly_name' => __('Database Partitioning Scheme', 'flowview'),
'description' => __('Depending on the number of flows per minute, you may require more tables per day.', 'flowview'),
@@ -771,6 +794,10 @@ function flowview_poller_bottom(): void {
include_once($config['base_path'] . '/lib/poller.php');
flowview_connect();
+ require_once __DIR__ . '/nbar_common.php';
+ if ((int) read_config_option('flowview_nbar_schema_version') >= 2) {
+ fv_nbar_launch_maintenance();
+ }
$retention_days = read_config_option('flowview_retention');
@@ -1069,6 +1096,11 @@ function flowview_setup_table(): void {
global $config, $settings, $flowviewdb_default;
flowview_connect();
+ require_once __DIR__ . '/nbar_common.php';
+ if (!fv_nbar_schema_install()) {
+ throw new RuntimeException('Unable to install NBAR history schema');
+ }
+ set_config_option('flowview_nbar_schema_version', '2');
flowview_db_execute("CREATE TABLE IF NOT EXISTS `plugin_flowview_dnscache` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
diff --git a/tests/Unit/CspNonceTest.php b/tests/Unit/CspNonceTest.php
index d536c9a..d9a7b7c 100644
--- a/tests/Unit/CspNonceTest.php
+++ b/tests/Unit/CspNonceTest.php
@@ -64,7 +64,7 @@
class CactiSecureHeaders {
public static function getNonceAttribute(): string {
- return ' nonce="pest-controlled-double"';
+ return 'nonce="pest-controlled-double"';
}
}
@@ -86,8 +86,16 @@ public static function getNonceAttribute(): string {
preg_match('/<<<(.*)>>>/s', (string) $output, $matches);
expect($matches)->toHaveKey(1);
- expect($matches[1])->toBe(' nonce="pest-controlled-double"');
+ expect($matches[1])->toBe('nonce="pest-controlled-double"');
} finally {
@unlink($tmp);
}
});
+
+
+test('inline script tags separate the nonce attribute from the tag name', function () {
+ foreach (glob(dirname(__DIR__, 2) . '/*.php') as $file) {
+ $source = file_get_contents($file);
+ expect(preg_match('/[\'"]', 'bytes' => 2000000000],
+ ['name' => 'Two', 'bytes' => 4000000], ['name' => 'Three', 'bytes' => 3000000],
+ ['name' => 'Four', 'bytes' => 2000000], ['name' => 'Five', 'bytes' => 1000000],
+ ['name' => 'Six', 'bytes' => 500000], ['name' => 'Zero', 'bytes' => 0],
+ ]);
+ ob_start();
+ fv_nbar_chart('d.listener_id=?', [1, 1800000000, 1800001800], 'all');
+ $html = ob_get_clean();
+ expect($html)->toContain('<script>', '2.00 GB', 'Other', '0.50 MB', 'conic-gradient');
+ expect($html)->not->toContain('