Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 98 additions & 16 deletions static/connections.html
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,66 @@
.folder-list li.filtered-out { display: none; }
.folder-row:focus { outline: none; }
.folder-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* UX (#254) app shell: the header stays put, the sidebar and the
entries area are two independent scroll panes that always fit the
viewport. The earlier sticky sidebar was sized from the viewport,
not from where it starts below the header, so at the top of the
page its bottom hung off screen and the page had to be scrolled
before the list could be scrolled to its end. */
html, body { height: 100%; }
/* overflow: clip, not hidden: hidden still lets scripts scroll the
body (scrollIntoView on anything inside), which moved the header
out of view; clip makes the body unscrollable for good. The border
box is the full height so the page padding is inside the viewport. */
body { display: flex; flex-direction: column; overflow: clip; min-height: 0; box-sizing: border-box; }
#main-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
#main-content > :not(.layout) { flex: none; }
.layout { flex: 1 1 auto; min-height: 0; align-items: stretch; }
.sidebar {
position: static; top: auto; align-self: auto;
max-height: none; height: auto; min-height: 0;
display: flex; flex-direction: column;
overflow-y: auto; overflow-x: hidden;
padding: 0; margin: 0;
border: 1px solid var(--border); border-radius: var(--radius);
background: rgba(255,255,255,0.015);
}
.main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-right: var(--s-2); }

/* Sidebar panel: one pinned header band (label, new-folder button,
filter) on a surface tone, then the list with hairline dividers.
Every band shares the same 12px horizontal padding so edges line up. */
.sidebar-pin {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: var(--s-2) var(--s-3);
gap: var(--s-2);
}
.sidebar-pin .sidebar-header {
margin: 0; padding: 0; border: 0;
min-height: 30px;
justify-content: space-between;
}
.sidebar-pin .sidebar-header strong { font-size: var(--fz-xs); letter-spacing: 0.14em; }
#btn-new-folder { height: 30px; padding: 0 var(--s-2); font-size: var(--fz-xs); }
#sidebar-tools { margin: 0; }
#sidebar-tools:not(:empty) { padding-bottom: 0; }
#folder-filter {
height: 34px; font-size: var(--fz-sm);
background: var(--bg); border: 1px solid var(--border);
}
.folder-list { padding: var(--s-1) 0 var(--s-2); }
.folder-list > li { margin: 0; }
.folder-list > li + li > .folder-row { border-top: 1px solid rgba(255,255,255,0.045); }
.folder-row {
padding: var(--s-2) var(--s-3) var(--s-2) calc(var(--s-3) - 3px);
min-height: 46px;
}
.folder-list li.selected > .folder-row { border-top-color: transparent; }
.folder-row-main { gap: 2px; }
.folder-desc { font-size: 12px; }
.tree-children { margin-left: var(--s-3) !important; }
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
Expand All @@ -545,12 +605,12 @@ <h1>rustguac</h1>
</div>
<nav>
<a href="/connections.html" class="active">Connections</a>
<a href="/sessions.html" id="sessions-link">Sessions</a>
<a href="/sessions.html" id="sessions-link" data-min-role="poweruser">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
<a href="/reports.html" id="reports-link" data-min-role="poweruser">Reports</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
<a href="#" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<span id="user-menu-wrapper">
<a href="#" id="logout-item">Logout</a>
Expand Down Expand Up @@ -1601,7 +1661,7 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
credentials: 'same-origin',
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight })
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight, screen_width: screen.width, screen_height: screen.height })
})
.then(function(res) { return res.json(); })
.then(function(data) {
Expand Down Expand Up @@ -1673,6 +1733,9 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
isAdmin = data.role === 'admin';
currentUserRole = data.role || 'viewer';
var level = roleLevel[data.role] || 0;
// Reconcile the server-written nav gate (see rustguac.css): the page
// GET carried no API key, so key users arrive on the anonymous variant.
document.documentElement.setAttribute('data-role-level', String((typeof apiKey !== 'undefined' && apiKey) ? 4 : level));
if (level < 3) {
document.getElementById('sessions-link').style.display = 'none';
}
Expand All @@ -1688,6 +1751,8 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
document.getElementById('tokens-link').style.display = '';
}
if (apiKey) {
// A key is never on the page GET, so the server served the anonymous variant.
document.documentElement.setAttribute('data-role-level', '4');
document.getElementById('admin-link').style.display = '';
document.getElementById('tokens-link').style.display = '';
}
Expand Down Expand Up @@ -1895,7 +1960,7 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
credentials: 'same-origin',
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight })
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight, screen_width: screen.width, screen_height: screen.height })
}).then(function(res) { return res.json(); }).then(function(data) {
if (data.session_id) window.open('/client/' + data.session_id, '_blank');
else showError(data.error || 'Failed to reconnect');
Expand Down Expand Up @@ -2361,7 +2426,7 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
if (needsPrompt && userCol) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials prompted at connect">[prompt]</span>';
var scopeIcon = r.scope === 'shared' ? '⊕' : '▣';
html += '<tr>';
html += '<td>' + highlight(r.label, tokens) + '</td>';
html += '<td>' + entryNameHtml(r.label, tokens) + '</td>';
html += '<td><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
html += '<td>' + highlight(r.host || '', tokens) + '</td>';
html += '<td>' + userCol + '</td>';
Expand Down Expand Up @@ -2495,6 +2560,15 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
});
}

// "host (Tenant)" display names, as the NetBox sync writes them, read as
// the host over a quieter tenant line. `tokens` highlights search hits.
function entryNameHtml(label, tokens) {
var esc = tokens ? function(t) { return highlight(t, tokens); } : escapeHtml;
var m = /^(.*\S)\s+\(([^()]+)\)$/.exec(label || '');
if (!m) return esc(label || '');
return '<span class="entry-name">' + esc(m[1]) + '</span><span class="entry-sub">' + esc(m[2]) + '</span>';
}

