Compare commits

...
Sign in to create a new pull request.

76 commits

Author SHA1 Message Date
ngxstat-bot
3717197991 analysis: make suggest_cache and detect_threats pure-callable, add CLI wrappers\n\n- Replace Typer Option defaults with plain Python defaults in functions used by generator/tests\n- Add CLI wrapper commands (, ) that delegate to the pure functions\n- Cast params to int for SQL/timedelta to avoid type issues\n- Resolves OptionInfo errors during run-reports analysis phase 2025-08-19 00:51:10 -05:00
ngxstat-bot
359d69c3e9 Recent: place Cache Status and HTTP Statuses side-by-side in a single row\n\n- Add a Bulma columns row in Recent section\n- Route the two key distribution charts into two half-width columns\n- Leave other global reports stacked below as before 2025-08-19 00:48:32 -05:00
ngxstat-bot
f0ed112626 reports: fix analysis import error when run as a script\n\n- Prepend project root to sys.path in scripts/generate_reports.py to allow when executed via path\n- Update run-reports.sh to invoke the generator as a module () for robust imports\n- Keeps CLI behavior the same while eliminating 'No module named scripts' 2025-08-19 00:40:07 -05:00
ngxstat-bot
8eec623c92 reports: use timezone-aware UTC for timestamps\n\n- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)\n- Keeps existing human-friendly format while avoiding deprecation warnings\n- Applies to marker file and generated_at in stats 2025-08-19 00:36:41 -05:00
ngxstat-bot
2bfd487106 UX: merge Distribution and Tables into a single Breakdown tab with clear option help\n\n- Replace separate Distribution/Tables tabs with one Breakdown tab\n- Breakdown shows categorical charts and data tables together\n- Add in-page help explaining Percent mode, Grouping, and Exclude '-'\n- Update filtering, containers, and tab logic to target new tab\n- Keep existing report JSON/HTML contracts; no server changes required 2025-08-19 00:28:42 -05:00
ngxstat-bot
95e54359d7 UX: unify time selection and simplify controls\n\n- Replace separate Interval + Window with a single Time preset (Last hour/24h/7d/30d/12w/12m/All time)\n- Map presets to sensible grouping (hourly/daily/weekly/monthly) based on available intervals\n- Keep backward compatibility: preserve existing URL/state params; keep legacy controls hidden\n- Add client support for new windows (12w, 12m) in time-bucket slicing\n- Show only relevant controls per tab (Trends: smoothing; Distribution: percent/group/exclude)\n- Streamline reset flow to a sane default (Last 7 days) 2025-08-19 00:09:49 -05:00
ngxstat-bot
6de85b7cc5 UX Phase 1 follow-ups: state v2 + reset, window defaults + support, palette support; analysis JSON generation; tests for LIMIT/metadata; README updates 2025-08-18 23:47:23 -05:00
ngxstat-bot
fab91d2e04 Phase 1 UX + JS transforms: tabs, windowing, percent/grouping, smoothing, stacked series, metadata pass-through, top_n
- Replace tabs with Recent/Trends/Distribution/Tables/Analysis and add sticky controls (interval, domain, window [default 7d], percent, group small, exclude '-' -> Uncached, smoothing toggle).

- Client-side transforms: time-window slicing, percent mode, group others (3%), per-report exclusions; stackedBar multi-series; moving average for error_rate.

- Generator: pass through optional UX metadata (windows_supported, window_default, group_others_threshold, exclude_values, top_n, stacked, palette) and enforce top_n LIMIT for table reports.

- Reports: add status_classes_timeseries and cache_status_timeseries; apply top_n=50 to heavy tables.

- Chart manager: add helpers (sliceWindow, excludeValues, toPercent, groupOthers, movingAverage).

