├── 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 | [](https://github.com/mbolli/nfsen-ng/blob/master/LICENSE)
4 | [](https://github.com/mbolli/nfsen-ng/issues)
5 | [](https://paypal.me/bolli)
6 |
7 | nfsen-ng is an in-place replacement for the ageing nfsen.
8 |
9 | 
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 |
--------------------------------------------------------------------------------