#!/usr/bin/env python3
"""Build and publish the public, static Strata playground independently of Stream."""
from pathlib import Path
import datetime
import gzip
import json
import subprocess
import tempfile
import hashlib
import argparse
import shutil
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'strata'
HOST = 'root@89.167.122.160'
SSH = ['-i', str(Path.home() / '.ssh/waow_ed25519'), '-o', 'IdentitiesOnly=yes']
def fetch(url, raw=False):
body = subprocess.check_output(['curl', '--fail', '--silent', '--show-error', '--max-time', '45', url])
return body.decode() if raw else json.loads(body)
parser = argparse.ArgumentParser()
parser.add_argument('--output-dir', type=Path, help='Build locally without publishing')
parser.add_argument('--overview', type=Path, help='Reuse a previously fetched overview for a local build')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='strata-preview-') as tmp:
out = Path(tmp) / datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
out.mkdir()
for name in ['index.html', 'style.css', 'app.js', 'sphere-map.js', 'storage-view.js', 'storage-parser.js', 'profile-parser.js', 'map-parser.js', 'd3.min.js', 'd3.LICENSE']:
(out / name).write_bytes((SOURCE / name).read_bytes())
index = (out / 'index.html').read_text().replace('strata ยท local preview', 'strata')
(out / 'index.html').write_text(index)
app = (out / 'app.js').read_text().replace("json('/api/overview')", "json('overview.json')").replace('Static preview snapshot.', 'Static summary.').replace('This preview opens', 'This map opens')
(out / 'app.js').write_text(app)
overview = json.loads(args.overview.read_text()) if args.overview else {
'mode': 'static-preview',
'progress': fetch('https://strata.zat.dev/api/progress'),
'collections': fetch('https://strata.zat.dev/api/collections')['collections'],
'buckets': fetch('https://strata.zat.dev/api/heat?step=100&top=1')['buckets'],
'status': fetch('https://stream.waow.tech/status', raw=True),
'fetchedAt': int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000),
'source': 'https://stream.waow.tech',
'summarySource': 'https://strata.zat.dev',
'identityProfiles': [entry['profile'] for entry in json.loads((SOURCE / 'identity-catalog.json').read_text()).values() if entry.get('profile')],
'storage': json.loads((SOURCE / 'storage-report.json').read_text()),
}
assert overview['collections'] and overview['buckets'] and overview['progress']['maxSeq'] > 0
# The opening map needs source totals, not every individual record type.
groups = {}
totals = {}
for row in overview['collections']:
ns = row['ns']
assert row['nsid'] == ns or row['nsid'].startswith(ns + '.')
groups.setdefault(ns, []).append([row['nsid'][len(ns) + 1:], row['count']])
totals[ns] = totals.get(ns, 0) + row['count']
assets = out / 'assets'
assets.mkdir()
def asset(name, body):
path = assets / (hashlib.sha256(body).hexdigest()[:16] + '.' + name)
path.write_bytes(body)
return 'assets/' + path.name
details_url = asset('json', json.dumps([{'ns': ns, 'records': [{'suffix': suffix, 'count': count} for suffix, count in rows]} for ns, rows in groups.items()], separators=(',', ':')).encode())
core = {k: v for k, v in overview.items() if k not in ('collections', 'buckets')}
core.update(collections=[], collectionsUrl=details_url,
recordTypeCount=len(overview['collections']),
namespaces=[{'key': ns, 'count': count} for ns, count in sorted(totals.items(), key=lambda pair: -pair[1])])
# One script download instead of a serial module graph. `bun install` in strata/ first.
(out / 'entry.js').write_text("import d3 from './d3.min.js'; globalThis.d3=d3; import('./app.js');")
subprocess.run([str(SOURCE / 'node_modules/.bin/esbuild'), str(out / 'entry.js'),
'--bundle', '--minify', '--format=iife', '--target=es2022',
'--outfile=' + str(out / 'bundle.js')], check=True)
script_url = asset('js', (out / 'bundle.js').read_bytes())
style_url = asset('css', (out / 'style.css').read_bytes())
index = index.replace('href="style.css"', 'href="' + style_url + '"')
# Escape HTML-significant characters in externally sourced profile text.
embedded = json.dumps(core, separators=(',', ':')).replace('<', '\\u003c')
index = index.replace('',
'')
(out / 'index.html').write_text(index)
for name in ['entry.js', 'bundle.js', 'bundle.js.LEGAL.txt']:
(out / name).unlink(missing_ok=True)
# Keep old filenames for pages already open during the rollout.
(out / 'overview.json').write_text(json.dumps(overview, separators=(',', ':')))
for asset in list(out.rglob("*")):
if asset.suffix in ('.html', '.js', '.css', '.json'):
asset.with_name(asset.name + '.gz').write_bytes(gzip.compress(asset.read_bytes(), mtime=0))
print(f'Opening page: {len((out / "index.html.gz").read_bytes()):,} gzip bytes; record types load on demand.')
if args.output_dir:
shutil.copytree(out, args.output_dir, dirs_exist_ok=True)
raise SystemExit(0)
remote = '/opt/stream-experiment/site/.strata-preview-releases'
subprocess.run(['ssh', *SSH, HOST, f'mkdir -p {remote}'], check=True)
subprocess.run(['scp', '-q', *SSH, '-r', str(out), f'{HOST}:{remote}/'], check=True)
# Retain immutable assets for cached pages from previous releases.
subprocess.run(['ssh', *SSH, HOST, f'if [ -d /opt/stream-experiment/site/strata/assets ]; then cp -an /opt/stream-experiment/site/strata/assets/. {remote}/{out.name}/assets/; fi'], check=True)
# Atomic pointer switch; retain earlier releases for rollback.
subprocess.run(['ssh', *SSH, HOST, f'cd /opt/stream-experiment/site && ln -s .strata-preview-releases/{out.name} .strata-next && mv -Tf .strata-next strata'], check=True)
print(f'Published {out.name}: https://stream.waow.tech/strata/')
print(f'Snapshot: {len(overview["collections"])} record types; no runtime archive queries.')