├── frontend ├── css │ ├── sprite-skin-nice.png │ ├── nfsen-ng.css │ ├── dygraph.css │ ├── ion.rangeSlider.css │ └── footable.bootstrap.min.css └── js │ └── popper.min.js ├── .github └── FUNDING.yml ├── .prettierrc ├── .gitignore ├── docker-compose.yml ├── backend ├── index.php ├── processor │ ├── Processor.php │ └── Nfdump.php ├── listen.php ├── common │ ├── Misc.php │ ├── Debug.php │ ├── Config.php │ └── Import.php ├── settings │ └── settings.php.dist ├── datasources │ ├── Datasource.php │ ├── Akumuli.php │ └── Rrd.php ├── cli.php ├── vendor │ └── ProgressBar.php └── api │ └── Api.php ├── .editorconfig ├── package.json ├── eslint.config.js ├── docker-entrypoint.sh ├── composer.json ├── .php-cs-fixer.php ├── Dockerfile ├── .htaccess ├── INSTALL.md ├── API_ENDPOINTS.md ├── README.md ├── LICENSE └── pnpm-lock.yaml /frontend/css/sprite-skin-nice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolli/nfsen-ng/HEAD/frontend/css/sprite-skin-nice.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mbolli 4 | custom: https://paypal.me/bolli 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "es5", 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | backend/settings/settings.php 3 | backend/datasources/data/ 4 | backend/cli/nfsen-ng.log 5 | backend/cli/nfsen-ng.pid 6 | profiles-data/ 7 | vendor/ 8 | node_modules/ 9 | *.cache 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | nfsen: 5 | build: . 6 | container_name: nfsen-ng 7 | ports: 8 | - "8080:80" 9 | volumes: 10 | - ./nfsen-ng-data:/data/nfsen-ng 11 | - ./nfsen-ng-config:/config/nfsen-ng 12 | restart: unless-stopped 13 | -------------------------------------------------------------------------------- /backend/index.php: -------------------------------------------------------------------------------- 1 | setUnsupportedPhpVersionAllowed(true) 5 | ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) 6 | ->setRiskyAllowed(true) 7 | ->setRules([ 8 | '@PhpCsFixer' => true, 9 | '@PhpCsFixer:risky' => true, 10 | '@auto:risky' => true, 11 | '@autoPHPMigration:risky' => true, 12 | 'braces_position' => ['classes_opening_brace' => 'same_line', 'functions_opening_brace' => 'same_line'], 13 | 'concat_space' => ['spacing' => 'one'], 14 | 'control_structure_continuation_position' => ['position' => 'same_line'], 15 | 'declare_strict_types' => false, 16 | 'final_internal_class' => false, 17 | 'mb_str_functions' => false, 18 | 'nullable_type_declaration_for_default_null_value' => true, 19 | 'operator_linebreak' => true, 20 | 'phpdoc_align' => ['align' => 'vertical', 'tags' => ['method', 'param', 'return', 'property', 'return', 'throws', 'type']], 21 | 'phpdoc_to_comment' => ['allow_before_return_statement' => true, 'ignored_tags' => ['var', 'phpstan-ignore', 'phpstan-ignore-next-line', 'psalm-suppress']], 22 | 'single_line_empty_body' => true, 23 | 'static_lambda' => false, 24 | 'string_implicit_backslashes' => ['double_quoted' => 'escape', 'single_quoted' => 'ignore', 'heredoc' => 'escape'], 25 | 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], 26 | ]) 27 | ->setFinder(PhpCsFixer\Finder::create()->exclude('vendor')->in(__DIR__)) 28 | ; 29 | -------------------------------------------------------------------------------- /backend/listen.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | log('Fatal: ' . $e->getMessage(), \LOG_ALERT); 24 | 25 | exit; 26 | } 27 | 28 | $folder = __DIR__; 29 | $lock_file = fopen($folder . '/nfsen-ng.pid', 'c'); 30 | $got_lock = flock($lock_file, \LOCK_EX | \LOCK_NB, $wouldblock); 31 | if ($lock_file === false || (!$got_lock && !$wouldblock)) { 32 | exit(128); 33 | } 34 | if (!$got_lock && $wouldblock) { 35 | exit(129); 36 | } 37 | 38 | // Lock acquired; let's write our PID to the lock file for the convenience 39 | // of humans who may wish to terminate the script. 40 | ftruncate($lock_file, 0); 41 | fwrite($lock_file, getmypid() . \PHP_EOL); 42 | 43 | // first import missed data if available 44 | $start = new DateTime(); 45 | $start->setDate(date('Y') - 3, (int) date('m'), (int) date('d')); 46 | $i = new Import(); 47 | $i->setQuiet(false); 48 | $i->setVerbose(true); 49 | $i->setProcessPorts(true); 50 | $i->setProcessPortsBySource(true); 51 | $i->setCheckLastUpdate(true); 52 | $i->start($start); 53 | 54 | $d->log('Starting periodic execution', \LOG_INFO); 55 | 56 | // @phpstan-ignore-next-line 57 | while (1) { 58 | // next import in 30 seconds 59 | sleep(30); 60 | 61 | // import from last db update 62 | $i->start($start); 63 | } 64 | 65 | // all done; blank the PID file and explicitly release the lock 66 | // @phpstan-ignore-next-line 67 | ftruncate($lock_file, 0); 68 | flock($lock_file, \LOCK_UN); 69 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base image: PHP 8.3 + Apache 2 | FROM php:8.3-apache 3 | 4 | ENV TZ="UTC" 5 | WORKDIR /var/www/html 6 | 7 | # Install dependencies required for nfdump and NFSen-NG 8 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 9 | git pkg-config rrdtool librrd-dev \ 10 | flex bison libbz2-dev zlib1g-dev \ 11 | build-essential autoconf automake libtool \ 12 | unzip wget curl \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # Add nfdump to PATH 16 | ENV PATH="/usr/local/nfdump/bin:${PATH}" 17 | 18 | # Compile and install nfdump 1.7.6 19 | RUN wget https://github.com/phaag/nfdump/archive/refs/tags/v1.7.6.zip \ 20 | && unzip v1.7.6.zip \ 21 | && cd nfdump-1.7.6 \ 22 | && ./autogen.sh \ 23 | && ./configure --prefix=/usr/local/nfdump \ 24 | && make \ 25 | && make install \ 26 | && ldconfig \ 27 | && nfdump -V 28 | 29 | # Enable Apache modules 30 | RUN a2enmod rewrite deflate headers expires 31 | 32 | # Install PHP RRD extension (mbstring already included in base image) 33 | RUN pecl install rrd \ 34 | && echo "extension=rrd.so" > /usr/local/etc/php/conf.d/rrd.ini 35 | 36 | # Setup volumes for persistent data and configuration 37 | VOLUME ["/data/nfsen-ng", "/config/nfsen-ng"] 38 | 39 | # Install NFSen-NG 40 | RUN git clone https://github.com/mbolli/nfsen-ng.git /var/www/html/nfsen-ng \ 41 | && chmod +x /var/www/html/nfsen-ng/backend/cli.php 42 | 43 | # Install composer and backend dependencies 44 | RUN curl -sS https://getcomposer.org/download/latest-stable/composer.phar -o /usr/local/bin/composer \ 45 | && chmod +x /usr/local/bin/composer \ 46 | && cd /var/www/html/nfsen-ng \ 47 | && composer install --no-dev 48 | 49 | # Copy entrypoint script 50 | COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh 51 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 52 | 53 | EXPOSE 80 54 | 55 | ENTRYPOINT ["docker-entrypoint.sh"] 56 | CMD ["apache2-foreground"] 57 | -------------------------------------------------------------------------------- /backend/common/Misc.php: -------------------------------------------------------------------------------- 1 | /dev/null', $op, $exitCode); 28 | 29 | return $exitCode === 0 && isset($op[1]); 30 | } 31 | 32 | /** 33 | * Count running processes by binary name 34 | * Uses pgrep first (preferred, especially in containers), then falls back to ps. 35 | * 36 | * @param string $binaryName The name of the binary/process to count 37 | * 38 | * @return int Number of running processes with that name 39 | */ 40 | public static function countProcessesByName(string $binaryName): int { 41 | // Method 1: Try pgrep first (more likely available in containers and more efficient) 42 | exec("command -v pgrep > /dev/null 2>&1 && pgrep -c '^{$binaryName}$' 2>/dev/null || echo '0'", $pgrep_output); 43 | if (!empty($pgrep_output[0]) && is_numeric($pgrep_output[0])) { 44 | return (int) $pgrep_output[0]; 45 | } 46 | 47 | // Method 2: Fallback to ps if pgrep is not available 48 | exec("command -v ps > /dev/null 2>&1 && ps -eo comm | grep -c '^{$binaryName}$' 2>/dev/null || echo '0'", $ps_output); 49 | if (!empty($ps_output[0]) && is_numeric($ps_output[0])) { 50 | return (int) $ps_output[0]; 51 | } 52 | 53 | // If neither method works, return 0 54 | return 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /backend/settings/settings.php.dist: -------------------------------------------------------------------------------- 1 | [ 12 | 'ports' => [ 13 | 80, 22, 53, 14 | ], 15 | 'sources' => [ 16 | 'source1', 'source2', 17 | ], 18 | 'filters' => [ 19 | 'proto udp', 20 | 'proto tcp', 21 | ], 22 | 'formats' => [ 23 | 'external_interfaces' => '%ts %td %pr %in %out %sa %sp %da %dp %ipkt %ibyt %opkt %obyt %flg', 24 | ], 25 | 'db' => 'RRD', 26 | 'processor' => 'NfDump', 27 | ], 28 | 'frontend' => [ 29 | 'reload_interval' => 60, 30 | 'defaults' => [ 31 | 'view' => 'graphs', // graphs, flows, statistics 32 | 'graphs' => [ 33 | 'display' => 'sources', // sources, protocols, ports 34 | 'datatype' => 'flows', // flows, packets, traffic 35 | 'protocols' => ['any'], // any, tcp, udp, icmp, others (multiple possible if display=protocols) 36 | ], 37 | 'flows' => [ 38 | 'limit' => 50, 39 | ], 40 | 'statistics' => [ 41 | 'order_by' => 'bytes', 42 | ], 43 | 'table'=> [ 44 | 'hidden_fields' => [ 45 | 'flg', 'fwd', 'in', 'out', 'sas', 'das' 46 | ], 47 | ] 48 | ], 49 | ], 50 | 'nfdump' => [ 51 | 'binary' => '/usr/bin/nfdump', 52 | 'profiles-data' => '/var/nfdump/profiles-data', 53 | 'profile' => 'live', 54 | 'max-processes' => 1, // maximum number of concurrently running nfdump processes 55 | ], 56 | 'db' => [ 57 | 'Akumuli' => [ 58 | // 'host' => 'localhost', 59 | // 'port' => 8282, 60 | ], 61 | 'RRD' => [], 62 | ], 63 | 'log' => [ 64 | 'priority' => \LOG_INFO, // LOG_ALERT, LOG_INFO or LOG_DEBUG. LOG_DEBUG is very talkative! 65 | ], 66 | ]; 67 | -------------------------------------------------------------------------------- /backend/common/Debug.php: -------------------------------------------------------------------------------- 1 | stopwatch = microtime(true); 13 | $this->cli = (\PHP_SAPI === 'cli'); 14 | } 15 | 16 | public static function getInstance(): self { 17 | if (!self::$_instance instanceof self) { 18 | self::$_instance = new self(); 19 | } 20 | 21 | return self::$_instance; 22 | } 23 | 24 | /** 25 | * Logs the message if allowed in settings. 26 | */ 27 | public function log(string $message, int $priority): void { 28 | if (Config::$cfg['log']['priority'] >= $priority) { 29 | syslog($priority, 'nfsen-ng: ' . $message); 30 | 31 | if ($this->cli === true && $this->debug === true) { 32 | echo date('Y-m-d H:i:s') . ' ' . $message . PHP_EOL; 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Returns the time passed from initialization. 39 | */ 40 | public function stopWatch(bool $precise = false): float { 41 | $result = microtime(true) - $this->stopwatch; 42 | if ($precise === false) { 43 | $result = round($result, 4); 44 | } 45 | 46 | return $result; 47 | } 48 | 49 | /** 50 | * Debug print. Prints the supplied string with the time passed from initialization. 51 | */ 52 | public function dpr(...$mixed): void { 53 | if ($this->debug === false) { 54 | return; 55 | } 56 | 57 | foreach ($mixed as $param) { 58 | echo ($this->cli) ? PHP_EOL . $this->stopWatch() . 's ' : "
" . $this->stopWatch() . ' '; 59 | if (\is_array($param)) { 60 | echo ($this->cli) ? print_r($mixed, true) : '
', var_export($mixed, true), '
'; 61 | } else { 62 | echo $param; 63 | } 64 | } 65 | } 66 | 67 | public function setDebug(bool $debug): void { 68 | $this->debug = $debug; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | ServerSignature Off 3 | 4 | 5 | RewriteEngine On 6 | RewriteCond %{REQUEST_FILENAME} !-f 7 | RewriteCond %{REQUEST_FILENAME} !-d 8 | RewriteRule ^api/(.*)$ backend/index.php?request=$1 [QSA,NC,L] 9 | RewriteRule ^$ frontend [L] 10 | 11 | 12 | 13 | AddOutputFilterByType DEFLATE text/plain text/html 14 | AddOutputFilterByType DEFLATE image/svg+xml 15 | AddOutputFilterByType DEFLATE text/css 16 | AddOutputFilterByType DEFLATE text/json application/json 17 | AddOutputFilterByType DEFLATE application/javascript application/x-javascript text/x-component 18 | 19 | # Drop problematic browsers 20 | BrowserMatch ^Mozilla/4 gzip-only-text/html 21 | BrowserMatch ^Mozilla/4\.0[678] no-gzip 22 | BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html 23 | 24 | # Make sure proxies don't deliver the wrong content 25 | 26 | Header append Vary User-Agent env=!dont-vary 27 | 28 | 29 | 30 | 31 | 32 | Header append Vary Accept-Encoding 33 | 34 | 35 | 36 | 37 | mod_gzip_on Yes 38 | mod_gzip_dechunk Yes 39 | mod_gzip_item_include file \.(html?|txt|css|json|php)$ 40 | mod_gzip_item_include handler ^cgi-script$ 41 | mod_gzip_item_include mime ^text/.* 42 | mod_gzip_item_include mime ^application/x-javascript.* 43 | mod_gzip_item_include mime ^application/json 44 | mod_gzip_item_exclude mime ^image/.* 45 | mod_gzip_item_exclude rspheader ^Content-Encoding:.*gzip.* 46 | 47 | 48 | 49 | Header set Connection keep-alive 50 | 51 | 52 | 53 | # enable the directives - assuming they're not enabled globally 54 | ExpiresActive on 55 | 56 | ExpiresByType text/html "access plus 1 year" 57 | 58 | # send an Expires: header for each of these mimetypes (as defined by server) 59 | ExpiresByType image/png "access plus 1 year" 60 | 61 | # css may change a bit sometimes, so define shorter expiration 62 | ExpiresByType text/css "access plus 3 months" 63 | 64 | # libraries won't change much 65 | ExpiresByType application/javascript "access plus 1 year" 66 | ExpiresByType application/x-javascript "access plus 1 year" 67 | 68 | 69 | 70 | # ModPagespeed on 71 | 72 | 73 | -------------------------------------------------------------------------------- /frontend/css/nfsen-ng.css: -------------------------------------------------------------------------------- 1 | /* general */ 2 | @media (prefers-color-scheme: light) { 3 | html { 4 | filter: invert(0); 5 | } 6 | :root { 7 | } 8 | } 9 | @media (prefers-color-scheme: dark) { 10 | html { 11 | filter: invert(0.85); 12 | } 13 | :root { 14 | } 15 | } 16 | html { 17 | filter: none; 18 | } 19 | 20 | :root { 21 | --bs-primary: #ccc; 22 | } 23 | 24 | .nav { 25 | --bs-nav-link-color: #000; 26 | --bs-link-color-rgb: 0, 0, 0; 27 | } 28 | 29 | .btn-outline-primary { 30 | --bs-btn-bg: #fff; 31 | --bs-btn-color: #000; 32 | --bs-btn-border-color: #ccc; 33 | --bs-btn-hover-bg: #ccc; 34 | --bs-btn-hover-border-color: #ccc; 35 | --bs-btn-active-bg: #ccc; 36 | --bs-btn-active-border-color: #ccc; 37 | --bs-btn-active-color: #000; 38 | --bs-gradient: linear-gradient(#000 0%, #fff 100%); 39 | --bs-btn-disabled-color: #000; 40 | --bs-btn-disabled-border-color: #000; 41 | } 42 | 43 | /* light grey background for active nav links */ 44 | .nav-tabs .nav-link.active { 45 | --bs-nav-tabs-link-active-bg: rgba(var(--bs-light-rgb), 1); 46 | border-bottom: 1px solid rgb(var(--bs-light-rgb)); 47 | font-weight: bold; 48 | } 49 | 50 | .btn.disabled, 51 | .btn:disabled, 52 | fieldset:disabled .btn { 53 | opacity: 0.3; 54 | } 55 | 56 | textarea { 57 | resize: none; 58 | } 59 | 60 | code, 61 | pre, 62 | .code { 63 | font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; 64 | } 65 | 66 | .h5, 67 | .h6 { 68 | text-transform: uppercase; 69 | font-weight: bold; 70 | } 71 | 72 | /* graph */ 73 | 74 | #flowDiv .dygraph-title { 75 | font-size: 1.5rem; 76 | text-transform: uppercase; 77 | } 78 | 79 | #flowDiv .dygraph-ylabel { 80 | font-size: 1rem; 81 | } 82 | 83 | #legend span { 84 | padding: 0.2rem; 85 | } 86 | 87 | #legend span.highlight { 88 | background-color: #eee; 89 | } 90 | 91 | #series label { 92 | display: block; 93 | } 94 | 95 | #viewList li h4 { 96 | margin-left: 10px; 97 | } 98 | 99 | /* graph options */ 100 | 101 | .accordion h4:after { 102 | content: '\25B2'; 103 | float: right; 104 | } 105 | 106 | .accordion h4.collapsed:after { 107 | content: '\25BC'; 108 | } 109 | 110 | /* flows */ 111 | #sourceCIDRPrefixDiv:before, 112 | #destinationCIDRPrefixDiv:before { 113 | content: '/'; 114 | width: auto; 115 | position: absolute; 116 | font-size: 170%; 117 | padding-left: 1rem; 118 | } 119 | #sourceCIDRPrefix, 120 | #destinationCIDRPrefix { 121 | padding-left: 2rem; 122 | } 123 | -------------------------------------------------------------------------------- /frontend/css/dygraph.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Default styles for the dygraphs charting library. 3 | */ 4 | 5 | .dygraph-legend { 6 | position: absolute; 7 | font-size: 14px; 8 | z-index: 10; 9 | width: 250px; /* labelsDivWidth */ 10 | /* 11 | dygraphs determines these based on the presence of chart labels. 12 | It might make more sense to create a wrapper div around the chart proper. 13 | top: 0px; 14 | right: 2px; 15 | */ 16 | background: white; 17 | line-height: normal; 18 | text-align: left; 19 | overflow: hidden; 20 | } 21 | 22 | .dygraph-legend[dir="rtl"] { 23 | text-align: right; 24 | } 25 | 26 | /* styles for a solid line in the legend */ 27 | .dygraph-legend-line { 28 | display: inline-block; 29 | position: relative; 30 | bottom: .5ex; 31 | padding-left: 1em; 32 | height: 1px; 33 | border-bottom-width: 2px; 34 | border-bottom-style: solid; 35 | /* border-bottom-color is set based on the series color */ 36 | } 37 | 38 | /* styles for a dashed line in the legend, e.g. when strokePattern is set */ 39 | .dygraph-legend-dash { 40 | display: inline-block; 41 | position: relative; 42 | bottom: .5ex; 43 | height: 1px; 44 | border-bottom-width: 2px; 45 | border-bottom-style: solid; 46 | /* border-bottom-color is set based on the series color */ 47 | /* margin-right is set based on the stroke pattern */ 48 | /* padding-left is set based on the stroke pattern */ 49 | } 50 | 51 | .dygraph-roller { 52 | position: absolute; 53 | z-index: 10; 54 | } 55 | 56 | /* This class is shared by all annotations, including those with icons */ 57 | .dygraph-annotation { 58 | position: absolute; 59 | z-index: 10; 60 | overflow: hidden; 61 | } 62 | 63 | /* This class only applies to annotations without icons */ 64 | /* Old class name: .dygraphDefaultAnnotation */ 65 | .dygraph-default-annotation { 66 | border: 1px solid black; 67 | background-color: white; 68 | text-align: center; 69 | } 70 | 71 | .dygraph-axis-label { 72 | /* position: absolute; */ 73 | /* font-size: 14px; */ 74 | z-index: 10; 75 | line-height: normal; 76 | overflow: hidden; 77 | color: black; /* replaces old axisLabelColor option */ 78 | } 79 | 80 | .dygraph-axis-label-x { 81 | } 82 | 83 | .dygraph-axis-label-y { 84 | } 85 | 86 | .dygraph-axis-label-y2 { 87 | } 88 | 89 | .dygraph-title { 90 | font-weight: bold; 91 | z-index: 10; 92 | text-align: center; 93 | /* font-size: based on titleHeight option */ 94 | } 95 | 96 | .dygraph-xlabel { 97 | text-align: center; 98 | /* font-size: based on xLabelHeight option */ 99 | } 100 | 101 | /* For y-axis label */ 102 | .dygraph-label-rotate-left { 103 | text-align: center; 104 | /* See http://caniuse.com/#feat=transforms2d */ 105 | transform: rotate(90deg); 106 | -webkit-transform: rotate(90deg); 107 | -moz-transform: rotate(90deg); 108 | -o-transform: rotate(90deg); 109 | -ms-transform: rotate(90deg); 110 | } 111 | 112 | /* For y2-axis label */ 113 | .dygraph-label-rotate-right { 114 | text-align: center; 115 | /* See http://caniuse.com/#feat=transforms2d */ 116 | transform: rotate(-90deg); 117 | -webkit-transform: rotate(-90deg); 118 | -moz-transform: rotate(-90deg); 119 | -o-transform: rotate(-90deg); 120 | -ms-transform: rotate(-90deg); 121 | } 122 | -------------------------------------------------------------------------------- /backend/datasources/Datasource.php: -------------------------------------------------------------------------------- 1 | array( 10 | * 'source' => 'name_of_souce', 11 | * 'date_timestamp' => 000000000, 12 | * 'date_iso' => 'Ymd\THis', 13 | * 'fields' => array( 14 | * 'flows', 15 | * 'flows_tcp', 16 | * 'flows_udp', 17 | * 'flows_icmp', 18 | * 'flows_other', 19 | * 'packets', 20 | * 'packets_tcp', 21 | * 'packets_udp', 22 | * 'packets_icmp', 23 | * 'packets_other', 24 | * 'bytes', 25 | * 'bytes_tcp', 26 | * 'bytes_udp', 27 | * 'bytes_icmp', 28 | * 'bytes_other') 29 | * );. 30 | * 31 | * @return bool TRUE on success or FALSE on failure 32 | * 33 | * @throws \Exception on error 34 | */ 35 | public function write(array $data): bool; 36 | 37 | /** 38 | * Gets data for plotting the graph in the frontend. 39 | * Each row in $return['data'] will be a time point in the graph. 40 | * The lines can be 41 | * * protocols - $sources must not contain more than one source (legend e.g. gateway_flows_udp, gateway_flows_tcp) 42 | * * sources - $protocols must not contain more than one protocol (legend e.g. gateway_traffic_icmp, othersource_traffic_icmp) 43 | * * ports. 44 | * 45 | * @param int $start timestamp 46 | * @param int $end timestamp 47 | * @param array $sources subset of sources specified in settings 48 | * @param array $protocols UDP/TCP/ICMP/other 49 | * @param string $type flows/packets/traffic 50 | * @param string $display protocols/sources/ports 51 | * 52 | * @return array in the following format: 53 | * 54 | * $return = array( 55 | * 'start' => 1490484600, // timestamp of first value 56 | * 'end' => 1490652000, // timestamp of last value 57 | * 'step' => 300, // resolution of the returned data in seconds. lowest value would probably be 300 = 5 minutes 58 | * 'legend' => array('swi6_flows_tcp', 'gate_flows_tcp'), // legend describes the graph series 59 | * 'data' => array( 60 | * 1490484600 => array(33.998333333333, 22.4), // the values/measurements for this specific timestamp are in an array 61 | * 1490485200 => array(37.005, 132.8282), 62 | * ... 63 | * ) 64 | * ); 65 | */ 66 | public function get_graph_data( 67 | int $start, 68 | int $end, 69 | array $sources, 70 | array $protocols, 71 | array $ports, 72 | string $type = 'flows', 73 | string $display = 'sources', 74 | ): array|string; 75 | 76 | /** 77 | * Removes all existing data for every source in $sources. 78 | * If $sources is empty, remove all existing data. 79 | */ 80 | public function reset(array $sources): bool; 81 | 82 | /** 83 | * Gets the timestamps of the first and last entry in the datasource (for this specific source). 84 | * 85 | * @return array (timestampfirst, timestamplast) 86 | */ 87 | public function date_boundaries(string $source): array; 88 | 89 | /** 90 | * Gets the timestamp of the last update of the datasource (for this specific source). 91 | */ 92 | public function last_update(string $source, int $port = 0): int; 93 | 94 | /** 95 | * Gets the path where the datasource's data is stored. 96 | */ 97 | public function get_data_path(): string; 98 | } 99 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng installation 2 | 3 | These instructions install nfsen-ng on a fresh Ubuntu 22.04/24.04 LTS or Debian 11/12 system. 4 | 5 | **Note that setup of nfcapd is not covered here, but nfsen-ng requires data captured by nfcapd to work.** 6 | 7 | ## Ubuntu 22.04/24.04 LTS 8 | 9 | ```bash 10 | # run following commands as root 11 | 12 | # add php repository 13 | add-apt-repository -y ppa:ondrej/php 14 | 15 | # install packages 16 | apt install apache2 git pkg-config php8.3 php8.3-dev php8.3-mbstring libapache2-mod-php8.3 rrdtool librrd-dev 17 | 18 | # compile nfdump (optional, if you want to use the most recent version) 19 | apt install flex libbz2-dev yacc unzip 20 | wget https://github.com/phaag/nfdump/archive/refs/tags/v1.7.4.zip 21 | unzip v1.7.4.zip 22 | cd nfdump-1.7.4/ 23 | ./autogen.sh 24 | ./configure 25 | make 26 | make install 27 | ldconfig 28 | nfdump -V 29 | 30 | # enable apache modules 31 | a2enmod rewrite deflate headers expires 32 | 33 | # install rrd library for php 34 | pecl install rrd 35 | 36 | # create rrd library mod entry for php 37 | echo "extension=rrd.so" > /etc/php/8.3/mods-available/rrd.ini 38 | 39 | # enable php mods 40 | phpenmod rrd mbstring 41 | 42 | # configure virtual host to read .htaccess files 43 | vi /etc/apache2/apache2.conf # set AllowOverride All for /var/www directory 44 | 45 | # restart apache web server 46 | systemctl restart apache2 47 | 48 | # install nfsen-ng 49 | cd /var/www # or wherever, needs to be in the web root 50 | git clone https://github.com/mbolli/nfsen-ng 51 | chown -R www-data:www-data . 52 | chmod +x nfsen-ng/backend/cli.php 53 | 54 | cd nfsen-ng 55 | # install composer with instructions from https://getcomposer.org/download/ 56 | php composer.phar install --no-dev 57 | 58 | # next step: create configuration file from backend/settings/settings.php.dist 59 | ``` 60 | 61 | ## Debian 11/12 62 | 63 | ```bash 64 | # run following commands as root 65 | su - 66 | 67 | # add php repository 68 | apt install -y apt-transport-https lsb-release ca-certificates wget curl gpg 69 | echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/php.list 70 | curl -fsSL https://packages.sury.org/php/apt.gpg | gpg --dearmor | tee /etc/apt/trusted.gpg.d/sury-php.gpg > /dev/null 71 | apt update 72 | 73 | # install packages 74 | apt install apache2 git pkg-config php8.4 php8.4-dev php8.4-mbstring libapache2-mod-php8.4 rrdtool librrd-dev 75 | 76 | # compile nfdump (optional, if you want to use the most recent version) 77 | apt install flex libbz2-dev yacc unzip 78 | wget https://github.com/phaag/nfdump/archive/refs/tags/v1.7.4.zip 79 | unzip v1.7.4.zip 80 | cd nfdump-1.7.4/ 81 | ./autogen.sh 82 | ./configure 83 | make 84 | make install 85 | ldconfig 86 | nfdump -V 87 | 88 | # enable apache modules 89 | a2enmod rewrite deflate headers expires 90 | 91 | # install rrd library for php 92 | pecl install rrd 93 | 94 | # create rrd library mod entry for php 95 | echo "extension=rrd.so" > /etc/php/8.4/mods-available/rrd.ini 96 | 97 | # enable php mods 98 | phpenmod rrd mbstring 99 | 100 | # configure virtual host to read .htaccess files 101 | vi /etc/apache2/apache2.conf # set AllowOverride All for /var/www 102 | 103 | # restart apache web server 104 | systemctl restart apache2 105 | 106 | # install nfsen-ng 107 | cd /var/www # or wherever 108 | git clone https://github.com/mbolli/nfsen-ng 109 | chown -R www-data:www-data . 110 | chmod +x nfsen-ng/backend/cli.php 111 | cd nfsen-ng 112 | 113 | # install composer with instructions from https://getcomposer.org/download/ 114 | php composer.phar install --no-dev 115 | 116 | # next step: create configuration file from backend/settings/settings.php.dist 117 | ``` 118 | -------------------------------------------------------------------------------- /backend/datasources/Akumuli.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 14 | $this->connect(); 15 | } 16 | 17 | public function __destruct() { 18 | if (\is_resource($this->client)) { 19 | fclose($this->client); 20 | } 21 | } 22 | 23 | /** 24 | * connects to TCP socket. 25 | */ 26 | public function connect(): void { 27 | try { 28 | $this->client = stream_socket_client('tcp://' . Config::$cfg['db']['akumuli']['host'] . ':' . Config::$cfg['db']['akumuli']['port'], $errno, $errmsg); 29 | 30 | if ($this->client === false) { 31 | throw new \Exception('Failed to connect to Akumuli: ' . $errmsg); 32 | } 33 | } catch (\Exception $e) { 34 | $this->d->dpr($e); 35 | } 36 | } 37 | 38 | /** 39 | * Convert data to redis-compatible string and write to Akumuli. 40 | */ 41 | public function write(array $data): bool { 42 | $fields = array_keys($data['fields']); 43 | $values = array_values($data['fields']); 44 | 45 | // writes assume redis protocol. first byte identification: 46 | // "+" simple strings "-" errors ":" integers "$" bulk strings "*" array 47 | $query = '+' . implode('|', $fields) . ' source=' . $data['source'] . "\r\n" 48 | . '+' . $data['date_iso'] . "\r\n" // timestamp 49 | . '*' . \count($fields) . "\r\n"; // length of following array 50 | 51 | // add the $values corresponding to $fields 52 | foreach ($values as $v) { 53 | $query .= ':' . $v . "\r\n"; 54 | } 55 | 56 | $this->d->dpr([$query]); 57 | 58 | // write redis-compatible string to socket 59 | fwrite($this->client, $query); 60 | 61 | return stream_get_contents($this->client); 62 | // to read: 63 | // curl localhost:8181/api/query -d "{'select':'flows'}" 64 | } 65 | 66 | /** 67 | * Gets data for plotting the graph in the frontend. 68 | * Each row in $return['data'] will be one line in the graph. 69 | * The lines can be 70 | * * protocols - $sources must not contain more than one source (legend e.g. gateway_flows_udp, gateway_flows_tcp) 71 | * * sources - $protocols must not contain more than one protocol (legend e.g. gateway_traffic_icmp, othersource_traffic_icmp). 72 | * 73 | * @param int $start timestamp 74 | * @param int $end timestamp 75 | * @param array $sources subset of sources specified in settings 76 | * @param array $protocols UDP/TCP/ICMP/other 77 | * @param string $type flows/packets/traffic 78 | * 79 | * @return array in the following format: 80 | * 81 | * $return = array( 82 | * 'start' => 1490484600, // timestamp of first value 83 | * 'end' => 1490652000, // timestamp of last value 84 | * 'step' => 300, // resolution of the returned data in seconds. lowest value would probably be 300 = 5 minutes 85 | * 'data' => array( 86 | * 0 => array( 87 | * 'legend' => 'source_type_protocol', 88 | * 'data' => array( 89 | * 1490484600 => 33.998333333333, 90 | * 1490485200 => 37.005, ... 91 | * ) 92 | * ), 93 | * 1 => array( e.g. gateway_flows_udp ...) 94 | * ) 95 | * ); 96 | */ 97 | public function get_graph_data(int $start, int $end, array $sources, array $protocols, array $ports, string $type = 'flows', string $display = 'sources'): array|string { 98 | // TODO: Implement get_graph_data() method. 99 | return []; 100 | } 101 | 102 | /** 103 | * Gets the timestamps of the first and last entry in the datasource (for this specific source). 104 | * 105 | * @return array (timestampfirst, timestamplast) 106 | */ 107 | public function date_boundaries(string $source): array { 108 | // TODO: Implement date_boundaries() method. 109 | return []; 110 | } 111 | 112 | /** 113 | * Gets the timestamp of the last update of the datasource (for this specific source). 114 | */ 115 | public function last_update(string $source, int $port = 0): int { 116 | // TODO: Implement last_update() method. 117 | return 0; 118 | } 119 | 120 | /** 121 | * Gets the path where the datasource's data is stored. 122 | */ 123 | public function get_data_path(): string { 124 | // TODO: Implement get_data_path() method. 125 | return ''; 126 | } 127 | 128 | /** 129 | * Removes all existing data for every source in $sources. 130 | * If $sources is empty, remove all existing data. 131 | */ 132 | public function reset(array $sources): bool { 133 | // TODO: Implement reset() method. 134 | return true; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /backend/cli.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | log('Fatal: ' . $e->getMessage(), \LOG_ALERT); 17 | 18 | exit; 19 | } 20 | 21 | if ($argc < 2 || in_array($argv[1], ['--help', '-help', '-h', '-?'], true)) { 22 | $script = $argv[0]; 23 | $help = <<log('CLI: Starting import', \LOG_INFO); 64 | $importYears = (int) (getenv('NFSEN_IMPORT_YEARS') ?: 3); 65 | $forceImport = in_array('-f', $argv, true); 66 | $d->log('CLI: NFSEN_IMPORT_YEARS=' . $importYears . ', force=' . ($forceImport ? 'true' : 'false'), \LOG_INFO); 67 | 68 | $start = new DateTime(); 69 | $start->modify('-' . $importYears . ' years'); 70 | $d->log('CLI: Import start date: ' . $start->format('Y-m-d H:i:s'), \LOG_INFO); 71 | 72 | $i = new Import(); 73 | if (in_array('-v', $argv, true)) { 74 | $i->setVerbose(true); 75 | } 76 | if (in_array('-p', $argv, true)) { 77 | $i->setProcessPorts(true); 78 | } 79 | if (in_array('-ps', $argv, true)) { 80 | $i->setProcessPortsBySource(true); 81 | } 82 | if ($forceImport) { 83 | $i->setForce(true); 84 | } 85 | $i->start($start); 86 | } elseif (in_array('start', $argv, true)) { 87 | // start the daemon 88 | 89 | // Check if already running 90 | if (file_exists($pidfile)) { 91 | $pid = trim(file_get_contents($pidfile)); 92 | 93 | if (Misc::daemonIsRunning($pid)) { 94 | echo 'Daemon already running, pid=' . $pid . \PHP_EOL; 95 | 96 | exit(0); 97 | } 98 | // Clean up stale PID file 99 | unlink($pidfile); 100 | } 101 | 102 | $d->log('CLI: Starting daemon...', \LOG_INFO); 103 | $phpBinary = trim(shell_exec('which php')); 104 | if (empty($phpBinary)) { 105 | echo 'Failed to start daemon. PHP binary not found in PATH.' . \PHP_EOL; 106 | 107 | exit(1); 108 | } 109 | $pid = exec('nohup ' . escapeshellarg($phpBinary) . ' ' . escapeshellarg($folder . '/listen.php') . ' > /dev/null 2>&1 & echo $!', $op, $exit); 110 | 111 | // Validate PID 112 | if (!is_numeric($pid) || (int) $pid <= 0) { 113 | echo 'Failed to start daemon. Could not retrieve valid PID.' . \PHP_EOL; 114 | 115 | exit(1); 116 | } 117 | 118 | // todo: get exit code of background process. possible at all? 119 | echo match ((int) $exit) { 120 | 128 => 'Unexpected error opening or locking lock file. Perhaps you don\'t have permission to write to the lock file or its containing directory?', 121 | 129 => 'Another instance is already running; terminating.', 122 | default => 'Daemon running, pid=' . $pid, 123 | }; 124 | echo \PHP_EOL; 125 | } elseif (in_array('stop', $argv, true)) { 126 | // stop the daemon 127 | 128 | if (!file_exists($pidfile)) { 129 | echo 'Not running' . \PHP_EOL; 130 | 131 | exit; 132 | } 133 | $pid = file_get_contents($pidfile); 134 | $d->log('CLI: Stopping daemon', \LOG_INFO); 135 | exec('kill ' . $pid); 136 | unlink($pidfile); 137 | 138 | echo 'Stopped.' . \PHP_EOL; 139 | } elseif (in_array('status', $argv, true)) { 140 | // print the daemon status 141 | 142 | if (!file_exists($pidfile)) { 143 | echo 'Not running' . \PHP_EOL; 144 | 145 | exit; 146 | } 147 | $pid = trim(file_get_contents($pidfile)); 148 | 149 | if (Misc::daemonIsRunning($pid)) { 150 | echo 'Running: ' . $pid . \PHP_EOL; 151 | } else { 152 | echo 'Not running (stale PID file: ' . $pid . ')' . \PHP_EOL; 153 | // Clean up stale PID file 154 | unlink($pidfile); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /backend/common/Config.php: -------------------------------------------------------------------------------- 1 | , filters?: array}, 14 | * frontend: array{reload_interval: int, defaults: array}, 15 | * nfdump: array{binary: string, profiles-data: string, profile: string, max-processes: int}, 16 | * db: array, 17 | * log: array{priority: int} 18 | * }|array{} 19 | */ 20 | public static array $cfg = []; 21 | public static string $path; 22 | public static Datasource $db; 23 | public static Processor $processorClass; 24 | private static bool $initialized = false; 25 | 26 | private function __construct() {} 27 | 28 | public static function initialize(bool $initProcessor = false): void { 29 | global $nfsen_config; 30 | if (self::$initialized === true) { 31 | return; 32 | } 33 | 34 | $settingsFile = \dirname(__DIR__) . \DIRECTORY_SEPARATOR . 'settings' . \DIRECTORY_SEPARATOR . 'settings.php'; 35 | if (!file_exists($settingsFile)) { 36 | throw new \Exception('No settings.php found. Did you rename the distributed settings correctly?'); 37 | } 38 | 39 | include $settingsFile; 40 | 41 | self::$cfg = $nfsen_config; 42 | self::$path = \dirname(__DIR__); 43 | self::$initialized = true; 44 | 45 | // Validate directory structure for nfcapd files 46 | self::validateDirectoryStructure(); 47 | 48 | // find data source 49 | $dbClass = 'mbolli\\nfsen_ng\\datasources\\' . ucfirst(strtolower(self::$cfg['general']['db'])); 50 | if (class_exists($dbClass)) { 51 | self::$db = new $dbClass(); 52 | } else { 53 | throw new \Exception('Failed loading class ' . self::$cfg['general']['db'] . '. The class doesn\'t exist.'); 54 | } 55 | 56 | // find processor 57 | $processorClass = \array_key_exists('processor', self::$cfg['general']) ? ucfirst(strtolower(self::$cfg['general']['processor'])) : 'Nfdump'; 58 | $processorClass = 'mbolli\\nfsen_ng\\processor\\' . $processorClass; 59 | if (!class_exists($processorClass)) { 60 | throw new \Exception('Failed loading class ' . $processorClass . '. The class doesn\'t exist.'); 61 | } 62 | 63 | if (!\in_array(Processor::class, class_implements($processorClass), true)) { 64 | throw new \Exception('Processor class ' . $processorClass . ' doesn\'t implement ' . Processor::class . '.'); 65 | } 66 | 67 | if ($initProcessor === true) { 68 | try { 69 | self::$processorClass = new $processorClass(); 70 | } catch (\Exception $e) { 71 | throw new \Exception('Failed initializing processor class ' . $processorClass . ': ' . $e->getMessage()); 72 | } 73 | } 74 | } 75 | 76 | /** 77 | * Validate that nfcapd files are organized in the expected directory structure. 78 | * nfsen-ng requires nfcapd files to be organized as: profiles-data/profile/source/YYYY/MM/DD/nfcapd.*. 79 | * 80 | * @throws \Exception if directory structure is invalid 81 | */ 82 | private static function validateDirectoryStructure(): void { 83 | $profilesData = self::$cfg['nfdump']['profiles-data'] ?? null; 84 | $profile = self::$cfg['nfdump']['profile'] ?? 'live'; 85 | $sources = self::$cfg['general']['sources'] ?? []; 86 | 87 | if (empty($profilesData) || empty($sources)) { 88 | return; // Skip validation if config is incomplete 89 | } 90 | 91 | $errors = []; 92 | 93 | foreach ($sources as $source) { 94 | $sourcePath = $profilesData . \DIRECTORY_SEPARATOR . $profile . \DIRECTORY_SEPARATOR . $source; 95 | 96 | // Check if source directory exists 97 | if (!is_dir($sourcePath)) { 98 | $errors[] = "Source directory does not exist: {$sourcePath}"; 99 | 100 | continue; 101 | } 102 | 103 | // Check if files are organized in date hierarchy (YYYY/MM/DD) 104 | // by looking for flat nfcapd files directly in source directory 105 | $flatFiles = glob($sourcePath . \DIRECTORY_SEPARATOR . 'nfcapd.*'); 106 | 107 | if ($flatFiles !== false && \count($flatFiles) > 0) { 108 | // Filter out symlinks and .current files 109 | $actualFiles = array_filter($flatFiles, fn ($file) => is_file($file) 110 | && !is_link($file) 111 | && !preg_match('/\.current\./', $file)); 112 | 113 | if (\count($actualFiles) > 0) { 114 | $errors[] = "Source '{$source}' has nfcapd files in flat structure at: {$sourcePath}"; 115 | $errors[] = " nfsen-ng requires hierarchical structure: {$sourcePath}/YYYY/MM/DD/nfcapd.*"; 116 | $errors[] = " Configure nfcapd with: -w {$sourcePath} -S 1"; 117 | $errors[] = " Run 'reorganize_nfcapd.sh' script to move existing files into proper structure."; 118 | } 119 | } 120 | } 121 | 122 | if (!empty($errors)) { 123 | $errorMsg = "Invalid nfcapd directory structure detected:\n\n" . implode("\n", $errors); 124 | $errorMsg .= "\n\nFor more information, see INSTALL.md"; 125 | 126 | throw new \Exception($errorMsg); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /frontend/css/ion.rangeSlider.css: -------------------------------------------------------------------------------- 1 | /* Ion.RangeSlider 2 | // css version 2.0.3 3 | // © 2013-2014 Denis Ineshin | IonDen.com 4 | // ===================================================================================================================*/ 5 | 6 | /* ===================================================================================================================== 7 | // RangeSlider */ 8 | 9 | .irs { 10 | position: relative; display: block; 11 | -webkit-touch-callout: none; 12 | -webkit-user-select: none; 13 | -khtml-user-select: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | user-select: none; 17 | } 18 | .irs-line { 19 | position: relative; display: block; 20 | overflow: hidden; 21 | outline: none !important; 22 | } 23 | .irs-line-left, .irs-line-mid, .irs-line-right { 24 | position: absolute; display: block; 25 | top: 0; 26 | } 27 | .irs-line-left { 28 | left: 0; width: 11%; 29 | } 30 | .irs-line-mid { 31 | left: 9%; width: 82%; 32 | } 33 | .irs-line-right { 34 | right: 0; width: 11%; 35 | } 36 | 37 | .irs-bar { 38 | position: absolute; display: block; 39 | left: 0; width: 0; 40 | } 41 | .irs-bar-edge { 42 | position: absolute; display: block; 43 | top: 0; left: 0; 44 | } 45 | 46 | .irs-shadow { 47 | position: absolute; display: none; 48 | left: 0; width: 0; 49 | } 50 | 51 | .irs-slider { 52 | position: absolute; display: block; 53 | cursor: default; 54 | z-index: 1; 55 | } 56 | .irs-slider.single { 57 | 58 | } 59 | .irs-slider.from { 60 | 61 | } 62 | .irs-slider.to { 63 | 64 | } 65 | .irs-slider.type_last { 66 | z-index: 2; 67 | } 68 | 69 | .irs-min { 70 | position: absolute; display: block; 71 | left: 0; 72 | cursor: default; 73 | } 74 | .irs-max { 75 | position: absolute; display: block; 76 | right: 0; 77 | cursor: default; 78 | } 79 | 80 | .irs-from, .irs-to, .irs-single { 81 | position: absolute; display: block; 82 | top: 0; left: 0; 83 | cursor: default; 84 | white-space: nowrap; 85 | } 86 | 87 | .irs-grid { 88 | position: absolute; display: none; 89 | bottom: 0; left: 0; 90 | width: 100%; height: 20px; 91 | } 92 | .irs-with-grid .irs-grid { 93 | display: block; 94 | } 95 | .irs-grid-pol { 96 | position: absolute; 97 | top: 0; left: 0; 98 | width: 1px; height: 8px; 99 | background: #000; 100 | } 101 | .irs-grid-pol.small { 102 | height: 4px; 103 | } 104 | .irs-grid-text { 105 | position: absolute; 106 | bottom: 0; left: 0; 107 | white-space: nowrap; 108 | text-align: center; 109 | font-size: 9px; line-height: 9px; 110 | padding: 0 3px; 111 | color: #000; 112 | } 113 | 114 | .irs-disable-mask { 115 | position: absolute; display: block; 116 | top: 0; left: -1%; 117 | width: 102%; height: 100%; 118 | cursor: default; 119 | background: rgba(0,0,0,0.0); 120 | z-index: 2; 121 | } 122 | .lt-ie9 .irs-disable-mask { 123 | background: #000; 124 | filter: alpha(opacity=0); 125 | cursor: not-allowed; 126 | } 127 | 128 | .irs-disabled { 129 | opacity: 0.4; 130 | } 131 | 132 | 133 | .irs-hidden-input { 134 | position: absolute !important; 135 | display: block !important; 136 | top: 0 !important; 137 | left: 0 !important; 138 | width: 0 !important; 139 | height: 0 !important; 140 | font-size: 0 !important; 141 | line-height: 0 !important; 142 | padding: 0 !important; 143 | margin: 0 !important; 144 | overflow: hidden; 145 | outline: none !important; 146 | z-index: -9999 !important; 147 | background: none !important; 148 | border-style: solid !important; 149 | border-color: transparent !important; 150 | } 151 | 152 | /* Ion.RangeSlider, Nice Skin 153 | // css version 2.0.3 154 | // © Denis Ineshin, 2014 https://github.com/IonDen 155 | // ===================================================================================================================*/ 156 | 157 | /* ===================================================================================================================== 158 | // Skin details */ 159 | 160 | .irs-line-mid, 161 | .irs-line-left, 162 | .irs-line-right, 163 | .irs-bar, 164 | .irs-bar-edge, 165 | .irs-slider { 166 | background: url(sprite-skin-nice.png) repeat-x; 167 | } 168 | 169 | .irs { 170 | height: 40px; 171 | } 172 | .irs-with-grid { 173 | height: 60px; 174 | } 175 | .irs-line { 176 | height: 8px; top: 25px; 177 | } 178 | .irs-line-left { 179 | height: 8px; 180 | background-position: 0 -30px; 181 | } 182 | .irs-line-mid { 183 | height: 8px; 184 | background-position: 0 0; 185 | } 186 | .irs-line-right { 187 | height: 8px; 188 | background-position: 100% -30px; 189 | } 190 | 191 | .irs-bar { 192 | height: 8px; top: 25px; 193 | background-position: 0 -60px; 194 | } 195 | .irs-bar-edge { 196 | top: 25px; 197 | height: 8px; width: 11px; 198 | background-position: 0 -90px; 199 | } 200 | 201 | .irs-shadow { 202 | height: 1px; top: 34px; 203 | background: #000; 204 | opacity: 0.15; 205 | } 206 | .lt-ie9 .irs-shadow { 207 | filter: alpha(opacity=15); 208 | } 209 | 210 | .irs-slider { 211 | width: 22px; height: 22px; 212 | top: 17px; 213 | background-position: 0 -120px; 214 | } 215 | .irs-slider.state_hover, .irs-slider:hover { 216 | background-position: 0 -150px; 217 | } 218 | 219 | .irs-min, .irs-max { 220 | color: #999; 221 | font-size: 10px; line-height: 1.333; 222 | text-shadow: none; 223 | top: 0; padding: 1px 3px; 224 | background: rgba(0,0,0,0.1); 225 | -moz-border-radius: 3px; 226 | border-radius: 3px; 227 | } 228 | .lt-ie9 .irs-min, .lt-ie9 .irs-max { 229 | background: #ccc; 230 | } 231 | 232 | .irs-from, .irs-to, .irs-single { 233 | color: #fff; 234 | font-size: 10px; line-height: 1.333; 235 | text-shadow: none; 236 | padding: 1px 5px; 237 | background: rgba(0,0,0,0.3); 238 | -moz-border-radius: 3px; 239 | border-radius: 3px; 240 | } 241 | .lt-ie9 .irs-from, .lt-ie9 .irs-to, .lt-ie9 .irs-single { 242 | background: #999; 243 | } 244 | 245 | .irs-grid-pol { 246 | background: #99a4ac; 247 | } 248 | .irs-grid-text { 249 | color: #99a4ac; 250 | } 251 | 252 | .irs-disabled { 253 | } 254 | -------------------------------------------------------------------------------- /API_ENDPOINTS.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng API endpoints 2 | 3 | ### /api/config 4 | * **URL** 5 | `/api/config` 6 | 7 | * **Method:** 8 | `GET` 9 | 10 | * **URL Params** 11 | none 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200 16 | **Content:** 17 | ```json 18 | { 19 | "sources": [ "gate", "swi6" ], 20 | "ports": [ 80, 22, 23 ], 21 | "stored_output_formats": [], 22 | "stored_filters": [], 23 | "daemon_running": true 24 | } 25 | ``` 26 | 27 | * **Error Response:** 28 | 29 | * **Code:** 400 BAD REQUEST 30 | **Content:** 31 | ```json 32 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 33 | ``` 34 | OR 35 | 36 | * **Code:** 404 NOT FOUND 37 | **Content:** 38 | ```json 39 | {"code": 404, "error": "400 - Not found. "} 40 | ``` 41 | 42 | * **Sample Call:** 43 | ```sh 44 | curl localhost/nfsen-ng/api/config 45 | ``` 46 | 47 | ### /api/graph 48 | * **URL** 49 | `/api/graph?datestart=1490484000&dateend=1490652000&type=flows&sources[0]=gate&protocols[0]=tcp&protocols[1]=icmp&display=sources` 50 | 51 | * **Method:** 52 | 53 | `GET` 54 | 55 | * **URL Params** 56 | * `datestart=[integer]` Unix timestamp 57 | * `dateend=[integer]` Unix timestamp 58 | * `type=[string]` Type of data to show: flows/packets/traffic 59 | * `sources=[array]` 60 | * `protocols=[array]` 61 | * `ports=[array]` 62 | * `display=[string]` can be `sources`, `protocols` or `ports` 63 | 64 | There can't be multiple sources and multiple protocols both. Either one source and multiple protocols, or one protocol and multiple sources. 65 | 66 | * **Success Response:** 67 | 68 | * **Code:** 200 69 | **Content:** 70 | ```json 71 | {"data": { 72 | "1490562300":[2.1666666667,94.396666667], 73 | "1490562600":[1.0466666667,72.976666667],... 74 | },"start":1490562300,"end":1490590800,"step":300,"legend":["swi6_flows_tcp","gate_flows_tcp"]} 75 | ``` 76 | 77 | * **Error Response:** 78 | * **Code:** 400 BAD REQUEST
79 | **Content:** 80 | ```json 81 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 82 | ``` 83 | 84 | OR 85 | 86 | * **Code:** 404 NOT FOUND
87 | **Content:** 88 | ```json 89 | {"code": 404, "error": "400 - Not found. "} 90 | ``` 91 | 92 | * **Sample Call:** 93 | 94 | ```sh 95 | curl -g "http://localhost/nfsen-ng/api/graph?datestart=1490484000&dateend=1490652000&type=flows&sources[0]=gate&protocols[0]=tcp&protocols[1]=icmp&display=sources" 96 | ``` 97 | 98 | ### /api/flows 99 | * **URL** 100 | `/api/flows?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&filter=&limit=100&aggregate=srcip&sort=&output[format]=auto` 101 | 102 | * **Method:** 103 | 104 | `GET` 105 | 106 | * **URL Params** 107 | * `datestart=[integer]` Unix timestamp 108 | * `dateend=[integer]` Unix timestamp 109 | * `sources=[array]` 110 | * `filter=[string]` pcap-syntaxed filter 111 | * `limit=[int]` max. returned rows 112 | * `aggregate=[string]` can be `bidirectional` or a valid nfdump aggregation string (e.g. `srcip4/24, dstport`), but not both at the same time 113 | * `sort=[string]` (will probably cease to exist, as ordering is done directly in aggregation) e.g. `tstart` 114 | * `output=[array]` can contain `[format] = auto|line|long|extended` and `[IPv6]` 115 | 116 | * **Success Response:** 117 | 118 | * **Code:** 200 119 | **Content:** 120 | ```json 121 | [["ts","td","sa","da","sp","dp","pr","ipkt","ibyt","opkt","obyt"], 122 | ["2017-03-27 10:40:46","0.000","85.105.45.96","0.0.0.0","0","0","","1","46","0","0"], 123 | ... 124 | ``` 125 | 126 | * **Error Response:** 127 | 128 | * **Code:** 400 BAD REQUEST
129 | **Content:** 130 | ```json 131 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 132 | ``` 133 | 134 | OR 135 | 136 | * **Code:** 404 NOT FOUND
137 | **Content:** 138 | ```json 139 | {"code": 404, "error": "400 - Not found. "} 140 | ``` 141 | 142 | * **Sample Call:** 143 | 144 | ```sh 145 | curl -g "http://localhost/nfsen-ng/api/flows?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&filter=&limit=100&aggregate[]=srcip&sort=&output[format]=auto" 146 | ``` 147 | 148 | ### /api/stats 149 | * **URL** 150 | `/api/stats?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&for=dstip&filter=&top=10&limit=100&aggregate[]=srcip&sort=&output[format]=auto` 151 | 152 | * **Method:** 153 | 154 | `GET` 155 | 156 | * **URL Params** 157 | * `datestart=[integer]` Unix timestamp 158 | * `dateend=[integer]` Unix timestamp 159 | * `sources=[array]` 160 | * `filter=[string]` pcap-syntaxed filter 161 | * `top=[int]` return top N rows 162 | * `for=[string]` field to get the statistics for. with optional ordering field as suffix, e.g. `ip/flows` 163 | * `limit=[string]` limit output to records above or below of `limit` e.g. `500K` 164 | * `output=[array]` can contain `[IPv6]` 165 | 166 | * **Success Response:** 167 | 168 | * **Code:** 200 169 | **Content:** 170 | ```json 171 | [ 172 | ["Packet limit: > 100 packets"], 173 | ["ts","te","td","pr","val","fl","flP","ipkt","ipktP","ibyt","ibytP","ipps","ipbs","ibpp"], 174 | ["2017-03-27 10:38:20","2017-03-27 10:47:58","577.973","any","193.5.80.180","673","2.7","676","2.5","56581","2.7","1","783","83"], 175 | ... 176 | ] 177 | ``` 178 | 179 | * **Error Response:** 180 | 181 | * **Code:** 400 BAD REQUEST
182 | **Content:** 183 | ```json 184 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 185 | ``` 186 | 187 | OR 188 | 189 | * **Code:** 404 NOT FOUND
190 | **Content:** 191 | ```json 192 | {"code": 404, "error": "400 - Not found. "} 193 | ``` 194 | 195 | * **Sample Call:** 196 | 197 | ```sh 198 | curl -g "http://localhost/nfsen-ng/api/stats?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&for=dstip&filter=&top=10&limit=100&aggregate[]=srcip&sort=&output[format]=auto" 199 | ``` 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng 2 | 3 | [![GitHub license](https://img.shields.io/github/license/mbolli/nfsen-ng.svg?style=flat-square)](https://github.com/mbolli/nfsen-ng/blob/master/LICENSE) 4 | [![GitHub issues](https://img.shields.io/github/issues/mbolli/nfsen-ng.svg?style=flat-square)](https://github.com/mbolli/nfsen-ng/issues) 5 | [![Donate a beer](https://img.shields.io/badge/paypal-donate-yellow.svg?style=flat-square)](https://paypal.me/bolli) 6 | 7 | nfsen-ng is an in-place replacement for the ageing nfsen. 8 | 9 | ![nfsen-ng dashboard overview](https://github.com/mbolli/nfsen-ng/assets/722725/c3df942e-3d3c-4ef9-86ad-4e5780c7b6d8) 10 | 11 | ## Used components 12 | 13 | * Front end: [jQuery](https://jquery.com), [dygraphs](http://dygraphs.com), [FooTable](http://fooplugins.github.io/FooTable/), [ion.rangeSlider](http://ionden.com/a/plugins/ion.rangeSlider/en.html) 14 | * Back end: [RRDtool](http://oss.oetiker.ch/rrdtool/), [nfdump tools](https://github.com/phaag/nfdump) 15 | 16 | ## TOC 17 | 18 | * [nfsen-ng](#nfsen-ng) 19 | * [Installation](#installation) 20 | * [Configuration](#configuration) 21 | * [Nfdump](#nfdump) 22 | * [CLI/Daemon](#cli--daemon) 23 | * [Daemon as a systemd service](#daemon-as-a-systemd-service) 24 | * [Logs](#logs) 25 | * [API](#api) 26 | 27 | ## Installation 28 | 29 | Detailed installation instructions are available in [INSTALL.md](./INSTALL.md). Pull requests for additional distributions are welcome. 30 | 31 | Software packages required: 32 | 33 | * nfdump 34 | * rrdtool 35 | * git 36 | * composer 37 | * apache2 38 | * php >= 8.1 39 | 40 | Apache modules required: 41 | 42 | * mod_rewrite 43 | * mod_deflate 44 | * mod_headers 45 | * mod_expires 46 | 47 | PHP modules required: 48 | 49 | * mbstring 50 | * rrd 51 | 52 | ## Configuration 53 | 54 | > *Note:* nfsen-ng expects the `profiles_data` folder structure to be `PROFILES_DATA_PATH/PROFILE/SOURCE/YYYY/MM/DD/nfcapd.YYYYMMDDHHII`, e.g. `/var/nfdump/profiles_data/live/source1/2018/12/01/nfcapd.201812010225`. 55 | 56 | The default settings file is `backend/settings/settings.php.dist`. Copy it to `backend/settings/settings.php` and start modifying it. Example values are in *italic*: 57 | 58 | * **general** 59 | * **ports:** (*array(80, 23, 22, ...)*) The ports to examine. *Note:* If you use RRD as datasource and want to import existing data, you might keep the number of ports to a minimum, or the import time will be measured in moon cycles... 60 | * **sources:** (*array('source1', ...)*) The sources to scan. 61 | * **db:** (*RRD*) The name of the datasource class (case-sensitive). 62 | * **frontend** 63 | * **reload_interval:** Interval in seconds between graph reloads. 64 | * **nfdump** 65 | * **binary:** (*/usr/bin/nfdump*) The location of your nfdump executable 66 | * **profiles-data:** (*/var/nfdump/profiles_data*) The location of your nfcapd files 67 | * **profile:** (*live*) The profile folder to use 68 | * **max-processes:** (*1*) The maximum number of concurrently running nfdump processes. *Note:* Statistics and aggregations can use lots of system resources, even to aggregate one week of data might take more than 15 minutes. Put this value to > 1 if you want nfsen-ng to be usable while running another query. 69 | * **db** If the used data source needs additional configuration, you can specify it here, e.g. host and port. 70 | * **log** 71 | * **priority:** (*LOG_INFO*) see other possible values at [http://php.net/manual/en/function.syslog.php] 72 | 73 | ### Nfdump 74 | 75 | Nfsen-ng uses nfdump to read the nfcapd files. You can specify the location of the nfdump binary in `backend/settings/settings.php`. The default location is `/usr/bin/nfdump`. 76 | 77 | You should also have a look at the nfdump configuration file `/etc/nfdump.conf` and make sure that the `nfcapd` files are written to the correct location. The default location is `/var/nfdump/profiles_data`. 78 | 79 | Hhere is an example of an nfdump configuration: 80 | 81 | ```ini 82 | options='-z -S 1 -T all -l /var/nfdump/profiles_data/live/ -p ' 83 | ``` 84 | 85 | where 86 | 87 | * `-z` is used to compress the nfcapd files 88 | * `-S 1` is used to specify the nfcapd directory structure 89 | * `-T all` is used to specify the extension of the nfcapd files 90 | * `-l` is used to specify the destination location of the nfcapd files 91 | * `-p` is used to specify the port of the nfcapd files. 92 | 93 | #### Nfcapd x Sfcapd 94 | 95 | To use sfcapd instead of nfcapd, you have to change the `nfdump` configuration file `/lib/systemd/system/nfdump@.service` to use `sfcapd` instead of `nfcapd`: 96 | 97 | ```ini 98 | [Unit] 99 | Description=netflow capture daemon, %I instance 100 | Documentation=man:sfcapd(1) 101 | After=network.target auditd.service 102 | PartOf=nfdump.service 103 | 104 | [Service] 105 | Type=forking 106 | EnvironmentFile=/etc/nfdump/%I.conf 107 | ExecStart=/usr/bin/sfcapd -D -P /run/sfcapd.%I.pid $options 108 | PIDFile=/run/sfcapd.%I.pid 109 | KillMode=process 110 | Restart=no 111 | 112 | [Install] 113 | WantedBy=multi-user.target 114 | ``` 115 | 116 | ## CLI + Daemon 117 | 118 | The command line interface is used to initially scan existing nfcapd.* files, or to administer the daemon. 119 | 120 | Usage: 121 | 122 | `./cli.php [ options ] import` 123 | 124 | or for the daemon 125 | 126 | `./cli.php start|stop|status` 127 | 128 | * **Options:** 129 | * **-v** Show verbose output 130 | * **-p** Import ports data as well *Note:* Using RRD this will take quite a bit longer, depending on the number of your defined ports. 131 | * **-ps** Import ports per source as well *Note:* Using RRD this will take quite a bit longer, depending on the number of your defined ports. 132 | * **-f** Force overwriting database and start fresh 133 | 134 | * **Commands:** 135 | * **import** Import existing nfdump data to nfsen-ng. *Note:* If you have existing nfcapd files, better do this overnight or over a week-end. 136 | * **start** Start the daemon for continuous reading of new data 137 | * **stop** Stop the daemon 138 | * **status** Get the daemon's status 139 | 140 | * **Examples:** 141 | * `./cli.php -f import` 142 | Imports fresh data for sources 143 | 144 | * `./cli.php -f -p -ps import` 145 | Imports all data 146 | 147 | * `./cli.php start` 148 | Starts the daemon 149 | 150 | ### Daemon as a systemd service 151 | 152 | You can use the daemon as a service. To do so, you can use the provided systemd service file below. You can copy it to `/etc/systemd/system/nfsen-ng.service` and then start it with `systemctl start nfsen-ng`. 153 | 154 | ```ini 155 | [Unit] 156 | Description=nfsen-ng 157 | After=network-online.target 158 | 159 | [Service] 160 | Type=simple 161 | RemainAfterExit=yes 162 | restart=always 163 | startLimitIntervalSec=0 164 | restartSec=2 165 | ExecStart=su - www-data --shell=/bin/bash -c '/var/www/html/nfsen-ng/backend/cli.php start' 166 | ExecStop=su - www-data --shell=/bin/bash -c '/var/www/html/nfsen-ng/backend/cli.php stop' 167 | 168 | [Install] 169 | WantedBy=multi-user.target 170 | ``` 171 | 172 | Now, you should reload and enable the service to start on boot with `systemctl daemon-reload` and `systemctl enable nfsen-ng`. 173 | 174 | ## Logs 175 | 176 | Nfsen-ng logs to syslog. You can find the logs in `/var/log/syslog` or `/var/log/messages` depending on your system. Some distributions might register it in `journalctl`. To access the logs, you can use `tail -f /var/log/syslog` or `journalctl -u nfsen-ng` 177 | 178 | You can change the log priority in `backend/settings/settings.php`. 179 | 180 | ## API 181 | 182 | The API is used by the frontend to retrieve data. The API endpoints are documented in [API_ENDPOINTS.md](./API_ENDPOINTS.md). 183 | -------------------------------------------------------------------------------- /frontend/css/footable.bootstrap.min.css: -------------------------------------------------------------------------------- 1 | table.footable-details,table.footable>thead>tr.footable-filtering>th div.form-group{margin-bottom:0}table.footable,table.footable-details{position:relative;width:100%;border-spacing:0;border-collapse:collapse}table.footable-hide-fouc{display:none}table>tbody>tr>td>span.footable-toggle{margin-right:8px;opacity:.3}table>tbody>tr>td>span.footable-toggle.last-column{margin-left:8px;float:right}table.table-condensed>tbody>tr>td>span.footable-toggle{margin-right:5px}table.footable-details>tbody>tr>th:nth-child(1){min-width:40px;width:120px}table.footable-details>tbody>tr>td:nth-child(2){word-break:break-all}table.footable-details>tbody>tr:first-child>td,table.footable-details>tbody>tr:first-child>th,table.footable-details>tfoot>tr:first-child>td,table.footable-details>tfoot>tr:first-child>th,table.footable-details>thead>tr:first-child>td,table.footable-details>thead>tr:first-child>th{border-top-width:0}table.footable-details.table-bordered>tbody>tr:first-child>td,table.footable-details.table-bordered>tbody>tr:first-child>th,table.footable-details.table-bordered>tfoot>tr:first-child>td,table.footable-details.table-bordered>tfoot>tr:first-child>th,table.footable-details.table-bordered>thead>tr:first-child>td,table.footable-details.table-bordered>thead>tr:first-child>th{border-top-width:1px}div.footable-loader{vertical-align:middle;text-align:center;height:300px;position:relative}div.footable-loader>span.fooicon{display:inline-block;opacity:.3;font-size:30px;line-height:32px;width:32px;height:32px;margin-top:-16px;margin-left:-16px;position:absolute;top:50%;left:50%;-webkit-animation:fooicon-spin-r 2s infinite linear;animation:fooicon-spin-r 2s infinite linear}table.footable>tbody>tr.footable-empty>td{vertical-align:middle;text-align:center;font-size:30px}table.footable>tbody>tr>td,table.footable>tbody>tr>th{display:none}table.footable>tbody>tr.footable-detail-row>td,table.footable>tbody>tr.footable-detail-row>th,table.footable>tbody>tr.footable-empty>td,table.footable>tbody>tr.footable-empty>th{display:table-cell}@-webkit-keyframes fooicon-spin-r{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fooicon-spin-r{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fooicon{position:relative;top:1px;display:inline-block;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fooicon:after,.fooicon:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.fooicon-loader:before{content:"\25CC"}.fooicon-plus:before{content:"\2b"}.fooicon-minus:before{content:"\2212"}.fooicon-search:before{content:"\2315"}.fooicon-remove:before{content:"\e014"}.fooicon-sort:before{content:"\21D5"}.fooicon-sort-asc:before{content:"\25B2"}.fooicon-sort-desc:before{content:"\25BC"}.fooicon-pencil:before{content:"\270f"}.fooicon-trash:before{content:"\1F5D1"}.fooicon-eye-close:before{content:"\e106"}.fooicon-flash:before{content:"\1F5F2"}.fooicon-cog:before{content:"\2699"}.fooicon-stats:before{content:"\e185"}table.footable>thead>tr.footable-filtering>th{border-bottom-width:1px;font-weight:400}.footable-filtering-external.footable-filtering-right,table.footable.footable-filtering-right>thead>tr.footable-filtering>th,table.footable>thead>tr.footable-filtering>th{text-align:right}.footable-filtering-external.footable-filtering-left,table.footable.footable-filtering-left>thead>tr.footable-filtering>th{text-align:left}.footable-filtering-external.footable-filtering-center,.footable-paging-external.footable-paging-center,table.footable-paging-center>tfoot>tr.footable-paging>td,table.footable.footable-filtering-center>thead>tr.footable-filtering>th,table.footable>tfoot>tr.footable-paging>td{text-align:center}table.footable>thead>tr.footable-filtering>th div.form-group+div.form-group{margin-top:5px}table.footable>thead>tr.footable-filtering>th div.input-group{width:100%}.footable-filtering-external ul.dropdown-menu>li>a.checkbox,table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox{margin:0;display:block;position:relative}.footable-filtering-external ul.dropdown-menu>li>a.checkbox>label,table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox>label{display:block;padding-left:20px}.footable-filtering-external ul.dropdown-menu>li>a.checkbox input[type=checkbox],table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox input[type=checkbox]{position:absolute;margin-left:-20px}@media (min-width:768px){table.footable>thead>tr.footable-filtering>th div.input-group{width:auto}table.footable>thead>tr.footable-filtering>th div.form-group{margin-left:2px;margin-right:2px}table.footable>thead>tr.footable-filtering>th div.form-group+div.form-group{margin-top:0}}table.footable>tbody>tr>td.footable-sortable,table.footable>tbody>tr>th.footable-sortable,table.footable>tfoot>tr>td.footable-sortable,table.footable>tfoot>tr>th.footable-sortable,table.footable>thead>tr>td.footable-sortable,table.footable>thead>tr>th.footable-sortable{position:relative;padding-right:30px;cursor:pointer}td.footable-sortable>span.fooicon,th.footable-sortable>span.fooicon{position:absolute;right:6px;top:50%;margin-top:-7px;opacity:0;transition:opacity .3s ease-in}td.footable-sortable.footable-asc>span.fooicon,td.footable-sortable.footable-desc>span.fooicon,td.footable-sortable:hover>span.fooicon,th.footable-sortable.footable-asc>span.fooicon,th.footable-sortable.footable-desc>span.fooicon,th.footable-sortable:hover>span.fooicon{opacity:1}table.footable-sorting-disabled td.footable-sortable.footable-asc>span.fooicon,table.footable-sorting-disabled td.footable-sortable.footable-desc>span.fooicon,table.footable-sorting-disabled td.footable-sortable:hover>span.fooicon,table.footable-sorting-disabled th.footable-sortable.footable-asc>span.fooicon,table.footable-sorting-disabled th.footable-sortable.footable-desc>span.fooicon,table.footable-sorting-disabled th.footable-sortable:hover>span.fooicon{opacity:0;visibility:hidden}.footable-paging-external ul.pagination,table.footable>tfoot>tr.footable-paging>td>ul.pagination{margin:10px 0 0}.footable-paging-external span.label,table.footable>tfoot>tr.footable-paging>td>span.label{display:inline-block;margin:0 0 10px;padding:4px 10px}.footable-paging-external.footable-paging-left,table.footable-paging-left>tfoot>tr.footable-paging>td{text-align:left}.footable-paging-external.footable-paging-right,table.footable-editing-right td.footable-editing,table.footable-editing-right tr.footable-editing,table.footable-paging-right>tfoot>tr.footable-paging>td{text-align:right}ul.pagination>li.footable-page{display:none}ul.pagination>li.footable-page.visible{display:inline}td.footable-editing{width:90px;max-width:90px}table.footable-editing-no-delete td.footable-editing,table.footable-editing-no-edit td.footable-editing,table.footable-editing-no-view td.footable-editing{width:70px;max-width:70px}table.footable-editing-no-delete.footable-editing-no-view td.footable-editing,table.footable-editing-no-edit.footable-editing-no-delete td.footable-editing,table.footable-editing-no-edit.footable-editing-no-view td.footable-editing{width:50px;max-width:50px}table.footable-editing-no-edit.footable-editing-no-delete.footable-editing-no-view td.footable-editing,table.footable-editing-no-edit.footable-editing-no-delete.footable-editing-no-view th.footable-editing{width:0;max-width:0;display:none!important}table.footable-editing-left td.footable-editing,table.footable-editing-left tr.footable-editing{text-align:left}table.footable-editing button.footable-add,table.footable-editing button.footable-hide,table.footable-editing-show button.footable-show,table.footable-editing.footable-editing-always-show button.footable-hide,table.footable-editing.footable-editing-always-show button.footable-show,table.footable-editing.footable-editing-always-show.footable-editing-no-add tr.footable-editing{display:none}table.footable-editing.footable-editing-always-show button.footable-add,table.footable-editing.footable-editing-show button.footable-add,table.footable-editing.footable-editing-show button.footable-hide{display:inline-block} 2 | -------------------------------------------------------------------------------- /backend/vendor/ProgressBar.php: -------------------------------------------------------------------------------- 1 | "\r:message::padding:%.01f%% %2\$d/%3\$d ETC: %4\$s. Elapsed: %5\$s [%6\$s]", 'message' => 'Running', 'size' => 30, 'width' => null]; 30 | 31 | /** 32 | * Runtime options 33 | */ 34 | protected static $options = []; 35 | 36 | /** 37 | * How much have we done already 38 | */ 39 | protected static $done = 0; 40 | 41 | /** 42 | * The format string used for the rendered status bar - see $defaults 43 | */ 44 | protected static $format; 45 | 46 | /** 47 | * message to display prefixing the progress bar text 48 | */ 49 | protected static $message; 50 | 51 | /** 52 | * How many chars to use for the progress bar itself. Not to be confused with $width 53 | */ 54 | protected static $size = 30; 55 | 56 | /** 57 | * When did we start (timestamp) 58 | */ 59 | protected static $start; 60 | 61 | /** 62 | * The width in characters the whole rendered string must fit in. defaults to the width of the 63 | * terminal window 64 | */ 65 | protected static $width; 66 | 67 | /** 68 | * What's the total number of times we're going to call set 69 | */ 70 | protected static $total; 71 | 72 | /** 73 | * Show a progress bar, actually not usually called explicitly. Called by next() 74 | * 75 | * @param int $done what fraction of $total to set as progress uses internal counter if not passed 76 | * 77 | * @static 78 | * @return string the formatted progress bar prefixed with a carriage return 79 | */ 80 | public static function display($done = null) 81 | { 82 | if ($done) { 83 | self::$done = $done; 84 | } 85 | 86 | $now = time(); 87 | 88 | if (self::$total) { 89 | $fractionComplete = (double) (self::$done / self::$total); 90 | } else { 91 | $fractionComplete = 0; 92 | } 93 | 94 | $bar = floor($fractionComplete * self::$size); 95 | $barSize = min($bar, self::$size); 96 | 97 | $barContents = str_repeat('=', $barSize); 98 | if ($bar < self::$size) { 99 | $barContents .= '>'; 100 | $barContents .= str_repeat(' ', self::$size - $barSize); 101 | } elseif ($fractionComplete > 1) { 102 | $barContents .= '!'; 103 | } else { 104 | $barContents .= '='; 105 | } 106 | 107 | $percent = number_format($fractionComplete * 100, 1); 108 | 109 | $elapsed = $now - self::$start; 110 | if (self::$done) { 111 | $rate = $elapsed / self::$done; 112 | } else { 113 | $rate = 0; 114 | } 115 | $left = self::$total - self::$done; 116 | $etc = round($rate * $left, 2); 117 | 118 | if (self::$done) { 119 | $etcNowText = '< 1 sec'; 120 | } else { 121 | $etcNowText = '???'; 122 | } 123 | $timeRemaining = self::humanTime($etc, $etcNowText); 124 | $timeElapsed = self::humanTime($elapsed); 125 | 126 | $return = sprintf( 127 | self::$format, 128 | $percent, 129 | self::$done, 130 | self::$total, 131 | $timeRemaining, 132 | $timeElapsed, 133 | $barContents 134 | ); 135 | 136 | $width = strlen(preg_replace('@(?:\r|:\w+:)@', '', $return)); 137 | 138 | if (strlen((string) self::$message) > ((int)self::$width - (int)$width - 3)) { 139 | $message = substr((string) self::$message, 0, ((int)self::$width - (int)$width - 4)) . '...'; 140 | $padding = ''; 141 | } else { 142 | $message = self::$message; 143 | $width += strlen((string) $message); 144 | $padding = str_repeat(' ', ((int)self::$width - (int)$width)); 145 | } 146 | 147 | $return = str_replace(':message:', $message, $return); 148 | $return = str_replace(':padding:', $padding, $return); 149 | 150 | return $return; 151 | } 152 | 153 | /** 154 | * reset internal state, and send a new line so that the progress bar text is "finished" 155 | * 156 | * @static 157 | * @return string a new line 158 | */ 159 | public static function finish() 160 | { 161 | self::reset(); 162 | return "\n"; 163 | } 164 | 165 | /** 166 | * Increment the internal counter, and returns the result of display 167 | * 168 | * @param int $inc Amount to increment the internal counter 169 | * @param string $message If passed, overrides the existing message 170 | * 171 | * @static 172 | * @return string - the progress bar 173 | */ 174 | public static function next($inc = 1, $message = '') 175 | { 176 | self::$done += $inc; 177 | 178 | if ($message) { 179 | self::$message = $message; 180 | } 181 | 182 | return self::display(); 183 | } 184 | 185 | /** 186 | * Called by start and finish 187 | * 188 | * @param array $options array 189 | * 190 | * @static 191 | * @return void 192 | */ 193 | public static function reset(array $options = []) 194 | { 195 | $options = array_merge(self::$defaults, $options); 196 | 197 | if (empty($options['done'])) { 198 | $options['done'] = 0; 199 | } 200 | if (empty($options['start'])) { 201 | $options['start'] = time(); 202 | } 203 | if (empty($options['total'])) { 204 | $options['total'] = 0; 205 | } 206 | 207 | self::$done = $options['done']; 208 | self::$format = $options['format']; 209 | self::$message = $options['message']; 210 | self::$size = $options['size']; 211 | self::$start = $options['start']; 212 | self::$total = $options['total']; 213 | self::setWidth($options['width']); 214 | } 215 | 216 | /** 217 | * change the message to be used the next time the display method is called 218 | * 219 | * @param string $message the string to display 220 | * 221 | * @static 222 | * @return void 223 | */ 224 | public static function setMessage($message = '') 225 | { 226 | self::$message = $message; 227 | } 228 | 229 | /** 230 | * change the total on a running progress bar 231 | * 232 | * @param int|string $total the new number of times we're expecting to run for 233 | * 234 | * @static 235 | * @return void 236 | */ 237 | public static function setTotal($total = '') 238 | { 239 | self::$total = $total; 240 | } 241 | 242 | /** 243 | * Initialize a progress bar 244 | * 245 | * @param int|null $total number of times we're going to call set 246 | * @param string $message message to prefix the bar with 247 | * @param array $options overrides for default options 248 | * 249 | * @static 250 | * @return string - the progress bar string with 0 progress 251 | */ 252 | public static function start(?int $total = null, string $message = '', array $options = []) 253 | { 254 | if ($message) { 255 | $options['message'] = $message; 256 | } 257 | $options['total'] = $total; 258 | $options['start'] = time(); 259 | self::reset($options); 260 | 261 | return self::display(); 262 | } 263 | 264 | /** 265 | * Convert a number of seconds into something human readable like "2 days, 4 hrs" 266 | * 267 | * @param int|float $seconds how far in the future/past to display 268 | * @param string $nowText if there are no seconds, what text to display 269 | * 270 | * @static 271 | * @return string representation of the time 272 | */ 273 | protected static function humanTime($seconds, string $nowText = '< 1 sec') 274 | { 275 | $prefix = ''; 276 | if ($seconds < 0) { 277 | $prefix = '- '; 278 | $seconds = -$seconds; 279 | } 280 | 281 | $days = $hours = $minutes = 0; 282 | 283 | if ($seconds >= 86400) { 284 | $days = (int) ($seconds / 86400); 285 | $seconds = $seconds - $days * 86400; 286 | } 287 | if ($seconds >= 3600) { 288 | $hours = (int) ($seconds / 3600); 289 | $seconds = $seconds - $hours * 3600; 290 | } 291 | if ($seconds >= 60) { 292 | $minutes = (int) ($seconds / 60); 293 | $seconds = $seconds - $minutes * 60; 294 | } 295 | $seconds = (int) $seconds; 296 | 297 | $return = []; 298 | 299 | if ($days) { 300 | $return[] = "$days days"; 301 | } 302 | if ($hours) { 303 | $return[] = "$hours hrs"; 304 | } 305 | if ($minutes) { 306 | $return[] = "$minutes mins"; 307 | } 308 | if ($seconds) { 309 | $return[] = "$seconds secs"; 310 | } 311 | 312 | if (!$return) { 313 | return $nowText; 314 | } 315 | return $prefix . implode( ', ', array_slice($return, 0, 2)); 316 | } 317 | 318 | /** 319 | * Set the width the rendered text must fit in 320 | * 321 | * @param int $width passed in options 322 | * 323 | * @static 324 | * @return void 325 | */ 326 | protected static function setWidth($width = null) 327 | { 328 | if ($width === null) { 329 | if (DIRECTORY_SEPARATOR === '/' && getenv("TERM")) { 330 | $width = `tput cols`; 331 | } 332 | if ($width < 80) { 333 | $width = 80; 334 | } 335 | } 336 | self::$width = $width; 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /backend/processor/Nfdump.php: -------------------------------------------------------------------------------- 1 | [], 13 | 'option' => [], 14 | 'format' => null, 15 | 'filter' => [], 16 | ]; 17 | private array $clean; 18 | private readonly Debug $d; 19 | 20 | public function __construct() { 21 | $this->d = Debug::getInstance(); 22 | $this->clean = $this->cfg; 23 | $this->reset(); 24 | } 25 | 26 | public static function getInstance(): self { 27 | if (!self::$_instance instanceof self) { 28 | self::$_instance = new self(); 29 | } 30 | 31 | return self::$_instance; 32 | } 33 | 34 | /** 35 | * Sets an option's value. 36 | * 37 | * @param mixed $value 38 | */ 39 | public function setOption(string $option, $value): void { 40 | switch ($option) { 41 | case '-M': // set sources 42 | // only sources specified in settings allowed 43 | $queried_sources = explode(':', (string) $value); 44 | foreach ($queried_sources as $s) { 45 | if (!\in_array($s, Config::$cfg['general']['sources'], true)) { 46 | continue; 47 | } 48 | $this->cfg['env']['sources'][] = $s; 49 | } 50 | 51 | // cancel if no sources remain 52 | if (empty($this->cfg['env']['sources'])) { 53 | break; 54 | } 55 | 56 | // set sources path 57 | $this->cfg['option'][$option] = implode(\DIRECTORY_SEPARATOR, [ 58 | $this->cfg['env']['profiles-data'], 59 | $this->cfg['env']['profile'], 60 | implode(':', $this->cfg['env']['sources']), 61 | ]); 62 | 63 | break; 64 | 65 | case '-R': // set path 66 | $this->cfg['option'][$option] = $this->convert_date_to_path($value[0], $value[1]); 67 | 68 | break; 69 | 70 | case '-o': // set output format 71 | $this->cfg['format'] = $value; 72 | 73 | break; 74 | 75 | default: 76 | $this->cfg['option'][$option] = $value; 77 | $this->cfg['option']['-o'] = 'csv'; // always get parsable data todo user-selectable? calculations bps/bpp/pps not in csv 78 | 79 | break; 80 | } 81 | } 82 | 83 | /** 84 | * Sets a filter's value. 85 | */ 86 | public function setFilter(string $filter): void { 87 | $this->cfg['filter'] = $filter; 88 | } 89 | 90 | /** 91 | * Executes the nfdump command, tries to throw an exception based on the return code. 92 | * 93 | * @throws \Exception 94 | */ 95 | public function execute(): array { 96 | $output = []; 97 | $processes = []; 98 | $return = ''; 99 | $timer = microtime(true); 100 | $filter = (empty($this->cfg['filter'])) ? '' : ' ' . escapeshellarg((string) $this->cfg['filter']); 101 | $command = $this->cfg['env']['bin'] . ' ' . $this->flatten($this->cfg['option']) . $filter . ' 2>&1'; 102 | $this->d->log('Trying to execute ' . $command, LOG_DEBUG); 103 | 104 | // check for already running nfdump processes 105 | // use pgrep if available, fallback to ps, or skip check if neither available 106 | $bin_name = basename($this->cfg['env']['bin']); 107 | $process_count = Misc::countProcessesByName($bin_name); 108 | 109 | if ($process_count > (int) Config::$cfg['nfdump']['max-processes']) { 110 | throw new \Exception('There already are ' . $process_count . ' processes of NfDump running!'); 111 | } 112 | 113 | // execute nfdump 114 | exec($command, $output, $return); 115 | 116 | // prevent logging the command usage description 117 | if (isset($output[0]) && stripos($output[0], 'usage') === 0) { 118 | $output = []; 119 | } 120 | 121 | switch ($return) { 122 | case 127: 123 | throw new \Exception('NfDump: Failed to start process. Is nfdump installed?
Output: ' . implode(' ', $output)); 124 | 125 | case 255: 126 | throw new \Exception('NfDump: Initialization failed. ' . $command . '
Output: ' . implode(' ', $output)); 127 | 128 | case 254: 129 | throw new \Exception('NfDump: Error in filter syntax.
Output: ' . implode(' ', $output)); 130 | 131 | case 250: 132 | throw new \Exception('NfDump: Internal error.
Output: ' . implode(' ', $output)); 133 | } 134 | 135 | // add command to output 136 | array_unshift($output, $command); 137 | 138 | // if last element contains a colon, it's not a csv 139 | if (str_contains($output[\count($output) - 1], ':')) { 140 | return $output; // return output if it is a flows/packets/bytes dump 141 | } 142 | 143 | // remove the 3 summary lines at the end of the csv output 144 | $output = \array_slice($output, 0, -3); 145 | 146 | // slice csv (only return the fields actually wanted) 147 | $field_ids_active = []; 148 | $parsed_header = false; 149 | $format = false; 150 | if (isset($this->cfg['format'])) { 151 | $format = $this->get_output_format($this->cfg['format']); 152 | } 153 | 154 | foreach ($output as $i => &$line) { 155 | if ($i === 0) { 156 | continue; 157 | } // skip nfdump command 158 | $line = str_getcsv($line, ','); 159 | $temp_line = []; 160 | 161 | if (\count($line) === 1 || str_contains($line[0], 'limit') || str_contains($line[0], 'error')) { // probably an error message or warning. add to command 162 | $output[0] .= '
' . $line[0] . ''; 163 | unset($output[$i]); 164 | 165 | continue; 166 | } 167 | if (!\is_array($format)) { 168 | $format = $line; 169 | } // set first valid line as header if not already defined 170 | 171 | foreach ($line as $field_id => $field) { 172 | // heading has the field identifiers. fill $fields_active with all active fields 173 | if ($parsed_header === false) { 174 | if (\in_array($field, $format, true)) { 175 | $field_ids_active[array_search($field, $format, true)] = $field_id; 176 | } 177 | } 178 | 179 | // remove field if not in $fields_active 180 | if (\in_array($field_id, $field_ids_active, true)) { 181 | $temp_line[array_search($field_id, $field_ids_active, true)] = $field; 182 | } 183 | } 184 | 185 | $parsed_header = true; 186 | ksort($temp_line); 187 | $line = array_values($temp_line); 188 | } 189 | 190 | // add execution time to output 191 | $executionMsg = '
Execution time: ' . round(microtime(true) - $timer, 3) . ' seconds'; 192 | if (isset($output[0])) { 193 | $output[0] .= $executionMsg; 194 | } else { 195 | $output[] = $executionMsg; 196 | } 197 | 198 | return array_values($output); 199 | } 200 | 201 | /** 202 | * Reset config. 203 | */ 204 | public function reset(): void { 205 | $this->clean['env'] = [ 206 | 'bin' => Config::$cfg['nfdump']['binary'], 207 | 'profiles-data' => Config::$cfg['nfdump']['profiles-data'], 208 | 'profile' => Config::$cfg['nfdump']['profile'], 209 | 'sources' => [], 210 | ]; 211 | $this->cfg = $this->clean; 212 | } 213 | 214 | /** 215 | * Converts a time range to a nfcapd file range 216 | * Ensures that files actually exist. 217 | * 218 | * @throws \Exception 219 | */ 220 | public function convert_date_to_path(int $datestart, int $dateend): string { 221 | $start = new \DateTime(); 222 | $end = new \DateTime(); 223 | $start->setTimestamp((int) $datestart - ($datestart % 300)); 224 | $end->setTimestamp((int) $dateend - ($dateend % 300)); 225 | $filestart = $fileend = '-'; 226 | $filestartexists = false; 227 | $fileendexists = false; 228 | $sourcepath = $this->cfg['env']['profiles-data'] . \DIRECTORY_SEPARATOR . $this->cfg['env']['profile'] . \DIRECTORY_SEPARATOR; 229 | 230 | // if start file does not exist, increment by 5 minutes and try again 231 | while ($filestartexists === false) { 232 | if ($start >= $end) { 233 | break; 234 | } 235 | 236 | foreach ($this->cfg['env']['sources'] as $source) { 237 | if (file_exists($sourcepath . $source . \DIRECTORY_SEPARATOR . $filestart)) { 238 | $filestartexists = true; 239 | } 240 | } 241 | 242 | $pathstart = $start->format('Y/m/d') . \DIRECTORY_SEPARATOR; 243 | $filestart = $pathstart . 'nfcapd.' . $start->format('YmdHi'); 244 | $start->add(new \DateInterval('PT5M')); 245 | } 246 | 247 | // if end file does not exist, subtract by 5 minutes and try again 248 | while ($fileendexists === false) { 249 | if ($end === $start) { // strict comparison won't work 250 | $fileend = $filestart; 251 | 252 | break; 253 | } 254 | 255 | foreach ($this->cfg['env']['sources'] as $source) { 256 | if (file_exists($sourcepath . $source . \DIRECTORY_SEPARATOR . $fileend)) { 257 | $fileendexists = true; 258 | } 259 | } 260 | 261 | $pathend = $end->format('Y/m/d') . \DIRECTORY_SEPARATOR; 262 | $fileend = $pathend . 'nfcapd.' . $end->format('YmdHi'); 263 | $end->sub(new \DateInterval('PT5M')); 264 | } 265 | 266 | return $filestart . PATH_SEPARATOR . $fileend; 267 | } 268 | 269 | public function get_output_format($format): array { 270 | // todo calculations like bps/pps? flows? concatenate sa/sp to sap? 271 | return match ($format) { 272 | 'line' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'ipkt', 'ibyt', 'fl'], 273 | 'long' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'flg', 'stos', 'dtos', 'ipkt', 'ibyt', 'fl'], 274 | 'extended' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'ipkt', 'ibyt', 'ibps', 'ipps', 'ibpp'], 275 | 'full' => ['ts', 'te', 'td', 'sa', 'da', 'sp', 'dp', 'pr', 'flg', 'fwd', 'stos', 'ipkt', 'ibyt', 'opkt', 'obyt', 'in', 'out', 'sas', 'das', 'smk', 'dmk', 'dtos', 'dir', 'nh', 'nhb', 'svln', 'dvln', 'ismc', 'odmc', 'idmc', 'osmc', 'mpls1', 'mpls2', 'mpls3', 'mpls4', 'mpls5', 'mpls6', 'mpls7', 'mpls8', 'mpls9', 'mpls10', 'cl', 'sl', 'al', 'ra', 'eng', 'exid', 'tr'], 276 | default => explode(' ', str_replace(['fmt:', '%'], '', (string) $format)), 277 | }; 278 | } 279 | 280 | /** 281 | * Concatenates key and value of supplied array. 282 | */ 283 | private function flatten(array $array): string { 284 | $output = ''; 285 | 286 | foreach ($array as $key => $value) { 287 | if ($value === null) { 288 | $output .= $key . ' '; 289 | } else { 290 | $output .= \is_int($key) ?: $key . ' ' . escapeshellarg((string) $value) . ' '; 291 | } 292 | } 293 | 294 | return $output; 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /backend/api/Api.php: -------------------------------------------------------------------------------- 1 | error(503, $e->getMessage()); 22 | } 23 | 24 | // get the HTTP method, path and body of the request 25 | $this->method = $_SERVER['REQUEST_METHOD']; 26 | $this->request = explode('/', trim((string) $_GET['request'], '/')); 27 | 28 | // only allow GET requests 29 | // if at some time POST requests are enabled, check the request's content type (or return 406) 30 | if ($this->method !== 'GET') { 31 | $this->error(501); 32 | } 33 | 34 | // call correct method 35 | if (!method_exists($this, $this->request[0])) { 36 | $this->error(404); 37 | } 38 | 39 | // remove method name from $_REQUEST 40 | $_REQUEST = array_filter($_REQUEST, fn ($x) => $x !== $this->request[0]); 41 | 42 | $method = new \ReflectionMethod($this, $this->request[0]); 43 | 44 | // check number of parameters 45 | if ($method->getNumberOfRequiredParameters() > \count($_REQUEST)) { 46 | $this->error(400, 'Not enough parameters'); 47 | } 48 | 49 | $args = []; 50 | // iterate over each parameter 51 | foreach ($method->getParameters() as $arg) { 52 | if (!isset($_REQUEST[$arg->name])) { 53 | if ($arg->isOptional()) { 54 | continue; 55 | } 56 | $this->error(400, 'Expected parameter ' . $arg->name); 57 | } 58 | 59 | /** @var ?\ReflectionNamedType $namedType */ 60 | $namedType = $arg->getType(); 61 | if ($namedType === null) { 62 | continue; 63 | } 64 | 65 | // make sure the data types are correct 66 | switch ($namedType->getName()) { 67 | case 'int': 68 | if (!is_numeric($_REQUEST[$arg->name])) { 69 | $this->error(400, 'Expected type int for ' . $arg->name); 70 | } 71 | $args[$arg->name] = (int) $_REQUEST[$arg->name]; 72 | 73 | break; 74 | 75 | case 'array': 76 | if (!\is_array($_REQUEST[$arg->name])) { 77 | $this->error(400, 'Expected type array for ' . $arg->name); 78 | } 79 | $args[$arg->name] = $_REQUEST[$arg->name]; 80 | 81 | break; 82 | 83 | case 'string': 84 | if (!\is_string($_REQUEST[$arg->name])) { 85 | $this->error(400, 'Expected type string for ' . $arg->name); 86 | } 87 | $args[$arg->name] = $_REQUEST[$arg->name]; 88 | 89 | break; 90 | 91 | default: 92 | $args[$arg->name] = $_REQUEST[$arg->name]; 93 | } 94 | } 95 | 96 | // get output 97 | $output = $this->{$this->request[0]}(...array_values($args)); 98 | 99 | // return output 100 | if (\array_key_exists('csv', $_REQUEST)) { 101 | // output CSV 102 | header('Content-Type: text/csv; charset=utf-8'); 103 | header('Content-Disposition: attachment; filename=export.csv'); 104 | $return = fopen('php://output', 'w'); 105 | foreach ($output as $i => $line) { 106 | if ($i === 0) { 107 | continue; 108 | } // skip first line 109 | fputcsv($return, $line); 110 | } 111 | 112 | fclose($return); 113 | } else { 114 | // output JSON 115 | echo json_encode($output, JSON_THROW_ON_ERROR); 116 | } 117 | } 118 | 119 | /** 120 | * Helper function, returns the http status and exits the application. 121 | * 122 | * @throws \JsonException 123 | */ 124 | public function error(int $code, string $msg = ''): never { 125 | http_response_code($code); 126 | $debug = Debug::getInstance(); 127 | 128 | $response = ['code' => $code, 'error' => '']; 129 | 130 | switch ($code) { 131 | case 400: 132 | $response['error'] = '400 - Bad Request. ' . (empty($msg) ? 'Probably wrong or not enough arguments.' : $msg); 133 | $debug->log($response['error'], LOG_INFO); 134 | 135 | break; 136 | 137 | case 401: 138 | $response['error'] = '401 - Unauthorized. ' . $msg; 139 | $debug->log($response['error'], LOG_WARNING); 140 | 141 | break; 142 | 143 | case 403: 144 | $response['error'] = '403 - Forbidden. ' . $msg; 145 | $debug->log($response['error'], LOG_WARNING); 146 | 147 | break; 148 | 149 | case 404: 150 | $response['error'] = '404 - Not found. ' . $msg; 151 | $debug->log($response['error'], LOG_WARNING); 152 | 153 | break; 154 | 155 | case 501: 156 | $response['error'] = '501 - Method not implemented. ' . $msg; 157 | $debug->log($response['error'], LOG_WARNING); 158 | 159 | break; 160 | 161 | case 503: 162 | $response['error'] = '503 - Service unavailable. ' . $msg; 163 | $debug->log($response['error'], LOG_ERR); 164 | 165 | break; 166 | } 167 | echo json_encode($response, JSON_THROW_ON_ERROR); 168 | 169 | exit; 170 | } 171 | 172 | /** 173 | * Execute the processor to get statistics. 174 | * 175 | * @return array 176 | */ 177 | public function stats( 178 | int $datestart, 179 | int $dateend, 180 | array $sources, 181 | string $filter, 182 | int $top, 183 | string $for, 184 | string $limit, 185 | array $output = [], 186 | ) { 187 | $sources = implode(':', $sources); 188 | 189 | $processor = Config::$processorClass; 190 | $processor->setOption('-M', $sources); // multiple sources 191 | $processor->setOption('-R', [$datestart, $dateend]); // date range 192 | $processor->setOption('-n', $top); 193 | if (\array_key_exists('format', $output)) { 194 | $processor->setOption('-o', $output['format']); 195 | 196 | if ($output['format'] === 'custom' && \array_key_exists('custom', $output) && !empty($output['custom'])) { 197 | $processor->setOption('-o', 'fmt:' . $output['custom']); 198 | } 199 | } 200 | 201 | $processor->setOption('-s', $for); 202 | if (!empty($limit)) { 203 | $processor->setOption('-l', $limit); 204 | } // todo -L for traffic, -l for packets 205 | 206 | $processor->setFilter($filter); 207 | 208 | try { 209 | return $processor->execute(); 210 | } catch (\Exception $e) { 211 | $this->error(503, $e->getMessage()); 212 | } 213 | } 214 | 215 | /** 216 | * Execute the processor to get flows. 217 | * 218 | * @return array 219 | */ 220 | public function flows( 221 | int $datestart, 222 | int $dateend, 223 | array $sources, 224 | string $filter, 225 | int $limit, 226 | string $aggregate, 227 | string $sort, 228 | array $output, 229 | ) { 230 | $aggregate_command = ''; 231 | // nfdump -M /srv/nfsen/profiles-data/live/tiber:n048:gate:swibi:n055:swi6 -T -r 2017/04/10/nfcapd.201704101150 -c 20 232 | $sources = implode(':', $sources); 233 | if (!empty($aggregate)) { 234 | $aggregate_command = ($aggregate === 'bidirectional') ? '-B' : '-A' . $aggregate; 235 | } // no space inbetween 236 | 237 | $processor = new Config::$processorClass(); 238 | $processor->setOption('-M', $sources); // multiple sources 239 | $processor->setOption('-R', [$datestart, $dateend]); // date range 240 | $processor->setOption('-c', $limit); // limit 241 | $processor->setOption('-o', $output['format']); 242 | if ($output['format'] === 'custom' && \array_key_exists('custom', $output) && !empty($output['custom'])) { 243 | $processor->setOption('-o', 'fmt:' . $output['custom']); 244 | } 245 | 246 | if (!empty($sort)) { 247 | $processor->setOption('-O', 'tstart'); 248 | } 249 | if (!empty($aggregate_command)) { 250 | $processor->setOption('-a', $aggregate_command); 251 | } 252 | $processor->setFilter($filter); 253 | 254 | try { 255 | $return = $processor->execute(); 256 | } catch (\Exception $e) { 257 | $this->error(503, $e->getMessage()); 258 | } 259 | 260 | return $return; 261 | } 262 | 263 | /** 264 | * Get data to build a graph. 265 | * 266 | * @return array|string 267 | */ 268 | public function graph( 269 | int $datestart, 270 | int $dateend, 271 | array $sources, 272 | array $protocols, 273 | array $ports, 274 | string $type, 275 | string $display, 276 | ) { 277 | $graph = Config::$db->get_graph_data($datestart, $dateend, $sources, $protocols, $ports, $type, $display); 278 | if (!\is_array($graph)) { 279 | $this->error(400, $graph); 280 | } 281 | 282 | return $graph; 283 | } 284 | 285 | public function graph_stats(): void {} 286 | 287 | /** 288 | * Get config info. 289 | * 290 | * @return array 291 | */ 292 | public function config() { 293 | $sources = Config::$cfg['general']['sources']; 294 | $ports = Config::$cfg['general']['ports']; 295 | $frontend = Config::$cfg['frontend']; 296 | 297 | $stored_output_formats = Config::$cfg['general']['formats']; 298 | $stored_filters = Config::$cfg['general']['filters']; 299 | 300 | $folder = \dirname(__FILE__, 2); 301 | $pidfile = $folder . '/nfsen-ng.pid'; 302 | $daemon_running = file_exists($pidfile); 303 | 304 | // get date of first data point 305 | $firstDataPoint = PHP_INT_MAX; 306 | foreach ($sources as $source) { 307 | $firstDataPoint = min($firstDataPoint, Config::$db->date_boundaries($source)[0]); 308 | } 309 | $frontend['data_start'] = $firstDataPoint; 310 | 311 | return [ 312 | 'sources' => $sources, 313 | 'ports' => $ports, 314 | 'stored_output_formats' => $stored_output_formats, 315 | 'stored_filters' => $stored_filters, 316 | 'daemon_running' => $daemon_running, 317 | 'frontend' => $frontend, 318 | 'import_years' => (int) (getenv('NFSEN_IMPORT_YEARS') ?: 3), 319 | 'version' => Config::VERSION, 320 | 'tz_offset' => (new \DateTimeZone(date_default_timezone_get()))->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))) / 3600, 321 | ]; 322 | } 323 | 324 | /** 325 | * executes the host command with a timeout of 5 seconds. 326 | */ 327 | public function host(string $ip): string { 328 | try { 329 | // check ip format 330 | if (!filter_var($ip, FILTER_VALIDATE_IP)) { 331 | $this->error(400, 'Invalid IP address'); 332 | } 333 | 334 | exec('host -W 5 ' . $ip, $output, $return_var); 335 | if ($return_var !== 0) { 336 | $this->error(404, 'Host command failed'); 337 | } 338 | 339 | $output_domains = array_map( 340 | static fn ($line) => preg_match('/domain name pointer (.*)\./', $line, $matches) 341 | ? $matches[1] 342 | : null, 343 | $output 344 | ); 345 | $output_domains = array_filter($output_domains); 346 | if (empty($output_domains)) { 347 | $this->error(500, 'Could not parse host output: ' . implode(' ', $output)); 348 | } 349 | 350 | return implode(', ', $output_domains); 351 | } catch (\Exception $e) { 352 | $this->error(500, $e->getMessage()); 353 | } 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /backend/datasources/Rrd.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 33 | 34 | if (!\function_exists('rrd_version')) { 35 | throw new \Exception('Please install the PECL rrd library.'); 36 | } 37 | 38 | // Get import years from environment variable (default: 3) 39 | $this->importYears = (int) (getenv('NFSEN_IMPORT_YEARS') ?: 3); 40 | 41 | // Calculate layout based on import years 42 | // Structure maintains same resolution levels but extends daily samples 43 | $this->layout = [ 44 | '0.5:1:' . ((60 / (1 * 5)) * 24 * 45), // 45 days of 5 min samples 45 | '0.5:6:' . ((60 / (6 * 5)) * 24 * 90), // 90 days of 30 min samples 46 | '0.5:24:' . ((60 / (24 * 5)) * 24 * 365), // 365 days of 2 hour samples 47 | '0.5:288:' . $this->importYears * 365, // N years of daily samples (based on NFSEN_IMPORT_YEARS) 48 | ]; 49 | } 50 | 51 | /** 52 | * Gets the timestamps of the first and last entry of this specific source. 53 | */ 54 | public function date_boundaries(string $source): array { 55 | $rrdFile = $this->get_data_path($source); 56 | 57 | return [rrd_first($rrdFile), rrd_last($rrdFile)]; 58 | } 59 | 60 | /** 61 | * Gets the timestamp of the last update of this specific source. 62 | * 63 | * @return int timestamp or false 64 | */ 65 | public function last_update(string $source = '', int $port = 0): int { 66 | $rrdFile = $this->get_data_path($source, $port); 67 | $last_update = rrd_last($rrdFile); 68 | 69 | // $this->d->log('Last update of ' . $rrdFile . ': ' . date('d.m.Y H:i', $last_update), LOG_DEBUG); 70 | return (int) $last_update; 71 | } 72 | 73 | /** 74 | * Create a new RRD file for a source. 75 | * 76 | * @param string $source e.g. gateway or server_xyz 77 | * @param bool $reset overwrites existing RRD file if true 78 | */ 79 | public function create(string $source, int $port = 0, bool $reset = false): bool { 80 | $rrdFile = $this->get_data_path($source, $port); 81 | 82 | // check if folder exists 83 | if (!file_exists(\dirname($rrdFile))) { 84 | if (!mkdir($concurrentDirectory = \dirname($rrdFile), 0o755, true) && !is_dir($concurrentDirectory)) { 85 | throw new \RuntimeException(\sprintf('Directory "%s" was not created', $concurrentDirectory)); 86 | } 87 | } 88 | 89 | // check if folder has correct access rights 90 | if (!is_writable(\dirname($rrdFile))) { 91 | $this->d->log('Error creating ' . $rrdFile . ': Not writable', LOG_CRIT); 92 | 93 | return false; 94 | } 95 | // check if file already exists 96 | if (file_exists($rrdFile)) { 97 | if ($reset === true) { 98 | unlink($rrdFile); 99 | } else { 100 | $this->d->log('Error creating ' . $rrdFile . ': File already exists', LOG_ERR); 101 | 102 | return false; 103 | } 104 | } 105 | 106 | $start = strtotime('-' . $this->importYears . ' years'); 107 | $starttime = (int) $start - ($start % 300); 108 | 109 | $creator = new \RRDCreator($rrdFile, (string) $starttime, 60 * 5); 110 | foreach ($this->fields as $field) { 111 | $creator->addDataSource($field . ':ABSOLUTE:600:U:U'); 112 | } 113 | foreach ($this->layout as $rra) { 114 | $creator->addArchive('AVERAGE:' . $rra); 115 | $creator->addArchive('MAX:' . $rra); 116 | } 117 | 118 | $saved = $creator->save(); 119 | if ($saved === false) { 120 | $this->d->log('Error saving RRD data structure to ' . $rrdFile, LOG_ERR); 121 | } 122 | 123 | return $saved; 124 | } 125 | 126 | /** 127 | * Validate if an existing RRD file matches the expected structure. 128 | * Returns true if valid, false if invalid or doesn't exist. 129 | * 130 | * @param string $source e.g. gateway or server_xyz 131 | * @param int $port port number (0 for no port) 132 | * @param bool $showWarnings whether to display CLI warnings when validation fails 133 | * @param bool $quiet suppress all output 134 | * 135 | * @return array{valid: bool, message: string, expected_rows: null|int, actual_rows: null|int} 136 | */ 137 | public function validateStructure(string $source, int $port = 0, bool $showWarnings = false, bool $quiet = false): array { 138 | $rrdFile = $this->get_data_path($source, $port); 139 | 140 | if (!file_exists($rrdFile)) { 141 | return [ 142 | 'valid' => false, 143 | 'message' => 'RRD file does not exist', 144 | 'expected_rows' => null, 145 | 'actual_rows' => null, 146 | ]; 147 | } 148 | 149 | // Get RRD info 150 | $info = rrd_info($rrdFile); 151 | if ($info === false) { // @phpstan-ignore identical.alwaysFalse 152 | return [ 153 | 'valid' => false, 154 | 'message' => 'Could not read RRD file info: ' . rrd_error(), 155 | 'expected_rows' => null, 156 | 'actual_rows' => null, 157 | ]; 158 | } 159 | 160 | // Check the last RRA (daily samples) which contains the year-dependent data 161 | // RRAs are indexed as rra[0], rra[1], etc. We have 2 RRAs per layout entry (AVERAGE and MAX) 162 | // So for N layout entries, we have 2*N RRAs total. The last AVERAGE RRA is index (count($this->layout) - 1) * 2. 163 | $expectedDailyRows = $this->importYears * 365; 164 | $lastAverageRraIndex = (\count($this->layout) - 1) * 2; 165 | 166 | // Check if the RRA exists and get its rows 167 | $rraKey = "rra[{$lastAverageRraIndex}].rows"; 168 | if (!isset($info[$rraKey])) { 169 | return [ 170 | 'valid' => false, 171 | 'message' => 'Could not find expected RRA structure in RRD file', 172 | 'expected_rows' => $expectedDailyRows, 173 | 'actual_rows' => null, 174 | ]; 175 | } 176 | 177 | $actualRows = (int) $info[$rraKey]; 178 | 179 | if ($actualRows !== $expectedDailyRows) { 180 | $message = "RRD structure mismatch: Expected {$expectedDailyRows} daily rows (NFSEN_IMPORT_YEARS={$this->importYears}), but RRD has {$actualRows} rows"; 181 | 182 | // Display warning if requested and we're in CLI mode 183 | if ($showWarnings && \PHP_SAPI === 'cli') { 184 | $actualYears = round($actualRows / 365, 1); 185 | 186 | echo <<importYears} (~{$this->importYears} years) 193 | Existing RRD has: ~{$actualYears} years of storage ({$actualRows} daily rows) 194 | 195 | This mismatch can cause: 196 | • Data loss if new setting stores less data than before 197 | • Import errors or incomplete data coverage 198 | 199 | \033[1mTo fix this:\033[0m 200 | 1. Set NFSEN_FORCE_IMPORT=true to recreate RRD files, OR 201 | 2. Adjust NFSEN_IMPORT_YEARS to match existing structure (~{$actualYears}) 202 | 203 | Continuing import with existing structure... 204 | 205 | 206 | WARNING; 207 | } 208 | 209 | return [ 210 | 'valid' => false, 211 | 'message' => $message, 212 | 'expected_rows' => $expectedDailyRows, 213 | 'actual_rows' => $actualRows, 214 | ]; 215 | } 216 | 217 | // Display success message if requested 218 | if ($showWarnings && !$quiet && \PHP_SAPI === 'cli') { 219 | echo '✓ RRD structure is valid (configured for ' . $this->importYears . ' years)' . PHP_EOL; 220 | } 221 | 222 | return [ 223 | 'valid' => true, 224 | 'message' => 'RRD structure is valid', 225 | 'expected_rows' => $expectedDailyRows, 226 | 'actual_rows' => $actualRows, 227 | ]; 228 | } 229 | 230 | /** 231 | * Write to an RRD file with supplied data. 232 | * 233 | * @throws \Exception 234 | */ 235 | public function write(array $data): bool { 236 | $rrdFile = $this->get_data_path($data['source'], $data['port']); 237 | if (!file_exists($rrdFile)) { 238 | $this->create($data['source'], $data['port'], false); 239 | } 240 | 241 | $nearest = (int) $data['date_timestamp'] - ($data['date_timestamp'] % 300); 242 | $this->d->log('Writing to file ' . $rrdFile, LOG_DEBUG); 243 | 244 | // write data 245 | return (new \RRDUpdater($rrdFile))->update($data['fields'], (string) $nearest); 246 | } 247 | 248 | /** 249 | * @param string $type flows/packets/traffic 250 | * @param string $display protocols/sources/ports 251 | */ 252 | public function get_graph_data( 253 | int $start, 254 | int $end, 255 | array $sources, 256 | array $protocols, 257 | array $ports, 258 | #[ExpectedValues(['flows', 'packets', 'bytes', 'bits'])] 259 | string $type = 'flows', 260 | #[ExpectedValues(['protocols', 'sources', 'ports'])] 261 | string $display = 'sources', 262 | ): array|string { 263 | $options = [ 264 | '--start', 265 | $start - ($start % 300), 266 | '--end', 267 | $end - ($end % 300), 268 | '--maxrows', 269 | 300, 270 | // number of values. works like the width value (in pixels) in rrd_graph 271 | // '--step', 1200, // by default, rrdtool tries to get data for each row. if you want rrdtool to get data at a one-hour resolution, set step to 3600. 272 | '--json', 273 | ]; 274 | 275 | $useBits = false; 276 | if ($type === 'bits') { 277 | $type = 'bytes'; 278 | $useBits = true; 279 | } 280 | 281 | if (empty($protocols)) { 282 | $protocols = ['tcp', 'udp', 'icmp', 'other']; 283 | } 284 | if (empty($sources)) { 285 | $sources = Config::$cfg['general']['sources']; 286 | } 287 | if (empty($ports)) { 288 | $ports = Config::$cfg['general']['ports']; 289 | } 290 | 291 | switch ($display) { 292 | case 'protocols': 293 | foreach ($protocols as $protocol) { 294 | $rrdFile = $this->get_data_path($sources[0]); 295 | $proto = ($protocol === 'any') ? '' : '_' . $protocol; 296 | $legend = array_filter([$protocol, $type, $sources[0]]); 297 | $options[] = 'DEF:data' . $sources[0] . $protocol . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 298 | $options[] = 'XPORT:data' . $sources[0] . $protocol . ':' . implode('_', $legend); 299 | } 300 | 301 | break; 302 | 303 | case 'sources': 304 | foreach ($sources as $source) { 305 | $rrdFile = $this->get_data_path($source); 306 | $proto = ($protocols[0] === 'any') ? '' : '_' . $protocols[0]; 307 | $legend = array_filter([$source, $type, $protocols[0]]); 308 | $options[] = 'DEF:data' . $source . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 309 | $options[] = 'XPORT:data' . $source . ':' . implode('_', $legend); 310 | } 311 | 312 | break; 313 | 314 | case 'ports': 315 | foreach ($ports as $port) { 316 | $source = ($sources[0] === 'any') ? '' : $sources[0]; 317 | $proto = ($protocols[0] === 'any') ? '' : '_' . $protocols[0]; 318 | $legend = array_filter([$port, $type, $source, $protocols[0]]); 319 | $rrdFile = $this->get_data_path($source, $port); 320 | $options[] = 'DEF:data' . $source . $port . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 321 | $options[] = 'XPORT:data' . $source . $port . ':' . implode('_', $legend); 322 | } 323 | } 324 | 325 | ob_start(); 326 | $data = rrd_xport($options); 327 | $error = ob_get_clean(); // rrd_xport weirdly prints stuff on error 328 | 329 | if (!\is_array($data)) { // @phpstan-ignore-line function.alreadyNarrowedType (probably wrong rrd stubs) 330 | return $error . '. ' . rrd_error(); 331 | } 332 | 333 | // remove invalid numbers and create processable array 334 | $output = [ 335 | 'data' => [], 336 | 'start' => $data['start'], 337 | 'end' => $data['end'], 338 | 'step' => $data['step'], 339 | 'legend' => [], 340 | ]; 341 | foreach ($data['data'] as $source) { 342 | $output['legend'][] = $source['legend']; 343 | foreach ($source['data'] as $date => $measure) { 344 | // ignore non-valid measures 345 | if (is_nan($measure)) { 346 | $measure = null; 347 | } 348 | 349 | if ($type === 'bytes' && $useBits) { 350 | $measure *= 8; 351 | } 352 | 353 | // add measure to output array 354 | if (\array_key_exists($date, $output['data'])) { 355 | $output['data'][$date][] = $measure; 356 | } else { 357 | $output['data'][$date] = [$measure]; 358 | } 359 | } 360 | } 361 | 362 | return $output; 363 | } 364 | 365 | /** 366 | * Creates a new database for every source/port combination. 367 | */ 368 | public function reset(array $sources): bool { 369 | $return = false; 370 | if (empty($sources)) { 371 | $sources = Config::$cfg['general']['sources']; 372 | } 373 | $ports = Config::$cfg['general']['ports']; 374 | $ports[] = 0; 375 | foreach ($ports as $port) { 376 | if ($port !== 0) { 377 | $return = $this->create('', $port, true); 378 | } 379 | if ($return === false) { 380 | return false; 381 | } 382 | 383 | foreach ($sources as $source) { 384 | $return = $this->create($source, $port, true); 385 | if ($return === false) { 386 | return false; 387 | } 388 | } 389 | } 390 | 391 | return true; 392 | } 393 | 394 | /** 395 | * Concatenates the path to the source's rrd file. 396 | */ 397 | public function get_data_path(string $source = '', int $port = 0): string { 398 | if ($port === 0) { 399 | $port = ''; 400 | } else { 401 | $port = empty($source) ? $port : '_' . $port; 402 | } 403 | $path = Config::$path . \DIRECTORY_SEPARATOR . 'datasources' . \DIRECTORY_SEPARATOR . 'data' . \DIRECTORY_SEPARATOR . $source . $port . '.rrd'; 404 | 405 | if (!file_exists($path)) { 406 | $this->d->log('Was not able to find ' . $path, LOG_INFO); 407 | } 408 | 409 | return $path; 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /backend/common/Import.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 21 | $this->cli = (\PHP_SAPI === 'cli'); 22 | $this->d->setDebug($this->verbose); 23 | } 24 | 25 | /** 26 | * @throws \Exception 27 | */ 28 | public function start(\DateTime $dateStart): void { 29 | $sources = Config::$cfg['general']['sources']; 30 | $processedSources = 0; 31 | 32 | // Validate RRD structure before starting import (if using RRD datasource and not in force mode) 33 | if ($this->force === false && Config::$db instanceof Rrd && !empty($sources)) { 34 | if ($this->cli === true && $this->quiet === false) { 35 | echo PHP_EOL . 'Validating RRD structure...' . PHP_EOL; 36 | } 37 | Config::$db->validateStructure($sources[0], 0, true, $this->quiet); 38 | } 39 | 40 | // if in force mode, reset existing data 41 | if ($this->force === true) { 42 | if ($this->cli === true) { 43 | echo 'Resetting existing data...' . PHP_EOL; 44 | } 45 | Config::$db->reset([]); 46 | } 47 | 48 | // start progress bar (CLI only) 49 | $daysTotal = ((int) $dateStart->diff(new \DateTime())->format('%a') + 1) * \count($sources); 50 | if ($this->cli === true && $this->quiet === false) { 51 | echo PHP_EOL . ProgressBar::start($daysTotal, 'Processing ' . \count($sources) . ' sources...'); 52 | } 53 | 54 | // process each source, e.g. gateway, mailserver, etc. 55 | foreach ($sources as $nr => $source) { 56 | $sourcePath = Config::$cfg['nfdump']['profiles-data'] . \DIRECTORY_SEPARATOR . Config::$cfg['nfdump']['profile']; 57 | if (!file_exists($sourcePath)) { 58 | throw new \Exception('Could not read nfdump profile directory ' . $sourcePath); 59 | } 60 | if ($this->cli === true && $this->quiet === false) { 61 | echo PHP_EOL . 'Processing source ' . $source . ' (' . ($nr + 1) . '/' . \count($sources) . ')...' . PHP_EOL; 62 | } 63 | 64 | $date = clone $dateStart; 65 | 66 | // check if we want to continue a stopped import 67 | // assumes the last update of a source is similar to the last update of its ports... 68 | $lastUpdateDb = Config::$db->last_update($source); 69 | 70 | $lastUpdate = null; 71 | if ($lastUpdateDb > 0) { 72 | $lastUpdate = (new \DateTime())->setTimestamp($lastUpdateDb); 73 | } 74 | 75 | if ($this->force === false && isset($lastUpdate)) { 76 | $daysSaved = (int) $date->diff($lastUpdate)->format('%a'); 77 | $daysTotal -= $daysSaved; 78 | if ($this->quiet === false) { 79 | $this->d->log('Last update: ' . $lastUpdate->format('Y-m-d H:i'), LOG_INFO); 80 | } 81 | if ($this->cli === true && $this->quiet === false) { 82 | ProgressBar::setTotal($daysTotal); 83 | } 84 | 85 | // set progress to the date when the import was stopped 86 | $date->setTimestamp($lastUpdateDb); 87 | $date->setTimezone(new \DateTimeZone(date_default_timezone_get())); 88 | } 89 | 90 | // iterate from $datestart until today 91 | while ((int) $date->format('Ymd') <= (int) (new \DateTime())->format('Ymd')) { 92 | $scan = [$sourcePath, $source, $date->format('Y'), $date->format('m'), $date->format('d')]; 93 | $scanPath = implode(\DIRECTORY_SEPARATOR, $scan); 94 | 95 | // set date to tomorrow for next iteration 96 | $date->modify('+1 day'); 97 | 98 | // if no data exists for current date (e.g. .../2017/03/03) 99 | if (!file_exists($scanPath)) { 100 | $this->d->dpr($scanPath . ' does not exist!'); 101 | if ($this->cli === true && $this->quiet === false) { 102 | echo ProgressBar::next(1); 103 | } 104 | 105 | continue; 106 | } 107 | 108 | // scan path 109 | $this->d->log('Scanning path ' . $scanPath, LOG_INFO); 110 | $scanFiles = scandir($scanPath); 111 | 112 | if ($this->cli === true && $this->quiet === false) { 113 | echo ProgressBar::next(1, 'Scanning ' . $scanPath . '...'); 114 | } 115 | 116 | foreach ($scanFiles as $file) { 117 | if (\in_array($file, ['.', '..'], true)) { 118 | continue; 119 | } 120 | 121 | try { 122 | // parse date of file name to compare against last_update 123 | preg_match('/nfcapd\.([0-9]{12})$/', (string) $file, $fileDate); 124 | if (\count($fileDate) !== 2) { 125 | throw new \LengthException('Bad file name format of nfcapd file: ' . $file); 126 | } 127 | $fileDatetime = new \DateTime($fileDate[1]); 128 | } catch (\LengthException $e) { 129 | $this->d->log('Caught exception: ' . $e->getMessage(), LOG_DEBUG); 130 | 131 | continue; 132 | } 133 | 134 | // compare file name date with last update 135 | if ($fileDatetime <= $lastUpdate) { 136 | continue; 137 | } 138 | 139 | // let nfdump parse each nfcapd file 140 | $statsPath = implode(\DIRECTORY_SEPARATOR, \array_slice($scan, 2, 5)) . \DIRECTORY_SEPARATOR . $file; 141 | 142 | try { 143 | // fill source.rrd 144 | $this->writeSourceData($source, $statsPath); 145 | 146 | // write general port data (queries data for all sources, should only be executed when data for all sources exists...) 147 | if ($this->processPorts === true && $nr === \count($sources) - 1) { 148 | $this->writePortsData($statsPath); 149 | } 150 | 151 | // if enabled, process ports per source as well (source_80.rrd) 152 | if ($this->processPortsBySource === true) { 153 | $this->writePortsData($statsPath, $source); 154 | } 155 | } catch (\Exception $e) { 156 | $this->d->log('Caught exception: ' . $e->getMessage(), LOG_WARNING); 157 | } 158 | } 159 | } 160 | ++$processedSources; 161 | } 162 | if ($processedSources === 0) { 163 | $this->d->log('Import did not process any sources.', LOG_WARNING); 164 | } 165 | if ($this->cli === true && $this->quiet === false) { 166 | echo ProgressBar::finish(); 167 | } 168 | } 169 | 170 | /** 171 | * Import a single nfcapd file. 172 | */ 173 | public function importFile(string $file, string $source, bool $last): void { 174 | try { 175 | $this->d->log('Importing file ' . $file . ' (' . $source . '), last=' . (int) $last, LOG_INFO); 176 | 177 | // fill source.rrd 178 | $this->writeSourceData($source, $file); 179 | 180 | // write general port data (not depending on source, so only executed per port) 181 | if ($last === true) { 182 | $this->writePortsData($file); 183 | } 184 | 185 | // if enabled, process ports per source as well (source_80.rrd) 186 | if ($this->processPorts === true) { 187 | $this->writePortsData($file, $source); 188 | } 189 | } catch (\Exception $e) { 190 | $this->d->log('Caught exception: ' . $e->getMessage(), LOG_WARNING); 191 | } 192 | } 193 | 194 | /** 195 | * Check if db is free to update (some databases only allow inserting data at the end). 196 | * 197 | * @throws \Exception 198 | */ 199 | public function dbUpdatable(string $file, string $source = '', int $port = 0): bool { 200 | if ($this->checkLastUpdate === false) { 201 | return true; 202 | } 203 | 204 | // parse capture file's datetime. can't use filemtime as we need the datetime in the file name. 205 | $date = []; 206 | if (!preg_match('/nfcapd\.([0-9]{12})$/', $file, $date)) { 207 | return false; 208 | } // nothing to import 209 | 210 | $fileDatetime = new \DateTime($date[1]); 211 | 212 | // get last updated time from database 213 | $lastUpdateDb = Config::$db->last_update($source, $port); 214 | $lastUpdate = null; 215 | if ($lastUpdateDb !== 0) { 216 | $lastUpdate = new \DateTime(); 217 | $lastUpdate->setTimestamp($lastUpdateDb); 218 | } 219 | 220 | // prevent attempt to import the same file again 221 | return $fileDatetime > $lastUpdate; 222 | } 223 | 224 | public function setVerbose(bool $verbose): void { 225 | if ($verbose === true) { 226 | $this->d->setDebug(true); 227 | } 228 | $this->verbose = $verbose; 229 | } 230 | 231 | public function setProcessPorts(bool $processPorts): void { 232 | $this->processPorts = $processPorts; 233 | } 234 | 235 | public function setForce(bool $force): void { 236 | $this->force = $force; 237 | } 238 | 239 | public function setQuiet(bool $quiet): void { 240 | $this->quiet = $quiet; 241 | } 242 | 243 | public function setProcessPortsBySource($processPortsBySource): void { 244 | $this->processPortsBySource = $processPortsBySource; 245 | } 246 | 247 | public function setCheckLastUpdate(bool $checkLastUpdate): void { 248 | $this->checkLastUpdate = $checkLastUpdate; 249 | } 250 | 251 | /** 252 | * @throws \Exception 253 | */ 254 | private function writeSourceData(string $source, string $statsPath): bool { 255 | // set options and get netflow summary statistics (-I) 256 | $nfdump = Nfdump::getInstance(); 257 | $nfdump->reset(); 258 | $nfdump->setOption('-I', null); 259 | $nfdump->setOption('-M', $source); 260 | $nfdump->setOption('-r', $statsPath); 261 | 262 | if ($this->dbUpdatable($statsPath, $source) === false) { 263 | return false; 264 | } 265 | 266 | try { 267 | $input = $nfdump->execute(); 268 | } catch (\Exception $e) { 269 | $this->d->log('Exception: ' . $e->getMessage(), LOG_WARNING); 270 | 271 | return false; 272 | } 273 | 274 | $date = new \DateTime(substr($statsPath, -12)); 275 | $data = [ 276 | 'fields' => [], 277 | 'source' => $source, 278 | 'port' => 0, 279 | 'date_iso' => $date->format('Ymd\THis'), 280 | 'date_timestamp' => $date->getTimestamp(), 281 | ]; 282 | // $input data is an array of lines looking like this: 283 | // flows_tcp: 323829 284 | foreach ($input as $i => $line) { 285 | if (!\is_string($line)) { 286 | $this->d->log('Got no output of previous command', LOG_DEBUG); 287 | } 288 | if ($i === 0) { 289 | continue; 290 | } // skip nfdump command 291 | if (!preg_match('/:/', (string) $line)) { 292 | continue; 293 | } // skip invalid lines like error messages 294 | [$type, $value] = explode(': ', (string) $line); 295 | 296 | // we only need flows/packets/bytes values, the source and the timestamp 297 | if (preg_match('/^(flows|packets|bytes)/i', $type)) { 298 | $data['fields'][strtolower($type)] = (int) $value; 299 | } 300 | } 301 | 302 | // write to database 303 | if (Config::$db->write($data) === false) { 304 | throw new \Exception('Error writing to ' . $statsPath); 305 | } 306 | 307 | return true; 308 | } 309 | 310 | /** 311 | * @throws \Exception 312 | */ 313 | private function writePortsData(string $statsPath, string $source = ''): bool { 314 | $ports = Config::$cfg['general']['ports']; 315 | 316 | foreach ($ports as $port) { 317 | $this->writePortData($port, $statsPath, $source); 318 | } 319 | 320 | return true; 321 | } 322 | 323 | /** 324 | * @throws \Exception 325 | */ 326 | private function writePortData(int $port, string $statsPath, string $source = ''): bool { 327 | $sources = Config::$cfg['general']['sources']; 328 | 329 | // set options and get netflow statistics 330 | $nfdump = Nfdump::getInstance(); 331 | $nfdump->reset(); 332 | 333 | if (empty($source)) { 334 | // if no source is specified, get data for all sources 335 | $nfdump->setOption('-M', implode(':', $sources)); 336 | if ($this->dbUpdatable($statsPath, '', $port) === false) { 337 | return false; 338 | } 339 | } else { 340 | $nfdump->setOption('-M', $source); 341 | if ($this->dbUpdatable($statsPath, $source, $port) === false) { 342 | return false; 343 | } 344 | } 345 | 346 | $nfdump->setFilter('dst port ' . $port); 347 | $nfdump->setOption('-s', 'dstport:p'); 348 | $nfdump->setOption('-r', $statsPath); 349 | 350 | try { 351 | $input = $nfdump->execute(); 352 | } catch (\Exception $e) { 353 | $this->d->log('Exception: ' . $e->getMessage(), LOG_WARNING); 354 | 355 | return false; 356 | } 357 | 358 | // parse and turn into usable data 359 | 360 | $date = new \DateTime(substr($statsPath, -12)); 361 | $data = [ 362 | 'fields' => [ 363 | 'flows' => 0, 364 | 'packets' => 0, 365 | 'bytes' => 0, 366 | ], 367 | 'source' => $source, 368 | 'port' => $port, 369 | 'date_iso' => $date->format('Ymd\THis'), 370 | 'date_timestamp' => $date->getTimestamp(), 371 | ]; 372 | 373 | // process protocols 374 | // headers: ts,te,td,pr,val,fl,flP,ipkt,ipktP,ibyt,ibytP,ipps,ipbs,ibpp 375 | foreach ($input as $i => $line) { 376 | if (!\is_array($line) && $line instanceof \Countable === false) { 377 | continue; 378 | } // skip anything uncountable 379 | if (\count($line) !== 14) { 380 | continue; 381 | } // skip anything invalid 382 | if ($line[0] === 'ts') { 383 | continue; 384 | } // skip header 385 | 386 | $proto = strtolower((string) $line[3]); 387 | 388 | // add protocol-specific 389 | $data['fields']['flows_' . $proto] = (int) $line[5]; 390 | $data['fields']['packets_' . $proto] = (int) $line[7]; 391 | $data['fields']['bytes_' . $proto] = (int) $line[9]; 392 | 393 | // add to overall stats 394 | $data['fields']['flows'] += (int) $line[5]; 395 | $data['fields']['packets'] += (int) $line[7]; 396 | $data['fields']['bytes'] += (int) $line[9]; 397 | } 398 | 399 | // write to database 400 | if (Config::$db->write($data) === false) { 401 | throw new \Exception('Error writing to ' . $statsPath); 402 | } 403 | 404 | return true; 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /frontend/js/popper.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @popperjs/core v2.11.8 - MIT License 3 | */ 4 | 5 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Popper={})}(this,(function(e){"use strict";function t(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function n(e){return e instanceof t(e).Element||e instanceof Element}function r(e){return e instanceof t(e).HTMLElement||e instanceof HTMLElement}function o(e){return"undefined"!=typeof ShadowRoot&&(e instanceof t(e).ShadowRoot||e instanceof ShadowRoot)}var i=Math.max,a=Math.min,s=Math.round;function f(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function c(){return!/^((?!chrome|android).)*safari/i.test(f())}function p(e,o,i){void 0===o&&(o=!1),void 0===i&&(i=!1);var a=e.getBoundingClientRect(),f=1,p=1;o&&r(e)&&(f=e.offsetWidth>0&&s(a.width)/e.offsetWidth||1,p=e.offsetHeight>0&&s(a.height)/e.offsetHeight||1);var u=(n(e)?t(e):window).visualViewport,l=!c()&&i,d=(a.left+(l&&u?u.offsetLeft:0))/f,h=(a.top+(l&&u?u.offsetTop:0))/p,m=a.width/f,v=a.height/p;return{width:m,height:v,top:h,right:d+m,bottom:h+v,left:d,x:d,y:h}}function u(e){var n=t(e);return{scrollLeft:n.pageXOffset,scrollTop:n.pageYOffset}}function l(e){return e?(e.nodeName||"").toLowerCase():null}function d(e){return((n(e)?e.ownerDocument:e.document)||window.document).documentElement}function h(e){return p(d(e)).left+u(e).scrollLeft}function m(e){return t(e).getComputedStyle(e)}function v(e){var t=m(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function y(e,n,o){void 0===o&&(o=!1);var i,a,f=r(n),c=r(n)&&function(e){var t=e.getBoundingClientRect(),n=s(t.width)/e.offsetWidth||1,r=s(t.height)/e.offsetHeight||1;return 1!==n||1!==r}(n),m=d(n),y=p(e,c,o),g={scrollLeft:0,scrollTop:0},b={x:0,y:0};return(f||!f&&!o)&&(("body"!==l(n)||v(m))&&(g=(i=n)!==t(i)&&r(i)?{scrollLeft:(a=i).scrollLeft,scrollTop:a.scrollTop}:u(i)),r(n)?((b=p(n,!0)).x+=n.clientLeft,b.y+=n.clientTop):m&&(b.x=h(m))),{x:y.left+g.scrollLeft-b.x,y:y.top+g.scrollTop-b.y,width:y.width,height:y.height}}function g(e){var t=p(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function b(e){return"html"===l(e)?e:e.assignedSlot||e.parentNode||(o(e)?e.host:null)||d(e)}function x(e){return["html","body","#document"].indexOf(l(e))>=0?e.ownerDocument.body:r(e)&&v(e)?e:x(b(e))}function w(e,n){var r;void 0===n&&(n=[]);var o=x(e),i=o===(null==(r=e.ownerDocument)?void 0:r.body),a=t(o),s=i?[a].concat(a.visualViewport||[],v(o)?o:[]):o,f=n.concat(s);return i?f:f.concat(w(b(s)))}function O(e){return["table","td","th"].indexOf(l(e))>=0}function j(e){return r(e)&&"fixed"!==m(e).position?e.offsetParent:null}function E(e){for(var n=t(e),i=j(e);i&&O(i)&&"static"===m(i).position;)i=j(i);return i&&("html"===l(i)||"body"===l(i)&&"static"===m(i).position)?n:i||function(e){var t=/firefox/i.test(f());if(/Trident/i.test(f())&&r(e)&&"fixed"===m(e).position)return null;var n=b(e);for(o(n)&&(n=n.host);r(n)&&["html","body"].indexOf(l(n))<0;){var i=m(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||n}var D="top",A="bottom",L="right",P="left",M="auto",k=[D,A,L,P],W="start",B="end",H="viewport",T="popper",R=k.reduce((function(e,t){return e.concat([t+"-"+W,t+"-"+B])}),[]),S=[].concat(k,[M]).reduce((function(e,t){return e.concat([t,t+"-"+W,t+"-"+B])}),[]),V=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function q(e){var t=new Map,n=new Set,r=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var r=t.get(e);r&&o(r)}})),r.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),r}function C(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&o(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function N(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function I(e,r,o){return r===H?N(function(e,n){var r=t(e),o=d(e),i=r.visualViewport,a=o.clientWidth,s=o.clientHeight,f=0,p=0;if(i){a=i.width,s=i.height;var u=c();(u||!u&&"fixed"===n)&&(f=i.offsetLeft,p=i.offsetTop)}return{width:a,height:s,x:f+h(e),y:p}}(e,o)):n(r)?function(e,t){var n=p(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(r,o):N(function(e){var t,n=d(e),r=u(e),o=null==(t=e.ownerDocument)?void 0:t.body,a=i(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=i(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),f=-r.scrollLeft+h(e),c=-r.scrollTop;return"rtl"===m(o||n).direction&&(f+=i(n.clientWidth,o?o.clientWidth:0)-a),{width:a,height:s,x:f,y:c}}(d(e)))}function _(e,t,o,s){var f="clippingParents"===t?function(e){var t=w(b(e)),o=["absolute","fixed"].indexOf(m(e).position)>=0&&r(e)?E(e):e;return n(o)?t.filter((function(e){return n(e)&&C(e,o)&&"body"!==l(e)})):[]}(e):[].concat(t),c=[].concat(f,[o]),p=c[0],u=c.reduce((function(t,n){var r=I(e,n,s);return t.top=i(r.top,t.top),t.right=a(r.right,t.right),t.bottom=a(r.bottom,t.bottom),t.left=i(r.left,t.left),t}),I(e,p,s));return u.width=u.right-u.left,u.height=u.bottom-u.top,u.x=u.left,u.y=u.top,u}function F(e){return e.split("-")[0]}function U(e){return e.split("-")[1]}function z(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function X(e){var t,n=e.reference,r=e.element,o=e.placement,i=o?F(o):null,a=o?U(o):null,s=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2;switch(i){case D:t={x:s,y:n.y-r.height};break;case A:t={x:s,y:n.y+n.height};break;case L:t={x:n.x+n.width,y:f};break;case P:t={x:n.x-r.width,y:f};break;default:t={x:n.x,y:n.y}}var c=i?z(i):null;if(null!=c){var p="y"===c?"height":"width";switch(a){case W:t[c]=t[c]-(n[p]/2-r[p]/2);break;case B:t[c]=t[c]+(n[p]/2-r[p]/2)}}return t}function Y(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function G(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function J(e,t){void 0===t&&(t={});var r=t,o=r.placement,i=void 0===o?e.placement:o,a=r.strategy,s=void 0===a?e.strategy:a,f=r.boundary,c=void 0===f?"clippingParents":f,u=r.rootBoundary,l=void 0===u?H:u,h=r.elementContext,m=void 0===h?T:h,v=r.altBoundary,y=void 0!==v&&v,g=r.padding,b=void 0===g?0:g,x=Y("number"!=typeof b?b:G(b,k)),w=m===T?"reference":T,O=e.rects.popper,j=e.elements[y?w:m],E=_(n(j)?j:j.contextElement||d(e.elements.popper),c,l,s),P=p(e.elements.reference),M=X({reference:P,element:O,strategy:"absolute",placement:i}),W=N(Object.assign({},O,M)),B=m===T?W:P,R={top:E.top-B.top+x.top,bottom:B.bottom-E.bottom+x.bottom,left:E.left-B.left+x.left,right:B.right-E.right+x.right},S=e.modifiersData.offset;if(m===T&&S){var V=S[i];Object.keys(R).forEach((function(e){var t=[L,A].indexOf(e)>=0?1:-1,n=[D,A].indexOf(e)>=0?"y":"x";R[e]+=V[n]*t}))}return R}var K={placement:"bottom",modifiers:[],strategy:"absolute"};function Q(){for(var e=arguments.length,t=new Array(e),n=0;n=0?-1:1,i="function"==typeof n?n(Object.assign({},t,{placement:e})):n,a=i[0],s=i[1];return a=a||0,s=(s||0)*o,[P,L].indexOf(r)>=0?{x:s,y:a}:{x:a,y:s}}(n,t.rects,i),e}),{}),s=a[t.placement],f=s.x,c=s.y;null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=f,t.modifiersData.popperOffsets.y+=c),t.modifiersData[r]=a}},se={left:"right",right:"left",bottom:"top",top:"bottom"};function fe(e){return e.replace(/left|right|bottom|top/g,(function(e){return se[e]}))}var ce={start:"end",end:"start"};function pe(e){return e.replace(/start|end/g,(function(e){return ce[e]}))}function ue(e,t){void 0===t&&(t={});var n=t,r=n.placement,o=n.boundary,i=n.rootBoundary,a=n.padding,s=n.flipVariations,f=n.allowedAutoPlacements,c=void 0===f?S:f,p=U(r),u=p?s?R:R.filter((function(e){return U(e)===p})):k,l=u.filter((function(e){return c.indexOf(e)>=0}));0===l.length&&(l=u);var d=l.reduce((function(t,n){return t[n]=J(e,{placement:n,boundary:o,rootBoundary:i,padding:a})[F(n)],t}),{});return Object.keys(d).sort((function(e,t){return d[e]-d[t]}))}var le={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,i=void 0===o||o,a=n.altAxis,s=void 0===a||a,f=n.fallbackPlacements,c=n.padding,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.flipVariations,h=void 0===d||d,m=n.allowedAutoPlacements,v=t.options.placement,y=F(v),g=f||(y===v||!h?[fe(v)]:function(e){if(F(e)===M)return[];var t=fe(e);return[pe(e),t,pe(t)]}(v)),b=[v].concat(g).reduce((function(e,n){return e.concat(F(n)===M?ue(t,{placement:n,boundary:p,rootBoundary:u,padding:c,flipVariations:h,allowedAutoPlacements:m}):n)}),[]),x=t.rects.reference,w=t.rects.popper,O=new Map,j=!0,E=b[0],k=0;k=0,S=R?"width":"height",V=J(t,{placement:B,boundary:p,rootBoundary:u,altBoundary:l,padding:c}),q=R?T?L:P:T?A:D;x[S]>w[S]&&(q=fe(q));var C=fe(q),N=[];if(i&&N.push(V[H]<=0),s&&N.push(V[q]<=0,V[C]<=0),N.every((function(e){return e}))){E=B,j=!1;break}O.set(B,N)}if(j)for(var I=function(e){var t=b.find((function(t){var n=O.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return E=t,"break"},_=h?3:1;_>0;_--){if("break"===I(_))break}t.placement!==E&&(t.modifiersData[r]._skip=!0,t.placement=E,t.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function de(e,t,n){return i(e,a(t,n))}var he={name:"preventOverflow",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,s=void 0===o||o,f=n.altAxis,c=void 0!==f&&f,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.padding,h=n.tether,m=void 0===h||h,v=n.tetherOffset,y=void 0===v?0:v,b=J(t,{boundary:p,rootBoundary:u,padding:d,altBoundary:l}),x=F(t.placement),w=U(t.placement),O=!w,j=z(x),M="x"===j?"y":"x",k=t.modifiersData.popperOffsets,B=t.rects.reference,H=t.rects.popper,T="function"==typeof y?y(Object.assign({},t.rects,{placement:t.placement})):y,R="number"==typeof T?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),S=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,V={x:0,y:0};if(k){if(s){var q,C="y"===j?D:P,N="y"===j?A:L,I="y"===j?"height":"width",_=k[j],X=_+b[C],Y=_-b[N],G=m?-H[I]/2:0,K=w===W?B[I]:H[I],Q=w===W?-H[I]:-B[I],Z=t.elements.arrow,$=m&&Z?g(Z):{width:0,height:0},ee=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[C],ne=ee[N],re=de(0,B[I],$[I]),oe=O?B[I]/2-G-re-te-R.mainAxis:K-re-te-R.mainAxis,ie=O?-B[I]/2+G+re+ne+R.mainAxis:Q+re+ne+R.mainAxis,ae=t.elements.arrow&&E(t.elements.arrow),se=ae?"y"===j?ae.clientTop||0:ae.clientLeft||0:0,fe=null!=(q=null==S?void 0:S[j])?q:0,ce=_+ie-fe,pe=de(m?a(X,_+oe-fe-se):X,_,m?i(Y,ce):Y);k[j]=pe,V[j]=pe-_}if(c){var ue,le="x"===j?D:P,he="x"===j?A:L,me=k[M],ve="y"===M?"height":"width",ye=me+b[le],ge=me-b[he],be=-1!==[D,P].indexOf(x),xe=null!=(ue=null==S?void 0:S[M])?ue:0,we=be?ye:me-B[ve]-H[ve]-xe+R.altAxis,Oe=be?me+B[ve]+H[ve]-xe-R.altAxis:ge,je=m&&be?function(e,t,n){var r=de(e,t,n);return r>n?n:r}(we,me,Oe):de(m?we:ye,me,m?Oe:ge);k[M]=je,V[M]=je-me}t.modifiersData[r]=V}},requiresIfExists:["offset"]};var me={name:"arrow",enabled:!0,phase:"main",fn:function(e){var t,n=e.state,r=e.name,o=e.options,i=n.elements.arrow,a=n.modifiersData.popperOffsets,s=F(n.placement),f=z(s),c=[P,L].indexOf(s)>=0?"height":"width";if(i&&a){var p=function(e,t){return Y("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:G(e,k))}(o.padding,n),u=g(i),l="y"===f?D:P,d="y"===f?A:L,h=n.rects.reference[c]+n.rects.reference[f]-a[f]-n.rects.popper[c],m=a[f]-n.rects.reference[f],v=E(i),y=v?"y"===f?v.clientHeight||0:v.clientWidth||0:0,b=h/2-m/2,x=p[l],w=y-u[c]-p[d],O=y/2-u[c]/2+b,j=de(x,O,w),M=f;n.modifiersData[r]=((t={})[M]=j,t.centerOffset=j-O,t)}},effect:function(e){var t=e.state,n=e.options.element,r=void 0===n?"[data-popper-arrow]":n;null!=r&&("string"!=typeof r||(r=t.elements.popper.querySelector(r)))&&C(t.elements.popper,r)&&(t.elements.arrow=r)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ve(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(e){return[D,L,A,P].some((function(t){return e[t]>=0}))}var ge={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,i=t.modifiersData.preventOverflow,a=J(t,{elementContext:"reference"}),s=J(t,{altBoundary:!0}),f=ve(a,r),c=ve(s,o,i),p=ye(f),u=ye(c);t.modifiersData[n]={referenceClippingOffsets:f,popperEscapeOffsets:c,isReferenceHidden:p,hasPopperEscaped:u},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":p,"data-popper-escaped":u})}},be=Z({defaultModifiers:[ee,te,oe,ie]}),xe=[ee,te,oe,ie,ae,le,he,me,ge],we=Z({defaultModifiers:xe});e.applyStyles=ie,e.arrow=me,e.computeStyles=oe,e.createPopper=we,e.createPopperLite=be,e.defaultModifiers=xe,e.detectOverflow=J,e.eventListeners=ee,e.flip=le,e.hide=ge,e.offset=ae,e.popperGenerator=Z,e.popperOffsets=te,e.preventOverflow=he,Object.defineProperty(e,"__esModule",{value:!0})})); 6 | //# sourceMappingURL=popper.min.js.map 7 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | eslint: 12 | specifier: ^9.38.0 13 | version: 9.38.0 14 | prettier: 15 | specifier: ^3.6.2 16 | version: 3.6.2 17 | 18 | packages: 19 | 20 | '@eslint-community/eslint-utils@4.9.0': 21 | resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} 22 | engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 23 | peerDependencies: 24 | eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 25 | 26 | '@eslint-community/regexpp@4.12.1': 27 | resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} 28 | engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} 29 | 30 | '@eslint/config-array@0.21.1': 31 | resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} 32 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 33 | 34 | '@eslint/config-helpers@0.4.1': 35 | resolution: {integrity: sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==} 36 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 37 | 38 | '@eslint/core@0.16.0': 39 | resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} 40 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 41 | 42 | '@eslint/eslintrc@3.3.1': 43 | resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} 44 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 45 | 46 | '@eslint/js@9.38.0': 47 | resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} 48 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 49 | 50 | '@eslint/object-schema@2.1.7': 51 | resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} 52 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 53 | 54 | '@eslint/plugin-kit@0.4.0': 55 | resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} 56 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 57 | 58 | '@humanfs/core@0.19.1': 59 | resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} 60 | engines: {node: '>=18.18.0'} 61 | 62 | '@humanfs/node@0.16.7': 63 | resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} 64 | engines: {node: '>=18.18.0'} 65 | 66 | '@humanwhocodes/module-importer@1.0.1': 67 | resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} 68 | engines: {node: '>=12.22'} 69 | 70 | '@humanwhocodes/retry@0.4.3': 71 | resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} 72 | engines: {node: '>=18.18'} 73 | 74 | '@types/estree@1.0.8': 75 | resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 76 | 77 | '@types/json-schema@7.0.15': 78 | resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 79 | 80 | acorn-jsx@5.3.2: 81 | resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} 82 | peerDependencies: 83 | acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 84 | 85 | acorn@8.15.0: 86 | resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 87 | engines: {node: '>=0.4.0'} 88 | hasBin: true 89 | 90 | ajv@6.12.6: 91 | resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} 92 | 93 | ansi-styles@4.3.0: 94 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 95 | engines: {node: '>=8'} 96 | 97 | argparse@2.0.1: 98 | resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 99 | 100 | balanced-match@1.0.2: 101 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 102 | 103 | brace-expansion@1.1.12: 104 | resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} 105 | 106 | callsites@3.1.0: 107 | resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} 108 | engines: {node: '>=6'} 109 | 110 | chalk@4.1.2: 111 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 112 | engines: {node: '>=10'} 113 | 114 | color-convert@2.0.1: 115 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 116 | engines: {node: '>=7.0.0'} 117 | 118 | color-name@1.1.4: 119 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 120 | 121 | concat-map@0.0.1: 122 | resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} 123 | 124 | cross-spawn@7.0.6: 125 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 126 | engines: {node: '>= 8'} 127 | 128 | debug@4.4.3: 129 | resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 130 | engines: {node: '>=6.0'} 131 | peerDependencies: 132 | supports-color: '*' 133 | peerDependenciesMeta: 134 | supports-color: 135 | optional: true 136 | 137 | deep-is@0.1.4: 138 | resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} 139 | 140 | escape-string-regexp@4.0.0: 141 | resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} 142 | engines: {node: '>=10'} 143 | 144 | eslint-scope@8.4.0: 145 | resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} 146 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 147 | 148 | eslint-visitor-keys@3.4.3: 149 | resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} 150 | engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 151 | 152 | eslint-visitor-keys@4.2.1: 153 | resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} 154 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 155 | 156 | eslint@9.38.0: 157 | resolution: {integrity: sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==} 158 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 159 | hasBin: true 160 | peerDependencies: 161 | jiti: '*' 162 | peerDependenciesMeta: 163 | jiti: 164 | optional: true 165 | 166 | espree@10.4.0: 167 | resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} 168 | engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 169 | 170 | esquery@1.6.0: 171 | resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} 172 | engines: {node: '>=0.10'} 173 | 174 | esrecurse@4.3.0: 175 | resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} 176 | engines: {node: '>=4.0'} 177 | 178 | estraverse@5.3.0: 179 | resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} 180 | engines: {node: '>=4.0'} 181 | 182 | esutils@2.0.3: 183 | resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} 184 | engines: {node: '>=0.10.0'} 185 | 186 | fast-deep-equal@3.1.3: 187 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 188 | 189 | fast-json-stable-stringify@2.1.0: 190 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} 191 | 192 | fast-levenshtein@2.0.6: 193 | resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} 194 | 195 | file-entry-cache@8.0.0: 196 | resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} 197 | engines: {node: '>=16.0.0'} 198 | 199 | find-up@5.0.0: 200 | resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} 201 | engines: {node: '>=10'} 202 | 203 | flat-cache@4.0.1: 204 | resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} 205 | engines: {node: '>=16'} 206 | 207 | flatted@3.3.3: 208 | resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} 209 | 210 | glob-parent@6.0.2: 211 | resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} 212 | engines: {node: '>=10.13.0'} 213 | 214 | globals@14.0.0: 215 | resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} 216 | engines: {node: '>=18'} 217 | 218 | has-flag@4.0.0: 219 | resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 220 | engines: {node: '>=8'} 221 | 222 | ignore@5.3.2: 223 | resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} 224 | engines: {node: '>= 4'} 225 | 226 | import-fresh@3.3.1: 227 | resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} 228 | engines: {node: '>=6'} 229 | 230 | imurmurhash@0.1.4: 231 | resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} 232 | engines: {node: '>=0.8.19'} 233 | 234 | is-extglob@2.1.1: 235 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 236 | engines: {node: '>=0.10.0'} 237 | 238 | is-glob@4.0.3: 239 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 240 | engines: {node: '>=0.10.0'} 241 | 242 | isexe@2.0.0: 243 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 244 | 245 | js-yaml@4.1.0: 246 | resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} 247 | hasBin: true 248 | 249 | json-buffer@3.0.1: 250 | resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} 251 | 252 | json-schema-traverse@0.4.1: 253 | resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} 254 | 255 | json-stable-stringify-without-jsonify@1.0.1: 256 | resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} 257 | 258 | keyv@4.5.4: 259 | resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} 260 | 261 | levn@0.4.1: 262 | resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} 263 | engines: {node: '>= 0.8.0'} 264 | 265 | locate-path@6.0.0: 266 | resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} 267 | engines: {node: '>=10'} 268 | 269 | lodash.merge@4.6.2: 270 | resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 271 | 272 | minimatch@3.1.2: 273 | resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} 274 | 275 | ms@2.1.3: 276 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 277 | 278 | natural-compare@1.4.0: 279 | resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 280 | 281 | optionator@0.9.4: 282 | resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} 283 | engines: {node: '>= 0.8.0'} 284 | 285 | p-limit@3.1.0: 286 | resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} 287 | engines: {node: '>=10'} 288 | 289 | p-locate@5.0.0: 290 | resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} 291 | engines: {node: '>=10'} 292 | 293 | parent-module@1.0.1: 294 | resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} 295 | engines: {node: '>=6'} 296 | 297 | path-exists@4.0.0: 298 | resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 299 | engines: {node: '>=8'} 300 | 301 | path-key@3.1.1: 302 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 303 | engines: {node: '>=8'} 304 | 305 | prelude-ls@1.2.1: 306 | resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} 307 | engines: {node: '>= 0.8.0'} 308 | 309 | prettier@3.6.2: 310 | resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} 311 | engines: {node: '>=14'} 312 | hasBin: true 313 | 314 | punycode@2.3.1: 315 | resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 316 | engines: {node: '>=6'} 317 | 318 | resolve-from@4.0.0: 319 | resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} 320 | engines: {node: '>=4'} 321 | 322 | shebang-command@2.0.0: 323 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 324 | engines: {node: '>=8'} 325 | 326 | shebang-regex@3.0.0: 327 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 328 | engines: {node: '>=8'} 329 | 330 | strip-json-comments@3.1.1: 331 | resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} 332 | engines: {node: '>=8'} 333 | 334 | supports-color@7.2.0: 335 | resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 336 | engines: {node: '>=8'} 337 | 338 | type-check@0.4.0: 339 | resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} 340 | engines: {node: '>= 0.8.0'} 341 | 342 | uri-js@4.4.1: 343 | resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 344 | 345 | which@2.0.2: 346 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 347 | engines: {node: '>= 8'} 348 | hasBin: true 349 | 350 | word-wrap@1.2.5: 351 | resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 352 | engines: {node: '>=0.10.0'} 353 | 354 | yocto-queue@0.1.0: 355 | resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 356 | engines: {node: '>=10'} 357 | 358 | snapshots: 359 | 360 | '@eslint-community/eslint-utils@4.9.0(eslint@9.38.0)': 361 | dependencies: 362 | eslint: 9.38.0 363 | eslint-visitor-keys: 3.4.3 364 | 365 | '@eslint-community/regexpp@4.12.1': {} 366 | 367 | '@eslint/config-array@0.21.1': 368 | dependencies: 369 | '@eslint/object-schema': 2.1.7 370 | debug: 4.4.3 371 | minimatch: 3.1.2 372 | transitivePeerDependencies: 373 | - supports-color 374 | 375 | '@eslint/config-helpers@0.4.1': 376 | dependencies: 377 | '@eslint/core': 0.16.0 378 | 379 | '@eslint/core@0.16.0': 380 | dependencies: 381 | '@types/json-schema': 7.0.15 382 | 383 | '@eslint/eslintrc@3.3.1': 384 | dependencies: 385 | ajv: 6.12.6 386 | debug: 4.4.3 387 | espree: 10.4.0 388 | globals: 14.0.0 389 | ignore: 5.3.2 390 | import-fresh: 3.3.1 391 | js-yaml: 4.1.0 392 | minimatch: 3.1.2 393 | strip-json-comments: 3.1.1 394 | transitivePeerDependencies: 395 | - supports-color 396 | 397 | '@eslint/js@9.38.0': {} 398 | 399 | '@eslint/object-schema@2.1.7': {} 400 | 401 | '@eslint/plugin-kit@0.4.0': 402 | dependencies: 403 | '@eslint/core': 0.16.0 404 | levn: 0.4.1 405 | 406 | '@humanfs/core@0.19.1': {} 407 | 408 | '@humanfs/node@0.16.7': 409 | dependencies: 410 | '@humanfs/core': 0.19.1 411 | '@humanwhocodes/retry': 0.4.3 412 | 413 | '@humanwhocodes/module-importer@1.0.1': {} 414 | 415 | '@humanwhocodes/retry@0.4.3': {} 416 | 417 | '@types/estree@1.0.8': {} 418 | 419 | '@types/json-schema@7.0.15': {} 420 | 421 | acorn-jsx@5.3.2(acorn@8.15.0): 422 | dependencies: 423 | acorn: 8.15.0 424 | 425 | acorn@8.15.0: {} 426 | 427 | ajv@6.12.6: 428 | dependencies: 429 | fast-deep-equal: 3.1.3 430 | fast-json-stable-stringify: 2.1.0 431 | json-schema-traverse: 0.4.1 432 | uri-js: 4.4.1 433 | 434 | ansi-styles@4.3.0: 435 | dependencies: 436 | color-convert: 2.0.1 437 | 438 | argparse@2.0.1: {} 439 | 440 | balanced-match@1.0.2: {} 441 | 442 | brace-expansion@1.1.12: 443 | dependencies: 444 | balanced-match: 1.0.2 445 | concat-map: 0.0.1 446 | 447 | callsites@3.1.0: {} 448 | 449 | chalk@4.1.2: 450 | dependencies: 451 | ansi-styles: 4.3.0 452 | supports-color: 7.2.0 453 | 454 | color-convert@2.0.1: 455 | dependencies: 456 | color-name: 1.1.4 457 | 458 | color-name@1.1.4: {} 459 | 460 | concat-map@0.0.1: {} 461 | 462 | cross-spawn@7.0.6: 463 | dependencies: 464 | path-key: 3.1.1 465 | shebang-command: 2.0.0 466 | which: 2.0.2 467 | 468 | debug@4.4.3: 469 | dependencies: 470 | ms: 2.1.3 471 | 472 | deep-is@0.1.4: {} 473 | 474 | escape-string-regexp@4.0.0: {} 475 | 476 | eslint-scope@8.4.0: 477 | dependencies: 478 | esrecurse: 4.3.0 479 | estraverse: 5.3.0 480 | 481 | eslint-visitor-keys@3.4.3: {} 482 | 483 | eslint-visitor-keys@4.2.1: {} 484 | 485 | eslint@9.38.0: 486 | dependencies: 487 | '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0) 488 | '@eslint-community/regexpp': 4.12.1 489 | '@eslint/config-array': 0.21.1 490 | '@eslint/config-helpers': 0.4.1 491 | '@eslint/core': 0.16.0 492 | '@eslint/eslintrc': 3.3.1 493 | '@eslint/js': 9.38.0 494 | '@eslint/plugin-kit': 0.4.0 495 | '@humanfs/node': 0.16.7 496 | '@humanwhocodes/module-importer': 1.0.1 497 | '@humanwhocodes/retry': 0.4.3 498 | '@types/estree': 1.0.8 499 | ajv: 6.12.6 500 | chalk: 4.1.2 501 | cross-spawn: 7.0.6 502 | debug: 4.4.3 503 | escape-string-regexp: 4.0.0 504 | eslint-scope: 8.4.0 505 | eslint-visitor-keys: 4.2.1 506 | espree: 10.4.0 507 | esquery: 1.6.0 508 | esutils: 2.0.3 509 | fast-deep-equal: 3.1.3 510 | file-entry-cache: 8.0.0 511 | find-up: 5.0.0 512 | glob-parent: 6.0.2 513 | ignore: 5.3.2 514 | imurmurhash: 0.1.4 515 | is-glob: 4.0.3 516 | json-stable-stringify-without-jsonify: 1.0.1 517 | lodash.merge: 4.6.2 518 | minimatch: 3.1.2 519 | natural-compare: 1.4.0 520 | optionator: 0.9.4 521 | transitivePeerDependencies: 522 | - supports-color 523 | 524 | espree@10.4.0: 525 | dependencies: 526 | acorn: 8.15.0 527 | acorn-jsx: 5.3.2(acorn@8.15.0) 528 | eslint-visitor-keys: 4.2.1 529 | 530 | esquery@1.6.0: 531 | dependencies: 532 | estraverse: 5.3.0 533 | 534 | esrecurse@4.3.0: 535 | dependencies: 536 | estraverse: 5.3.0 537 | 538 | estraverse@5.3.0: {} 539 | 540 | esutils@2.0.3: {} 541 | 542 | fast-deep-equal@3.1.3: {} 543 | 544 | fast-json-stable-stringify@2.1.0: {} 545 | 546 | fast-levenshtein@2.0.6: {} 547 | 548 | file-entry-cache@8.0.0: 549 | dependencies: 550 | flat-cache: 4.0.1 551 | 552 | find-up@5.0.0: 553 | dependencies: 554 | locate-path: 6.0.0 555 | path-exists: 4.0.0 556 | 557 | flat-cache@4.0.1: 558 | dependencies: 559 | flatted: 3.3.3 560 | keyv: 4.5.4 561 | 562 | flatted@3.3.3: {} 563 | 564 | glob-parent@6.0.2: 565 | dependencies: 566 | is-glob: 4.0.3 567 | 568 | globals@14.0.0: {} 569 | 570 | has-flag@4.0.0: {} 571 | 572 | ignore@5.3.2: {} 573 | 574 | import-fresh@3.3.1: 575 | dependencies: 576 | parent-module: 1.0.1 577 | resolve-from: 4.0.0 578 | 579 | imurmurhash@0.1.4: {} 580 | 581 | is-extglob@2.1.1: {} 582 | 583 | is-glob@4.0.3: 584 | dependencies: 585 | is-extglob: 2.1.1 586 | 587 | isexe@2.0.0: {} 588 | 589 | js-yaml@4.1.0: 590 | dependencies: 591 | argparse: 2.0.1 592 | 593 | json-buffer@3.0.1: {} 594 | 595 | json-schema-traverse@0.4.1: {} 596 | 597 | json-stable-stringify-without-jsonify@1.0.1: {} 598 | 599 | keyv@4.5.4: 600 | dependencies: 601 | json-buffer: 3.0.1 602 | 603 | levn@0.4.1: 604 | dependencies: 605 | prelude-ls: 1.2.1 606 | type-check: 0.4.0 607 | 608 | locate-path@6.0.0: 609 | dependencies: 610 | p-locate: 5.0.0 611 | 612 | lodash.merge@4.6.2: {} 613 | 614 | minimatch@3.1.2: 615 | dependencies: 616 | brace-expansion: 1.1.12 617 | 618 | ms@2.1.3: {} 619 | 620 | natural-compare@1.4.0: {} 621 | 622 | optionator@0.9.4: 623 | dependencies: 624 | deep-is: 0.1.4 625 | fast-levenshtein: 2.0.6 626 | levn: 0.4.1 627 | prelude-ls: 1.2.1 628 | type-check: 0.4.0 629 | word-wrap: 1.2.5 630 | 631 | p-limit@3.1.0: 632 | dependencies: 633 | yocto-queue: 0.1.0 634 | 635 | p-locate@5.0.0: 636 | dependencies: 637 | p-limit: 3.1.0 638 | 639 | parent-module@1.0.1: 640 | dependencies: 641 | callsites: 3.1.0 642 | 643 | path-exists@4.0.0: {} 644 | 645 | path-key@3.1.1: {} 646 | 647 | prelude-ls@1.2.1: {} 648 | 649 | prettier@3.6.2: {} 650 | 651 | punycode@2.3.1: {} 652 | 653 | resolve-from@4.0.0: {} 654 | 655 | shebang-command@2.0.0: 656 | dependencies: 657 | shebang-regex: 3.0.0 658 | 659 | shebang-regex@3.0.0: {} 660 | 661 | strip-json-comments@3.1.1: {} 662 | 663 | supports-color@7.2.0: 664 | dependencies: 665 | has-flag: 4.0.0 666 | 667 | type-check@0.4.0: 668 | dependencies: 669 | prelude-ls: 1.2.1 670 | 671 | uri-js@4.4.1: 672 | dependencies: 673 | punycode: 2.3.1 674 | 675 | which@2.0.2: 676 | dependencies: 677 | isexe: 2.0.0 678 | 679 | word-wrap@1.2.5: {} 680 | 681 | yocto-queue@0.1.0: {} 682 | --------------------------------------------------------------------------------