diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index 65f72a0..b2cc447 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -561,6 +561,10 @@ class AppStore { * CEC tab renders it as a live "Dialing…" row so a connect attempt is * visible from the first click (discovery alone can take up to ~45s). */ cecDialingNumber = $state(null); + /** The NODE currently being dialed (Answer / Open / Chat go by node, which + * never sets `cecDialingNumber`) — what lets the help sidebar pin its + * inline "Connecting…" status to the row that was actually clicked. */ + cecDialingNode = $state(null); /** Live CEC sessions this app is party to, newest first. */ cecSessions = $state<{ sessionId: string; state: string; node?: string }[]>([]); /** The customer's live consent grants (populated when hosting). */ @@ -4280,11 +4284,24 @@ class AppStore { * raised-hand row offer the KVM's web Site (and anything else the graph * knows) alongside the remote-control console. Undefined for an ordinary * customer, a KVM outside our graph, or one whose KVM affordances aren't - * ours to use. */ - kvmTwin(cecNode: string | undefined): MeshNode | undefined { + * ours to use. + * + * Authorization is EITHER side of the tech/owner divide: `kvmAllowed` + * (ours / co-fleet / shared) for a KVM in our own fleet, or — mirroring + * `capabilitiesFor`'s `isCec` arm — a dialed CEC customer. A help + * technician is neither owner nor co-fleet of the customer's appliance, + * yet the KVM's whole point on the help queue is that the tech can open + * its manufacturer web UI; the KVM itself authorizes that tunnel by its + * mesh roster, so the gate here only decides whether the button shows. + * `raisedHand` (queue rows) skips the ACL entirely: the hand-raise IS the + * ask — a beacon this customer deliberately lit — so the Site door shows + * the moment the graph knows the machine is a KVM, even before Answer. */ + kvmTwin(cecNode: string | undefined, opts?: { raisedHand?: boolean }): MeshNode | undefined { if (!cecNode) return undefined; const node = this.nodeByCanonical(cecNode); - return this.isKvm(node) && this.kvmAllowed(node) ? node : undefined; + if (!this.isKvm(node)) return undefined; + if (opts?.raisedHand) return node; + return this.kvmAllowed(node) || this.isCecCustomer(cecNode) ? node : undefined; } /** Open a KVM's own web UI through the mesh — map its web site to a local @@ -7223,6 +7240,10 @@ class AppStore { const byNode = "node" in target; this.cecDialing = true; this.cecDialingNumber = byNode ? null : target.number; + // Which ROW is being dialed, for the help sidebar's inline status — a + // by-node dial (Answer / Open / Chat) never sets `cecDialingNumber`, so + // without this the sidebar had nothing to pin its "Connecting…" line to. + this.cecDialingNode = byNode ? target.node : null; clientLog(`[cec] dialing ${display}…`); try { // Hard cap the wait so a wedged socket request can't leave cecDialing @@ -7258,6 +7279,7 @@ class AppStore { } finally { this.cecDialing = false; this.cecDialingNumber = null; + this.cecDialingNode = null; void this.loadCec(); } } @@ -7270,6 +7292,22 @@ class AppStore { await cecCancelDial(); } + /** The inline wait status a CEC row should show for `node`: "dialing" while + * the connect RPC is in flight to that machine, "approval" once the dial + * landed and the ball is in the customer's court (their approve prompt is + * up), null when nothing is pending. The help sidebar renders this in the + * row itself — it has no status strip like the settings console panel. */ + cecWaitPhase(node: string | undefined): "dialing" | "approval" | null { + if (!node) return null; + if (this.cecAutoOpenNode && this.isSameMachine(this.cecAutoOpenNode, node)) { + return "approval"; + } + if (this.cecDialing && this.cecDialingNode && this.isSameMachine(this.cecDialingNode, node)) { + return "dialing"; + } + return null; + } + /** Flip the "Watch the help queue" opt-in, then re-read the node's status — * membership is the saved state, so the toggle shows what actually took * (and keeps showing it after a restart, no local flag needed). A failure diff --git a/gui/src/ui/HelpTab.svelte b/gui/src/ui/HelpTab.svelte index 6ef9fad..4cb806f 100644 --- a/gui/src/ui/HelpTab.svelte +++ b/gui/src/ui/HelpTab.svelte @@ -119,46 +119,72 @@ @@ -169,65 +195,86 @@ {@const name = app.cecCustomerName(c)} {@const kvm = app.kvmTwin(c.node)}
  • - -
    - {#if editingKey === c.number} - - saveRename(c.number)} - onkeydown={(e) => { - if (e.key === "Enter") saveRename(c.number); - else if (e.key === "Escape") cancelRename(); - }} - /> - {:else} - - - #{groupNumber(c.number)} - · {lastUsedLabel(c.last_used)} - - {/if} +
    + +
    + {#if editingKey === c.number} + + saveRename(c.number)} + onkeydown={(e) => { + if (e.key === "Enter") saveRename(c.number); + else if (e.key === "Escape") cancelRename(); + }} + /> + {:else} + + + #{groupNumber(c.number)} + · {lastUsedLabel(c.last_used)} + + {/if} +
    {#if editingKey !== c.number} - - {#if kvm} - - {/if} - + + {@const phase = app.cecWaitPhase(c.node)} +
    + {#if phase} + + + {phase === "approval" ? "Waiting for them to approve…" : "Connecting…"} + + + {:else} + + {#if kvm} + + {:else} + + + {/if} + {/if} +
    {/if}
  • {/snippet} @@ -316,14 +363,61 @@ flex-direction: column; gap: 0.4rem; } + /* Each entry is a small card: identity on top, its action buttons in a + row along the bottom (with room for more as they earn a spot). */ .row { display: flex; - align-items: center; + flex-direction: column; gap: 0.5rem; padding: 0.5rem 0.55rem; background: var(--surface-2); border-radius: var(--r-sm); } + .row-head { + display: flex; + align-items: center; + gap: 0.5rem; + } + .row-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + } + /* Inline connect status - takes the action row's place while a dial to + this row is in flight or the customer's approve prompt is up. */ + .wait-line { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.74rem; + font-weight: 600; + color: var(--ok); + } + .wait-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--ok); + animation: pulse 1.8s ease-out infinite; + } + .stop-btn { + flex-shrink: 0; + border: 1px solid var(--danger); + background: transparent; + color: var(--danger); + font: inherit; + font-size: 0.74rem; + font-weight: 700; + padding: 0.28rem 0.6rem; + border-radius: var(--r-sm); + cursor: pointer; + } + .stop-btn:hover { + background: var(--danger-soft); + } .dot { flex-shrink: 0; width: 0.55rem; @@ -398,25 +492,27 @@ opacity: 0.5; cursor: default; } - /* The KVM's web-Site door — same compact icon-button shape as the chat - companion, shown only when the row's machine is one of our KVMs. */ + /* The KVM's manufacturer web-Site door — a labeled button on the card's + action row, shown only when the row's machine is a KVM. */ .site-btn { flex-shrink: 0; border: 1px solid var(--line-strong); background: transparent; color: var(--ink-soft); font: inherit; - font-size: 0.82rem; + font-size: 0.74rem; + font-weight: 700; line-height: 1; - padding: 0.28rem 0.4rem; + padding: 0.3rem 0.6rem; border-radius: var(--r-sm); cursor: pointer; } .site-btn:hover { background: var(--surface); } - /* The compact chat companion to Answer / Open — an icon button so the narrow - sidebar row stays readable, with an unread badge riding its corner. */ + /* The chat companion to Answer / Open — a labeled button on the action row + (people only; a KVM row shows its Site instead), with an unread badge + riding its corner. */ .chat-btn { position: relative; flex-shrink: 0; @@ -424,9 +520,10 @@ background: transparent; color: var(--ink-soft); font: inherit; - font-size: 0.82rem; + font-size: 0.74rem; + font-weight: 700; line-height: 1; - padding: 0.28rem 0.4rem; + padding: 0.3rem 0.6rem; border-radius: var(--r-sm); cursor: pointer; } diff --git a/gui/src/ui/settings/CecSection.svelte b/gui/src/ui/settings/CecSection.svelte index 8d203f3..6cfdc24 100644 --- a/gui/src/ui/settings/CecSection.svelte +++ b/gui/src/ui/settings/CecSection.svelte @@ -225,7 +225,7 @@