From da822390762ffd9162e96320f4272217a051c802 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Tue, 13 Oct 2026 13:58:04 +0700 Subject: [PATCH] connections: live refresh driven by an address-book change counter connections: live refresh from a per-folder address-book change counter The Connections page keeps itself current without reloading. rustguac keeps an in-memory change log of the address book: every successful write through its API (entries, folders, folder config, moves, copies, bulk) is logged against the folders it touched under a version counter. The page polls GET /api/addressbook/version?since=N every 3 seconds while visible and gets back only what changed after the version it last saw: one row per folder, saying whether its entries or the folder itself moved. The poll is answered from memory; it reads nothing from Vault. A tab acts only on what it shows. Entries changed in the open folder: that folder is re-read. Entries changed elsewhere: the cached copy is forgotten and nothing is read until the folder is opened. A folder created, deleted or reconfigured: the folder list is re-read (no entries), plus the open folder if it was in that subtree. The search index is never rebuilt on a poll: it is marked stale and rebuilt the next time the box is focused or typed in; while results are showing, only the changed folder's rows are re-read and patched in. So one change costs a tab at most one folder's entries plus one folder-list listing, never the whole book, however many tabs are open. There is no timed refresh. A null answer (first poll, restart, or a tab further behind than the 512-entry log reaches) refreshes what is shown once, under the same bound. Folders are attributed from the request path; the four routes whose folders only the body knows (create folder, move, copy, bulk) report them from the handler through a response extension, and a handler that reports nothing is logged as `all` so a page refreshes rather than misses it. A user's own credential variables (/api/me/credentials) are not address-book changes and never reach the log: the saving tab reloads its own status, and no other tab needs to know. Hidden tabs do not poll and catch up with an immediate check when shown; a tab with a dialog open does not poll either, so the change waits for the dialog instead of being missed. Tests: the attribution table, the log's since/dedup/eviction/restart answers, the middleware on a router (a credentials save logs nothing, a failed write logs nothing, reported folders are logged precisely), and a plain-node test pinning what the page re-reads for each change. Closes #253 (part 3/3). --- docs/api.md | 19 ++ src/api.rs | 605 ++++++++++++++++++++++++++++++++++++- src/main.rs | 8 +- static/connections.html | 351 +++++++++++++++++++-- tests/connections-live.mjs | 182 +++++++++++ 5 files changed, 1123 insertions(+), 42 deletions(-) create mode 100644 tests/connections-live.mjs diff --git a/docs/api.md b/docs/api.md index b278de79..cc694ec4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -655,6 +655,25 @@ The response lists what happened to each entry, plus a count per outcome: `status` is one of `moved`, `copied`, `deleted`, `skipped`, `failed`, or `copied_not_removed` (a move whose copy was written but whose original could not be removed, so the entry is in both folders). A renamed copy also carries `new_name`. Every entry acted on is recorded in the address book audit log. +### `GET /api/addressbook/version?since=N` + +The address book's change log, answered from memory: no Vault or disk read, so clients may poll it often. Requires **operator**. `version` counts every successful change made through this API to entries, folders and folder config (moves, copies and bulk operations included); `changed_at` is unix milliseconds, `0` before the first change since start. With `since`, `changes` lists what moved after that version, one row per folder: `kind` is `entries` (entries in the folder were written), `tree` (the folder itself was created, deleted or its config changed) or `all` (not attributable; refresh everything). It is `null` when `since` is omitted, when the log no longer reaches back that far (it keeps the last few hundred changes), or when the server restarted since, and the client should refresh what it shows once. + +A user's own credential variables (`/api/me/credentials`) are not address-book changes and never appear here. The log is per process: with several rustguac instances on one Vault, each sees only its own changes. + +The Connections page polls this every 3 seconds while visible and re-reads only the folder it shows, or the folder list when a folder came or went; its search index is rebuilt only when search is next used. + +```json +{ + "version": 17, + "changed_at": 1760155200000, + "changes": [ + { "scope": "shared", "folder": "customers/acme", "kind": "entries" }, + { "scope": "instance", "folder": "lab", "kind": "tree" } + ] +} +``` + ### `GET /api/addressbook/folder-paths` (admin) Every folder in both scopes, as `{ "folders": [{ "scope": "shared", "path": "servers/linux" }, ...] }`. Used to choose a target for bulk moves and copies. diff --git a/src/api.rs b/src/api.rs index cfe236da..48c2a664 100644 --- a/src/api.rs +++ b/src/api.rs @@ -11,7 +11,7 @@ use axum::{ response::{IntoResponse, Redirect, Response}, Extension, Json, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::net::SocketAddr; use std::sync::Arc; @@ -2517,6 +2517,322 @@ pub async fn ab_list_subfolders( } } +// ── Address book change log ── +// +// The Connections page keeps itself current by polling +// `GET /api/addressbook/version?since=N` every few seconds. The answer comes +// from this in-memory log and names the folders that changed since the +// version the page last saw, so a page re-reads only what it shows: the +// open folder when its entries changed, the folder list when a folder came +// or went. The search index is never rebuilt on a poll; the page marks it +// stale and walks the book again only when the box is next used. Per +// change, an open tab therefore costs Vault at most one folder's entries +// plus one folder-list listing, however many tabs are open; a poll costs +// nothing. +// +// Changes are attributed from the request path (`ab_changes_for_request`). +// Routes whose folders only the body knows (create folder, move, copy, bulk) +// report them through an `AbChanged` response extension; should one not, +// the change is logged as `all` and the page refreshes what it shows. +// +// Per-user credential variables (`/api/me/credentials`) are not address-book +// changes and never touch this log: the tab that saved them reloads its own +// credential status, and no other tab needs to know. +// +// The log is per process. A second rustguac on the same Vault, or a direct +// Vault write, is not seen; those take a page reload, as they always did. + +/// What changed, as the Connections page needs to know it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AbChange { + /// "shared" or "instance"; "*" for `All`. + pub scope: String, + /// Folder path, `a/b` for a subfolder; empty for `All`. + pub folder: String, + pub kind: AbChangeKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AbChangeKind { + /// Entries in the folder were created, updated, deleted, moved or copied. + Entries, + /// The folder itself: created, deleted, or its config changed. + Tree, + /// Could not be attributed: the page refreshes everything it shows. + All, +} + +impl AbChange { + pub fn entries(scope: &str, folder: &str) -> Self { + AbChange { + scope: scope.to_string(), + folder: folder.to_string(), + kind: AbChangeKind::Entries, + } + } + pub fn tree(scope: &str, folder: &str) -> Self { + AbChange { + scope: scope.to_string(), + folder: folder.to_string(), + kind: AbChangeKind::Tree, + } + } + pub fn all() -> Self { + AbChange { + scope: "*".to_string(), + folder: String::new(), + kind: AbChangeKind::All, + } + } +} + +/// Response extension a handler sets to name the folders it changed when +/// the request path alone does not say (the target of a move, the folder a +/// POST created, every folder a bulk request touched). +#[derive(Clone, Debug)] +pub struct AbChanged(pub Vec); + +/// Attach the folders a handler changed to its response, for `ab_change_track`. +fn with_ab_changes(mut response: Response, changes: Vec) -> Response { + response.extensions_mut().insert(AbChanged(changes)); + response +} + +/// How many individual changes the log keeps. A page that has not polled +/// for longer than it takes to make this many changes gets `changes: null` +/// and refreshes what it shows, so the bound on reads holds either way. +const AB_CHANGE_LOG_LEN: usize = 512; + +/// The change log: a version counter and the recent changes behind it. +#[derive(Clone, Default)] +pub struct AddressBookChanges { + inner: Arc>, +} + +#[derive(Default)] +struct AbChangeLog { + version: u64, + changed_at_ms: u64, + /// `(version, change)`, oldest first. One `record` call may push several + /// changes under the same version. + recent: std::collections::VecDeque<(u64, AbChange)>, + /// Lowest version every change of which is still in `recent`. Eviction + /// moves it past the version it evicted from, so a partially evicted + /// version is never reported as complete. + oldest_complete: u64, +} + +/// Answer to `since`. +pub struct AbChangesSince { + pub version: u64, + pub changed_at_ms: u64, + /// `None` when the log no longer reaches back to `since`. + pub changes: Option>, +} + +impl AddressBookChanges { + /// Log a change set under one new version; returns that version. + pub fn record(&self, changes: Vec) -> u64 { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let mut log = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + log.version += 1; + log.changed_at_ms = now_ms; + let version = log.version; + if log.recent.is_empty() { + log.oldest_complete = version; + } + for change in changes { + if log.recent.len() >= AB_CHANGE_LOG_LEN { + if let Some((evicted, _)) = log.recent.pop_front() { + log.oldest_complete = log.oldest_complete.max(evicted + 1); + } + } + log.recent.push_back((version, change)); + } + version + } + + /// Current version and timestamp (unix ms, 0 before the first change). + pub fn snapshot(&self) -> (u64, u64) { + let log = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + (log.version, log.changed_at_ms) + } + + /// Everything logged after version `since`, deduplicated, or `None` if + /// the log has already dropped part of that range. `All` swallows the + /// rest: the page refreshes what it shows once. + pub fn since(&self, since: u64) -> AbChangesSince { + let log = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let (version, changed_at_ms) = (log.version, log.changed_at_ms); + if since == version { + return AbChangesSince { + version, + changed_at_ms, + changes: Some(Vec::new()), + }; + } + // Everything in (since, version] must still be in the log. A page + // ahead of the log saw a previous process: it refreshes too. With an + // empty log the only reachable case is `since == version`, above. + if since > version || log.recent.is_empty() || since + 1 < log.oldest_complete { + return AbChangesSince { + version, + changed_at_ms, + changes: None, + }; + } + let mut out: Vec = Vec::new(); + for (v, change) in log.recent.iter() { + if *v <= since { + continue; + } + if change.kind == AbChangeKind::All { + return AbChangesSince { + version, + changed_at_ms, + changes: Some(vec![AbChange::all()]), + }; + } + if !out.contains(change) { + out.push(change.clone()); + } + } + AbChangesSince { + version, + changed_at_ms, + changes: Some(out), + } + } +} + +/// The address-book changes a request implies, from its method and path. +/// +/// `None`: not an address-book change (reads, connects, and everything +/// outside `/api/addressbook`, `/api/me/credentials` included). Otherwise +/// the changes the path names and whether that is all of them; when it is +/// not, the handler reports the rest (`AbChanged`) or the change is logged +/// as `All`. Folder segments arrive percent-encoded (`a%2Fb`) and are +/// decoded here, as the routes would. +pub fn ab_changes_for_request( + method: &axum::http::Method, + path: &str, +) -> Option<(Vec, bool)> { + use axum::http::Method; + if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) { + return None; + } + let rest = path.strip_prefix("/api/addressbook")?; + let segs: Vec = rest + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| { + urlencoding::decode(s) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| s.to_string()) + }) + .collect(); + let seg = |i: usize| segs.get(i).map(String::as_str); + match (seg(0), seg(1), seg(2), seg(3), seg(4), seg(5)) { + // POST /folders: the folder is in the body. + (Some("folders"), None, ..) => Some((Vec::new(), false)), + // POST /bulk: the folders are in the body. + (Some("bulk"), None, ..) => Some((Vec::new(), false)), + // PUT|DELETE /folders/{scope}/{folder} + (Some("folders"), Some(scope), Some(folder), None, ..) => { + Some((vec![AbChange::tree(scope, folder)], true)) + } + // POST /folders/{scope}/{folder}/entries + (Some("folders"), Some(scope), Some(folder), Some("entries"), None, _) => { + Some((vec![AbChange::entries(scope, folder)], true)) + } + // PUT|DELETE /folders/{scope}/{folder}/entries/{entry} + (Some("folders"), Some(scope), Some(folder), Some("entries"), Some(_), None) => { + Some((vec![AbChange::entries(scope, folder)], true)) + } + // POST .../entries/{entry}/connect starts a session, changes nothing. + (Some("folders"), Some(_), Some(_), Some("entries"), Some(_), Some("connect")) => None, + // POST .../entries/{entry}/move|copy: the target is in the body. + (Some("folders"), Some(scope), Some(folder), Some("entries"), Some(_), Some(_)) => { + Some((vec![AbChange::entries(scope, folder)], false)) + } + // Anything else that writes under the prefix: refresh everything + // rather than miss it. + _ => Some((Vec::new(), false)), + } +} + +/// Middleware: log the address-book changes of every successful mutating +/// request. Sits inside the auth layer, so only requests that were allowed +/// to change anything get here. +pub async fn ab_change_track( + changes: Option>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let implied = ab_changes_for_request(request.method(), request.uri().path()); + let response = next.run(request).await; + if let Some((mut found, mut complete)) = implied { + if response.status().is_success() { + if let Some(AbChanged(reported)) = response.extensions().get::() { + found.extend(reported.iter().cloned()); + complete = true; + } + if !complete { + found.push(AbChange::all()); + } + if let Some(Extension(log)) = changes { + log.record(found); + } + } + } + response +} + +#[derive(Deserialize, Default)] +pub struct AbVersionQuery { + pub since: Option, +} + +/// GET /api/addressbook/version?since=N — the change log since version N, +/// answered from memory. Requires operator. +pub async fn ab_version( + identity: Option>, + changes: Option>, + Query(q): Query, +) -> impl IntoResponse { + if !matches!(identity, Some(Extension(ref id)) if id.has_role("operator")) { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "operator role required"})), + ) + .into_response(); + } + let Some(Extension(log)) = changes else { + return Json(json!({ "version": 0, "changed_at": 0, "changes": null })).into_response(); + }; + let answer = match q.since { + Some(since) => log.since(since), + None => { + let (version, changed_at_ms) = log.snapshot(); + AbChangesSince { + version, + changed_at_ms, + changes: None, + } + } + }; + Json(json!({ + "version": answer.version, + "changed_at": answer.changed_at_ms, + "changes": answer.changes, + })) + .into_response() +} + /// GET /api/addressbook — Batch endpoint returning all visible folders with entries. /// Replaces the N+1 pattern of listing folders then entries per folder. pub async fn ab_list_all( @@ -3203,7 +3519,10 @@ pub async fn ab_create_folder( Some(&details), ) .await; - (StatusCode::CREATED, Json(json!({"ok": true}))).into_response() + with_ab_changes( + (StatusCode::CREATED, Json(json!({"ok": true}))).into_response(), + vec![AbChange::tree(&req.scope, &req.name)], + ) } Err(e) => ( StatusCode::BAD_GATEWAY, @@ -3717,7 +4036,13 @@ pub async fn ab_move_entry( Some(&details), ) .await; - (StatusCode::OK, Json(json!({"ok": true}))).into_response() + with_ab_changes( + (StatusCode::OK, Json(json!({"ok": true}))).into_response(), + vec![ + AbChange::entries(&scope, &folder), + AbChange::entries(&req.target_scope, &req.target_folder), + ], + ) } /// Most entries one bulk request may name. @@ -4045,11 +4370,29 @@ pub async fn ab_bulk( *summary.entry(s.to_string()).or_default() += 1; } } - ( - StatusCode::OK, - Json(json!({ "results": results, "summary": summary })), + // Every source folder and the target, for the change log; which of them + // actually changed is not worth a per-item account. + let mut touched: Vec = Vec::new(); + for item in &req.items { + let c = AbChange::entries(&item.scope, &item.folder); + if !touched.contains(&c) { + touched.push(c); + } + } + if let Some(t) = &req.target { + let c = AbChange::entries(&t.scope, &t.folder); + if !touched.contains(&c) { + touched.push(c); + } + } + with_ab_changes( + ( + StatusCode::OK, + Json(json!({ "results": results, "summary": summary })), + ) + .into_response(), + touched, ) - .into_response() } /// GET /api/addressbook/folder-paths @@ -4142,7 +4485,13 @@ pub async fn ab_copy_entry( Some(&details), ) .await; - (StatusCode::CREATED, Json(json!({"ok": true}))).into_response() + with_ab_changes( + (StatusCode::CREATED, Json(json!({"ok": true}))).into_response(), + vec![ + AbChange::entries(&scope, &folder), + AbChange::entries(&req.target_scope, &req.target_folder), + ], + ) } /// DELETE /api/addressbook/folders/:scope/:folder/entries/:entry — Delete an entry. Admin only. @@ -5778,6 +6127,246 @@ fn html_escape(s: &str) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn address_book_change_attribution() { + use axum::http::Method; + let f = ab_changes_for_request; + // entries: create, update, delete; the folder segment is decoded + assert_eq!( + f(&Method::POST, "/api/addressbook/folders/shared/x/entries"), + Some((vec![AbChange::entries("shared", "x")], true)) + ); + assert_eq!( + f( + &Method::PUT, + "/api/addressbook/folders/shared/a%2Fb/entries/y" + ), + Some((vec![AbChange::entries("shared", "a/b")], true)) + ); + assert_eq!( + f( + &Method::DELETE, + "/api/addressbook/folders/instance/x/entries/y" + ), + Some((vec![AbChange::entries("instance", "x")], true)) + ); + // the folder itself + assert_eq!( + f(&Method::PUT, "/api/addressbook/folders/shared/a%2Fb"), + Some((vec![AbChange::tree("shared", "a/b")], true)) + ); + assert_eq!( + f(&Method::DELETE, "/api/addressbook/folders/shared/x"), + Some((vec![AbChange::tree("shared", "x")], true)) + ); + // the body knows the rest: the handler reports it + assert_eq!( + f(&Method::POST, "/api/addressbook/folders"), + Some((vec![], false)) + ); + assert_eq!( + f(&Method::POST, "/api/addressbook/bulk"), + Some((vec![], false)) + ); + assert_eq!( + f( + &Method::POST, + "/api/addressbook/folders/shared/x/entries/y/move" + ), + Some((vec![AbChange::entries("shared", "x")], false)) + ); + assert_eq!( + f( + &Method::POST, + "/api/addressbook/folders/shared/x/entries/y/copy" + ), + Some((vec![AbChange::entries("shared", "x")], false)) + ); + // reads and connects are not changes + assert_eq!(f(&Method::GET, "/api/addressbook"), None); + assert_eq!(f(&Method::GET, "/api/addressbook/version"), None); + assert_eq!(f(&Method::GET, "/api/addressbook/search-index"), None); + assert_eq!( + f( + &Method::POST, + "/api/addressbook/folders/shared/x/entries/y/connect" + ), + None + ); + assert_eq!(f(&Method::POST, "/api/sessions"), None); + // a user's own credential variables are not an address-book change: + // saving them must not touch every other tab + assert_eq!(f(&Method::PUT, "/api/me/credentials"), None); + assert_eq!(f(&Method::GET, "/api/me/credentials"), None); + } + + #[test] + fn address_book_change_log_since() { + let log = AddressBookChanges::default(); + assert_eq!(log.snapshot(), (0, 0)); + let s = log.since(0); + assert_eq!((s.version, s.changes), (0, Some(vec![]))); + + assert_eq!(log.record(vec![AbChange::entries("shared", "a")]), 1); + assert_eq!(log.record(vec![AbChange::entries("shared", "a")]), 2); + assert_eq!( + log.record(vec![ + AbChange::tree("shared", "b"), + AbChange::entries("instance", "c"), + ]), + 3 + ); + let (v, t) = log.snapshot(); + assert_eq!(v, 3); + assert!(t > 1_600_000_000_000); + + // everything since 0, repeats collapsed, order kept + let s = log.since(0); + assert_eq!( + s.changes, + Some(vec![ + AbChange::entries("shared", "a"), + AbChange::tree("shared", "b"), + AbChange::entries("instance", "c"), + ]) + ); + // only what came after the version the page saw + assert_eq!( + log.since(2).changes, + Some(vec![ + AbChange::tree("shared", "b"), + AbChange::entries("instance", "c"), + ]) + ); + assert_eq!(log.since(3).changes, Some(vec![])); + // a page ahead of the log saw a previous process: told to refresh, + // not that nothing moved, or it would wait for the count to catch up + assert_eq!(log.since(99).changes, None); + + // an unattributed change swallows the rest + log.record(vec![AbChange::all()]); + assert_eq!(log.since(0).changes, Some(vec![AbChange::all()])); + assert_eq!(log.since(3).changes, Some(vec![AbChange::all()])); + assert_eq!( + log.since(4).changes, + Some(vec![]), + "the swallow applies only to what the page has not seen" + ); + } + + #[test] + fn address_book_change_log_forgets_the_oldest() { + let log = AddressBookChanges::default(); + // Two changes per version: the ring then holds LEN/2 versions. + for i in 0..(AB_CHANGE_LOG_LEN as u64) { + log.record(vec![ + AbChange::entries("shared", &format!("f{i}")), + AbChange::tree("shared", &format!("f{i}")), + ]); + } + let version = log.snapshot().0; + assert_eq!(version, AB_CHANGE_LOG_LEN as u64); + let oldest_kept = version - (AB_CHANGE_LOG_LEN as u64 / 2) + 1; + // Reachable: every version after `since` is still whole in the log. + assert!(log.since(oldest_kept - 1).changes.is_some()); + assert_eq!( + log.since(oldest_kept - 1).changes.unwrap().len(), + AB_CHANGE_LOG_LEN + ); + // One further back and part of the range is gone: say so, rather + // than hand the page a partial list it would trust. + assert!(log.since(oldest_kept - 2).changes.is_none()); + assert!(log.since(0).changes.is_none()); + // Still exact at the newest end. + assert_eq!(log.since(version - 1).changes.unwrap().len(), 2); + } + + /// The middleware, on a router: what reaches the log for each kind of + /// request, and that a user's credential variables never do. + #[tokio::test] + async fn address_book_change_track_middleware() { + use axum::body::Body; + use axum::http::{Method, Request}; + use axum::routing::{post, put}; + use axum::Router; + use tower::ServiceExt; + + let log = AddressBookChanges::default(); + let app = Router::new() + .route("/api/me/credentials", put(|| async { "ok" })) + .route( + "/api/addressbook/folders/{scope}/{folder}/entries/{entry}", + put(|| async { "ok" }), + ) + .route( + "/api/addressbook/folders/{scope}/{folder}/entries", + post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "vault down") }), + ) + .route( + "/api/addressbook/folders", + post(|| async { + with_ab_changes( + (StatusCode::CREATED, "ok").into_response(), + vec![AbChange::tree("instance", "new/sub")], + ) + }), + ) + .route("/api/addressbook/bulk", post(|| async { "ok" })) + .layer(axum::middleware::from_fn(ab_change_track)) + .layer(Extension(log.clone())); + + let send = |method: Method, uri: &str| { + let app = app.clone(); + let req = Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap(); + async move { app.oneshot(req).await.unwrap().status() } + }; + + // A user saving their own credential variables: nothing logged. + assert!(send(Method::PUT, "/api/me/credentials").await.is_success()); + assert_eq!(log.snapshot().0, 0); + + // An entry write: logged against its (decoded) folder. + assert!(send( + Method::PUT, + "/api/addressbook/folders/shared/a%2Fb/entries/host1" + ) + .await + .is_success()); + assert_eq!(log.snapshot().0, 1); + assert_eq!( + log.since(0).changes, + Some(vec![AbChange::entries("shared", "a/b")]) + ); + + // A failed write changes nothing, so it is not logged. + assert_eq!( + send(Method::POST, "/api/addressbook/folders/shared/x/entries").await, + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!(log.snapshot().0, 1); + + // A handler that reports its folders: logged precisely, not as `all`. + assert!(send(Method::POST, "/api/addressbook/folders") + .await + .is_success()); + assert_eq!( + log.since(1).changes, + Some(vec![AbChange::tree("instance", "new/sub")]) + ); + + // A body-dependent route whose handler reports nothing: `all`, so + // the page refreshes rather than misses it. + assert!(send(Method::POST, "/api/addressbook/bulk") + .await + .is_success()); + assert_eq!(log.since(2).changes, Some(vec![AbChange::all()])); + assert_eq!(log.snapshot().0, 3); + } use std::path::Path; #[test] diff --git a/src/main.rs b/src/main.rs index 06f018b9..830ad21c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -898,6 +898,7 @@ async fn run_server(config: Config, database: Db) { let oidc_enabled = OidcEnabled(oidc_state.is_some()); let vault_configured = VaultConfigured(config.vault.is_some()); + let ab_changes = api::AddressBookChanges::default(); let credential_default_scope = CredentialDefaultScope(config.user_credentials_default_scope.clone()); let drive_configured = DriveConfigured(config.drive.is_some()); @@ -1268,6 +1269,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/version", get(api::ab_version)) .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)) @@ -1327,11 +1329,15 @@ async fn run_server(config: Config, database: Db) { .merge(session_create_route) .with_state(manager.clone()) .layer(middleware::from_fn(auth::require_auth)) + // Inside auth: only requests allowed to change the address book + // are logged. The log itself is layered below so this sees it. + .layer(middleware::from_fn(api::ab_change_track)) .layer(Extension(ws_ticket_store.clone())) .layer(Extension(vault_client.clone())) .layer(Extension(vault_configured.clone())) .layer(Extension(credential_default_scope.clone())) - .layer(Extension(database.clone())); + .layer(Extension(database.clone())) + .layer(Extension(ab_changes.clone())); // Applied OUTSIDE require_auth so requests with bad credentials are // counted too: a flood of bogus keys is throttled before each one // queues on the database lock to be looked up. diff --git a/static/connections.html b/static/connections.html index 4b0f0b24..449dcf45 100644 --- a/static/connections.html +++ b/static/connections.html @@ -1330,6 +1330,7 @@

