Skip to content

Connections UX: scrollable sidebar, flush folder rows, selection feedback, filter and keyboard navigation - #255

Open
jseifeddine wants to merge 49 commits into
sol1:mainfrom
jseifeddine:ux-enhancements
Open

jseifeddine wants to merge 49 commits into
sol1:mainfrom
jseifeddine:ux-enhancements

Conversation

@jseifeddine

@jseifeddine jseifeddine commented Oct 10, 2026 •

Copy link
Copy Markdown

Fixes #249, fixes #251, fixes #252, fixes #253, fixes #254, fixes #256, fixes #257.

This is one branch for the whole piece of work: the Safari/Firefox rendering fix, the RDP display fixes, and the Connections/Sessions UX overhaul. It supersedes #250 (now closed), whose four commits form the base of this branch.

Rendering and RDP display

Safari and Firefox drew sessions black (#249). img-src 'self' data: blob: is added to the Content-Security-Policy that security_headers puts on every response, the policy moved into a documented constant, and a unit test fails if img-src ever loses data: or blob: again. The Guacamole client draws each img stream by loading a data: URI into an Image on browsers without WebCodecs ImageDecoder; with no img-src directive default-src 'self' refused every load, Display.draw() skipped it silently, and the frame still flushed, so nothing showed in rustguac or guacd logs. The same directive also unblocks the remote cursor (cursor: url(data:...)) and the blob: image path. Verified in Safari 27: before, 109 image loads / 109 img-src violations / black framebuffer; after, 119/119 loads, 0 violations, identical to Chrome.

RDP strip of bare page on one edge (#251). The display channel rounds every size down to a multiple of 16 (patch 007, which prevents the H.264 green band), so a 1710-wide window gets a 1696-wide desktop and the uniform fit in scaleDisplay() left the rest as bare page. The framebuffer is now centred so any residual margin is split evenly and shows the dark background rather than a strip on one edge, and it is stretched to fill that margin only when the picture is already being resampled (effective scale × devicePixelRatio ≠ 1), where the stretch costs no sharpness. On a pixel-exact or HiDPI session it is never stretched, so text stays crisp. Pointer coordinates are divided by the fill factors before sendMouseState; offsetLeft includes the centring margins and ignores the transform, so that stays correct. Larger gaps are real aspect mismatches and keep the letterbox.

Connections table overran the page at 1710px (#252). Edit, clone and delete share one cell, and a max-width: 1800px breakpoint tightens page padding, sidebar, cell size and the Connect button. The row that ran off the right edge now fits with room to spare, nothing hidden.

Stale entries after a NetBox tenant change, folder defaults, live page (#253). PUT .../entries/:entry?move=true makes the target folder the entry's only home in the scope (same-named entries elsewhere are deleted and audited), so a device that changes tenant follows instead of doubling. Folders can lend default credentials per session type to entries with none of their own, inherited by subfolders. GET /api/addressbook/version is a counter bumped after every successful change; the page polls it every 3 s and reloads only when it moves.

Fullscreen-on-connect

Stuck mouse button after fullscreen-on-connect (#256). The first press after CONNECTED is the browser's fullscreen gesture; it was also forwarded to guacd, and the transition swallowed its release, so the terminal dragged a selection on every move. The gesture now stops before the display's handlers (capture phase), and entering fullscreen releases every held key and button explicitly.

Zero-click fullscreen, no resize, no black screen (#257). Connecting a fullscreen-on-connect entry now goes straight to the fullscreen session with no second click and no intermediate repaint — the first picture the user sees is the final one:

  • A browser only grants fullscreen to the tab that holds the user gesture, and a newly opened tab loses it. So the Connect click fullscreens the current tab and loads the session in a full-viewport same-origin iframe inside it. The embedded client fills the frame; there is no new tab and no cover to click through.
  • This required relaxing the frame policy: X-Frame-Options is now SAMEORIGIN (was DENY) with CSP frame-ancestors 'self', so a same-origin page can host the client while cross-origin framing stays blocked. Without this the iframe was blocked and the session showed as a black screen — the symptom this fixes.
  • The session is created at the measured fullscreen viewport, not the screen size. The final viewport cannot be predicted (on a notched MacBook it is shorter than screen.height, and the transition is asynchronous), so the launcher enters fullscreen first, waits until the viewport has held one size for 150 ms, and only then sends the connect with that width and height. If fullscreen is refused the overlay fills the tab as it is and the connect goes out at once; a cap never leaves the user on a spinner.
  • The server rounds every RDP initial size down to a multiple of 16, fullscreen or windowed. The display channel rounds the client's first post-connect size send to that grid regardless, so creation and first send always agree: guacd coalesces them, there is no resize, and the H.264 hardware decoder is not rebuilt (no multi-second stall). VNC keeps exact sizes.
  • The embedded client keeps an opaque connect cover (spinner, entry name, status) up from load and lifts it only once the session is connected and the launcher has confirmed the viewport, and its fit-to-window sends wait for the same signal, so nothing is painted or sent against a viewport that is still moving.
  • Exit (the overlay's control, Esc, or the session ending) tears the iframe down and returns to the list.

Open in a new window (⧉) is explicitly windowed. The button next to Connect never applies fullscreen-on-connect, whatever the entry says (the client is opened with fs=0), so it no longer shows a "click to enter fullscreen" cover that a popup — which carries no click activation — could never satisfy. Its session is sized the same way as the framed one: the popup opens on a small splash page (static/launch.html: spinner, entry name, site theme) that measures its own viewport once laid out and stable, posts it to the opener, and the session is created at exactly that size before the popup is navigated to the client. Reading the popup's size from the opener right after window.open reports the opener's size until the new window is laid out, which was the one remaining resize on this path. The client keeps its connect cover up for a windowed session until connected, so the splash hands over to the final picture with nothing in between. The search overlay's ⧉ opens the same sized popup as the connections page.

Reuse instead of a duplicate session. A repeat Connect within 15 s reattaches to the Pending session just created for that entry rather than spawning another; after that window a fresh one is made, so a stale session is never reused.

Sessions, Reports, Recordings

One list toolbar across the pages. Sessions, Recordings and Reports share one borderless .list-tools row: a .list-search box on the left, the facet filters, and the page's action pinned right (New ad-hoc session on Sessions, Export CSV on Reports). Placeholders read Search <noun> (fields…). Sessions gained a search (user, host, owner, ID, type) and a User facet alongside Owner, so its filter set is complete.

Facet filters on every list (#254). static/filters.js gives sessions, recordings, reports and connections a row of dropdown filters next to the search: Type, Folder, User, Owner and Status as each page has them — compact multi-selects of the present values, counts that narrow as other facets apply, All/None, a find box, a Clear link, and selections kept per page in sessionStorage.

One type badge everywhere. All four tables use the shared .type-badge (sessions printed plain text, recordings a coloured word).

Redundant page titles removed. The nav marks the active page, so the duplicate page-level heading (Recordings, Reports, the Sessions label) is gone; only genuine sub-section headings remain.

Connections page (#254)

Sidebar and density. The Connections page is an app shell: the header stays and the sidebar and entries area are two independent scroll panes that always fit the viewport. The sidebar is a bordered panel with a pinned header band (Folders label, a compact new-folder button, the filter box) over the list — hairline dividers, ~46px rows, the chevron column only for folders with children, name and count on one line (count a dim-at-zero pill), the scope glyph small at the right edge.

Selection feedback. Selecting a folder scrolls the entries header to the top (smooth, or instant under prefers-reduced-motion) and keeps the selected row visible; the title slides in and the action bar's underline flashes accent. While entries load the table shows a same-height skeleton. Count badges update in place; the sidebar is rebuilt only when the folder set changes, preserving scroll position; live refreshes trigger none of the motion.

Folder filter and keyboard navigation. A filter box narrows the tree as you type (name, path, description; ancestors of matches stay visible; "N of M" while filtering; Esc clears; text survives live refreshes). The tree carries ARIA tree roles with a roving tabindex: Arrow keys move/expand/collapse, Home/End jump, Enter/Space select, f focuses the box.

Header that does not flash. Each page is served in one variant per role level, differing only in a data-role-level attribute on <html>, so a logged-in request paints its navigation correctly on first load (ETag per variant); the stylesheet gates links on that attribute. Every page carries the same eight links in the same order, and the logo is content-addressed and cached for a year.

Sticky folder header. The folder header pins to the top of the entries pane: the display name and description share a baseline with the folder key beneath, the action buttons group on the right, a single divider separates them from the filter row.

Folder display names. A folder config can carry a display_name beside its description (stored in Vault, accepted by create/update, returned in listings, set by the NetBox tenant sync), shown as the title with the folder key as a sub-header, in the header and sidebar and the folder modal.

Tenant sublines. "host (Tenant)" display names read as the host over a quieter tenant line in the entries, search, recordings and reports tables.

Shared components

Global search, one component, on every page. The search box and its results overlay live once — styles in rustguac.css, behaviour in static/search.js — and run on every page including connections (which dropped its duplicated inline copy). Focusing the box (or /) dims the page and floats the results; results show the folder display name over its key with Connect, open-in-new-window and open-folder actions; a fullscreen-on-connect result Connects straight into the iframe fullscreen; Escape and a backdrop click close the overlay; the index is cached per session and re-fetched only when the address book version changes.

Themed dialogs. static/ui.js provides promise-based rgConfirm/rgAlert replacing every native alert()/confirm() with a modal matching the app (destructive in red, host-key trust in accent); self-contained so it works on the standalone client page too.

Shared page gutter. The 1800px narrow-desktop padding lives in rustguac.css for every page, so switching tabs no longer snaps the content sideways.

How it was built and checked

cargo test passes (381 tests, 1 ignored), cargo fmt --check clean. The CSP fix was verified in Safari 27 against patched and unpatched builds. The RDP display, fullscreen-on-connect, session reuse, mod-16 sizing and the Sessions search/User filter were verified end to end against a live deployment with a real xrdp target: Connect goes straight to a painted fullscreen desktop with no second click, two rapid connects return the same session and a third after the cap a fresh one, and the Sessions list, search and User facet filter live data. The measured-viewport sizing was verified in a real browser on a notched MacBook (1710×1112 screen, 1710×1074 fullscreen viewport) against SSH and RDP targets: the session log shows the session created at the viewport size (RDP on the mod-16 grid, 1696×1072), and the first picture is the final one with no resize or repaint. The UX slices were rendered in headless Chrome at 1710×1112 from the real renderFolders() output; the keyboard/filter logic has a 37-assertion DOM test and the facet filters 19 checks across the four pages.

The Content-Security-Policy had no img-src, so default-src 'self'
governed images and refused the data: URIs the Guacamole client draws
every tile from on browsers without WebCodecs ImageDecoder. Safari and
Firefox rendered SSH and RDP sessions black with only rect fills and the
cursor cell showing; Chrome decodes straight from the stream and never
fetches a URL, which hid it. The same directive blocks the remote mouse
cursor (cursor: url(data:...)) and blob: image URLs.

Move the policy into a documented constant, add img-src 'self' data:
blob:, and add a unit test that fails if img-src loses either source.
Document the proxy-side header replacement for releases up to v1.10.5.

Fixes sol1#249
Windows RDP without GFX rounds the requested width down to a multiple
of 16, so a 1710-wide window gets a 1696-wide desktop. The uniform fit
in scaleDisplay() then keeps the scale at 1 (the height matches) and
the missing 14px shows as a strip of bare page down the right edge.

After the uniform fit, stretch the display element by the remaining
fraction on each axis when it is at most 2.5%, and divide the same
factors out of pointer coordinates before sendMouseState. A larger gap
is a real aspect mismatch and keeps the letterbox. Multi-monitor
pop-outs reset the fill.

Fixes sol1#251
The 18px table with 20px cell padding and a separate cell each for
edit, clone and delete overran the main column by ~300px on a 1710px
MacBook display, cutting "delete" off the right edge.

Put the three admin actions in one cell, and add a max-width: 1800px
breakpoint on the connections page: 20px page padding, 240-280px
sidebar, 16px cells with tighter padding, 14px headers, a 110px
Connect button and a smaller folder title. The icon-only "open in new
window" button keeps its compact size. Nothing is hidden; at 1710px the
table fits with about 250px to spare.

Fixes sol1#252
Three things kept the Connections page a stale picture of NetBox and
Vault: a device moved between tenants kept its entry in both folders,
a synced entry waited on a five-minute timer for its credentials, and
the page never refreshed until it was reloaded.

PUT .../entries/:entry?move=true deletes same-named entries from every
other folder of the scope after the write, each audited, so an upsert
keyed on a stable name follows its device when the folder changes.

Folders get default credentials: a per-session-type username, password
or private key at <folder>/.defaults, lent at connect time to entries
with no secret of their own, subfolders inheriting per type. Listings
report such entries as has_credentials with inherited_credentials, so
they show Connect from the moment a webhook creates them. Admin API
(GET/PUT/DELETE .../defaults, secrets write-only, audited) and a section
in the folder dialog.

GET /api/addressbook/version is a counter bumped by middleware after
every successful change through the API; it costs no Vault read. The
page polls it every three seconds and reloads folders, entries, the
search index and the credential status only when it moves, plus an
unconditional refresh every two minutes for changes made elsewhere.
Nothing reloads while a dialog is open or the tab is hidden.

Fixes sol1#253
…ol1#254)

With 20+ customer folders the sidebar shared the page scroll with the
entries table, so picking a folder far down the list changed content off
screen, and every leaf row was pushed ~90px in by a hidden chevron
placeholder while names were ellipsised.

- .sidebar is position: sticky with its own thin scrollbar and
  max-height tied to the viewport; .main keeps scrolling with the page.
- The FOLDERS header is wrapped in .sidebar-pin together with a new
  empty #sidebar-tools slot, both pinned at the top of the sidebar.
- buildFolderNode() renders a .tree-chevron only for folders with
  children; leaves get no placeholder (removed the .tree-chevron.leaf
  rule), so rows start right after the 3px accent bar.
- Name, entry-count pill (.folder-count, now just the number with a
  data-count attribute and "N entries" title) and a smaller scope glyph
  share one line; description sits beneath at --fz-xs; rows are ~40px.
- Nested .tree-children indent 14px behind the existing dashed guide.

All new CSS is in one block at the end of the page's <style>.
With a long folder list, clicking a folder appeared to do nothing: the
entries table was off screen, the only loading state was a text swap,
and nothing drew the eye to the changed title. loadEntries() also
called renderFolders() just to update one count badge, rebuilding the
whole sidebar on every click.

- Jump to the entries header when the shown folder changes (smooth,
  'auto' under prefers-reduced-motion); never on initial load, live
  refresh, forceRefresh after a mutation or search clear.
- .changed on #entries-header replays a 350ms title slide-in and an
  accent underline flash on the action bar; cleared on animationend.
- Skeleton table (4 shimmering rows, same geometry as the real table)
  while entries are fetched from the server; the cached path renders
  immediately with no skeleton.
- updateFolderCount() swaps the number in the existing .folder-count
  badge in place; renderFolders() only as a fallback when the badge
  does not exist yet.
- renderFolders() is wrapped (not edited) to preserve the sidebar
  scroll position across rebuilds and to reveal the selected row within
  the sidebar (nearest, no page scroll) when the selection changes or
  after loadFolders() rebuilds the tree.
The 1800px breakpoint set a tighter page gutter on the connections page
only, so switching to Sessions or any other tab snapped the content
sideways. Move it to rustguac.css so every page moves together.

Part of sol1#254
# Conflicts:
#	static/connections.html
…es (sol1#254)

The sidebar lists one folder per customer and gets long. Add a live
filter box (name, path, description; loaded subfolders included, with
ancestors of a match kept visible) mounted in a new #sidebar-tools div,
persisted in sessionStorage so live refreshes keep it. Make the tree
keyboard-navigable (arrows, Home/End, Enter/Space, Left/Right to
collapse/expand) with role=tree/treeitem, aria-selected/expanded and a
roving tabindex, and add an 'f' shortcut that mirrors the '/' one.

Implemented by post-processing the DOM renderFolders() builds and by
wrapping that function; its body is unchanged.
# Conflicts:
#	static/connections.html
The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.

Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.

Part of sol1#254
Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.

Part of sol1#254
…sublines

The sticky sidebar was sized from the viewport rather than from where
it starts under the header, so at the top of the page its bottom hung
off screen and the page had to be scrolled before the list could be
scrolled to its end. The page is now an app shell: the header stays,
and the sidebar and the entries area are two independent scroll panes
that always fit the viewport. Selecting a folder scrolls the entries
pane, not the page.

The sidebar is a bordered panel: one pinned header band on a surface
tone with the Folders label, a compact new-folder button and the filter
box, then the list with hairline dividers, 46px rows and one 12px edge
for every band. "host (Tenant)" names read as the host over a quieter
tenant line in the entries and search tables, and a role-gate
reconciliation covers API-key users.

Part of sol1#254
… input after the transition

With fullscreen-on-connect, the first mousedown after CONNECTED is the
browser's fullscreen gesture, but Guacamole.Mouse forwarded it to guacd
as well, and the transition that followed swallowed its release. guacd
was left with the button held and the terminal selected text on every
pointer move; the same with a key press as the gesture.

Stop the gesture event before the display's handlers see it, and on
entering fullscreen release everything explicitly: keyboard.reset() and
a mouse state with no buttons at the last pointer position, so a release
lost for any other reason during the transition cannot stick either.

Fixes sol1#256
…t request

The session was created at the window's size, re-sent after connect,
then replaced by the fullscreen size once the gesture landed, with the
display scaled to fit in between and refit after the server's rounding:
four or five visible size changes for one connection.

The connect call now carries the screen's size and the server creates a
fullscreen-on-connect session at it; the client keeps that target while
still windowed (scaled to fit until the gesture lands), so the
post-connect sends and the fullscreen resize ask for what the server
already has and guacd coalesces them to nothing.

Fixes sol1#257
jumpToEntriesHeader() used scrollIntoView(), which scrolls every
scrollable ancestor, and a body with overflow hidden can still be
scrolled by script: selecting a folder pushed the brand row and the nav
off the top of the page. Scroll the entries pane to its top instead, and
make the body overflow: clip, which no script can scroll. The body's
border box now spans the full height so the page padding stays inside
the viewport.

Part of sol1#254
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 sol1#254
The 18px tables with 20px cell padding ran past the right edge on a
1710px MacBook display, the recording link cut off. Same treatment as
the connections table: 16px cells with tighter padding, 14px headers,
actions kept on one line. Nothing is hidden.

Part of sol1#254
…lscreen

Sizing every fullscreen-on-connect session to the screen made an SSH
terminal narrower and soft until the gesture landed: it was scaled to
fit the window's height. A terminal reflows instantly and must stay
crisp, so only RDP and VNC, where a server-side resize is slow and
visible, start at the screen's size; everything else keeps the window's
size and resizes on fullscreen.

The screen-size target also outlived fullscreen: after Esc the client
kept asking for it and the user was stuck with the scaled view. It now
applies only until fullscreen has been entered once. The SSH creation
log carries width and height, as the RDP one does.

Verified: unit test on the server rule, five client-side cases, and six
end-to-end cases against a live instance reading the sizes guacd was
given.

Part of sol1#257
…ol1#257)

A fullscreen-on-connect session needs a user gesture before the browser
will go fullscreen, so until now the first thing the user saw was the
session scaled down to the window, with a gap down the right where the
taller screen-sized framebuffer did not fit, and only the next click
brought the real picture. Now a gate covers the page from before the
tunnel opens: the session connects and paints behind it at the size it
will have in fullscreen, and the gesture that dismisses the gate is the
one that enters fullscreen. No extra round trip, no scaled frame first.

The gate shows the entry name and the connection state, and offers to
stay windowed (button or Escape); a rejected fullscreen request, or one
the browser ignores, falls back to the window after a moment and sizes
the session to it. Gesture events are taken on window in the capture
phase so neither the click nor the key reaches the remote. The gate is
hidden on disconnect or error so the usual overlays show.
…l1#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.
…der display names (sol1#254)

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.

Connections: the folder header (name, description, search, actions and
the new filters) 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.
Folders with a display name show it as the title with the folder key as
a sub-header, in the header and the sidebar; the folder modal gets a
Display name field.

Sessions: the ad-hoc session form moves into a modal opened from a
button on the right of the Sessions heading. Nav: the key glyph before
Credentials is sized to the text.
A folder can carry a display_name beside its description. It is stored
in the Vault .config (omitted when empty, so existing configs are
unchanged), returned in folder listings and GET .../config, and accepted
by POST /api/addressbook/folders and PUT .../folders/:scope/:folder.
The NetBox tenant sync sets it to the tenant name, so the connections
page can show 'Dani & Fiori's Gourmet Pizzeria' over the folder key
instead of three spellings of the tenant in one line.
The ad-hoc form drops the page's framed form box inside the modal, and
the folder modal asks for the display name before the description.
…tion (sol1#257)

A fullscreen-on-connect RDP/VNC session was created at the screen's size
so no resize was needed once fullscreen landed. But while the window is
shorter than the screen, that taller framebuffer is scaled to fit and
letterboxes with a gap down the side, which is what showed on the first
paint. The session is now always created and kept at the window's size,
so the first paint fills the viewport. The one resize into fullscreen is
hidden behind the cover instead.

The connect cover is no longer a gate that asks for a click: it is a
plain splash with a spinner, the entry name and the connection state. It
lifts on its own once connected, revealing the window-filled session.
The next click or key is armed as the fullscreen gesture, exactly as
before; that press is taken in the capture phase so it never reaches the
remote, and a cover goes back up over the fullscreen resize. No button,
no 'click to proceed' step.
…; compact filter buttons (sol1#254)

The '/' search box leaves the folder header for a bigger, centred box in
the brand row, inline with the logo, with a search icon and a '/' hint
that clears once it is in use. Focusing it (or pressing /) engages a
search mode: the folder's own header is hidden as irrelevant, a results
summary and the Type/Folder facets take its place, and with no text yet
every connection the user can see is listed. Escape or opening a folder
leaves the mode.

The results table is rebuilt: fixed columns, one line per cell with
ellipsis, folder shown by display name over its key, and Connect, new
window and open-folder gathered into one right-aligned actions cell
instead of sprawling across the row. The facet filter buttons are
compacted to the same height and padding as the other controls.
…sol1#257)

The previous approach painted the session windowed as soon as it
connected, then entered fullscreen on a click and resized — a visible
windowed frame, a delay, and a second repaint. Now the session goes
fullscreen first and is painted once at the final size.

The Connect click opens the client window within the gesture, so that
window carries the user activation; the client requests fullscreen on
load and it is usually granted with no further click. A bitmap (RDP/VNC)
session is created at the screen size, so the picture painted behind the
cover is already the fullscreen one with no resize. The cover stays over
the page the whole time — fullscreen request, connect, paint — and lifts
only once the window is fullscreen and the session connected. If the
browser refuses the load-time request, the cover says so and the next
click or key enters fullscreen (taken in the capture phase, never sent
to the remote).

Also tighten the connections folder header: the title and description
share one baseline with the key tucked beneath, the action buttons sit
as a group on the right, a single divider separates them from the filter
row, and the search-engaged summary and filters form a compact band.
…ed control heights (sol1#254)

The folder view no longer changes when you search. The results open in a
dim, blurred overlay over it: a centred panel with the summary, the Type
and Folder facets, and the results table, while the folder you were in
stays exactly as it was underneath. Focusing the search (or pressing /)
opens it and lists every connection; typing narrows it; Escape, the
backdrop or opening a folder closes it and the folder view is untouched.
The results get their own filter instance so the folder view keeps its
own Type filter. Connect, new-window and open-folder in a result row work
the same as in the folder view (the handler is bound to both panes).

Also: the main content pane gets a left gutter so the tenant name,
buttons and table are not jammed against the edge, and the list search
input is set to the same height as the facet buttons on the recordings
and reports pages.
)

The ad-hoc modal's Cancel used the small button height while Connect used
the full one, so they did not line up; Cancel now uses the shared modal
cancel style and both are the same height, with the primary button first
to match the other modals. Section headings center their heading and
action button on one line instead of sitting on the text baseline.
The search is a floating overlay now, so it no longer has to live on the
connections page. static/search.js is a drop-in module: it injects the
search box into the brand row, the results overlay into the body, loads
the address-book index, and wires the / shortcut and the facets, on any
page that includes it. Added to sessions, recordings, reports, admin,
tokens, docs and index. On the connections page its own in-page search
still runs (results interact with the folder tree there), so the module
stands aside when it sees that page's search box.

Connect from a result opens the client in a window opened within the
click (so fullscreen activation carries); an entry needing credentials
sends the user to the connections page to set them. Opening a folder from
another page's search navigates to connections with #open=scope/path,
which selects that folder on arrival.
sol1#254)

On pages other than connections the brand row had no stacking context,
so the search box was dimmed behind its own overlay; the injected styles
now raise the brand row above it. The magnifier glyph matches the
connections page. The index is cached in sessionStorage and only
re-fetched when the address book's version changes (checked on load and
polled), so moving between pages is instant with no re-index flash; the
connections page still loads a fresh copy and bumps the version on edits.
…nect (sol1#254)

The main content pane gets a more generous, symmetric horizontal gutter
and the folder header a little more room at the top, so the tenant name,
buttons and table are clearly inset. The facet dropdown reserves space
for its scrollbar so it no longer sits over the count numbers. The
Connect button keeps a fixed size and spins in place instead of growing
to 'Connecting...' and shoving the row around; it also keeps its own
label (Connect or Login...) rather than resetting to Connect.
static/ui.js provides promise-based rgConfirm/rgAlert that show a themed
modal matching the app, injected self-contained so it works on any page
including the standalone client. Every native alert()/confirm() is
replaced: delete user and revoke token (admin), revoke token (tokens),
delete recording (recordings), popup-blocked (client), and terminate
session, delete entry, delete folder and the SSH host-key probe
(connections). Destructive actions get a red confirm button, the host-key
trust prompt an accent one. The global search's Connect buttons show the
same in-place spinner.
…dex carries folder display name (sol1#254)

The folder title was flush against the header's left edge; it now gets
the same horizontal padding as the table cells, so it is inset and sits
over the table content below. The address-book search index now includes
each entry's folder display name, so search results can show it the way
the folder view does.
…sol1#254)

The connection search and its results overlay were implemented twice —
inline on the connections page and again in search.js. They are now one
component. The styles live once in rustguac.css, and search.js is the
single implementation, used on every page including connections, which
dropped ~16k of duplicated markup, CSS and JS.

The connections page keeps only the folder-specific hooks it needs:
window.rgSelectFolder selects a folder in the sidebar in place (so
opening a folder from a result there doesn't navigate), and the loaded
index is shared as window.rgSearchIndex for the credentials banner.
search.js uses window.rgSelectFolder when present and otherwise navigates
to connections with #open=scope/path.

Also folded in: results show the folder display name over its key (the
search index now carries it), the overlay is wider (one step under full
width), results have the open-in-new-window button, Escape closes the
overlay from anywhere rather than only when the box is focused, and a
click on the dim backdrop closes it.
Defensive escaping so the type value can never break out of the class
attribute, matching how the folder view builds the badge.
From an independent review of the new modules:
- rgConfirm/rgAlert resolve any already-open dialog before replacing it,
  so a second call can't leave the first promise hanging.
- Enter in a dialog acts on the focused button (Cancel cancels), and Tab
  is trapped between the two buttons.
- The search overlay's Escape yields to an open facet menu, closing just
  the menu first, then the overlay on the next press.
- search.js stands down cleanly on a page with no brand row instead of
  throwing.
…ro-click fullscreen (sol1#254, sol1#257)

- The folder action buttons (add entry, subfolder, edit, delete) move
  down to the filter row beside Type, leaving the top row for the tenant
  name, description and key alone.
- The search overlay now closes predictably: Escape from anywhere while
  it is open, and a mousedown anywhere outside the search box and the
  results panel. Clicking inside the panel (results, facets) keeps it
  open; a facet menu's own Escape closes just the menu first. Replaces the
  previous blur/keep-timer logic that could leave it stuck open after a
  click into the results.
- Fullscreen-on-connect requests fullscreen the moment the client page is
  interactive (gated by a new ?fs=1 flag the launchers add), before the
  async session fetch, so it uses the user activation carried from the
  Connect click rather than losing it to the round trip. When the browser
  grants it there is no click at all; the armed gesture remains as the
  fallback.
)

The results table's base styling — cell padding, header style, row
dividers, the folder-scope icon size and colour — came only from the
connections page's inline .entries-table rules, so the overlay looked
right on connections but unstyled on every other page. Those rules now
live under #search-results in rustguac.css, scoped and specific enough to
override any page's own table styles, so the results look identical
everywhere.
A browser only lets the tab that holds the Connect gesture go fullscreen;
opening a new tab drops that gesture, so the ?fs=1 request in the new tab
always fell back to a manual click. Fullscreen-on-connect now fullscreens
the CURRENT tab on the Connect click — the one moment a valid gesture
exists — and loads the session in a full-viewport iframe (the embedded
client fills it). No new tab, no second click.

- search.js: rgLaunchFullscreen (shared, used by connections and global
  search); overlay + spinner + Exit button; tears down on the Exit button,
  on fullscreen exit, or on a session-end message from the embedded client.
- client.html: ?embedded=1 skips all in-client fullscreen logic (the parent
  tab owns it) and posts ready/end to the parent so the launcher can hide
  the spinner and tear down.
- The session is created at screen size so the embedded client paints at
  the final fullscreen size with no resize.
- The new-window (new tab) button keeps the old path; a cred-prompt entry
  keeps the old path too.
@pletch

pletch commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Only commenting on the client/RDP display parts here (#257). The rest is UI work I haven't reviewed.

#257 (fullscreen-on-connect). Avoiding the resize is worth having: with H.264 passthrough, a resolution change rebuilds the browser's hardware decoder, which stalls video for several seconds. One thing to be aware of with the screen-size start, though:

Creating at screen.width/height doesn't give a single size in practice:

  • Screens that aren't mod-16: because of the mod-16 display patch (see RDP: strip of bare page on the right when Windows rounds the width down (GFX off) #251), a screen that isn't mod-16 is still resized once after connect: the initial size is not rounded, the post-connect send is, and the two differ. So "guacd coalesces them to nothing" holds only for mod-16 screens.
  • Please keep that post-connect send. It is what puts the session on a mod-16 size. A follow-up that suppressed it because "the size already matches" would leave such a session at an unrounded size for its whole life, which brings back the green bottom band the patch exists to prevent.

Zero-click fullscreen loads the session in a same-origin iframe, but every
response carried X-Frame-Options: DENY, so the browser blocked the frame and
the session showed a black screen with a spinner. The only way through was to
reopen from Active Sessions and reconnect. Fixes, all together:

- main.rs: X-Frame-Options SAMEORIGIN (was DENY) and CSP frame-ancestors
  'self', so a same-origin page can host the client in a frame; cross-origin
  framing stays blocked.
- api.rs: reuse a Pending session the same user already opened for an entry
  instead of creating a duplicate on a repeat Connect (find_reusable_session).
- api.rs: create a fullscreen RDP session at the screen size rounded down to
  a multiple of 16 (round_down_mod16), matching guacd's display-channel
  rounding so there is no post-connect resize and the H.264 decoder does not
  rebuild/stall (sol1#257).
- client.html: fit a bitmap session sharply — stretch to fill the mod-16 gap
  only when the picture is already resampled; on a pixel-exact session keep it
  crisp and centre it so the small margin is split evenly in the dark
  background rather than a bare strip on one edge (sol1#251).
- recordings/reports/sessions: drop the redundant page title; the nav already
  names and marks the active page.
Cap reuse to recently-created Pending sessions so a repeat Connect within a
few seconds reattaches to the one just made, while an older Pending — whose
guacd may have died — is left to the pending-timeout reaper and a fresh
session is created instead. Avoids ever handing back a stale, dead stream.
@pletch

pletch commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Thanks, both look right to me. Creating at the mod-16 size means guacd's own check makes the post-connect send a no-op, and the session is never off-grid even during connect, which is better than what I suggested. The pixel-exact centering for #251 is exactly what I had in mind.

One note for whenever this meets #238 (native resolution): there the requested size is multiplied by devicePixelRatio server-side, so the mod-16 rounding needs to apply to the final device-pixel size, after that multiply. Rounding the CSS size first still works at 2x, but at fractional ratios (1.25, 1.5) it lands off-grid and costs the one resize this avoids. Happy to handle that in whichever PR lands second.

Sessions now matches Recordings: one borderless row with the search box, the
facet filters, and the page action on the right — not a bordered section head
above a separate filter row. Adds a Sessions search (user, host, owner, ID,
type) and a User facet alongside Owner, so the filter set is complete. Reports
moves from its own .filter-bar to the same shared .list-tools/.list-search,
with Export CSV as the right-aligned action. Placeholders standardised to
'Search <noun> (fields…)' everywhere.
@jseifeddine

Copy link
Copy Markdown
Author

Thanks — both points are addressed, and both now live in #255 (the single inclusive PR; #250 is folded into it).

#251 — the margin. Took both of your alternatives, together. The framebuffer is centred, so any residual mod-16 margin is split evenly and shows the dark background rather than a bare strip on one edge. The fill stretch now fires only when the picture is already being resampled (display.getScale() * devicePixelRatio !== 1); on a pixel-exact or HiDPI session it never stretches, so single-pixel text stays crisp and the stretch only ever acts where it costs no sharpness. Pointer coordinates are still divided by the fill factors, and the centring is done with offsetLeft margins (which ignore the transform), so that stays correct.

#257 — the post-connect resize. Kept the post-connect size send, as you asked. On top of it, a fullscreen RDP session is now created at the screen size rounded down to a multiple of 16 (round_down_mod16, mirroring the display channel), so on a non-mod-16 screen the initial size and the post-connect send agree and guacd coalesces them — no resize and no decoder rebuild, even there. The send still lands every session on a mod-16 size, so nothing is left un-rounded.

Also relevant: the zero-click path changed since you reviewed it. A new tab drops the fullscreen gesture, so Connect now fullscreens the current tab and loads the session in a same-origin iframe instead. That needed X-Frame-Options: SAMEORIGIN with CSP frame-ancestors 'self' (cross-origin framing stays blocked).

One heads-up for merge order: #255 overlaps #238 in the display-sizing path — src/api.rs, static/client.html and static/connections.html conflict, all in the fullscreen/HiDPI sizing logic (the mod-16 and screen-size work here vs the physical-pixel framebuffer and display_scale there). Happy to reconcile the two once we pick which lands first.

…ewport

The ⧉ (open in a new window) button still showed the "click to enter
fullscreen" cover: the launcher omitted ?fs=1, but the client re-read the
entry's fullscreen_on_connect from the session metadata and armed the gate
anyway, and a popup window carries no click activation so the request was
refused. ⧉ now opens the client with fs=0 (explicitly windowed), the client
never arms fullscreen for it, and both launchers create the session at the
popup's own inner size so nothing resizes on load. The search overlay's ⧉
opens the same sized popup as the connections page instead of a tab.

Zero-click fullscreen (same-tab iframe) still showed one visible snap: the
launcher tab's fullscreen transition lands after the session connects, so the
frame first fitted the picture to the pre-fullscreen viewport (gaps at the
sides) and then snapped on the resize event. The embedded client now shows
the connect cover from load, the launcher reports whether fullscreen was
entered or refused once the frame is connected, and the frame lifts the cover
— and only then runs the post-connect fit-to-window sends — once the viewport
has reached the screen size (immediately if refused; capped at 1.5s after the
signal and 3s after connect). The user sees only the final picture.
The remaining snap on zero-click fullscreen was a size mismatch, not timing:
the launcher created the session at screen.width x screen.height, but a
fullscreen viewport on a notched Mac is shorter than the screen (the strip
beside the camera is never used), so the first paint was a fit of a taller
picture (gaps at the sides) that the post-connect fit then resized away.

The final viewport size cannot be predicted, so measure it: the launcher
enters fullscreen, waits until the viewport has held one size for 150ms (and,
having entered, until it has actually left its windowed size, capped at
1.5s), then sends the connect with that width/height. Refused or unavailable
fullscreen connects at the tab's size immediately; a 2.5s cap never leaves
the user on a spinner. The embedded client now simply trusts the launcher's
'viewport' message to lift the cover and start the fit sends, instead of
comparing against the screen size (which never matched on a notched Mac and
left the cover up for the 1.5s fallback).
…always

SSH was pixel-perfect after the previous commit but RDP still flashed once:
initial_session_size prefers screen_width/height for a fullscreen bitmap
session, and the launcher still sent the physical screen (1710x1112) next to
the measured viewport (1710x1074). The session was created at 1696x1104, the
client's first size send became 1696x1072, and the remote lost 32 rows after
the first paint.

The launcher now sends only the measured viewport (no screen_*), and the
server rounds every RDP initial size down to the 16px grid, fullscreen or
windowed, since guacd rounds the client's first post-connect size send to
that grid regardless — so creation and first send always agree and no path
resizes on connect. VNC keeps exact sizes. Tests updated.
"Open in a new window" still resized once on RDP: the launcher read the
popup's innerWidth/innerHeight straight after window.open, which reports the
opener's size until the new window is laid out, and the search-overlay path
also sent screen_width/height, which the server prefers for a fullscreen
bitmap entry. Either way the session was created at a size the popup never
had, and the first fit-to-window send resized it.

The popup now opens on a small splash page, static/launch.html (spinner,
entry name, site theme), which measures its own viewport once loaded and
stable for 150ms and posts it to the opener. rgOpenSessionWindow in search.js
wraps that (popup + a size promise, with defaults if the popup is blocked and
a 2.5s cap); both the connections page and the search overlay create the
session at that size and then navigate the popup to the client. No screen_*
is sent for a popup. The client keeps its connect cover up for a windowed
(fs=0) session until connected, so the splash hands over to the final picture
with nothing in between.
@davekempe

Copy link
Copy Markdown
Member

@jseifeddine I appreciate the effort. This is way too much to review all at once and test. Can you please put some time into breaking this up into chunks that can be seperately tested? I need automated tests, playwright tests and user tests on all these. Great to have you contributing, I look forward to work with you on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment