From 4b27018965599dde6cf7e7d26e48f3fc021ff367 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 17 Aug 2020 22:17:06 -0300 Subject: [PATCH] Initial commit Basic working prototype of most of the first pages (templates brought in from the old explorer but updated for jinja). --- .gitignore | 5 + .gitmodules | 3 + app.py | 242 +++++++++++++++++++++ pylokimq | 1 + static/style.css | 233 ++++++++++++++++++++ templates/_basic.html | 54 +++++ templates/include/block_page_controls.html | 31 +++ templates/include/mempool.html | 43 ++++ templates/include/tx_fee.html | 13 ++ templates/include/tx_type_symbol.html | 33 +++ templates/index.html | 197 +++++++++++++++++ 11 files changed, 855 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 app.py create mode 160000 pylokimq create mode 100644 static/style.css create mode 100644 templates/_basic.html create mode 100644 templates/include/block_page_controls.html create mode 100644 templates/include/mempool.html create mode 100644 templates/include/tx_fee.html create mode 100644 templates/include/tx_type_symbol.html create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d0dc72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +mainnet.sock +testnet.sock +devnet.sock +pylokimq.cpython-*.so +__pycache__ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b287c4e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pylokimq"] + path = pylokimq + url = https://github.com/majestrate/pylokimq.git diff --git a/app.py b/app.py new file mode 100644 index 0000000..fae9176 --- /dev/null +++ b/app.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 + +import flask +import pylokimq +from datetime import datetime, timedelta +import babel.dates +import json +import sys +import statistics + +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.start() +lokid = lmq.connect_remote('ipc://./mainnet.sock') +#lokid = lmq.connect_remote('ipc://./testnet.sock') +#lokid = lmq.connect_remote('ipc://./devnet.sock') + +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('round') +def filter_round(value): + return ("{:.0f}" if value >= 100 or isinstance(value, int) else "{:.1f}" if value >= 10 else "{:.2f}").format(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: + 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/') +@app.route('/page//') +@app.route('/range//') +@app.route('/autorefresh/') +def main(refresh=None, page=0, per_page=None, first=None, last=None): + info = 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) + # 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(), + ) + + config = dict( + # FIXME: make these configurable + pusher=True, + key_image_checker=True, + output_key_checker=True, + autorefresh_option=True, + mainnet_url='', + testnet_url='', + devnet_url='', + blocks_per_page=20 + ) + + custom_per_page = '' + if per_page is None or per_page <= 0 or per_page > 100: + 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. + height = info.get()['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'] = [] + + return flask.render_template('index.html', + info=info.get(), + stake=stake.get(), + fees=base_fee.get(), + emission=coinbase.get(), + hf=hfinfo.get(), + 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=config, + refresh=refresh, + ) diff --git a/pylokimq b/pylokimq new file mode 160000 index 0000000..32de27c --- /dev/null +++ b/pylokimq @@ -0,0 +1 @@ +Subproject commit 32de27c2fe8ff9ccb86031afaf900734ea16508a diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d6a607e --- /dev/null +++ b/static/style.css @@ -0,0 +1,233 @@ +body { + margin: 0; + padding: 0; + font-size: 80%; + background-color: #1a1a1a; + font-family: sans-serif; + color: white; +} + +h1, h2, h3, h4, h5, h6 { + text-align: left; + font-weight: normal; + margin-bottom: 0.2em; +} + +.general_info { + font-size: 12px; + margin-top: 5px; + margin-bottom: 3px; +} +.nowrap-spans { + text-indent: -2em; + padding-left: 2em; +} +.nowrap-spans>span { + white-space: nowrap; +} + +.Subtitle { + font-size: 1.0em; + color: rgba(255, 255, 255, 0.8); + margin: 0; + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.PaginationControl, +.PageButton { + background-color: #008522; + min-height: 1.5em; + padding: 5px; + padding-top: 8px; + margin-right: 1em; + font-weight: bold; + font-size: 1em; + color: white; + border: none; + font-family: sans-serif; +} + +.PageButton.disabled { + background-color: #808080; +} + +.PaginationControl select { + font-size: inherit; + background-color: #1a1a1a; + color: #008522; + border: none; + padding: 0; + margin: 0; +} + +.PageButton:hover { + background-color: #006a1b; +} +.PageButton.disabled:hover { + background-color: #707070; +} + +.Wrapper { + width: 80%; + margin: auto; +} + +.Header { + margin: 0; +} + +.Header > a:link { + text-decoration: none; + color: white; !important +} + +.Table { + width: 100%; +} + +table thead tr, +.TableHeader { + font-weight: bold; + background-color: #008522; +} + +.TitleDivider { + height: 2px; + width: 100%; + background-color: #008522; + margin-bottom: 1em; +} + +.LinkNoUnderline { + text-decoration: none !important; +} + +.LinkNoUnderline:hover { + color: white; +} + +.center { + margin: auto; + width: 96%; + /*border: 1px solid #73AD21; + padding: 10px;*/ +} + +tr, li, #pages, .info { + height: 2em; + margin: auto; +} + +#pages { + margin-top: 1em; + margin-bottom: 1em; + display: flex; + justify-content: center; +} + +td { + text-align: center; +} + +a:link, a:visited { + text-decoration: underline; + color: white; +} + +a:hover { + text-decoration: underline; + color: #78be20; +} + +form { + display: inline-block; +} + +.style-1 input[type="text"] { + padding: 2px; + border: solid 1px rgba(255, 255, 255, 0.25); + background-color: #1a1a1a; + height: 2em; + width: 80%; + font-size: 1em; + color: white; +} + +.style-1 input[type="text"]:focus, +.style-1 input[type="text"].focus { + border: solid 1px #008522; +} +h1, h2, h3, h4, h5, h6 { color: white; } + +.tabs { + position: relative; + min-height: 220px; /* This part sucks */ + clear: both; + margin: 25px 0; +} + +.tab { + float: left; +} + +.tab label { + background: rgba(0, 133, 34, 0.5); + padding: 10px; + margin-right: 2px; + margin-left: -1px; + position: relative; + left: 1px; +} + +.tab [type=radio] { + display: none; +} + +.content { + position: absolute; + top: 28px; + left: 0; + background: #1a1a1a; + right: 0; + bottom: 0; + padding-top: 20px; + padding-right: 20px; + padding-bottom: 20px; + border-top: 2px solid #008522; +} + +[type=radio]:checked ~ label { + background: #008522 ; + z-index: 2; +} + +[type=radio]:checked ~ label ~ .content { + z-index: 1; +} + +input#toggle-1[type=checkbox] { + position: absolute; + /*top: -9999px;*/ + left: -9999px; + +} +label#show-decoded-inputs { + /*-webkit-appearance: push-button;*/ + /*-moz-appearance: button;*/ + /*margin: 60px 0 10px 0;*/ + cursor: pointer; + display: block; +} + +div#decoded-inputs{ +} + +/* Toggled State */ +input#toggle-1[type=checkbox]:checked ~ div#decoded-inputs { + display: block; +} + +span.icon { + cursor: help; +} diff --git a/templates/_basic.html b/templates/_basic.html new file mode 100644 index 0000000..6014f32 --- /dev/null +++ b/templates/_basic.html @@ -0,0 +1,54 @@ +{# Basic content template: provides an overridable header/content/footer #} + + + + {% block head %} + {% block title %}{% endblock %}Loki{{' TESTNET' if info and info.testnet else ' DEVNET' if info and info.devnet else ''}} + Blockchain Explorer + + {% if refresh %} + + {% endif %} + {% endblock %} + + + + + +{% block content %} +{% endblock %} + + + + + diff --git a/templates/include/block_page_controls.html b/templates/include/block_page_controls.html new file mode 100644 index 0000000..e18565d --- /dev/null +++ b/templates/include/block_page_controls.html @@ -0,0 +1,31 @@ +
+ {% set top_page = (info.height - 1) // per_page %} + {% if page > 0 %} + +
Prev
+
+ {% else %} +
Prev
+ {% endif %} +
+ Current Page: {{page}}/{{top_page}} +
+ + {%if page < top_page%} + +
Next
+
+ {% else %} +
Next
+ {% endif %} + +
+ {% set per_page_options = [5, 10, 20, 25, 50, 100] %} + {% if not per_page in per_page_options %} + {{ '' if per_page_options.append(per_page) }} + {% endif %} + Showing blocks/page +
+
diff --git a/templates/include/mempool.html b/templates/include/mempool.html new file mode 100644 index 0000000..cd11862 --- /dev/null +++ b/templates/include/mempool.html @@ -0,0 +1,43 @@ +{# Lists mempool transactions. mempool_limit can be set to limit the number of shown transactions; defaults to 25. Set explicitly to none to show all. #} +{% if not mempool_limit is defined %}{% set mempool_limit = 25 %} +{% elif mempool_limit is none %}{% set mempool_limit = mempool.transactions|length %} +{% endif %} +
+

Transaction Pool

+ +

{{mempool.transactions|length}} transactions, + {{mempool.transactions|sum(attribute='blob_size') | si}}B

+
+ + + + + + + + + + + + + + {% for tx in mempool.transactions[:mempool_limit] %} + + + + + + + + {% endfor %} + +
Age [h:m:s]TypeTransaction HashFee/Per kBIn/OutTX Size
{{tx.receive_time | from_timestamp | ago}}{{tx_type_symbol}}{{tx.id_hash}}{{tx.info.vin | length}}/{{tx.info.vout | length}}{{tx.blob_size | si}}B
+ + {% if mempool.transactions|length > mempool_limit %} + + {% endif %} + +
+ diff --git a/templates/include/tx_fee.html b/templates/include/tx_fee.html new file mode 100644 index 0000000..eb954ac --- /dev/null +++ b/templates/include/tx_fee.html @@ -0,0 +1,13 @@ +{% macro display(tx, show_zero=false) -%} + {% set fee = + (tx.info.rct_signatures.txnFee if 'rct_signatures' in tx.info and 'txnFee' in tx.info.rct_signatures else 0) + + (tx.extra.burn_amount if 'burn_amount' in tx.info else 0) + -%} + {% if fee > 0 or show_zero -%} + {{ fee | loki(tag=False, fixed=True, decimals=4) }} + {%- if 'burn_amount' in tx.extra %} + 🔥 + {%-endif-%} + {%endif-%} +{% endmacro %} diff --git a/templates/include/tx_type_symbol.html b/templates/include/tx_type_symbol.html new file mode 100644 index 0000000..62049aa --- /dev/null +++ b/templates/include/tx_type_symbol.html @@ -0,0 +1,33 @@ +{% macro display(tx) -%} + {% if tx.info.version >= 4 -%} + {% if tx.info.type == 1 and 'sn_state_change' in tx.extra -%} + {% if tx.extra.sn_state_change.type == 'decom' -%} + 👎 + {% elif tx.extra.sn_state_change.type == 'recom' -%} + 👍 + {% elif tx.extra.sn_state_change.type == 'dereg' -%} + 🚫 + {% elif tx.extra.sn_state_change.type == 'ip' -%} + 📋 + {% else -%} + + {% endif -%} + {% elif tx.info.type == 2 -%} + 🔓 + {% elif tx.info.type == 4 and 'lns' in tx.extra -%} + {% if 'buy' in tx.extra.lns -%} + 🎫 + {% elif 'update' in tx.extra.lns -%} + 💾 + {% endif -%} + {% elif 'sn_registration' in tx.extra -%} + 🏁 + {% elif 'sn_contributor' in tx.extra -%} + + {% endif -%} + {% endif -%} +{% endmacro %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d944641 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,197 @@ +{% extends "_basic.html" %} + +{% block content %} +
+ +
+ Server Time: {{ server.timestamp | format_datetime }} + | Transaction Pool + {% if config.pusher %} + | Transaction pusher + {% endif %} + {% if config.key_image_checker %} + | Key images checker + {% endif %} + {% if config.output_key_checker %} + | Output keys checker + {% endif %} + {% if config.autorefresh_option %} + | + {% if refresh %} + Autorefresh is ON ({{refresh}} s) + {% else %} + Autorefresh is OFF + {% endif %} + + {% endif %} + {% if config.testnet_url and not info.testnet %} + | Go to testnet explorer + {% endif %} + {% if config.devnet_url and not info.devnet %} + | Go to devnet explorer + {% endif %} + {% if config.mainnet_url and not info.mainnet %} + | Go to mainnet explorer + {% endif %} + {% if info.testnet %} + | This is TESTNET blockchain + {% elif info.devnet %} + | This is DEVNET blockchain + {% endif %} +
+ + + {% if info %} +

+ Hard fork: v{{hf.version}} + | Network difficulty: {{info.difficulty}} + | Hash rate: ~{{(info.difficulty / info.target) | si }}H/s + | Staking requirement: {{stake.staking_requirement | loki}} + | + Base fee: + {{fees.fee_per_output | loki}}/output + {{(fees.fee_per_byte * 1000) | loki}}/kB + + | + Blink fee: + {{fees.blink_fee_per_output | loki}}/output + {{(fees.blink_fee_per_byte * 1000) | loki}}/kB + + | + Block size limit: + {{(info.block_size_limit / 2) | si}}B/{{info.block_size_limit | si}}B + + | Blockchain size: {{info.database_size | si}}B +

+ {% endif %} + + {{emission}} + {% if emission %} +

+ Circulating Supply*: + {% if emission.status == 'BUSY' %} + (still calculating...) + {% elif emission.status == 'OK' %} + {{(emission.emission_amount - emission.burn_amount) | loki}} + (Coinbase: {{emission.emission_amount | loki}} + | Fees: {{emission.fee_amount | loki}} + | Burned: {{emission.burn_amount | loki}}). + {%endif%} +

+ * — Circulating supply may exclude any currently, publicised locked tokens, otherwise it is equal to the Coinbase minus burned coins. + Fees includes paid transaction fees less any portion of the fee that was burned. +

+

+ {% endif %} + + +

TX Types Legend

+

+ Service Node - Registration: 🏁 + | Contribution: ⚑ + | Recommission: 👍 + | Decommission: 👎 + | Deregister: 🚫 + | IP Change Penalty: 📋 + | Stake Unlock: 🔓 + | Loki Name System Buy: 🎫 + | LNS Update: 💾 +

+
+ + {% include 'include/mempool.html' %} + +
+

Transactions in + {% if page == 0 %} + the Last {{blocks|length}} Blocks + {% else %} + Blocks {{blocks[0].height}}–{{blocks[-1].height}} + {% endif %} + 🔗 +

+ + {% set block_sizes = blocks | map(attribute='block_size') | sort %} + {%if block_sizes|count > 0%} +

(Min. / Median / Average / Max. size of these blocks: + {{block_sizes[0] | si}}B / + {{(block_sizes[(block_sizes|count-1)//2]/2 + block_sizes[(block_sizes|count)//2]/2) | si}}B / + {{(block_sizes|sum / block_sizes|count) | si}}B / + {{block_sizes[-1] | si}}B) +

+ {%endif%} +
+ + {% include 'include/block_page_controls.html' %} + + + + + + + + + + + + + + + + + {% import 'include/tx_type_symbol.html' as symbol %} + {% import 'include/tx_fee.html' as fee %} + {% for b in blocks | reverse %} + + + + + + + + + + + + {% for tx in b.txs[1:] %} + + + + + + + + + + + + {% endfor %} + {% endfor %} + +
HeightAge [h:m:s]SizeTypeTransaction HashFeeOutputsIn/OutTX Size
{{b.height}}{{b.timestamp | from_timestamp | ago}}{{b.block_size | si}}{{symbol.display(b.txs[0])}}{{b.miner_tx_hash}}{{fee.display(b.txs[0])}}{{b.reward | loki(tag=False, fixed=True, decimals=2)}}{{b.txs[0].info.vin | length}}/{{b.txs[0].info.vout | length}}{{b.txs[0].size | si}}
{{symbol.display(tx)}}{{tx.tx_hash}}{{fee.display(tx)}}{{tx.info.vin | length}}/{{tx.info.vout | length}}{{tx.size | si}}
+ + {% include 'include/block_page_controls.html' %} +
+ + {{service_node_summary}} + + + {{quorum_state_summary}} +
+

Note: The quorum shown here is the currently active voting height + which is not necessarily the latest quorum. Quorums can only be voted on + after a number of blocks have transpired.

+

Click here to see the last 1hrs worth of the stored quorum states

+
+ + {% if show_cache_times %} +
+
+ Tx details construction time: {{construction_time_total}} s +
+ includes {{construction_time_cached}} s from block cache ({{cache_hits}} hits) + and {{construction_time_non_cached}} s from non cache ({{cache_misses}} misses) +
+
+ {% endif %} +{% endblock %}