Enter Credentials

// Quick-find search state var searchIndex = null; // [{scope, folderPath, entry, label, host, haystack}] var searchIndexLoading = false; + var searchIndexStale = false; // the book changed since the index was built; rebuilt when search is next used var searchQuery = ''; // current query (lowercased & trimmed) var searchActive = false; // entries-content showing results, not folder entries function folderKey(scope, path) { return scope + '|' + path; } @@ -1706,6 +1707,9 @@

Enter Credentials

} return; } + // The first poll records the version this page is about to + // load; issued before the load so nothing in between is missed. + startLiveRefresh(); loadFolders(); loadCredStatus(); loadActiveSessions(); @@ -2218,17 +2222,318 @@

Enter Credentials

return null; } + // ── Live refresh ── + // GET /api/addressbook/version?since=N answers from memory with what + // rustguac changed in the address book after version N: which folders, + // and whether it was their entries or the folder itself. It is polled + // every few seconds while the tab is visible and no dialog is open, + // and a tab acts only on what it shows: + // entries changed in the open folder -> that folder is re-read + // entries changed elsewhere -> the cached copy is forgotten; + // nothing is read until opened + // a folder created, deleted or changed -> the folder list is re-read + // (no entries), plus the open + // folder if it was in that subtree + // the search index -> marked stale and rebuilt the + // next time the box is used; + // while results are showing, only + // the changed folder's rows are + // re-read and patched in + // So one change costs a tab at most one folder's entries plus one + // folder list, never the whole book, and a poll costs no Vault read. + // `changes: null` (first poll, or the tab fell further behind than + // the server's log reaches, or the server restarted) refreshes what + // is shown, once. Nothing is reloaded on a timer. A hidden tab does + // not poll and catches up the moment it is shown; a tab with a + // dialog open does not poll either, so a change waits for the dialog + // instead of being missed. A user's own credential variables are not + // address-book changes and never arrive here: the tab that saved them + // reloads its own status (saveMyCredentials). + var LIVE_POLL_MS = 3000; + var liveVersion = null; // the version this tab's view reflects + var liveTimer = null; + var liveBusy = false; + + // What a batch of changes means for this tab. Pure, so it is tested + // without a browser (tests/connections-live.mjs). + // changes: [{scope, folder, kind: 'entries'|'tree'|'all'}], or null + // to refresh everything shown + // view: { selectedKey, searchActive, indexLoaded, cachedKeys } + // Returns { tree, treeAll, treeKeys, fetch: [key], drop: [key], + // index: 'keep'|'stale'|'rebuild' }: `fetch` is re-read now + // (the open folder, or a folder whose index rows to patch), + // `drop` is forgotten until opened. + function livePlan(changes, view) { + var plan = { tree: false, treeAll: false, treeKeys: [], fetch: [], drop: [], index: 'keep' }; + var all = !changes; + var stale = false; + var touched = {}; + var under = function(k, key) { return k === key || k.indexOf(key + '/') === 0; }; + (changes || []).forEach(function(c) { if (c.kind === 'all') all = true; }); + if (!all) changes.forEach(function(c) { + var key = c.scope + '|' + c.folder; + if (c.kind === 'tree') { + plan.tree = true; + plan.treeKeys.push(key); + stale = true; // paths or visibility moved: only a walk knows + // Whatever was cached below the folder went with it. + view.cachedKeys.forEach(function(k) { if (under(k, key)) touched[k] = 'drop'; }); + if (view.selectedKey && under(view.selectedKey, key)) touched[view.selectedKey] = 'fetch'; + } else if (key === view.selectedKey) { + touched[key] = 'fetch'; + } else if (view.searchActive && view.indexLoaded) { + touched[key] = touched[key] || 'patch'; + } else { + touched[key] = touched[key] || 'drop'; + stale = true; + } + }); + if (all) { + plan.tree = true; + plan.treeAll = true; + stale = true; + view.cachedKeys.forEach(function(k) { touched[k] = 'drop'; }); + if (view.selectedKey) touched[view.selectedKey] = 'fetch'; + } + if (stale) plan.index = (view.searchActive && view.indexLoaded) ? 'rebuild' : 'stale'; + Object.keys(touched).forEach(function(k) { + var what = touched[k]; + // While searching the open folder is not on screen: patch the + // index from it, or just forget it when the index is being + // rebuilt anyway. + if (what === 'fetch' && view.searchActive) what = 'patch'; + if (what === 'patch' && plan.index === 'rebuild') what = 'drop'; + if (what === 'drop') plan.drop.push(k); else plan.fetch.push(k); + }); + return plan; + } + + function liveView() { + return { + selectedKey: selectedFolder ? folderKey(selectedFolder.scope, folderPath(selectedFolder)) : null, + searchActive: searchActive, + indexLoaded: !!searchIndex, + cachedKeys: Object.keys(folderEntries) + }; + } + + function liveApply(plan) { + plan.drop.forEach(function(k) { delete folderEntries[k]; delete folderEntryCounts[k]; }); + if (plan.index === 'stale') searchIndexStale = true; + if (plan.index === 'rebuild') loadSearchIndex(true); + var fetchFolders = function() { + plan.fetch.forEach(function(k) { + var sep = k.indexOf('|'); + var scope = k.slice(0, sep), path = k.slice(sep + 1); + if (!searchActive && liveView().selectedKey === k) { + loadEntries(scope, path, true); + } else { + refetchFolderEntries(scope, path); + } + }); + }; + if (plan.tree) reloadFolderList(plan, fetchFolders); else fetchFolders(); + } + + // Re-read one folder's entries into the cache and the search index + // without showing it: a changed folder while search results are up. + function refetchFolderEntries(scope, path) { + var key = folderKey(scope, path); + fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(path) + '/entries', { + headers: apiHeaders(), + credentials: 'same-origin' + }) + .then(function(res) { return res.ok ? res.json() : []; }) + .then(function(entries) { + if (Array.isArray(entries) && entries.length) { + folderEntries[key] = entries; + folderEntryCounts[key] = entries.length; + } else { + delete folderEntries[key]; + delete folderEntryCounts[key]; + entries = []; + } + indexPutFolder(scope, path, entries); + renderFolders(); + if (searchActive && searchQuery) runSearch(); + }) + .catch(function() {}); + } + + // Re-read the folder list alone (no entries) after a folder came, + // went or changed, then the subfolder listings that change made + // stale: the changed folder's parent (it came or went there), its + // own, and its subtree's. The open folder is re-resolved; if it is + // gone the first folder is shown, as after deleting one here. + function reloadFolderList(plan, cb) { + fetch('/api/addressbook/folders', { 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(list) { + folders = (list || []).map(function(f) { + return { + name: f.name, + scope: f.scope, + description: f.description || '', + path: f.path || f.name, + has_children: !!f.has_children + }; + }); + var parentOf = function(key) { var i = key.lastIndexOf('/'); return i < 0 ? null : key.slice(0, i); }; + Object.keys(subfolderCache).forEach(function(k) { + var stale = plan.treeAll || plan.treeKeys.some(function(key) { + return k === key || k === parentOf(key) || k.indexOf(key + '/') === 0; + }); + if (stale) delete subfolderCache[k]; + }); + var emptyEl = document.getElementById('empty-state'); + var mainEl = document.getElementById('main-content'); + if (folders.length === 0 && unavailableScopes.length === 0 && isAdmin) { + emptyEl.style.display = ''; + mainEl.style.display = 'none'; + } else { + emptyEl.style.display = 'none'; + mainEl.style.display = ''; + } + restoreExpandedTree(function() { + resolveSelectedFolder(function(sel) { + if (selectedFolder && !sel) { + var goneKey = folderKey(selectedFolder.scope, folderPath(selectedFolder)); + plan.fetch = plan.fetch.filter(function(k) { return k !== goneKey; }); + selectedFolder = folders[0] || null; + saveSelectedFolder(); + if (selectedFolder && !searchActive) { + loadEntries(selectedFolder.scope, folderPath(selectedFolder)); + } else if (!selectedFolder) { + document.getElementById('entries-header').style.display = 'none'; + document.getElementById('entries-content').innerHTML = '