// Credential variables an entry references that the user hasn't set
// yet. Empty until the cred status has loaded (setCredVars !== null).
function entryUnsetVars(e) {
Expand Down Expand Up @@ -2622,7 +2696,7 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
var credPill = unsetVars.length > 0
? ' <span title="Set these first: ' + escapeAttr(unsetVars.join(', ')) + '" style="background:var(--status-pending);color:var(--bg);border-radius:8px;padding:0.05em 0.5em;font-size:0.72em;font-weight:bold;white-space:nowrap;vertical-align:middle">credentials needed</span>'
: '';
html += '<td>' + escapeHtml(label) + credPill + '</td>';
html += '<td>' + entryNameHtml(label) + credPill + '</td>';
var typeLabel = e.session_type.toUpperCase();
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
html += '<td><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
Expand Down Expand Up @@ -5106,14 +5180,17 @@ <h3 id="cred-modal-title">Enter Credentials</h3>
// sidebar, or pushed into the lower half by the strips above it).
function jumpToEntriesHeader(header) {
if (!header) return;
var r = header.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
var inView = r.top >= 0 && r.top <= vh * 0.5 && r.bottom <= vh;
if (inView) return;
try {
header.scrollIntoView({ block: 'start', behavior: prefersReducedMotion ? 'auto' : 'smooth' });
} catch (e) {
header.scrollIntoView(true);
// Scroll the entries pane only. scrollIntoView() would also scroll
// every scrollable ancestor, and a body with overflow hidden can
// still be scrolled by script: that pushed the brand row and the
// nav off the top of the page.
var pane = header.closest('.main');
if (pane && pane.scrollHeight > pane.clientHeight) {
try {
pane.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
} catch (e) {
pane.scrollTop = 0;
}
}
}

Expand Down Expand Up @@ -5592,6 +5669,11 @@ <h3 id="cred-modal-title">Enter Credentials</h3>


init();
// Other pages link here as /connections.html#my-credentials.
if (location.hash === '#my-credentials') {
history.replaceState(null, '', location.pathname + location.search);
setTimeout(function() { openMyCredentials(); }, 250);
}
</script>
</body>
</html>
1 change: 1 addition & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ One spec per change, named after the branch that introduced it:
| spec | pins |
|---|---|
| `tables-1710` | #252: no horizontal overflow at 1710px on Connections, Recordings and Reports, with short and with long unbreakable names |
| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page |

The fixture suppresses the first-visit onboarding tour (`options.onboarding`
re-enables it) and defaults to the admin role; pass `role` to test gating.
59 changes: 59 additions & 0 deletions tests/e2e/specs/connections-app-shell.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// #254: the page is an app shell. The header stays, the sidebar and the
// entries area are two independent scroll panes that always fit the
// viewport, selecting a folder scrolls the entries pane and never the page,
// and the body cannot be scrolled by script at all.
import { test, expect } from '@playwright/test';
import { installApi, settle, fixtureFolders, entry } from '../lib/api.mjs';

test.use({ viewport: { width: 1400, height: 600 } });

// A folder with enough entries to overflow the entries pane.
function longFolders() {
const folders = fixtureFolders();
const big = folders.find(f => f.name === 'beta');
for (let i = 1; i <= 60; i++) big.entries.push(entry(`host-${String(i).padStart(2, '0')}`, 'ssh'));
return folders;
}

test('the body is clipped and both panes fit the viewport', async ({ page }) => {
await installApi(page, { folders: longFolders() });
await page.goto('/connections.html');
await settle(page);
const m = await page.evaluate(() => {
const vh = window.innerHeight;
const side = document.querySelector('.sidebar').getBoundingClientRect();
const main = document.querySelector('.main').getBoundingClientRect();
return {
bodyOverflow: getComputedStyle(document.body).overflow,
sideBottom: side.bottom, mainBottom: main.bottom, vh,
mainOverflowY: getComputedStyle(document.querySelector('.main')).overflowY
};
});
expect(m.bodyOverflow).toBe('clip');
expect(m.sideBottom).toBeLessThanOrEqual(m.vh + 1);
expect(m.mainBottom).toBeLessThanOrEqual(m.vh + 1);
expect(['auto', 'scroll']).toContain(m.mainOverflowY);
});

test('selecting a folder scrolls the entries pane to its top, never the page', async ({ page }) => {
await installApi(page, { folders: longFolders() });
await page.goto('/connections.html');
await settle(page);
await page.locator('.folder-row').filter({ hasText: /beta/ }).first().click();
await expect(page.locator('#entries-title')).toHaveText(/beta/);
// Scroll the entries pane down, then pick another folder: the pane goes
// back to the top and the document never moved.
const main = page.locator('.main');
await main.evaluate(el => { el.scrollTop = 600; });
expect(await main.evaluate(el => el.scrollTop)).toBeGreaterThan(0);
await page.locator('.folder-row').filter({ hasText: /tenant-03/ }).first().click();
await expect(page.locator('#entries-title')).toHaveText(/tenant-03/);
const after = await page.evaluate(() => ({
mainTop: document.querySelector('.main').scrollTop,
pageTop: document.scrollingElement.scrollTop,
brandVisible: document.querySelector('.brand-row, h1').getBoundingClientRect().top >= 0
}));
expect(after.mainTop).toBe(0);
expect(after.pageTop).toBe(0);
expect(after.brandVisible, 'the brand row must not be pushed off the top').toBe(true);
});
Loading