From fe3d3adccf3ee2ca9c4f8066f3dbf79b89161aaf Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Wed, 29 Apr 2026 13:35:40 +1000 Subject: [PATCH] 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 . - 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. --- src/api.rs | 82 ++++++++++++ src/main.rs | 1 + static/connections.html | 278 +++++++++++++++++++++++++++++++++++++++- static/rustguac.css | 44 +++++++ 4 files changed, 401 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index 324a216..7be5175 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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(vault): Extension, +) -> 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>, diff --git a/src/main.rs b/src/main.rs index 5c8cc6d..6e4e465 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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( diff --git a/static/connections.html b/static/connections.html index 15cfb5c..cf44fb1 100644 --- a/static/connections.html +++ b/static/connections.html @@ -369,7 +369,7 @@
- + @@ -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 = + '

Select a folder to view entries.

'; + } + } + return; + } + searchActive = true; + if (!searchIndex) { + document.getElementById('entries-content').innerHTML = + '

Indexing connections, please wait...

'; + 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 = '
' + + '

No matches for ' + escapeHtml(query) + '

' + + '
'; + return; + } + var maxResults = 50; + var more = matches.length - maxResults; + var shown = matches.slice(0, maxResults); + var html = '' + + '' + + ''; + shown.forEach(function(m) { + var r = m.row; + var e = r.entry; + var typeLabel = e.session_type.toUpperCase(); + if (e.auth_pkg) typeLabel += ' (' + escapeHtml(e.auth_pkg) + ')'; + 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 += ' [prompt]'; + var scopeIcon = r.scope === 'shared' ? '⊕' : '▣'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
NameTypeHostUserFolder
' + highlight(r.label, tokens) + '' + typeLabel + '' + highlight(r.host || '', tokens) + '' + userCol + '' + scopeIcon + ' ' + highlight(r.folderPath, tokens) + '↗ open folder
'; + if (more > 0) { + html += '
+' + more + ' more match' + (more === 1 ? '' : 'es') + ' — refine your search to narrow results
'; + } + 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, '$1'); + } + + 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'; diff --git a/static/rustguac.css b/static/rustguac.css index 5795f9d..56c1825 100644 --- a/static/rustguac.css +++ b/static/rustguac.css @@ -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); +}