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:
1 parent
0b3a86aad2
commit
205ad35c70
12 files changed
+440
-65
No files matched your search
@@ -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
|
||||
@@ -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/
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,2 +1,3 @@
|
||||
requests>=2.31
|
||||
flask>=3.0
|
||||
python-dotenv>=1.0
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user