Compare commits
	
		
			No commits in common. "main" and "codex/remove-global-option-from-duration-view" have entirely different histories.
		
	
	
		
			
				main
			
			...
			
				codex/remo
			
		
	
		
							
								
								
									
										3
									
								
								.flake8
									
										
									
									
									
								
							
							
						
						|  | @ -1,3 +0,0 @@ | ||||||
| [flake8] |  | ||||||
| exclude = .git, .venv, output, static/icons |  | ||||||
| max-line-length = 160 |  | ||||||
|  | @ -1,151 +0,0 @@ | ||||||
| name: CI |  | ||||||
| 
 |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|   pull_request: |  | ||||||
|   workflow_dispatch: |  | ||||||
| 
 |  | ||||||
| jobs: |  | ||||||
|   ci: |  | ||||||
|     name: Lint, test, and build |  | ||||||
|     # This label must match your Forgejo runner's label |  | ||||||
|     runs-on: docker |  | ||||||
|     # Use a clean Debian container so tools are predictable |  | ||||||
|     container: debian:stable-slim |  | ||||||
|     env: |  | ||||||
|       PYTHONDONTWRITEBYTECODE: "1" |  | ||||||
|       PIP_DISABLE_PIP_VERSION_CHECK: "1" |  | ||||||
|       UV_SYSTEM_PYTHON: "1" |  | ||||||
|     steps: |  | ||||||
|       - name: Install build tooling |  | ||||||
|         run: | |  | ||||||
|           set -euo pipefail |  | ||||||
|           apt-get update |  | ||||||
|           DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ |  | ||||||
|             git ca-certificates python3 python3-venv python3-pip python3-setuptools \ |  | ||||||
|             python3-wheel sqlite3 |  | ||||||
|           update-ca-certificates || true |  | ||||||
| 
 |  | ||||||
|       - name: Checkout repository (manual) |  | ||||||
|         run: | |  | ||||||
|           set -euo pipefail |  | ||||||
|           if [ -f Makefile ] || [ -d .git ]; then |  | ||||||
|             echo "Repository present in workspace; skipping clone" |  | ||||||
|             exit 0 |  | ||||||
|           fi |  | ||||||
|           REMOTE_URL="${CI_REPOSITORY_URL:-}" |  | ||||||
|           if [ -z "$REMOTE_URL" ]; then |  | ||||||
|             if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then |  | ||||||
|               REMOTE_URL="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git" |  | ||||||
|             elif [ -n "${GITHUB_REPOSITORY:-}" ]; then |  | ||||||
|               REMOTE_URL="https://git.jordanwages.com/${GITHUB_REPOSITORY}.git" |  | ||||||
|             else |  | ||||||
|               echo "Unable to determine repository URL from CI environment" >&2 |  | ||||||
|               exit 1 |  | ||||||
|             fi |  | ||||||
|           fi |  | ||||||
|           AUTH_URL="$REMOTE_URL" |  | ||||||
|           if [ -n "${GITHUB_TOKEN:-}" ]; then |  | ||||||
|             ACTOR="${GITHUB_ACTOR:-oauth2}" |  | ||||||
|             AUTH_URL=$(printf '%s' "$REMOTE_URL" | sed -E "s#^https://#https://${ACTOR}:${GITHUB_TOKEN}@#") |  | ||||||
|           fi |  | ||||||
|           echo "Cloning from: $REMOTE_URL" |  | ||||||
|           if ! git clone --depth 1 "$AUTH_URL" .; then |  | ||||||
|             echo "Auth clone failed; trying anonymous clone..." >&2 |  | ||||||
|             git clone --depth 1 "$REMOTE_URL" . |  | ||||||
|           fi |  | ||||||
|           if [ -n "${GITHUB_SHA:-}" ]; then |  | ||||||
|             git fetch --depth 1 origin "$GITHUB_SHA" || true |  | ||||||
|             git checkout -q "$GITHUB_SHA" || true |  | ||||||
|           elif [ -n "${GITHUB_REF_NAME:-}" ]; then |  | ||||||
|             git fetch --depth 1 origin "$GITHUB_REF_NAME" || true |  | ||||||
|             git checkout -q "$GITHUB_REF_NAME" || true |  | ||||||
|           fi |  | ||||||
| 
 |  | ||||||
|       - name: Set up venv and install deps |  | ||||||
|         run: | |  | ||||||
|           set -euo pipefail |  | ||||||
|           # Prefer persistent cache if runner provides /cache |  | ||||||
|           USE_CACHE=0 |  | ||||||
|           if [ -d /cache ] && [ -w /cache ]; then |  | ||||||
|             export PIP_CACHE_DIR=/cache/pip |  | ||||||
|             mkdir -p "$PIP_CACHE_DIR" |  | ||||||
|             REQ_HASH=$(sha256sum requirements.txt | awk '{print $1}') |  | ||||||
|             PYVER=$(python3 -c 'import sys;print(".".join(map(str, sys.version_info[:2])))') |  | ||||||
|             CACHE_VENV="/cache/venv-${REQ_HASH}-py${PYVER}" |  | ||||||
|             if [ ! -f "$CACHE_VENV/bin/activate" ]; then |  | ||||||
|               echo "Preparing cached virtualenv: $CACHE_VENV" |  | ||||||
|               rm -rf "$CACHE_VENV" || true |  | ||||||
|               python3 -m venv "$CACHE_VENV" |  | ||||||
|             fi |  | ||||||
|             ln -sfn "$CACHE_VENV" .venv |  | ||||||
|             USE_CACHE=1 |  | ||||||
|           else |  | ||||||
|             # Fallback to local venv |  | ||||||
|             python3 -m venv .venv |  | ||||||
|           fi |  | ||||||
| 
 |  | ||||||
|           # If the link didn't produce an activate file, fallback to local venv |  | ||||||
|           if [ ! -f .venv/bin/activate ]; then |  | ||||||
|             echo "Cached venv missing; creating local .venv" |  | ||||||
|             rm -f .venv |  | ||||||
|             python3 -m venv .venv |  | ||||||
|             USE_CACHE=0 |  | ||||||
|           fi |  | ||||||
| 
 |  | ||||||
|           . .venv/bin/activate |  | ||||||
|           python -m pip install --upgrade pip |  | ||||||
|           if [ "$USE_CACHE" = "1" ]; then |  | ||||||
|             # Ensure required packages are present; pip will use cache |  | ||||||
|             pip install -r requirements.txt pytest || pip install -r requirements.txt pytest |  | ||||||
|           else |  | ||||||
|             pip install -r requirements.txt pytest |  | ||||||
|           fi |  | ||||||
| 
 |  | ||||||
|       - name: Format check (black) |  | ||||||
|         run: | |  | ||||||
|           . .venv/bin/activate |  | ||||||
|           black --check . |  | ||||||
| 
 |  | ||||||
|       - name: Lint (flake8) |  | ||||||
|         run: | |  | ||||||
|           . .venv/bin/activate |  | ||||||
|           flake8 . |  | ||||||
| 
 |  | ||||||
|       - name: Run tests (pytest) |  | ||||||
|         run: | |  | ||||||
|           . .venv/bin/activate |  | ||||||
|           export PYTHONPATH="$(pwd)${PYTHONPATH:+:$PYTHONPATH}" |  | ||||||
|           pytest -q --maxfail=1 |  | ||||||
| 
 |  | ||||||
|       - name: Build sample reports (no artifact upload) |  | ||||||
|         run: | |  | ||||||
|           set -euo pipefail |  | ||||||
|           . .venv/bin/activate |  | ||||||
|           python - <<'PY' |  | ||||||
|           import sqlite3, pathlib |  | ||||||
|           db = pathlib.Path('database/ngxstat.db') |  | ||||||
|           db.parent.mkdir(parents=True, exist_ok=True) |  | ||||||
|           conn = sqlite3.connect(db) |  | ||||||
|           cur = conn.cursor() |  | ||||||
|           cur.execute('''CREATE TABLE IF NOT EXISTS logs ( |  | ||||||
|               id INTEGER PRIMARY KEY, |  | ||||||
|               ip TEXT, |  | ||||||
|               host TEXT, |  | ||||||
|               time TEXT, |  | ||||||
|               request TEXT, |  | ||||||
|               status INTEGER, |  | ||||||
|               bytes_sent INTEGER, |  | ||||||
|               referer TEXT, |  | ||||||
|               user_agent TEXT, |  | ||||||
|               cache_status TEXT |  | ||||||
|           )''') |  | ||||||
|           cur.execute("INSERT INTO logs (ip, host, time, request, status, bytes_sent, referer, user_agent, cache_status) VALUES ('127.0.0.1','example.com','2024-01-01 10:00:00','GET / HTTP/1.1',200,100,'-','curl','MISS')") |  | ||||||
|           cur.execute("INSERT INTO logs (ip, host, time, request, status, bytes_sent, referer, user_agent, cache_status) VALUES ('127.0.0.1','example.com','2024-01-01 10:05:00','GET /about HTTP/1.1',200,100,'-','curl','MISS')") |  | ||||||
|           conn.commit(); conn.close() |  | ||||||
|           PY |  | ||||||
|           python scripts/generate_reports.py global |  | ||||||
|           python scripts/generate_reports.py hourly |  | ||||||
|           python scripts/generate_reports.py index |  | ||||||
|           tar -czf ngxstat-reports.tar.gz -C output . |  | ||||||
|           echo "Built sample reports archive: ngxstat-reports.tar.gz" |  | ||||||
							
								
								
									
										12
									
								
								AGENTS.md
									
										
									
									
									
								
							
							
						
						|  | @ -24,9 +24,6 @@ This document outlines general practices and expectations for AI agents assistin | ||||||
|   The `run-import.sh` script can initialize this environment automatically. |   The `run-import.sh` script can initialize this environment automatically. | ||||||
|   Always activate the virtual environment before running scripts or tests. |   Always activate the virtual environment before running scripts or tests. | ||||||
| 
 | 
 | ||||||
| * Before committing code run `black` for consistent formatting and execute |  | ||||||
|   the test suite with `pytest`. All tests should pass. |  | ||||||
| 
 |  | ||||||
| * Dependency management: Use `requirements.txt` or `pip-tools` | * Dependency management: Use `requirements.txt` or `pip-tools` | ||||||
| * Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`) | * Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`) | ||||||
| * Adopt `typer` for CLI command interface (if CLI ergonomics matter) | * Adopt `typer` for CLI command interface (if CLI ergonomics matter) | ||||||
|  | @ -92,14 +89,6 @@ ngxstat/ | ||||||
| 
 | 
 | ||||||
| If uncertain, the agent should prompt the human for clarification before making architectural assumptions. | If uncertain, the agent should prompt the human for clarification before making architectural assumptions. | ||||||
| 
 | 
 | ||||||
| ## Testing |  | ||||||
| 
 |  | ||||||
| Use `pytest` for automated tests. Run the suite from an activated virtual environment and ensure all tests pass before committing: |  | ||||||
| 
 |  | ||||||
| ```bash |  | ||||||
| pytest -q |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| ## Future Capabilities | ## Future Capabilities | ||||||
|  | @ -117,4 +106,3 @@ As the project matures, agents may also: | ||||||
| 
 | 
 | ||||||
| * **2025-07-17**: Initial version by Jordan + ChatGPT | * **2025-07-17**: Initial version by Jordan + ChatGPT | ||||||
| * **2025-07-17**: Expanded virtual environment usage guidance | * **2025-07-17**: Expanded virtual environment usage guidance | ||||||
| 
 |  | ||||||
|  |  | ||||||
							
								
								
									
										147
									
								
								README.md
									
										
									
									
									
								
							
							
						
						|  | @ -1,16 +1,11 @@ | ||||||
| # ngxstat | # ngxstat | ||||||
|  | Per-domain Nginx log analytics with hybrid static reports and live insights. | ||||||
| 
 | 
 | ||||||
| `ngxstat` is a lightweight log analytics toolkit for Nginx. It imports access | ## Generating Reports | ||||||
| logs into an SQLite database and renders static dashboards so you can explore |  | ||||||
| per-domain metrics without running a heavy backend service. |  | ||||||
| 
 | 
 | ||||||
| ## Requirements | Use the `generate_reports.py` script to build aggregated JSON and HTML snippet files from `database/ngxstat.db`. | ||||||
| 
 | 
 | ||||||
| * Python 3.10+ | Create a virtual environment and install dependencies: | ||||||
| * Access to the Nginx log files (default: `/var/log/nginx`) |  | ||||||
| 
 |  | ||||||
| The helper scripts create a virtual environment on first run, but you can also |  | ||||||
| set one up manually: |  | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| python3 -m venv .venv | python3 -m venv .venv | ||||||
|  | @ -18,95 +13,105 @@ source .venv/bin/activate | ||||||
| pip install -r requirements.txt | pip install -r requirements.txt | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | Then run one or more of the interval commands: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | python scripts/generate_reports.py hourly | ||||||
|  | python scripts/generate_reports.py daily | ||||||
|  | python scripts/generate_reports.py weekly | ||||||
|  | python scripts/generate_reports.py monthly | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Each command accepts optional flags to generate per-domain reports. Use | ||||||
|  | `--domain <name>` to limit output to a specific domain or `--all-domains` | ||||||
|  | to generate a subdirectory for every domain found in the database: | ||||||
|  | 
 | ||||||
|  | ```bash | ||||||
|  | # Hourly reports for example.com only | ||||||
|  | python scripts/generate_reports.py hourly --domain example.com | ||||||
|  | 
 | ||||||
