Connections: quick-find search across all entries

Adds a find-as-you-type search input to the Connections page entries-header
with global search over every entry the user has access to. Search runs
client-side against an in-memory index built from a new endpoint.

Backend (GET /api/addressbook/search-index):
- Iterative tree walk (BFS over (scope, path) queue) using list_folders +
  list_subfolders.
- Subfolder traversal is unconditional because resolve_folder_access permits
  a child to grant access independently of a denied parent; ACL is enforced
  per folder before its entries are emitted.
- Returns flat {entries: [{scope, folder_path, entry: EntryInfo}]}.
- Operator role required, admin bypass.

Frontend (static/connections.html):
- Search input lives in .folder-actions between folder title/desc and admin
  buttons; auto right margin keeps add/edit/delete folder buttons hard-right.
- loadSearchIndex runs once after loadFolders; placeholder shows "Indexing..."
  until ready.
- Tokenized substring matcher with simple scoring (name-prefix > name-substring
  > host > folder-path); cap at 50 results with "+N more" footer.
- Results render in entries-table styling with a Folder breadcrumb column,
  inline Connect, and an "open folder" link. Matched substrings highlighted
  with <mark>.
- Connect from search results looks up the entry in searchIndex (not
  currentEntries) when searchActive is true.
- "open folder" walks the tree, expands ancestors via loadSubfolders chain,
  selects the target, scrolls into view, clears search.
- Keyboard: / focuses the input (skipped in inputs/textareas/modals); Esc
  clears the query then blurs.

CSS (static/rustguac.css):
- .connections-search styling, mark highlight, breadcrumb cell,
  search-open-folder link, and search-more footer.
