oxen-observer/app.py

297 lines
11 KiB
Python

#!/usr/bin/env python3
import flask
import pylokimq
from datetime import datetime, timedelta
import babel.dates
import json
import sys
import statistics
import config
# Make a dict of config.* to pass to templating
conf = {x: getattr(config, x) for x in dir(config) if not x.startswith('__')}
app = flask.Flask(__name__)
# DEBUG:
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.jinja_env.auto_reload = True
# end DEBUG
lmq = pylokimq.LokiMQ(pylokimq.LogLevel.warn)
lmq.max_message_size = 10*1024*1024
lmq.start()
lokid = lmq.connect_remote(config.lokid_rpc)
cached = {}
cached_args = {}
cache_expiry = {}
class FutureJSON():
def __init__(self, endpoint, cache_seconds=3, *, args=[], fail_okay=False, timeout=10):
self.endpoint = endpoint
self.fail_okay = fail_okay
if self.endpoint in cached and cached_args[self.endpoint] == args and cache_expiry[self.endpoint] >= datetime.now():
self.json = cached[self.endpoint]
self.args = None
self.future = None
else:
self.json = None
self.args = args
self.future = lmq.request_future(lokid, self.endpoint, self.args, timeout=timeout)
self.cache_seconds = cache_seconds
def get(self):
"""If the result is already available, returns it immediately (and can safely be called multiple times.
Otherwise waits for the result, parses as json, and caches it. Returns None if the request fails"""
if self.json is None and self.future is not None:
try:
result = self.future.get()
if result[0] != b'200':
raise RuntimeError("Request failed: got {}".format(result))
self.json = json.loads(result[1])
cached[self.endpoint] = self.json
cached_args[self.endpoint] = self.args
cache_expiry[self.endpoint] = datetime.now() + timedelta(seconds=self.cache_seconds)
except RuntimeError as e:
if not self.fail_okay:
print("Something getting wrong: {}".format(e), file=sys.stderr)
self.future = None
pass
return self.json
@app.template_filter('format_datetime')
def format_datetime(value, format='long'):
return babel.dates.format_datetime(value, format, tzinfo=babel.dates.get_timezone('UTC'))
@app.template_filter('from_timestamp')
def from_timestamp(value):
return datetime.fromtimestamp(value)
@app.template_filter('ago')
def datetime_ago(value):
delta = datetime.now() - value
disp=''
if delta.days < 0:
delta = -delta
disp += '-'
if delta.days > 0:
disp += '{}d '.format(delta.days)
disp += '{:2d}:{:02d}:{:02d}'.format(delta.seconds // 3600, delta.seconds // 60 % 60, delta.seconds % 60)
return disp
@app.template_filter('reltime')
def relative_time(seconds):
ago = False
if seconds == 0:
return 'now'
elif seconds < 0:
seconds = -seconds
ago = True
if seconds < 90:
delta = '{:.0f} seconds'.format(seconds)
elif seconds < 90 * 60:
delta = '{:.1f} minutes'.format(seconds / 60)
elif seconds < 36 * 3600:
delta = '{:.1f} hours'.format(seconds / 3600)
elif seconds < 99.5 * 86400:
delta = '{:.1f} days'.format(seconds / 86400)
else:
delta = '{:.0f} days'.format(seconds / 86400)
return delta + ' ago' if ago else 'in ' + delta
@app.template_filter('roundish')
def filter_round(value):
return ("{:.0f}" if value >= 100 or isinstance(value, int) else "{:.1f}" if value >= 10 else "{:.2f}").format(value)
@app.template_filter('chop0')
def filter_chop0(value):
value = str(value)
if '.' in value:
return value.rstrip('0').rstrip('.')
return value
si_suffix = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
@app.template_filter('si')
def format_si(value):
i = 0
while value >= 1000 and i < len(si_suffix) - 1:
value /= 1000
i += 1
return filter_round(value) + '{}'.format(si_suffix[i])
@app.template_filter('loki')
def format_loki(atomic, tag=True, fixed=False, decimals=9):
"""Formats an atomic current value as a human currency value.
tag - if False then don't append " LOKI"
fixed - if True then don't strip insignificant trailing 0's and '.'
decimals - at how many decimal we should round; the default is full precision
"""
disp = "{{:.{}f}}".format(decimals).format(atomic * 1e-9)
if not fixed and decimals > 0:
disp = disp.rstrip('0').rstrip('.')
if tag:
disp += ' LOKI'
return disp
@app.after_request
def add_global_headers(response):
if 'Cache-Control' not in response.headers:
response.headers['Cache-Control'] = 'no-store'
return response
@app.route('/style.css')
def css():
return flask.send_from_directory('static', 'style.css')
@app.route('/')
@app.route('/page/<int:page>')
@app.route('/page/<int:page>/<int:per_page>')
@app.route('/range/<int:first>/<int:last>')
@app.route('/autorefresh/<int:refresh>')
def main(refresh=None, page=0, per_page=None, first=None, last=None):
inforeq = FutureJSON('rpc.get_info', 1)
stake = FutureJSON('rpc.get_staking_requirement', 10)
base_fee = FutureJSON('rpc.get_fee_estimate', 10)
hfinfo = FutureJSON('rpc.hard_fork_info', 10)
mempool = FutureJSON('rpc.get_transaction_pool', 5)
sns = FutureJSON('rpc.get_service_nodes', 5,
args=[json.dumps({
'all': False,
'fields': { x: True for x in ('service_node_pubkey', 'requested_unlock_height', 'last_reward_block_height',
'last_reward_transaction_index', 'active', 'funded', 'earned_downtime_blocks',
'service_node_version', 'contributors', 'total_contributed', 'total_reserved',
'staking_requirement', 'portions_for_operator', 'operator_address', 'pubkey_ed25519',
'last_uptime_proof', 'service_node_version') } }).encode()])
# This call is slow the first time it gets called in lokid but will be fast after that, so call
# it with a very short timeout. It's also an admin-only command, so will always fail if we're
# using a restricted RPC interface.
coinbase = FutureJSON('admin.get_coinbase_tx_sum', 10, timeout=1, fail_okay=True,
args=[json.dumps({"height":0, "count":2**31-1}).encode()])
server = dict(
timestamp=datetime.utcnow(),
)
custom_per_page = ''
if per_page is None or per_page <= 0 or per_page > config.max_blocks_per_page:
per_page = config.blocks_per_page
else:
custom_per_page = '/{}'.format(per_page)
# We have some chained request dependencies here and below, so get() them as needed; all other
# non-dependent requests should already have a future initiated above so that they can
# potentially run in parallel.
info = inforeq.get()
height = info['height']
# Permalinked block range:
if first is not None and last is not None and 0 <= first <= last and last <= first + 99:
start_height, end_height = first, last
if end_height - start_height + 1 != per_page:
per_page = end_height - start_height + 1;
custom_per_page = '/{}'.format(per_page)
# We generally can't get a perfect page number because our range (e.g. 5-14) won't line up
# with pages (e.g. 10-19, 0-19), so just get as close as we can. Next/Prev page won't be
# quite right, but they'll be within half a page.
page = round((height - 1 - end_height) / per_page)
else:
end_height = max(0, height - per_page*page - 1)
start_height = max(0, end_height - per_page + 1)
blocks = FutureJSON('rpc.get_block_headers_range', args=[json.dumps({
'start_height': start_height,
'end_height': end_height,
'get_tx_hashes': True,
}).encode()]).get()['headers']
# If 'txs' is already there then it is probably left over from our cached previous call through
# here.
if blocks and 'txs' not in blocks[0]:
txids = []
for b in blocks:
b['txs'] = []
txids.append(b['miner_tx_hash'])
if 'tx_hashes' in b:
txids += b['tx_hashes']
txs = FutureJSON('rpc.get_transactions', args=[json.dumps({
"txs_hashes": txids,
"decode_as_json": True,
"tx_extra": True,
"prune": True,
}).encode()]).get()
txs = txs['txs']
i = 0
for tx in txs:
# TXs should come back in the same order so we can just skip ahead one when the block
# height changes rather than needing to search for the block
if blocks[i]['height'] != tx['block_height']:
i += 1
while i < len(blocks) and blocks[i]['height'] != tx['block_height']:
print("Something getting wrong: missing txes?", file=sys.stderr)
i += 1
if i >= len(blocks):
print("Something getting wrong: have leftover txes")
break
tx['info'] = json.loads(tx['as_json'])
blocks[i]['txs'].append(tx)
#txes = FutureJSON('rpc.get_transactions');
# mempool RPC return values are about as nasty as can be. For each mempool tx, we get back
# *both* binary+hex encoded values and JSON-encoded values slammed into a string, which means we
# have to invoke an *extra* JSON parser for each tx. This is terrible.
mp = mempool.get()
if 'transactions' in mp:
for tx in mp['transactions']:
tx['info'] = json.loads(tx["tx_json"])
else:
mp['transactions'] = []
# Clean up the SN data a bit to make things easier for the templates
sn_states = sns.get()['service_node_states']
awaiting_sns, active_sns, inactive_sns = [], [], []
for sn in sn_states:
sn['contribution_open'] = sn['staking_requirement'] - sn['total_reserved']
sn['contribution_required'] = sn['staking_requirement'] - sn['total_contributed']
sn['num_contributions'] = sum(len(x['locked_contributions']) for x in sn['contributors'])
if sn['active']:
active_sns.append(sn)
elif sn['funded']:
sn['decomm_blocks_remaining'] = max(sn['earned_downtime_blocks'], 0)
sn['decomm_blocks'] = info['height'] - sn['state_height']
inactive_sns.append(sn)
else:
awaiting_sns.append(sn)
return flask.render_template('index.html',
info=info,
stake=stake.get(),
fees=base_fee.get(),
emission=coinbase.get(),
hf=hfinfo.get(),
active_sns=active_sns,
inactive_sns=inactive_sns,
awaiting_sns=awaiting_sns,
blocks=blocks,
block_size_median=statistics.median(b['block_size'] for b in blocks),
page=page,
per_page=per_page,
custom_per_page=custom_per_page,
mempool=mp,
server=server,
config=conf,
refresh=refresh,
)