Skip to content

Commit 9859f28

Browse files
VirusAlexaegupov-nclaude
authored
feat(ui): symmetric Connect button + clear error feedback for both sides (#68)
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: VirusAlex <alexey.egupov@norse.bh> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf4faf0 commit 9859f28

3 files changed

Lines changed: 132 additions & 24 deletions

File tree

src/main/resources/web/app.js

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ document.addEventListener('alpine:init', () => {
1515
peerToken: '',
1616
localOk: false,
1717
peerOk: false,
18+
// Connection lifecycle states for the UI status pill: 'idle' = nothing
19+
// attempted yet, 'connecting' = request in flight, 'ok' = success,
20+
// 'error' = failed with `localError`/`peerError` populated. Pre-v0.4.1
21+
// we only had a boolean and silent failure; "disconnected" displayed
22+
// forever even when the token was just plain wrong.
23+
localStatus: 'idle',
24+
peerStatus: 'idle',
25+
localError: null,
26+
peerError: null,
1827
hostname: '',
1928
// TCP blob ports learned from /api/peer/info on each side. 0 means
2029
// "TCP server disabled or not yet known"; protocol=tcp transfers
@@ -62,64 +71,91 @@ document.addEventListener('alpine:init', () => {
6271
this.hostname = window.location.hostname || 'localhost';
6372
},
6473

65-
// ---- token management -------------------------------------------
66-
applyLocalToken() {
74+
// ---- connect local ----------------------------------------------
75+
// Symmetric with connectPeer: takes whatever's in the input field,
76+
// tries /api/peer/info, surfaces success / failure to the status pill.
77+
// Wired to BOTH the "Connect" button and the Enter key in the input.
78+
async connectLocal() {
6779
const t = (this.localTokenInput || '').trim();
6880
if (!t) {
6981
this.localToken = null;
7082
this.localOk = false;
83+
this.localStatus = 'idle';
84+
this.localError = null;
7185
this.closeWs();
7286
return;
7387
}
7488
this.localToken = t;
75-
this.checkLocal();
76-
},
77-
78-
async checkLocal() {
79-
// /api/health returns ok regardless of token. /api/peer/info is
80-
// behind the auth filter and also returns the local TCP port,
81-
// which we need later for protocol=tcp transfers initiated from
82-
// this side.
89+
this.localStatus = 'connecting';
90+
this.localError = null;
8391
try {
8492
const r = await fetch('/api/peer/info', {
8593
headers: { 'X-NetCopy-Token': this.localToken }
8694
});
87-
this.localOk = r.ok;
8895
if (r.ok) {
8996
const info = await r.json();
9097
this.localTcpPort = info.tcpPort || 0;
9198
this.hostname = info.hostname || this.hostname;
99+
this.localOk = true;
100+
this.localStatus = 'ok';
92101
this.openWs();
93102
document.dispatchEvent(new CustomEvent('netcopy:local-ready'));
103+
} else {
104+
this.localOk = false;
105+
this.localStatus = 'error';
106+
this.localError = describeHttpFailure(r);
107+
this.closeWs();
94108
}
95-
} catch (_) {
109+
} catch (e) {
96110
this.localOk = false;
111+
this.localStatus = 'error';
112+
this.localError = describeNetworkFailure(e);
113+
this.closeWs();
97114
}
98115
},
99116

117+
// Legacy alias — older code paths may still refer to applyLocalToken /
118+
// checkLocal. Both forward to the new combined entry point.
119+
applyLocalToken() { return this.connectLocal(); },
120+
checkLocal() { return this.connectLocal(); },
121+
100122
async connectPeer() {
101-
if (!this.peerUrl || !this.peerToken) {
123+
const url = (this.peerUrl || '').trim();
124+
const tok = (this.peerToken || '').trim();
125+
if (!url || !tok) {
102126
this.peerOk = false;
127+
this.peerStatus = 'idle';
128+
this.peerError = (!url && !tok)
129+
? null
130+
: (!url ? 'peer URL required' : 'peer token required');
103131
this.closePeerWs();
104132
return;
105133
}
106134
localStorage.setItem('netcopy.peerUrl', this.peerUrl);
135+
this.peerStatus = 'connecting';
136+
this.peerError = null;
107137
try {
108138
const r = await fetch(this.normalisedPeerUrl() + '/api/peer/info', {
109-
headers: { 'X-NetCopy-Token': this.peerToken }
139+
headers: { 'X-NetCopy-Token': tok }
110140
});
111-
this.peerOk = r.ok;
112141
if (r.ok) {
113142
const info = await r.json();
114143
this.peerTcpPort = info.tcpPort || 0;
115144
this.peerHostname = info.hostname || null;
145+
this.peerOk = true;
146+
this.peerStatus = 'ok';
116147
this.openPeerWs();
117148
document.dispatchEvent(new CustomEvent('netcopy:peer-ready'));
118149
} else {
150+
this.peerOk = false;
151+
this.peerStatus = 'error';
152+
this.peerError = describeHttpFailure(r);
119153
this.closePeerWs();
120154
}
121-
} catch (_) {
155+
} catch (e) {
122156
this.peerOk = false;
157+
this.peerStatus = 'error';
158+
this.peerError = describeNetworkFailure(e);
123159
this.closePeerWs();
124160
}
125161
},
@@ -725,6 +761,40 @@ function fmtStats(s) {
725761
' · max: ' + formatMs(s.maxMs);
726762
}
727763

764+
/**
765+
* Maps a non-2xx Response to a short, human-friendly message for the status
766+
* pill / error text. Tries to be specific where it matters (401 = token,
767+
* 404 = URL points at the wrong server, 5xx = peer broken).
768+
*/
769+
function describeHttpFailure(resp) {
770+
if (!resp) return 'request failed';
771+
const code = resp.status;
772+
if (code === 401) return '401 — invalid token';
773+
if (code === 403) return '403 — forbidden';
774+
if (code === 404) return '404 — endpoint not found (check URL points at NetCopy)';
775+
if (code >= 500) return code + ' — peer server error';
776+
return code + ' — ' + (resp.statusText || 'request failed');
777+
}
778+
779+
/**
780+
* Maps a fetch-rejection error (TypeError, AbortError, etc.) to a short
781+
* message. Browsers don't surface concrete TCP-level reasons (security
782+
* boundary), so we have to be vague — the message merely tells the user
783+
* "something below the HTTP layer broke". Most often it's CORS, an
784+
* unreachable host, or a wrong protocol (http vs https).
785+
*/
786+
function describeNetworkFailure(e) {
787+
const msg = (e && e.message) ? e.message : String(e);
788+
// The browser-spec lie: every fetch failure surfaces as "Failed to fetch"
789+
// / "Load failed" / "NetworkError" without further detail, regardless of
790+
// whether the host is unreachable, refused, CORS-blocked, or used the
791+
// wrong scheme. Translate to a hint covering the common causes.
792+
if (/failed to fetch|load failed|networkerror/i.test(msg)) {
793+
return 'unreachable (host down, wrong port, or http/https mismatch)';
794+
}
795+
return msg;
796+
}
797+
728798
/**
729799
* Per-file detail-dialog badge progress (0–100). The badge background is a hard-stop
730800
* gradient driven by --progress; this picks the right denominator for each lifecycle:

src/main/resources/web/index.html

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,53 @@
4040
autocomplete="off" spellcheck="false"
4141
placeholder="paste token from server stdout"
4242
x-model="$store.app.localTokenInput"
43-
@change="$store.app.applyLocalToken()">
43+
@keyup.enter="$store.app.connectLocal()">
44+
<button class="btn primary"
45+
@click="$store.app.connectLocal()"
46+
:disabled="$store.app.localStatus === 'connecting'"
47+
x-text="$store.app.localStatus === 'connecting' ? '…' : 'Connect'"></button>
4448
<span class="status-pill"
45-
:class="$store.app.localOk ? 'ok' : 'bad'"
46-
x-text="$store.app.localOk ? 'connected' : 'disconnected'"></span>
49+
:class="{ ok: $store.app.localStatus === 'ok',
50+
bad: $store.app.localStatus === 'error' }"
51+
x-text="$store.app.localStatus === 'connecting' ? 'connecting…'
52+
: $store.app.localStatus === 'ok' ? 'connected'
53+
: $store.app.localStatus === 'error' ? 'error'
54+
: 'disconnected'"></span>
55+
<span class="conn-error"
56+
x-show="$store.app.localError"
57+
x-text="$store.app.localError"
58+
:title="$store.app.localError"></span>
4759
</div>
4860

4961
<div class="topbar-section">
5062
<label class="field-label">Peer URL</label>
5163
<input type="text"
5264
class="text-input"
5365
placeholder="http://host:7777"
54-
x-model="$store.app.peerUrl">
66+
x-model="$store.app.peerUrl"
67+
@keyup.enter="$store.app.connectPeer()">
5568
<label class="field-label">Peer token</label>
5669
<input type="text"
5770
class="text-input mono"
5871
autocomplete="off" spellcheck="false"
5972
placeholder="peer token"
60-
x-model="$store.app.peerToken">
61-
<button class="btn primary" @click="$store.app.connectPeer()">Connect peer</button>
73+
x-model="$store.app.peerToken"
74+
@keyup.enter="$store.app.connectPeer()">
75+
<button class="btn primary"
76+
@click="$store.app.connectPeer()"
77+
:disabled="$store.app.peerStatus === 'connecting'"
78+
x-text="$store.app.peerStatus === 'connecting' ? '…' : 'Connect peer'"></button>
6279
<span class="status-pill"
63-
:class="$store.app.peerOk ? 'ok' : 'bad'"
64-
x-text="$store.app.peerOk ? 'peer ok' : 'no peer'"></span>
80+
:class="{ ok: $store.app.peerStatus === 'ok',
81+
bad: $store.app.peerStatus === 'error' }"
82+
x-text="$store.app.peerStatus === 'connecting' ? 'connecting…'
83+
: $store.app.peerStatus === 'ok' ? 'peer ok'
84+
: $store.app.peerStatus === 'error' ? 'error'
85+
: 'no peer'"></span>
86+
<span class="conn-error"
87+
x-show="$store.app.peerError"
88+
x-text="$store.app.peerError"
89+
:title="$store.app.peerError"></span>
6590
</div>
6691
</header>
6792

src/main/resources/web/style.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ body {
139139
.status-pill.ok { color: var(--ok); border-color: rgba(109, 212, 154, 0.3); }
140140
.status-pill.bad { color: var(--bad); border-color: rgba(239, 111, 111, 0.3); }
141141

142+
/* Inline error message next to the connect button (v0.4.1+). Truncates with
143+
ellipsis at narrow window widths; full text is in the title attribute for
144+
hover. */
145+
.conn-error {
146+
color: var(--bad);
147+
font-size: 11px;
148+
font-family: var(--font-mono);
149+
max-width: 320px;
150+
overflow: hidden;
151+
text-overflow: ellipsis;
152+
white-space: nowrap;
153+
}
154+
142155
/* ---------- Panels ---------------------------------------------------- */
143156

144157
.panels {

0 commit comments

Comments
 (0)