|  | # Weekly reports for all domains individually | ||||||
|  | python scripts/generate_reports.py weekly --all-domains | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Reports are written under the `output/` directory. Each command updates the corresponding `<interval>.json` file and writes one HTML snippet per report. These snippets are loaded dynamically by the main dashboard using Chart.js and DataTables. | ||||||
|  | 
 | ||||||
|  | ### Configuring Reports | ||||||
|  | 
 | ||||||
|  | Report queries are defined in `reports.yml`. Each entry specifies the `name`, | ||||||
|  | optional `label` and `chart` type, and a SQL `query` that must return `bucket` | ||||||
|  | and `value` columns. The special token `{bucket}` is replaced with the | ||||||
|  | appropriate SQLite `strftime` expression for each interval (hourly, daily, | ||||||
|  | weekly or monthly) so that a single definition works across all durations. | ||||||
|  | When `generate_reports.py` runs, every definition is executed for the requested | ||||||
|  | interval and creates `output/<interval>/<name>.json` plus a small HTML snippet | ||||||
|  | `output/<interval>/<name>.html` used by the dashboard. | ||||||
|  | 
 | ||||||
|  | Example snippet: | ||||||
|  | 
 | ||||||
|  | ```yaml | ||||||
|  | - name: hits | ||||||
|  |   chart: bar | ||||||
|  |   query: | | ||||||
|  |     SELECT {bucket} AS bucket, | ||||||
|  |            COUNT(*) AS value | ||||||
|  |     FROM logs | ||||||
|  |     GROUP BY bucket | ||||||
|  |     ORDER BY bucket | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Add or modify entries in `reports.yml` to tailor the generated metrics. | ||||||
|  | 
 | ||||||
| ## Importing Logs | ## Importing Logs | ||||||
| 
 | 
 | ||||||
| Run the importer to ingest new log entries into `database/ngxstat.db`: | Use the `run-import.sh` script to set up the Python environment if needed and import the latest Nginx log entries into `database/ngxstat.db`. | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| ./run-import.sh | ./run-import.sh | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| Rotated logs are processed in order and only entries newer than the last | This script is suitable for cron jobs as it creates the virtual environment on first run, installs dependencies and reuses the environment on subsequent runs. | ||||||
| imported timestamp are added. |  | ||||||
| 
 | 
 | ||||||
| ## Generating Reports | The importer handles rotated logs in order from oldest to newest so entries are | ||||||
|  | processed exactly once. If you rerun the script, it only ingests records with a | ||||||
|  | timestamp newer than the latest one already stored in the database, preventing | ||||||
|  | duplicates. | ||||||
| 
 | 
 | ||||||
| To build the HTML dashboard and JSON data files use `run-reports.sh` which runs | ## Cron Report Generation | ||||||
| all intervals in one go: | 
 | ||||||
|  | Use the `run-reports.sh` script to run all report intervals in one step. The script sets up the Python environment the same way as `run-import.sh`, making it convenient for automation via cron. | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| ./run-reports.sh | ./run-reports.sh | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| The script calls `scripts/generate_reports.py` internally to create hourly, | Running this script will create or update the hourly, daily, weekly and monthly reports under `output/`. It also detects all unique domains found in the database and writes per-domain reports to `output/domains/<domain>/<interval>` alongside the aggregate data. After generation, open `output/index.html` in your browser to browse the reports. | ||||||
| daily, weekly and monthly reports, then writes analysis JSON files used by the |  | ||||||
| "Analysis" tab. Per-domain reports are written under `output/domains/<domain>` |  | ||||||
| alongside the aggregate data. Open `output/index.html` in a browser to view the |  | ||||||
| dashboard. |  | ||||||
| 
 | 
 | ||||||
| If you prefer to run individual commands you can invoke the generator directly: | ## Serving Reports with Nginx | ||||||
| 
 | 
 | ||||||
| ```bash | To expose the generated HTML dashboards and JSON files over HTTP you can use a | ||||||
| python scripts/generate_reports.py hourly | simple Nginx server block. Point the `root` directive to the repository's | ||||||
| python scripts/generate_reports.py daily --all-domains | `output/` directory and optionally restrict access to your local network. | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ## Analysis Helpers |  | ||||||
| 
 |  | ||||||
| `run-analysis.sh` executes additional utilities that examine the database for |  | ||||||
| missing domains, caching opportunities and potential threats. The JSON output is |  | ||||||
| saved under `output/analysis` and appears in the "Analysis" tab. The |  | ||||||
| `run-reports.sh` script also generates these JSON files as part of the build. |  | ||||||
| 
 |  | ||||||
| ## UX Controls |  | ||||||
| 
 |  | ||||||
| The dashboard defaults to a 7‑day window for time series. Your view preferences |  | ||||||
| persist locally in the browser under the `ngxstat-state-v2` key. Use the |  | ||||||
| "Reset view" button to clear saved state and restore defaults. |  | ||||||
| 
 |  | ||||||
| ```bash |  | ||||||
| ./run-analysis.sh |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ## Serving the Reports |  | ||||||
| 
 |  | ||||||
| The generated files are static. You can serve them with a simple Nginx block: |  | ||||||
| 
 | 
 | ||||||
| ```nginx | ```nginx | ||||||
| server { | server { | ||||||
|     listen 80; |     listen 80; | ||||||
|     server_name example.com; |     server_name example.com; | ||||||
|  | 
 | ||||||
|  |     # Path to the generated reports | ||||||
|     root /path/to/ngxstat/output; |     root /path/to/ngxstat/output; | ||||||
| 
 | 
 | ||||||
|     location / { |     location / { | ||||||
|         try_files $uri $uri/ =404; |         try_files $uri $uri/ =404; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     # Allow access only from private networks | ||||||
|  |     allow 192.0.0.0/8; | ||||||
|  |     allow 10.0.0.0/8; | ||||||
|  |     deny  all; | ||||||
| } | } | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| Restrict access if the reports should not be public. | With this configuration the generated static files are served directly by | ||||||
|  | Nginx while connections outside of `192.*` and `10.*` are denied. | ||||||
| 
 | 
 | ||||||
| ## Running Tests |  | ||||||
| 
 |  | ||||||
| Install the development dependencies and execute the suite with `pytest`: |  | ||||||
| 
 |  | ||||||
| ```bash |  | ||||||
| pip install -r requirements.txt |  | ||||||
| pytest -q |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| All tests must pass before submitting changes. |  | ||||||
| 
 |  | ||||||
| ## Acknowledgements |  | ||||||
| 
 |  | ||||||
| ngxstat uses the following third‑party resources: |  | ||||||
| 
 |  | ||||||
