1c52aaa92828b6ecc18dd5800fe6650b91daf696
max
  Fri Sep 18 07:22:10 2026 -0700
hgSession: the new Sessions page also lists the sessions saved on our other servers

#Preview2 week - bugs introduced now will need a build patch to fix
Every geo mirror node keeps its own namedSessionDb, so a session saved on
genome-euro is invisible on genome.ucsc.edu and people do not find it again.
The table now holds the sessions from every node, with a new Server column
saying where each one lives, and the session name links to the server that
holds it.

Two new hgSession actions, both answered in main() before the cart is built,
so that a request from another node leaves no cart, userDb or sessionDb row
behind:

hgS_doSessionListJson  returns this server's sessions for the user named by
the login cookie, as JSON.  This is what the other
nodes call.
hgS_doMirrorSessions   asks every other gbNode for that list and returns
what came back.  It sends all of the requests before
reading any of the answers, so the wait is the
slowest node rather than the sum of them.

The fetch goes through our own server rather than from the browser to the
other nodes, which keeps cross-origin requests out of it: apache handles the
CORS headers differently on every node, and the two mirrors are administered
by Bielefeld and RIKEN, while CGI code reaches them with the release.  The
user's login cookies, and only those, are passed on (wikiLinkLoginCookieHeader),
which works because the nodes share a cookie salt.

The page never waits for any of this: the table is drawn from this server's
own sessions and the rows from the other servers are merged in as they
arrive, keeping the current sort, search and page.  A node that does not
answer, times out, or runs a release without the endpoint is named in a line
under the toolbar instead, and costs nothing else.

Sessions from another server are shown but not managed here: no Share, Edit,
Overwrite or Delete, no bulk-select checkbox, and the Update now shortcut
keeps ignoring them.  Table rows are keyed by a new uid rather than by the
session name, since the same name can now be in the table twice, once per
server.

geoMirrorThisNode() and geoMirrorOtherNodes() read the node list from the
hgcentral gbNode table, so nothing new has to be configured: browser.node
already says which node a server is.

diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js
index 15160be5b58..fae84c8c90f 100644
--- src/hg/js/hgSession.js
+++ src/hg/js/hgSession.js
@@ -160,164 +160,175 @@
 function sessAlert(title, bodyHtml, onOk) {
     sessModalOpen(
         '<div class="gbModalTitle">' + sessEnc(title) + '</div>' +
         '<div class="gbModalText">' + bodyHtml + '</div>' +
         '<div class="gbModalBtns">' +
         '<button type="button" class="gbPill primary" id="sessAlertOk">OK</button></div>');
     $('#sessAlertOk').on('click', function() {
         sessModalClose();
         if (onOk) { onOk(); }
     });
     document.getElementById('sessAlertOk').focus();
 }
 
 // ---- session lookup / row helpers ---------------------------------------
 
