- Dockerfile (web) + Dockerfile.scan (scan; sertakan tzdata) + .dockerignore
- scan_loop.py: jadwal skan SCAN_HOURS (0,8,16 lokal via TZ) + skan-awal SCAN_RUN_ON_START; loop tak keluar; tunggu DB siap
- docker-compose.yml → STACK SWARM produksi (mariadb bind mount /data/db/mariadb/databisnisid/data + placement node.hostname == server5.saltis.id; web :5000; scan); image dari registry git.proit.id/proitlab/databisnisid-{web,scan}
- docker-compose.dev.yml: mariadb uji lokal (127.0.0.1:3306) — pengganti docker-compose.yml lama
- SPEC.md §V18/T18; AGENTS.md runbook swarm + dev compose
- .env.example: TZ/SCAN_HOURS/SCAN_RUN_ON_START/MARIADB_*
9.7 KiB
9.7 KiB
AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (databisnisid): a Node.js reference script and the Python production tool that persists results to SQLite.
Run
- Node reference script:
node get_voters.js(no deps, Node 18+). - Python tool (production):
./venv/bin/python get_voters.py. venv is Python 3.12, depsrequests+flask+python-dotenv+gunicorn+pymysql(mysql backend only) (requirements.txt). Install with./venv/bin/pip install -r requirements.txt. - Storage backend:
db.pyabstracts it. Defaultsqlite(VEX_DB_PATH, stdlibsqlite3, WAL). Optionalmysql(VEX_DB_BACKEND=mysql+VEX_DB_HOST/PORT/USER/PASS/NAME, PyMySQL). Oracle tests stay on sqlite;test_mariadb.pyis opt-in (skips unlessVEX_DB_BACKEND=mysql). Query SQL is written once with%splaceholders (translated to?for sqlite);db.queryalways returns a list. - Test MariaDB/MySQL via docker:
docker compose -f docker-compose.dev.yml up -d(mariadb:11 containerdatabisnisid-mariadb, localhost-only127.0.0.1:3306, db/user/passdatabisnisid/databisnis/databisnis, named volume, healthcheck). Stop/remove withdocker compose -f docker-compose.dev.yml down; wipe data withdocker compose -f docker-compose.dev.yml down -v. Verify withdocker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'. Smoke against the container:VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py - Docker Swarm (production stack): images are registry-pushed
git.proit.id/proitlab/databisnisid-web+databisnisid-scan— build & push them first (docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web, same for the scan image), then from a swarm manager rundocker stack deploy -c docker-compose.yml databisnisid. Stack = mariadb (internal) + web (gunicorn dashboard, published:5000) + scan (scan_loop.py, runsget_voters.pyatSCAN_HOURSdefault0,8,16, local timezoneTZdefaultAsia/Jakarta, plus one scan at container start viaSCAN_RUN_ON_START).mariadbis pinned byplacement.constraints: node.hostname == server5.saltis.idbecause its data lives in the host bind mount/data/db/mariadb/databisnisid/dataon that node;web/scancan run on any node and reach it over the overlay networkappnet. Inspect:docker stack services databisnisid,docker service logs databisnisid_scan,docker stack rm databisnisid.docker stack deployignoresbuild:(images must already be in the registry) and ignoresenv_file(env is inlined with${VAR}interpolation from.env). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task Pending since no node is namedserver5.saltis.id. - Web dashboard (read-only, reads the store via
db.py): production./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app→ http://127.0.0.1:5000/ (run from repo dir). Easier:./run.sh(same command, works from any cwd,$@passed through).gunicorn.conf.pyimportsconfig→.envhonored;DASH_WORKERS(default 2) controls workers,DASH_HOST/DASH_PORTthe bind. Dev server (single-process) still works via./venv/bin/python dashboard.py. Paged 50/page, sortedstaked DESC, live owner search (/api/search). Data freshness comes from the daily scan run — the dashboard never scans. - Config: all tunables load from env /
.envviaconfig.py(python-dotenv):VEX_TARGET_BP,VEX_API_NODE,VEX_DB_PATH,VEX_DB_BACKEND,VEX_DB_HOST,VEX_DB_PORT,VEX_DB_USER,VEX_DB_PASS,VEX_DB_NAME,VEX_MIN_STAKED_VEX,DASH_PAGE_SIZE,DASH_HOST,DASH_PORT,DASH_WORKERS,VEX_STALE_DAYS,DATABISNIS_API,DASH_LIQUID_TTL. Copy.env.example→.envto override;.envis gitignored. Chain constants (vexcore/scope/table) stay hardcoded. - Syntax check:
node --check get_voters.js,./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py test_get_voters.py test_dashboard.py test_mariadb.py. - Tests (the verification oracles):
./venv/bin/python test_get_voters.pyand./venv/bin/python test_dashboard.pymust both exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
Key facts
TARGET_BP(databisnisid) andAPI_NODE(https://v2.vexascan.com:2096) are the defaults inconfig.py(env-overridable); the JS reference still hardcodes them at the top of the file.- The public Vexanium RPC node is flaky/timeout-prone; both scripts have retry logic built in — don't remove or bypass it. A full scan of the voters table takes minutes.
- The system contract on Vexanium is
vexcore(notvexio), scope is alsovexcore. Do not "fix" this to match EOS convention. - VEX stake is stored scaled by 10000; the Python tool divides by 10000 before storing.
- The node sometimes returns
stakedas a string instead of a number — normalize coerces to float (spec §V8). - The voters table has a sentinel first row (
owner="...........q", uint64 0) that must be skipped. - Authoritative vote weight is
last_vote_weight(high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6). - Freshness filter (spec §V13):
last_vote(estimated last re-vote date) is derived at scan time from the Vexanium weight formulalast_vote_weight = staked_raw × 2^(years since 2000)→log2(weight / (staked×10000)). Voters whoselast_voteis older thanVEX_STALE_DAYS(default 28) — or can't be derived (zero/empty weight) — are dropped innormalize()and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), sonormalize()clamps anylast_votepast scan time down tonow. - The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and
/api/searchJSON but is not rendered as a column. - Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (
account.core_liquid_balance) fetched fromGET {DATABISNIS_API}/v2/state/get_account?account=<BP>. The dashboard fetches it live but caches per-worker in memory forDASH_LIQUID_TTL(default 60s); a failed fetch keeps the last value (or renders—if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
Layout
SPEC.md— spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.get_voters.py— production fetcher: scan → filter (stake + freshness, basi dibuang) →db.replace_snapshot(each run replaces the table = daily snapshot, withscanned_at+ derivedlast_vote; never appends history).db.py— storage abstraction (sqlite default | mysql via PyMySQL);connect/query/queryone/replace_snapshot;%s→?for sqlite;queryreturns list.get_voters.js— reference implementation only.dashboard.py+templates/index.html+static/style.css+static/app.js— Flask web dashboard; reads the store viadb.py, styled perDESIGN.md;app.js= debounced live owner search (fetch/api/search), degrades to the server-side?q=GET form if JS is off.gunicorn.conf.py— gunicorn production config (bind/workers fromconfig, sync worker).run.sh— launcher:./run.sh=./venv/bin/gunicorn -c gunicorn.conf.py dashboard:appfrom any cwd.config.py— loads env/.env(python-dotenv) →TARGET_BP,API_NODE,DB_PATH,DB_BACKEND,DB_HOST,DB_PORT,DB_USER,DB_PASS,DB_NAME,MIN_STAKED_VEX,PAGE_SIZE,DASH_HOST,DASH_PORT,DASH_WORKERS,VEX_STALE_DAYS,DATABISNIS_API,DASH_LIQUID_TTL; shared by get_voters & dashboard.scan_loop.py— scheduler dalam container stack: menunggu batasSCAN_HOURS(lokal viaTZ), panggilget_voters.main(); skan-awalSCAN_RUN_ON_START+ tunggu DB siap; loop tak pernah keluar.Dockerfile— image webdatabisnisid-web(gunicorn dashboard;DASH_HOST=0.0.0.0di stack agar ingress menjangkaunya).Dockerfile.scan— imagedatabisnisid-scan(scan_loop; sertakantzdata). Di stack produksi keduanya di-push ke registrygit.proit.id/proitlab/databisnisid-{web,scan}(⊥build:di compose)..dockerignore— venv/.env/artifak tak masuk build context.docker-compose.yml— STACK SWARM PRODUKSI (mariadb internal + web:5000+ scan); image dari registrygit.proit.id/proitlab/databisnisid-*; mariadb bind mount/data/db/mariadb/databisnisid/data+ placementnode.hostname == server5.saltis.id; network overlayappnet.docker-compose.dev.yml— mariadb uji lokal (127.0.0.1:3306, volumemariadb_data).DESIGN.md— Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).voters.db— SQLite output (daily snapshot, gitignored in spirit).
Conventions
- Comments and console output are in Indonesian — keep new output/comments in Indonesian.
- Dashboard UI labels are Indonesian uppercase captions (e.g. "DAFTAR PEMILIH", "TOTAL PEMILIH").
- JS style: 2-space indent, semicolons, single quotes, trailing commas, async/await.
- Python style: 4-space indent, stdlib
sqlite3,requests,flask, PEP8. - New SQL shared across backends:
%splaceholders (never?),ESCAPE '!'for LIKE (backslash breaks MySQL string literals), no MySQL-only syntax.