- URL state + localStorage for context; per-tab filtering for Trends/Distribution/Tables.
2025-08-18 23:01:00 -05:00
ngxstat-bot
9c26ae3e90 ci: ensure repo root on PYTHONPATH when running pytest
All checks were successful
CI / Lint, test, and build (push) Successful in 49s
2025-08-16 05:29:20 -05:00
ngxstat-bot
a8f7ac9b7a lint: remove unused Path import in tests/test_nginx_config.py
Some checks failed
CI / Lint, test, and build (push) Failing after 45s
2025-08-16 05:27:02 -05:00
ngxstat-bot
5053a4c4db lint: re-enable E402; remove sys.path hacks; drop unused pytest imports in tests
Some checks failed
CI / Lint, test, and build (push) Failing after 46s
2025-08-16 05:24:14 -05:00
ngxstat-bot
176359d010 lint: remove unused typing import; mark test pytest import as noqa F401
All checks were successful
CI / Lint, test, and build (push) Successful in 48s
2025-08-16 05:19:48 -05:00
ngxstat-bot
ab4f017ba8 ci: robust venv creation; verify cached venv has activate and fallback to local
Some checks failed
CI / Lint, test, and build (push) Failing after 45s
2025-08-16 05:17:51 -05:00
ngxstat-bot
136b4196ea ci: add pytest --maxfail=1 and simple pip/venv cache at /cache if available
Some checks failed
CI / Lint, test, and build (push) Failing after 33s
2025-08-16 05:15:59 -05:00
ngxstat-bot
979fbb0e64 ci(lint): configure flake8 excludes/line-length; fix F541 and F401 in tests
Some checks failed
CI / Lint, test, and build (push) Failing after 39s
2025-08-16 05:14:01 -05:00
ngxstat-bot
0363c37202 ci: replace Node-based actions with manual git clone and Debian container
Some checks failed
CI / Lint, test, and build (push) Failing after 49s
2025-08-16 05:05:33 -05:00
ngxstat-bot
91f87689d0 ci: add Forgejo Actions workflow for lint, test, and sample reports artifact
Some checks failed
CI / Lint and test (py3.10) (push) Failing after 40s
CI / Lint and test (py3.11) (push) Failing after 8s
CI / Lint and test (py3.12) (push) Failing after 8s
CI / Build sample reports artifact (push) Has been skipped
2025-08-16 04:57:20 -05:00
97b735f17a
Merge pull request #51 from wagesj45/codex/locate-usage-of-/output-directory
Record timestamp of last report generation
2025-08-02 03:13:49 -05:00
2300849fdc Place generated marker in output directory 2025-08-02 03:12:59 -05:00
75d6b219aa
Merge pull request #50 from wagesj45/codex/add-domain-grouping-to-reports
Enable multi-column table reports
2025-07-19 18:20:08 -05:00
1d4e99c69b Add multi-bucket support for tables and update reports 2025-07-19 18:19:58 -05:00
250cce8c11
Merge pull request #49 from wagesj45/codex/implement-chartmanager-for-loading-and-aborting
Add chart manager for abortable fetches
2025-07-19 18:01:36 -05:00
5d2546ad60 Add chart loading management 2025-07-19 18:01:26 -05:00
3135dbe378
Merge pull request #48 from wagesj45/codex/investigate-per-domain-chart-loading-error
Fix per-domain chart reload issue
2025-07-19 17:04:17 -05:00
297c913f2a fix: destroy charts when switching tabs 2025-07-19 17:03:48 -05:00
bf09af6b5e
Merge pull request #47 from wagesj45/codex/fix-per-domain-charts-loading-bug
Fix chart reload bug
2025-07-19 16:42:18 -05:00
fb753a1189 fix: destroy charts when reloading domain reports 2025-07-19 16:41:58 -05:00
7271da95ac
Merge pull request #46 from wagesj45/codex/add-report-generation-time-statistics
Add report generation timing stats
2025-07-19 04:50:22 -05:00
669b51e03c Add report generation timing stats 2025-07-19 04:50:09 -05:00
8800f73661
Merge pull request #45 from wagesj45/codex/update-agents.md-and-readme.md
Update docs and agent instructions
2025-07-19 04:31:28 -05:00
832a0a49dd docs: refresh README and agent guide 2025-07-19 04:31:13 -05:00
2a9bab6e6f
Merge pull request #44 from wagesj45/codex/retool-reports-for-better-visualization
Improve report field naming
2025-07-19 04:17:51 -05:00
87608ccdb9 refactor: improve report field names 2025-07-19 04:17:38 -05:00
9d55abfebe
Merge pull request #43 from wagesj45/codex/update-duration-dropdown-to-include-specific-directories
Update duration dropdown logic
2025-07-19 04:08:51 -05:00
66b1209486 Fix dropdown duration dirs 2025-07-19 04:08:40 -05:00
24c4b05424
Merge pull request #42 from wagesj45/codex/integrate-cc0-icons-and-update-ui
Switch to CC0 icons
2025-07-19 03:59:09 -05:00
4017b4ab72 Fix duplicate icon assignment 2025-07-19 03:58:41 -05:00
ad28b6e81e
Merge pull request #41 from wagesj45/codex/add-logging,-locking-and-cron-setup-scripts
Add lock files and cron setup
2025-07-19 03:35:31 -05:00
2b38de598f Add lock files and cron setup 2025-07-19 03:35:18 -05:00
573a41c556
Merge pull request #40 from wagesj45/codex/remove-duration-dropdown-from-analysis-tab
Fix analysis tab intervals
2025-07-19 03:30:30 -05:00
d1f3c5a9ae Hide analysis duration 2025-07-19 03:30:08 -05:00
d33a3f162f
Merge pull request #39 from wagesj45/codex/fix-notimplementederror-in-analysis
Fix absolute include patterns in nginx config discovery
2025-07-19 03:17:58 -05:00
2443aecaf6 fix nginx config discovery for absolute include patterns 2025-07-19 03:17:07 -05:00
07528dad13
Merge pull request #38 from wagesj45/codex/fix-modulenotfounderror-in-analyze.py
Fix module imports in run-analysis
2025-07-19 02:55:29 -05:00
0a05f0c010 Fix module imports in run-analysis 2025-07-19 02:55:15 -05:00
086920339a
Merge pull request #37 from wagesj45/codex/add-analysis-tab-to-report-page
Add analysis tab with JSON output
2025-07-19 02:30:55 -05:00
9cf27ecb2f Add analysis tab and JSON outputs 2025-07-19 02:30:15 -05:00
8dcf2035aa
Merge pull request #36 from wagesj45/codex/add-run-analysis.sh-script
Add run-analysis script and tests
2025-07-19 02:19:25 -05:00
2e7e75e4ce Add run-analysis helper script and tests 2025-07-19 02:19:08 -05:00
b1658d28ba
Merge pull request #35 from wagesj45/codex/add-detect_threats-command-in-analyze.py
Add threat detection command
2025-07-19 02:12:51 -05:00
350445b167 Add threat detection analysis 2025-07-19 02:12:24 -05:00
0354185bb9
Merge pull request #34 from wagesj45/codex/add-suggest_cache-command-to-analyze.py
Add suggest_cache analysis command
2025-07-19 02:07:35 -05:00
7f996fe123 Add suggest_cache command and tests 2025-07-19 02:07:23 -05:00
e32a47843c
Merge pull request #33 from wagesj45/codex/add-check_missing_domains-command
Add missing domain check command
2025-07-19 02:01:14 -05:00
1a6e836631 Add check_missing_domains command and tests 2025-07-19 02:00:37 -05:00
4c80860b20
Merge pull request #32 from wagesj45/codex/create-analyze.py-for-nginx-stats
Add analyze script for simple DB queries
2025-07-19 01:53:56 -05:00
37fc898556 Add analyze utility for DB queries 2025-07-19 01:53:36 -05:00
9bd926ff06
Merge pull request #31 from wagesj45/codex/create-nginx_config-utility-module
Add nginx config parser utilities
2025-07-19 01:51:04 -05:00
97ad5bc998 Add nginx config parsing utilities 2025-07-19 01:50:27 -05:00
84e3e0e7de
Merge pull request #30 from wagesj45/codex/fix-data-loading-bug-on-per-domain-tab
Fix domain chart paths
2025-07-19 01:37:43 -05:00
315cdaf2ad Fix per-domain chart paths 2025-07-19 01:37:33 -05:00
ad9030a6fd
Merge pull request #29 from wagesj45/codex/remove-global-from-duration-options
Fix dropdown showing 'Global' interval
2025-07-19 01:00:31 -05:00
35c1a00feb Fix interval dropdown options 2025-07-19 00:58:25 -05:00
71b11721ab
Merge pull request #28 from wagesj45/codex/remove-global-option-from-duration-view
Fix dropdown intervals to exclude global reports
2025-07-19 00:46:15 -05:00
f6b04e7e2e Exclude global directory from interval dropdown 2025-07-19 00:43:25 -05:00
5de2aa4c66
Merge pull request #27 from wagesj45/codex/implement-tabbed-interface-for-reports
Add Bulma tabs to dashboard
2025-07-19 00:26:59 -05:00
b67266d3b3 Add tabbed interface to dashboard 2025-07-19 00:26:00 -05:00
207facedc2
Merge pull request #26 from wagesj45/codex/extend-reports.yml-syntax-for-per_domain
Support per_domain flag in report config
2025-07-19 00:21:52 -05:00
e03c7bc434 Add per_domain flag to report config 2025-07-19 00:21:42 -05:00
0bf67f6107
Merge pull request #25 from wagesj45/codex/implement-report-generation-and-rendering
Add global stats reporting
2025-07-19 00:16:22 -05:00
a1102952e9 Add global stats generation 2025-07-19 00:16:11 -05:00
9619888a3a
Merge pull request #24 from wagesj45/codex/add-global-report-command-and-tests
Add global report generation command
2025-07-19 00:10:30 -05:00
a3f06fd9e2 Add global report generation 2025-07-19 00:09:26 -05:00
11d6e5e4ba
Merge pull request #23 from wagesj45/codex/implement-report-snippet-template-and-updates
Add snippet-based report rendering
2025-07-18 23:20:26 -05:00
ab2af1015a Switch to snippet-based reports 2025-07-18 23:20:13 -05:00
da330a6058
Merge pull request #22 from wagesj45/codex/add-color-differentiation-to-charts
Improve chart color support
2025-07-18 23:03:24 -05:00
230 changed files with 3415 additions and 249 deletions