Select a folder to view entries.

'; + } + } else if (sel) { + selectedFolder = sel; + } + renderFolders(); + if (cb) cb(); + }); + }); + }) + .catch(function(err) { + showError('Failed to refresh folders: ' + err.message); + if (cb) cb(); + }); + } + + // The open folder's object in the rebuilt tree, loading the listings + // of collapsed ancestors it hides behind; null if it no longer exists. + function resolveSelectedFolder(cb) { + if (!selectedFolder) { cb(null); return; } + var scope = selectedFolder.scope, path = folderPath(selectedFolder); + var parts = path.split('/'); + var ancestors = []; + for (var i = 1; i < parts.length; i++) ancestors.push(parts.slice(0, i).join('/')); + var idx = 0; + (function next() { + if (idx >= ancestors.length) { cb(findFolder(scope, path)); return; } + var p = ancestors[idx++]; + if (!findFolder(scope, p)) { cb(null); return; } + if (subfolderCache[folderKey(scope, p)]) next(); else loadSubfolders(scope, p, next); + })(); + } + + function livePoll() { + if (liveBusy) return; + // The baseline is taken whatever the tab's state, or a page loaded + // in a background tab would miss what changed before it was shown. + // After that a hidden tab does not poll, and a dialog may be + // editing what just changed: the poll waits for it, and the change + // is picked up once it closes. + if (liveVersion !== null && (document.hidden || document.querySelector('.modal-overlay.active'))) return; + liveBusy = true; + var url = '/api/addressbook/version' + (liveVersion === null ? '' : '?since=' + liveVersion); + fetch(url, { headers: apiHeaders(), credentials: 'same-origin' }) + .then(function(res) { return res.ok ? res.json() : null; }) + .then(function(data) { + if (!data || typeof data.version !== 'number') return; + if (liveVersion === null) { + // Baseline: the page is loading this version now. + liveVersion = data.version; + return; + } + if (data.version === liveVersion && data.changes) return; + liveVersion = data.version; + liveApply(livePlan(data.changes, liveView())); + }) + .catch(function() {}) + .then(function() { liveBusy = false; }); + } + function startLiveRefresh() { + if (liveTimer) return; + livePoll(); + liveTimer = setInterval(livePoll, LIVE_POLL_MS); + document.addEventListener('visibilitychange', function() { if (!document.hidden) livePoll(); }); + window.addEventListener('focus', livePoll); + } + // ── 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. + // Index is fetched once after loadFolders() and after any mutation + // this page makes that already triggers a full reload. A change seen + // by the live refresh only marks it stale; it is rebuilt (quietly, + // without the "Indexing..." state that would blank the box + // mid-typing) the next time the box is focused or typed in. Search + // runs entirely client-side against the in-memory haystack. + + function searchRow(scope, folderPathStr, e) { + var label = e.display_name || e.name; + var host = e.session_type === 'web' ? (e.url || '') + : e.session_type === 'vdi' ? (e.container_image || '') + : e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : '')) + : (e.hostname || ''); + var haystack = [ + e.name, + e.display_name || '', + host, + e.username || '', + e.proxmox_token_id || '', + e.url || '', + e.domain || '', + e.session_type, + folderPathStr + ].join(' ').toLowerCase(); + return { + scope: scope, + folderPath: folderPathStr, + entry: e, + label: label, + host: host, + haystack: haystack + }; + } + + // Replace one folder's rows in the index with entries just read for + // it, so a folder re-read for the view keeps the index exact for free. + function indexPutFolder(scope, folderPathStr, entries) { + if (!searchIndex) return; + searchIndex = searchIndex.filter(function(r) { + return !(r.scope === scope && r.folderPath === folderPathStr); + }); + (entries || []).forEach(function(e) { searchIndex.push(searchRow(scope, folderPathStr, e)); }); + } + + // Rebuild a stale index the moment search is used, not before. + function refreshSearchIndexIfStale() { + if (searchIndexStale && !searchIndexLoading) loadSearchIndex(true); + } - function loadSearchIndex() { + function loadSearchIndex(quiet) { var input = document.getElementById('connections-search'); if (!input) return; searchIndexLoading = true; - input.placeholder = 'Indexing...'; - input.disabled = true; + searchIndexStale = false; + if (!quiet) { + 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); }); @@ -2236,33 +2541,7 @@

