Compare commits
No commits in common. "main" and "codex/add-color-differentiation-to-charts" have entirely different histories.
main
...
codex/add-
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.
|
||||
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`
|
||||
* Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`)
|
||||
* 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.
|
||||
|
||||
## 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
|
||||
|
@ -117,4 +106,3 @@ As the project matures, agents may also:
|
|||
|
||||
* **2025-07-17**: Initial version by Jordan + ChatGPT
|
||||
* **2025-07-17**: Expanded virtual environment usage guidance
|
||||
|
||||
|
|
147
README.md
|
@ -1,16 +1,11 @@
|
|||
# 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
|
||||
logs into an SQLite database and renders static dashboards so you can explore
|
||||
per-domain metrics without running a heavy backend service.
|
||||
## Generating Reports
|
||||
|
||||
## Requirements
|
||||
Use the `generate_reports.py` script to build aggregated JSON and HTML files from `database/ngxstat.db`.
|
||||
|
||||
* Python 3.10+
|
||||
* 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:
|
||||
Create a virtual environment and install dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
|
@ -18,95 +13,105 @@ source .venv/bin/activate
|
|||
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 produces an HTML dashboard using Chart.js.
|
||||
|
||||
### 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` along with an HTML
|
||||
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
|
||||
|
||||
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
|
||||
./run-import.sh
|
||||
```
|
||||
|
||||
Rotated logs are processed in order and only entries newer than the last
|
||||
imported timestamp are added.
|
||||
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.
|
||||
|
||||
## 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
|
||||
all intervals in one go:
|
||||
## Cron Report Generation
|
||||
|
||||
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
|
||||
./run-reports.sh
|
||||
```
|
||||
|
||||
The script calls `scripts/generate_reports.py` internally to create hourly,
|
||||
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.
|
||||
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.
|
||||
|
||||
If you prefer to run individual commands you can invoke the generator directly:
|
||||
## Serving Reports with Nginx
|
||||
|
||||
```bash
|
||||
python scripts/generate_reports.py hourly
|
||||
python scripts/generate_reports.py daily --all-domains
|
||||
```
|
||||
|
||||
## 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:
|
||||
To expose the generated HTML dashboards and JSON files over HTTP you can use a
|
||||
simple Nginx server block. Point the `root` directive to the repository's
|
||||
`output/` directory and optionally restrict access to your local network.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
|
||||
# Path to the generated reports
|
||||
root /path/to/ngxstat/output;
|
||||
|
||||
location / {
|
||||
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.
|
||||
|
|
168
reports.yml
|
@ -1,37 +1,28 @@
|
|||
- name: hits
|
||||
label: Hits
|
||||
icon: pulse
|
||||
chart: line
|
||||
bucket: time_bucket
|
||||
bucket_label: Time
|
||||
query: |
|
||||
SELECT {bucket} AS time_bucket,
|
||||
SELECT {bucket} AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY time_bucket
|
||||
ORDER BY time_bucket
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
|
||||
- name: error_rate
|
||||
label: Error Rate (%)
|
||||
icon: file-alert
|
||||
chart: line
|
||||
bucket: time_bucket
|
||||
bucket_label: Time
|
||||
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
|
||||
FROM logs
|
||||
GROUP BY time_bucket
|
||||
ORDER BY time_bucket
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
|
||||
- name: cache_status_breakdown
|
||||
label: Cache Status
|
||||
icon: archive
|
||||
chart: polarArea
|
||||
bucket: cache_status
|
||||
bucket_label: Cache Status
|
||||
query: |
|
||||
SELECT cache_status AS cache_status,
|
||||
SELECT cache_status AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY cache_status
|
||||
|
@ -46,168 +37,77 @@
|
|||
|
||||
- name: domain_traffic
|
||||
label: Top Domains
|
||||
icon: globe
|
||||
chart: table
|
||||
top_n: 50
|
||||
per_domain: false
|
||||
bucket: domain
|
||||
bucket_label: Domain
|
||||
query: |
|
||||
SELECT host AS domain,
|
||||
SELECT host AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY domain
|
||||
GROUP BY host
|
||||
ORDER BY value DESC
|
||||
|
||||
- name: bytes_sent
|
||||
label: Bytes Sent
|
||||
icon: upload
|
||||
chart: line
|
||||
bucket: time_bucket
|
||||
bucket_label: Time
|
||||
query: |
|
||||
SELECT {bucket} AS time_bucket,
|
||||
SELECT {bucket} AS bucket,
|
||||
SUM(bytes_sent) AS value
|
||||
FROM logs
|
||||
GROUP BY time_bucket
|
||||
ORDER BY time_bucket
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
|
||||
- name: top_paths
|
||||
label: Top Paths
|
||||
icon: map
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- path
|
||||
bucket_label:
|
||||
- Domain
|
||||
- Path
|
||||
query: |
|
||||
WITH paths AS (
|
||||
SELECT host AS domain,
|
||||
substr(substr(request, instr(request, ' ') + 1), 1,
|
||||
SELECT path AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM (
|
||||
SELECT substr(substr(request, instr(request, ' ') + 1), 1,
|
||||
instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path
|
||||
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
|
||||
FROM ranked
|
||||
WHERE rn <= 20
|
||||
ORDER BY domain, value DESC
|
||||
GROUP BY path
|
||||
ORDER BY value DESC
|
||||
LIMIT 20
|
||||
|
||||
- name: user_agents
|
||||
label: User Agents
|
||||
icon: user
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- user_agent
|
||||
bucket_label:
|
||||
- Domain
|
||||
- User Agent
|
||||
query: |
|
||||
WITH ua AS (
|
||||
SELECT host AS domain, user_agent
|
||||
FROM logs
|
||||
), ranked AS (
|
||||
SELECT domain, user_agent, COUNT(*) AS value,
|
||||
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
|
||||
FROM ua
|
||||
GROUP BY domain, user_agent
|
||||
)
|
||||
SELECT domain, user_agent, value
|
||||
FROM ranked
|
||||
WHERE rn <= 20
|
||||
ORDER BY domain, value DESC
|
||||
SELECT user_agent AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY user_agent
|
||||
ORDER BY value DESC
|
||||
LIMIT 20
|
||||
|
||||
- name: referrers
|
||||
label: Referrers
|
||||
icon: link
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- referrer
|
||||
bucket_label:
|
||||
- Domain
|
||||
- Referrer
|
||||
query: |
|
||||
WITH ref AS (
|
||||
SELECT host AS domain, referer AS referrer
|
||||
FROM logs
|
||||
), ranked AS (
|
||||
SELECT domain, referrer, COUNT(*) AS value,
|
||||
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
|
||||
FROM ref
|
||||
GROUP BY domain, referrer
|
||||
)
|
||||
SELECT domain, referrer, value
|
||||
FROM ranked
|
||||
WHERE rn <= 20
|
||||
ORDER BY domain, value DESC
|
||||
SELECT referer AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY referer
|
||||
ORDER BY value DESC
|
||||
LIMIT 20
|
||||
|
||||
- name: status_distribution
|
||||
label: HTTP Statuses
|
||||
icon: server
|
||||
chart: pie
|
||||
bucket: status_group
|
||||
bucket_label: Status
|
||||
query: |
|
||||
SELECT CASE
|
||||
WHEN status BETWEEN 200 AND 299 THEN '2xx'
|
||||
WHEN status BETWEEN 300 AND 399 THEN '3xx'
|
||||
WHEN status BETWEEN 400 AND 499 THEN '4xx'
|
||||
ELSE '5xx'
|
||||
END AS status_group,
|
||||
END AS bucket,
|
||||
COUNT(*) AS value
|
||||
FROM logs
|
||||
GROUP BY status_group
|
||||
ORDER BY status_group
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
colors:
|
||||
- "#48c78e"
|
||||
- "#209cee"
|
||||
- "#ffdd57"
|
||||
- "#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
|
||||
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
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "[INFO] Creating virtual environment..."
|
||||
|
|
|
@ -1,15 +1,6 @@
|
|||
#!/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..."
|
||||
|
@ -29,25 +20,20 @@ fi
|
|||
|
||||
# Generate reports for all domains combined
|
||||
echo "[INFO] Generating aggregate reports..."
|
||||
python -m scripts.generate_reports hourly
|
||||
python -m scripts.generate_reports daily
|
||||
python -m scripts.generate_reports weekly
|
||||
python -m scripts.generate_reports monthly
|
||||
python -m scripts.generate_reports global
|
||||
python scripts/generate_reports.py hourly
|
||||
python scripts/generate_reports.py daily
|
||||
python scripts/generate_reports.py weekly
|
||||
python scripts/generate_reports.py monthly
|
||||
|
||||
# Generate reports for each individual domain
|
||||
echo "[INFO] Generating per-domain reports..."
|
||||
python -m scripts.generate_reports hourly --all-domains
|
||||
python -m scripts.generate_reports daily --all-domains
|
||||
python -m scripts.generate_reports weekly --all-domains
|
||||
python -m scripts.generate_reports monthly --all-domains
|
||||
|
||||
# Generate analysis JSON
|
||||
echo "[INFO] Generating analysis files..."
|
||||
python -m scripts.generate_reports analysis
|
||||
python scripts/generate_reports.py hourly --all-domains
|
||||
python scripts/generate_reports.py daily --all-domains
|
||||
python scripts/generate_reports.py weekly --all-domains
|
||||
python scripts/generate_reports.py monthly --all-domains
|
||||
|
||||
# Generate root index
|
||||
python -m scripts.generate_reports index
|
||||
python scripts/generate_reports.py index
|
||||
|
||||
# Deactivate to keep cron environment clean
|
||||
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 sys
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
import yaml
|
||||
|
||||
import typer
|
||||
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")
|
||||
OUTPUT_DIR = Path("output")
|
||||
TEMPLATE_DIR = Path("templates")
|
||||
REPORT_CONFIG = Path("reports.yml")
|
||||
GENERATED_MARKER = OUTPUT_DIR / "generated.txt"
|
||||
|
||||
# Mapping of interval names to SQLite strftime formats. These strings are
|
||||
# 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.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]:
|
||||
"""Return a sorted list of unique domains from the logs table."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
|
@ -77,61 +54,10 @@ def _save_json(path: Path, data: List[Dict]) -> None:
|
|||
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:
|
||||
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""
|
||||
def _render_html(interval: str, reports: List[Dict], out_path: Path) -> None:
|
||||
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
||||
template = env.get_template("report_snippet.html")
|
||||
snippet_path = out_dir / f"{report['name']}.html"
|
||||
snippet_path.write_text(template.render(report=report))
|
||||
|
||||
|
||||
def _write_stats(
|
||||
generated_at: Optional[str] = None, generation_seconds: Optional[float] = None
|
||||
) -> None:
|
||||
"""Query basic dataset stats and write them to ``output/global/stats.json``."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM logs")
|
||||
total_logs = cur.fetchone()[0] or 0
|
||||
|
||||
cur.execute("SELECT MIN(time), MAX(time) FROM logs")
|
||||
row = cur.fetchone() or (None, None)
|
||||
start_date = row[0] or ""
|
||||
end_date = row[1] or ""
|
||||
|
||||
cur.execute("SELECT COUNT(DISTINCT host) FROM logs")
|
||||
unique_domains = cur.fetchone()[0] or 0
|
||||
|
||||
conn.close()
|
||||
|
||||
stats = {
|
||||
"total_logs": total_logs,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"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"
|
||||
_save_json(out_path, stats)
|
||||
template = env.get_template("report.html")
|
||||
out_path.write_text(template.render(interval=interval, reports=reports))
|
||||
|
||||
|
||||
def _bucket_expr(interval: str) -> str:
|
||||
|
@ -149,8 +75,6 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
|||
typer.echo("No report definitions found")
|
||||
return
|
||||
|
||||
_copy_icons()
|
||||
|
||||
bucket = _bucket_expr(interval)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
|
@ -175,26 +99,9 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
|||
|
||||
report_list = []
|
||||
for definition in cfg:
|
||||
if "{bucket}" not in definition["query"] or definition.get("global"):
|
||||
# Global reports are generated separately
|
||||
continue
|
||||
if domain and not definition.get("per_domain", True):
|
||||
# Skip reports marked as not applicable to per-domain runs
|
||||
continue
|
||||
|
||||
name = definition["name"]
|
||||
query = definition["query"].replace("{bucket}", bucket)
|
||||
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)
|
||||
rows = cur.fetchall()
|
||||
headers = [c[0] for c in cur.description]
|
||||
|
@ -206,40 +113,16 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
|||
"label": definition.get("label", name.title()),
|
||||
"chart": definition.get("chart", "line"),
|
||||
"json": f"{name}.json",
|
||||
"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:
|
||||
entry["color"] = definition["color"]
|
||||
if "colors" in definition:
|
||||
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)
|
||||
report_list.append(entry)
|
||||
|
||||
_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")
|
||||
_render_html(interval, report_list, out_dir / "index.html")
|
||||
typer.echo(f"Generated {interval} reports")
|
||||
|
||||
|
||||
def _generate_all_domains(interval: str) -> None:
|
||||
|
@ -250,10 +133,8 @@ def _generate_all_domains(interval: str) -> None:
|
|||
|
||||
def _generate_root_index() -> None:
|
||||
"""Render the top-level index listing all intervals and domains."""
|
||||
_copy_icons()
|
||||
intervals = sorted(
|
||||
[name for name in INTERVAL_FORMATS if (OUTPUT_DIR / name).is_dir()]
|
||||
)
|
||||
intervals = [p.name for p in OUTPUT_DIR.iterdir() if p.is_dir() and p.name != "domains"]
|
||||
intervals.sort()
|
||||
|
||||
domains_dir = OUTPUT_DIR / "domains"
|
||||
domains: List[str] = []
|
||||
|
@ -270,116 +151,6 @@ def _generate_root_index() -> None:
|
|||
typer.echo(f"Generated root index at {out_path}")
|
||||
|
||||
|
||||
def _generate_global() -> None:
|
||||
"""Generate reports that do not depend on an interval."""
|
||||
cfg = _load_config()
|
||||
if not cfg:
|
||||
typer.echo("No report definitions found")
|
||||
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)
|
||||
cur = conn.cursor()
|
||||
|
||||
out_dir = OUTPUT_DIR / "global"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report_list = []
|
||||
for definition in cfg:
|
||||
if "{bucket}" in definition["query"] and not definition.get("global"):
|
||||
continue
|
||||
|
||||
name = definition["name"]
|
||||
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)
|
||||
rows = cur.fetchall()
|
||||
headers = [c[0] for c in cur.description]
|
||||
data = [dict(zip(headers, row)) for row in rows]
|
||||
json_path = out_dir / f"{name}.json"
|
||||
_save_json(json_path, data)
|
||||
entry = {
|
||||
"name": name,
|
||||
"label": definition.get("label", name.title()),
|
||||
"chart": definition.get("chart", "line"),
|
||||
"json": f"{name}.json",
|
||||
"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:
|
||||
entry["color"] = definition["color"]
|
||||
if "colors" in definition:
|
||||
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)
|
||||
report_list.append(entry)
|
||||
|
||||
_save_json(out_dir / "reports.json", report_list)
|
||||
elapsed = round(time.time() - start_time, 2)
|
||||
_write_stats(generated_at, elapsed)
|
||||
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()
|
||||
def hourly(
|
||||
domain: Optional[str] = typer.Option(
|
||||
|
@ -444,18 +215,6 @@ def monthly(
|
|||
_generate_interval("monthly", domain)
|
||||
|
||||
|
||||
@app.command("global")
|
||||
def global_reports() -> None:
|
||||
"""Generate global reports."""
|
||||
_generate_global()
|
||||
|
||||
|
||||
@app.command()
|
||||
def analysis() -> None:
|
||||
"""Generate analysis JSON files for the Analysis tab."""
|
||||
_generate_analysis()
|
||||
|
||||
|
||||
@app.command()
|
||||
def index() -> None:
|
||||
"""Generate the root index page linking all reports."""
|
||||
|
|
|
@ -61,9 +61,7 @@ try:
|
|||
suffix = match.group(1)
|
||||
number = int(suffix.lstrip(".")) if suffix else 0
|
||||
log_files.append((number, os.path.join(LOG_DIR, f)))
|
||||
log_files = [
|
||||
path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)
|
||||
]
|
||||
log_files = [path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)]
|
||||
except FileNotFoundError:
|
||||
print(f"[ERROR] Log directory not found: {LOG_DIR}")
|
||||
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 |