Compare commits

..

44 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
228 changed files with 2182 additions and 278 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

148
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 snippet 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,118 +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 writes one HTML snippet per report. These snippets are loaded dynamically by the main dashboard using Chart.js and DataTables.
### Configuring Reports
Report queries are defined in `reports.yml`. Each entry specifies the `name`,
optional `label` and `chart` type, and a SQL `query` that must return `bucket`
and `value` columns. The special token `{bucket}` is replaced with the
appropriate SQLite `strftime` expression for each interval (hourly, daily,
weekly or monthly) so that a single definition works across all durations.
When `generate_reports.py` runs, every definition is executed for the requested
interval and creates `output/<interval>/<name>.json` plus a small HTML snippet
`output/<interval>/<name>.html` used by the dashboard.
Example snippet:
```yaml
- name: hits
chart: bar
query: |
SELECT {bucket} AS bucket,
COUNT(*) AS value
FROM logs
GROUP BY bucket
ORDER BY bucket
```
Add or modify entries in `reports.yml` to tailor the generated metrics.
## Importing Logs
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. After generation, open `output/index.html` in your browser to browse the reports.
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.
If you prefer to run individual commands you can invoke the generator directly:
## Log Analysis
```bash
python scripts/generate_reports.py hourly
python scripts/generate_reports.py daily --all-domains
```
The `run-analysis.sh` script runs helper routines that inspect the database. It
creates or reuses the virtual environment and then executes a set of analysis
commands to spot missing domains, suggest cache rules and detect potential
threats.
## 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
```
The JSON results are written under `output/analysis` and can be viewed from the
"Analysis" tab in the generated dashboard.
## Serving Reports with Nginx
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.
## 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,78 +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

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..."

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,21 +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 scripts/generate_reports.py global
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

View file

@ -18,7 +18,7 @@ from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Dict, List, Optional, Set
from typing import List, Optional, Set
from datetime import datetime, timedelta
import json
@ -105,7 +105,9 @@ def hits(domain: Optional[str] = typer.Option(None, help="Filter by domain")) ->
@app.command("cache-ratio")
def cache_ratio_cmd(domain: Optional[str] = typer.Option(None, help="Filter by domain")) -> None:
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:
@ -115,7 +117,11 @@ def cache_ratio_cmd(domain: Optional[str] = typer.Option(None, help="Filter by d
@app.command("check-missing-domains")
def check_missing_domains(json_output: bool = typer.Option(False, "--json", help="Output missing domains as JSON")) -> None:
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
@ -149,12 +155,9 @@ def check_missing_domains(json_output: bool = typer.Option(False, "--json", help
typer.echo(d)
@app.command("suggest-cache")
def suggest_cache(
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"),
threshold: int = 10,
json_output: bool = False,
) -> None:
"""Suggest domain/path pairs that could benefit from caching.
@ -187,7 +190,7 @@ def suggest_cache(
HAVING miss_count >= ?
ORDER BY miss_count DESC
""",
(threshold,),
(int(threshold),),
)
rows = [r for r in cur.fetchall() if r[0] in no_cache]
@ -207,13 +210,18 @@ def suggest_cache(
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)
@app.command("detect-threats")
def detect_threats(
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"
),
hours: int = 1,
ip_threshold: int = 100,
) -> None:
"""Detect potential security threats from recent logs."""
@ -229,8 +237,8 @@ def detect_threats(
max_dt = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
recent_end = max_dt
recent_start = recent_end - timedelta(hours=hours)
prev_start = recent_start - timedelta(hours=hours)
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"
@ -316,9 +324,7 @@ def detect_threats(
""",
(recent_start_s, recent_end_s, ip_threshold),
)
high_ip_requests = [
{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()
]
high_ip_requests = [{"ip": ip, "requests": cnt} for ip, cnt in cur.fetchall()]
conn.close()
@ -339,6 +345,14 @@ def detect_threats(
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,6 +77,20 @@ def _save_json(path: Path, data: List[Dict]) -> None:
path.write_text(json.dumps(data, indent=2))
def _copy_icons() -> None:
"""Copy vendored icons and scripts to the output directory."""
src_dir = Path("static/icons")
dst_dir = OUTPUT_DIR / "icons"
if src_dir.is_dir():
dst_dir.mkdir(parents=True, exist_ok=True)
for icon in src_dir.glob("*.svg"):
shutil.copy(icon, dst_dir / icon.name)
js_src = Path("static/chartManager.js")
if js_src.is_file():
shutil.copy(js_src, OUTPUT_DIR / js_src.name)
def _render_snippet(report: Dict, out_dir: Path) -> None:
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
@ -62,7 +99,9 @@ def _render_snippet(report: Dict, out_dir: Path) -> None:
snippet_path.write_text(template.render(report=report))
def _write_stats() -> None:
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()
@ -86,6 +125,10 @@ def _write_stats() -> None:
"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)
@ -106,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)
@ -140,6 +185,16 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
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]
@ -153,15 +208,38 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
"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)
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:
@ -172,12 +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.lower() not in {"domains", "global"}
]
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] = []
@ -201,6 +277,12 @@ def _generate_global() -> None:
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()
@ -214,6 +296,16 @@ def _generate_global() -> None:
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]
@ -227,18 +319,67 @@ def _generate_global() -> None:
"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)
_write_stats()
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(
@ -309,6 +450,12 @@ def global_reports() -> None:
_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)

View file

@ -49,7 +49,15 @@ def discover_configs() -> Set[Path]:
found.add(path)
for pattern in INCLUDE_RE.findall(text):
pattern = os.path.expanduser(pattern.strip())
for included in path.parent.glob(pattern):
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
@ -85,4 +93,3 @@ def parse_servers(paths: Set[Path]) -> List[Dict[str, str]]:
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

3
static/icons/code.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="m10 20.5 5-17M6 17l-4-4.5L6 8m12 0 4 4.5-4 4.5"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

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