pages: shared facet filters and one list toolbar on every list page

reports: show entry names as host over a tenant subline

Session history and the most-used table showed "host (Tenant)" names
wrapped across three lines; they now read as the host over a quieter
tenant line, as on Connections and Recordings.

Part of #254

sessions, recordings: show the session type with the shared badge (#254)

The sessions page printed the type as plain text and the recordings page
as a coloured word; connections and reports already used .type-badge
from rustguac.css. All four now use the same badge. The Proxmox badge
colour moves into the shared stylesheet with the others.

Shared facet filters (static/filters.js) on the sessions, recordings,
reports and connections pages: a dropdown per facet (Type, Folder, User,
Owner, Status as each page has them), multi-select with counts that narrow
as other facets apply, All/None, a find box for long lists, a Clear filters
link, and selections kept per page in sessionStorage. They sit beside the
existing text search and combine with it; the buttons are compacted to the
height and padding of the other controls and the dropdown reserves room for
its scrollbar so it no longer sits over the counts.

The list pages share one toolbar: a borderless row with the search box, the
facets and the page action on the right (New ad-hoc session, Export CSV),
placeholders standardised to 'Search <noun> (fields…)'. Sessions gains a
search (user, host, owner, ID, type) and a User facet; Reports moves from
its own .filter-bar to the shared one. The redundant page titles go — the
nav already names and marks the active page — and section headings centre
their heading and action button on one line.

Connections: the folder header is sticky at the top of the entries pane, so
it stays under the nav and the active-sessions strip while a long table
scrolls. All four pages show the session type with the shared .type-badge
(the Proxmox colour moves into rustguac.css), reports and recordings show
"host (Tenant)" names as the host over a quieter tenant line, and the key
glyph before Credentials in the nav is sized to the text.

Part of #254

tests: shared facet filters and the list toolbar on every list page
This commit is contained in:
Jad Seifeddine
2026-10-13 13:58:06 +07:00
parent 1ef48871e3
commit be866264bd
11 changed files with 674 additions and 55 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
<a href="/admin.html" class="active" id="admin-link" data-min-role="admin">Admin</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
+53 -9
View File
@@ -216,8 +216,6 @@
display: flex; gap: var(--s-1); align-items: center;
}
.session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; }
/* Proxmox VE brand orange badge */
.type-badge.type-proxmox { background: #e57000; color: #000; padding: 1px 6px; border-radius: 3px; font-weight: 600; }
.session-card.dormant { opacity: 0.75; }
.session-card.dormant .session-card-thumb { filter: brightness(0.7); }
.card-actions {
@@ -561,6 +559,18 @@
background: rgba(255,255,255,0.015);
}
.main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-right: var(--s-2); }
/* The folder header (name, description, search, actions, filters)
pins to the top of the entries pane, so it stays under the nav and
the active-sessions strip while a long table scrolls beneath it. */
#entries-header {
position: sticky; top: 0; z-index: 30;
background: var(--bg);
padding-bottom: var(--s-2);
margin-bottom: var(--s-2);
box-shadow: 0 6px 10px -8px rgba(0, 0, 0, 0.6);
}
#entries-header .folder-actions { margin-bottom: 0; }
#entries-header #entries-filters { margin: var(--s-2) 0 0 0; }
/* Sidebar panel: one pinned header band (label, new-folder button,
filter) on a surface tone, then the list with hairline dividers.
@@ -611,7 +621,7 @@
<a href="/docs.html">Docs</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>
<a href="#" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper">
<a href="#" id="logout-item">Logout</a>
<span id="user-menu-btn" title="Preferences">&#9881; Settings</span>
@@ -681,6 +691,7 @@
<button class="btn-small admin-only" id="btn-edit-folder" style="display:none" title="Edit folder settings">edit folder</button>
<button class="btn-small admin-only" id="btn-delete-folder" style="display:none" title="Delete folder and all entries">delete folder</button>
</div>
<div id="entries-filters" class="list-tools"></div>
</div>
<div id="entries-content">
<p class="empty">Select a folder to view entries.</p>
@@ -1334,7 +1345,8 @@
</div>
</div>
<script>
<script src="/filters.js"></script>
<script>
function togglePw(btn){var inp=btn.previousElementSibling;if(inp.type==='password'){inp.type='text';btn.textContent='\u25CB'}else{inp.type='password';btn.textContent='\u25CF'}}
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='aurora';
@@ -2400,10 +2412,15 @@
bulkSel.names.clear();
updateBulkBar();
var content = document.getElementById('entries-content');
entryFilters.show('folder', true);
entryFilters.update(matches.map(function(m) { return m.row; }));
var allMatches = matches;
matches = matches.filter(function(m) { return entryFilters.apply([m.row]).length > 0; });
if (matches.length === 0) {
content.innerHTML = '<div class="empty-state" style="padding:2em;margin:1em 0">' +
'<p style="color:var(--text-dim);margin:0">No matches for ' + escapeHtml(query) + '</p>' +
'</div>';
'<p style="color:var(--text-dim);margin:0">' +
(allMatches.length ? 'No matches for ' + escapeHtml(query) + ' with the current filters' : 'No matches for ' + escapeHtml(query)) +
'</p></div>';
return;
}
var maxResults = 50;
@@ -2647,10 +2664,37 @@
}
}
function renderEntries(entries, scope, folder) {
lastEntriesRender = { entries: entries, scope: scope, folder: folder };
bulkSyncSelection(entries, scope, folder);
// Shared facet filters (filters.js) above the entries table: Type in a
// folder, Type and Folder across search results. Selections persist in
// sessionStorage for this tab; the Folder facet only applies while
// searching.
var entryFilters = rgFilters.create({
mount: document.getElementById('entries-filters'),
id: 'connections',
facets: [
{ key: 'type', label: 'Type',
value: function(x) { var e = x.entry || x; return e.session_type; },
render: function(v) { return '<span class="type-badge type-' + escapeAttr(v) + '">' + escapeHtml(v) + '</span>'; } },
{ key: 'folder', label: 'Folder', value: function(x) { return x.folderPath || null; } }
],
onChange: function() {
if (searchActive) runSearch();
else if (lastEntriesRender) renderEntries(lastEntriesRender.entries, lastEntriesRender.scope, lastEntriesRender.folder);
}
});
function renderEntries(allEntries, scope, folder) {
lastEntriesRender = { entries: allEntries, scope: scope, folder: folder };
bulkSyncSelection(allEntries, scope, folder);
var content = document.getElementById('entries-content');
entryFilters.show('folder', false);
entryFilters.update(allEntries);
var entries = entryFilters.apply(allEntries);
if (entries.length === 0 && allEntries.length > 0) {
updateBulkBar();
content.innerHTML = '<p class="empty">No entries match the current filters.</p>';
return;
}
if (entries.length === 0) {
updateBulkBar();
if (isAdmin) {
+1 -1
View File
@@ -101,7 +101,7 @@
<a href="/docs.html" class="active">Docs</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="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
+265
View File
@@ -0,0 +1,265 @@
/*
* rustguac shared facet filters.
*
* A row of dropdown buttons, one per facet (Type, Folder, User, ...), each a
* multi-select of the values present in the current rows with a count beside
* each value. Values within a facet are OR'd, facets are AND'd, and counts
* narrow as other facets are applied. Selections persist per page in
* sessionStorage so a reload or a return to the page keeps them.
*
* var f = rgFilters.create({
* mount: document.getElementById('session-filters'),
* id: 'sessions', // storage key suffix
* facets: [
* { key: 'type', label: 'Type', value: function(r) { return r.session_type; },
* render: function(v) { return '<span class="type-badge type-' + v + '">' + v + '</span>'; } },
* { key: 'folder', label: 'Folder', value: function(r) { return r.folder; } }
* ],
* onChange: function() { render(); }
* });
* f.update(rows); // recompute values and counts from the full row set
* var shown = f.apply(rows); // rows matching the current selection
*
* `value` may return a string, a number, null/undefined (shown as "(none)")
* or an array of strings (the row matches if any selected value is in it).
* `render` is optional and returns HTML for a value; the default escapes it.
* Only one menu is open at a time; Escape and clicks outside close it.
*/
(function() {
'use strict';
var NONE = '\u0000none';
var openMenu = null;
function esc(s) {
return String(s).replace(/[&<>"']/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function el(tag, cls, html) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (html != null) e.innerHTML = html;
return e;
}
function valuesOf(facet, row) {
var v;
try { v = facet.value(row); } catch (e) { v = null; }
if (v == null || v === '') return [NONE];
if (Object.prototype.toString.call(v) === '[object Array]') {
var out = [];
for (var i = 0; i < v.length; i++) if (v[i] != null && v[i] !== '') out.push(String(v[i]));
return out.length ? out : [NONE];
}
return [String(v)];
}
function label(facet, v) {
if (v === NONE) return facet.none || '(none)';
return facet.render ? facet.render(v) : esc(v);
}
function plain(facet, v) {
if (v === NONE) return facet.none || '(none)';
return facet.text ? facet.text(v) : String(v);
}
function closeOpen() {
if (!openMenu) return;
openMenu.wrap.classList.remove('open');
openMenu.btn.setAttribute('aria-expanded', 'false');
openMenu.menu.hidden = true;
openMenu = null;
}
document.addEventListener('mousedown', function(e) {
if (openMenu && !openMenu.wrap.contains(e.target)) closeOpen();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && openMenu) { closeOpen(); e.stopPropagation(); }
});
function create(opts) {
var facets = opts.facets || [];
var storageKey = 'rustguac_filters_' + (opts.id || 'page');
var selected = {}; // facet key -> { value: true }
var rows = [];
var ui = {}; // facet key -> { wrap, btn, menu, list, find }
try {
var saved = JSON.parse(sessionStorage.getItem(storageKey) || '{}');
facets.forEach(function(f) {
var arr = saved[f.key];
if (arr && arr.length) {
selected[f.key] = {};
arr.forEach(function(v) { selected[f.key][v] = true; });
}
});
} catch (e) { selected = {}; }
function persist() {
var out = {};
facets.forEach(function(f) {
var s = selected[f.key];
if (s) { var arr = Object.keys(s); if (arr.length) out[f.key] = arr; }
});
try {
if (Object.keys(out).length) sessionStorage.setItem(storageKey, JSON.stringify(out));
else sessionStorage.removeItem(storageKey);
} catch (e) {}
}
function isActive(key) { return !!(selected[key] && Object.keys(selected[key]).length); }
function anyActive() { return facets.some(function(f) { return isActive(f.key) && !(ui[f.key] && ui[f.key].wrap.hidden); }); }
function matches(row, skipKey) {
for (var i = 0; i < facets.length; i++) {
var f = facets[i];
if (f.key === skipKey || !isActive(f.key) || (ui[f.key] && ui[f.key].wrap.hidden)) continue;
var vals = valuesOf(f, row), hit = false;
for (var j = 0; j < vals.length; j++) if (selected[f.key][vals[j]]) { hit = true; break; }
if (!hit) return false;
}
return true;
}
// Values and counts for one facet, narrowed by every other facet.
function tally(facet) {
var counts = {}, order = [];
rows.forEach(function(r) {
if (!matches(r, facet.key)) return;
valuesOf(facet, r).forEach(function(v) {
if (!(v in counts)) { counts[v] = 0; order.push(v); }
counts[v]++;
});
});
// Selected values no longer present stay listed (at 0) so they can be cleared.
Object.keys(selected[facet.key] || {}).forEach(function(v) {
if (!(v in counts)) { counts[v] = 0; order.push(v); }
});
order.sort(function(a, b) {
if (a === NONE) return 1; if (b === NONE) return -1;
if (facet.sort === 'count' && counts[a] !== counts[b]) return counts[b] - counts[a];
return plain(facet, a).localeCompare(plain(facet, b), undefined, { numeric: true, sensitivity: 'base' });
});
return { counts: counts, order: order };
}
function renderButton(facet) {
var u = ui[facet.key], s = selected[facet.key] || {}, keys = Object.keys(s);
u.wrap.classList.toggle('active', keys.length > 0);
var n = u.btn.querySelector('.rg-facet-n');
if (keys.length === 1) { n.textContent = plain(facet, keys[0]); n.className = 'rg-facet-n one'; }
else if (keys.length) { n.textContent = String(keys.length); n.className = 'rg-facet-n'; }
else { n.textContent = ''; n.className = 'rg-facet-n'; }
n.hidden = !keys.length;
u.btn.title = keys.length ? facet.label + ': ' + keys.map(function(k) { return plain(facet, k); }).join(', ') : facet.label;
}
function renderList(facet) {
var u = ui[facet.key], t = tally(facet), s = selected[facet.key] || {};
var q = (u.find.value || '').toLowerCase();
u.list.innerHTML = '';
var shown = 0;
t.order.forEach(function(v) {
if (q && plain(facet, v).toLowerCase().indexOf(q) === -1) return;
shown++;
var opt = el('label', 'rg-facet-opt' + (t.counts[v] ? '' : ' zero'));
var cb = el('input'); cb.type = 'checkbox'; cb.checked = !!s[v]; cb.setAttribute('data-v', v);
opt.appendChild(cb);
opt.appendChild(el('span', 'rg-facet-val', label(facet, v)));
opt.appendChild(el('span', 'rg-facet-count', String(t.counts[v])));
u.list.appendChild(opt);
});
if (!shown) u.list.appendChild(el('div', 'rg-facet-empty', q ? 'No matches' : 'Nothing to filter'));
u.find.parentNode.style.display = t.order.length > 7 || q ? '' : 'none';
}
function refreshAll() {
facets.forEach(function(f) { renderButton(f); if (openMenu && openMenu.key === f.key) renderList(f); });
ui.clear.hidden = !anyActive();
}
function change(facet, v, on) {
if (!selected[facet.key]) selected[facet.key] = {};
if (on) selected[facet.key][v] = true; else delete selected[facet.key][v];
persist(); refreshAll();
if (opts.onChange) opts.onChange();
}
function setAll(facet, on) {
var t = tally(facet), q = (ui[facet.key].find.value || '').toLowerCase();
if (!selected[facet.key]) selected[facet.key] = {};
t.order.forEach(function(v) {
if (q && plain(facet, v).toLowerCase().indexOf(q) === -1) return;
if (on) selected[facet.key][v] = true; else delete selected[facet.key][v];
});
persist(); refreshAll();
if (opts.onChange) opts.onChange();
}
function open(facet) {
var u = ui[facet.key];
if (openMenu && openMenu.key === facet.key) { closeOpen(); return; }
closeOpen();
u.find.value = '';
renderList(facet);
u.menu.hidden = false;
u.wrap.classList.add('open');
u.btn.setAttribute('aria-expanded', 'true');
openMenu = { key: facet.key, wrap: u.wrap, btn: u.btn, menu: u.menu };
// Keep the menu on screen when the button sits near the right edge.
u.menu.classList.remove('right');
var r = u.menu.getBoundingClientRect();
if (r.right > window.innerWidth - 8) u.menu.classList.add('right');
if (u.find.parentNode.style.display !== 'none') { try { u.find.focus(); } catch (e) {} }
}
var bar = el('div', 'rg-filters');
if (opts.label) bar.appendChild(el('span', 'rg-filters-label', esc(opts.label)));
facets.forEach(function(facet) {
var wrap = el('div', 'rg-facet');
var btn = el('button', 'rg-facet-btn', '<span class="rg-facet-label">' + esc(facet.label) + '</span><span class="rg-facet-n" hidden></span><span class="rg-facet-caret">&#9662;</span>');
btn.type = 'button';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
var menu = el('div', 'rg-facet-menu');
menu.hidden = true;
var tools = el('div', 'rg-facet-tools');
var find = el('input'); find.type = 'search'; find.placeholder = 'Find…'; find.setAttribute('aria-label', 'Find ' + facet.label.toLowerCase());
tools.appendChild(find);
var all = el('button', 'rg-facet-tool', 'All'); all.type = 'button';
var none = el('button', 'rg-facet-tool', 'None'); none.type = 'button';
var toolsRow = el('div', 'rg-facet-tools-row');
toolsRow.appendChild(tools); toolsRow.appendChild(all); toolsRow.appendChild(none);
var list = el('div', 'rg-facet-list');
menu.appendChild(toolsRow); menu.appendChild(list);
wrap.appendChild(btn); wrap.appendChild(menu);
bar.appendChild(wrap);
ui[facet.key] = { wrap: wrap, btn: btn, menu: menu, list: list, find: find };
btn.addEventListener('click', function() { open(facet); });
find.addEventListener('input', function() { renderList(facet); });
all.addEventListener('click', function() { setAll(facet, true); });
none.addEventListener('click', function() { setAll(facet, false); });
list.addEventListener('change', function(e) {
var cb = e.target;
if (cb && cb.type === 'checkbox') change(facet, cb.getAttribute('data-v'), cb.checked);
});
});
var clear = el('button', 'rg-filters-clear', 'Clear filters');
clear.type = 'button';
clear.hidden = true;
clear.addEventListener('click', function() {
selected = {}; persist(); refreshAll();
if (opts.onChange) opts.onChange();
});
bar.appendChild(clear);
ui.clear = clear;
if (opts.mount) opts.mount.appendChild(bar);
var api = {
element: bar,
update: function(newRows) { rows = newRows || []; refreshAll(); return api; },
apply: function(list) { return (list || rows).filter(function(r) { return matches(r, null); }); },
active: anyActive,
selected: function(key) { return Object.keys(selected[key] || {}); },
// Hide a facet that has no meaning in the current view (its selection is kept but not applied).
show: function(key, on) { if (ui[key]) ui[key].wrap.hidden = !on; return api; },
clear: function() { selected = {}; persist(); refreshAll(); }
};
refreshAll();
return api;
}
window.rgFilters = { create: create };
})();
+24 -7
View File
@@ -125,6 +125,7 @@
<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>
<!-- Guacamole JS modules (same as client.html) -->
<script src="/filters.js"></script>
<script src="/guac/Namespace.js"></script>
<script src="/guac/Client.js"></script>
<script src="/guac/Display.js"></script>
@@ -174,12 +175,10 @@
<a href="/docs.html">Docs</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="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
<h2>Recordings</h2>
<div id="player-section">
<div id="player-header">
<span id="player-title"></span>
@@ -197,7 +196,10 @@
</div>
<div style="max-width:1200px;margin-bottom:1em">
<input type="text" id="rec-search" placeholder="Search recordings (user, entry, folder...)" style="width:100%;padding:0.5em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;box-sizing:border-box">
<div class="list-tools">
<input type="text" id="rec-search" placeholder="Search recordings (user, entry, folder&#8230;)" class="list-search">
<div id="rec-filters"></div>
</div>
</div>
<div id="recording-list"></div>
<div id="rec-pagination" style="max-width:1200px;margin-top:0.8em;font-family:monospace;font-size:0.9em"></div>
@@ -373,9 +375,24 @@
function recSearchQuery() {
return (document.getElementById('rec-search').value || '').toLowerCase();
}
// Shared facet filters (filters.js): Type, Folder, User; applied on top
// of the text search. Selections persist in sessionStorage for this tab.
var recFilters = rgFilters.create({
mount: document.getElementById('rec-filters'),
id: 'recordings',
facets: [
{ key: 'type', label: 'Type', value: function(r) { return r.session_type; },
render: function(v) { return '<span class="type-badge type-' + escapeAttr(v) + '">' + escapeHtml(v) + '</span>'; } },
{ key: 'folder', label: 'Folder', value: function(r) { return r.folder; } },
{ key: 'user', label: 'User', value: function(r) { return r.user; } }
],
onChange: function() { recPage = 1; renderRecordings(filterRecordings(recSearchQuery())); }
});
function filterRecordings(q) {
if (!q) return allRecordings;
return allRecordings.filter(function(r) {
recFilters.update(allRecordings);
var list = recFilters.apply(allRecordings);
if (!q) return list;
return list.filter(function(r) {
return (r.name || '').toLowerCase().indexOf(q) >= 0 ||
(r.user || '').toLowerCase().indexOf(q) >= 0 ||
(r.entry_display_name || '').toLowerCase().indexOf(q) >= 0 ||
@@ -453,7 +470,7 @@
slice.forEach(function(r) {
var date = r.modified ? new Date(r.modified).toLocaleString() : (r.created_at ? new Date(r.created_at).toLocaleString() : '');
var entryName = r.entry_display_name || r.address_book_entry || r.name;
var typeBadge = r.session_type ? '<span style="color:var(--type-' + r.session_type + '-fg)">' + r.session_type + '</span>' : '';
var typeBadge = r.session_type ? '<span class="type-badge type-' + escapeAttr(r.session_type) + '">' + escapeHtml(r.session_type) + '</span>' : '';
html += '<tr>' +
'<td title="' + escapeAttr(r.name) + '">' + entryNameHtml(entryName) + '</td>' +
'<td>' + escapeHtml(r.user || '') + '</td>' +
+31 -20
View File
@@ -5,17 +5,6 @@
<title>rustguac - Reports</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
.filter-bar {
display: flex;
align-items: center;
gap: var(--s-3);
margin: var(--s-3) 0 var(--s-2) 0;
flex-wrap: wrap;
}
.filter-bar input {
width: 360px;
max-width: 100%;
}
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--text); }
th .sort-arrow { font-size: 0.7em; margin-left: var(--s-1); }
@@ -70,6 +59,7 @@
.btn-small { padding: 0 var(--s-1); }
}
</style>
<script src="/filters.js"></script>
<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>
<body>
@@ -85,12 +75,10 @@
<a href="/docs.html">Docs</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="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
<h2>Reports</h2>
<div class="summary-cards" id="summary-cards">
<div class="summary-card">
<div class="card-label">Total Sessions</div>
@@ -112,9 +100,10 @@
<div style="margin-top:2em">
<h2>Session History</h2>
<div class="filter-bar">
<input type="text" id="filter-input" placeholder="Filter sessions...">
<button id="export-csv-btn" class="btn-add">Export CSV</button>
<div class="list-tools">
<input type="text" id="filter-input" class="list-search" placeholder="Search session history (user, host, folder&#8230;)">
<div id="history-filters"></div>
<button id="export-csv-btn" class="btn-add list-action">Export CSV</button>
</div>
<table id="history-table">
<thead>
@@ -215,6 +204,13 @@
return h;
}
// "host (Tenant)" display names, as the NetBox sync writes them, read
// as the host over a quieter tenant line (same as Connections).
function entryNameHtml(label) {
var m = /^(.*\S)\s+\(([^()]+)\)$/.exec(label || '');
if (!m) return escapeHtml(label || '');
return '<span class="entry-name">' + escapeHtml(m[1]) + '</span><span class="entry-sub">' + escapeHtml(m[2]) + '</span>';
}
function escapeHtml(s) {
if (!s) return '';
var d = document.createElement('div');
@@ -319,7 +315,9 @@
return 0;
});
// Apply text filter
// Facet filters (filters.js), then the text filter
historyFilters.update(sessionsData);
sorted = historyFilters.apply(sorted);
var filterText = (document.getElementById('filter-input').value || '').toLowerCase();
if (filterText) {
sorted = sorted.filter(function(s) {
@@ -350,7 +348,7 @@
}
html += '<tr>' +
'<td>' + escapeHtml(s.created_by || s.username || '') + '</td>' +
'<td>' + escapeHtml(s.entry_display_name || s.address_book_entry || '') + '</td>' +
'<td>' + entryNameHtml(s.entry_display_name || s.address_book_entry || '') + '</td>' +
'<td>' + escapeHtml(s.address_book_folder || '') + '</td>' +
'<td>' + typeBadge(s.session_type) + '</td>' +
'<td>' + escapeHtml(s.hostname || '') + '</td>' +
@@ -415,6 +413,19 @@
document.getElementById('filter-input').addEventListener('input', function() {
applySortAndRender();
});
// Shared facet filters (filters.js): Type, Folder, User, Status.
// Selections persist in sessionStorage for this tab.
var historyFilters = rgFilters.create({
mount: document.getElementById('history-filters'),
id: 'reports',
facets: [
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; }, render: typeBadge },
{ key: 'folder', label: 'Folder', value: function(s) { return s.address_book_folder; } },
{ key: 'user', label: 'User', value: function(s) { return s.created_by; } },
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
],
onChange: function() { applySortAndRender(); }
});
// ── Export CSV ──
document.getElementById('export-csv-btn').addEventListener('click', function() {
@@ -446,7 +457,7 @@
var html = '';
data.forEach(function(c) {
html += '<tr>' +
'<td>' + escapeHtml(c.name || c.address_book_entry || '') + '</td>' +
'<td>' + entryNameHtml(c.name || c.address_book_entry || '') + '</td>' +
'<td>' + typeBadge(c.session_type) + '</td>' +
'<td>' + (c.session_count || 0) + '</td>' +
'<td>' + (c.total_hours != null ? c.total_hours.toFixed(1) : '0') + '</td>' +
+141 -1
View File
@@ -93,6 +93,8 @@ nav a {
line-height: 1.4;
}
nav a:hover { color: var(--text); text-decoration: none; }
/* The key glyph before Credentials renders as a large colour emoji; keep it to text height. */
nav .nav-key { font-size: 0.75em; vertical-align: 0.05em; margin-right: 0.1em; }
nav a.active { color: var(--primary); font-weight: bold; }
nav a.active::after {
content: '';
@@ -353,12 +355,14 @@ tbody tr:last-child td { border-bottom: none; }
.type-vnc { background: var(--type-vnc-bg); color: var(--type-vnc-fg); }
.type-web { background: var(--type-web-bg); color: var(--type-web-fg); }
.type-vdi { background: var(--type-vdi-bg); color: var(--type-vdi-fg); }
.type-proxmox { background: #e57000; color: #000; }
/* ── Section heading strip (used above grids/lists) ──────── */
.section-head {
display: flex;
align-items: baseline;
align-items: center;
gap: var(--s-2);
min-height: var(--ctl-sm);
margin-bottom: var(--s-3);
padding-bottom: var(--s-2);
border-bottom: 1px solid var(--border);
@@ -870,3 +874,139 @@ html:not([data-role-level="4"]) .admin-only { display: none !important; }
"host (Tenant)" display names read as the host over a quieter subline. */
.entry-name { display: block; color: var(--text); }
.entry-sub { display: block; font-size: var(--fz-xs); color: var(--text-dim); margin-top: 1px; letter-spacing: 0.02em; }
/* ── Facet filters (filters.js) ──────────────────────────── */
.rg-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
}
.rg-filters-label {
color: var(--text-muted);
font-size: var(--fz-sm);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.rg-facet { position: relative; }
.rg-facet[hidden] { display: none; }
.rg-facet-btn {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: var(--ctl-sm);
padding: 0 var(--s-2);
background: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-family: monospace;
font-size: var(--fz-sm);
cursor: pointer;
white-space: nowrap;
}
/* The generic button:hover paints the primary colour; these stay on the input tone. */
.rg-facet-btn:hover, .rg-facet-btn:focus, .rg-facet.open .rg-facet-btn { background: var(--input); border-color: var(--accent); color: var(--text); }
.rg-facet-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.rg-facet.active .rg-facet-btn { border-color: var(--accent); color: var(--accent); }
.rg-facet.active .rg-facet-btn:hover, .rg-facet.active .rg-facet-btn:focus { color: var(--accent); }
.rg-facet-n {
background: var(--accent);
color: var(--bg);
border-radius: 999px;
padding: 0 0.55em;
font-size: var(--fz-xs);
font-weight: bold;
line-height: 1.6;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.rg-facet-n.one { background: transparent; color: var(--accent); padding: 0; font-weight: normal; font-size: var(--fz-sm); }
.rg-facet-n.one::before { content: "\00b7\00a0"; color: var(--text-dim); }
.rg-facet-n[hidden] { display: none; }
.rg-facet-caret { font-size: 0.7em; opacity: 0.6; margin-left: 1px; }
.rg-facet.open .rg-facet-caret { transform: rotate(180deg); }
.rg-facet-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 600;
min-width: 260px;
max-width: min(440px, calc(100vw - 32px));
padding: var(--s-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
}
.rg-facet-menu.right { left: auto; right: 0; }
.rg-facet-menu[hidden] { display: none; }
.rg-facet-tools-row { display: flex; align-items: center; gap: var(--s-1); margin-bottom: var(--s-2); }
.rg-facet-tools { flex: 1; min-width: 0; display: flex; }
.rg-facet-tools input[type="search"] { flex: 1; min-width: 0; height: var(--ctl-sm); font-size: var(--fz-sm); }
.rg-facet-tool {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-family: monospace;
font-size: var(--fz-xs);
height: var(--ctl-sm);
padding: 0 var(--s-2);
cursor: pointer;
}
.rg-facet-tool:hover, .rg-facet-tool:focus { background: transparent; color: var(--accent); border-color: var(--border); }
.rg-facet-list { max-height: 340px; overflow-y: auto; overflow-x: hidden; padding-right: var(--s-2); scrollbar-gutter: stable; }
.rg-facet-opt {
display: flex;
align-items: center;
gap: var(--s-2);
margin: 0;
padding: 4px var(--s-1);
border-radius: var(--radius);
color: var(--text);
font-size: var(--fz-sm);
font-weight: normal;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.rg-facet-opt:hover { background: var(--input); }
.rg-facet-opt.zero { opacity: 0.55; }
.rg-facet-opt input[type="checkbox"] { margin: 0; flex: none; accent-color: var(--accent); width: 15px; height: 15px; }
.rg-facet-val { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rg-facet-val .type-badge { font-size: var(--fz-xs); min-width: 44px; padding: 1px var(--s-2); }
.rg-facet-count { color: var(--text-dim); font-size: var(--fz-xs); flex: none; }
.rg-facet-empty { color: var(--text-dim); font-size: var(--fz-sm); padding: var(--s-2) var(--s-1); }
.rg-filters-clear {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-muted);
font-family: monospace;
font-size: var(--fz-sm);
height: var(--ctl-sm);
padding: 0 var(--s-2);
cursor: pointer;
}
.rg-filters-clear:hover, .rg-filters-clear:focus { background: transparent; color: var(--primary); border-color: var(--border); }
.rg-filters-clear[hidden] { display: none; }
/* ── List tool rows (search box + facet filters above a table) ── */
.list-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
margin-bottom: var(--s-3);
}
.list-tools:empty { display: none; }
.list-tools .list-search { flex: 1 1 260px; min-width: 200px; max-width: 520px; height: var(--ctl-sm); font-size: var(--fz-sm); }
/* A page-specific action (New ad-hoc session, Export CSV) pinned to the right
of the shared search+filters row. */
.list-tools .list-action { margin-left: auto; }
/* ── Modal extras ─────────────────────────────────────── */
.modal .modal-hint { color: var(--text-muted); font-size: var(--fz-sm); margin: 0 0 var(--s-3); }
.modal.adhoc-modal { min-width: 520px; max-width: 640px; }
+70 -15
View File
@@ -64,7 +64,7 @@
<a href="/docs.html">Docs</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="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
@@ -188,12 +188,12 @@
</div>
<div id="sessions">
<div class="section-head">
<strong>Sessions</strong>
<span class="section-count" id="session-counts"></span>
<button type="button" class="btn-add section-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">&#9889; New ad-hoc session</button>
<div class="list-tools">
<input type="text" id="session-search" class="list-search" placeholder="Search sessions (user, host, owner, ID&#8230;)">
<div id="session-filters"></div>
<button type="button" class="btn-add list-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">&#9889; New ad-hoc session</button>
</div>
<div id="session-filters" class="list-tools"></div>
<span class="section-count" id="session-counts" style="display:none"></span>
<table>
<thead><tr>
<th>ID</th><th>Type</th><th>Host</th><th>User</th><th>Owner</th><th>Status</th><th>Viewers</th><th></th><th></th>
@@ -203,6 +203,7 @@
<p id="session-empty" class="empty" style="display:none"></p>
</div>
<script src="/filters.js"></script>
<script>
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='aurora';
@@ -617,6 +618,48 @@
return res.json();
})
.then(function(sessions) {
lastSessionList = sessions;
renderSessionList(sessions);
});
}
// Shared facet filters (filters.js): Type, Status, Owner. Selections
// persist in sessionStorage for this tab. Counts in the header stay
// for the full list; the table shows the filtered one.
var lastSessionList = null;
var sessionFilters = rgFilters.create({
mount: document.getElementById('session-filters'),
id: 'sessions',
facets: [
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; },
render: function(v) { return '<span class="type-badge type-' + v.replace(/[^a-z0-9-]/gi, '') + '">' + v.replace(/[<>&]/g, '') + '</span>'; } },
{ key: 'user', label: 'User', value: function(s) { return s.username; } },
{ key: 'owner', label: 'Owner', value: function(s) { return s.created_by; } },
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
],
onChange: function() { if (lastSessionList) renderSessionList(lastSessionList); }
});
function sessionSearchQuery() {
return (document.getElementById('session-search').value || '').toLowerCase();
}
document.getElementById('session-search').addEventListener('input', function() {
if (lastSessionList) renderSessionList(lastSessionList);
});
function renderSessionList(allSessions) {
sessionFilters.update(allSessions);
var sessions = sessionFilters.apply(allSessions);
var q = sessionSearchQuery();
if (q) {
sessions = sessions.filter(function(s) {
var host = (s.session_type === 'web' ? s.url : s.hostname) || '';
return (s.username || '').toLowerCase().indexOf(q) >= 0
|| host.toLowerCase().indexOf(q) >= 0
|| (s.created_by || '').toLowerCase().indexOf(q) >= 0
|| (s.session_id || '').toLowerCase().indexOf(q) >= 0
|| (s.session_type || '').toLowerCase().indexOf(q) >= 0;
});
}
var tbody = document.getElementById('session-list');
var empty = document.getElementById('session-empty');
var counts = document.getElementById('session-counts');
@@ -642,7 +685,9 @@
tdId.appendChild(idLink);
tr.appendChild(tdId);
tr.appendChild(h('td', s.session_type));
var tdType = document.createElement('td');
tdType.appendChild(h('span', s.session_type, { 'class': 'type-badge type-' + s.session_type }));
tr.appendChild(tdType);
tr.appendChild(h('td', hostCol));
tr.appendChild(h('td', s.username));
tr.appendChild(h('td', s.created_by || '', { 'class': isOwn ? 'owner-self' : 'owner-other' }));
@@ -683,21 +728,31 @@
// Count indicator: "12 active · 3 yours" for admins seeing others,
// "3 active" when caller only sees their own.
if (activeCount === 0) {
if (sessions.length === 0 && allSessions.length > 0) {
empty.style.display = '';
empty.textContent = 'No sessions match the current filters.';
} else if (activeCount === 0) {
empty.style.display = '';
empty.textContent = myRole === 'admin'
? 'No active sessions.'
: 'No active sessions. Use Connections to connect to a saved entry, or the form above to start an ad-hoc session.';
counts.textContent = '';
} else {
empty.style.display = 'none';
if (myRole === 'admin' && activeCount !== ownActiveCount) {
counts.textContent = activeCount + ' active · ' + ownActiveCount + ' yours';
} else {
counts.textContent = activeCount + ' active';
}
}
});
// Header counts describe the whole list, not the filtered view.
var totalActive = 0, ownActive = 0;
allSessions.forEach(function(s) {
if (s.status !== 'active') return;
totalActive++;
if (myName && s.created_by === myName) ownActive++;
});
if (totalActive === 0) {
counts.textContent = '';
} else if (myRole === 'admin' && totalActive !== ownActive) {
counts.textContent = totalActive + ' active · ' + ownActive + ' yours' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
} else {
counts.textContent = totalActive + ' active' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
}
}
document.getElementById('session-list').addEventListener('click', function(e) {
+1 -1
View File
@@ -30,7 +30,7 @@
<a href="/docs.html">Docs</a>
<a href="/tokens.html" class="active" id="tokens-link" data-min-role="operator">Tokens</a>
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
+1
View File
@@ -27,6 +27,7 @@ One spec per change, named after the branch that introduced it:
| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page |
| `role-nav` | one nav on every page, gated by `data-role-level` |
| `sessions-adhoc-modal` | ad-hoc form opens as a modal |
| `facet-filters` | shared facets narrow lists, persist, combine with search |
The fixture suppresses the first-visit onboarding tour (`options.onboarding`
re-enables it) and defaults to the admin role; pass `role` to test gating.
+86
View File
@@ -0,0 +1,86 @@
// Shared facet filters (filters.js) on the list pages: a dropdown per facet,
// multi-select with counts, combined with the text search; the selection
// persists per page in sessionStorage. One list toolbar on every page.
import { test, expect } from '@playwright/test';
import { installApi, settle } from '../lib/api.mjs';
async function openFacet(page, mount, label) {
const btn = page.locator(`${mount} .rg-facet-btn`).filter({ hasText: label }).first();
await expect(btn).toBeVisible();
await btn.click();
const menu = btn.locator('xpath=..').locator('.rg-facet-menu');
await expect(menu).toBeVisible();
return menu;
}
test('sessions: the Type facet narrows the list and the count follows', async ({ page }) => {
await installApi(page, { role: 'admin' });
await page.goto('/sessions.html');
await settle(page);
const rows = page.locator('#session-list tr');
await expect(rows).toHaveCount(2);
// Every list page shares the toolbar: search box, facets, action on the right.
await expect(page.locator('#sessions .list-tools .list-search')).toBeVisible();
await expect(page.locator('#sessions .list-tools #new-session-btn')).toBeVisible();
const menu = await openFacet(page, '#session-filters', 'Type');
const ssh = menu.locator('.rg-facet-opt').filter({ hasText: /ssh/i }).first();
await expect(ssh.locator('.rg-facet-count')).toHaveText('1');
await ssh.locator('input[type="checkbox"]').check();
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText('web01');
// The selection survives a reload of the page (sessionStorage).
await page.reload();
await settle(page);
await expect(page.locator('#session-list tr')).toHaveCount(1);
});
test('sessions: the search box combines with the facets', async ({ page }) => {
await installApi(page, { role: 'admin' });
await page.goto('/sessions.html');
await settle(page);
const search = page.locator('#session-search');
await expect(search).toHaveAttribute('placeholder', /^Search sessions/);
await search.fill('bob@');
await expect(page.locator('#session-list tr')).toHaveCount(1);
await expect(page.locator('#session-list tr').first()).toContainText('dc01');
await search.fill('nobody');
await expect(page.locator('#session-list tr')).toHaveCount(0);
});
test('recordings and reports carry the shared facets and the type badge', async ({ page }) => {
await installApi(page);
await page.goto('/recordings.html');
await settle(page);
await expect(page.locator('#rec-filters .rg-facet-btn').first()).toBeVisible();
await expect(page.locator('#rec-search')).toHaveAttribute('placeholder', /^Search recordings/);
await expect(page.locator('#recording-list .type-badge').first()).toBeVisible();
const menu = await openFacet(page, '#rec-filters', 'Type');
await menu.locator('.rg-facet-opt').filter({ hasText: /rdp/i }).locator('input').check();
await expect(page.locator('#recording-list tbody tr')).toHaveCount(1);
await expect(page.locator('#recording-list tbody tr').first()).toContainText('dc01');
await page.goto('/reports.html');
await settle(page);
await expect(page.locator('#history-filters .rg-facet-btn').first()).toBeVisible();
await expect(page.locator('#export-csv-btn')).toBeVisible();
const m2 = await openFacet(page, '#history-filters', 'Type');
await m2.locator('.rg-facet-opt').filter({ hasText: /ssh/i }).locator('input').check();
await expect(page.locator('#history-body tr')).toHaveCount(1);
await expect(page.locator('#history-body tr').first()).toContainText('web01');
});
test('connections: the Type facet in the folder header filters the entries table', async ({ page }) => {
await installApi(page);
await page.goto('/connections.html');
await settle(page);
await page.locator('.folder-row').filter({ hasText: /acme/ }).first().click();
await expect(page.locator('.main .entries-table tbody tr')).toHaveCount(4);
const menu = await openFacet(page, '#entries-filters', 'Type');
await menu.locator('.rg-facet-opt').filter({ hasText: /rdp/i }).locator('input').check();
await expect(page.locator('.main .entries-table tbody tr')).toHaveCount(2);
// The folder header stays pinned at the top of the entries pane.
const pos = await page.locator('#entries-header').evaluate(el => getComputedStyle(el).position);
expect(pos).toBe('sticky');
});