Files
databisnisid/dashboard.py
T

225 lines
7.2 KiB
Python

"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list.
Read-only (V10/V11): tidak pernah menulis ke DB. Data pemilih disegarkan oleh
`get_voters.py` (run harian / cron). Saldo liquid akun diambil langsung dari
API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`.
Konfigurasi dari env/`.env`.
"""
import time
from datetime import datetime
import requests
from flask import Flask, abort, jsonify, render_template, request
import db
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
PAGE_SIZE, TARGET_BP)
SEARCH_CAP = 500
MAX_Q_LEN = 64
app = Flask(__name__)
@app.after_request
def _no_cache_html(resp):
"""HTML ⊥ di-cache browser (V28): tautan style.css memakai ?v= utk
memaksa fetch versi baru; halaman itu sendiri tak boleh heuristik cache
sehingga tiap muat selalu menarik tautan ber-version tsb."""
if resp.mimetype == 'text/html':
resp.headers['Cache-Control'] = 'no-store'
return resp
# Cache saldo liquid per worker (in-memory, ⊥ tulis DB). `at` mencatat
# kapan sukses ATAU gagal terakhir → API dihubungi paling banyak sekali
# per TTL (cooldown saat gagal pun ikut mencegah pukulan berulang).
_liquid_cache = {'at': 0.0, 'value': None}
@app.template_filter('vex')
def vex(value):
"""Format jumlah VEX ala id-ID (koma ribuan, titik desimal)."""
return f'{value:,.4f}'.replace(',', '\u00a0').replace('.', ',').replace('\u00a0', '.')
@app.template_filter('shortdate')
def shortdate(value):
"""Potong stempel waktu ISO → YYYY-MM-DD (untuk kolom vote terakhir)."""
if not value:
return '—'
try:
return datetime.fromisoformat(value).strftime('%Y-%m-%d')
except ValueError:
return '—'
def _get_liquid_vex():
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum
lewat → nilai cache. Bila API gagal → nilai lama tetap dipakai (atau None
bila belum pernah sukses), dan `at` ikut diset → API tak dipukul berulang.
"""
now = time.monotonic()
if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL:
return _liquid_cache['value']
value = None
try:
resp = requests.get(
f'{DATABISNIS_API}/v2/state/get_account',
params={'account': TARGET_BP},
timeout=8,
)
resp.raise_for_status()
raw = resp.json().get('account', {}).get('core_liquid_balance')
if raw:
value = float(raw.split()[0])
except (requests.RequestException, KeyError, TypeError, ValueError):
value = _liquid_cache['value'] # gagal → nilai lama bila ada
_liquid_cache.update(at=now, value=value)
return value
def _escape_like(q):
"""Escape wildcard LIKE agar `%`/`_`/`!` dari pengguna tidak jadi pola.
`!` dipakai sebagai karakter escape, bukan backslash: backslash bermasalah
di literal string MySQL (`'\'` memutus string), sedangkan `!` netral di
kedua backend (sqlite & mysql sama-sama paham `ESCAPE '!'`).
"""
return (q.replace('!', '!!')
.replace('%', '!%')
.replace('_', '!_'))
def _filter_sql(q):
"""V11: klausa WHERE + params untuk filter owner substring."""
clauses = []
params = []
if q:
clauses.append("owner LIKE %s ESCAPE '!'")
params.append(f'%{_escape_like(q)}%')
if not clauses:
return '', []
return 'WHERE ' + ' AND '.join(clauses), params
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, '
' v.last_vote '
'FROM voters v ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
)
return db.query(sql, params + [PAGE_SIZE, offset])
def _count(q):
"""V11: banyak baris cocok filter (atau total bila tanpa filter)."""
where, params = _filter_sql(q)
return db.queryone(
'SELECT COUNT(*) FROM voters ' + where, params
)[0]
def _stats():
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search)."""
row = db.queryone(
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
'FROM voters'
)
total_voters, total_staked, top_staked = row
scanned = db.queryone(
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters'
)[0]
return total_voters, total_staked, top_staked, scanned
@app.get('/')
def index():
try:
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()
liquid_vex = _get_liquid_vex()
filtered_count = _count(q)
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages)
rows = _db_rows(page, q)
return render_template(
'index.html',
bp=TARGET_BP,
rows=rows,
page=page,
total_pages=total_pages,
total_voters=total_voters,
total_staked=total_staked,
top_staked=top_staked,
liquid_vex=liquid_vex,
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, '
' v.last_vote '
'FROM voters v ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
)
rows = db.query(sql, params + [SEARCH_CAP])
return jsonify({
'query': q,
'count': len(rows),
'cap': SEARCH_CAP,
'results': [
{
'owner': r[0], 'staked': r[1], 'weight': r[2],
'rank': r[3], 'last_vote': r[4],
}
for r in rows
],
})
@app.get('/history')
def history():
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.
Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul.
"""
runs = db.list_runs()
latest = runs[0][0] if runs else None
payments = db.list_payments(run_id=latest)
return render_template('history.html', runs=runs, payments=payments,
latest_date=runs[0][1] if runs else None,
bp=TARGET_BP)
if __name__ == '__main__':
app.run(host=DASH_HOST, port=DASH_PORT, debug=False)