T9-T12: env config + live owner search + responsive (§V11, I.env, I.web)

- config.py: env/.env (python-dotenv), defaults = konstanta lama; get_voters & dashboard pakai config
- dashboard: ?q= server filter + /api/search JSON (substring, LIKE-escape, rank global via correlated count)
- static/app.js: debounced live search; degrades to GET ?q=
- static/style.css: input search + responsive (1024/768/480), list→kartu berlabel di mobile
- test_dashboard: search/API/rank-global/config tests; .env.example; .env gitignored
This commit is contained in:
proitlab committed 2026-08-04 16:23:26 +07:00
1 parent 0b3a86aad2
commit 205ad35c70
12 files changed
+440 -65

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Salin ke `.env` lalu sesuaikan. Semua opsional — default di `config.py`.
# Nilai dari env shell (export) juga didahulukan lewat os.getenv.
# Akun Block Producer yang dicari
VEX_TARGET_BP=databisnisid
# Node RPC publik Vexanium (ganti bila node utama lambat/timeout)
VEX_API_NODE=https://v2.vexascan.com:2096
# Lokasi file SQLite (disarankan absolut bila dijadwalkan via cron)
VEX_DB_PATH=voters.db
# Syarat minimum stake (VEX) agar pemilih disimpan
VEX_MIN_STAKED_VEX=1000
# Dashboard: jumlah baris per halaman
DASH_PAGE_SIZE=50
# Dashboard: host & port server
DASH_HOST=127.0.0.1
DASH_PORT=5000
+3
View File
@@ -4,5 +4,8 @@ scan.log
__pycache__/
*.pyc
# local env config (secrets/overrides — jangan di-commit)
.env
# virtual env (deps installable via requirements.txt)
venv/
+7 -5
View File
@@ -5,14 +5,15 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
## 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, deps `requests` + `flask` (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Web dashboard (read-only, reads `voters.db`): `./venv/bin/python dashboard.py` → http://127.0.0.1:5000/. Paged 50/page, sorted `staked DESC`. Data freshness comes from the daily scan run — the dashboard never scans.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile get_voters.py dashboard.py`.
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Web dashboard (read-only, reads `voters.db`): `./venv/bin/python dashboard.py` → http://127.0.0.1:5000/. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`. Copy `.env.example` → `.env` to override; `.env` is 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`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py` and `./venv/bin/python test_dashboard.py` must 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`) and `API_NODE` (`https://v2.vexascan.com:2096`) are hardcoded constants at the top of both scripts.
- `TARGET_BP` (`databisnisid`) and `API_NODE` (`https://v2.vexascan.com:2096`) are the defaults in `config.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` (not `vexio`), scope is also `vexcore`. Do not "fix" this to match EOS convention.
- VEX stake is stored scaled by 10000; the Python tool divides by 10000 before storing.
@@ -25,7 +26,8 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `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 → SQLite `voters.db` (each run replaces the table = daily snapshot, with `scanned_at`; never appends history).
- `get_voters.js` — reference implementation only.
- `dashboard.py` + `templates/index.html` + `static/style.css` — Flask web dashboard; reads `voters.db`, styled per `DESIGN.md`.
- `dashboard.py` + `templates/index.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads `voters.db`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`; shared by get_voters & dashboard.
- `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).
+15 -6
View File
@@ -6,14 +6,15 @@ Re-runnable scheduled. Python 3.12, venv `./venv`.
## §C — Constraints
- venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global
- lib: `requests` ! pip-installed; others stdlib
- db: SQLite stdlib `sqlite3` → file `voters.db`
- api: `https://v2.vexascan.com:2096` (public, flaky → retry)
- contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥)
- filter: ∃ voter where `producers.length==1 & producers[0]=='databisnisid'`
- lib: `requests`, `flask`, `python-dotenv` ! pip-installed; others stdlib
- config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama
- db: SQLite stdlib `sqlite3` → `DB_PATH` (default `voters.db`)
- api: `API_NODE` (default `https://v2.vexascan.com:2096`, public, flaky → retry)
- contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able
- filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP`
- `staked` scaled ×10000 → store ÷10000
- weight = `last_vote_weight` (string, high-precision decimal) — keep verbatim
- staked ≥ 1000 VEX ! required (÷10000 dulu)
- staked ≥ `MIN_STAKED_VEX` (default 1000 VEX) ! required (÷10000 dulu)
- output/comments: Indonesian
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
@@ -24,6 +25,9 @@ api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows`
db: `voters.db` → table `voters` (owner PK, weight, staked, scanned_at) — ganti tiap skan
cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID)
web: GET `/` (Flask) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only)
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank}]}`, rank global, cap 500
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT` — via `config.py` (`.env`)
## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -36,6 +40,7 @@ V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate
V8: normalize coerces `staked` → float (node kadang memberi string)
V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only
## §T — Tasks
id|status|task|cites
@@ -47,6 +52,10 @@ T5|x|CLI entry + Indonesian summary stdout|I.cmd
T6|x|dashboard: Flask read-only, paged spec-list sort staked DESC|V10,I.web,I.db
T7|x|dashboard: styling per DESIGN.md (canvas hitam, typografi, hairline)|I.web
T8|x|dashboard: test oracle + smoke|V10,I.web
T9|x|env config: config.py + .env/.env.example + dotenv; get_voters & dashboard pakai config|I.env
T10|x|search: ?q= server filter + /api/search JSON (substring, escape, rank global)|V11,I.web
T11|x|live JS filter (app.js) + responsive (DESIGN.md breakpoints, list→kartu mobile)|V11,I.web
T12|x|dashboard oracle + search/API/config tests|V11,V10
## §B — Bug log
id|date|cause|fix
+30
View File
@@ -0,0 +1,30 @@
"""Konfigurasi proyek dari env / `.env`.
Bacaan tipe via `os.getenv`; nilai default = konstanta lama. Muat `.env` sekali
di sini supaya semua modul (get_voters, dashboard) berbagi sumber yang sama.
"""
import os
from dotenv import load_dotenv
load_dotenv()
# BP target
TARGET_BP = os.getenv('VEX_TARGET_BP', 'databisnisid')
# Node RPC publik Vexanium
API_NODE = os.getenv('VEX_API_NODE', 'https://v2.vexascan.com:2096')
# Lokasi basis data SQLite (dipakai fetcher & dashboard)
DB_PATH = os.getenv('VEX_DB_PATH', 'voters.db')
# Syarat minimum stake (dalam VEX) untuk disimpan
MIN_STAKED_VEX = float(os.getenv('VEX_MIN_STAKED_VEX', '1000'))
# Dashboard: ukuran halaman list
PAGE_SIZE = int(os.getenv('DASH_PAGE_SIZE', '50'))
# Dashboard: host & port server Flask
DASH_HOST = os.getenv('DASH_HOST', '127.0.0.1')
DASH_PORT = int(os.getenv('DASH_PORT', '5000'))
+86 -17
View File
@@ -1,43 +1,79 @@
"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list.
Read-only (V10): tidak pernah menulis ke DB. Data disegarkan oleh `get_voters.py`
(run harian / cron). Styling mengikuti `DESIGN.md`.
Read-only (V10/V11): tidak pernah menulis ke DB. Data disegarkan oleh `get_voters.py`
(run harian / cron). Styling mengikuti `DESIGN.md`. Konfigurasi dari env/`.env`.
"""
import sqlite3
from flask import Flask, abort, render_template, request
from flask import Flask, abort, jsonify, render_template, request
from get_voters import DB_PATH, TARGET_BP
from config import DASH_HOST, DASH_PORT, DB_PATH, PAGE_SIZE, TARGET_BP
PAGE_SIZE = 50
SEARCH_CAP = 500
MAX_Q_LEN = 64
app = Flask(__name__)
@app.template_filter('vex')
def vex(value):
"""Format jumlah VEX ala id-ID (koma ribuan, titik desimal), tanpa emoji."""
"""Format jumlah VEX ala id-ID (koma ribuan, titik desimal)."""
return f'{value:,.4f}'.replace(',', '\u00a0').replace('.', ',').replace('\u00a0', '.')
def _db_rows(page):
"""V10: baca satu halaman, urut staked DESC lalu owner ASC."""
def _escape_like(q):
"""Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola."""
return (q.replace('\\', '\\\\')
.replace('%', '\\%')
.replace('_', '\\_'))
def _filter_sql(q):
"""V11: klausa WHERE + params untuk filter owner substring (case-insensitive)."""
if not q:
return '', []
return "WHERE owner LIKE ? ESCAPE '\\'", [f'%{_escape_like(q)}%']
def _db_rows(page, q):
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global.
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global.
"""
offset = (page - 1) * PAGE_SIZE
where, params = _filter_sql(q)
sql = (
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn '
'FROM voters v ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT ? OFFSET ?'
)
conn = sqlite3.connect(DB_PATH)
try:
return conn.execute(sql, params + [PAGE_SIZE, offset]).fetchall()
finally:
conn.close()
def _count(q):
"""V11: banyak baris cocok filter (atau total bila tanpa filter)."""
where, params = _filter_sql(q)
conn = sqlite3.connect(DB_PATH)
try:
return conn.execute(
'SELECT owner, staked, weight, scanned_at FROM voters '
'ORDER BY staked DESC, owner ASC '
'LIMIT ? OFFSET ?',
(PAGE_SIZE, offset),
).fetchall()
'SELECT COUNT(*) FROM voters ' + where, params
).fetchone()[0]
finally:
conn.close()
def _stats():
"""Ringkasan agregat untuk band spec."""
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search)."""
conn = sqlite3.connect(DB_PATH)
try:
total_voters, total_staked, top_staked = conn.execute(
@@ -58,10 +94,12 @@ def index():
page = max(1, int(request.args.get('page', 1)))
except ValueError:
abort(400)
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
total_voters, total_staked, top_staked, scanned = _stats()
total_pages = max(1, (total_voters + PAGE_SIZE - 1) // PAGE_SIZE)
filtered_count = _count(q)
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages)
rows = _db_rows(page)
rows = _db_rows(page, q)
return render_template(
'index.html',
@@ -73,8 +111,39 @@ def index():
total_staked=total_staked,
top_staked=top_staked,
scanned=scanned,
q=q,
filtered_count=filtered_count,
)
@app.get('/api/search')
def api_search():
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global."""
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
where, params = _filter_sql(q)
sql = (
'SELECT v.owner, v.staked, v.weight, '
' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn '
'FROM voters v ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT ?'
)
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(sql, params + [SEARCH_CAP]).fetchall()
finally:
conn.close()
return jsonify({
'query': q,
'count': len(rows),
'cap': SEARCH_CAP,
'results': [
{'owner': r[0], 'staked': r[1], 'weight': r[2], 'rank': r[3]}
for r in rows
],
})
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000, debug=False)
app.run(host=DASH_HOST, port=DASH_PORT, debug=False)
+1 -11
View File
@@ -10,11 +10,7 @@ from datetime import datetime
import requests
# Akun Block Producer (BP) yang dicari
TARGET_BP = 'databisnisid'
# Node RPC publik Vexanium
API_NODE = 'https://v2.vexascan.com:2096'
from config import API_NODE, DB_PATH, MIN_STAKED_VEX, TARGET_BP
# Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS)
CODE = 'vexcore'
@@ -24,12 +20,6 @@ TABLE = 'voters'
# Ukuran halaman saat paginasi
LIMIT = 500
# Syarat minimum stake (dalam VEX) untuk disimpan
MIN_STAKED_VEX = 1000
# Lokasi basis data SQLite
DB_PATH = 'voters.db'
# Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters
ZERO_OWNER = '...........q'
+1
View File
@@ -1,2 +1,3 @@
requests>=2.31
flask>=3.0
python-dotenv>=1.0
+89
View File
@@ -0,0 +1,89 @@
// Live filter daftar pemilih by owner (V11). Tanpa JS: form GET ?q= tetap jalan.
(function () {
'use strict';
var input = document.getElementById('search-input');
var rows = document.getElementById('rows');
var pager = document.getElementById('pager');
var result = document.getElementById('search-result');
if (!input || !rows || !pager) {
return;
}
var timer = null;
var originalRows = null;
var originalPager = null;
var DEBOUNCE_MS = 200;
function escapeHtml(text) {
var el = document.createElement('div');
el.textContent = text == null ? '' : String(text);
return el.innerHTML;
}
function vex(value) {
return Number(value).toLocaleString('id-ID', {
minimumFractionDigits: 4,
maximumFractionDigits: 4
});
}
function setSearchMode(on) {
if (on && originalRows === null) {
originalRows = rows.innerHTML;
originalPager = pager.style.display || '';
} else if (!on && originalRows !== null) {
rows.innerHTML = originalRows;
pager.style.display = originalPager;
originalRows = null;
originalPager = null;
if (result) {
result.textContent = '';
}
}
}
function renderResults(data) {
if (!data.results.length) {
rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>';
} else {
rows.innerHTML = data.results.map(function (r) {
return '<div class="list-row">' +
'<span class="rank">' + r.rank + '</span>' +
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
'<span class="weight" data-label="BOBOT SUARA">' + escapeHtml(r.weight) + '</span>' +
'</div>';
}).join('');
}
if (result) {
result.textContent = 'HASIL · ' + data.count + ' AKUN UNTUK "' + data.query + '"';
}
pager.style.display = 'none';
}
function liveSearch(q) {
fetch('/api/search?q=' + encodeURIComponent(q))
.then(function (res) {
return res.json();
})
.then(renderResults)
.catch(function () {
// Biarkan baris server tetap tampil bila fetch gagal.
});
}
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
if (q === '') {
setSearchMode(false);
return;
}
setSearchMode(true);
timer = setTimeout(function () {
liveSearch(q);
}, DEBOUNCE_MS);
});
})();
+84 -9
View File
@@ -121,8 +121,48 @@ body {
color: var(--muted);
}
/* ————— search ————— */
.search {
display: flex;
align-items: flex-end;
gap: var(--sp-md);
margin: 0 var(--sp-lg) var(--sp-sm);
max-width: 560px;
}
.search-input {
background: transparent;
color: var(--on-dark);
font-family: var(--font-text);
font-size: 16px;
border: none;
border-bottom: 1px solid var(--hairline-strong);
border-radius: 0;
padding: var(--sp-sm) 0;
height: 44px;
flex: 1;
min-width: 0;
}
.search-input::placeholder { color: var(--muted); }
.search-input:focus { outline: none; border-bottom-color: var(--on-dark); }
button.pill {
background: transparent;
cursor: pointer;
}
.clear {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--link);
text-decoration: none;
padding-bottom: 12px;
white-space: nowrap;
}
.search-result { margin: 0 var(--sp-lg); }
.search-result:empty { display: none; }
/* ————— spec-list ————— */
.spec-list { margin: 0 var(--sp-lg); border-top: 1px solid var(--hairline-strong); }
.spec-list { margin: var(--sp-md) var(--sp-lg) 0; border-top: 1px solid var(--hairline-strong); }
.list-header,
.list-row {
display: grid;
@@ -153,6 +193,7 @@ body {
font-size: 20px;
letter-spacing: 1px;
color: var(--on-dark);
overflow-wrap: anywhere;
}
.stake {
font-family: var(--font-display);
@@ -218,17 +259,51 @@ body {
color: var(--muted-soft);
}
/* ————— responsive ————— */
/* ————— tablet (768–1024px) ————— */
@media (max-width: 1024px) {
.list-header,
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
}
/* ————— mobile (<768px): list → kartu berlabel ————— */
@media (max-width: 768px) {
.stats { grid-template-columns: 1fr; }
.spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); }
.list-header,
.list-row { grid-template-columns: 48px 1fr; gap: var(--sp-sm); }
.list-header span:nth-child(3),
.list-header span:nth-child(4),
.search { flex-direction: column; align-items: stretch; }
.search .pill { justify-content: center; width: 100%; }
.list-header { display: none; }
.list-row {
grid-template-columns: 48px 1fr;
gap: var(--sp-xxs) var(--sp-sm);
padding: var(--sp-lg) var(--sp-sm);
}
.list-row .rank { grid-row: 1 / span 3; grid-column: 1; align-self: start; padding-top: 6px; }
.list-row .owner { grid-column: 2; font-size: 18px; }
.list-row .stake,
.list-row .weight { grid-column: 2; }
.list-header span:nth-child(3) { display: none; }
.list-header span:nth-child(4) { display: none; }
.pager { flex-wrap: wrap; }
.list-row .stake { text-align: left; }
.list-row .stake::before,
.list-row .weight::before {
content: attr(data-label);
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 2px;
}
.pager { flex-direction: column; gap: var(--sp-md); }
.pager .pill { justify-content: center; width: 100%; }
.page-count { order: -1; }
}
/* ————— layar sangat kecil (<480px): sembunyikan nav-label ————— */
@media (max-width: 480px) {
.nav-label { display: none; }
.hero { padding-top: var(--sp-lg); }
}
+33 -13
View File
@@ -38,36 +38,56 @@
</div>
</section>
<section class="spec-list">
<form class="search" method="get" action="{{ url_for('index') }}">
<input class="search-input" type="search" name="q" id="search-input"
value="{{ q }}" placeholder="CARI AKUN..." autocomplete="off">
<button class="pill" type="submit">CARI</button>
{% if q %}
<a class="clear" href="{{ url_for('index') }}">HAPUS</a>
{% endif %}
</form>
<p class="caption search-result" id="search-result">
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
</p>
<section class="spec-list" id="spec-list">
<div class="list-header">
<span>RANK</span>
<span>AKUN</span>
<span>STAKE (VEX)</span>
<span>BOBOT SUARA</span>
</div>
{% for row in rows %}
<div class="list-row">
<span class="rank">{{ (page - 1) * 50 + loop.index }}</span>
<span class="owner">{{ row[0] }}</span>
<span class="stake">{{ row[1]|vex }}</span>
<span class="weight">{{ row[2] }}</span>
<div class="list-rows" id="rows">
{% for row in rows %}
<div class="list-row">
<span class="rank">{{ row[4] }}</span>
<span class="owner">{{ row[0] }}</span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="weight" data-label="BOBOT SUARA">{{ row[2] }}</span>
</div>
{% else %}
<div class="empty">
{% if q %}TAK ADA AKUN COCOK — "{{ q }}"{% else %}BELUM ADA DATA — JALANKAN get_voters.py{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">BELUM ADA DATA — JALANKAN get_voters.py</div>
{% endfor %}
</section>
<nav class="pager" aria-label="Navigasi halaman">
<nav class="pager" id="pager" aria-label="Navigasi halaman">
{% set qp = q if q else None %}
<a class="pill {% if page <= 1 %}pill-disabled{% endif %}"
href="{{ url_for('index', page=page-1) }}">← SEBELUMNYA</a>
href="{{ url_for('index', page=page-1, q=qp) }}">← SEBELUMNYA</a>
<span class="page-count">{{ page }} / {{ total_pages }}</span>
<a class="pill {% if page >= total_pages %}pill-disabled{% endif %}"
href="{{ url_for('index', page=page+1) }}">BERIKUTNYA →</a>
href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a>
</nav>
</main>
<footer class="footer">
<span>DATA TERVALIDASI DARI JARINGAN VEXANIUM · VEXCORE · VOTERS</span>
</footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
+70 -4
View File
@@ -52,10 +52,7 @@ def main():
for i in range(1, 4):
resp = client.get(f'/?page={i}')
check(f'V10 halaman {i} → 200', resp.status_code == 200)
text = resp.get_data(as_text=True)
for token in text.split('class="list-row"')[1:]:
stake_token = token.split('<span class="stake">')[1].split('</span>')[0]
staked_vals.append(_parse_vex(stake_token))
staked_vals += _extract_stakes(resp.get_data(as_text=True))
check('V10 total 125 baris ditarik', len(staked_vals) == 125)
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
for i in range(len(staked_vals) - 1)))
@@ -69,6 +66,75 @@ def main():
client.get('/')
check('V10 read-only (DB tak berubah)', _table_hashes(db) == before)
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
resp = client.get('/?q=acct12')
check('V11 search ?q= → 200', resp.status_code == 200)
html = resp.get_data(as_text=True)
check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html
and 'acct119' not in html)
check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html)
q_vals = _extract_stakes(html)
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
and len(q_vals) == 6)
# pager membawa q
check('V11 pager mempertahankan q', 'q=acct12' in html)
# halaman ekstra saat filter → dipatok ke total_pages filter
resp = client.get('/?q=acct12&page=99')
check('V11 clamp halaman saat search', resp.status_code == 200)
# search tanpa hasil → status kosong
resp = client.get('/?q=zzzz')
html = resp.get_data(as_text=True)
check('V11 tak ada hasil → pesan kosong', 'TAK ADA AKUN COCOK' in html)
# ————— V11: /api/search (JSON, rank global via ROW_NUMBER) —————
resp = client.get('/api/search?q=acct12')
data = resp.get_json()
check('V11 /api/search shape', set(data.keys()) == {'query', 'count', 'cap', 'results'})
check('V11 /api/search count', data['count'] == 6)
owners = [r['owner'] for r in data['results']]
ranks = [r['rank'] for r in data['results']]
check('V11 /api/search cocok substring',
all('acct12' in o for o in owners))
check('V11 /api/search rank global',
ranks == sorted(ranks) and ranks == list(range(1, 7)))
check('V11 /api/search rank sejajar staked tertinggi',
[r['owner'] for r in data['results']] ==
['acct125', 'acct124', 'acct123', 'acct122', 'acct121', 'acct120'])
check('V11 /api/search urut staked DESC',
[r['staked'] for r in data['results']] == sorted(
[r['staked'] for r in data['results']], reverse=True))
check('V11 /api/search bobot string verbatim',
isinstance(data['results'][0]['weight'], str))
resp = client.get('/api/search?q=zzzz')
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil
resp = client.get('/api/search?q=acct00')
data = resp.get_json()
check('V11 rank global (bukan dalam-filter)',
[r['rank'] for r in data['results']] == list(range(117, 126)))
# ————— env config: default = konstanta lama —————
import config
check('V11 config default TARGET_BP', config.TARGET_BP == 'databisnisid')
check('V11 config default API_NODE', config.API_NODE == 'https://v2.vexascan.com:2096')
check('V11 config default PAGE_SIZE', config.PAGE_SIZE == 50)
check('V11 config default MIN_STAKED_VEX', config.MIN_STAKED_VEX == 1000.0)
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
def _extract_stakes(text):
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML."""
vals = []
for token in text.split('class="list-row"')[1:]:
stake_token = token.split('<span class="stake"')[1].split('</span>')[0].split('>', 1)[1]
vals.append(_parse_vex(stake_token))
return vals
def _parse_vex(text):
# format '1.749.791,2303' → float