This commit is contained in:
Dave Kempe
2026-04-29 13:35:40 +10:00
parent 8a25869606
commit fe3d3adccf
4 changed files with 401 additions and 4 deletions
+82
View File
@@ -1943,6 +1943,88 @@ pub async fn ab_list_all(
Json(json!({"folders": result})).into_response()
}
/// GET /api/addressbook/search-index — Flat list of every entry the user has access to,
/// across all folders and subfolders. Powers the Connections page quick-search.
///
/// Walks the full tree (top-level via list_folders, then list_subfolders per node) and
/// emits one row per entry as `{scope, folder_path, entry: EntryInfo}`. Subfolder
/// traversal is unconditional because a child may grant access independently of a
/// denied parent (resolve_folder_access semantics); ACL is enforced per folder before
/// its entries are emitted.
pub async fn ab_search_index(
identity: Option<Extension<AuthIdentity>>,
Extension(vault): Extension<VaultState>,
) -> impl IntoResponse {
let vault = match require_vault(&vault).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id = match identity {
Some(Extension(ref id)) if id.has_role("operator") => id,
_ => {
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "operator role required"})),
)
.into_response()
}
};
let user_groups = id.groups();
let is_admin = id.has_role("admin");
let top = match vault.list_folders().await {
Ok(f) => f,
Err(e) => {
return (
StatusCode::BAD_GATEWAY,
Json(json!({"error": e.to_string()})),
)
.into_response()
}
};
let mut queue: Vec<(String, String)> = top
.into_iter()
.map(|f| (f.scope, f.path.unwrap_or(f.name)))
.collect();
let mut emitted = Vec::new();
while let Some((scope, path)) = queue.pop() {
if let Ok(subs) = vault.list_subfolders(&scope, &path).await {
for s in subs {
let child_path = s.path.unwrap_or_else(|| s.name.clone());
queue.push((scope.clone(), child_path));
}
}
let allowed = is_admin
|| vault
.resolve_folder_access(&scope, &path, user_groups)
.await
.unwrap_or(false);
if !allowed {
continue;
}
let names = match vault.list_entries(&scope, &path).await {
Ok(n) => n,
Err(_) => continue,
};
for name in &names {
if let Ok(entry) = vault.get_entry(&scope, &path, name).await {
emitted.push(json!({
"scope": scope,
"folder_path": path,
"entry": crate::vault::EntryInfo::from((name.as_str(), &entry)),
}));
}
}
}
Json(json!({"entries": emitted})).into_response()
}
/// GET /api/addressbook/folders/:scope/:folder/entries — List entries in a folder.
pub async fn ab_list_entries(
identity: Option<Extension<AuthIdentity>>,
+1
View File
@@ -925,6 +925,7 @@ async fn run_server(config: Config, database: Db) {
.route("/api/ws-ticket", post(api::create_ws_ticket))
// Address book routes
.route("/api/addressbook", get(api::ab_list_all))
.route("/api/addressbook/search-index", get(api::ab_search_index))
.route("/api/addressbook/folders", get(api::ab_list_folders))
.route("/api/addressbook/folders", post(api::ab_create_folder))
.route(
+274 -4
View File
@@ -369,7 +369,7 @@
<div class="folder-actions">
<strong id="entries-title"></strong>
<span id="entries-desc" class="desc"></span>
<span style="flex:1"></span>
<input type="search" id="connections-search" class="connections-search" placeholder="Search connections..." autocomplete="off" disabled />
<button class="btn-add admin-only" id="btn-new-entry" style="display:none">+ add entry</button>
<button class="btn-add admin-only" id="btn-new-subfolder" style="display:none" title="Create subfolder under this folder">+ subfolder</button>
<button class="btn-small admin-only" id="btn-edit-folder" style="display:none" title="Edit folder settings">edit folder</button>
@@ -859,6 +859,11 @@
var expandedPaths = {}; // "scope|path" -> true (tree expansion state; persisted)
var EXPANDED_STORAGE_KEY = 'rustguac_connections_expanded';
var SELECTED_STORAGE_KEY = 'rustguac_connections_selected';
// Quick-find search state
var searchIndex = null; // [{scope, folderPath, entry, label, host, haystack}]
var searchIndexLoading = false;
var searchQuery = ''; // current query (lowercased & trimmed)
var searchActive = false; // entries-content showing results, not folder entries
function folderKey(scope, path) { return scope + '|' + path; }
function folderPath(f) { return f.path || f.name; }
@@ -1507,9 +1512,11 @@
}
maybeAutoOpenSingleton();
});
loadSearchIndex();
return;
}
maybeAutoOpenSingleton();
loadSearchIndex();
})
.catch(function(err) {
showError('Failed to load folders: ' + err.message);
@@ -1688,6 +1695,213 @@
return null;
}
// ── Quick-find search ──────────────────────────────────────────────
// Index is fetched once after loadFolders() and after any mutation that
// already triggers a refresh. Search runs entirely client-side against
// the in-memory haystack.
function loadSearchIndex() {
var input = document.getElementById('connections-search');
if (!input) return;
searchIndexLoading = true;
input.placeholder = 'Indexing...';
input.disabled = true;
fetch('/api/addressbook/search-index', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
return res.json();
})
.then(function(data) {
var rows = (data && data.entries) || [];
searchIndex = rows.map(function(r) {
var e = r.entry;
var label = e.display_name || e.name;
var host = e.session_type === 'web' ? (e.url || '')
: e.session_type === 'vdi' ? (e.container_image || '')
: (e.hostname || '');
var haystack = [
e.name,
e.display_name || '',
host,
e.username || '',
e.url || '',
e.domain || '',
e.session_type,
r.folder_path
].join(' ').toLowerCase();
return {
scope: r.scope,
folderPath: r.folder_path,
entry: e,
label: label,
host: host,
haystack: haystack
};
});
searchIndexLoading = false;
input.placeholder = 'Search ' + searchIndex.length + ' connection' + (searchIndex.length === 1 ? '' : 's') + '... (press / )';
input.disabled = false;
if (searchQuery) runSearch();
})
.catch(function(err) {
searchIndexLoading = false;
input.placeholder = 'Search unavailable';
input.disabled = false;
if (window.console) console.warn('Search index failed:', err.message);
});
}
function runSearch() {
var input = document.getElementById('connections-search');
if (!input) return;
var q = (input.value || '').trim().toLowerCase();
searchQuery = q;
if (!q) {
if (searchActive) {
searchActive = false;
if (selectedFolder) {
loadEntries(selectedFolder.scope, folderPath(selectedFolder));
} else {
document.getElementById('entries-content').innerHTML =
'<p class="empty">Select a folder to view entries.</p>';
}
}
return;
}
searchActive = true;
if (!searchIndex) {
document.getElementById('entries-content').innerHTML =
'<p class="empty">Indexing connections, please wait...</p>';
return;
}
var tokens = q.split(/\s+/).filter(Boolean);
var matches = [];
for (var i = 0; i < searchIndex.length; i++) {
var row = searchIndex[i];
var ok = true;
for (var t = 0; t < tokens.length; t++) {
if (row.haystack.indexOf(tokens[t]) === -1) { ok = false; break; }
}
if (!ok) continue;
var first = tokens[0];
var lcLabel = row.label.toLowerCase();
var lcName = row.entry.name.toLowerCase();
var score = 0;
if (lcLabel.indexOf(first) === 0 || lcName.indexOf(first) === 0) score += 100;
else if (lcLabel.indexOf(first) !== -1 || lcName.indexOf(first) !== -1) score += 50;
if ((row.host || '').toLowerCase().indexOf(first) !== -1) score += 20;
if (row.folderPath.toLowerCase().indexOf(first) !== -1) score += 10;
matches.push({ row: row, score: score });
}
matches.sort(function(a, b) {
if (b.score !== a.score) return b.score - a.score;
if (a.row.folderPath !== b.row.folderPath) return a.row.folderPath.localeCompare(b.row.folderPath);
return a.row.label.localeCompare(b.row.label);
});
renderSearchResults(matches, q, tokens);
}
function renderSearchResults(matches, query, tokens) {
var content = document.getElementById('entries-content');
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>';
return;
}
var maxResults = 50;
var more = matches.length - maxResults;
var shown = matches.slice(0, maxResults);
var html = '<table class="entries-table"><thead><tr>' +
'<th>Name</th><th>Type</th><th>Host</th><th>User</th><th>Folder</th><th></th><th></th>' +
'</tr></thead><tbody>';
shown.forEach(function(m) {
var r = m.row;
var e = r.entry;
var typeLabel = e.session_type.toUpperCase();
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
var typeCls = 'type-' + e.session_type;
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web';
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
var userCol = e.username ? highlight(e.username, tokens) : '';
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><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
html += '<td>' + highlight(r.host || '', tokens) + '</td>';
html += '<td>' + userCol + '</td>';
html += '<td><span class="search-folder-cell"><span class="folder-scope">' + scopeIcon + '</span> ' + highlight(r.folderPath, tokens) + '</span></td>';
html += '<td><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(r.scope) + '" data-folder="' + escapeAttr(r.folderPath) + '">' + connectLabel + '</button></td>';
html += '<td><a href="#" class="search-open-folder" data-open-scope="' + escapeAttr(r.scope) + '" data-open-folder="' + escapeAttr(r.folderPath) + '" title="Open this folder">↗ open folder</a></td>';
html += '</tr>';
});
html += '</tbody></table>';
if (more > 0) {
html += '<div class="search-more">+' + more + ' more match' + (more === 1 ? '' : 'es') + ' — refine your search to narrow results</div>';
}
content.innerHTML = html;
}
function highlight(text, tokens) {
var safe = escapeHtml(text || '');
if (!tokens || !tokens.length) return safe;
var escaped = tokens
.filter(function(t) { return t.length > 0; })
.map(function(t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
if (!escaped.length) return safe;
var re = new RegExp('(' + escaped.join('|') + ')', 'gi');
return safe.replace(re, '<mark>$1</mark>');
}
function openFolderFromSearch(scope, targetPath) {
var input = document.getElementById('connections-search');
if (input) input.value = '';
searchQuery = '';
searchActive = false;
var parts = targetPath.split('/');
var ancestors = [];
for (var i = 1; i < parts.length; i++) {
ancestors.push(parts.slice(0, i).join('/'));
}
ancestors.forEach(function(p) {
expandedPaths[folderKey(scope, p)] = true;
});
saveExpandedPaths();
var idx = 0;
function loadNext() {
if (idx >= ancestors.length) {
var target = findFolder(scope, targetPath);
if (target) {
selectedFolder = target;
saveSelectedFolder();
renderFolders();
loadEntries(scope, targetPath);
setTimeout(function() {
try {
var sel = '.folder-list li[data-scope="' + scope + '"][data-path="' + targetPath.replace(/"/g, '\\"') + '"]';
var row = document.querySelector(sel);
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' });
} catch (_) { /* selector edge case — non-fatal */ }
}, 80);
} else {
renderFolders();
}
return;
}
var p = ancestors[idx++];
var key = folderKey(scope, p);
if (subfolderCache[key]) {
loadNext();
} else {
loadSubfolders(scope, p, loadNext);
}
}
loadNext();
}
function loadEntries(scope, folderPath, forceRefresh) {
var header = document.getElementById('entries-header');
header.style.display = '';
@@ -1907,16 +2121,39 @@
document.getElementById('entries-content').addEventListener('click', function(e) {
var btn = e.target;
// Open folder from a search result row
var openLink = btn.closest && btn.closest('.search-open-folder');
if (openLink) {
e.preventDefault();
openFolderFromSearch(
openLink.getAttribute('data-open-scope'),
openLink.getAttribute('data-open-folder')
);
return;
}
if (btn.getAttribute('data-connect')) {
var name = btn.getAttribute('data-connect');
var scope = btn.getAttribute('data-scope');
var folder = btn.getAttribute('data-folder');
clearError();
// Find the entry data to check if credentials are stored
// Find the entry data. When the table is showing search results,
// currentEntries is the previously-selected folder's entries —
// look in searchIndex instead.
var entry = null;
for (var i = 0; i < currentEntries.length; i++) {
if (currentEntries[i].name === name) { entry = currentEntries[i]; break; }
if (searchActive && searchIndex) {
for (var si = 0; si < searchIndex.length; si++) {
var r = searchIndex[si];
if (r.scope === scope && r.folderPath === folder && r.entry.name === name) {
entry = r.entry; break;
}
}
} else {
for (var i = 0; i < currentEntries.length; i++) {
if (currentEntries[i].name === name) { entry = currentEntries[i]; break; }
}
}
// Prompt for credentials if prompt_credentials is set or no stored creds
@@ -3128,6 +3365,39 @@
}
});
// Quick-find: '/' focuses search, Esc clears + blurs.
// Skip when typing in any input/textarea or when a modal is open.
var _searchInput = document.getElementById('connections-search');
if (_searchInput) {
_searchInput.addEventListener('input', runSearch);
_searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (_searchInput.value) {
_searchInput.value = '';
runSearch();
} else {
_searchInput.blur();
}
e.stopPropagation();
}
});
}
document.addEventListener('keydown', function(e) {
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return;
var t = document.activeElement;
if (t) {
var tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
}
if (document.querySelector('.modal-overlay.active')) return;
var input = document.getElementById('connections-search');
if (input && !input.disabled) {
e.preventDefault();
input.focus();
input.select();
}
});
// ── My Credentials ──
document.getElementById('my-creds-item').addEventListener('click', function() {
document.getElementById('user-menu').style.display = 'none';
+44
View File
@@ -742,3 +742,47 @@ form.card,
.flow-node-hop { background: var(--hop-bg); color: var(--hop-fg); }
.flow-node-target { background: var(--input); color: var(--accent); font-weight: bold; }
.flow-arrow { color: var(--text-muted); margin: 0 var(--s-1); }
/* ── Connections quick-find ─────────────────────────────────── */
.connections-search {
flex: 1 1 220px;
min-width: 180px;
max-width: 480px;
height: var(--ctl-md);
margin-left: var(--s-2);
margin-right: auto;
}
.connections-search:disabled {
opacity: 0.6;
cursor: progress;
}
.entries-table mark {
background: var(--accent);
color: var(--bg);
padding: 0 0.15em;
border-radius: 2px;
}
.search-folder-cell {
color: var(--text-muted);
font-size: var(--fz-sm);
}
.search-folder-cell .folder-scope {
color: var(--accent);
margin-right: 0.2em;
}
.search-open-folder {
color: var(--accent);
text-decoration: none;
font-size: var(--fz-sm);
white-space: nowrap;
}
.search-open-folder:hover { text-decoration: underline; }
.search-more {
margin-top: var(--s-2);
padding: var(--s-2) var(--s-3);
color: var(--text-muted);
font-size: var(--fz-sm);
font-style: italic;
text-align: center;
border-top: 1px dashed var(--border);
}