-function sessByEnc(enc) {
+function sessByKey(key) {
+    // Rows are keyed by uid, not by session name: the table also holds sessions merged in from our
+    // other servers, and the same name can exist on more than one of them.
     var list = sessData.sessions;
     for (var i = 0; i < list.length; i++) {
-        if (list[i].encName === enc) { return list[i]; }
+        if (list[i].uid === key) { return list[i]; }
     }
     return null;
 }
 
-function sessRowByEnc(enc) {
-    // Return the DataTables row API for the session with this encName, or null.
+function sessRowByKey(key) {
+    // Return the DataTables row API for the session with this uid, or null.
     var found = null;
     sessDt.rows().every(function() {
-        if (this.data().encName === enc) { found = this; }
+        if (this.data().uid === key) { found = this; }
     });
     return found;
 }
 
 function sessApplyTooltips() {
     // Runs after every table draw, because DataTables renders rows on demand (page two, a re-sort)
     // and the one-time conversion utils.js does at page load never sees those.  Two jobs:
     //  - give the plain title attributes in the new rows the same styled mouseovers as the rest of
     //    the page, skipping whatever has already been converted;
     //  - hand each info bubble its session description.  The description is the user's own text and
     //    the tooltip is inserted with innerHTML, so it is passed already escaped: markup in a
     //    description then reads as the characters that were typed instead of being parsed as HTML.
     if (typeof titleTagToMouseover !== 'function' || typeof addMouseover !== 'function') { return; }
     var $table = $('#sessionAppTable');
     $table.find('[title]').each(function() {
         if (this.title && this.getAttribute('mouseoverText') === null) { titleTagToMouseover(this); }
     });
-    $table.find('span.sessInfo[data-enc]').each(function() {
-        var row = sessByEnc(this.getAttribute('data-enc'));
+    $table.find('span.sessInfo[data-key]').each(function() {
+        var row = sessByKey(this.getAttribute('data-key'));
         if (row && row.description) { addMouseover(this, sessEnc(row.description)); }
     });
 }
 
 // ---- table cell rendering ------------------------------------------------
 
 // Inline icons (Font Awesome solid paths, embedded as SVG so they do not depend on the site's Font
 // Awesome version).  fill:currentColor picks up the button's text/danger color.
 var SESS_TRASH_SVG = '<svg class="sessIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"' +
     ' aria-hidden="true"><path fill="currentColor" d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3' +
     ' 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8' +
     'c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7' +
     ' 47.9-45L416 128z"/></svg>';
 // Font Awesome "floppy-disk" (save) regular/outline path - reads more clearly as a save icon.
 var SESS_SAVE_SVG = '<svg class="sessIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"' +
     ' aria-hidden="true"><path fill="currentColor" d="M160 144C151.2 144 144 151.2 144 160L144 480C144' +
     ' 488.8 151.2 496 160 496L480 496C488.8 496 496 488.8 496 480L496 237.3C496 233.1 494.3 229 491.3' +
     ' 226L416 150.6L416 240C416 257.7 401.7 272 384 272L224 272C206.3 272 192 257.7 192 240L192 144L160' +
     ' 144zM240 144L240 224L368 224L368 144L240 144zM96 160C96 124.7 124.7 96 160 96L402.7 96C419.7 96' +
     ' 436 102.7 448 114.7L525.3 192C537.3 204 544 220.3 544 237.3L544 480C544 515.3 515.3 544 480' +
     ' 544L160 544C124.7 544 96 515.3 96 480L96 160zM256 384C256 348.7 284.7 320 320 320C355.3 320 384' +
     ' 348.7 384 384C384 419.3 355.3 448 320 448C284.7 448 256 419.3 256 384z"/></svg>';
 // Font Awesome "lock" solid path - marks a private (not-shared) session, since sharing is the default.
 var SESS_LOCK_SVG = '<svg class="sessLock" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"' +
     ' aria-hidden="true"><path fill="currentColor" d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80' +
     ' 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0' +
     ' 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/></svg>';
 
 function sessActionsHtml(row) {
     // Order: Share, Edit, then the icon-only Overwrite (floppy) and Delete (trash).
-    var e = sessEnc(row.encName);
-    return '<button type="button" class="gbPill" data-act="share" data-enc="' + e + '" ' +
+    if (row.remote) {
+        // A session on another server can only be shown here: sharing, renaming, overwriting and
+        // deleting all go through the cart and the session table of the server that holds it. So
+        // the one action offered is to go over there, where the full row of buttons is waiting.
+        return '<button type="button" class="gbPill" data-act="server" data-key="' +
+            sessEnc(row.uid) + '" title="Open your sessions on ' + sessEnc(row.serverTitle || '') +
+            ', where this session can be shared, renamed or deleted">Go to ' +
+            sessEnc(row.server || 'server') + '</button>';
+    }
+    var e = sessEnc(row.uid);
+    return '<button type="button" class="gbPill" data-act="share" data-key="' + e + '" ' +
         'title="Copy a shareable link or change who can see this session">Share</button>' +
-        '<button type="button" class="gbPill" data-act="edit" data-enc="' + e + '" ' +
+        '<button type="button" class="gbPill" data-act="edit" data-key="' + e + '" ' +
         'title="Rename this session or edit its description">Edit</button>' +
-        '<button type="button" class="gbPill" data-act="overwrite" data-enc="' + e + '" ' +
+        '<button type="button" class="gbPill" data-act="overwrite" data-key="' + e + '" ' +
         'aria-label="Overwrite with current view" ' +
         'title="Overwrite this session with your current browser view">' + SESS_SAVE_SVG + '</button>' +
-        '<button type="button" class="gbPill danger" data-act="delete" data-enc="' + e + '" ' +
+        '<button type="button" class="gbPill danger" data-act="delete" data-key="' + e + '" ' +
         'aria-label="Delete this session" title="Delete this session">' + SESS_TRASH_SVG + '</button>';
 }
 
 function sessNameCellHtml(row) {
     var html = '<a href="' + sessEnc(row.shareUrl) + '" ' +
         'title="Load this session in the Genome Browser">' + sessEnc(row.name) + '</a>';
     if (row.description) {
         // No title attribute here: the description is the user's own text and the tooltip machinery
         // in utils.js inserts its text with innerHTML, so a title would have markup in a description
         // parsed as HTML.  sessApplyTooltips() attaches it, escaped, after the row is drawn.
-        html += ' <span class="sessInfo" data-enc="' + sessEnc(row.encName) + '">&#9432;</span>';
+        html += ' <span class="sessInfo" data-key="' + sessEnc(row.uid) + '">&#9432;</span>';
     }
     // Sessions are shared by default; mark only the exceptions: a lock for private, a badge for the
     // public gallery.  A plain shared-by-link session gets no marker.
     if (row.shared === 0) {
         html += ' <span class="sessLockWrap" title="Private — only you can load this session">' +
             SESS_LOCK_SVG + '</span>';
     } else if (row.shared >= 2) {
         html += ' <span class="sessPublic" title="Listed in the Public Sessions gallery">Public</span>';
     }
     return html;
 }
 
 // ---- Overwrite -----------------------------------------------------------
 
 function sessDoOverwrite(row) {
     sessAjax(sessActParams(SESS_ACT.overwrite, row), function(resp) {
         row.useCount = resp.useCount;
         if (resp.created) { row.created = resp.created; }
         if (resp.db) { row.db = resp.db; }
-        var r = sessRowByEnc(row.encName);
+        var r = sessRowByKey(row.uid);
         if (r) { r.data(row).draw(false); }
         sessModalClose();
         sessMsg('Overwrote “' + row.name + '” with your current browser view.', 'ok');
     });
 }
 
 function sessOpenOverwrite(row) {
     sessConfirm({
         title: 'Overwrite session',
         bodyHtml: 'Replace the saved session <b>' + sessEnc(row.name) + '</b> with the view you are ' +
             'looking at now? The name and description stay the same; the previously saved view is lost.',
         okLabel: 'Overwrite',
         onOk: function() { sessDoOverwrite(row); }
     });
 }
 
 // ---- Delete --------------------------------------------------------------
 
 function sessOpenDelete(row) {
     sessConfirm({
         title: 'Delete session',
         bodyHtml: 'Delete the session <b>' + sessEnc(row.name) + '</b>? This cannot be undone. ' +
             'Any shared links to it will stop working.',
         okLabel: 'Delete',
         okClass: 'danger',
         onOk: function() {
             sessAjax(sessActParams(SESS_ACT.del, row), function() {
-                var r = sessRowByEnc(row.encName);
+                var r = sessRowByKey(row.uid);
                 if (r) { r.remove().draw(false); }
                 var list = sessData.sessions;
                 for (var i = 0; i < list.length; i++) {
-                    if (list[i].encName === row.encName) { list.splice(i, 1); break; }
+                    if (list[i].uid === row.uid) { list.splice(i, 1); break; }
                 }
                 sessModalClose();
                 sessMsg('Deleted “' + row.name + '”.', 'ok');
             });
         }
     });
 }
 
 // ---- Edit (rename + description) ----------------------------------------
 
 function sessOpenEdit(row) {
     var html = '<div class="gbModalTitle">Edit session</div>' +
         '<label class="gbModalLabel" for="sessEditName">Name</label>' +
         '<input id="sessEditName" class="gbModalInput" type="text" maxlength="255">' +
         '<label class="gbModalLabel" for="sessEditDesc">Description ' +
@@ -348,31 +359,31 @@
         err.innerHTML = sessEnc('Please enter a name.');
         return;
     }
     var descChanged = (newDesc !== (row.description || ''));
     var nameChanged = (newName !== row.name);
     var wantPrivate = document.getElementById('sessEditPrivate').checked;
     var privChanged = (wantPrivate !== (row.shared === 0));
 
     // Order matters: apply description and privacy while the session still has its OLD name, then
     // rename last (renaming changes the DB key the other endpoints look up by).
     function finish() {
         if (nameChanged) {
             // encName changes on rename; reload for authoritative state.
             window.location.reload();
         } else {
-            var r = sessRowByEnc(row.encName);
+            var r = sessRowByKey(row.uid);
             if (r) { r.data(row).draw(false); }
             sessModalClose();
             sessMsg('Saved changes to “' + row.name + '”.', 'ok');
         }
     }
     function doRename() {
         if (nameChanged) {
             var rp = sessActParams(SESS_ACT.rename, row);
             rp[SESS_P.newName] = newName;
             // A rename moves the public-listing thumbnail, which can fail on its own (a mirror with
             // no ImageMagick); say so before the reload takes the message away.
             sessAjax(rp, function(resp) {
                 if (resp && resp.warning) {
                     sessAlert('Session renamed', sessEnc(resp.warning), finish);
                 } else { finish(); }
@@ -438,31 +449,31 @@
         else { try { document.execCommand('copy'); } catch (e) { /* ignore */ } }
         this.textContent = 'Copied';
     });
     $('#sessGalleryChk').on('change', function() { sessSetGallery(row, this.checked ? 1 : 0); });
 }
 
 function sessShareErr(msg) {
     var err = document.getElementById('sessShareErr');
     if (err) { err.style.display = 'block'; err.innerHTML = sessEnc(msg); }
 }
 
 function sessAfterSharedChange(row, newShared) {
     row.shared = newShared;
     var g = document.getElementById('sessGalleryChk');
     if (g) { g.checked = (newShared >= 2); }
-    var r = sessRowByEnc(row.encName);
+    var r = sessRowByKey(row.uid);
     if (r) { r.data(row).draw(false); }
 }
 
 function sessSetGallery(row, want) {
     var p = sessActParams(SESS_ACT.gallery, row);
     p[SESS_P.share] = want;
     sessAjax(p, function(resp) {
         sessAfterSharedChange(row, resp.shared);
         // The listing itself worked; the picture for it may not have (e.g. a mirror without
         // ImageMagick convert).  Show what the server said rather than a bare success.
         if (resp && resp.warning) { sessShareErr(resp.warning); }
     }, function(m) {
         sessShareErr(m);
         document.getElementById('sessGalleryChk').checked = (row.shared >= 2);
     });
@@ -676,54 +687,141 @@
         'placeholder="Description (optional) — shown on hover and in the Public Sessions gallery">' +
         '</div>';
 }
 
 function sessRecentHtml(recent) {
     // A quick shortcut to re-save the session the user most recently saved, keeping its name and
     // description.  Hidden when there are no saved sessions.
     if (!recent) { return ''; }
     return '<div class="sessRecent">Most recently saved session: <b>' + sessEnc(recent.name) +
         '</b> <span class="sessRecentTime">(' + sessEnc(recent.lastUse) + ')</span> ' +
         '<button type="button" class="gbPill" id="sessUpdateNow" ' +
         'title="Overwrite this session with the currently active view; keeps the session name and ' +
         'description identical">Update now</button></div>';
 }
 
+function sessScrollToTable() {
+    // Arriving from another server's Server column or "Go to" button, on a #sessions link. The
+    // browser cannot act on that anchor by itself: the table is built here, long after it has
+    // given up looking for the element, so scroll to it once it exists.
+    if (window.location.hash !== '#sessions') { return; }
+    var el = document.getElementById('sessions');
+    if (el) { el.scrollIntoView(); }
+}
+
+function sessMirrorLinks(mirrors) {
+    // "genome-euro", or "genome-euro or genome-asia", each linking to that server's own hgSession.
+    var links = mirrors.map(function(m) {
+        return '<a href="' + sessEnc(m.url) + '" target="_blank" title="' + sessEnc(m.title) +
+            '">' + sessEnc(m.label) + '</a>';
+    });
+    return links.length > 1 ?
+        links.slice(0, -1).join(', ') + ' or ' + links[links.length - 1] : links[0];
+}
+
+function sessMirrorNoteHtml(C) {
+    // Placeholder in the table toolbar, filled in by sessMergeMirrors() once the other servers
+    // have answered - or not answered.
+    return (C.mirrors || []).length ? '<div class="sessMirrorNote"></div>' : '';
+}
+
+function sessMirrorStatus(html) {
+    var el = document.querySelector('#sessionApp .sessMirrorNote');
+    if (el) { el.innerHTML = html; }
+}
+
+function sessMergeMirrors() {
+    // Each of our servers keeps its own sessions, so the ones saved on the others are fetched
+    // after this server's table is already on the screen, and merged into it - every row marked
+    // in the Server column with the server it lives on.  One request: our own hgSession is what
+    // asks the other nodes, so nothing here depends on cross-origin requests.
+    var C = sessData.config;
+    var mirrors = C.mirrors || [];
+    if (!C.loggedIn || !mirrors.length) { return; }
+    sessMirrorStatus('Also looking for your sessions on ' + sessMirrorLinks(mirrors) + '…');
+    $.ajax({
+        type: 'GET',
+        url: 'hgSession',
+        data: { hgS_doMirrorSessions: 1 },
+        dataType: 'json',
+        cache: false,
+        success: function(resp) {
+            var added = [], failed = [], n = 0;
+            (resp.mirrors || []).forEach(function(m) {
+                if (!m.sessions) { failed.push(m); return; }
+                m.sessions.forEach(function(row) {
+                    row.uid = 'R' + (n++);
+                    row.remote = true;
+                    row.server = m.label;
+                    row.serverTitle = m.title;
+                    row.serverUrl = m.url;
+                    added.push(row);
+                });
+            });
+            if (added.length) {
+                sessData.sessions = sessData.sessions.concat(added);
+                sessDt.rows.add(added).draw(false);   // keeps the current sort, search and page
+            }
+            // Only a server that did not answer is worth a word: one that did has its sessions in
+            // the table now, named in the Server column.
+            sessMirrorStatus(failed.length ?
+                'Missing a session? We could not reach ' + sessMirrorLinks(failed) + '.' : '');
+        },
+        error: function() {
+            sessMirrorStatus('Missing a session? We could not reach ' +
+                             sessMirrorLinks(mirrors) + '.');
+        }
+    });
+}
+
 function sessTableHtml(C) {
     if (!C.loggedIn) { return ''; }
-    return '<div class="gbSection">Your saved sessions</div>' +
+    // id="sessions" is the anchor a link from one of our other servers arrives on, see
+    // sessScrollToTable().
+    return '<div class="gbSection" id="sessions">Your saved sessions</div>' +
         '<table id="sessionAppTable" class="gbTable" style="width:100%"></table>';
 }
 
 function sessionBuild() {
     sessData = hgSessionData;
     // When the page was loaded with &measureTiming=1 the C side attaches sessData.timing; time the
     // client-side render too so the dialog shows the full server+client picture.
     var tBuildStart = (sessData.timing && window.performance) ? performance.now() : 0;
     var C = sessData.config;
     var app = $('#sessionApp');
 
     var intro = '<div class="sessIntro">' + sessAccountHtml(C) +
         (sessAccountHtml(C) ? '<br>' : '') +
         'A session is a stable link to a Genome Browser view that you can save, load later, share ' +
         'or copy into a manuscript. See the ' +
         '<a href="' + sessEnc(C.helpUrl) + '" target="_blank">Sessions User’s Guide</a> and the ' +
         '<a href="' + sessEnc(C.galleryUrl) + '" target="_blank">Session Gallery</a>.</div>';
 
+    // Every row carries the server it lives on; at this point they are all this server's own.
+    // The sessions merged in later from the other nodes get theirs in sessMergeMirrors().
+    (sessData.sessions || []).forEach(function(s, i) {
+        s.uid = 'L' + i;
+        s.server = C.thisServer ? C.thisServer.label : '';
+        s.serverTitle = C.thisServer ? C.thisServer.title : '';
+        s.serverUrl = '';   // this is that server, so the Server cell here is text, not a link
+    });
+
     // The session most recently saved/overwritten (max lastUse), for the one-click "Update now".
+    // Only this server's own sessions: "Update now" saves through this server's cart.
     var recent = null;
     (sessData.sessions || []).forEach(function(s) {
+        if (s.remote) { return; }
         if (!recent || s.lastUseEpoch > recent.lastUseEpoch) { recent = s; }
     });
 
     app.html(
         intro +
         '<div id="sessMsg" class="sessMsg"></div>' +
         sessRecentHtml(recent) +
         sessSaveCardHtml(C) +
         sessAdvancedHtml(C) +
         sessTableHtml(C)
     );
 
     // Save current view.  Enter in either the name or the description field saves.
     $('#sessSaveBtn').on('click', sessDoSave);
     $('#sessSaveName, #sessSaveDesc').on('keydown', function(ev) {
@@ -739,32 +837,32 @@
                     'looking at now? The session name and description stay the same.',
                 okLabel: 'Update now',
                 onOk: function() { sessDoOverwrite(recent); }
             });
         });
     }
 
     // Advanced toggle.
     $('#sessAdvHead').on('click', function() {
         var body = document.getElementById('sessAdvBody');
         var open = body.style.display !== 'none';
         body.style.display = open ? 'none' : 'grid';
         $(this).find('.caret').html(open ? '▸' : '▾');
     });
 
-    // Session table.
-    if (C.loggedIn) { sessBuildTable(); }
+    // Session table, then the sessions our other servers hold, merged in as they arrive.
+    if (C.loggedIn) { sessBuildTable(); sessScrollToTable(); sessMergeMirrors(); }
 
     if (typeof convertTitleTagsToMouseovers === 'function') { convertTitleTagsToMouseovers(); }
     if (C.loggedIn) { sessApplyTooltips(); }
 
     // Timing report (only when loaded with &measureTiming=1): a pill that opens the shared dialog.
     if (sessData.timing) {
         var clientRows = [{ label: 'build page (JS)',
                             ms: Math.round(performance.now() - tBuildStart) }];
         var pill = document.createElement('button');
         pill.type = 'button';
         pill.className = 'gbPill';
         pill.id = 'sessTimingBtn';
         pill.innerHTML = '&#9201; Timing';
         pill.title = 'Show where this page spent its time (server and browser)';
         pill.addEventListener('click', function() {
@@ -777,139 +875,172 @@
         // right away; the pill stays for reopening it after Close.
         gbShowTimingDialog(sessData.timing, clientRows);
     }
 }
 
 // ---- bulk select / delete ------------------------------------------------
 
 function sessToggleSelect() {
     if (sessSelectMode) { sessDeleteSelected(); } else { sessSetSelectMode(true); }
 }
 
 function sessSetSelectMode(on) {
     sessSelectMode = on;
     sessDt.column(0).visible(on);   // the leading checkbox column
     var b = document.getElementById('sessSelectBtn');
+    var cancel = document.getElementById('sessSelCancelBtn');
+    if (cancel) { cancel.style.display = on ? '' : 'none'; }
     if (on) {
         b.textContent = 'Delete all selected';
         b.classList.add('primary');
     } else {
         b.textContent = 'Select';
         b.classList.remove('primary');
         $('#sessionAppTable .sessSelChk, #sessSelAll').prop('checked', false);
     }
 }
 
 function sessDeleteSelected() {
-    var encs = [];
+    var keys = [];
     $('#sessionAppTable tbody .sessSelChk:checked').each(function() {
-        encs.push(this.getAttribute('data-enc'));
+        keys.push(this.getAttribute('data-key'));
     });
-    if (encs.length === 0) { sessSetSelectMode(false); return; }   // nothing picked: just exit
+    if (keys.length === 0) { sessSetSelectMode(false); return; }   // nothing picked: just exit
     sessConfirm({
         title: 'Delete selected sessions',
-        bodyHtml: 'Delete <b>' + encs.length + '</b> selected session' +
-            (encs.length === 1 ? '' : 's') + '? This cannot be undone.',
-        okLabel: 'Delete ' + encs.length,
+        bodyHtml: 'Delete <b>' + keys.length + '</b> selected session' +
+            (keys.length === 1 ? '' : 's') + '? This cannot be undone.',
+        okLabel: 'Delete ' + keys.length,
         okClass: 'danger',
         onOk: function() {
             sessModalClose();
-            var remaining = encs.length;
+            var remaining = keys.length;
             function done() { if (--remaining === 0) { window.location.reload(); } }
-            encs.forEach(function(enc) {
-                var row = sessByEnc(enc);
+            keys.forEach(function(key) {
+                var row = sessByKey(key);
                 if (!row) { done(); return; }
                 sessAjax(sessActParams(SESS_ACT.del, row), done, done);
             });
         }
     });
 }
 
 function sessBuildTable() {
     sessDt = $('#sessionAppTable').DataTable({
         data: sessData.sessions,
         pageLength: 15,
         lengthChange: true,
         lengthMenu: [[15, 50, 100, -1], [15, 50, 100, 'All']],
         order: [[3, 'desc']],   // newest (Created) first by default
         dom: '<"sessToolbar"fl>rt<"sessFoot"ip>',
         language: { searchPlaceholder: 'Search sessions', search: '', lengthMenu: 'Show _MENU_',
                     emptyTable: 'No saved sessions yet.' },
         columns: [
             { title: '<input type="checkbox" id="sessSelAll" title="Select all on this page">',
               className: 'sessSelCol', data: null, orderable: false, searchable: false, visible: false,
               render: function(d, type, row) {
-                  return '<input type="checkbox" class="sessSelChk" data-enc="' +
-                      sessEnc(row.encName) + '">'; } },
+                  if (row.remote) { return ''; }
+                  return '<input type="checkbox" class="sessSelChk" data-key="' +
+                      sessEnc(row.uid) + '">'; } },
             { title: 'Session', className: 'sessNameCol', data: 'name',
               render: function(d, type, row) {
                   return (type === 'display') ? sessNameCellHtml(row) : row.name; } },
             { title: 'Assembly', data: 'db',
               render: function(d, type, row) {
                   if (type !== 'display') { return row.db || ''; }
                   var html = sessEnc(row.db || 'n/a');
                   // Region: band, locus (gene) and the position shortened to Mbp, so it all fits.
                   var region = [];
                   if (row.band) { region.push(sessEnc(row.band)); }
                   if (row.locus) { region.push(sessEnc(row.locus)); }
                   if (row.position) { region.push(sessEnc(sessMbpPos(row.position))); }
                   if (region.length) {
                       html += ' <span class="sessPos">' + region.join('&nbsp;&nbsp; ') + '</span>';
                   }
                   return html; } },
             { title: 'Created', data: 'createdEpoch',
               render: function(d, type, row) {
                   return (type === 'display') ?
                       '<span title="' + sessEnc(row.createdFull || row.created) + '">' +
                       sessEnc(row.created) + '</span>' : row.createdEpoch; } },
             { title: 'Last used', data: 'lastUseEpoch',
               render: function(d, type, row) {
                   return (type === 'display') ?
                       '<span title="' + sessEnc(row.lastUse) + '">' +
                       sessEnc(row.lastUseDate || row.lastUse) + '</span>' : row.lastUseEpoch; } },
             { title: 'Views', data: 'useCount',
               render: function(d, type) { return (type === 'display') ? sessNum(d) : d; } },
+            { title: 'Server', data: 'server',
+              render: function(d, type, row) {
+                  if (type !== 'display') { return row.server || ''; }
+                  // The session lives on that server and only there. Another server's name links
+                  // to its own sessions page; this one's is plain text, it is where you already
+                  // are. Either way the gbNode label ("European Server") is the mouseover.
+                  var label = sessEnc(row.server || ''), title = sessEnc(row.serverTitle || '');
+                  if (!row.remote || !row.serverUrl) {
+                      return '<span title="' + title + '">' + label + '</span>';
+                  }
+                  return '<a href="' + sessEnc(row.serverUrl) + '" title="' + title +
+                      '">' + label + '</a>'; } },
             { title: 'Actions', className: 'sessActionsCol', data: null, orderable: false,
               searchable: false,
               render: function(d, type, row) { return (type === 'display') ? sessActionsHtml(row) : ''; } }
         ]
     });
 
     // Every redraw brings rows the tooltip conversion has not seen yet.
     $('#sessionAppTable').on('draw.dt', sessApplyTooltips);
 
     // Delegate the row action buttons.
     $('#sessionAppTable tbody').on('click', 'button[data-act]', function() {
         var act = this.getAttribute('data-act');
-        var row = sessByEnc(this.getAttribute('data-enc'));
+        var row = sessByKey(this.getAttribute('data-key'));
         if (!row) { return; }
         if (act === 'overwrite') { sessOpenOverwrite(row); }
         else if (act === 'share') { sessOpenShare(row); }
         else if (act === 'edit') { sessOpenEdit(row); }
         else if (act === 'delete') { sessOpenDelete(row); }
+        else if (act === 'server' && row.serverUrl) { window.location.href = row.serverUrl; }
     });
 
     // Bulk-select control: a "Select" button after the length dropdown reveals a checkbox column and
     // becomes a primary "Delete all selected" button.
     var selBtn = document.createElement('button');
     selBtn.type = 'button';
     selBtn.id = 'sessSelectBtn';
     selBtn.className = 'gbPill';
     selBtn.textContent = 'Select';
     selBtn.title = 'Select multiple sessions to delete them at once';
     document.querySelector('#sessionApp .sessToolbar').appendChild(selBtn);
     $(selBtn).on('click', sessToggleSelect);
+    // Way out of select mode: without it the only exit is "Delete all selected" with nothing
+    // checked.  Only shown while select mode is on.
+    var cancelBtn = document.createElement('button');
+    cancelBtn.type = 'button';
+    cancelBtn.id = 'sessSelCancelBtn';
+    cancelBtn.className = 'gbPill';
+    cancelBtn.textContent = 'Cancel';
+    cancelBtn.title = 'Leave select mode and clear the checkboxes';
+    cancelBtn.style.display = 'none';
+    document.querySelector('#sessionApp .sessToolbar').appendChild(cancelBtn);
+    $(cancelBtn).on('click', function() { sessSetSelectMode(false); });
+    // The "missing a session?" note rides along at the end of the toolbar, after the buttons, so
+    // that it does not take a line of its own above the table.
+    var note = sessMirrorNoteHtml(sessData.config);
+    if (note) {
+        document.querySelector('#sessionApp .sessToolbar').insertAdjacentHTML('beforeend', note);
+    }
     // Header "select all" toggles every checkbox on the current page.
     $('#sessionAppTable').on('change', '#sessSelAll', function() {
         $('#sessionAppTable tbody .sessSelChk').prop('checked', this.checked);
     });
 
     // Column-header tooltips (set title, then let utils.js convert to styled mouseovers).
     var tips = {
         'Session': 'Click a name to load that session in the Genome Browser',
         'Assembly': 'The genome assembly this session was saved on',
         'Created': 'When the session was first saved',
         'Last used': 'When the session was last saved or loaded',
         'Views': 'How many times this session has been loaded',
         'Actions': 'Overwrite with your current view, share, edit, or delete'
     };
     $('#sessionAppTable thead th').each(function() {