Compare commits
No commits in common. "main" and "codex/fix-modulenotfounderror-in-analyze.py" have entirely different histories.
main
...
codex/fix-
3
.flake8
|
@ -1,3 +0,0 @@
|
||||||
[flake8]
|
|
||||||
exclude = .git, .venv, output, static/icons
|
|
||||||
max-line-length = 160
|
|
|
@ -1,151 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
name: Lint, test, and build
|
|
||||||
# This label must match your Forgejo runner's label
|
|
||||||
runs-on: docker
|
|
||||||
# Use a clean Debian container so tools are predictable
|
|
||||||
container: debian:stable-slim
|
|
||||||
env:
|
|
||||||
PYTHONDONTWRITEBYTECODE: "1"
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
|
||||||
UV_SYSTEM_PYTHON: "1"
|
|
||||||
steps:
|
|
||||||
- name: Install build tooling
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
apt-get update
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
||||||
git ca-certificates python3 python3-venv python3-pip python3-setuptools \
|
|
||||||
python3-wheel sqlite3
|
|
||||||
update-ca-certificates || true
|
|
||||||
|
|
||||||
- name: Checkout repository (manual)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -f Makefile ] || [ -d .git ]; then
|
|
||||||
echo "Repository present in workspace; skipping clone"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
REMOTE_URL="${CI_REPOSITORY_URL:-}"
|
|
||||||
if [ -z "$REMOTE_URL" ]; then
|
|
||||||
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
|
|
||||||
REMOTE_URL="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
|
||||||
elif [ -n "${GITHUB_REPOSITORY:-}" ]; then
|
|
||||||
REMOTE_URL="https://git.jordanwages.com/${GITHUB_REPOSITORY}.git"
|
|
||||||
else
|
|
||||||
echo "Unable to determine repository URL from CI environment" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
AUTH_URL="$REMOTE_URL"
|
|
||||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
|
||||||
ACTOR="${GITHUB_ACTOR:-oauth2}"
|
|
||||||
AUTH_URL=$(printf '%s' "$REMOTE_URL" | sed -E "s#^https://#https://${ACTOR}:${GITHUB_TOKEN}@#")
|
|
||||||
fi
|
|
||||||
echo "Cloning from: $REMOTE_URL"
|
|
||||||
if ! git clone --depth 1 "$AUTH_URL" .; then
|
|
||||||
echo "Auth clone failed; trying anonymous clone..." >&2
|
|
||||||
git clone --depth 1 "$REMOTE_URL" .
|
|
||||||
fi
|
|
||||||
if [ -n "${GITHUB_SHA:-}" ]; then
|
|
||||||
git fetch --depth 1 origin "$GITHUB_SHA" || true
|
|
||||||
git checkout -q "$GITHUB_SHA" || true
|
|
||||||
elif [ -n "${GITHUB_REF_NAME:-}" ]; then
|
|
||||||
git fetch --depth 1 origin "$GITHUB_REF_NAME" || true
|
|
||||||
git checkout -q "$GITHUB_REF_NAME" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up venv and install deps
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# Prefer persistent cache if runner provides /cache
|
|
||||||
USE_CACHE=0
|
|
||||||
if [ -d /cache ] && [ -w /cache ]; then
|
|
||||||
export PIP_CACHE_DIR=/cache/pip
|
|
||||||
mkdir -p "$PIP_CACHE_DIR"
|
|
||||||
REQ_HASH=$(sha256sum requirements.txt | awk '{print $1}')
|
|
||||||
PYVER=$(python3 -c 'import sys;print(".".join(map(str, sys.version_info[:2])))')
|
|
||||||
CACHE_VENV="/cache/venv-${REQ_HASH}-py${PYVER}"
|
|
||||||
if [ ! -f "$CACHE_VENV/bin/activate" ]; then
|
|
||||||
echo "Preparing cached virtualenv: $CACHE_VENV"
|
|
||||||
rm -rf "$CACHE_VENV" || true
|
|
||||||
python3 -m venv "$CACHE_VENV"
|
|
||||||
fi
|
|
||||||
ln -sfn "$CACHE_VENV" .venv
|
|
||||||
USE_CACHE=1
|
|
||||||
else
|
|
||||||
# Fallback to local venv
|
|
||||||
python3 -m venv .venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If the link didn't produce an activate file, fallback to local venv
|
|
||||||
if [ ! -f .venv/bin/activate ]; then
|
|
||||||
echo "Cached venv missing; creating local .venv"
|
|
||||||
rm -f .venv
|
|
||||||
python3 -m venv .venv
|
|
||||||
USE_CACHE=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
. .venv/bin/activate
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ "$USE_CACHE" = "1" ]; then
|
|
||||||
# Ensure required packages are present; pip will use cache
|
|
||||||
pip install -r requirements.txt pytest || pip install -r requirements.txt pytest
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt pytest
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Format check (black)
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
black --check .
|
|
||||||
|
|
||||||
- name: Lint (flake8)
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
flake8 .
|
|
||||||
|
|
||||||
- name: Run tests (pytest)
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
export PYTHONPATH="$(pwd)${PYTHONPATH:+:$PYTHONPATH}"
|
|
||||||
pytest -q --maxfail=1
|
|
||||||
|
|
||||||
- name: Build sample reports (no artifact upload)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
. .venv/bin/activate
|
|
||||||
python - <<'PY'
|
|
||||||
import sqlite3, pathlib
|
|
||||||
db = pathlib.Path('database/ngxstat.db')
|
|
||||||
db.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(db)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute('''CREATE TABLE IF NOT EXISTS logs (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
ip TEXT,
|
|
||||||
host TEXT,
|
|
||||||
time TEXT,
|
|
||||||
request TEXT,
|
|
||||||
status INTEGER,
|
|
||||||
bytes_sent INTEGER,
|
|
||||||
referer TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
cache_status TEXT
|
|
||||||
)''')
|
|
||||||
cur.execute("INSERT INTO logs (ip, host, time, request, status, bytes_sent, referer, user_agent, cache_status) VALUES ('127.0.0.1','example.com','2024-01-01 10:00:00','GET / HTTP/1.1',200,100,'-','curl','MISS')")
|
|
||||||
cur.execute("INSERT INTO logs (ip, host, time, request, status, bytes_sent, referer, user_agent, cache_status) VALUES ('127.0.0.1','example.com','2024-01-01 10:05:00','GET /about HTTP/1.1',200,100,'-','curl','MISS')")
|
|
||||||
conn.commit(); conn.close()
|
|
||||||
PY
|
|
||||||
python scripts/generate_reports.py global
|
|
||||||
python scripts/generate_reports.py hourly
|
|
||||||
python scripts/generate_reports.py index
|
|
||||||
tar -czf ngxstat-reports.tar.gz -C output .
|
|
||||||
echo "Built sample reports archive: ngxstat-reports.tar.gz"
|
|
12
AGENTS.md
|
@ -24,9 +24,6 @@ This document outlines general practices and expectations for AI agents assistin
|
||||||
The `run-import.sh` script can initialize this environment automatically.
|
The `run-import.sh` script can initialize this environment automatically.
|
||||||
Always activate the virtual environment before running scripts or tests.
|
Always activate the virtual environment before running scripts or tests.
|
||||||
|
|
||||||
* Before committing code run `black` for consistent formatting and execute
|
|
||||||
the test suite with `pytest`. All tests should pass.
|
|
||||||
|
|
||||||
* Dependency management: Use `requirements.txt` or `pip-tools`
|
* Dependency management: Use `requirements.txt` or `pip-tools`
|
||||||
* Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`)
|
* Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`)
|
||||||
* Adopt `typer` for CLI command interface (if CLI ergonomics matter)
|
* Adopt `typer` for CLI command interface (if CLI ergonomics matter)
|
||||||
|
@ -92,14 +89,6 @@ ngxstat/
|
||||||
|
|
||||||
If uncertain, the agent should prompt the human for clarification before making architectural assumptions.
|
If uncertain, the agent should prompt the human for clarification before making architectural assumptions.
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Use `pytest` for automated tests. Run the suite from an activated virtual environment and ensure all tests pass before committing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Capabilities
|
## Future Capabilities
|
||||||
|
@ -117,4 +106,3 @@ As the project matures, agents may also:
|
||||||
|
|
||||||
* **2025-07-17**: Initial version by Jordan + ChatGPT
|
* **2025-07-17**: Initial version by Jordan + ChatGPT
|
||||||
* **2025-07-17**: Expanded virtual environment usage guidance
|
* **2025-07-17**: Expanded virtual environment usage guidance
|
||||||
|
|
||||||
|
|
148
README.md
|
@ -1,16 +1,11 @@
|
||||||
# ngxstat
|
# ngxstat
|
||||||
|
Per-domain Nginx log analytics with hybrid static reports and live insights.
|
||||||
|
|
||||||
`ngxstat` is a lightweight log analytics toolkit for Nginx. It imports access
|
## Generating Reports
|
||||||
logs into an SQLite database and renders static dashboards so you can explore
|
|
||||||
per-domain metrics without running a heavy backend service.
|
|
||||||
|
|
||||||
## Requirements
|
Use the `generate_reports.py` script to build aggregated JSON and HTML snippet files from `database/ngxstat.db`.
|
||||||
|
|
||||||
* Python 3.10+
|
Create a virtual environment and install dependencies:
|
||||||
* Access to the Nginx log files (default: `/var/log/nginx`)
|
|
||||||
|
|
||||||
The helper scripts create a virtual environment on first run, but you can also
|
|
||||||
set one up manually:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
|
@ -18,95 +13,118 @@ source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then run one or more of the interval commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_reports.py hourly
|
||||||
|
python scripts/generate_reports.py daily
|
||||||
|
python scripts/generate_reports.py weekly
|
||||||
|
python scripts/generate_reports.py monthly
|
||||||
|
```
|
||||||
|
|
||||||
|
Each command accepts optional flags to generate per-domain reports. Use
|
||||||
|
`--domain <name>` to limit output to a specific domain or `--all-domains`
|
||||||
|
to generate a subdirectory for every domain found in the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hourly reports for example.com only
|
||||||
|
python scripts/generate_reports.py hourly --domain example.com
|
||||||
|
|
||||||
|
# Weekly reports for all domains individually
|
||||||
|
python scripts/generate_reports.py weekly --all-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports are written under the `output/` directory. Each command updates the corresponding `<interval>.json` file and writes one HTML snippet per report. These snippets are loaded dynamically by the main dashboard using Chart.js and DataTables.
|
||||||
|
|
||||||
|
### Configuring Reports
|
||||||
|
|
||||||
|
Report queries are defined in `reports.yml`. Each entry specifies the `name`,
|
||||||
|
optional `label` and `chart` type, and a SQL `query` that must return `bucket`
|
||||||
|
and `value` columns. The special token `{bucket}` is replaced with the
|
||||||
|
appropriate SQLite `strftime` expression for each interval (hourly, daily,
|
||||||
|
weekly or monthly) so that a single definition works across all durations.
|
||||||
|
When `generate_reports.py` runs, every definition is executed for the requested
|
||||||
|
interval and creates `output/<interval>/<name>.json` plus a small HTML snippet
|
||||||
|
`output/<interval>/<name>.html` used by the dashboard.
|
||||||
|
|
||||||
|
Example snippet:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: hits
|
||||||
|
chart: bar
|
||||||
|
query: |
|
||||||
|
SELECT {bucket} AS bucket,
|
||||||
|
COUNT(*) AS value
|
||||||
|
FROM logs
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket
|
||||||
|
```
|
||||||
|
|
||||||
|
Add or modify entries in `reports.yml` to tailor the generated metrics.
|
||||||
|
|
||||||
## Importing Logs
|
## Importing Logs
|
||||||
|
|
||||||
Run the importer to ingest new log entries into `database/ngxstat.db`:
|
Use the `run-import.sh` script to set up the Python environment if needed and import the latest Nginx log entries into `database/ngxstat.db`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run-import.sh
|
./run-import.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Rotated logs are processed in order and only entries newer than the last
|
This script is suitable for cron jobs as it creates the virtual environment on first run, installs dependencies and reuses the environment on subsequent runs.
|
||||||
imported timestamp are added.
|
|
||||||
|
|
||||||
## Generating Reports
|
The importer handles rotated logs in order from oldest to newest so entries are
|
||||||
|
processed exactly once. If you rerun the script, it only ingests records with a
|
||||||
|
timestamp newer than the latest one already stored in the database, preventing
|
||||||
|
duplicates.
|
||||||
|
|
||||||
To build the HTML dashboard and JSON data files use `run-reports.sh` which runs
|
## Cron Report Generation
|
||||||
all intervals in one go:
|
|
||||||
|
Use the `run-reports.sh` script to run all report intervals in one step. The script sets up the Python environment the same way as `run-import.sh`, making it convenient for automation via cron.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run-reports.sh
|
./run-reports.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script calls `scripts/generate_reports.py` internally to create hourly,
|
Running this script will create or update the hourly, daily, weekly and monthly reports under `output/`. It also detects all unique domains found in the database and writes per-domain reports to `output/domains/<domain>/<interval>` alongside the aggregate data. After generation, open `output/index.html` in your browser to browse the reports.
|
||||||
daily, weekly and monthly reports, then writes analysis JSON files used by the
|
|
||||||
"Analysis" tab. Per-domain reports are written under `output/domains/<domain>`
|
|
||||||
alongside the aggregate data. Open `output/index.html` in a browser to view the
|
|
||||||
dashboard.
|
|
||||||
|
|
||||||
If you prefer to run individual commands you can invoke the generator directly:
|
|
||||||
|
|
||||||
```bash
|
## Log Analysis
|
||||||
python scripts/generate_reports.py hourly
|
|
||||||
python scripts/generate_reports.py daily --all-domains
|
|
||||||
```
|
|
||||||
|
|
||||||
## Analysis Helpers
|
The `run-analysis.sh` script runs helper routines that inspect the database. It
|
||||||
|
creates or reuses the virtual environment and then executes a set of analysis
|
||||||
`run-analysis.sh` executes additional utilities that examine the database for
|
commands to spot missing domains, suggest cache rules and detect potential
|
||||||
missing domains, caching opportunities and potential threats. The JSON output is
|
threats.
|
||||||
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
|
```bash
|
||||||
./run-analysis.sh
|
./run-analysis.sh
|
||||||
```
|
```
|
||||||
|
The JSON results are written under `output/analysis` and can be viewed from the
|
||||||
|
"Analysis" tab in the generated dashboard.
|
||||||
|
## Serving Reports with Nginx
|
||||||
|
|
||||||
## Serving the Reports
|
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
|
||||||
The generated files are static. You can serve them with a simple Nginx block:
|
`output/` directory and optionally restrict access to your local network.
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name example.com;
|
server_name example.com;
|
||||||
|
|
||||||
|
# Path to the generated reports
|
||||||
root /path/to/ngxstat/output;
|
root /path/to/ngxstat/output;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Allow access only from private networks
|
||||||
|
allow 192.0.0.0/8;
|
||||||
|
allow 10.0.0.0/8;
|
||||||
|
deny all;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Restrict access if the reports should not be public.
|
With this configuration the generated static files are served directly by
|
||||||
|
Nginx while connections outside of `192.*` and `10.*` are denied.
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
Install the development dependencies and execute the suite with `pytest`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
All tests must pass before submitting changes.
|
|
||||||
|
|
||||||
## Acknowledgements
|
|
||||||
|
|
||||||
ngxstat uses the following third‑party resources:
|
|
||||||
|
|
||||||
* [Chart.js](https://www.chartjs.org/) for charts
|
|
||||||
* [DataTables](https://datatables.net/) and [jQuery](https://jquery.com/) for table views
|
|
||||||
* [Bulma CSS](https://bulma.io/) for styling
|
|
||||||
* Icons from [Free CC0 Icons](https://cc0-icons.jonh.eu/) by Jon Hicks (CC0 / MIT)
|
|
||||||
* [Typer](https://typer.tiangolo.com/) for the command-line interface
|
|
||||||
* [Jinja2](https://palletsprojects.com/p/jinja/) for templating
|
|
||||||
|
|
||||||
The project is licensed under the GPLv3. Icon assets remain in the public domain
|
|
||||||
via the CC0 license.
|
|
||||||
|
|
167
reports.yml
|
@ -1,37 +1,28 @@
|
||||||
- name: hits
|
- name: hits
|
||||||
label: Hits
|
label: Hits
|
||||||
icon: pulse
|
|
||||||
chart: line
|
chart: line
|
||||||
bucket: time_bucket
|
|
||||||
bucket_label: Time
|
|
||||||
query: |
|
query: |
|
||||||
SELECT {bucket} AS time_bucket,
|
SELECT {bucket} AS bucket,
|
||||||
COUNT(*) AS value
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY time_bucket
|
GROUP BY bucket
|
||||||
ORDER BY time_bucket
|
ORDER BY bucket
|
||||||
|
|
||||||
- name: error_rate
|
- name: error_rate
|
||||||
label: Error Rate (%)
|
label: Error Rate (%)
|
||||||
icon: file-alert
|
|
||||||
chart: line
|
chart: line
|
||||||
bucket: time_bucket
|
|
||||||
bucket_label: Time
|
|
||||||
query: |
|
query: |
|
||||||
SELECT {bucket} AS time_bucket,
|
SELECT {bucket} AS bucket,
|
||||||
SUM(CASE WHEN status BETWEEN 400 AND 599 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS value
|
SUM(CASE WHEN status BETWEEN 400 AND 599 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY time_bucket
|
GROUP BY bucket
|
||||||
ORDER BY time_bucket
|
ORDER BY bucket
|
||||||
|
|
||||||
- name: cache_status_breakdown
|
- name: cache_status_breakdown
|
||||||
label: Cache Status
|
label: Cache Status
|
||||||
icon: archive
|
|
||||||
chart: polarArea
|
chart: polarArea
|
||||||
bucket: cache_status
|
|
||||||
bucket_label: Cache Status
|
|
||||||
query: |
|
query: |
|
||||||
SELECT cache_status AS cache_status,
|
SELECT cache_status AS bucket,
|
||||||
COUNT(*) AS value
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY cache_status
|
GROUP BY cache_status
|
||||||
|
@ -46,168 +37,78 @@
|
||||||
|
|
||||||
- name: domain_traffic
|
- name: domain_traffic
|
||||||
label: Top Domains
|
label: Top Domains
|
||||||
icon: globe
|
|
||||||
chart: table
|
chart: table
|
||||||
top_n: 50
|
|
||||||
per_domain: false
|
per_domain: false
|
||||||
bucket: domain
|
|
||||||
bucket_label: Domain
|
|
||||||
query: |
|
query: |
|
||||||
SELECT host AS domain,
|
SELECT host AS bucket,
|
||||||
COUNT(*) AS value
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY domain
|
GROUP BY host
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
|
|
||||||
- name: bytes_sent
|
- name: bytes_sent
|
||||||
label: Bytes Sent
|
label: Bytes Sent
|
||||||
icon: upload
|
|
||||||
chart: line
|
chart: line
|
||||||
bucket: time_bucket
|
|
||||||
bucket_label: Time
|
|
||||||
query: |
|
query: |
|
||||||
SELECT {bucket} AS time_bucket,
|
SELECT {bucket} AS bucket,
|
||||||
SUM(bytes_sent) AS value
|
SUM(bytes_sent) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY time_bucket
|
GROUP BY bucket
|
||||||
ORDER BY time_bucket
|
ORDER BY bucket
|
||||||
|
|
||||||
- name: top_paths
|
- name: top_paths
|
||||||
label: Top Paths
|
label: Top Paths
|
||||||
icon: map
|
|
||||||
chart: table
|
chart: table
|
||||||
top_n: 50
|
|
||||||
buckets:
|
|
||||||
- domain
|
|
||||||
- path
|
|
||||||
bucket_label:
|
|
||||||
- Domain
|
|
||||||
- Path
|
|
||||||
query: |
|
query: |
|
||||||
WITH paths AS (
|
SELECT path AS bucket,
|
||||||
SELECT host AS domain,
|
COUNT(*) AS value
|
||||||
substr(substr(request, instr(request, ' ') + 1), 1,
|
FROM (
|
||||||
|
SELECT substr(substr(request, instr(request, ' ') + 1), 1,
|
||||||
instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path
|
instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path
|
||||||
FROM logs
|
FROM logs
|
||||||
), ranked AS (
|
|
||||||
SELECT domain, path, COUNT(*) AS value,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
|
|
||||||
FROM paths
|
|
||||||
GROUP BY domain, path
|
|
||||||
)
|
)
|
||||||
SELECT domain, path, value
|
GROUP BY path
|
||||||
FROM ranked
|
ORDER BY value DESC
|
||||||
WHERE rn <= 20
|
LIMIT 20
|
||||||
ORDER BY domain, value DESC
|
|
||||||
|
|
||||||
- name: user_agents
|
- name: user_agents
|
||||||
label: User Agents
|
label: User Agents
|
||||||
icon: user
|
|
||||||
chart: table
|
chart: table
|
||||||
top_n: 50
|
|
||||||
buckets:
|
|
||||||
- domain
|
|
||||||
- user_agent
|
|
||||||
bucket_label:
|
|
||||||
- Domain
|
|
||||||
- User Agent
|
|
||||||
query: |
|
query: |
|
||||||
WITH ua AS (
|
SELECT user_agent AS bucket,
|
||||||
SELECT host AS domain, user_agent
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
), ranked AS (
|
GROUP BY user_agent
|
||||||
SELECT domain, user_agent, COUNT(*) AS value,
|
ORDER BY value DESC
|
||||||
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
|
LIMIT 20
|
||||||
FROM ua
|
|
||||||
GROUP BY domain, user_agent
|
|
||||||
)
|
|
||||||
SELECT domain, user_agent, value
|
|
||||||
FROM ranked
|
|
||||||
WHERE rn <= 20
|
|
||||||
ORDER BY domain, value DESC
|
|
||||||
|
|
||||||
- name: referrers
|
- name: referrers
|
||||||
label: Referrers
|
label: Referrers
|
||||||
icon: link
|
|
||||||
chart: table
|
chart: table
|
||||||
top_n: 50
|
|
||||||
buckets:
|
|
||||||
- domain
|
|
||||||
- referrer
|
|
||||||
bucket_label:
|
|
||||||
- Domain
|
|
||||||
- Referrer
|
|
||||||
query: |
|
query: |
|
||||||
WITH ref AS (
|
SELECT referer AS bucket,
|
||||||
SELECT host AS domain, referer AS referrer
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
), ranked AS (
|
GROUP BY referer
|
||||||
SELECT domain, referrer, COUNT(*) AS value,
|
ORDER BY value DESC
|
||||||
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
|
LIMIT 20
|
||||||
FROM ref
|
|
||||||
GROUP BY domain, referrer
|
|
||||||
)
|
|
||||||
SELECT domain, referrer, value
|
|
||||||
FROM ranked
|
|
||||||
WHERE rn <= 20
|
|
||||||
ORDER BY domain, value DESC
|
|
||||||
|
|
||||||
- name: status_distribution
|
- name: status_distribution
|
||||||
label: HTTP Statuses
|
label: HTTP Statuses
|
||||||
icon: server
|
|
||||||
chart: pie
|
chart: pie
|
||||||
bucket: status_group
|
|
||||||
bucket_label: Status
|
|
||||||
query: |
|
query: |
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN status BETWEEN 200 AND 299 THEN '2xx'
|
WHEN status BETWEEN 200 AND 299 THEN '2xx'
|
||||||
WHEN status BETWEEN 300 AND 399 THEN '3xx'
|
WHEN status BETWEEN 300 AND 399 THEN '3xx'
|
||||||
WHEN status BETWEEN 400 AND 499 THEN '4xx'
|
WHEN status BETWEEN 400 AND 499 THEN '4xx'
|
||||||
ELSE '5xx'
|
ELSE '5xx'
|
||||||
END AS status_group,
|
END AS bucket,
|
||||||
COUNT(*) AS value
|
COUNT(*) AS value
|
||||||
FROM logs
|
FROM logs
|
||||||
GROUP BY status_group
|
GROUP BY bucket
|
||||||
ORDER BY status_group
|
ORDER BY bucket
|
||||||
colors:
|
colors:
|
||||||
- "#48c78e"
|
- "#48c78e"
|
||||||
- "#209cee"
|
- "#209cee"
|
||||||
- "#ffdd57"
|
- "#ffdd57"
|
||||||
- "#f14668"
|
- "#f14668"
|
||||||
|
|
||||||
# New time-series: status classes over time (stacked)
|
|
||||||
- name: status_classes_timeseries
|
|
||||||
label: Status Classes Over Time
|
|
||||||
icon: server
|
|
||||||
chart: stackedBar
|
|
||||||
bucket: time_bucket
|
|
||||||
bucket_label: Time
|
|
||||||
stacked: true
|
|
||||||
query: |
|
|
||||||
SELECT {bucket} AS time_bucket,
|
|
||||||
SUM(CASE WHEN status BETWEEN 200 AND 299 THEN 1 ELSE 0 END) AS "2xx",
|
|
||||||
SUM(CASE WHEN status BETWEEN 300 AND 399 THEN 1 ELSE 0 END) AS "3xx",
|
|
||||||
SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS "4xx",
|
|
||||||
SUM(CASE WHEN status BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS "5xx",
|
|
||||||
COUNT(*) AS total
|
|
||||||
FROM logs
|
|
||||||
GROUP BY time_bucket
|
|
||||||
ORDER BY time_bucket
|
|
||||||
|
|
||||||
# New time-series: cache status over time (compact Hit/Miss; exclude '-' by default)
|
|
||||||
- name: cache_status_timeseries
|
|
||||||
label: Cache Status Over Time
|
|
||||||
icon: archive
|
|
||||||
chart: stackedBar
|
|
||||||
bucket: time_bucket
|
|
||||||
bucket_label: Time
|
|
||||||
stacked: true
|
|
||||||
exclude_values: ["-"]
|
|
||||||
query: |
|
|
||||||
SELECT {bucket} AS time_bucket,
|
|
||||||
SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) AS hit,
|
|
||||||
SUM(CASE WHEN cache_status = 'MISS' THEN 1 ELSE 0 END) AS miss,
|
|
||||||
COUNT(*) AS total
|
|
||||||
FROM logs
|
|
||||||
GROUP BY time_bucket
|
|
||||||
ORDER BY time_bucket
|
|
||||||
|
|
|
@ -1,15 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Prevent concurrent executions of this script.
|
|
||||||
LOCK_FILE="/tmp/$(basename "$0").lock"
|
|
||||||
if [ -e "$LOCK_FILE" ]; then
|
|
||||||
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
touch "$LOCK_FILE"
|
|
||||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
|
||||||
|
|
||||||
# Ensure virtual environment exists
|
# Ensure virtual environment exists
|
||||||
if [ ! -d ".venv" ]; then
|
if [ ! -d ".venv" ]; then
|
||||||
echo "[INFO] Creating virtual environment..."
|
echo "[INFO] Creating virtual environment..."
|
||||||
|
|
|
@ -1,17 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Prevent multiple simultaneous runs by using a lock file specific to this
|
|
||||||
# script. If the lock already exists, assume another instance is running and
|
|
||||||
# exit gracefully.
|
|
||||||
LOCK_FILE="/tmp/$(basename "$0").lock"
|
|
||||||
if [ -e "$LOCK_FILE" ]; then
|
|
||||||
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
touch "$LOCK_FILE"
|
|
||||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
|
||||||
|
|
||||||
# Ensure virtual environment exists
|
# Ensure virtual environment exists
|
||||||
if [ ! -d ".venv" ]; then
|
if [ ! -d ".venv" ]; then
|
||||||
echo "[INFO] Creating virtual environment..."
|
echo "[INFO] Creating virtual environment..."
|
||||||
|
|
|
@ -1,15 +1,6 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Prevent concurrent executions of this script.
|
|
||||||
LOCK_FILE="/tmp/$(basename "$0").lock"
|
|
||||||
if [ -e "$LOCK_FILE" ]; then
|
|
||||||
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
touch "$LOCK_FILE"
|
|
||||||
trap 'rm -f "$LOCK_FILE"' EXIT
|
|
||||||
|
|
||||||
# Ensure virtual environment exists
|
# Ensure virtual environment exists
|
||||||
if [ ! -d ".venv" ]; then
|
if [ ! -d ".venv" ]; then
|
||||||
echo "[INFO] Creating virtual environment..."
|
echo "[INFO] Creating virtual environment..."
|
||||||
|
@ -29,25 +20,21 @@ fi
|
||||||
|
|
||||||
# Generate reports for all domains combined
|
# Generate reports for all domains combined
|
||||||
echo "[INFO] Generating aggregate reports..."
|
echo "[INFO] Generating aggregate reports..."
|
||||||
python -m scripts.generate_reports hourly
|
python scripts/generate_reports.py hourly
|
||||||
python -m scripts.generate_reports daily
|
python scripts/generate_reports.py daily
|
||||||
python -m scripts.generate_reports weekly
|
python scripts/generate_reports.py weekly
|
||||||
python -m scripts.generate_reports monthly
|
python scripts/generate_reports.py monthly
|
||||||
python -m scripts.generate_reports global
|
python scripts/generate_reports.py global
|
||||||
|
|
||||||
# Generate reports for each individual domain
|
# Generate reports for each individual domain
|
||||||
echo "[INFO] Generating per-domain reports..."
|
echo "[INFO] Generating per-domain reports..."
|
||||||
python -m scripts.generate_reports hourly --all-domains
|
python scripts/generate_reports.py hourly --all-domains
|
||||||
python -m scripts.generate_reports daily --all-domains
|
python scripts/generate_reports.py daily --all-domains
|
||||||
python -m scripts.generate_reports weekly --all-domains
|
python scripts/generate_reports.py weekly --all-domains
|
||||||
python -m scripts.generate_reports monthly --all-domains
|
python scripts/generate_reports.py monthly --all-domains
|
||||||
|
|
||||||
# Generate analysis JSON
|
|
||||||
echo "[INFO] Generating analysis files..."
|
|
||||||
python -m scripts.generate_reports analysis
|
|
||||||
|
|
||||||
# Generate root index
|
# Generate root index
|
||||||
python -m scripts.generate_reports index
|
python scripts/generate_reports.py index
|
||||||
|
|
||||||
# Deactivate to keep cron environment clean
|
# Deactivate to keep cron environment clean
|
||||||
if type deactivate >/dev/null 2>&1; then
|
if type deactivate >/dev/null 2>&1; then
|
||||||
|
|
|
@ -18,7 +18,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Set
|
from typing import Dict, List, Optional, Set
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
@ -105,9 +105,7 @@ def hits(domain: Optional[str] = typer.Option(None, help="Filter by domain")) ->
|
||||||
|
|
||||||
|
|
||||||
@app.command("cache-ratio")
|
@app.command("cache-ratio")
|
||||||
def cache_ratio_cmd(
|
def cache_ratio_cmd(domain: Optional[str] = typer.Option(None, help="Filter by domain")) -> None:
|
||||||
domain: Optional[str] = typer.Option(None, help="Filter by domain")
|
|
||||||
) -> None:
|
|
||||||
"""Display cache hit ratio as a percentage."""
|
"""Display cache hit ratio as a percentage."""
|
||||||
ratio = get_cache_ratio(domain) * 100
|
ratio = get_cache_ratio(domain) * 100
|
||||||
if domain:
|
if domain:
|
||||||
|
@ -117,11 +115,7 @@ def cache_ratio_cmd(
|
||||||
|
|
||||||
|
|
||||||
@app.command("check-missing-domains")
|
@app.command("check-missing-domains")
|
||||||
def check_missing_domains(
|
def check_missing_domains(json_output: bool = typer.Option(False, "--json", help="Output missing domains as JSON")) -> None:
|
||||||
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."""
|
"""Show domains present in the database but absent from Nginx config."""
|
||||||
try:
|
try:
|
||||||
from scripts.generate_reports import _get_domains as _db_domains
|
from scripts.generate_reports import _get_domains as _db_domains
|
||||||
|
@ -155,9 +149,12 @@ def check_missing_domains(
|
||||||
typer.echo(d)
|
typer.echo(d)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("suggest-cache")
|
||||||
def suggest_cache(
|
def suggest_cache(
|
||||||
threshold: int = 10,
|
threshold: int = typer.Option(
|
||||||
json_output: bool = False,
|
10, help="Minimum number of MISS entries to report"
|
||||||
|
),
|
||||||
|
json_output: bool = typer.Option(False, "--json", help="Output results as JSON"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Suggest domain/path pairs that could benefit from caching.
|
"""Suggest domain/path pairs that could benefit from caching.
|
||||||
|
|
||||||
|
@ -190,7 +187,7 @@ def suggest_cache(
|
||||||
HAVING miss_count >= ?
|
HAVING miss_count >= ?
|
||||||
ORDER BY miss_count DESC
|
ORDER BY miss_count DESC
|
||||||
""",
|
""",
|
||||||
(int(threshold),),
|
(threshold,),
|
||||||
)
|
)
|
||||||
|
|
||||||
rows = [r for r in cur.fetchall() if r[0] in no_cache]
|
rows = [r for r in cur.fetchall() if r[0] in no_cache]
|
||||||
|
@ -210,18 +207,13 @@ def suggest_cache(
|
||||||
for item in result:
|
for item in result:
|
||||||
typer.echo(f"{item['host']} {item['path']} {item['misses']}")
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("detect-threats")
|
||||||
def detect_threats(
|
def detect_threats(
|
||||||
hours: int = 1,
|
hours: int = typer.Option(1, help="Number of recent hours to analyze"),
|
||||||
ip_threshold: int = 100,
|
ip_threshold: int = typer.Option(
|
||||||
|
100, help="Requests from a single IP to flag"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Detect potential security threats from recent logs."""
|
"""Detect potential security threats from recent logs."""
|
||||||
|
|
||||||
|
@ -237,8 +229,8 @@ def detect_threats(
|
||||||
|
|
||||||
max_dt = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
|
max_dt = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
|
||||||
recent_end = max_dt
|
recent_end = max_dt
|
||||||
recent_start = recent_end - timedelta(hours=int(hours))
|
recent_start = recent_end - timedelta(hours=hours)
|
||||||
prev_start = recent_start - timedelta(hours=int(hours))
|
prev_start = recent_start - timedelta(hours=hours)
|
||||||
prev_end = recent_start
|
prev_end = recent_start
|
||||||
|
|
||||||
fmt = "%Y-%m-%d %H:%M:%S"
|
fmt = "%Y-%m-%d %H:%M:%S"
|
||||||
|
@ -324,7 +316,9 @@ def detect_threats(
|
||||||
""",
|
""",
|
||||||
(recent_start_s, recent_end_s, ip_threshold),
|
(recent_start_s, recent_end_s, ip_threshold),
|
||||||
)
|
)
|
||||||
high_ip_requests = [{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()]
|
high_ip_requests = [
|
||||||
|
{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@ -345,14 +339,6 @@ def detect_threats(
|
||||||
out_path.write_text(json.dumps(report, indent=2))
|
out_path.write_text(json.dumps(report, indent=2))
|
||||||
typer.echo(json.dumps(report))
|
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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import json
|
|
||||||
from urllib.request import urlopen, Request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ICON_LIST_URL = "https://cc0-icons.jonh.eu/icons.json"
|
|
||||||
BASE_URL = "https://cc0-icons.jonh.eu/"
|
|
||||||
|
|
||||||
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "static" / "icons"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
req = Request(ICON_LIST_URL, headers={"User-Agent": "Mozilla/5.0"})
|
|
||||||
with urlopen(req) as resp:
|
|
||||||
data = json.load(resp)
|
|
||||||
icons = data.get("icons", [])
|
|
||||||
for icon in icons:
|
|
||||||
slug = icon.get("slug")
|
|
||||||
url = BASE_URL + icon.get("url")
|
|
||||||
path = OUTPUT_DIR / f"{slug}.svg"
|
|
||||||
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
||||||
with urlopen(req) as resp:
|
|
||||||
path.write_bytes(resp.read())
|
|
||||||
print(f"Downloaded {len(icons)} icons to {OUTPUT_DIR}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -1,27 +1,17 @@
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime, timezone
|
|
||||||
import time
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
# Ensure project root is importable when running as a script (python scripts/generate_reports.py)
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
DB_PATH = Path("database/ngxstat.db")
|
DB_PATH = Path("database/ngxstat.db")
|
||||||
OUTPUT_DIR = Path("output")
|
OUTPUT_DIR = Path("output")
|
||||||
TEMPLATE_DIR = Path("templates")
|
TEMPLATE_DIR = Path("templates")
|
||||||
REPORT_CONFIG = Path("reports.yml")
|
REPORT_CONFIG = Path("reports.yml")
|
||||||
GENERATED_MARKER = OUTPUT_DIR / "generated.txt"
|
|
||||||
|
|
||||||
# Mapping of interval names to SQLite strftime formats. These strings are
|
# Mapping of interval names to SQLite strftime formats. These strings are
|
||||||
# substituted into report queries whenever the special ``{bucket}`` token is
|
# substituted into report queries whenever the special ``{bucket}`` token is
|
||||||
|
@ -37,19 +27,6 @@ INTERVAL_FORMATS = {
|
||||||
app = typer.Typer(help="Generate aggregated log reports")
|
app = typer.Typer(help="Generate aggregated log reports")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
|
||||||
def _cli_callback(ctx: typer.Context) -> None:
|
|
||||||
"""Register post-command hook to note generation time."""
|
|
||||||
|
|
||||||
def _write_marker() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Use timezone-aware UTC to avoid deprecation warnings and ambiguity
|
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
GENERATED_MARKER.write_text(f"{timestamp}\n")
|
|
||||||
|
|
||||||
ctx.call_on_close(_write_marker)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_domains() -> List[str]:
|
def _get_domains() -> List[str]:
|
||||||
"""Return a sorted list of unique domains from the logs table."""
|
"""Return a sorted list of unique domains from the logs table."""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
@ -77,20 +54,6 @@ def _save_json(path: Path, data: List[Dict]) -> None:
|
||||||
path.write_text(json.dumps(data, indent=2))
|
path.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
def _copy_icons() -> None:
|
|
||||||
"""Copy vendored icons and scripts to the output directory."""
|
|
||||||
src_dir = Path("static/icons")
|
|
||||||
dst_dir = OUTPUT_DIR / "icons"
|
|
||||||
if src_dir.is_dir():
|
|
||||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for icon in src_dir.glob("*.svg"):
|
|
||||||
shutil.copy(icon, dst_dir / icon.name)
|
|
||||||
|
|
||||||
js_src = Path("static/chartManager.js")
|
|
||||||
if js_src.is_file():
|
|
||||||
shutil.copy(js_src, OUTPUT_DIR / js_src.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_snippet(report: Dict, out_dir: Path) -> None:
|
def _render_snippet(report: Dict, out_dir: Path) -> None:
|
||||||
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""
|
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""
|
||||||
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
||||||
|
@ -99,9 +62,7 @@ def _render_snippet(report: Dict, out_dir: Path) -> None:
|
||||||
snippet_path.write_text(template.render(report=report))
|
snippet_path.write_text(template.render(report=report))
|
||||||
|
|
||||||
|
|
||||||
def _write_stats(
|
def _write_stats() -> None:
|
||||||
generated_at: Optional[str] = None, generation_seconds: Optional[float] = None
|
|
||||||
) -> None:
|
|
||||||
"""Query basic dataset stats and write them to ``output/global/stats.json``."""
|
"""Query basic dataset stats and write them to ``output/global/stats.json``."""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
@ -125,10 +86,6 @@ def _write_stats(
|
||||||
"end_date": end_date,
|
"end_date": end_date,
|
||||||
"unique_domains": unique_domains,
|
"unique_domains": unique_domains,
|
||||||
}
|
}
|
||||||
if generated_at:
|
|
||||||
stats["generated_at"] = generated_at
|
|
||||||
if generation_seconds is not None:
|
|
||||||
stats["generation_seconds"] = generation_seconds
|
|
||||||
|
|
||||||
out_path = OUTPUT_DIR / "global" / "stats.json"
|
out_path = OUTPUT_DIR / "global" / "stats.json"
|
||||||
_save_json(out_path, stats)
|
_save_json(out_path, stats)
|
||||||
|
@ -149,8 +106,6 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
||||||
typer.echo("No report definitions found")
|
typer.echo("No report definitions found")
|
||||||
return
|
return
|
||||||
|
|
||||||
_copy_icons()
|
|
||||||
|
|
||||||
bucket = _bucket_expr(interval)
|
bucket = _bucket_expr(interval)
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
@ -185,16 +140,6 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
||||||
name = definition["name"]
|
name = definition["name"]
|
||||||
query = definition["query"].replace("{bucket}", bucket)
|
query = definition["query"].replace("{bucket}", bucket)
|
||||||
query = query.replace("FROM logs", "FROM logs_view")
|
query = query.replace("FROM logs", "FROM logs_view")
|
||||||
# Apply top_n limit for tables (performance-friendly), if configured
|
|
||||||
top_n = definition.get("top_n")
|
|
||||||
chart_type = definition.get("chart", "line")
|
|
||||||
if top_n and chart_type == "table":
|
|
||||||
try:
|
|
||||||
n = int(top_n)
|
|
||||||
if "LIMIT" not in query.upper():
|
|
||||||
query = f"{query}\nLIMIT {n}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
cur.execute(query)
|
cur.execute(query)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
headers = [c[0] for c in cur.description]
|
headers = [c[0] for c in cur.description]
|
||||||
|
@ -208,38 +153,15 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
||||||
"json": f"{name}.json",
|
"json": f"{name}.json",
|
||||||
"html": f"{name}.html",
|
"html": f"{name}.html",
|
||||||
}
|
}
|
||||||
if "icon" in definition:
|
|
||||||
entry["icon"] = definition["icon"]
|
|
||||||
if "bucket" in definition:
|
|
||||||
entry["bucket"] = definition["bucket"]
|
|
||||||
if "buckets" in definition:
|
|
||||||
entry["buckets"] = definition["buckets"]
|
|
||||||
if "bucket_label" in definition:
|
|
||||||
entry["bucket_label"] = definition["bucket_label"]
|
|
||||||
if "color" in definition:
|
if "color" in definition:
|
||||||
entry["color"] = definition["color"]
|
entry["color"] = definition["color"]
|
||||||
if "colors" in definition:
|
if "colors" in definition:
|
||||||
entry["colors"] = definition["colors"]
|
entry["colors"] = definition["colors"]
|
||||||
# Optional UX metadata passthrough for frontend-only transforms
|
|
||||||
for key in (
|
|
||||||
"windows_supported",
|
|
||||||
"window_default",
|
|
||||||
"group_others_threshold",
|
|
||||||
"exclude_values",
|
|
||||||
"top_n",
|
|
||||||
"stacked",
|
|
||||||
"palette",
|
|
||||||
):
|
|
||||||
if key in definition:
|
|
||||||
entry[key] = definition[key]
|
|
||||||
_render_snippet(entry, out_dir)
|
_render_snippet(entry, out_dir)
|
||||||
report_list.append(entry)
|
report_list.append(entry)
|
||||||
|
|
||||||
_save_json(out_dir / "reports.json", report_list)
|
_save_json(out_dir / "reports.json", report_list)
|
||||||
if domain:
|
typer.echo(f"Generated {interval} reports")
|
||||||
typer.echo(f"Generated {interval} reports for {domain}")
|
|
||||||
else:
|
|
||||||
typer.echo(f"Generated {interval} reports")
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_all_domains(interval: str) -> None:
|
def _generate_all_domains(interval: str) -> None:
|
||||||
|
@ -250,10 +172,12 @@ def _generate_all_domains(interval: str) -> None:
|
||||||
|
|
||||||
def _generate_root_index() -> None:
|
def _generate_root_index() -> None:
|
||||||
"""Render the top-level index listing all intervals and domains."""
|
"""Render the top-level index listing all intervals and domains."""
|
||||||
_copy_icons()
|
intervals = [
|
||||||
intervals = sorted(
|
p.name
|
||||||
[name for name in INTERVAL_FORMATS if (OUTPUT_DIR / name).is_dir()]
|
for p in OUTPUT_DIR.iterdir()
|
||||||
)
|
if p.is_dir() and p.name.lower() not in {"domains", "global"}
|
||||||
|
]
|
||||||
|
intervals.sort()
|
||||||
|
|
||||||
domains_dir = OUTPUT_DIR / "domains"
|
domains_dir = OUTPUT_DIR / "domains"
|
||||||
domains: List[str] = []
|
domains: List[str] = []
|
||||||
|
@ -277,12 +201,6 @@ def _generate_global() -> None:
|
||||||
typer.echo("No report definitions found")
|
typer.echo("No report definitions found")
|
||||||
return
|
return
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
# Use timezone-aware UTC for generated_at (string remains unchanged format)
|
|
||||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
_copy_icons()
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
@ -296,16 +214,6 @@ def _generate_global() -> None:
|
||||||
|
|
||||||
name = definition["name"]
|
name = definition["name"]
|
||||||
query = definition["query"]
|
query = definition["query"]
|
||||||
# Apply top_n limit for tables (performance-friendly), if configured
|
|
||||||
top_n = definition.get("top_n")
|
|
||||||
chart_type = definition.get("chart", "line")
|
|
||||||
if top_n and chart_type == "table":
|
|
||||||
try:
|
|
||||||
n = int(top_n)
|
|
||||||
if "LIMIT" not in query.upper():
|
|
||||||
query = f"{query}\nLIMIT {n}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
cur.execute(query)
|
cur.execute(query)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
headers = [c[0] for c in cur.description]
|
headers = [c[0] for c in cur.description]
|
||||||
|
@ -319,67 +227,18 @@ def _generate_global() -> None:
|
||||||
"json": f"{name}.json",
|
"json": f"{name}.json",
|
||||||
"html": f"{name}.html",
|
"html": f"{name}.html",
|
||||||
}
|
}
|
||||||
if "icon" in definition:
|
|
||||||
entry["icon"] = definition["icon"]
|
|
||||||
if "bucket" in definition:
|
|
||||||
entry["bucket"] = definition["bucket"]
|
|
||||||
if "buckets" in definition:
|
|
||||||
entry["buckets"] = definition["buckets"]
|
|
||||||
if "bucket_label" in definition:
|
|
||||||
entry["bucket_label"] = definition["bucket_label"]
|
|
||||||
if "color" in definition:
|
if "color" in definition:
|
||||||
entry["color"] = definition["color"]
|
entry["color"] = definition["color"]
|
||||||
if "colors" in definition:
|
if "colors" in definition:
|
||||||
entry["colors"] = definition["colors"]
|
entry["colors"] = definition["colors"]
|
||||||
# Optional UX metadata passthrough for frontend-only transforms
|
|
||||||
for key in (
|
|
||||||
"windows_supported",
|
|
||||||
"window_default",
|
|
||||||
"group_others_threshold",
|
|
||||||
"exclude_values",
|
|
||||||
"top_n",
|
|
||||||
"stacked",
|
|
||||||
"palette",
|
|
||||||
):
|
|
||||||
if key in definition:
|
|
||||||
entry[key] = definition[key]
|
|
||||||
_render_snippet(entry, out_dir)
|
_render_snippet(entry, out_dir)
|
||||||
report_list.append(entry)
|
report_list.append(entry)
|
||||||
|
|
||||||
_save_json(out_dir / "reports.json", report_list)
|
_save_json(out_dir / "reports.json", report_list)
|
||||||
elapsed = round(time.time() - start_time, 2)
|
_write_stats()
|
||||||
_write_stats(generated_at, elapsed)
|
|
||||||
typer.echo("Generated global reports")
|
typer.echo("Generated global reports")
|
||||||
|
|
||||||
|
|
||||||
def _generate_analysis() -> None:
|
|
||||||
"""Generate analysis JSON files consumed by the Analysis tab."""
|
|
||||||
try:
|
|
||||||
# Import lazily to avoid circulars and keep dependencies optional
|
|
||||||
from scripts import analyze
|
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
|
||||||
typer.echo(f"Failed to import analysis module: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Ensure output root and icons present for parity
|
|
||||||
_copy_icons()
|
|
||||||
|
|
||||||
# These commands write JSON files under output/analysis/
|
|
||||||
try:
|
|
||||||
analyze.check_missing_domains(json_output=True)
|
|
||||||
except Exception as exc: # pragma: no cover - continue best-effort
|
|
||||||
typer.echo(f"check_missing_domains failed: {exc}")
|
|
||||||
try:
|
|
||||||
analyze.suggest_cache(json_output=True)
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
typer.echo(f"suggest_cache failed: {exc}")
|
|
||||||
try:
|
|
||||||
analyze.detect_threats()
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
typer.echo(f"detect_threats failed: {exc}")
|
|
||||||
typer.echo("Generated analysis JSON files")
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def hourly(
|
def hourly(
|
||||||
domain: Optional[str] = typer.Option(
|
domain: Optional[str] = typer.Option(
|
||||||
|
@ -450,12 +309,6 @@ def global_reports() -> None:
|
||||||
_generate_global()
|
_generate_global()
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def analysis() -> None:
|
|
||||||
"""Generate analysis JSON files for the Analysis tab."""
|
|
||||||
_generate_analysis()
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def index() -> None:
|
def index() -> None:
|
||||||
"""Generate the root index page linking all reports."""
|
"""Generate the root index page linking all reports."""
|
||||||
|
|
|
@ -61,9 +61,7 @@ try:
|
||||||
suffix = match.group(1)
|
suffix = match.group(1)
|
||||||
number = int(suffix.lstrip(".")) if suffix else 0
|
number = int(suffix.lstrip(".")) if suffix else 0
|
||||||
log_files.append((number, os.path.join(LOG_DIR, f)))
|
log_files.append((number, os.path.join(LOG_DIR, f)))
|
||||||
log_files = [
|
log_files = [path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)]
|
||||||
path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)
|
|
||||||
]
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(f"[ERROR] Log directory not found: {LOG_DIR}")
|
print(f"[ERROR] Log directory not found: {LOG_DIR}")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
|
@ -49,15 +49,7 @@ def discover_configs() -> Set[Path]:
|
||||||
found.add(path)
|
found.add(path)
|
||||||
for pattern in INCLUDE_RE.findall(text):
|
for pattern in INCLUDE_RE.findall(text):
|
||||||
pattern = os.path.expanduser(pattern.strip())
|
pattern = os.path.expanduser(pattern.strip())
|
||||||
if os.path.isabs(pattern):
|
for included in path.parent.glob(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:
|
if included.is_file() and included not in found:
|
||||||
queue.append(included)
|
queue.append(included)
|
||||||
return found
|
return found
|
||||||
|
@ -93,3 +85,4 @@ def parse_servers(paths: Set[Path]) -> List[Dict[str, str]]:
|
||||||
entry["root"] = " ".join(directives["root"])
|
entry["root"] = " ".join(directives["root"])
|
||||||
servers.append(entry)
|
servers.append(entry)
|
||||||
return servers
|
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 |
|
@ -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.5 5-17M6 17l-4-4.5L6 8m12 0 4 4.5-4 4.5"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 281 B |