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 &middot; '
             f'<b>{galaxyStatus.get("queued",  "?")}</b> queued &middot; '
             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(' &middot; '.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' &middot; <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")