| * [Chart.js](https://www.chartjs.org/) for charts |  | ||||||
| * [DataTables](https://datatables.net/) and [jQuery](https://jquery.com/) for table views |  | ||||||
| * [Bulma CSS](https://bulma.io/) for styling |  | ||||||
| * Icons from [Free CC0 Icons](https://cc0-icons.jonh.eu/) by Jon Hicks (CC0 / MIT) |  | ||||||
| * [Typer](https://typer.tiangolo.com/) for the command-line interface |  | ||||||
| * [Jinja2](https://palletsprojects.com/p/jinja/) for templating |  | ||||||
| 
 |  | ||||||
| The project is licensed under the GPLv3. Icon assets remain in the public domain |  | ||||||
| via the CC0 license. |  | ||||||
|  |  | ||||||
							
								
								
									
										163
									
								
								reports.yml
									
										
									
									
									
								
							
							
						
						|  | @ -1,37 +1,28 @@ | ||||||
| - name: hits | - name: hits | ||||||
|   label: Hits |   label: Hits | ||||||
|   icon: pulse |  | ||||||
|   chart: line |   chart: line | ||||||
|   bucket: time_bucket |  | ||||||
|   bucket_label: Time |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT {bucket} AS time_bucket, |     SELECT {bucket} AS bucket, | ||||||
|            COUNT(*) AS value |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY time_bucket |     GROUP BY bucket | ||||||
|     ORDER BY time_bucket |     ORDER BY bucket | ||||||
| 
 | 
 | ||||||
| - name: error_rate | - name: error_rate | ||||||
|   label: Error Rate (%) |   label: Error Rate (%) | ||||||
|   icon: file-alert |  | ||||||
|   chart: line |   chart: line | ||||||
|   bucket: time_bucket |  | ||||||
|   bucket_label: Time |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT {bucket} AS time_bucket, |     SELECT {bucket} AS bucket, | ||||||
|            SUM(CASE WHEN status BETWEEN 400 AND 599 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS value |            SUM(CASE WHEN status BETWEEN 400 AND 599 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY time_bucket |     GROUP BY bucket | ||||||
|     ORDER BY time_bucket |     ORDER BY bucket | ||||||
| 
 | 
 | ||||||
| - name: cache_status_breakdown | - name: cache_status_breakdown | ||||||
|   label: Cache Status |   label: Cache Status | ||||||
|   icon: archive |  | ||||||
|   chart: polarArea |   chart: polarArea | ||||||
|   bucket: cache_status |  | ||||||
|   bucket_label: Cache Status |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT cache_status AS cache_status, |     SELECT cache_status AS bucket, | ||||||
|            COUNT(*) AS value |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY cache_status |     GROUP BY cache_status | ||||||
|  | @ -46,168 +37,78 @@ | ||||||
| 
 | 
 | ||||||
| - name: domain_traffic | - name: domain_traffic | ||||||
|   label: Top Domains |   label: Top Domains | ||||||
|   icon: globe |  | ||||||
|   chart: table |   chart: table | ||||||
|   top_n: 50 |  | ||||||
|   per_domain: false |   per_domain: false | ||||||
|   bucket: domain |  | ||||||
|   bucket_label: Domain |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT host AS domain, |     SELECT host AS bucket, | ||||||
|            COUNT(*) AS value |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY domain |     GROUP BY host | ||||||
|     ORDER BY value DESC |     ORDER BY value DESC | ||||||
| 
 | 
 | ||||||
| - name: bytes_sent | - name: bytes_sent | ||||||
|   label: Bytes Sent |   label: Bytes Sent | ||||||
|   icon: upload |  | ||||||
|   chart: line |   chart: line | ||||||
|   bucket: time_bucket |  | ||||||
|   bucket_label: Time |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT {bucket} AS time_bucket, |     SELECT {bucket} AS bucket, | ||||||
|            SUM(bytes_sent) AS value |            SUM(bytes_sent) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY time_bucket |     GROUP BY bucket | ||||||
|     ORDER BY time_bucket |     ORDER BY bucket | ||||||
| 
 | 
 | ||||||
| - name: top_paths | - name: top_paths | ||||||
|   label: Top Paths |   label: Top Paths | ||||||
|   icon: map |  | ||||||
|   chart: table |   chart: table | ||||||
|   top_n: 50 |  | ||||||
|   buckets: |  | ||||||
|     - domain |  | ||||||
|     - path |  | ||||||
|   bucket_label: |  | ||||||
|     - Domain |  | ||||||
|     - Path |  | ||||||
|   query: | |   query: | | ||||||
|     WITH paths AS ( |     SELECT path AS bucket, | ||||||
|         SELECT host AS domain, |            COUNT(*) AS value | ||||||
|                substr(substr(request, instr(request, ' ') + 1), 1, |     FROM ( | ||||||
|  |         SELECT substr(substr(request, instr(request, ' ') + 1), 1, | ||||||
|                       instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path |                       instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path | ||||||
|         FROM logs |         FROM logs | ||||||
|     ), ranked AS ( |  | ||||||
|         SELECT domain, path, COUNT(*) AS value, |  | ||||||
|                ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn |  | ||||||
|         FROM paths |  | ||||||
|         GROUP BY domain, path |  | ||||||
|     ) |     ) | ||||||
|     SELECT domain, path, value |     GROUP BY path | ||||||
|     FROM ranked |     ORDER BY value DESC | ||||||
|     WHERE rn <= 20 |     LIMIT 20 | ||||||
|     ORDER BY domain, value DESC |  | ||||||
| 
 | 
 | ||||||
| - name: user_agents | - name: user_agents | ||||||
|   label: User Agents |   label: User Agents | ||||||
|   icon: user |  | ||||||
|   chart: table |   chart: table | ||||||
|   top_n: 50 |  | ||||||
|   buckets: |  | ||||||
|     - domain |  | ||||||
|     - user_agent |  | ||||||
|   bucket_label: |  | ||||||
|     - Domain |  | ||||||
|     - User Agent |  | ||||||
|   query: | |   query: | | ||||||
|     WITH ua AS ( |     SELECT user_agent AS bucket, | ||||||
|         SELECT host AS domain, user_agent |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     ), ranked AS ( |     GROUP BY user_agent | ||||||
|         SELECT domain, user_agent, COUNT(*) AS value, |     ORDER BY value DESC | ||||||
|                ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn |     LIMIT 20 | ||||||
|         FROM ua |  | ||||||
|         GROUP BY domain, user_agent |  | ||||||
|     ) |  | ||||||
|     SELECT domain, user_agent, value |  | ||||||
|     FROM ranked |  | ||||||
|     WHERE rn <= 20 |  | ||||||
|     ORDER BY domain, value DESC |  | ||||||
| 
 | 
 | ||||||
| - name: referrers | - name: referrers | ||||||
|   label: Referrers |   label: Referrers | ||||||
|   icon: link |  | ||||||
|   chart: table |   chart: table | ||||||
|   top_n: 50 |  | ||||||
|   buckets: |  | ||||||
|     - domain |  | ||||||
|     - referrer |  | ||||||
|   bucket_label: |  | ||||||
|     - Domain |  | ||||||
|     - Referrer |  | ||||||
|   query: | |   query: | | ||||||
|     WITH ref AS ( |     SELECT referer AS bucket, | ||||||
|         SELECT host AS domain, referer AS referrer |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     ), ranked AS ( |     GROUP BY referer | ||||||
|         SELECT domain, referrer, COUNT(*) AS value, |     ORDER BY value DESC | ||||||
|                ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn |     LIMIT 20 | ||||||
|         FROM ref |  | ||||||
|         GROUP BY domain, referrer |  | ||||||
|     ) |  | ||||||
|     SELECT domain, referrer, value |  | ||||||
|     FROM ranked |  | ||||||
|     WHERE rn <= 20 |  | ||||||
|     ORDER BY domain, value DESC |  | ||||||
| 
 | 
 | ||||||
| - name: status_distribution | - name: status_distribution | ||||||
|   label: HTTP Statuses |   label: HTTP Statuses | ||||||
|   icon: server |  | ||||||
|   chart: pie |   chart: pie | ||||||
|   bucket: status_group |  | ||||||
|   bucket_label: Status |  | ||||||
|   query: | |   query: | | ||||||
|     SELECT CASE |     SELECT CASE | ||||||
|              WHEN status BETWEEN 200 AND 299 THEN '2xx' |              WHEN status BETWEEN 200 AND 299 THEN '2xx' | ||||||
|              WHEN status BETWEEN 300 AND 399 THEN '3xx' |              WHEN status BETWEEN 300 AND 399 THEN '3xx' | ||||||
|              WHEN status BETWEEN 400 AND 499 THEN '4xx' |              WHEN status BETWEEN 400 AND 499 THEN '4xx' | ||||||
|              ELSE '5xx' |              ELSE '5xx' | ||||||
|            END AS status_group, |            END AS bucket, | ||||||
|            COUNT(*) AS value |            COUNT(*) AS value | ||||||
|     FROM logs |     FROM logs | ||||||
|     GROUP BY status_group |     GROUP BY bucket | ||||||
|     ORDER BY status_group |     ORDER BY bucket | ||||||
|   colors: |   colors: | ||||||
|     - "#48c78e" |     - "#48c78e" | ||||||
|     - "#209cee" |     - "#209cee" | ||||||
|     - "#ffdd57" |     - "#ffdd57" | ||||||
|     - "#f14668" |     - "#f14668" | ||||||
| 
 |  | ||||||
| # New time-series: status classes over time (stacked) |  | ||||||
| - name: status_classes_timeseries |  | ||||||
|   label: Status Classes Over Time |  | ||||||
|   icon: server |  | ||||||
|   chart: stackedBar |  | ||||||
|   bucket: time_bucket |  | ||||||
|   bucket_label: Time |  | ||||||
|   stacked: true |  | ||||||
|   query: | |  | ||||||
|     SELECT {bucket} AS time_bucket, |  | ||||||
|            SUM(CASE WHEN status BETWEEN 200 AND 299 THEN 1 ELSE 0 END) AS "2xx", |  | ||||||
|            SUM(CASE WHEN status BETWEEN 300 AND 399 THEN 1 ELSE 0 END) AS "3xx", |  | ||||||
|            SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS "4xx", |  | ||||||
|            SUM(CASE WHEN status BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS "5xx", |  | ||||||
|            COUNT(*) AS total |  | ||||||
|     FROM logs |  | ||||||
|     GROUP BY time_bucket |  | ||||||
|     ORDER BY time_bucket |  | ||||||
| 
 |  | ||||||
| # New time-series: cache status over time (compact Hit/Miss; exclude '-' by default) |  | ||||||
| - name: cache_status_timeseries |  | ||||||
|   label: Cache Status Over Time |  | ||||||
|   icon: archive |  | ||||||
|   chart: stackedBar |  | ||||||
|   bucket: time_bucket |  | ||||||
|   bucket_label: Time |  | ||||||
|   stacked: true |  | ||||||
|   exclude_values: ["-"] |  | ||||||
|   query: | |  | ||||||
|     SELECT {bucket} AS time_bucket, |  | ||||||
|            SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) AS hit, |  | ||||||
|            SUM(CASE WHEN cache_status = 'MISS' THEN 1 ELSE 0 END) AS miss, |  | ||||||
|            COUNT(*) AS total |  | ||||||
|     FROM logs |  | ||||||
|     GROUP BY time_bucket |  | ||||||
|     ORDER BY time_bucket |  | ||||||
|  |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| #!/usr/bin/env bash |  | ||||||
| set -e |  | ||||||
| 
 |  | ||||||
| # Prevent concurrent executions of this script. |  | ||||||
| LOCK_FILE="/tmp/$(basename "$0").lock" |  | ||||||
| if [ -e "$LOCK_FILE" ]; then |  | ||||||
|     echo "[WARN] $(basename "$0") is already running (lock file present)." >&2 |  | ||||||
|     exit 0 |  | ||||||
| fi |  | ||||||
| touch "$LOCK_FILE" |  | ||||||
| trap 'rm -f "$LOCK_FILE"' EXIT |  | ||||||
| 
 |  | ||||||
| # Ensure virtual environment exists |  | ||||||
| if [ ! -d ".venv" ]; then |  | ||||||
|     echo "[INFO] Creating virtual environment..." |  | ||||||
|     python3 -m venv .venv |  | ||||||
|     source .venv/bin/activate |  | ||||||
|     echo "[INFO] Installing dependencies..." |  | ||||||
|     pip install --upgrade pip |  | ||||||
|     if [ -f requirements.txt ]; then |  | ||||||
|         pip install -r requirements.txt |  | ||||||
|     else |  | ||||||
|         echo "[WARN] requirements.txt not found, skipping." |  | ||||||
|     fi |  | ||||||
| else |  | ||||||
|     echo "[INFO] Activating virtual environment..." |  | ||||||
|     source .venv/bin/activate |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Run analysis helpers |  | ||||||
| echo "[INFO] Checking for missing domains..." |  | ||||||
| python -m scripts.analyze check-missing-domains |  | ||||||
| 
 |  | ||||||
| echo "[INFO] Suggesting cache improvements..." |  | ||||||
| python -m scripts.analyze suggest-cache |  | ||||||
| 
 |  | ||||||
| echo "[INFO] Detecting threats..." |  | ||||||
| python -m scripts.analyze detect-threats |  | ||||||
| 
 |  | ||||||
| # Deactivate to keep cron environment clean |  | ||||||
| if type deactivate >/dev/null 2>&1; then |  | ||||||
|     deactivate |  | ||||||
| fi |  | ||||||
|  | @ -1,17 +1,6 @@ | ||||||
| #!/usr/bin/env bash | #!/usr/bin/env bash | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
| # Prevent multiple simultaneous runs by using a lock file specific to this |  | ||||||
| # script. If the lock already exists, assume another instance is running and |  | ||||||
| # exit gracefully. |  | ||||||
| LOCK_FILE="/tmp/$(basename "$0").lock" |  | ||||||
| if [ -e "$LOCK_FILE" ]; then |  | ||||||
|     echo "[WARN] $(basename "$0") is already running (lock file present)." >&2 |  | ||||||
|     exit 0 |  | ||||||
| fi |  | ||||||
| touch "$LOCK_FILE" |  | ||||||
| trap 'rm -f "$LOCK_FILE"' EXIT |  | ||||||
| 
 |  | ||||||
| # Ensure virtual environment exists | # Ensure virtual environment exists | ||||||
| if [ ! -d ".venv" ]; then | if [ ! -d ".venv" ]; then | ||||||
|     echo "[INFO] Creating virtual environment..." |     echo "[INFO] Creating virtual environment..." | ||||||
|  |  | ||||||
|  | @ -1,15 +1,6 @@ | ||||||
| #!/usr/bin/env bash | #!/usr/bin/env bash | ||||||
| set -e | set -e | ||||||
| 
 | 
 | ||||||
| # Prevent concurrent executions of this script. |  | ||||||
| LOCK_FILE="/tmp/$(basename "$0").lock" |  | ||||||
| if [ -e "$LOCK_FILE" ]; then |  | ||||||
|     echo "[WARN] $(basename "$0") is already running (lock file present)." >&2 |  | ||||||
|     exit 0 |  | ||||||
| fi |  | ||||||
| touch "$LOCK_FILE" |  | ||||||
| trap 'rm -f "$LOCK_FILE"' EXIT |  | ||||||
| 
 |  | ||||||
| # Ensure virtual environment exists | # Ensure virtual environment exists | ||||||
| if [ ! -d ".venv" ]; then | if [ ! -d ".venv" ]; then | ||||||
|     echo "[INFO] Creating virtual environment..." |     echo "[INFO] Creating virtual environment..." | ||||||
|  | @ -29,25 +20,21 @@ fi | ||||||
| 
 | 
 | ||||||
| # Generate reports for all domains combined | # Generate reports for all domains combined | ||||||
| echo "[INFO] Generating aggregate reports..." | echo "[INFO] Generating aggregate reports..." | ||||||
| python -m scripts.generate_reports hourly | python scripts/generate_reports.py hourly | ||||||
| python -m scripts.generate_reports daily | python scripts/generate_reports.py daily | ||||||
| python -m scripts.generate_reports weekly | python scripts/generate_reports.py weekly | ||||||
| python -m scripts.generate_reports monthly | python scripts/generate_reports.py monthly | ||||||
| python -m scripts.generate_reports global | python scripts/generate_reports.py global | ||||||
| 
 | 
 | ||||||
| # Generate reports for each individual domain | # Generate reports for each individual domain | ||||||
| echo "[INFO] Generating per-domain reports..." | echo "[INFO] Generating per-domain reports..." | ||||||
| python -m scripts.generate_reports hourly --all-domains | python scripts/generate_reports.py hourly --all-domains | ||||||
| python -m scripts.generate_reports daily --all-domains | python scripts/generate_reports.py daily --all-domains | ||||||
| python -m scripts.generate_reports weekly --all-domains | python scripts/generate_reports.py weekly --all-domains | ||||||
| python -m scripts.generate_reports monthly --all-domains | python scripts/generate_reports.py monthly --all-domains | ||||||
| 
 |  | ||||||
| # Generate analysis JSON |  | ||||||
| echo "[INFO] Generating analysis files..." |  | ||||||
| python -m scripts.generate_reports analysis |  | ||||||
| 
 | 
 | ||||||
| # Generate root index | # Generate root index | ||||||
| python -m scripts.generate_reports index | python scripts/generate_reports.py index | ||||||
| 
 | 
 | ||||||
| # Deactivate to keep cron environment clean | # Deactivate to keep cron environment clean | ||||||
| if type deactivate >/dev/null 2>&1; then | if type deactivate >/dev/null 2>&1; then | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| "Utility package for ngxstat scripts" |  | ||||||
|  | @ -1,358 +0,0 @@ | ||||||
| #!/usr/bin/env python3 |  | ||||||
| """Utility helpers for ad-hoc log analysis. |  | ||||||
| 
 |  | ||||||
| This module exposes small helper functions to inspect the ``ngxstat`` SQLite |  | ||||||
| database.  The intent is to allow quick queries from the command line or other |  | ||||||
| scripts without rewriting SQL each time. |  | ||||||
| 
 |  | ||||||
| Examples |  | ||||||
| -------- |  | ||||||
| To list all domains present in the database:: |  | ||||||
| 
 |  | ||||||
|     python scripts/analyze.py domains |  | ||||||
| 
 |  | ||||||
| The CLI is powered by :mod:`typer` and currently only offers a couple of |  | ||||||
| commands.  More analysis routines can be added over time. |  | ||||||
| """ |  | ||||||
| from __future__ import annotations |  | ||||||
| 
 |  | ||||||
| import sqlite3 |  | ||||||
| from pathlib import Path |  | ||||||
| from typing import List, Optional, Set |  | ||||||
| from datetime import datetime, timedelta |  | ||||||
| 
 |  | ||||||
| import json |  | ||||||
| 
 |  | ||||||
| import typer |  | ||||||
| 
 |  | ||||||
| from scripts import nginx_config  # noqa: F401  # imported for side effects/usage |  | ||||||
| 
 |  | ||||||
| DB_PATH = Path("database/ngxstat.db") |  | ||||||
| ANALYSIS_DIR = Path("output/analysis") |  | ||||||
| 
 |  | ||||||
| app = typer.Typer(help="Ad-hoc statistics queries") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _connect() -> sqlite3.Connection: |  | ||||||
|     """Return a new SQLite connection to :data:`DB_PATH`.""" |  | ||||||
|     return sqlite3.connect(DB_PATH) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def load_domains_from_db() -> List[str]: |  | ||||||
|     """Return a sorted list of distinct domains from the ``logs`` table.""" |  | ||||||
|     conn = _connect() |  | ||||||
|     cur = conn.cursor() |  | ||||||
|     cur.execute("SELECT DISTINCT host FROM logs ORDER BY host") |  | ||||||
|     domains = [row[0] for row in cur.fetchall()] |  | ||||||
|     conn.close() |  | ||||||
|     return domains |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_hit_count(domain: Optional[str] = None) -> int: |  | ||||||
|     """Return total request count. |  | ||||||
| 
 |  | ||||||
|     Parameters |  | ||||||
|     ---------- |  | ||||||
|     domain: |  | ||||||
|         Optional domain to filter on. If ``None`` the count includes all logs. |  | ||||||
|     """ |  | ||||||
|     conn = _connect() |  | ||||||
|     cur = conn.cursor() |  | ||||||
|     if domain: |  | ||||||
|         cur.execute("SELECT COUNT(*) FROM logs WHERE host = ?", (domain,)) |  | ||||||
|     else: |  | ||||||
|         cur.execute("SELECT COUNT(*) FROM logs") |  | ||||||
|     count = cur.fetchone()[0] or 0 |  | ||||||
|     conn.close() |  | ||||||
|     return count |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_cache_ratio(domain: Optional[str] = None) -> float: |  | ||||||
|     """Return the percentage of requests served from cache.""" |  | ||||||
|     conn = _connect() |  | ||||||
|     cur = conn.cursor() |  | ||||||
|     if domain: |  | ||||||
|         cur.execute( |  | ||||||
|             "SELECT SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) * 1.0 / " |  | ||||||
|             "COUNT(*) FROM logs WHERE host = ?", |  | ||||||
|             (domain,), |  | ||||||
|         ) |  | ||||||
|     else: |  | ||||||
|         cur.execute( |  | ||||||
|             "SELECT SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) * 1.0 / " |  | ||||||
|             "COUNT(*) FROM logs" |  | ||||||
|         ) |  | ||||||
|     result = cur.fetchone()[0] |  | ||||||
|     conn.close() |  | ||||||
|     return float(result or 0.0) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() |  | ||||||
| def domains() -> None: |  | ||||||
|     """Print the list of domains discovered in the database.""" |  | ||||||
|     for d in load_domains_from_db(): |  | ||||||
|         typer.echo(d) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() |  | ||||||
| def hits(domain: Optional[str] = typer.Option(None, help="Filter by domain")) -> None: |  | ||||||
|     """Show request count.""" |  | ||||||
|     count = get_hit_count(domain) |  | ||||||
|     if domain: |  | ||||||
|         typer.echo(f"{domain}: {count} hits") |  | ||||||
|     else: |  | ||||||
|         typer.echo(f"Total hits: {count}") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command("cache-ratio") |  | ||||||
| def cache_ratio_cmd( |  | ||||||
|     domain: Optional[str] = typer.Option(None, help="Filter by domain") |  | ||||||
| ) -> None: |  | ||||||
|     """Display cache hit ratio as a percentage.""" |  | ||||||
|     ratio = get_cache_ratio(domain) * 100 |  | ||||||
|     if domain: |  | ||||||
|         typer.echo(f"{domain}: {ratio:.2f}% cached") |  | ||||||
|     else: |  | ||||||
|         typer.echo(f"Cache hit ratio: {ratio:.2f}%") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command("check-missing-domains") |  | ||||||
| def check_missing_domains( |  | ||||||
|     json_output: bool = typer.Option( |  | ||||||
|         False, "--json", help="Output missing domains as JSON" |  | ||||||
|     ) |  | ||||||
| ) -> None: |  | ||||||
|     """Show domains present in the database but absent from Nginx config.""" |  | ||||||
|     try: |  | ||||||
|         from scripts.generate_reports import _get_domains as _db_domains |  | ||||||
|     except Exception:  # pragma: no cover - fallback if import fails |  | ||||||
|         _db_domains = load_domains_from_db |  | ||||||
| 
 |  | ||||||
|     if not isinstance(json_output, bool): |  | ||||||
|         json_output = False |  | ||||||
| 
 |  | ||||||
|     db_domains = set(_db_domains()) |  | ||||||
| 
 |  | ||||||
|     paths = nginx_config.discover_configs() |  | ||||||
|     servers = nginx_config.parse_servers(paths) |  | ||||||
|     config_domains: Set[str] = set() |  | ||||||
|     for server in servers: |  | ||||||
|         names = server.get("server_name", "") |  | ||||||
|         for name in names.split(): |  | ||||||
|             if name: |  | ||||||
|                 config_domains.add(name) |  | ||||||
| 
 |  | ||||||
|     missing = sorted(db_domains - config_domains) |  | ||||||
| 
 |  | ||||||
|     ANALYSIS_DIR.mkdir(parents=True, exist_ok=True) |  | ||||||
|     out_path = ANALYSIS_DIR / "missing_domains.json" |  | ||||||
|     out_path.write_text(json.dumps(missing, indent=2)) |  | ||||||
| 
 |  | ||||||
|     if json_output: |  | ||||||
|         typer.echo(json.dumps(missing)) |  | ||||||
|     else: |  | ||||||
|         for d in missing: |  | ||||||
|             typer.echo(d) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def suggest_cache( |  | ||||||
|     threshold: int = 10, |  | ||||||
|     json_output: bool = False, |  | ||||||
| ) -> None: |  | ||||||
|     """Suggest domain/path pairs that could benefit from caching. |  | ||||||
| 
 |  | ||||||
|     Paths with at least ``threshold`` ``MISS`` entries are shown for domains |  | ||||||
|     whose server blocks lack a ``proxy_cache`` directive. |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     # Discover domains without explicit proxy_cache |  | ||||||
|     paths = nginx_config.discover_configs() |  | ||||||
|     servers = nginx_config.parse_servers(paths) |  | ||||||
|     no_cache: Set[str] = set() |  | ||||||
|     for server in servers: |  | ||||||
|         if "proxy_cache" in server: |  | ||||||
|             continue |  | ||||||
|         for name in server.get("server_name", "").split(): |  | ||||||
|             if name: |  | ||||||
|                 no_cache.add(name) |  | ||||||
| 
 |  | ||||||
|     conn = _connect() |  | ||||||
|     cur = conn.cursor() |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT host, |  | ||||||
|                substr(request, instr(request, ' ')+1, |  | ||||||
|                       instr(request, ' HTTP') - instr(request, ' ') - 1) AS path, |  | ||||||
|                COUNT(*) AS miss_count |  | ||||||
|         FROM logs |  | ||||||
|         WHERE cache_status = 'MISS' |  | ||||||
|         GROUP BY host, path |  | ||||||
|         HAVING miss_count >= ? |  | ||||||
|         ORDER BY miss_count DESC |  | ||||||
|         """, |  | ||||||
|         (int(threshold),), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     rows = [r for r in cur.fetchall() if r[0] in no_cache] |  | ||||||
|     conn.close() |  | ||||||
| 
 |  | ||||||
|     result = [ |  | ||||||
|         {"host": host, "path": path, "misses": count} for host, path, count in rows |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
|     ANALYSIS_DIR.mkdir(parents=True, exist_ok=True) |  | ||||||
|     out_path = ANALYSIS_DIR / "cache_suggestions.json" |  | ||||||
|     out_path.write_text(json.dumps(result, indent=2)) |  | ||||||
| 
 |  | ||||||
|     if json_output: |  | ||||||
|         typer.echo(json.dumps(result)) |  | ||||||
|     else: |  | ||||||
|         for item in result: |  | ||||||
|             typer.echo(f"{item['host']} {item['path']} {item['misses']}") |  | ||||||
| 
 |  | ||||||
| @app.command("suggest-cache") |  | ||||||
| def suggest_cache_cli( |  | ||||||
|     threshold: int = typer.Option(10, help="Minimum number of MISS entries to report"), |  | ||||||
|     json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), |  | ||||||
| ) -> None: |  | ||||||
|     """CLI wrapper for suggest_cache.""" |  | ||||||
|     suggest_cache(threshold=threshold, json_output=json_output) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def detect_threats( |  | ||||||
|     hours: int = 1, |  | ||||||
|     ip_threshold: int = 100, |  | ||||||
| ) -> None: |  | ||||||
|     """Detect potential security threats from recent logs.""" |  | ||||||
| 
 |  | ||||||
|     conn = _connect() |  | ||||||
|     cur = conn.cursor() |  | ||||||
| 
 |  | ||||||
|     cur.execute("SELECT MAX(time) FROM logs") |  | ||||||
|     row = cur.fetchone() |  | ||||||
|     if not row or not row[0]: |  | ||||||
|         typer.echo("No logs found") |  | ||||||
|         conn.close() |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     max_dt = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") |  | ||||||
|     recent_end = max_dt |  | ||||||
|     recent_start = recent_end - timedelta(hours=int(hours)) |  | ||||||
|     prev_start = recent_start - timedelta(hours=int(hours)) |  | ||||||
|     prev_end = recent_start |  | ||||||
| 
 |  | ||||||
|     fmt = "%Y-%m-%d %H:%M:%S" |  | ||||||
|     recent_start_s = recent_start.strftime(fmt) |  | ||||||
|     recent_end_s = recent_end.strftime(fmt) |  | ||||||
|     prev_start_s = prev_start.strftime(fmt) |  | ||||||
|     prev_end_s = prev_end.strftime(fmt) |  | ||||||
| 
 |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT host, |  | ||||||
|                SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS errors, |  | ||||||
|                COUNT(*) AS total |  | ||||||
|         FROM logs |  | ||||||
|         WHERE time >= ? AND time < ? |  | ||||||
|         GROUP BY host |  | ||||||
|         """, |  | ||||||
|         (recent_start_s, recent_end_s), |  | ||||||
|     ) |  | ||||||
|     recent_rows = {r[0]: (r[1], r[2]) for r in cur.fetchall()} |  | ||||||
| 
 |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT host, |  | ||||||
|                SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS errors, |  | ||||||
|                COUNT(*) AS total |  | ||||||
|         FROM logs |  | ||||||
|         WHERE time >= ? AND time < ? |  | ||||||
|         GROUP BY host |  | ||||||
|         """, |  | ||||||
|         (prev_start_s, prev_end_s), |  | ||||||
|     ) |  | ||||||
|     prev_rows = {r[0]: (r[1], r[2]) for r in cur.fetchall()} |  | ||||||
| 
 |  | ||||||
|     error_spikes = [] |  | ||||||
|     for host in set(recent_rows) | set(prev_rows): |  | ||||||
|         r_err, r_total = recent_rows.get(host, (0, 0)) |  | ||||||
|         p_err, p_total = prev_rows.get(host, (0, 0)) |  | ||||||
|         r_rate = r_err * 100.0 / r_total if r_total else 0.0 |  | ||||||
|         p_rate = p_err * 100.0 / p_total if p_total else 0.0 |  | ||||||
|         if r_rate >= 10 and r_rate >= p_rate * 2: |  | ||||||
|             error_spikes.append( |  | ||||||
|                 { |  | ||||||
|                     "host": host, |  | ||||||
|                     "recent_error_rate": round(r_rate, 2), |  | ||||||
|                     "previous_error_rate": round(p_rate, 2), |  | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT DISTINCT user_agent FROM logs |  | ||||||
|         WHERE time >= ? AND time < ? |  | ||||||
|         """, |  | ||||||
|         (prev_start_s, prev_end_s), |  | ||||||
|     ) |  | ||||||
|     prev_agents = {r[0] for r in cur.fetchall()} |  | ||||||
| 
 |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT user_agent, COUNT(*) AS c |  | ||||||
|         FROM logs |  | ||||||
|         WHERE time >= ? AND time < ? |  | ||||||
|         GROUP BY user_agent |  | ||||||
|         HAVING c >= 10 |  | ||||||
|         """, |  | ||||||
|         (recent_start_s, recent_end_s), |  | ||||||
|     ) |  | ||||||
|     suspicious_agents = [ |  | ||||||
|         {"user_agent": ua, "requests": cnt} |  | ||||||
|         for ua, cnt in cur.fetchall() |  | ||||||
|         if ua not in prev_agents |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
|     cur.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT ip, COUNT(*) AS c |  | ||||||
|         FROM logs |  | ||||||
|         WHERE time >= ? AND time < ? |  | ||||||
|         GROUP BY ip |  | ||||||
|         HAVING c >= ? |  | ||||||
|         ORDER BY c DESC |  | ||||||
|         """, |  | ||||||
|         (recent_start_s, recent_end_s, ip_threshold), |  | ||||||
|     ) |  | ||||||
|     high_ip_requests = [{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()] |  | ||||||
| 
 |  | ||||||
|     conn.close() |  | ||||||
| 
 |  | ||||||
|     report = { |  | ||||||
|         "time_range": { |  | ||||||
|             "recent_start": recent_start_s, |  | ||||||
|             "recent_end": recent_end_s, |  | ||||||
|             "previous_start": prev_start_s, |  | ||||||
|             "previous_end": prev_end_s, |  | ||||||
|         }, |  | ||||||
|         "error_spikes": error_spikes, |  | ||||||
|         "suspicious_agents": suspicious_agents, |  | ||||||
|         "high_ip_requests": high_ip_requests, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     ANALYSIS_DIR.mkdir(parents=True, exist_ok=True) |  | ||||||
|     out_path = ANALYSIS_DIR / "threat_report.json" |  | ||||||
|     out_path.write_text(json.dumps(report, indent=2)) |  | ||||||
|     typer.echo(json.dumps(report)) |  | ||||||
| 
 |  | ||||||
| @app.command("detect-threats") |  | ||||||
| def detect_threats_cli( |  | ||||||
|     hours: int = typer.Option(1, help="Number of recent hours to analyze"), |  | ||||||
|     ip_threshold: int = typer.Option(100, help="Requests from a single IP to flag"), |  | ||||||
| ) -> None: |  | ||||||
|     """CLI wrapper for detect_threats.""" |  | ||||||
|     detect_threats(hours=hours, ip_threshold=ip_threshold) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     app() |  | ||||||
|  | @ -1,28 +0,0 @@ | ||||||
| import json |  | ||||||
| from urllib.request import urlopen, Request |  | ||||||
| from pathlib import Path |  | ||||||
| 
 |  | ||||||
| ICON_LIST_URL = "https://cc0-icons.jonh.eu/icons.json" |  | ||||||
| BASE_URL = "https://cc0-icons.jonh.eu/" |  | ||||||
| 
 |  | ||||||
| OUTPUT_DIR = Path(__file__).resolve().parent.parent / "static" / "icons" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def main() -> None: |  | ||||||
|     OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |  | ||||||
|     req = Request(ICON_LIST_URL, headers={"User-Agent": "Mozilla/5.0"}) |  | ||||||
|     with urlopen(req) as resp: |  | ||||||
|         data = json.load(resp) |  | ||||||
|     icons = data.get("icons", []) |  | ||||||
|     for icon in icons: |  | ||||||
|         slug = icon.get("slug") |  | ||||||
|         url = BASE_URL + icon.get("url") |  | ||||||
|         path = OUTPUT_DIR / f"{slug}.svg" |  | ||||||
|         req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) |  | ||||||
|         with urlopen(req) as resp: |  | ||||||
|             path.write_bytes(resp.read()) |  | ||||||
|     print(f"Downloaded {len(icons)} icons to {OUTPUT_DIR}") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     main() |  | ||||||
|  | @ -1,27 +1,17 @@ | ||||||
| import json | import json | ||||||
| import sys |  | ||||||
| import sqlite3 | import sqlite3 | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| import shutil |  | ||||||
| from typing import List, Dict, Optional | from typing import List, Dict, Optional | ||||||
| from datetime import datetime, timezone |  | ||||||
| import time |  | ||||||
| 
 | 
 | ||||||
| import yaml | import yaml | ||||||
| 
 | 
 | ||||||
| import typer | import typer | ||||||
| from jinja2 import Environment, FileSystemLoader | from jinja2 import Environment, FileSystemLoader | ||||||
| 
 | 
 | ||||||
| # Ensure project root is importable when running as a script (python scripts/generate_reports.py) |  | ||||||
| PROJECT_ROOT = Path(__file__).resolve().parent.parent |  | ||||||
| if str(PROJECT_ROOT) not in sys.path: |  | ||||||
|     sys.path.insert(0, str(PROJECT_ROOT)) |  | ||||||
| 
 |  | ||||||
| DB_PATH = Path("database/ngxstat.db") | DB_PATH = Path("database/ngxstat.db") | ||||||
| OUTPUT_DIR = Path("output") | OUTPUT_DIR = Path("output") | ||||||
| TEMPLATE_DIR = Path("templates") | TEMPLATE_DIR = Path("templates") | ||||||
| REPORT_CONFIG = Path("reports.yml") | REPORT_CONFIG = Path("reports.yml") | ||||||
| GENERATED_MARKER = OUTPUT_DIR / "generated.txt" |  | ||||||
| 
 | 
 | ||||||
| # Mapping of interval names to SQLite strftime formats.  These strings are | # Mapping of interval names to SQLite strftime formats.  These strings are | ||||||
| # substituted into report queries whenever the special ``{bucket}`` token is | # substituted into report queries whenever the special ``{bucket}`` token is | ||||||
|  | @ -37,19 +27,6 @@ INTERVAL_FORMATS = { | ||||||
| app = typer.Typer(help="Generate aggregated log reports") | app = typer.Typer(help="Generate aggregated log reports") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.callback() |  | ||||||
| def _cli_callback(ctx: typer.Context) -> None: |  | ||||||
|     """Register post-command hook to note generation time.""" |  | ||||||
| 
 |  | ||||||
|     def _write_marker() -> None: |  | ||||||
|         OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |  | ||||||
|         # Use timezone-aware UTC to avoid deprecation warnings and ambiguity |  | ||||||
|         timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") |  | ||||||
|         GENERATED_MARKER.write_text(f"{timestamp}\n") |  | ||||||
| 
 |  | ||||||
|     ctx.call_on_close(_write_marker) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _get_domains() -> List[str]: | def _get_domains() -> List[str]: | ||||||
|     """Return a sorted list of unique domains from the logs table.""" |     """Return a sorted list of unique domains from the logs table.""" | ||||||
|     conn = sqlite3.connect(DB_PATH) |     conn = sqlite3.connect(DB_PATH) | ||||||
|  | @ -77,20 +54,6 @@ def _save_json(path: Path, data: List[Dict]) -> None: | ||||||
|     path.write_text(json.dumps(data, indent=2)) |     path.write_text(json.dumps(data, indent=2)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _copy_icons() -> None: |  | ||||||
|     """Copy vendored icons and scripts to the output directory.""" |  | ||||||
|     src_dir = Path("static/icons") |  | ||||||
|     dst_dir = OUTPUT_DIR / "icons" |  | ||||||
|     if src_dir.is_dir(): |  | ||||||
|         dst_dir.mkdir(parents=True, exist_ok=True) |  | ||||||
|         for icon in src_dir.glob("*.svg"): |  | ||||||
|             shutil.copy(icon, dst_dir / icon.name) |  | ||||||
| 
 |  | ||||||
|     js_src = Path("static/chartManager.js") |  | ||||||
|     if js_src.is_file(): |  | ||||||
|         shutil.copy(js_src, OUTPUT_DIR / js_src.name) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _render_snippet(report: Dict, out_dir: Path) -> None: | def _render_snippet(report: Dict, out_dir: Path) -> None: | ||||||
|     """Render a single report snippet to ``<name>.html`` inside ``out_dir``.""" |     """Render a single report snippet to ``<name>.html`` inside ``out_dir``.""" | ||||||
|     env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) |     env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) | ||||||
|  | @ -99,9 +62,7 @@ def _render_snippet(report: Dict, out_dir: Path) -> None: | ||||||
|     snippet_path.write_text(template.render(report=report)) |     snippet_path.write_text(template.render(report=report)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _write_stats( | def _write_stats() -> None: | ||||||
|     generated_at: Optional[str] = None, generation_seconds: Optional[float] = None |  | ||||||
| ) -> None: |  | ||||||
|     """Query basic dataset stats and write them to ``output/global/stats.json``.""" |     """Query basic dataset stats and write them to ``output/global/stats.json``.""" | ||||||
|     conn = sqlite3.connect(DB_PATH) |     conn = sqlite3.connect(DB_PATH) | ||||||
|     cur = conn.cursor() |     cur = conn.cursor() | ||||||
|  | @ -125,10 +86,6 @@ def _write_stats( | ||||||
|         "end_date": end_date, |         "end_date": end_date, | ||||||
|         "unique_domains": unique_domains, |         "unique_domains": unique_domains, | ||||||
|     } |     } | ||||||
|     if generated_at: |  | ||||||
|         stats["generated_at"] = generated_at |  | ||||||
|     if generation_seconds is not None: |  | ||||||
|         stats["generation_seconds"] = generation_seconds |  | ||||||
| 
 | 
 | ||||||
|     out_path = OUTPUT_DIR / "global" / "stats.json" |     out_path = OUTPUT_DIR / "global" / "stats.json" | ||||||
|     _save_json(out_path, stats) |     _save_json(out_path, stats) | ||||||
|  | @ -149,8 +106,6 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None: | ||||||
|         typer.echo("No report definitions found") |         typer.echo("No report definitions found") | ||||||
|         return |         return | ||||||
| 
 | 
 | ||||||
|     _copy_icons() |  | ||||||
| 
 |  | ||||||
|     bucket = _bucket_expr(interval) |     bucket = _bucket_expr(interval) | ||||||
| 
 | 
 | ||||||
|     conn = sqlite3.connect(DB_PATH) |     conn = sqlite3.connect(DB_PATH) | ||||||
|  | @ -185,16 +140,6 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None: | ||||||
|         name = definition["name"] |         name = definition["name"] | ||||||
|         query = definition["query"].replace("{bucket}", bucket) |         query = definition["query"].replace("{bucket}", bucket) | ||||||
|         query = query.replace("FROM logs", "FROM logs_view") |         query = query.replace("FROM logs", "FROM logs_view") | ||||||
|         # Apply top_n limit for tables (performance-friendly), if configured |  | ||||||
|         top_n = definition.get("top_n") |  | ||||||
|         chart_type = definition.get("chart", "line") |  | ||||||
|         if top_n and chart_type == "table": |  | ||||||
|             try: |  | ||||||
|                 n = int(top_n) |  | ||||||
|                 if "LIMIT" not in query.upper(): |  | ||||||
|                     query = f"{query}\nLIMIT {n}" |  | ||||||
|             except Exception: |  | ||||||
|                 pass |  | ||||||
|         cur.execute(query) |         cur.execute(query) | ||||||
|         rows = cur.fetchall() |         rows = cur.fetchall() | ||||||
|         headers = [c[0] for c in cur.description] |         headers = [c[0] for c in cur.description] | ||||||
|  | @ -208,37 +153,14 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None: | ||||||
|             "json": f"{name}.json", |             "json": f"{name}.json", | ||||||
|             "html": f"{name}.html", |             "html": f"{name}.html", | ||||||
|         } |         } | ||||||
|         if "icon" in definition: |  | ||||||
|             entry["icon"] = definition["icon"] |  | ||||||
|         if "bucket" in definition: |  | ||||||
|             entry["bucket"] = definition["bucket"] |  | ||||||
|         if "buckets" in definition: |  | ||||||
|             entry["buckets"] = definition["buckets"] |  | ||||||
|         if "bucket_label" in definition: |  | ||||||
|             entry["bucket_label"] = definition["bucket_label"] |  | ||||||
|         if "color" in definition: |         if "color" in definition: | ||||||
|             entry["color"] = definition["color"] |             entry["color"] = definition["color"] | ||||||
|         if "colors" in definition: |         if "colors" in definition: | ||||||
|             entry["colors"] = definition["colors"] |             entry["colors"] = definition["colors"] | ||||||
|         # Optional UX metadata passthrough for frontend-only transforms |  | ||||||
|         for key in ( |  | ||||||
|             "windows_supported", |  | ||||||
|             "window_default", |  | ||||||
|             "group_others_threshold", |  | ||||||
|             "exclude_values", |  | ||||||
|             "top_n", |  | ||||||
|             "stacked", |  | ||||||
|             "palette", |  | ||||||
|         ): |  | ||||||
|             if key in definition: |  | ||||||
|                 entry[key] = definition[key] |  | ||||||
|         _render_snippet(entry, out_dir) |         _render_snippet(entry, out_dir) | ||||||
|         report_list.append(entry) |         report_list.append(entry) | ||||||
| 
 | 
 | ||||||
|     _save_json(out_dir / "reports.json", report_list) |     _save_json(out_dir / "reports.json", report_list) | ||||||
|     if domain: |  | ||||||
|         typer.echo(f"Generated {interval} reports for {domain}") |  | ||||||
|     else: |  | ||||||
|     typer.echo(f"Generated {interval} reports") |     typer.echo(f"Generated {interval} reports") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -250,10 +172,12 @@ def _generate_all_domains(interval: str) -> None: | ||||||
| 
 | 
 | ||||||
| def _generate_root_index() -> None: | def _generate_root_index() -> None: | ||||||
|     """Render the top-level index listing all intervals and domains.""" |     """Render the top-level index listing all intervals and domains.""" | ||||||
|     _copy_icons() |     intervals = [ | ||||||
|     intervals = sorted( |         p.name | ||||||
|         [name for name in INTERVAL_FORMATS if (OUTPUT_DIR / name).is_dir()] |         for p in OUTPUT_DIR.iterdir() | ||||||
|     ) |         if p.is_dir() and p.name not in {"domains", "global"} | ||||||
|  |     ] | ||||||
|  |     intervals.sort() | ||||||
| 
 | 
 | ||||||
|     domains_dir = OUTPUT_DIR / "domains" |     domains_dir = OUTPUT_DIR / "domains" | ||||||
|     domains: List[str] = [] |     domains: List[str] = [] | ||||||
|  | @ -277,12 +201,6 @@ def _generate_global() -> None: | ||||||
|         typer.echo("No report definitions found") |         typer.echo("No report definitions found") | ||||||
|         return |         return | ||||||
| 
 | 
 | ||||||
|     start_time = time.time() |  | ||||||
|     # Use timezone-aware UTC for generated_at (string remains unchanged format) |  | ||||||
|     generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") |  | ||||||
| 
 |  | ||||||
|     _copy_icons() |  | ||||||
| 
 |  | ||||||
|     conn = sqlite3.connect(DB_PATH) |     conn = sqlite3.connect(DB_PATH) | ||||||
|     cur = conn.cursor() |     cur = conn.cursor() | ||||||
| 
 | 
 | ||||||
|  | @ -296,16 +214,6 @@ def _generate_global() -> None: | ||||||
| 
 | 
 | ||||||
|         name = definition["name"] |         name = definition["name"] | ||||||
|         query = definition["query"] |         query = definition["query"] | ||||||
|         # Apply top_n limit for tables (performance-friendly), if configured |  | ||||||
|         top_n = definition.get("top_n") |  | ||||||
|         chart_type = definition.get("chart", "line") |  | ||||||
|         if top_n and chart_type == "table": |  | ||||||
|             try: |  | ||||||
|                 n = int(top_n) |  | ||||||
|                 if "LIMIT" not in query.upper(): |  | ||||||
|                     query = f"{query}\nLIMIT {n}" |  | ||||||
|             except Exception: |  | ||||||
|                 pass |  | ||||||
|         cur.execute(query) |         cur.execute(query) | ||||||
|         rows = cur.fetchall() |         rows = cur.fetchall() | ||||||
|         headers = [c[0] for c in cur.description] |         headers = [c[0] for c in cur.description] | ||||||
|  | @ -319,67 +227,18 @@ def _generate_global() -> None: | ||||||
|             "json": f"{name}.json", |             "json": f"{name}.json", | ||||||
|             "html": f"{name}.html", |             "html": f"{name}.html", | ||||||
|         } |         } | ||||||
|         if "icon" in definition: |  | ||||||
|             entry["icon"] = definition["icon"] |  | ||||||
|         if "bucket" in definition: |  | ||||||
|             entry["bucket"] = definition["bucket"] |  | ||||||
|         if "buckets" in definition: |  | ||||||
|             entry["buckets"] = definition["buckets"] |  | ||||||
|         if "bucket_label" in definition: |  | ||||||
|             entry["bucket_label"] = definition["bucket_label"] |  | ||||||
|         if "color" in definition: |         if "color" in definition: | ||||||
|             entry["color"] = definition["color"] |             entry["color"] = definition["color"] | ||||||
|         if "colors" in definition: |         if "colors" in definition: | ||||||
|             entry["colors"] = definition["colors"] |             entry["colors"] = definition["colors"] | ||||||
|         # Optional UX metadata passthrough for frontend-only transforms |  | ||||||
|         for key in ( |  | ||||||
|             "windows_supported", |  | ||||||
|             "window_default", |  | ||||||
|             "group_others_threshold", |  | ||||||
|             "exclude_values", |  | ||||||
|             "top_n", |  | ||||||
|             "stacked", |  | ||||||
|             "palette", |  | ||||||
|         ): |  | ||||||
|             if key in definition: |  | ||||||
|                 entry[key] = definition[key] |  | ||||||
|         _render_snippet(entry, out_dir) |         _render_snippet(entry, out_dir) | ||||||
|         report_list.append(entry) |         report_list.append(entry) | ||||||
| 
 | 
 | ||||||
|     _save_json(out_dir / "reports.json", report_list) |     _save_json(out_dir / "reports.json", report_list) | ||||||
|     elapsed = round(time.time() - start_time, 2) |     _write_stats() | ||||||
|     _write_stats(generated_at, elapsed) |  | ||||||
|     typer.echo("Generated global reports") |     typer.echo("Generated global reports") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _generate_analysis() -> None: |  | ||||||
|     """Generate analysis JSON files consumed by the Analysis tab.""" |  | ||||||
|     try: |  | ||||||
|         # Import lazily to avoid circulars and keep dependencies optional |  | ||||||
|         from scripts import analyze |  | ||||||
|     except Exception as exc:  # pragma: no cover - defensive |  | ||||||
|         typer.echo(f"Failed to import analysis module: {exc}") |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     # Ensure output root and icons present for parity |  | ||||||
|     _copy_icons() |  | ||||||
| 
 |  | ||||||
|     # These commands write JSON files under output/analysis/ |  | ||||||
|     try: |  | ||||||
|         analyze.check_missing_domains(json_output=True) |  | ||||||
|     except Exception as exc:  # pragma: no cover - continue best-effort |  | ||||||
|         typer.echo(f"check_missing_domains failed: {exc}") |  | ||||||
|     try: |  | ||||||
|         analyze.suggest_cache(json_output=True) |  | ||||||
|     except Exception as exc:  # pragma: no cover |  | ||||||
|         typer.echo(f"suggest_cache failed: {exc}") |  | ||||||
|     try: |  | ||||||
|         analyze.detect_threats() |  | ||||||
|     except Exception as exc:  # pragma: no cover |  | ||||||
|         typer.echo(f"detect_threats failed: {exc}") |  | ||||||
|     typer.echo("Generated analysis JSON files") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() | @app.command() | ||||||
| def hourly( | def hourly( | ||||||
|     domain: Optional[str] = typer.Option( |     domain: Optional[str] = typer.Option( | ||||||
|  | @ -450,12 +309,6 @@ def global_reports() -> None: | ||||||
|     _generate_global() |     _generate_global() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.command() |  | ||||||
| def analysis() -> None: |  | ||||||
|     """Generate analysis JSON files for the Analysis tab.""" |  | ||||||
|     _generate_analysis() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @app.command() | @app.command() | ||||||
| def index() -> None: | def index() -> None: | ||||||
|     """Generate the root index page linking all reports.""" |     """Generate the root index page linking all reports.""" | ||||||
|  |  | ||||||
|  | @ -61,9 +61,7 @@ try: | ||||||
|             suffix = match.group(1) |             suffix = match.group(1) | ||||||
|             number = int(suffix.lstrip(".")) if suffix else 0 |             number = int(suffix.lstrip(".")) if suffix else 0 | ||||||
|             log_files.append((number, os.path.join(LOG_DIR, f))) |             log_files.append((number, os.path.join(LOG_DIR, f))) | ||||||
|     log_files = [ |     log_files = [path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)] | ||||||
|         path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True) |  | ||||||
|     ] |  | ||||||
| except FileNotFoundError: | except FileNotFoundError: | ||||||
|     print(f"[ERROR] Log directory not found: {LOG_DIR}") |     print(f"[ERROR] Log directory not found: {LOG_DIR}") | ||||||
|     exit(1) |     exit(1) | ||||||
|  |  | ||||||
|  | @ -1,95 +0,0 @@ | ||||||
| #!/usr/bin/env python3 |  | ||||||
| """Utilities for discovering and parsing Nginx configuration files. |  | ||||||
| 
 |  | ||||||
| This module provides helper functions to locate Nginx configuration files and |  | ||||||
| extract key details from ``server`` blocks.  Typical usage:: |  | ||||||
| 
 |  | ||||||
|     from scripts.nginx_config import discover_configs, parse_servers |  | ||||||
| 
 |  | ||||||
|     files = discover_configs() |  | ||||||
|     servers = parse_servers(files) |  | ||||||
|     for s in servers: |  | ||||||
|         print(s.get("server_name"), s.get("listen")) |  | ||||||
| 
 |  | ||||||
| The functions intentionally tolerate missing or unreadable files and will simply |  | ||||||
| skip over them. |  | ||||||
| """ |  | ||||||
| 
 |  | ||||||
| import os |  | ||||||
| import re |  | ||||||
| from pathlib import Path |  | ||||||
| from typing import Dict, List, Set |  | ||||||
| 
 |  | ||||||
| DEFAULT_PATHS = [ |  | ||||||
|     "/etc/nginx/nginx.conf", |  | ||||||
|     "/usr/local/etc/nginx/nginx.conf", |  | ||||||
| ] |  | ||||||
| 
 |  | ||||||
| INCLUDE_RE = re.compile(r"^\s*include\s+(.*?);", re.MULTILINE) |  | ||||||
| SERVER_RE = re.compile(r"server\s*{(.*?)}", re.DOTALL) |  | ||||||
| DIRECTIVE_RE = re.compile(r"^\s*(\S+)\s+(.*?);", re.MULTILINE) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def discover_configs() -> Set[Path]: |  | ||||||
|     """Return a set of all config files reachable from :data:`DEFAULT_PATHS`.""" |  | ||||||
| 
 |  | ||||||
|     found: Set[Path] = set() |  | ||||||
|     queue = [Path(p) for p in DEFAULT_PATHS] |  | ||||||
| 
 |  | ||||||
|     while queue: |  | ||||||
|         path = queue.pop() |  | ||||||
|         if path in found: |  | ||||||
|             continue |  | ||||||
|         if not path.exists(): |  | ||||||
|             continue |  | ||||||
|         try: |  | ||||||
|             text = path.read_text() |  | ||||||
|         except OSError: |  | ||||||
|             continue |  | ||||||
|         found.add(path) |  | ||||||
|         for pattern in INCLUDE_RE.findall(text): |  | ||||||
|             pattern = os.path.expanduser(pattern.strip()) |  | ||||||
|             if os.path.isabs(pattern): |  | ||||||
|                 # ``Path.glob`` does not allow absolute patterns, so we |  | ||||||
|                 # anchor at the filesystem root and remove the leading |  | ||||||
|                 # separator. |  | ||||||
|                 base = Path(os.sep) |  | ||||||
|                 glob_iter = base.glob(pattern.lstrip(os.sep)) |  | ||||||
|             else: |  | ||||||
|                 glob_iter = path.parent.glob(pattern) |  | ||||||
|             for included in glob_iter: |  | ||||||
|                 if included.is_file() and included not in found: |  | ||||||
|                     queue.append(included) |  | ||||||
|     return found |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def parse_servers(paths: Set[Path]) -> List[Dict[str, str]]: |  | ||||||
|     """Parse ``server`` blocks from the given files. |  | ||||||
| 
 |  | ||||||
|     Parameters |  | ||||||
|     ---------- |  | ||||||
|     paths: |  | ||||||
|         Iterable of configuration file paths. |  | ||||||
|     """ |  | ||||||
| 
 |  | ||||||
|     servers: List[Dict[str, str]] = [] |  | ||||||
|     for p in paths: |  | ||||||
|         try: |  | ||||||
|             text = Path(p).read_text() |  | ||||||
|         except OSError: |  | ||||||
|             continue |  | ||||||
|         for block in SERVER_RE.findall(text): |  | ||||||
|             directives: Dict[str, List[str]] = {} |  | ||||||
|             for name, value in DIRECTIVE_RE.findall(block): |  | ||||||
|                 directives.setdefault(name, []).append(value.strip()) |  | ||||||
|             entry: Dict[str, str] = {} |  | ||||||
|             if "server_name" in directives: |  | ||||||
|                 entry["server_name"] = " ".join(directives["server_name"]) |  | ||||||
|             if "listen" in directives: |  | ||||||
|                 entry["listen"] = " ".join(directives["listen"]) |  | ||||||
|             if "proxy_cache" in directives: |  | ||||||
|                 entry["proxy_cache"] = " ".join(directives["proxy_cache"]) |  | ||||||
|             if "root" in directives: |  | ||||||
|                 entry["root"] = " ".join(directives["root"]) |  | ||||||
|             servers.append(entry) |  | ||||||
|     return servers |  | ||||||
							
								
								
									
										46
									
								
								setup.sh
									
										
									
									
									
								
							
							
						
						|  | @ -1,46 +0,0 @@ | ||||||
| #!/usr/bin/env bash |  | ||||||
| set -e |  | ||||||
| 
 |  | ||||||
| # Default schedules |  | ||||||
| import_sched="*/5 * * * *" |  | ||||||
| report_sched="0 * * * *" |  | ||||||
| analysis_sched="0 0 * * *" |  | ||||||
| remove=false |  | ||||||
| 
 |  | ||||||
| usage() { |  | ||||||
|     echo "Usage: $0 [--import CRON] [--reports CRON] [--analysis CRON] [--remove]" |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| while [ $# -gt 0 ]; do |  | ||||||
|     case "$1" in |  | ||||||
|         --import) |  | ||||||
|             import_sched="$2"; shift 2;; |  | ||||||
|         --reports) |  | ||||||
|             report_sched="$2"; shift 2;; |  | ||||||
|         --analysis) |  | ||||||
|             analysis_sched="$2"; shift 2;; |  | ||||||
|         --remove) |  | ||||||
|             remove=true; shift;; |  | ||||||
|         -h|--help) |  | ||||||
|             usage; exit 0;; |  | ||||||
|         *) |  | ||||||
|             usage; exit 1;; |  | ||||||
|     esac |  | ||||||
| done |  | ||||||
| 
 |  | ||||||
| repo_dir="$(cd "$(dirname "$0")" && pwd)" |  | ||||||
| 
 |  | ||||||
| if [ "$remove" = true ]; then |  | ||||||
|     tmp=$(mktemp) |  | ||||||
|     sudo crontab -l 2>/dev/null | grep -v "# ngxstat import" | grep -v "# ngxstat reports" | grep -v "# ngxstat analysis" > "$tmp" || true |  | ||||||
|     sudo crontab "$tmp" |  | ||||||
|     rm -f "$tmp" |  | ||||||
|     echo "[INFO] Removed ngxstat cron entries" |  | ||||||
|     exit 0 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| cron_entries="${import_sched} cd ${repo_dir} && ./run-import.sh # ngxstat import\n${report_sched} cd ${repo_dir} && ./run-reports.sh # ngxstat reports\n${analysis_sched} cd ${repo_dir} && ./run-analysis.sh # ngxstat analysis" |  | ||||||
| 
 |  | ||||||
| ( sudo crontab -l 2>/dev/null; echo -e "$cron_entries" ) | sudo crontab - |  | ||||||
| 
 |  | ||||||
| echo "[INFO] Installed ngxstat cron entries" |  | ||||||
|  | @ -1,109 +0,0 @@ | ||||||
| export let currentLoad = null; |  | ||||||
| const loadInfo = new Map(); |  | ||||||
| 
 |  | ||||||
| export function newLoad(container) { |  | ||||||
|   if (currentLoad) { |  | ||||||
|     abortLoad(currentLoad); |  | ||||||
|   } |  | ||||||
|   reset(container); |  | ||||||
|   const controller = new AbortController(); |  | ||||||
|   const token = { controller, charts: new Map() }; |  | ||||||
|   loadInfo.set(token, token); |  | ||||||
|   currentLoad = token; |  | ||||||
|   return token; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function abortLoad(token) { |  | ||||||
|   const info = loadInfo.get(token); |  | ||||||
|   if (!info) return; |  | ||||||
|   info.controller.abort(); |  | ||||||
|   info.charts.forEach(chart => { |  | ||||||
|     try { |  | ||||||
|       chart.destroy(); |  | ||||||
|     } catch (e) {} |  | ||||||
|   }); |  | ||||||
|   loadInfo.delete(token); |  | ||||||
|   if (currentLoad === token) { |  | ||||||
|     currentLoad = null; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function registerChart(token, id, chart) { |  | ||||||
|   const info = loadInfo.get(token); |  | ||||||
|   if (info) { |  | ||||||
|     info.charts.set(id, chart); |  | ||||||
|   } else { |  | ||||||
|     chart.destroy(); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function reset(container) { |  | ||||||
|   if (!container) return; |  | ||||||
|   container.querySelectorAll('canvas').forEach(c => { |  | ||||||
|     const chart = Chart.getChart(c); |  | ||||||
|     if (chart) { |  | ||||||
|       chart.destroy(); |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
|   container.innerHTML = ''; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // ---- Lightweight client-side data helpers ----
 |  | ||||||
| 
 |  | ||||||
| // Slice last N rows from a time-ordered array
 |  | ||||||
| export function sliceWindow(data, n) { |  | ||||||
|   if (!Array.isArray(data) || n === undefined || n === null) return data; |  | ||||||
|   if (n === 'all') return data; |  | ||||||
|   const count = Number(n); |  | ||||||
|   if (!Number.isFinite(count) || count <= 0) return data; |  | ||||||
|   return data.slice(-count); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Exclude rows whose value in key is in excluded list
 |  | ||||||
| export function excludeValues(data, key, excluded = []) { |  | ||||||
|   if (!excluded || excluded.length === 0) return data; |  | ||||||
|   const set = new Set(excluded); |  | ||||||
|   return data.filter(row => !set.has(row[key])); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Compute percentages for categorical distributions (valueKey default 'value')
 |  | ||||||
| export function toPercent(data, valueKey = 'value') { |  | ||||||
|   const total = data.reduce((s, r) => s + (Number(r[valueKey]) || 0), 0); |  | ||||||
|   if (total <= 0) return data.map(r => ({ ...r })); |  | ||||||
|   return data.map(r => ({ ...r, [valueKey]: (Number(r[valueKey]) || 0) * 100 / total })); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Group categories with share < threshold into an 'Other' bucket.
 |  | ||||||
| export function groupOthers(data, bucketKey, valueKey = 'value', threshold = 0.03, otherLabel = 'Other') { |  | ||||||
|   if (!Array.isArray(data) || data.length === 0) return data; |  | ||||||
|   const total = data.reduce((s, r) => s + (Number(r[valueKey]) || 0), 0); |  | ||||||
|   if (total <= 0) return data; |  | ||||||
|   const major = []; |  | ||||||
|   let other = 0; |  | ||||||
|   for (const r of data) { |  | ||||||
|     const v = Number(r[valueKey]) || 0; |  | ||||||
|     if (total && v / total < threshold) { |  | ||||||
|       other += v; |  | ||||||
|     } else { |  | ||||||
|       major.push({ ...r }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   if (other > 0) major.push({ [bucketKey]: otherLabel, [valueKey]: other }); |  | ||||||
|   return major; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Simple moving average over numeric array
 |  | ||||||
| export function movingAverage(series, span = 3) { |  | ||||||
|   const n = Math.max(1, Number(span) || 1); |  | ||||||
|   const out = []; |  | ||||||
|   for (let i = 0; i < series.length; i++) { |  | ||||||
|     const start = Math.max(0, i - n + 1); |  | ||||||
|     let sum = 0, cnt = 0; |  | ||||||
|     for (let j = start; j <= i; j++) { |  | ||||||
|       const v = Number(series[j]); |  | ||||||
|       if (Number.isFinite(v)) { sum += v; cnt++; } |  | ||||||
|     } |  | ||||||
|     out.push(cnt ? sum / cnt : null); |  | ||||||
|   } |  | ||||||
|   return out; |  | ||||||
| } |  | ||||||
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 13a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Z"/> |  | ||||||
|   <path d="M8.5 12 6 22l6-2 6 2-2.5-10"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 322 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M5.25 18h13.5M8 12h8M2.5 6h19"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 264 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M2.5 18H16M2.5 12h8m-8-6h19"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 262 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 18h13.5m-8-6h8m-19-6h19"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 261 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8.28 13.5H4.222A2.22 2.22 0 0 0 2 15.72v4.058C2 21.005 2.995 22 4.222 22H8.28a2.22 2.22 0 0 0 2.22-2.222V15.72c0-1.227-.993-2.22-2.22-2.22Zm0-11.5H4.222A2.22 2.22 0 0 0 2 4.22v4.058c0 1.227.995 2.222 2.222 2.222H8.28a2.22 2.22 0 0 0 2.22-2.222V4.22C10.5 2.993 9.507 2 8.28 2Zm11.5 0h-4.058A2.22 2.22 0 0 0 13.5 4.22v4.058c0 1.227.995 2.222 2.222 2.222h4.058A2.22 2.22 0 0 0 22 8.278V4.22C22 2.993 21.007 2 19.78 2Zm0 11.5h-4.058a2.22 2.22 0 0 0-2.222 2.22v4.058c0 1.227.995 2.222 2.222 2.222h4.058A2.22 2.22 0 0 0 22 19.778V15.72c0-1.227-.993-2.22-2.22-2.22Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 794 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M21 8.5v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9m16.791-4H4.21c-2.902 0-2 4-2 4h19.58c-.012 0 .902-4-2-4ZM13 12h-2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 343 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M4.932 10.595V4.94h5.657m-5.65-.008L19.08 19.074m0-5.663v5.657h-5.656"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 304 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M7.758 4.93h11.314v11.314m-.001-11.315L4.93 19.07M4.928 7.756V19.07h11.314"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 309 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M16.242 4.93H4.928v11.314m.001-11.315L19.07 19.071m.002-11.315V19.07H7.758"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 309 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.08 10.595V4.94h-5.656m5.65-.008L4.932 19.074m0-5.663v5.657h5.657"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 303 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m14 20 8-8-8-8m8 8H2m8 8-8-8 8-8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 267 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m18 16 4-4-4-4m4 4H2m4 4-4-4 4-4"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 267 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m4 10 8-8 8 8m-8-8v20m-8-8 8 8 8-8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 269 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m15.99 6-4-4-4 4M12 2v20m3.99-4-4 4-4-4"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 274 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m4 14 8 8 8-8M12 2v20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 256 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M5 8v11.314h11.314M19.142 5 5 19.142"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 271 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M4.932 13.411v5.657h5.657m-5.65.006L19.08 4.932"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 282 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19 8v11.314H7.686M4.858 5 19 19.142"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 271 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.08 13.411v5.657h-5.656m5.65.006L4.932 4.932"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 282 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m12.99 6 4-4 4 4m-4.01-4v20m-5.99-4-4 4-4-4M7 22V2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 285 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m15.99 18-4 4-4-4M12 22V2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 260 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m10 20-8-8 8-8m12 8H2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 256 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.987 11.005-4-4 4-4m-4 4.005h20m-3.994 5.985 4 4-4 4m4-4.005h-20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 301 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m6 16-4-4 4-4m-4 4h20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 256 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m14 20 8-8-8-8m8 8H2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 255 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.987 12.995-4 4 4 4m-4-4.005h20m-3.994-5.985 4-4-4-4m4 4.005h-20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 301 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m18 16 4-4-4-4m4 4H2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 255 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m4 10 8-8 8 8m-8-8v20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 256 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m10.99 6-4-4-4 4M7 2v20m5.99-4 4 4 4-4m-4.01 4V2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 283 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M5 16.314V5h11.314M19 19 5 5"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 263 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M4.932 10.595V4.94h5.657m-5.65-.008L19.08 19.074"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 283 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.314 16.314V5H8M5 19.142 19.142 5"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 271 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.08 10.595V4.94h-5.656m5.65-.008L4.932 19.074"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 283 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m15.99 6-4-4-4 4M12 2v20"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 259 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20.08 9.595V3.94h-5.656m5.65-.008L3.932 20.074m0-5.663v5.657H9.59M3.932 9.595V3.94H9.59m-5.651-.008L20.08 20.074m0-5.663v5.657h-5.656"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 369 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM2 22 22 2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 282 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20 6.5H4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-11a2 2 0 0 0-2-2ZM8 6V4.5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2V6M2 14h8m4 0h8"/> |  | ||||||
|   <path d="M12 16a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 414 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20 6.5H4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-11a2 2 0 0 0-2-2Zm-13 0v15m10-15v15M8 6V4.5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2V6"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 369 B | 
|  | @ -1,11 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" |  | ||||||
|     stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" |  | ||||||
|     viewBox="0 0 24 24"> |  | ||||||
|     <path |  | ||||||
|         d="M20 6.5H4C2.89543 6.5 2 7.39543 2 8.5V19.5C2 20.6046 2.89543 21.5 4 21.5H20C21.1046 21.5 22 20.6046 22 19.5V8.5C22 7.39543 21.1046 6.5 20 6.5Z" /> |  | ||||||
|     <path |  | ||||||
|         d="M8 6V4.5C8 3.96957 8.21071 3.46086 8.58579 3.08579C8.96086 2.71071 9.46957 2.5 10 2.5L14 2.5C14.5304 2.5 15.0391 2.71071 15.4142 3.08579C15.7893 3.46086 16 3.96957 16 4.5V6" /> |  | ||||||
|     <path |  | ||||||
|         d="M15.1111 13.5H8.88889C8.39797 13.5 8 13.898 8 14.3889V17.5C8 17.9909 8.39797 18.3889 8.88889 18.3889H15.1111C15.602 18.3889 16 17.9909 16 17.5V14.3889C16 13.898 15.602 13.5 15.1111 13.5Z" /> |  | ||||||
|     <path d="M14 13.5V11.8333C14 9.08164 10 9.08164 10 11.8333V13.5" /> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 877 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20 6.5H4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-11a2 2 0 0 0-2-2ZM8 6V4.5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2V6m0 8H8m4-4v8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 363 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 6V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1m5.5 4.5V19a2 2 0 0 1-2 2h-15a2 2 0 0 1-2-2v-8.5"/> |  | ||||||
|   <path d="M19.791 6H4.21C1.5 6 2 10 2 10s8 5 10 5 10-5 10-5 .5-4-2.209-4Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 399 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20 6.5H4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-11a2 2 0 0 0-2-2ZM8 6V4.5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2V6"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 351 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M5 14.539 11.77 2l-1.309 8.461 7.462-1L11.153 22l1.308-8.461-7.461 1Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 304 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M15.778 2H8.222A2.222 2.222 0 0 0 6 4.222V22l6-4.336L18 22V4.222A2.222 2.222 0 0 0 15.778 2Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 327 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M2.5 18h19m-19-6h19m-19-6h19"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 263 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.778 3.5H4.222C2.995 3.5 2 4.543 2 5.77v14.008C2 21.005 2.995 22 4.222 22h15.556A2.222 2.222 0 0 0 22 19.778V5.77c0-1.227-.995-2.27-2.222-2.27ZM7 5V2m10 3V2m5 7H2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 400 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.778 3.5H4.222C2.995 3.5 2 4.543 2 5.77v14.008C2 21.005 2.995 22 4.222 22h15.556A2.222 2.222 0 0 0 22 19.778V5.77c0-1.227-.995-2.27-2.222-2.27ZM7 5V2m10 3V2m5 7H2m5.5 4h-1m1 3h-1m1 3h-1m11-6h-1m1 3h-1m1 3h-1m-4-6h-1m1 3h-1m1 3h-1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 467 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="m17.85 7.5-7.678 9L6.15 12"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 348 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M19.778 2H4.222A2.222 2.222 0 0 0 2 4.222v15.556C2 21.005 2.995 22 4.222 22h15.556A2.222 2.222 0 0 0 22 19.778V4.222A2.222 2.222 0 0 0 19.778 2Z"/> |  | ||||||
|   <path d="M18.5 7 9.969 17 5.5 12"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 417 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M20 6 9.5 18 4 12"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 252 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="m5.8 9.95 6.2 6.1 6.2-6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 347 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.8 8.95 6.2 6.1 6.2-6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 260 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="M14.05 18.2 7.95 12l6.1-6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 349 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M15.05 18.2 8.95 12l6.1-6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 262 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="m9.95 5.8 6.1 6.2-6.1 6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 347 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m8.95 5.8 6.1 6.2-6.1 6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 260 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="m5.8 14.05 6.2-6.1 6.2 6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 348 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.8 15.05 6.2-6.1 6.2 6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 261 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m18.25 6.45-6.2 6.1-6.2-6.1m12.4 5-6.2 6.1-6.2-6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 285 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M17.6 5.8 11.5 12l6.1 6.2m-5-12.4L6.5 12l6.1 6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 283 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m6.5 5.8 6.1 6.2-6.1 6.2m5-12.4 6.1 6.2-6.1 6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 282 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.8 8.95 6.2 6.1 6.2-6.1m-12.4-5 6.2 6.1 6.2-6.1m-12.4 10 6.2 6.1 6.2-6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 309 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M15.05 5.8 8.95 12l6.1 6.2m5-12.4-6.1 6.2 6.1 6.2m-10-12.4L3.95 12l6.1 6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 309 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m8.95 18.2 6.1-6.2-6.1-6.2m-5 12.4 6.1-6.2-6.1-6.2m10 12.4 6.1-6.2-6.1-6.2"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 309 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M18.2 15.05 12 8.95l-6.2 6.1m12.4 5-6.2-6.1-6.2 6.1m12.4-10L12 3.95l-6.2 6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 311 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m5.85 17.55 6.2-6.1 6.2 6.1m-12.4-5 6.2-6.1 6.2 6.1"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 286 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 357 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path fill="#000" d="M16.5 12.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm-4.5 0a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm-4.5 0a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 451 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm-7-3L19 5"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 317 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 307 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16m-4 12v-5m0 8h.01"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 473 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16M8.5 16v3m3.5-5v5m3.5-7v7"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 481 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16"/> |  | ||||||
|   <path d="m15 11.5-3.938 4.615L9 13.808"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 500 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M9.55 3.275V3.7c0 .47.38.85.85.85h5.1c.47 0 .85-.38.85-.85v-.425m-6.8 0V2.85c0-.47.38-.85.85-.85h5.1c.47 0 .85.38.85.85v.425m-6.8 0h-.661A1.895 1.895 0 0 0 7 5.17v11.941A1.89 1.89 0 0 0 8.889 19h8.122a1.889 1.889 0 0 0 1.889-1.889V5.171a1.895 1.895 0 0 0-1.889-1.896h-.661"/> |  | ||||||
|   <path d="M7 6.275H5.889A1.895 1.895 0 0 0 4 8.17v11.941A1.89 1.89 0 0 0 5.889 22h8.122a1.889 1.889 0 0 0 1.889-1.889V19"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 632 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16M10.5 18H8m8 0h-2.5m-3-3H8m8 0h-2.5m2.5-3h-2.5m-3 0H8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 509 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16"/> |  | ||||||
|   <path d="m9 16 3.01 3L15 16m-3-5v8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 496 B | 
|  | @ -1,7 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" |  | ||||||
|     stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" |  | ||||||
|     viewBox="0 0 24 24"> |  | ||||||
|     <path |  | ||||||
|         d="M8 3.5V4C8 4.55228 8.44772 5 9 5H15C15.5523 5 16 4.55228 16 4V3.5M8 3.5V3C8 2.44772 8.44772 2 9 2H15C15.5523 2 16 2.44772 16 3V3.5M8 3.5L7.22222 3.5C5.99492 3.5 5 4.5027 5 5.73V19.7778C5 21.0051 5.99492 22 7.22222 22H16.7778C18.0051 22 19 21.0051 19 19.7778V5.73C19 4.5027 18.0051 3.5 16.7778 3.5L16 3.5" /> |  | ||||||
|     <path d="M16 13L12.5 16.5L10.2 14.8L8 17" /> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 605 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M8 3.5V4a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-.5m-8 0V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v.5m-8 0h-.778A2.229 2.229 0 0 0 5 5.73v14.048C5 21.005 5.995 22 7.222 22h9.556A2.222 2.222 0 0 0 19 19.778V5.73a2.23 2.23 0 0 0-2.222-2.23H16"/> |  | ||||||
|   <path d="m15 14-3.01-3L9 14m3-3v8"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 495 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="M6.6 7.5 12 12l4.8-1.5"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 344 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/> |  | ||||||
|   <path d="M12 5v7l4.4 2.4"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 337 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M7.01 15.314H5.79C1.795 15.318.458 9.97 4.505 8.5 3.476 5.69 7.01 2 10.017 5c2.004-5 10.521-3.5 9.018 2.5 4.705 1.49 3.69 7.814-1.527 7.814h-.477M8 18l4 4 4-4m-3.983 4V11"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 405 B | 
|  | @ -1,4 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M7.01 15.824H5.79C1.795 15.828.458 10.48 4.505 9.01c-1.029-2.81 2.505-6.5 5.512-3.5 2.004-5 10.521-3.5 9.018 2.5 4.705 1.49 3.69 7.814-1.527 7.814h-.477"/> |  | ||||||
|   <path d="m8 12.5 4-4 4 4m-4 9v-11"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 426 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="M5.782 19.164h11.694c5.207 0 6.2-7.04 1.504-8.53.923-6.2-6.47-8-8.497-2.5-3.488-2.5-7.095 1.19-6.068 4-4.04 1.47-2.62 7.034 1.367 7.03Z"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 370 B | 
|  | @ -1,3 +0,0 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24"> |  | ||||||
|   <path d="m8 19-6-7 6-7m8 0 6 7-6 7"/> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 260 B |