├── .gitignore ├── README.md ├── atendo ├── __init__.py ├── mempool.py ├── query.py ├── txndb.py └── utils.py ├── examples └── atendo.service ├── exportjson.py ├── getmempool.py ├── mkdb.py └── web ├── index.html ├── memplots.js ├── qrcode-mempool.png ├── styles.css └── styles.less /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | *.py[co] 4 | *.db 5 | *.json 6 | !example_config.json 7 | *.log 8 | *.swp 9 | *~ 10 | __pycache__ 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xmr-atendo 2 | Tools for gathering and presenting Monero mempool stats 3 | 4 | Visit http://pooldata.xmrlab.com to see it in action. 5 | -------------------------------------------------------------------------------- /atendo/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from .mempool import MemPool 4 | from .txndb import mksession, Txn, TxnStat 5 | 6 | PICONERO = Decimal('0.000000000001') 7 | 8 | class Atendo(object): 9 | def __init__(self, daemon_host='127.0.0.1', daemon_port=18081, db_url='sqlite://atendo.db', 10 | timeout=None): 11 | self.mempool = MemPool(host=daemon_host, port=daemon_port, timeout=timeout) 12 | self.dbsession = mksession(db_url) 13 | 14 | def fetch(self): 15 | now = datetime.utcnow() 16 | stat = { 17 | 'txns': 0, 18 | 'sumfee': 0, 19 | 'sumsize': 0 20 | } 21 | feesperkb = [] 22 | maxage = Decimal('-Infinity') 23 | for txdata in self.mempool.refresh(): 24 | stat['txns'] += 1 25 | stat['sumfee'] += txdata['fee'] 26 | stat['sumsize'] += txdata['size'] 27 | maxage = max(maxage, (now - txdata['received']).total_seconds()) 28 | feesperkb.append(txdata['fee'] / (txdata['size'] / Decimal(1024))) 29 | if self.dbsession.query(Txn).filter(Txn.hash_id == txdata['hash_id']).count() == 0: 30 | txn = Txn(queried=now, **txdata) 31 | self.dbsession.add(txn) 32 | if stat['txns'] > 0: 33 | stat['avgfee'] = (stat['sumfee'] / stat['txns']).quantize(PICONERO) 34 | stat['avgsize'] = int(stat['sumsize'] / stat['txns']) 35 | stat['avgfeeperkb'] = (sum(feesperkb) / len(feesperkb)).quantize(PICONERO) 36 | else: 37 | stat['avgfee'] = stat['avgfeeperkb'] = None 38 | stat['avgsize'] = 0 39 | stat['maxage'] = maxage 40 | txnstat = TxnStat(queried=now, **stat) 41 | self.dbsession.add(txnstat) 42 | self.dbsession.commit() 43 | -------------------------------------------------------------------------------- /atendo/mempool.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | import json 4 | import logging 5 | import re 6 | import requests 7 | 8 | class MemPool(object): 9 | timeout = 20 10 | _clean = re.compile(r'"tx_blob"\s*:\s*"(\\.|[^"\\]+)*"\s*,') 11 | 12 | def __init__(self, host, port=18081, timeout=None): 13 | self.url = 'http://{host}:{port}/get_transaction_pool'.format(host=host, port=port) 14 | self.timeout = self.timeout or timeout 15 | self._log = logging.getLogger(__name__) 16 | 17 | def refresh(self): 18 | req = requests.post( 19 | self.url, headers={'Content-Type': 'application/json'}, timeout=self.timeout) 20 | cleantext = self._clean.sub('', req.text) 21 | self._raw_data = json.loads(cleantext) 22 | try: 23 | return self._process() 24 | except Exception: 25 | self._log.exception("Error while processing data") 26 | self._log.debug(str(self._raw_data)) 27 | raise 28 | 29 | def _process(self): 30 | txs = [] 31 | for tx in self._raw_data.get('transactions', []): 32 | tx = tx.copy() 33 | tx['tx_json'] = json.loads(tx['tx_json']) 34 | txs.append({ 35 | 'received': datetime.fromtimestamp(tx['receive_time']), 36 | 'size': tx['blob_size'], 37 | 'fee': tx['fee'] / Decimal('1000000000000'), 38 | 'inputs': len(tx['tx_json']['vin']), 39 | 'outputs': len(tx['tx_json']['vout']), 40 | 'ring': len(tx['tx_json']['vin'][0]['key']['key_offsets']), 41 | 'version': tx['tx_json']['version'], 42 | 'hash_id': tx['id_hash']}) 43 | return txs 44 | -------------------------------------------------------------------------------- /atendo/query.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from datetime import datetime, timedelta 3 | import operator 4 | from sqlalchemy.sql import func 5 | 6 | from .txndb import Txn, TxnStat 7 | 8 | class Query(object): 9 | when = None 10 | _stats = None 11 | _hists = {} 12 | 13 | def __init__(self, dbsession, periods, maxtimeline=None, bins=20): 14 | self.dbsession = dbsession 15 | self.periods = periods 16 | self.bins = bins 17 | if maxtimeline: 18 | self._maxtimelinerange = periods[maxtimeline] 19 | else: 20 | self._maxtimelinerange = max(periods.values()) 21 | 22 | def get_timeline(self, prop): 23 | if not self._stats: 24 | self.fetch_data() 25 | return {'timestamp': int(self.when.timestamp()), 'data': self.timelinegen(prop)} 26 | 27 | def get_hists(self, prop, period): 28 | if not self._stats: 29 | self.fetch_data() 30 | try: 31 | return self._hists[period][prop] 32 | except KeyError: 33 | return [] 34 | 35 | def fetch_data(self, when=None): 36 | self.when = when or datetime.now() 37 | stats = self.dbsession.query(TxnStat) \ 38 | .filter(TxnStat.queried > self.when - timedelta(seconds=self._maxtimelinerange)) \ 39 | .order_by(TxnStat.queried) 40 | self._stats = list(map(operator.attrgetter('__dict__'), stats.all())) 41 | 42 | self._hists = {} 43 | for label, period in self.periods.items(): 44 | self._hists[label] = self._mkhists(period) 45 | 46 | def timelinegen(self, key): 47 | for x in self._stats: 48 | if x[key]: 49 | yield (int(x['queried'].timestamp() * 1000), x[key]) 50 | 51 | def _mkhists(self, period): 52 | hists = {} 53 | hash_ids = set() 54 | txnq = self.dbsession.query(Txn) \ 55 | .filter(Txn.queried > self.when - timedelta(seconds=period)) \ 56 | .order_by(Txn.queried) 57 | 58 | if txnq.count() == 0: 59 | return hists 60 | txns = txnq.all() 61 | 62 | stats = self.dbsession.query( 63 | func.min(Txn.fee).label('minfee'), 64 | func.max(Txn.fee).label('maxfee'), 65 | func.min(Txn.size).label('minsize'), 66 | func.max(Txn.size).label('maxsize'), 67 | func.min(Txn.inputs).label('mininputs'), 68 | func.max(Txn.inputs).label('maxinputs'), 69 | func.min(Txn.outputs).label('minoutputs'), 70 | func.max(Txn.outputs).label('maxoutputs'), 71 | func.min(Txn.ring).label('minring'), 72 | func.max(Txn.ring).label('maxring')) \ 73 | .filter(Txn.queried > self.when - timedelta(seconds=period)).one() 74 | 75 | hists['ring'] = self._mkhist_int( 76 | txns, 'ring', range(stats.minring, stats.maxring + 1)) 77 | hists['inputs'] = self._mkhist_int( 78 | txns, 'inputs', range(stats.mininputs, stats.maxinputs + 1)) 79 | hists['outputs'] = self._mkhist_int( 80 | txns, 'outputs', range(stats.minoutputs, stats.maxoutputs + 1)) 81 | hists['fee'] = self._mkhist_range( 82 | txns, 'fee', stats.minfee, stats.maxfee, 20) 83 | hists['size'] = self._mkhist_range( 84 | txns, 'size', stats.minsize, stats.maxsize, 20) 85 | return hists 86 | 87 | def _mkhist_int(self, txns, key, bins): 88 | buckets = { k: 0 for k in bins } 89 | for txn in txns: 90 | buckets[getattr(txn, key)] += 1 91 | return sorted(buckets.items(), key=operator.itemgetter(0)) 92 | 93 | def _mkhist_range(self, txns, key, minval, maxval, bins): 94 | step = (maxval - minval) / bins 95 | if step == 0: 96 | return {'min': minval, 'max': maxval, 'step': step, 'data': []} 97 | buckets = [0] * bins 98 | for txn in txns: 99 | val = getattr(txn,key) 100 | # int() rounds down 101 | b = int((val - minval) / step) 102 | buckets[min(b, bins-1)] += 1 103 | return {'min': minval, 'max': maxval, 'step': step, 'data': buckets} 104 | -------------------------------------------------------------------------------- /atendo/txndb.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, DateTime, Numeric, String, create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | Base = declarative_base() 6 | 7 | class Txn(Base): 8 | __tablename__ = 'txn' 9 | hash_id = Column(String(64), nullable=False, primary_key=True) 10 | queried = Column(DateTime(timezone=False), nullable=False, index=True) 11 | received = Column(DateTime(timezone=False), nullable=False, index=True) 12 | fee = Column(Numeric(precision=24, scale=12), nullable=False) 13 | size = Column(Integer, nullable=False) 14 | inputs = Column(Integer, nullable=False) 15 | outputs = Column(Integer, nullable=False) 16 | ring = Column(Integer, nullable=False) 17 | version = Column(Integer, nullable=False) 18 | 19 | 20 | class TxnStat(Base): 21 | __tablename__ = 'txnstat' 22 | queried = Column(DateTime(timezone=False), primary_key=True) 23 | txns = Column(Integer, nullable=False) 24 | sumfee = Column(Numeric(precision=24, scale=12), nullable=False) 25 | sumsize = Column(Integer, nullable=False) 26 | avgsize = Column(Integer, nullable=False) 27 | avgfee = Column(Numeric(precision=24, scale=12), nullable=True) 28 | avgfeeperkb = Column(Numeric(precision=24, scale=12), nullable=True) 29 | maxage = Column(Numeric(precision=24, scale=12), nullable=False) 30 | 31 | 32 | def create_tables(url): 33 | engine = create_engine(url) 34 | Base.metadata.create_all(engine) 35 | 36 | 37 | def mksession(url): 38 | engine = create_engine(url) 39 | Base.metadata.bind = engine 40 | DBSession = sessionmaker(bind=engine) 41 | return DBSession() 42 | -------------------------------------------------------------------------------- /atendo/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | def get_config_or_usage(usage=None): 5 | usage = usage or "Usage: {0} \n\n" 6 | try: 7 | return json.loads(open(sys.argv[1], 'r').read()) 8 | except Exception: 9 | print(usage.format(*sys.argv), file=sys.stderr) 10 | raise 11 | -------------------------------------------------------------------------------- /examples/atendo.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMR mempool data fetch 3 | 4 | [Service] 5 | Type=simple 6 | WorkingDirectory=/srv/xmr-atendo 7 | ExecStart=/srv/xmr-atendo/getmempool.py /srv/xmr-atendo/config.json 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /exportjson.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import logging 3 | import os 4 | import re 5 | import simplejson 6 | import sys 7 | import tempfile 8 | from atendo.txndb import mksession 9 | from atendo.query import Query 10 | from atendo.utils import get_config_or_usage 11 | 12 | usage = "Usage: {0} [timelines|histograms|all]\n\n" \ 13 | "\t\"all\" is default\n\n" 14 | config = get_config_or_usage(usage) 15 | what = sys.argv[2] if len(sys.argv) > 2 else 'all' 16 | if what not in ('timelines', 'histograms', 'all'): 17 | print(usage.format(*sys.argv), file=sys.stderr) 18 | sys.exit(-1) 19 | log = logging.getLogger(sys.argv[0]) 20 | 21 | def write_json(label, data): 22 | tmpfd, tmpname = tempfile.mkstemp( 23 | prefix="{0}-".format(label), suffix='.tmp.json', dir=config['output_dir']) 24 | tmpout = os.fdopen(tmpfd, 'w') 25 | simplejson.dump(data, tmpout, iterable_as_array=True) 26 | tmpout.close() 27 | os.chmod(tmpname, 0o644) 28 | os.rename(tmpname, os.path.join(config['output_dir'], "{0}.json".format(label))) 29 | 30 | 31 | s = mksession(config['db_url']) 32 | try: 33 | periods = config['exportjson']['periods'] 34 | except KeyError: 35 | periods = { 36 | '1h': 60 * 60, 37 | '1d': 60 * 60 * 24, 38 | '1w': 60 * 60 * 24 * 7 39 | } 40 | q = Query(s, periods, maxtimeline='1d') 41 | if what in ('timelines', 'all'): 42 | for prop in ('txns', 'sumfee', 'sumsize', 'avgsize', 'avgfee', 'avgfeeperkb'): 43 | name = "timeline-{0}".format(prop) 44 | try: 45 | write_json(name, q.get_timeline(prop)) 46 | except Exception: 47 | log.exception("Failed to generate {0}".format(name)) 48 | if what in ('histograms', 'all'): 49 | for period in periods.keys(): 50 | for prop in ('inputs', 'outputs', 'ring', 'fee', 'size'): 51 | name = "hist-{0}-{1}".format(prop, period) 52 | try: 53 | write_json(name, q.get_hists(prop, period)) 54 | except Exception: 55 | log.exception("Failed to generate {0}".format(name)) 56 | -------------------------------------------------------------------------------- /getmempool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import logging 3 | import sched 4 | import sys 5 | import time 6 | from atendo import Atendo 7 | from atendo.utils import get_config_or_usage 8 | 9 | config = get_config_or_usage() 10 | logging.basicConfig(level=config.get('log_level', 'INFO')) 11 | log = logging.getLogger(sys.argv[0]) 12 | try: 13 | loop = config['loop_fetch']['seconds'] 14 | except KeyError: 15 | loop = None 16 | 17 | atendo = Atendo( 18 | daemon_host=config.get('daemon_host', None), 19 | daemon_port=config.get('daemon_port', None), 20 | db_url=config.get('db_url', None), 21 | timeout=config.get('timeout', 20) 22 | ) 23 | 24 | if loop: 25 | def fetch(): 26 | log.debug("fetching data") 27 | s.enter(loop, 1, fetch) 28 | try: 29 | atendo.fetch() 30 | except Exception: 31 | log.exception("Fetch failed") 32 | 33 | log.info("Will fetch data every {0} second(s)".format(loop)) 34 | s = sched.scheduler(time.time, time.sleep) 35 | s.enter(loop, 1, fetch) 36 | s.run() 37 | else: 38 | # run only once 39 | atendo.fetch() 40 | -------------------------------------------------------------------------------- /mkdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import json 3 | import sys 4 | from atendo.txndb import create_tables 5 | 6 | try: 7 | config = json.loads(open(sys.argv[1], 'r').read()) 8 | except Exception: 9 | print("Usage: {0} ".format(*sys.argv), file=sys.stderr) 10 | raise 11 | 12 | create_tables(config['db_url']) 13 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | XMR mempool 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |

