348d17775ccde5f178d2b87915f16beda576f8fa hiram Wed May 27 15:29:13 2026 -0700 add toggle buttons to hide/show the requestType assembly or liftRequest and allow viewing either hgwdev or the RR table refs #31811 diff --git src/hg/utils/otto/userRequests/ottoRequestView.cgi src/hg/utils/otto/userRequests/ottoRequestView.cgi index dc7d51122da..9f15674425b 100644 --- src/hg/utils/otto/userRequests/ottoRequestView.cgi +++ src/hg/utils/otto/userRequests/ottoRequestView.cgi @@ -8,35 +8,38 @@ Any other REMOTE_ADDR gets a 403. """ import cgi import html import json import os import re import subprocess import sys import time import urllib.parse from datetime import datetime ALLOWED_IP = '128.114.198.5' -HGDB_CONF = '/usr/local/apache/cgi-bin/hg.conf' TRASH = '/data/apache/trash' -DB = 'hgcentraltest' TABLE = 'ottoRequest' +# Configuration will be set dynamically in main() +HGDB_CONF = None +DB = None +USE_PROFILE = None + # Galaxy queue status panel - snapshot is refreshed by ottoRequestWatch.sh # (cron, every 11 minutes), CGI just reads it. CACHE_PATH = '/data/apache/trash/ottoRequestGalaxyStatus.json' CACHE_TTL = 1800 # seconds; older than this -> show "stale" instead # featureBits coverage snapshot - append-only file maintained by # featureBitsSnapshot.py (cron, via ottoRequestWatch.sh). fb.*.txt # values are immutable once an alignment completes so no TTL is needed; # featureBitsPct() falls back to an NFS read on a snapshot miss. FB_SNAPSHOT_PATH = '/data/apache/trash/ottoRequestFeatureBitsPct.json' # from README.txt in this directory STATUS_NAMES = { 0: 'received by API', 1: 'acknowledged, email sent', @@ -64,56 +67,72 @@ def forbidden(msg): sys.stdout.write("Status: 403 Forbidden\r\n") sys.stdout.write("Content-Type: text/plain; charset=utf-8\r\n\r\n") sys.stdout.write(msg + "\n") sys.exit(0) def checkIp(): remote = os.environ.get('REMOTE_ADDR', '') if remote != ALLOWED_IP: forbidden(f"Access denied for {remote!r}; this page is restricted.") +def setDbConfig(use_otto=False): + """Set database configuration globals based on config parameter.""" + global HGDB_CONF, DB, USE_PROFILE + if use_otto: + HGDB_CONF = '/data/apache/cgi-bin/otto/.otto.conf' + DB = 'hgcentral' + USE_PROFILE = False + else: + HGDB_CONF = '/usr/local/apache/cgi-bin/hg.conf' + DB = 'hgcentraltest' + USE_PROFILE = True + + def unescapeMysql(s): """Reverse `hgsql -B` escaping (\\n, \\t, \\\\, \\0). One pass so \\\\n stays a literal backslash + 'n'.""" out, i, n = [], 0, len(s) while i < n: if s[i] == '\\' and i + 1 < n: c = s[i+1] if c == 'n': out.append('\n') elif c == 't': out.append('\t') elif c == '\\': out.append('\\') elif c == '0': out.append('\0') else: out.append(s[i:i+2]) i += 2 else: out.append(s[i]); i += 1 return ''.join(out) def hgsqlRun(sql): """Run sql via hgsql against DB. Returns (ok, stdout, stderr). Running under Apache the process has no ~/.hg.conf, so point hgsql at the cgi-bin hg.conf via HGDB_CONF.""" env = dict(os.environ) env['HGDB_CONF'] = HGDB_CONF env['HOME'] = TRASH - cmd = ['/cluster/bin/x86_64/hgsql', '-profile=central', DB, '-N', '-B', '-e', sql] + cmd = ['/cluster/bin/x86_64/hgsql'] + if USE_PROFILE: + cmd.append('-profile=central') + cmd.extend([DB, '-N', '-B', '-e', sql]) r = subprocess.run(cmd, capture_output=True, text=True, env=env) return (r.returncode == 0, r.stdout, r.stderr) def fetchRows(): sql = f"SELECT {','.join(COLS)} FROM {TABLE} ORDER BY id DESC" ok, out, err = hgsqlRun(sql) if not ok: raise RuntimeError(err.strip() or 'hgsql failed') rows = [] if out.strip(): for line in out.rstrip('\n').split('\n'): rows.append([unescapeMysql(f) for f in line.split('\t')]) return rows @@ -263,110 +282,139 @@ def loadGalaxyStatus(): """Return the Galaxy queue snapshot written by ottoRequestWatch.sh (which calls galaxyStatus.py from cron). Returns the parsed dict with an added 'stale' flag when the file is older than CACHE_TTL, or None if the file is missing/unreadable.""" try: mtime = os.path.getmtime(CACHE_PATH) with open(CACHE_PATH) as f: data = json.load(f) except (OSError, ValueError): return None data['stale'] = (time.time() - mtime) > CACHE_TTL return data -def renderPage(rows, info=None, error=None, galaxyStatus=None): +def renderPage(rows, info=None, error=None, galaxyStatus=None, use_otto=False): sys.stdout.write("Content-Type: text/html; charset=utf-8\r\n\r\n") out = sys.stdout.write + db_label = 'RR' if use_otto else 'hgwdev' out('<!DOCTYPE html>\n<html><head><meta charset="utf-8">\n') - out(f'<title>{TABLE}</title>\n') + out(f'<title>{TABLE} ({db_label})</title>\n') out('<style>\n' 'body{font-family:sans-serif;margin:1em;font-size:13px}\n' 'h2{margin:.2em 0}\n' 'table{border-collapse:collapse;width:100%;margin-top:.5em}\n' 'th,td{border:1px solid #ccc;padding:3px 6px;vertical-align:top;' 'font-size:12px}\n' 'th{background:#eee;text-align:left;position:sticky;top:0}\n' 'tr:nth-child(even){background:#f8f8f8}\n' 'td.comment{max-width:28em;white-space:pre-wrap;' 'word-break:break-word}\n' 'tr.s7 td{background:#ffe0e0}\n' 'tr.s8 td{background:#e0f0e0;color:#555}\n' 'select,button{font-size:12px}\n' '.banner{padding:.5em;margin:.4em 0;border-radius:4px}\n' '.info {background:#dfd;border:1px solid #5a5}\n' '.error{background:#fdd;border:1px solid #a55}\n' '.legend{font-size:15px;color:#333;margin:.4em 0}\n' '.legend code{background:#eee;padding:0 3px;font-size:14px}\n' '.refreshBtn{font-size:14px;padding:3px 10px;margin-left:6px;' 'cursor:pointer}\n' '.toggleBtn{font-size:14px;padding:3px 10px;margin-left:6px;' 'cursor:pointer;background:#f0f0f0;border:1px solid #ccc}\n' + '.configBtn{font-size:14px;padding:4px 12px;margin-left:6px;' + 'cursor:pointer;background:#e6f3ff;border:2px solid #0066cc;' + 'font-weight:bold;color:#0066cc}\n' '.hide-complete tr.s8{display:none}\n' + '.hide-assembly tr.assembly{display:none}\n' + '.hide-liftover tr.liftover{display:none}\n' '</style></head><body>\n') - out(f'<h2>{DB}.{TABLE}</h2>\n') + db_label = 'RR' if use_otto else 'hgwdev' + out(f'<h2>{DB}.{TABLE} ({db_label})</h2>\n') if galaxyStatus: staleNote = ' <b>[stale]</b>' if galaxyStatus.get('stale') else '' out('<div class="legend">Galaxy queue: ' f'<b>{galaxyStatus.get("running", "?")}</b> running · ' f'<b>{galaxyStatus.get("queued", "?")}</b> queued · ' f'<b>{galaxyStatus.get("new", "?")}</b> new ' f'<small>(as of {html.escape(galaxyStatus.get("ts", ""))})</small>' f'{staleNote}</div>\n') else: out('<div class="legend">Galaxy queue: ' '<i>status unavailable</i></div>\n') if info: out(f'<div class="banner info">{html.escape(info)}</div>\n') if error: out(f'<div class="banner error">{html.escape(error)}</div>\n') out('<div class="legend">status: ') out(' · '.join(f'<code>{k}</code>={html.escape(v)}' for k, v in STATUS_NAMES.items())) - # Count completed rows for the toggle button label + # Count rows by type for toggle button labels completed_count = sum(1 for r in rows if len(r) > 7 and r[7] == '8') + assembly_count = sum(1 for r in rows if len(r) > 1 and r[1] == 'assembly') + liftover_count = sum(1 for r in rows if len(r) > 1 and r[1] == 'liftOver') + # Config toggle button - switches between test and production databases + current_config = 'otto' if use_otto else 'test' + switch_config = 'test' if use_otto else 'otto' + switch_label = 'Switch to hgwdev' if use_otto else 'Switch to RR' + config_url = f'?config={switch_config}' + out(f' · <b>{len(rows)}</b> row(s)' + f'<a href="{config_url}" class="configBtn">{switch_label}</a>' '<button class="refreshBtn" type="button" ' 'onclick="location.reload()">refresh</button>' f'<button class="toggleBtn" type="button" id="toggleComplete" ' - f'onclick="toggleCompleted()">hide completed ({completed_count})</button></div>\n') + f'onclick="toggleCompleted()">hide completed ({completed_count})</button>' + f'<button class="toggleBtn" type="button" id="toggleAssembly" ' + f'onclick="toggleAssembly()">hide assembly ({assembly_count})</button>' + f'<button class="toggleBtn" type="button" id="toggleLiftover" ' + f'onclick="toggleLiftover()">hide liftOver ({liftover_count})</button></div>\n') out(f'<div class="legend">cron times: 9,20,31,42,53 for ottoRequestWatch.sh, and 4,26,46 for ottoRequestPush and 1,8,15,22,29,36,43,50,57 for the first acknowledgement</div>\n') out('<table class="sortable">\n<tr>') for c in COLS: out(f'<th>{c}</th>') out('<th title="% of fromDb covered by chains to toDb / ' '% of toDb covered by chains to fromDb">' 'coverage<br><small>from / to</small></th>' '<th>elapsed</th><th>set status</th></tr>\n') reqIdx = COLS.index('requestTime') doneIdx = COLS.index('completeTime') fromIdx = COLS.index('fromDb') toIdx = COLS.index('toDb') + typeIdx = COLS.index('requestType') for r in rows: rid = r[0] try: stnum = int(r[7]) except (ValueError, IndexError): stnum = -1 - cls = f's{stnum}' if stnum in (7, 8) else '' + cls_parts = [] + if stnum in (7, 8): + cls_parts.append(f's{stnum}') + # Add requestType class for toggle filtering + if typeIdx < len(r): + req_type = r[typeIdx].lower() + if req_type in ('assembly', 'liftover'): + cls_parts.append(req_type) + cls = ' '.join(cls_parts) out(f'<tr class="{cls}">') for i, c in enumerate(COLS): cell = r[i] if i < len(r) else '' if c == 'comment': out(f'<td class="comment">{html.escape(cell)}</td>') elif c == 'status': label = STATUS_NAMES.get(stnum, '?') out(f'<td><b>{html.escape(cell)}</b> ' f'<small>{html.escape(label)}</small></td>') elif c in ('fromDb', 'toDb') and cell: href = ('https://genome-test.gi.ucsc.edu/cgi-bin/hgTracks?db=' + urllib.parse.quote(cell, safe='')) out(f'<td><a href="{html.escape(href)}" target="_blank">' f'{html.escape(cell)}</a></td>') elif c == 'email' and '@' in cell: @@ -405,83 +453,127 @@ out('<script>\n' 'function toggleCompleted() {\n' ' var table = document.querySelector("table");\n' ' var btn = document.getElementById("toggleComplete");\n' ' var isHidden = table.classList.contains("hide-complete");\n' ' if (isHidden) {\n' ' table.classList.remove("hide-complete");\n' f' btn.textContent = "hide completed ({completed_count})";\n' ' localStorage.setItem("hideCompleted", "false");\n' ' } else {\n' ' table.classList.add("hide-complete");\n' ' btn.textContent = "show completed";\n' ' localStorage.setItem("hideCompleted", "true");\n' ' }\n' '}\n' - '// Restore toggle state from localStorage\n' + 'function toggleAssembly() {\n' + ' var table = document.querySelector("table");\n' + ' var btn = document.getElementById("toggleAssembly");\n' + ' var isHidden = table.classList.contains("hide-assembly");\n' + ' if (isHidden) {\n' + ' table.classList.remove("hide-assembly");\n' + f' btn.textContent = "hide assembly ({assembly_count})";\n' + ' localStorage.setItem("hideAssembly", "false");\n' + ' } else {\n' + ' table.classList.add("hide-assembly");\n' + ' btn.textContent = "show assembly";\n' + ' localStorage.setItem("hideAssembly", "true");\n' + ' }\n' + '}\n' + 'function toggleLiftover() {\n' + ' var table = document.querySelector("table");\n' + ' var btn = document.getElementById("toggleLiftover");\n' + ' var isHidden = table.classList.contains("hide-liftover");\n' + ' if (isHidden) {\n' + ' table.classList.remove("hide-liftover");\n' + f' btn.textContent = "hide liftOver ({liftover_count})";\n' + ' localStorage.setItem("hideLiftover", "false");\n' + ' } else {\n' + ' table.classList.add("hide-liftover");\n' + ' btn.textContent = "show liftOver";\n' + ' localStorage.setItem("hideLiftover", "true");\n' + ' }\n' + '}\n' + '// Restore toggle states from localStorage\n' 'window.addEventListener("load", function() {\n' - ' if (localStorage.getItem("hideCompleted") === "true") {\n' ' var table = document.querySelector("table");\n' + ' if (localStorage.getItem("hideCompleted") === "true") {\n' ' var btn = document.getElementById("toggleComplete");\n' ' table.classList.add("hide-complete");\n' ' btn.textContent = "show completed";\n' ' }\n' + ' if (localStorage.getItem("hideAssembly") === "true") {\n' + ' var btn = document.getElementById("toggleAssembly");\n' + ' table.classList.add("hide-assembly");\n' + ' btn.textContent = "show assembly";\n' + ' }\n' + ' if (localStorage.getItem("hideLiftover") === "true") {\n' + ' var btn = document.getElementById("toggleLiftover");\n' + ' table.classList.add("hide-liftover");\n' + ' btn.textContent = "show liftOver";\n' + ' }\n' '});\n' '</script>\n') out('</body></html>\n') def main(): # checkIp() + # Create FieldStorage once - it consumes stdin and can't be read twice + form = cgi.FieldStorage() + + # Detect configuration from URL parameter + use_otto = form.getfirst('config') == 'otto' + setDbConfig(use_otto) + # POST/Redirect/GET: handle the write, then 303 to a GET of the same URL # so a browser reload doesn't re-submit the form and re-run the UPDATE. if os.environ.get('REQUEST_METHOD', 'GET') == 'POST': - form = cgi.FieldStorage() action = form.getfirst('action', '') if action == 'resetStatus': info, error = doResetStatus(form) else: info, error = None, f"unknown action: {action!r}" params = {} + if use_otto: params['config'] = 'otto' # preserve config in redirect if info: params['info'] = info if error: params['error'] = error qs = ('?' + urllib.parse.urlencode(params)) if params else '' sys.stdout.write(f"Status: 303 See Other\r\n" f"Location: {os.environ.get('SCRIPT_NAME','')}{qs}" f"\r\n\r\n") return # GET: pick up banner messages left by the PRG redirect, if any - qs = cgi.FieldStorage() - info = qs.getfirst('info') or None - error = qs.getfirst('error') or None + info = form.getfirst('info') or None + error = form.getfirst('error') or None try: rows = fetchRows() except RuntimeError as e: rows = [] error = (error + ' / ' if error else '') + f"fetch failed: {e}" # one bulk lookup of GenArk asmNames so hubBuildDir() avoids NFS readdir fromIdx = COLS.index('fromDb') toIdx = COLS.index('toDb') gcAccs = set() for r in rows: for idx in (fromIdx, toIdx): if idx < len(r): v = r[idx] if v.startswith('GCA_') or v.startswith('GCF_'): gcAccs.add(v) loadGenarkNames(gcAccs) loadFeatureBitsSnapshot() galaxyStatus = loadGalaxyStatus() - renderPage(rows, info=info, error=error, galaxyStatus=galaxyStatus) + renderPage(rows, info=info, error=error, galaxyStatus=galaxyStatus, use_otto=use_otto) if __name__ == '__main__': try: main() except Exception as e: sys.stdout.write("Content-Type: text/plain; charset=utf-8\r\n\r\n") sys.stdout.write(f"ottoRequestView.cgi error: {e}\n")