From 9c1162ca9bfb25f6673bf8b61899554c7a2ebe83 Mon Sep 17 00:00:00 2001 From: VirusAlex Date: Fri, 1 May 2026 11:41:43 +0300 Subject: [PATCH] feat(ui): symmetric Connect button + clear error feedback for both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complaints from the v0.4.0 live test: 1. Local-token field had no Connect button — user had to press Enter and hope. Peer side has a button. Asymmetric and confusing. 2. Both sides silently swallowed connect failures. Wrong token? Wrong URL? Down peer? Mistyped port? UI just said "disconnected" / "no peer" forever, no hint about what went wrong. Fix: - Local token field gets the same "Connect" button the peer side has. Enter still works for power users; click works for everyone else. Both forward to the same connectLocal() entry point. The legacy applyLocalToken() / checkLocal() names are kept as aliases so any bookmarked URL / external integration that called them still works. - Both sides now track a 4-state status (idle / connecting / ok / error) and a localError / peerError string. The status pill flips through the states; the error string lands inline next to it (with full text in the title attribute for hover, ellipsis in the chip). - Error messages classified: * 401 → "401 — invalid token" * 403 → "403 — forbidden" * 404 → "404 — endpoint not found (check URL points at NetCopy)" * 5xx → "5xx — peer server error" * fetch reject → "unreachable (host down, wrong port, or http/https mismatch)" — browsers don't surface concrete TCP errors so the message points at the most likely causes. - Connect button is disabled while a request is in flight; its label becomes "…" so the user knows something's happening even on a slow network. Pure client-side: app.js + index.html + style.css. No API changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/resources/web/app.js | 102 +++++++++++++++++++++++++----- src/main/resources/web/index.html | 41 +++++++++--- src/main/resources/web/style.css | 13 ++++ 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 1b4deef..53ec9ff 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -15,6 +15,15 @@ document.addEventListener('alpine:init', () => { peerToken: '', localOk: false, peerOk: false, + // Connection lifecycle states for the UI status pill: 'idle' = nothing + // attempted yet, 'connecting' = request in flight, 'ok' = success, + // 'error' = failed with `localError`/`peerError` populated. Pre-v0.4.1 + // we only had a boolean and silent failure; "disconnected" displayed + // forever even when the token was just plain wrong. + localStatus: 'idle', + peerStatus: 'idle', + localError: null, + peerError: null, hostname: '', // TCP blob ports learned from /api/peer/info on each side. 0 means // "TCP server disabled or not yet known"; protocol=tcp transfers @@ -62,64 +71,91 @@ document.addEventListener('alpine:init', () => { this.hostname = window.location.hostname || 'localhost'; }, - // ---- token management ------------------------------------------- - applyLocalToken() { + // ---- connect local ---------------------------------------------- + // Symmetric with connectPeer: takes whatever's in the input field, + // tries /api/peer/info, surfaces success / failure to the status pill. + // Wired to BOTH the "Connect" button and the Enter key in the input. + async connectLocal() { const t = (this.localTokenInput || '').trim(); if (!t) { this.localToken = null; this.localOk = false; + this.localStatus = 'idle'; + this.localError = null; this.closeWs(); return; } this.localToken = t; - this.checkLocal(); - }, - - async checkLocal() { - // /api/health returns ok regardless of token. /api/peer/info is - // behind the auth filter and also returns the local TCP port, - // which we need later for protocol=tcp transfers initiated from - // this side. + this.localStatus = 'connecting'; + this.localError = null; try { const r = await fetch('/api/peer/info', { headers: { 'X-NetCopy-Token': this.localToken } }); - this.localOk = r.ok; if (r.ok) { const info = await r.json(); this.localTcpPort = info.tcpPort || 0; this.hostname = info.hostname || this.hostname; + this.localOk = true; + this.localStatus = 'ok'; this.openWs(); document.dispatchEvent(new CustomEvent('netcopy:local-ready')); + } else { + this.localOk = false; + this.localStatus = 'error'; + this.localError = describeHttpFailure(r); + this.closeWs(); } - } catch (_) { + } catch (e) { this.localOk = false; + this.localStatus = 'error'; + this.localError = describeNetworkFailure(e); + this.closeWs(); } }, + // Legacy alias — older code paths may still refer to applyLocalToken / + // checkLocal. Both forward to the new combined entry point. + applyLocalToken() { return this.connectLocal(); }, + checkLocal() { return this.connectLocal(); }, + async connectPeer() { - if (!this.peerUrl || !this.peerToken) { + const url = (this.peerUrl || '').trim(); + const tok = (this.peerToken || '').trim(); + if (!url || !tok) { this.peerOk = false; + this.peerStatus = 'idle'; + this.peerError = (!url && !tok) + ? null + : (!url ? 'peer URL required' : 'peer token required'); this.closePeerWs(); return; } localStorage.setItem('netcopy.peerUrl', this.peerUrl); + this.peerStatus = 'connecting'; + this.peerError = null; try { const r = await fetch(this.normalisedPeerUrl() + '/api/peer/info', { - headers: { 'X-NetCopy-Token': this.peerToken } + headers: { 'X-NetCopy-Token': tok } }); - this.peerOk = r.ok; if (r.ok) { const info = await r.json(); this.peerTcpPort = info.tcpPort || 0; this.peerHostname = info.hostname || null; + this.peerOk = true; + this.peerStatus = 'ok'; this.openPeerWs(); document.dispatchEvent(new CustomEvent('netcopy:peer-ready')); } else { + this.peerOk = false; + this.peerStatus = 'error'; + this.peerError = describeHttpFailure(r); this.closePeerWs(); } - } catch (_) { + } catch (e) { this.peerOk = false; + this.peerStatus = 'error'; + this.peerError = describeNetworkFailure(e); this.closePeerWs(); } }, @@ -725,6 +761,40 @@ function fmtStats(s) { ' · max: ' + formatMs(s.maxMs); } +/** + * Maps a non-2xx Response to a short, human-friendly message for the status + * pill / error text. Tries to be specific where it matters (401 = token, + * 404 = URL points at the wrong server, 5xx = peer broken). + */ +function describeHttpFailure(resp) { + if (!resp) return 'request failed'; + const code = resp.status; + if (code === 401) return '401 — invalid token'; + if (code === 403) return '403 — forbidden'; + if (code === 404) return '404 — endpoint not found (check URL points at NetCopy)'; + if (code >= 500) return code + ' — peer server error'; + return code + ' — ' + (resp.statusText || 'request failed'); +} + +/** + * Maps a fetch-rejection error (TypeError, AbortError, etc.) to a short + * message. Browsers don't surface concrete TCP-level reasons (security + * boundary), so we have to be vague — the message merely tells the user + * "something below the HTTP layer broke". Most often it's CORS, an + * unreachable host, or a wrong protocol (http vs https). + */ +function describeNetworkFailure(e) { + const msg = (e && e.message) ? e.message : String(e); + // The browser-spec lie: every fetch failure surfaces as "Failed to fetch" + // / "Load failed" / "NetworkError" without further detail, regardless of + // whether the host is unreachable, refused, CORS-blocked, or used the + // wrong scheme. Translate to a hint covering the common causes. + if (/failed to fetch|load failed|networkerror/i.test(msg)) { + return 'unreachable (host down, wrong port, or http/https mismatch)'; + } + return msg; +} + /** * Per-file detail-dialog badge progress (0–100). The badge background is a hard-stop * gradient driven by --progress; this picks the right denominator for each lifecycle: diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index dcf8018..5c67d2c 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -40,10 +40,22 @@ autocomplete="off" spellcheck="false" placeholder="paste token from server stdout" x-model="$store.app.localTokenInput" - @change="$store.app.applyLocalToken()"> + @keyup.enter="$store.app.connectLocal()"> + + :class="{ ok: $store.app.localStatus === 'ok', + bad: $store.app.localStatus === 'error' }" + x-text="$store.app.localStatus === 'connecting' ? 'connecting…' + : $store.app.localStatus === 'ok' ? 'connected' + : $store.app.localStatus === 'error' ? 'error' + : 'disconnected'"> +
@@ -51,17 +63,30 @@ + x-model="$store.app.peerUrl" + @keyup.enter="$store.app.connectPeer()"> - + x-model="$store.app.peerToken" + @keyup.enter="$store.app.connectPeer()"> + + :class="{ ok: $store.app.peerStatus === 'ok', + bad: $store.app.peerStatus === 'error' }" + x-text="$store.app.peerStatus === 'connecting' ? 'connecting…' + : $store.app.peerStatus === 'ok' ? 'peer ok' + : $store.app.peerStatus === 'error' ? 'error' + : 'no peer'"> +
diff --git a/src/main/resources/web/style.css b/src/main/resources/web/style.css index 0879d3e..64c4e53 100644 --- a/src/main/resources/web/style.css +++ b/src/main/resources/web/style.css @@ -139,6 +139,19 @@ body { .status-pill.ok { color: var(--ok); border-color: rgba(109, 212, 154, 0.3); } .status-pill.bad { color: var(--bad); border-color: rgba(239, 111, 111, 0.3); } +/* Inline error message next to the connect button (v0.4.1+). Truncates with + ellipsis at narrow window widths; full text is in the title attribute for + hover. */ +.conn-error { + color: var(--bad); + font-size: 11px; + font-family: var(--font-mono); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ---------- Panels ---------------------------------------------------- */ .panels {