3
.flake8 Normal file
View file

@ -0,0 +1,3 @@
[flake8]
exclude = .git, .venv, output, static/icons
max-line-length = 160

151
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,151 @@
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"

View file

@ -24,6 +24,9 @@ This document outlines general practices and expectations for AI agents assistin
The `run-import.sh` script can initialize this environment automatically.
Always activate the virtual environment before running scripts or tests.
* Before committing code run `black` for consistent formatting and execute
the test suite with `pytest`. All tests should pass.
* Dependency management: Use `requirements.txt` or `pip-tools`
* Use standard libraries where feasible (e.g., `sqlite3`, `argparse`, `datetime`)
* Adopt `typer` for CLI command interface (if CLI ergonomics matter)
@ -89,6 +92,14 @@ ngxstat/
If uncertain, the agent should prompt the human for clarification before making architectural assumptions.
## Testing
Use `pytest` for automated tests. Run the suite from an activated virtual environment and ensure all tests pass before committing:
```bash
pytest -q
```
---
## Future Capabilities
@ -106,3 +117,4 @@ As the project matures, agents may also:
* **2025-07-17**: Initial version by Jordan + ChatGPT
* **2025-07-17**: Expanded virtual environment usage guidance

147
README.md
View file

@ -1,11 +1,16 @@
# ngxstat
Per-domain Nginx log analytics with hybrid static reports and live insights.
## Generating Reports
`ngxstat` is a lightweight log analytics toolkit for Nginx. It imports access
logs into an SQLite database and renders static dashboards so you can explore
per-domain metrics without running a heavy backend service.
Use the `generate_reports.py` script to build aggregated JSON and HTML files from `database/ngxstat.db`.
## Requirements
Create a virtual environment and install dependencies:
* Python 3.10+
* Access to the Nginx log files (default: `/var/log/nginx`)
The helper scripts create a virtual environment on first run, but you can also
set one up manually:
```bash
python3 -m venv .venv
@ -13,105 +18,95 @@ source .venv/bin/activate
pip install -r requirements.txt
```
Then run one or more of the interval commands:
```bash
python scripts/generate_reports.py hourly
python scripts/generate_reports.py daily
python scripts/generate_reports.py weekly
python scripts/generate_reports.py monthly
```
Each command accepts optional flags to generate per-domain reports. Use
`--domain <name>` to limit output to a specific domain or `--all-domains`
to generate a subdirectory for every domain found in the database:
```bash
# Hourly reports for example.com only
python scripts/generate_reports.py hourly --domain example.com
# Weekly reports for all domains individually
python scripts/generate_reports.py weekly --all-domains
```
Reports are written under the `output/` directory. Each command updates the corresponding `<interval>.json` file and produces an HTML dashboard using Chart.js.
### Configuring Reports
Report queries are defined in `reports.yml`. Each entry specifies the `name`,
optional `label` and `chart` type, and a SQL `query` that must return `bucket`
and `value` columns. The special token `{bucket}` is replaced with the
appropriate SQLite `strftime` expression for each interval (hourly, daily,
weekly or monthly) so that a single definition works across all durations.
When `generate_reports.py` runs, every definition is executed for the requested
interval and creates `output/<interval>/<name>.json` along with an HTML
dashboard.
Example snippet:
```yaml
- name: hits
chart: bar
query: |
SELECT {bucket} AS bucket,
COUNT(*) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
```
Add or modify entries in `reports.yml` to tailor the generated metrics.
## Importing Logs
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`.
Run the importer to ingest new log entries into `database/ngxstat.db`:
```bash
./run-import.sh
```
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.
Rotated logs are processed in order and only entries newer than the last
imported timestamp are added.
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.
## Generating Reports
## Cron Report Generation
Use the `run-reports.sh` script to run all report intervals in one step. The script sets up the Python environment the same way as `run-import.sh`, making it convenient for automation via cron.
To build the HTML dashboard and JSON data files use `run-reports.sh` which runs
all intervals in one go:
```bash
./run-reports.sh
```
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.
The script calls `scripts/generate_reports.py` internally to create hourly,
daily, weekly and monthly reports, then writes analysis JSON files used by the
"Analysis" tab. Per-domain reports are written under `output/domains/<domain>`
alongside the aggregate data. Open `output/index.html` in a browser to view the
dashboard.
## Serving Reports with Nginx
If you prefer to run individual commands you can invoke the generator directly:
To expose the generated HTML dashboards and JSON files over HTTP you can use a
simple Nginx server block. Point the `root` directive to the repository's
`output/` directory and optionally restrict access to your local network.
```bash
python scripts/generate_reports.py hourly
python scripts/generate_reports.py daily --all-domains
```
## Analysis Helpers
`run-analysis.sh` executes additional utilities that examine the database for
missing domains, caching opportunities and potential threats. The JSON output is
saved under `output/analysis` and appears in the "Analysis" tab. The
`run-reports.sh` script also generates these JSON files as part of the build.
## UX Controls
The dashboard defaults to a 7day window for time series. Your view preferences
persist locally in the browser under the `ngxstat-state-v2` key. Use the
"Reset view" button to clear saved state and restore defaults.
```bash
./run-analysis.sh
```
## Serving the Reports
The generated files are static. You can serve them with a simple Nginx block:
```nginx
server {
listen 80;
server_name example.com;
# Path to the generated reports
root /path/to/ngxstat/output;
location / {
try_files $uri $uri/ =404;
}
# Allow access only from private networks
allow 192.0.0.0/8;
allow 10.0.0.0/8;
deny all;
}
```
With this configuration the generated static files are served directly by
Nginx while connections outside of `192.*` and `10.*` are denied.
Restrict access if the reports should not be public.
## 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 thirdparty 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.

View file

@ -1,28 +1,37 @@
- name: hits
label: Hits
icon: pulse
chart: line
bucket: time_bucket
bucket_label: Time
query: |
SELECT {bucket} AS bucket,
SELECT {bucket} AS time_bucket,
COUNT(*) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
GROUP BY time_bucket
ORDER BY time_bucket
- name: error_rate
label: Error Rate (%)
icon: file-alert
chart: line
bucket: time_bucket
bucket_label: Time
query: |
SELECT {bucket} AS bucket,
SELECT {bucket} AS time_bucket,
SUM(CASE WHEN status BETWEEN 400 AND 599 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
GROUP BY time_bucket
ORDER BY time_bucket
- name: cache_status_breakdown
label: Cache Status
icon: archive
chart: polarArea
bucket: cache_status
bucket_label: Cache Status
query: |
SELECT cache_status AS bucket,
SELECT cache_status AS cache_status,
COUNT(*) AS value
FROM logs
GROUP BY cache_status
@ -37,77 +46,168 @@
- name: domain_traffic
label: Top Domains
icon: globe
chart: table
top_n: 50
per_domain: false
bucket: domain
bucket_label: Domain
query: |
SELECT host AS bucket,
SELECT host AS domain,
COUNT(*) AS value
FROM logs
GROUP BY host
GROUP BY domain
ORDER BY value DESC
- name: bytes_sent
label: Bytes Sent
icon: upload
chart: line
bucket: time_bucket
bucket_label: Time
query: |
SELECT {bucket} AS bucket,
SELECT {bucket} AS time_bucket,
SUM(bytes_sent) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
GROUP BY time_bucket
ORDER BY time_bucket
- name: top_paths
label: Top Paths
icon: map
chart: table
top_n: 50
buckets:
- domain
- path
bucket_label:
- Domain
- Path
query: |
SELECT path AS bucket,
COUNT(*) AS value
FROM (
SELECT substr(substr(request, instr(request, ' ') + 1), 1,
WITH paths AS (
SELECT host AS domain,
substr(substr(request, instr(request, ' ') + 1), 1,
instr(substr(request, instr(request, ' ') + 1), ' ') - 1) AS path
FROM logs
), ranked AS (
SELECT domain, path, COUNT(*) AS value,
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM paths
GROUP BY domain, path
)
GROUP BY path
ORDER BY value DESC
LIMIT 20
SELECT domain, path, value
FROM ranked
WHERE rn <= 20
ORDER BY domain, value DESC
- name: user_agents
label: User Agents
icon: user
chart: table
top_n: 50
buckets:
- domain
- user_agent
bucket_label:
- Domain
- User Agent
query: |
SELECT user_agent AS bucket,
COUNT(*) AS value
FROM logs
GROUP BY user_agent
ORDER BY value DESC
LIMIT 20
WITH ua AS (
SELECT host AS domain, user_agent
FROM logs
), ranked AS (
SELECT domain, user_agent, COUNT(*) AS value,
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM ua
GROUP BY domain, user_agent
)
SELECT domain, user_agent, value
FROM ranked
WHERE rn <= 20
ORDER BY domain, value DESC
- name: referrers
label: Referrers
icon: link
chart: table
top_n: 50
buckets:
- domain
- referrer
bucket_label:
- Domain
- Referrer
query: |
SELECT referer AS bucket,
COUNT(*) AS value
FROM logs
GROUP BY referer
ORDER BY value DESC
LIMIT 20
WITH ref AS (
SELECT host AS domain, referer AS referrer
FROM logs
), ranked AS (
SELECT domain, referrer, COUNT(*) AS value,
ROW_NUMBER() OVER (PARTITION BY domain ORDER BY COUNT(*) DESC) AS rn
FROM ref
GROUP BY domain, referrer
)
SELECT domain, referrer, value
FROM ranked
WHERE rn <= 20
ORDER BY domain, value DESC
- name: status_distribution
label: HTTP Statuses
icon: server
chart: pie
bucket: status_group
bucket_label: Status
query: |
SELECT CASE
WHEN status BETWEEN 200 AND 299 THEN '2xx'
WHEN status BETWEEN 300 AND 399 THEN '3xx'
WHEN status BETWEEN 400 AND 499 THEN '4xx'
ELSE '5xx'
END AS bucket,
END AS status_group,
COUNT(*) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
GROUP BY status_group
ORDER BY status_group
colors:
- "#48c78e"
- "#209cee"
- "#ffdd57"
- "#f14668"
# New time-series: status classes over time (stacked)
- name: status_classes_timeseries
label: Status Classes Over Time
icon: server
chart: stackedBar
bucket: time_bucket
bucket_label: Time
stacked: true
query: |
SELECT {bucket} AS time_bucket,
SUM(CASE WHEN status BETWEEN 200 AND 299 THEN 1 ELSE 0 END) AS "2xx",
SUM(CASE WHEN status BETWEEN 300 AND 399 THEN 1 ELSE 0 END) AS "3xx",
SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS "4xx",
SUM(CASE WHEN status BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS "5xx",
COUNT(*) AS total
FROM logs
GROUP BY time_bucket
ORDER BY time_bucket
# New time-series: cache status over time (compact Hit/Miss; exclude '-' by default)
- name: cache_status_timeseries
label: Cache Status Over Time
icon: archive
chart: stackedBar
bucket: time_bucket
bucket_label: Time
stacked: true
exclude_values: ["-"]
query: |
SELECT {bucket} AS time_bucket,
SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) AS hit,
SUM(CASE WHEN cache_status = 'MISS' THEN 1 ELSE 0 END) AS miss,
COUNT(*) AS total
FROM logs
GROUP BY time_bucket
ORDER BY time_bucket

43
run-analysis.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -e
# Prevent concurrent executions of this script.
LOCK_FILE="/tmp/$(basename "$0").lock"
if [ -e "$LOCK_FILE" ]; then
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
exit 0
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
# Ensure virtual environment exists
if [ ! -d ".venv" ]; then
echo "[INFO] Creating virtual environment..."
python3 -m venv .venv
source .venv/bin/activate
echo "[INFO] Installing dependencies..."
pip install --upgrade pip
if [ -f requirements.txt ]; then
pip install -r requirements.txt
else
echo "[WARN] requirements.txt not found, skipping."
fi
else
echo "[INFO] Activating virtual environment..."
source .venv/bin/activate
fi
# Run analysis helpers
echo "[INFO] Checking for missing domains..."
python -m scripts.analyze check-missing-domains
echo "[INFO] Suggesting cache improvements..."
python -m scripts.analyze suggest-cache
echo "[INFO] Detecting threats..."
python -m scripts.analyze detect-threats
# Deactivate to keep cron environment clean
if type deactivate >/dev/null 2>&1; then
deactivate
fi

View file

@ -1,6 +1,17 @@
#!/usr/bin/env bash
set -e
# Prevent multiple simultaneous runs by using a lock file specific to this
# script. If the lock already exists, assume another instance is running and
# exit gracefully.
LOCK_FILE="/tmp/$(basename "$0").lock"
if [ -e "$LOCK_FILE" ]; then
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
exit 0
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
# Ensure virtual environment exists
if [ ! -d ".venv" ]; then
echo "[INFO] Creating virtual environment..."

View file

@ -1,6 +1,15 @@
#!/usr/bin/env bash
set -e
# Prevent concurrent executions of this script.
LOCK_FILE="/tmp/$(basename "$0").lock"
if [ -e "$LOCK_FILE" ]; then
echo "[WARN] $(basename "$0") is already running (lock file present)." >&2
exit 0
fi
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
# Ensure virtual environment exists
if [ ! -d ".venv" ]; then
echo "[INFO] Creating virtual environment..."
@ -20,20 +29,25 @@ fi
# Generate reports for all domains combined
echo "[INFO] Generating aggregate reports..."
python scripts/generate_reports.py hourly
python scripts/generate_reports.py daily
python scripts/generate_reports.py weekly
python scripts/generate_reports.py monthly
python -m scripts.generate_reports hourly
python -m scripts.generate_reports daily
python -m scripts.generate_reports weekly
python -m scripts.generate_reports monthly
python -m scripts.generate_reports global
# Generate reports for each individual domain
echo "[INFO] Generating per-domain reports..."
python scripts/generate_reports.py hourly --all-domains
python scripts/generate_reports.py daily --all-domains
python scripts/generate_reports.py weekly --all-domains
python scripts/generate_reports.py monthly --all-domains
python -m scripts.generate_reports hourly --all-domains
python -m scripts.generate_reports daily --all-domains
python -m scripts.generate_reports weekly --all-domains
python -m scripts.generate_reports monthly --all-domains
# Generate analysis JSON
echo "[INFO] Generating analysis files..."
python -m scripts.generate_reports analysis
# Generate root index
python scripts/generate_reports.py index
python -m scripts.generate_reports index
# Deactivate to keep cron environment clean
if type deactivate >/dev/null 2>&1; then

1
scripts/__init__.py Normal file
View file

@ -0,0 +1 @@
"Utility package for ngxstat scripts"

358
scripts/analyze.py Normal file
View file

@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Utility helpers for ad-hoc log analysis.
This module exposes small helper functions to inspect the ``ngxstat`` SQLite
database. The intent is to allow quick queries from the command line or other
scripts without rewriting SQL each time.
Examples
--------
To list all domains present in the database::
python scripts/analyze.py domains
The CLI is powered by :mod:`typer` and currently only offers a couple of
commands. More analysis routines can be added over time.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import List, Optional, Set
from datetime import datetime, timedelta
import json
import typer
from scripts import nginx_config # noqa: F401 # imported for side effects/usage
DB_PATH = Path("database/ngxstat.db")
ANALYSIS_DIR = Path("output/analysis")
app = typer.Typer(help="Ad-hoc statistics queries")
def _connect() -> sqlite3.Connection:
"""Return a new SQLite connection to :data:`DB_PATH`."""
return sqlite3.connect(DB_PATH)
def load_domains_from_db() -> List[str]:
"""Return a sorted list of distinct domains from the ``logs`` table."""
conn = _connect()
cur = conn.cursor()
cur.execute("SELECT DISTINCT host FROM logs ORDER BY host")
domains = [row[0] for row in cur.fetchall()]
conn.close()
return domains
def get_hit_count(domain: Optional[str] = None) -> int:
"""Return total request count.
Parameters
----------
domain:
Optional domain to filter on. If ``None`` the count includes all logs.
"""
conn = _connect()
cur = conn.cursor()
if domain:
cur.execute("SELECT COUNT(*) FROM logs WHERE host = ?", (domain,))
else:
cur.execute("SELECT COUNT(*) FROM logs")
count = cur.fetchone()[0] or 0
conn.close()
return count
def get_cache_ratio(domain: Optional[str] = None) -> float:
"""Return the percentage of requests served from cache."""
conn = _connect()
cur = conn.cursor()
if domain:
cur.execute(
"SELECT SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) * 1.0 / "
"COUNT(*) FROM logs WHERE host = ?",
(domain,),
)
else:
cur.execute(
"SELECT SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) * 1.0 / "
"COUNT(*) FROM logs"
)
result = cur.fetchone()[0]
conn.close()
return float(result or 0.0)
@app.command()
def domains() -> None:
"""Print the list of domains discovered in the database."""
for d in load_domains_from_db():
typer.echo(d)
@app.command()
def hits(domain: Optional[str] = typer.Option(None, help="Filter by domain")) -> None:
"""Show request count."""
count = get_hit_count(domain)
if domain:
typer.echo(f"{domain}: {count} hits")
else:
typer.echo(f"Total hits: {count}")
@app.command("cache-ratio")
def cache_ratio_cmd(
domain: Optional[str] = typer.Option(None, help="Filter by domain")
) -> None:
"""Display cache hit ratio as a percentage."""
ratio = get_cache_ratio(domain) * 100
if domain:
typer.echo(f"{domain}: {ratio:.2f}% cached")
else:
typer.echo(f"Cache hit ratio: {ratio:.2f}%")
@app.command("check-missing-domains")
def check_missing_domains(
json_output: bool = typer.Option(
False, "--json", help="Output missing domains as JSON"
)
) -> None:
"""Show domains present in the database but absent from Nginx config."""
try:
from scripts.generate_reports import _get_domains as _db_domains
except Exception: # pragma: no cover - fallback if import fails
_db_domains = load_domains_from_db
if not isinstance(json_output, bool):
json_output = False
db_domains = set(_db_domains())
paths = nginx_config.discover_configs()
servers = nginx_config.parse_servers(paths)
config_domains: Set[str] = set()
for server in servers:
names = server.get("server_name", "")
for name in names.split():
if name:
config_domains.add(name)
missing = sorted(db_domains - config_domains)
ANALYSIS_DIR.mkdir(parents=True, exist_ok=True)
out_path = ANALYSIS_DIR / "missing_domains.json"
out_path.write_text(json.dumps(missing, indent=2))
if json_output:
typer.echo(json.dumps(missing))
else:
for d in missing:
typer.echo(d)
def suggest_cache(
threshold: int = 10,
json_output: bool = False,
) -> None:
"""Suggest domain/path pairs that could benefit from caching.
Paths with at least ``threshold`` ``MISS`` entries are shown for domains
whose server blocks lack a ``proxy_cache`` directive.
"""
# Discover domains without explicit proxy_cache
paths = nginx_config.discover_configs()
servers = nginx_config.parse_servers(paths)
no_cache: Set[str] = set()
for server in servers:
if "proxy_cache" in server:
continue
for name in server.get("server_name", "").split():
if name:
no_cache.add(name)
conn = _connect()
cur = conn.cursor()
cur.execute(
"""
SELECT host,
substr(request, instr(request, ' ')+1,
instr(request, ' HTTP') - instr(request, ' ') - 1) AS path,
COUNT(*) AS miss_count
FROM logs
WHERE cache_status = 'MISS'
GROUP BY host, path
HAVING miss_count >= ?
ORDER BY miss_count DESC
""",
(int(threshold),),
)
rows = [r for r in cur.fetchall() if r[0] in no_cache]
conn.close()
result = [
{"host": host, "path": path, "misses": count} for host, path, count in rows
]
ANALYSIS_DIR.mkdir(parents=True, exist_ok=True)
out_path = ANALYSIS_DIR / "cache_suggestions.json"
out_path.write_text(json.dumps(result, indent=2))
if json_output:
typer.echo(json.dumps(result))
else:
for item in result:
typer.echo(f"{item['host']} {item['path']} {item['misses']}")
@app.command("suggest-cache")
def suggest_cache_cli(
threshold: int = typer.Option(10, help="Minimum number of MISS entries to report"),
json_output: bool = typer.Option(False, "--json", help="Output results as JSON"),
) -> None:
"""CLI wrapper for suggest_cache."""
suggest_cache(threshold=threshold, json_output=json_output)
def detect_threats(
hours: int = 1,
ip_threshold: int = 100,
) -> None:
"""Detect potential security threats from recent logs."""
conn = _connect()
cur = conn.cursor()
cur.execute("SELECT MAX(time) FROM logs")
row = cur.fetchone()
if not row or not row[0]:
typer.echo("No logs found")
conn.close()
return
max_dt = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
recent_end = max_dt
recent_start = recent_end - timedelta(hours=int(hours))
prev_start = recent_start - timedelta(hours=int(hours))
prev_end = recent_start
fmt = "%Y-%m-%d %H:%M:%S"
recent_start_s = recent_start.strftime(fmt)
recent_end_s = recent_end.strftime(fmt)
prev_start_s = prev_start.strftime(fmt)
prev_end_s = prev_end.strftime(fmt)
cur.execute(
"""
SELECT host,
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS errors,
COUNT(*) AS total
FROM logs
WHERE time >= ? AND time < ?
GROUP BY host
""",
(recent_start_s, recent_end_s),
)
recent_rows = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
cur.execute(
"""
SELECT host,
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS errors,
COUNT(*) AS total
FROM logs
WHERE time >= ? AND time < ?
GROUP BY host
""",
(prev_start_s, prev_end_s),
)
prev_rows = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
error_spikes = []
for host in set(recent_rows) | set(prev_rows):
r_err, r_total = recent_rows.get(host, (0, 0))
p_err, p_total = prev_rows.get(host, (0, 0))
r_rate = r_err * 100.0 / r_total if r_total else 0.0
p_rate = p_err * 100.0 / p_total if p_total else 0.0
if r_rate >= 10 and r_rate >= p_rate * 2:
error_spikes.append(
{
"host": host,
"recent_error_rate": round(r_rate, 2),
"previous_error_rate": round(p_rate, 2),
}
)
cur.execute(
"""
SELECT DISTINCT user_agent FROM logs
WHERE time >= ? AND time < ?
""",
(prev_start_s, prev_end_s),
)
prev_agents = {r[0] for r in cur.fetchall()}
cur.execute(
"""
SELECT user_agent, COUNT(*) AS c
FROM logs
WHERE time >= ? AND time < ?
GROUP BY user_agent
HAVING c >= 10
""",
(recent_start_s, recent_end_s),
)
suspicious_agents = [
{"user_agent": ua, "requests": cnt}
for ua, cnt in cur.fetchall()
if ua not in prev_agents
]
cur.execute(
"""
SELECT ip, COUNT(*) AS c
FROM logs
WHERE time >= ? AND time < ?
GROUP BY ip
HAVING c >= ?
ORDER BY c DESC
""",
(recent_start_s, recent_end_s, ip_threshold),
)
high_ip_requests = [{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()]
conn.close()
report = {
"time_range": {
"recent_start": recent_start_s,
"recent_end": recent_end_s,
"previous_start": prev_start_s,
"previous_end": prev_end_s,
},
"error_spikes": error_spikes,
"suspicious_agents": suspicious_agents,
"high_ip_requests": high_ip_requests,
}
ANALYSIS_DIR.mkdir(parents=True, exist_ok=True)
out_path = ANALYSIS_DIR / "threat_report.json"
out_path.write_text(json.dumps(report, indent=2))
typer.echo(json.dumps(report))
@app.command("detect-threats")
def detect_threats_cli(
hours: int = typer.Option(1, help="Number of recent hours to analyze"),
ip_threshold: int = typer.Option(100, help="Requests from a single IP to flag"),
) -> None:
"""CLI wrapper for detect_threats."""
detect_threats(hours=hours, ip_threshold=ip_threshold)
if __name__ == "__main__":
app()

28
scripts/download_icons.py Normal file
View file

@ -0,0 +1,28 @@
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()

View file

@ -1,17 +1,27 @@
import json
import sys
import sqlite3
from pathlib import Path
import shutil
from typing import List, Dict, Optional
from datetime import datetime, timezone
import time
import yaml
import typer
from jinja2 import Environment, FileSystemLoader
# Ensure project root is importable when running as a script (python scripts/generate_reports.py)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
DB_PATH = Path("database/ngxstat.db")
OUTPUT_DIR = Path("output")
TEMPLATE_DIR = Path("templates")
REPORT_CONFIG = Path("reports.yml")
GENERATED_MARKER = OUTPUT_DIR / "generated.txt"
# Mapping of interval names to SQLite strftime formats. These strings are
# substituted into report queries whenever the special ``{bucket}`` token is
@ -27,6 +37,19 @@ INTERVAL_FORMATS = {
app = typer.Typer(help="Generate aggregated log reports")
@app.callback()
def _cli_callback(ctx: typer.Context) -> None:
"""Register post-command hook to note generation time."""
def _write_marker() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Use timezone-aware UTC to avoid deprecation warnings and ambiguity
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
GENERATED_MARKER.write_text(f"{timestamp}\n")
ctx.call_on_close(_write_marker)
def _get_domains() -> List[str]:
"""Return a sorted list of unique domains from the logs table."""
conn = sqlite3.connect(DB_PATH)
@ -54,10 +77,61 @@ def _save_json(path: Path, data: List[Dict]) -> None:
path.write_text(json.dumps(data, indent=2))
def _render_html(interval: str, reports: List[Dict], out_path: Path) -> None:
def _copy_icons() -> None:
"""Copy vendored icons and scripts to the output directory."""
src_dir = Path("static/icons")
dst_dir = OUTPUT_DIR / "icons"
if src_dir.is_dir():
dst_dir.mkdir(parents=True, exist_ok=True)
for icon in src_dir.glob("*.svg"):
shutil.copy(icon, dst_dir / icon.name)
js_src = Path("static/chartManager.js")
if js_src.is_file():
shutil.copy(js_src, OUTPUT_DIR / js_src.name)
def _render_snippet(report: Dict, out_dir: Path) -> None:
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
template = env.get_template("report.html")
out_path.write_text(template.render(interval=interval, reports=reports))
template = env.get_template("report_snippet.html")
snippet_path = out_dir / f"{report['name']}.html"
snippet_path.write_text(template.render(report=report))
def _write_stats(
generated_at: Optional[str] = None, generation_seconds: Optional[float] = None
) -> None:
"""Query basic dataset stats and write them to ``output/global/stats.json``."""
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM logs")
total_logs = cur.fetchone()[0] or 0
cur.execute("SELECT MIN(time), MAX(time) FROM logs")
row = cur.fetchone() or (None, None)
start_date = row[0] or ""
end_date = row[1] or ""
cur.execute("SELECT COUNT(DISTINCT host) FROM logs")
unique_domains = cur.fetchone()[0] or 0
conn.close()
stats = {
"total_logs": total_logs,
"start_date": start_date,
"end_date": end_date,
"unique_domains": unique_domains,
}
if generated_at:
stats["generated_at"] = generated_at
if generation_seconds is not None:
stats["generation_seconds"] = generation_seconds
out_path = OUTPUT_DIR / "global" / "stats.json"
_save_json(out_path, stats)
def _bucket_expr(interval: str) -> str:
@ -75,6 +149,8 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
typer.echo("No report definitions found")
return
_copy_icons()
bucket = _bucket_expr(interval)
conn = sqlite3.connect(DB_PATH)
@ -99,9 +175,26 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
report_list = []
for definition in cfg:
if "{bucket}" not in definition["query"] or definition.get("global"):
# Global reports are generated separately
continue
if domain and not definition.get("per_domain", True):
# Skip reports marked as not applicable to per-domain runs
continue
name = definition["name"]
query = definition["query"].replace("{bucket}", bucket)
query = query.replace("FROM logs", "FROM logs_view")
# Apply top_n limit for tables (performance-friendly), if configured
top_n = definition.get("top_n")
chart_type = definition.get("chart", "line")
if top_n and chart_type == "table":
try:
n = int(top_n)
if "LIMIT" not in query.upper():
query = f"{query}\nLIMIT {n}"
except Exception:
pass
cur.execute(query)
rows = cur.fetchall()
headers = [c[0] for c in cur.description]
@ -113,16 +206,40 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
"label": definition.get("label", name.title()),
"chart": definition.get("chart", "line"),
"json": f"{name}.json",
"html": f"{name}.html",
}
if "icon" in definition:
entry["icon"] = definition["icon"]
if "bucket" in definition:
entry["bucket"] = definition["bucket"]
if "buckets" in definition:
entry["buckets"] = definition["buckets"]
if "bucket_label" in definition:
entry["bucket_label"] = definition["bucket_label"]
if "color" in definition:
entry["color"] = definition["color"]
if "colors" in definition:
entry["colors"] = definition["colors"]
# Optional UX metadata passthrough for frontend-only transforms
for key in (
"windows_supported",
"window_default",
"group_others_threshold",
"exclude_values",
"top_n",
"stacked",
"palette",
):
if key in definition:
entry[key] = definition[key]
_render_snippet(entry, out_dir)
report_list.append(entry)
_save_json(out_dir / "reports.json", report_list)
_render_html(interval, report_list, out_dir / "index.html")
typer.echo(f"Generated {interval} reports")
if domain:
typer.echo(f"Generated {interval} reports for {domain}")
else:
typer.echo(f"Generated {interval} reports")
def _generate_all_domains(interval: str) -> None:
@ -133,8 +250,10 @@ def _generate_all_domains(interval: str) -> None:
def _generate_root_index() -> None:
"""Render the top-level index listing all intervals and domains."""
intervals = [p.name for p in OUTPUT_DIR.iterdir() if p.is_dir() and p.name != "domains"]
intervals.sort()
_copy_icons()
intervals = sorted(
[name for name in INTERVAL_FORMATS if (OUTPUT_DIR / name).is_dir()]
)
domains_dir = OUTPUT_DIR / "domains"
domains: List[str] = []
@ -151,6 +270,116 @@ def _generate_root_index() -> None:
typer.echo(f"Generated root index at {out_path}")
def _generate_global() -> None:
"""Generate reports that do not depend on an interval."""
cfg = _load_config()
if not cfg:
typer.echo("No report definitions found")
return
start_time = time.time()
# Use timezone-aware UTC for generated_at (string remains unchanged format)
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
_copy_icons()
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
out_dir = OUTPUT_DIR / "global"
out_dir.mkdir(parents=True, exist_ok=True)
report_list = []
for definition in cfg:
if "{bucket}" in definition["query"] and not definition.get("global"):
continue
name = definition["name"]
query = definition["query"]
# Apply top_n limit for tables (performance-friendly), if configured
top_n = definition.get("top_n")
chart_type = definition.get("chart", "line")
if top_n and chart_type == "table":
try:
n = int(top_n)
if "LIMIT" not in query.upper():
query = f"{query}\nLIMIT {n}"
except Exception:
pass
cur.execute(query)
rows = cur.fetchall()
headers = [c[0] for c in cur.description]
data = [dict(zip(headers, row)) for row in rows]
json_path = out_dir / f"{name}.json"
_save_json(json_path, data)
entry = {
"name": name,
"label": definition.get("label", name.title()),
"chart": definition.get("chart", "line"),
"json": f"{name}.json",
"html": f"{name}.html",
}
if "icon" in definition:
entry["icon"] = definition["icon"]
if "bucket" in definition:
entry["bucket"] = definition["bucket"]
if "buckets" in definition:
entry["buckets"] = definition["buckets"]
if "bucket_label" in definition:
entry["bucket_label"] = definition["bucket_label"]
if "color" in definition:
entry["color"] = definition["color"]
if "colors" in definition:
entry["colors"] = definition["colors"]
# Optional UX metadata passthrough for frontend-only transforms
for key in (
"windows_supported",
"window_default",
"group_others_threshold",
"exclude_values",
"top_n",
"stacked",
"palette",
):
if key in definition:
entry[key] = definition[key]
_render_snippet(entry, out_dir)
report_list.append(entry)
_save_json(out_dir / "reports.json", report_list)
elapsed = round(time.time() - start_time, 2)
_write_stats(generated_at, elapsed)
typer.echo("Generated global reports")
def _generate_analysis() -> None:
"""Generate analysis JSON files consumed by the Analysis tab."""
try:
# Import lazily to avoid circulars and keep dependencies optional
from scripts import analyze
except Exception as exc: # pragma: no cover - defensive
typer.echo(f"Failed to import analysis module: {exc}")
return
# Ensure output root and icons present for parity
_copy_icons()
# These commands write JSON files under output/analysis/
try:
analyze.check_missing_domains(json_output=True)
except Exception as exc: # pragma: no cover - continue best-effort
typer.echo(f"check_missing_domains failed: {exc}")
try:
analyze.suggest_cache(json_output=True)
except Exception as exc: # pragma: no cover
typer.echo(f"suggest_cache failed: {exc}")
try:
analyze.detect_threats()
except Exception as exc: # pragma: no cover
typer.echo(f"detect_threats failed: {exc}")
typer.echo("Generated analysis JSON files")
@app.command()
def hourly(
domain: Optional[str] = typer.Option(
@ -215,6 +444,18 @@ def monthly(
_generate_interval("monthly", domain)
@app.command("global")
def global_reports() -> None:
"""Generate global reports."""
_generate_global()
@app.command()
def analysis() -> None:
"""Generate analysis JSON files for the Analysis tab."""
_generate_analysis()
@app.command()
def index() -> None:
"""Generate the root index page linking all reports."""

View file

@ -61,7 +61,9 @@ try:
suffix = match.group(1)
number = int(suffix.lstrip(".")) if suffix else 0
log_files.append((number, os.path.join(LOG_DIR, f)))
log_files = [path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)]
log_files = [
path for _, path in sorted(log_files, key=lambda x: x[0], reverse=True)
]
except FileNotFoundError:
print(f"[ERROR] Log directory not found: {LOG_DIR}")
exit(1)

95
scripts/nginx_config.py Normal file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Utilities for discovering and parsing Nginx configuration files.
This module provides helper functions to locate Nginx configuration files and
extract key details from ``server`` blocks. Typical usage::
from scripts.nginx_config import discover_configs, parse_servers
files = discover_configs()
servers = parse_servers(files)
for s in servers:
print(s.get("server_name"), s.get("listen"))
The functions intentionally tolerate missing or unreadable files and will simply
skip over them.
"""
import os
import re
from pathlib import Path
from typing import Dict, List, Set
DEFAULT_PATHS = [
"/etc/nginx/nginx.conf",
"/usr/local/etc/nginx/nginx.conf",
]
INCLUDE_RE = re.compile(r"^\s*include\s+(.*?);", re.MULTILINE)
SERVER_RE = re.compile(r"server\s*{(.*?)}", re.DOTALL)
DIRECTIVE_RE = re.compile(r"^\s*(\S+)\s+(.*?);", re.MULTILINE)
def discover_configs() -> Set[Path]:
"""Return a set of all config files reachable from :data:`DEFAULT_PATHS`."""
found: Set[Path] = set()
queue = [Path(p) for p in DEFAULT_PATHS]
while queue:
path = queue.pop()
if path in found:
continue
if not path.exists():
continue
try:
text = path.read_text()
except OSError:
continue
found.add(path)
for pattern in INCLUDE_RE.findall(text):
pattern = os.path.expanduser(pattern.strip())
if os.path.isabs(pattern):
# ``Path.glob`` does not allow absolute patterns, so we
# anchor at the filesystem root and remove the leading
# separator.
base = Path(os.sep)
glob_iter = base.glob(pattern.lstrip(os.sep))
else:
glob_iter = path.parent.glob(pattern)
for included in glob_iter:
if included.is_file() and included not in found:
queue.append(included)
return found
def parse_servers(paths: Set[Path]) -> List[Dict[str, str]]:
"""Parse ``server`` blocks from the given files.
Parameters
----------
paths:
Iterable of configuration file paths.
"""
servers: List[Dict[str, str]] = []
for p in paths:
try:
text = Path(p).read_text()
except OSError:
continue
for block in SERVER_RE.findall(text):
directives: Dict[str, List[str]] = {}
for name, value in DIRECTIVE_RE.findall(block):
directives.setdefault(name, []).append(value.strip())
entry: Dict[str, str] = {}
if "server_name" in directives:
entry["server_name"] = " ".join(directives["server_name"])
if "listen" in directives:
entry["listen"] = " ".join(directives["listen"])
if "proxy_cache" in directives:
entry["proxy_cache"] = " ".join(directives["proxy_cache"])
if "root" in directives:
entry["root"] = " ".join(directives["root"])
servers.append(entry)
return servers

46
setup.sh Executable file
View file

@ -0,0 +1,46 @@
#!/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"

109
static/chartManager.js Normal file
View file

@ -0,0 +1,109 @@
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;
}

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 264 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 794 B

3
static/icons/archive.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 343 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 304 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 303 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 269 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 271 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 282 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 271 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 282 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 285 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 301 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 255 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 301 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 255 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 263 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 271 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 259 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 369 B

3
static/icons/average.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 282 B

4
static/icons/bag-2.svg Normal file
View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 414 B

3
static/icons/bag-4.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 369 B

11
static/icons/bag-lock.svg Normal file
View file

@ -0,0 +1,11 @@
<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>

After

Width:  |  Height:  |  Size: 877 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 363 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 399 B

3
static/icons/bag.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 351 B

3
static/icons/bolt.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 304 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 327 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 263 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 400 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 467 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 348 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 417 B

3
static/icons/check.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 252 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 347 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 347 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 348 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 285 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 282 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 311 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 357 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 451 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 317 B

3
static/icons/circle.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 307 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 473 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 481 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 500 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 632 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 509 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 605 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 495 B

4
static/icons/clock-2.svg Normal file
View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 344 B

4
static/icons/clock.svg Normal file
View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 337 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 405 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 426 B

3
static/icons/cloud.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 370 B

3
static/icons/code-2.svg Normal file
View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 260 B

Some files were not shown because too many files have changed in this diff Show more