15 | Monero mempool | 16 | 17 |

18 |

19 | Transactions sent from wallets enter mempool where they await to be signed into a new block. 20 | A new block is being mined every 2 minutes on average and its creation is visible on the graphs 21 | as rapid drop in the mempool size. 22 |

23 |

24 | Each transaction has a fee, which depends on the priority of the transaction set by its sender. 25 | The fee is an incentive for miners to include transactions in freshly mined blocks. The miner 26 | who does it, gets the fees. 27 |

28 |

29 | All times in UTC | 30 | ɱ = Monero | 31 | 1 mɱ = 0.001 ɱ | 32 | 1 µɱ = 0.000001 ɱ 33 |

34 |
35 |
36 |
37 |
38 |

Timelines

39 |
40 |
41 |
42 | 46 |
47 |
48 | 52 |
53 |
54 | 58 |
59 |
60 | 64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
Number of transactions in the mempool
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
Total size and fee of transactions in the mempool
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
Average size and fee of transaction
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
Average fee per kB
97 |
98 |
99 |
100 |
101 |
102 |
103 |

Inputs/outputs

104 |
105 |
106 |
107 | 111 |
112 |
113 | 117 |
118 |
119 | 123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
Number of inputs
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
Number of outputs
140 |
141 |
142 |
143 |
144 | 154 |
155 | 169 |
170 |
171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 187 | 188 |
189 | 190 | 191 | -------------------------------------------------------------------------------- /web/memplots.js: -------------------------------------------------------------------------------- 1 | var timelineSpan; 2 | var histogramSpan; 3 | var colors = ['#ff6600', '#4c4c4c', '#0062a3']; 4 | var labelWidth = 48; 5 | var resizableParams = { 6 | minWidth: 300, 7 | maxWidth: 1200, 8 | minHeight: 300, 9 | maxHeight: 400 10 | }; 11 | var tooltip; 12 | 13 | function drawTimeline(el, graphs, options) { 14 | var tooltipTimeout = null; 15 | 16 | el.parent().resizable(resizableParams); 17 | el.bind('plothover', function onPlotHover(evt, pos, item) { 18 | var date; 19 | var tstamp; 20 | var vals = []; 21 | var yaxis; 22 | var gr; 23 | var dat; 24 | var val; 25 | var xpos; 26 | 27 | for (var i = 0; i < graphs.length; i++) { 28 | gr = graphs[i]; 29 | dat = gr.dataset.data; 30 | yaxis = options.yaxes[(gr.yaxis || 1) - 1]; 31 | val = null; 32 | for (var j = 0; j < dat.length; j++) { 33 | if (dat[j][0] >= pos.x) { 34 | tstamp = tstamp || dat[j][0]; 35 | val = dat[j][1]; 36 | break; 37 | } 38 | } 39 | if (val != null) { 40 | vals.push(yaxis.tickFormatter ? yaxis.tickFormatter(dat[j][1], yaxis) : dat[j][1]); 41 | } else { 42 | vals.push('—'); 43 | } 44 | } 45 | if (tstamp) { 46 | date = new Date(tstamp).toUTCString(); 47 | xpos = $(document).width() - pos.pageX > 150 ? pos.pageX + 5 : pos.pageX - 200; 48 | tooltip.html('' + vals.join('/') + '' + date + '') 49 | .css({ 50 | top: pos.pageY - 65, 51 | left: xpos 52 | }); 53 | if (tooltipTimeout) clearTimeout(tooltipTimeout); 54 | tooltip.show(); 55 | tooltipTimeout = setTimeout(function() { tooltip.hide(); }, 2000); 56 | } else { 57 | tooltip.hide(); 58 | } 59 | }); 60 | 61 | plotLoop(); 62 | 63 | $('#timeline-span input[name="timeline-span"]').bind('change', function onTimespanChange() { 64 | var tsp = getTimelineSpan($(this).val()); 65 | timelineSpan = tsp[0]; 66 | fetchAndPlot(); 67 | }); 68 | 69 | function plotLoop() { 70 | fetchAndPlot(); 71 | setTimeout(plotLoop, 60000); 72 | } 73 | 74 | function fetchAndPlot() { 75 | var promises = []; 76 | var p; 77 | 78 | _.forEach(graphs, function(gr) { 79 | p = $.ajax({ 80 | url: gr.url, 81 | success: function onDataRcv(result) { 82 | gr.dataset = { 83 | label: gr.label, 84 | index: gr.index, 85 | yaxis: gr.yaxis, 86 | data: result.data 87 | }; 88 | } 89 | }); 90 | promises.push(p); 91 | }); 92 | 93 | $.when.apply($, promises).done(function onAllDataRcv() { 94 | var datasets = []; 95 | var now = new Date(); 96 | _.forEach(graphs, function(gr) { 97 | gr.dataset.data = _.dropWhile(gr.dataset.data, function inSpan(item) { return item[0] < now - timelineSpan; }); 98 | options.xaxis.minTickSize[0] = timelineSpan / (1000 * 60 * 60) / ($(document).width() > 500 ? 6 : 4); 99 | datasets.push(gr.dataset); 100 | }); 101 | datasets.sort(cmpIndex); 102 | el.plot(datasets, options); 103 | }); 104 | } 105 | } 106 | 107 | function drawHistogram(rootUrl, dataName, tooltipFormatter) { 108 | var el = $('#' + dataName); 109 | var options = { 110 | xaxis: { 111 | ticks: genPo2, 112 | tickFormatter: function toInt(v) { return v.toFixed(0); } 113 | }, 114 | yaxis: { 115 | ticks: genPo2, 116 | transform: function safeLog(v) { return v == 0 ? 0 : (Math.log2(v) + 1); }, 117 | labelWidth: labelWidth, 118 | reserveSpace: true 119 | }, 120 | grid: { 121 | hoverable: true 122 | } 123 | }; 124 | el.parent().resizable(resizableParams); 125 | el.bind('plothover', function onPlotHover(evt, pos, item) { 126 | var xpos; 127 | if (!item) { 128 | tooltip.hide(); 129 | return; 130 | } 131 | xpos = $(document).width() - item.pageX > 100 ? item.pageX + 5 : item.pageX - 150; 132 | tooltip.html('' + tooltipFormatter(item.datapoint) + '') 133 | .css({ 134 | top: item.pageY - 55, 135 | left: xpos 136 | }); 137 | tooltip.show(); 138 | }); 139 | 140 | plotLoop(); 141 | 142 | $('#histogram-span input[name="histogram-span"]').bind('change', function onHistorgramChange() { 143 | histogramSpan = getHistogramSpan($(this).val()); 144 | fetchAndPlot(); 145 | }); 146 | 147 | function plotLoop() { 148 | fetchAndPlot(); 149 | setTimeout(plotLoop, 60000); 150 | } 151 | 152 | function fetchAndPlot() { 153 | $.ajax({ 154 | url: rootUrl + 'hist-' + dataName + '-' + histogramSpan + '.json', 155 | success: function onDataRcv(result) { 156 | el.plot([{ 157 | data: result, 158 | color: '#0062a3', 159 | bars: { show: true, lineWidth: 0, align: 'center' } 160 | }], options); 161 | } 162 | }); 163 | } 164 | 165 | function genPo2(axis) { 166 | var ticks = []; 167 | var thr = axis.direction == 'x' ? axis.max / $(document).width() * 32 : 0; 168 | var p = 1; 169 | if (axis.min < 1) ticks.push(0); 170 | while (p <= axis.max) { 171 | if (p > thr) ticks.push(p); 172 | p = 2 * p; 173 | } 174 | return ticks; 175 | } 176 | } 177 | 178 | function drawTimelines(rootUrl) { 179 | var xAxisOptions = { 180 | mode: 'time', 181 | minTickSize: [$(document).width() > 500 ? 2 : 6, 'hour'] 182 | }; 183 | var twoAxisOpts = { 184 | colors: colors, 185 | grid: { hoverable: true, autoHighlight: false }, 186 | crosshair: { mode: 'x', color: '#0062a3', lineWidth: 1 }, 187 | xaxis: xAxisOptions, 188 | yaxes: [ 189 | { 190 | min: 0, 191 | reserveSpace: true, 192 | labelWidth: labelWidth, 193 | tickFormatter: kBFormatter 194 | }, { 195 | min: 0, 196 | position: 'right', 197 | reserveSpace: true, 198 | labelWidth: labelWidth, 199 | tickFormatter: xmrFormatter 200 | } 201 | ], 202 | legend: { position: 'nw' } 203 | }; 204 | var tsp = getTimelineSpan(); 205 | timelineSpan = tsp[0]; 206 | $('#timeline-span input[name="timeline-span"][value="' + tsp[1] + '"]').attr('checked', true); 207 | 208 | drawTimeline( 209 | $('#txns'), 210 | [ { url: rootUrl + 'timeline-txns.json' } ], 211 | { colors: colors, 212 | grid: { hoverable: true, autoHighlight: false }, 213 | crosshair: { mode: 'x', color: '#0062a3', lineWidth: 1 }, 214 | xaxis: xAxisOptions, 215 | yaxes: [ 216 | { reserveSpace: true, 217 | labelWidth: labelWidth }, 218 | { position: 'right', 219 | reserveSpace: true, 220 | labelWidth: labelWidth }]}); 221 | 222 | drawTimeline( 223 | $('#totals'), 224 | [ { url: rootUrl + 'timeline-sumsize.json', 225 | label: 'Mempool size', 226 | index: 10 }, 227 | { url: rootUrl + 'timeline-sumfee.json', 228 | label: 'Total fees', 229 | yaxis: 2, 230 | index: 20 } ], 231 | twoAxisOpts); 232 | 233 | drawTimeline( 234 | $('#averages'), 235 | [ { url: rootUrl + 'timeline-avgsize.json', 236 | label: 'Avg txn size', 237 | index: 10 }, 238 | { url: rootUrl + 'timeline-avgfee.json', 239 | label: 'Avg txn fee', 240 | yaxis: 2, 241 | index: 20 } ], 242 | twoAxisOpts); 243 | 244 | drawTimeline( 245 | $('#perkb'), 246 | [ { url: rootUrl + 'timeline-avgfeeperkb.json', 247 | label: 'Avg fee per kB', 248 | yaxis: 2, 249 | index: 10 }], 250 | { colors: colors, 251 | grid: { hoverable: true, autoHighlight: false }, 252 | crosshair: { mode: 'x', color: '#0062a3', lineWidth: 1 }, 253 | xaxis: xAxisOptions, 254 | yaxes: [ { 255 | reserveSpace: true, 256 | labelWidth: labelWidth }, 257 | { min: 0, 258 | position: 'right', 259 | labelWidth: labelWidth, 260 | reserveSpace: true, 261 | tickFormatter: xmrFormatter }], 262 | legend: { position: 'nw' }}); 263 | } 264 | 265 | function drawHistograms(rootUrl) { 266 | histogramSpan = getHistogramSpan(); 267 | $('#histogram-span input[name="histogram-span"][value="' + histogramSpan + '"]').attr('checked', true); 268 | 269 | drawHistogram( 270 | rootUrl, 271 | 'inputs', 272 | function (dpt) { 273 | return dpt[1] + ' txn' + (dpt[1] == 1 ? '' : 's') + 274 | ' with ' + dpt[0] + ' input' + (dpt[0] == 1 ? '' : 's'); 275 | } 276 | ); 277 | drawHistogram( 278 | rootUrl, 279 | 'outputs', 280 | function (dpt) { 281 | return dpt[1] + ' txn' + (dpt[1] == 1 ? '' : 's') + 282 | ' with ' + dpt[0] + ' output' + (dpt[0] == 1 ? '' : 's'); 283 | } 284 | ); 285 | drawHistogram( 286 | rootUrl, 287 | 'ring', 288 | function (dpt) { 289 | return dpt[1] + ' txn' + (dpt[1] == 1 ? '' : 's') + 290 | ' with ' + dpt[0] + ' ring size'; 291 | } 292 | ); 293 | } 294 | 295 | function drawPlots(rootUrl) { 296 | tooltip = $('#tooltip'); 297 | drawTimelines(rootUrl); 298 | drawHistograms(rootUrl); 299 | } 300 | 301 | // functions 302 | function xmrFormatter(v) { 303 | if (Math.abs(v) < 0.000000000001) return '0 ɱ'; 304 | if (Math.abs(v) < 0.01) { 305 | if (Math.abs(v) < 0.001) { 306 | if (Math.abs(v) < 0.000001) { 307 | if (Math.abs(v) < 0.000000001) { 308 | return (v * 1000000000000).toFixed(1) + ' pɱ'; 309 | } 310 | return (v * 1000000000).toFixed(1) + ' nɱ'; 311 | } 312 | return (v * 1000000).toFixed(1) + ' µɱ'; 313 | } 314 | return (v * 1000).toFixed(1) + ' mɱ'; 315 | } 316 | return v.toFixed(2) + ' ɱ'; 317 | } 318 | 319 | function kBFormatter(v) { 320 | var kb = v / 1024.0; 321 | if (Math.abs(v) < 0.1) return '0 kB'; 322 | if (kb > 1024) return (kb / 1024.0).toFixed(1) + ' MB'; 323 | return kb.toFixed(kb >= 100 ? 0 : 1) + ' kB'; 324 | } 325 | 326 | function cmpIndex(a, b) { 327 | return (a.index - b.index) || 0; 328 | } 329 | 330 | function getTimelineSpan(val) { 331 | var result = /^(24|12|4|1)h$/.exec(val); 332 | var hrs = 4; 333 | if (result) hrs = parseInt(result[1]) || hrs; 334 | return [hrs * 60 * 60 * 1000, hrs + 'h']; 335 | } 336 | 337 | function getHistogramSpan(val) { 338 | var result = /^(1w|1d|1h)$/.exec(val); 339 | if (result) return result[1]; 340 | return '1d'; 341 | } 342 | -------------------------------------------------------------------------------- /web/qrcode-mempool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emesik/xmr-atendo/a99b718d10dcf5b3c7832590021c8b992f81e284/web/qrcode-mempool.png -------------------------------------------------------------------------------- /web/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | color: #2f1300; 3 | font-face: sans-serif; 4 | font-size: 12pt; 5 | } 6 | body { 7 | background-color: #fffdf9; 8 | } 9 | fieldset { 10 | margin-top: 20px; 11 | } 12 | figcaption.figure-caption { 13 | color: #4c4c4c; 14 | } 15 | .align-to-label { 16 | margin-left: 54px; 17 | } 18 | .plot-wrapper { 19 | margin-bottom: 10px; 20 | } 21 | .plot { 22 | height: 400px; 23 | width: 100%; 24 | } 25 | #tooltip { 26 | position: absolute; 27 | display: none; 28 | border: 1px solid #ff6600; 29 | border-radius: 4px; 30 | padding: 2px 6px; 31 | background-color: #fffdf9; 32 | box-shadow: 4px 4px 10px #4c4c4c; 33 | opacity: 0.9; 34 | } 35 | #tooltip .date { 36 | display: block; 37 | font-size: 8pt; 38 | } 39 | #tooltip .value { 40 | display: block; 41 | } 42 | .xmraddr { 43 | font-size: 8pt; 44 | font-family: monospace; 45 | overflow-wrap: break-word; 46 | word-wrap: break-word; 47 | word-break: break-word; 48 | color: #0062a3; 49 | } 50 | .logo .xmr { 51 | color: #ff6600; 52 | } 53 | .logo .lab { 54 | color: #4c4c4c; 55 | } 56 | img.qr { 57 | width: 200px; 58 | height: 200px; 59 | } 60 | img.qr.small { 61 | width: 120px; 62 | height: 120px; 63 | } 64 | #footer { 65 | margin-top: 60px; 66 | padding-bottom: 30px; 67 | } 68 | #footer img { 69 | margin-right: 20px; 70 | margin-bottom: 20px; 71 | } 72 | .w100 { 73 | width: 100%; 74 | } 75 | .pt10 { 76 | padding-top: 10px; 77 | } 78 | -------------------------------------------------------------------------------- /web/styles.less: -------------------------------------------------------------------------------- 1 | @glow: rgba(255, 102, 0, 1); 2 | @afterglow: rgba(76, 76, 76, 1); 3 | @link: rgba(0, 98, 163, 1); 4 | @text: rgba(47, 19, 0, 1); 5 | @textbg: rgba(255, 253, 249, 1); 6 | html { 7 | color: @text; 8 | font-face: sans-serif; 9 | font-size: 12pt; 10 | } 11 | body { 12 | background-color: @textbg; 13 | } 14 | fieldset { 15 | margin-top: 20px; 16 | } 17 | figcaption.figure-caption { 18 | color: @afterglow; 19 | } 20 | .align-to-label { 21 | margin-left: 54px; 22 | } 23 | .plot-wrapper { 24 | margin-bottom: 10px; 25 | } 26 | .plot { 27 | height: 400px; 28 | width: 100%; 29 | } 30 | #tooltip { 31 | position: absolute; 32 | display: none; 33 | border: 1px solid @glow; 34 | border-radius: 4px; 35 | padding: 2px 6px; 36 | background-color: @textbg; 37 | box-shadow: 4px 4px 10px @afterglow; 38 | opacity: 0.9; 39 | .date { 40 | display: block; 41 | font-size: 8pt; 42 | } 43 | .value { 44 | display: block; 45 | } 46 | } 47 | .xmraddr { 48 | font-size: 8pt; 49 | font-family: monospace; 50 | overflow-wrap: break-word; 51 | word-wrap: break-word; 52 | word-break: break-word; 53 | color: @link; 54 | } 55 | .logo { 56 | .xmr { 57 | color: @glow; 58 | } 59 | .lab { 60 | color: @afterglow; 61 | } 62 | } 63 | img.qr { 64 | width: 200px; 65 | height: 200px; 66 | &.small { 67 | width: 120px; 68 | height: 120px; 69 | } 70 | } 71 | #footer { 72 | margin-top: 60px; 73 | padding-bottom: 30px; 74 | img { 75 | margin-right: 20px; 76 | margin-bottom: 20px; 77 | } 78 | } 79 | .w100 { width: 100%; } 80 | .pt10 { padding-top: 10px; } 81 | --------------------------------------------------------------------------------