Enter Credentials

}) .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.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : '')) - : (e.hostname || ''); - var haystack = [ - e.name, - e.display_name || '', - host, - e.username || '', - e.proxmox_token_id || '', - 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 - }; - }); + searchIndex = rows.map(function(r) { return searchRow(r.scope, r.folder_path, r.entry); }); searchIndexLoading = false; input.placeholder = 'Search ' + searchIndex.length + ' connection' + (searchIndex.length === 1 ? '' : 's') + '... (press / )'; input.disabled = false; @@ -2273,6 +2552,7 @@

Enter Credentials

}) .catch(function(err) { searchIndexLoading = false; + searchIndexStale = true; input.placeholder = 'Search unavailable'; input.disabled = false; if (window.console) console.warn('Search index failed:', err.message); @@ -2284,6 +2564,9 @@

Enter Credentials

if (!input) return; var q = (input.value || '').trim().toLowerCase(); searchQuery = q; + // Search is in use: a stale index is rebuilt now, and the results + // re-run against it when it lands; until then the old one serves. + if (q) refreshSearchIndexIfStale(); if (!q) { if (searchActive) { searchActive = false; @@ -2487,6 +2770,7 @@

Enter Credentials

currentEntries = entries; folderEntries[cacheKey] = entries; folderEntryCounts[cacheKey] = entries.length; + indexPutFolder(scope, folderPath, entries); updateFolderCount(scope, folderPath, entries.length); // in-place badge update renderEntries(entries, scope, folderPath); }) @@ -4624,6 +4908,7 @@

Enter Credentials

var _searchInput = document.getElementById('connections-search'); if (_searchInput) { _searchInput.addEventListener('input', runSearch); + _searchInput.addEventListener('focus', refreshSearchIndexIfStale); _searchInput.addEventListener('keydown', function(e) { if (e.key === 'Escape') { if (_searchInput.value) { diff --git a/tests/connections-live.mjs b/tests/connections-live.mjs new file mode 100644 index 00000000..4ab0d9ac --- /dev/null +++ b/tests/connections-live.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/* + * Pins what the Connections page re-reads when the address book changes + * under it. + * + * The page polls GET /api/addressbook/version?since=N and gets back the + * folders that changed. livePlan() turns that into the least work that keeps + * the view honest, and the point of it is what it does NOT do: a change in + * a folder you are not looking at reads nothing, a change anywhere does not + * rebuild the search index, and the folder list is re-read only when a + * folder came or went. Those are load bounds a reviewer asked for, so they + * are pinned here rather than left to be eroded by the next convenience. + * + * Usage: node tests/connections-live.mjs + */ + +import { readFile } from 'node:fs/promises'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const html = await readFile(join(root, 'static/connections.html'), 'utf8'); + +/** The source of one top-level function in the page, by brace matching. */ +function lift(name) { + const start = html.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} is defined in connections.html`); + let i = html.indexOf('{', start), depth = 0; + for (; i < html.length; i++) { + if (html[i] === '{') depth++; + else if (html[i] === '}' && --depth === 0) break; + } + return html.slice(start, i + 1); +} + +const ctx = {}; +vm.createContext(ctx); +vm.runInContext(lift('livePlan'), ctx); +// Round-tripped through JSON: arrays born in the vm context have their own +// Array prototype, which strict deep equality would otherwise reject. +const livePlan = (changes, view) => JSON.parse(JSON.stringify(ctx.livePlan(changes, view))); + +const entries = (scope, folder) => ({ scope, folder, kind: 'entries' }); +const tree = (scope, folder) => ({ scope, folder, kind: 'tree' }); +const all = () => ({ scope: '*', folder: '', kind: 'all' }); + +/** A tab looking at shared|cust/acme with a few folders cached and the index built. */ +const looking = (over = {}) => ({ + selectedKey: 'shared|cust/acme', + searchActive: false, + indexLoaded: true, + cachedKeys: ['shared|cust', 'shared|cust/acme', 'shared|cust/acme/dc1', 'instance|lab'], + ...over +}); + +// A change in the open folder re-reads that folder, and that alone. +{ + const p = livePlan([entries('shared', 'cust/acme')], looking()); + assert.deepEqual(p.fetch, ['shared|cust/acme']); + assert.deepEqual(p.drop, []); + assert.equal(p.tree, false, 'entries do not move the folder list'); + assert.equal(p.index, 'keep', 'the open folder re-read patches the index for free'); +} + +// A change elsewhere reads nothing: the cached copy is forgotten and the +// index is marked stale for when search is next used. +{ + const p = livePlan([entries('instance', 'lab')], looking()); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop, ['instance|lab']); + assert.equal(p.tree, false); + assert.equal(p.index, 'stale'); +} + +// A change in a folder this tab never cached still costs nothing now. +{ + const p = livePlan([entries('shared', 'other')], looking()); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop, ['shared|other']); + assert.equal(p.index, 'stale'); +} + +// Several changes: each folder once, the open one read, the rest dropped. +{ + const p = livePlan( + [entries('instance', 'lab'), entries('shared', 'cust/acme'), entries('instance', 'lab')], + looking()); + assert.deepEqual(p.fetch, ['shared|cust/acme']); + assert.deepEqual(p.drop, ['instance|lab']); +} + +// A folder coming or going re-reads the folder list (no entries) and +// forgets what was cached under it; the open folder is untouched when it +// is elsewhere. +{ + const p = livePlan([tree('instance', 'lab')], looking()); + assert.equal(p.tree, true); + assert.deepEqual(p.treeKeys, ['instance|lab']); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop, ['instance|lab']); + assert.equal(p.index, 'stale', 'paths or visibility may have moved'); +} + +// A tree change above the open folder re-reads the open folder too (it may +// be gone, or its description changed) and drops the rest of the subtree. +{ + const p = livePlan([tree('shared', 'cust')], looking()); + assert.equal(p.tree, true); + assert.deepEqual(p.fetch, ['shared|cust/acme']); + assert.deepEqual(p.drop.sort(), ['shared|cust', 'shared|cust/acme/dc1']); +} + +// A sibling whose name is a prefix is not "under" the changed folder. +{ + const p = livePlan([tree('shared', 'cust/ac')], looking()); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop, []); +} + +// `all` (or no change list at all) refreshes what is shown, once: the +// folder list and the open folder, nothing else is read. +for (const changes of [[all()], null, [entries('instance', 'lab'), all()]]) { + const p = livePlan(changes, looking()); + assert.equal(p.tree, true); + assert.equal(p.treeAll, true); + assert.deepEqual(p.fetch, ['shared|cust/acme']); + assert.deepEqual(p.drop.sort(), ['instance|lab', 'shared|cust', 'shared|cust/acme/dc1']); + assert.equal(p.index, 'stale'); +} + +// Nothing changed, nothing happens. +{ + const p = livePlan([], looking()); + assert.deepEqual(p, { tree: false, treeAll: false, treeKeys: [], fetch: [], drop: [], index: 'keep' }); +} + +// While search results are showing, a changed folder is re-read so its rows +// in the index can be patched: bounded to the changed folders, no walk. +{ + const p = livePlan([entries('instance', 'lab')], looking({ searchActive: true })); + assert.deepEqual(p.fetch, ['instance|lab']); + assert.deepEqual(p.drop, []); + assert.equal(p.index, 'keep'); +} + +// ...and the open folder, not on screen behind the results, is patched the +// same way rather than rendered. +{ + const p = livePlan([entries('shared', 'cust/acme')], looking({ searchActive: true })); + assert.deepEqual(p.fetch, ['shared|cust/acme']); + assert.equal(p.index, 'keep'); +} + +// A tree change while searching needs the walk (visibility may have moved): +// the index is rebuilt once and no per-folder patches are read on top. +{ + const p = livePlan([tree('instance', 'lab'), entries('shared', 'cust/acme')], + looking({ searchActive: true })); + assert.equal(p.index, 'rebuild'); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop.sort(), ['instance|lab', 'shared|cust/acme']); +} + +// Without an index built there is nothing to patch: the folder is dropped +// and the stale mark means the first search builds it. +{ + const p = livePlan([entries('instance', 'lab')], looking({ searchActive: true, indexLoaded: false })); + assert.deepEqual(p.fetch, []); + assert.deepEqual(p.drop, ['instance|lab']); + assert.equal(p.index, 'stale'); +} + +// No folder open (nothing selected yet): a change reads nothing. +{ + const p = livePlan([entries('shared', 'x'), tree('shared', 'y')], looking({ selectedKey: null, cachedKeys: [] })); + assert.deepEqual(p.fetch, []); + assert.equal(p.tree, true); +} + +console.log('connections-live: ok');