Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 86 additions & 16 deletions src/main/resources/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
},
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 33 additions & 8 deletions src/main/resources/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,28 +40,53 @@
autocomplete="off" spellcheck="false"
placeholder="paste token from server stdout"
x-model="$store.app.localTokenInput"
@change="$store.app.applyLocalToken()">
@keyup.enter="$store.app.connectLocal()">
<button class="btn primary"
@click="$store.app.connectLocal()"
:disabled="$store.app.localStatus === 'connecting'"
x-text="$store.app.localStatus === 'connecting' ? '…' : 'Connect'"></button>
<span class="status-pill"
:class="$store.app.localOk ? 'ok' : 'bad'"
x-text="$store.app.localOk ? 'connected' : 'disconnected'"></span>
: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'"></span>
<span class="conn-error"
x-show="$store.app.localError"
x-text="$store.app.localError"
:title="$store.app.localError"></span>
</div>

<div class="topbar-section">
<label class="field-label">Peer URL</label>
<input type="text"
class="text-input"
placeholder="http://host:7777"
x-model="$store.app.peerUrl">
x-model="$store.app.peerUrl"
@keyup.enter="$store.app.connectPeer()">
<label class="field-label">Peer token</label>
<input type="text"
class="text-input mono"
autocomplete="off" spellcheck="false"
placeholder="peer token"
x-model="$store.app.peerToken">
<button class="btn primary" @click="$store.app.connectPeer()">Connect peer</button>
x-model="$store.app.peerToken"
@keyup.enter="$store.app.connectPeer()">
<button class="btn primary"
@click="$store.app.connectPeer()"
:disabled="$store.app.peerStatus === 'connecting'"
x-text="$store.app.peerStatus === 'connecting' ? '…' : 'Connect peer'"></button>
<span class="status-pill"
:class="$store.app.peerOk ? 'ok' : 'bad'"
x-text="$store.app.peerOk ? 'peer ok' : 'no peer'"></span>
: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'"></span>
<span class="conn-error"
x-show="$store.app.peerError"
x-text="$store.app.peerError"
:title="$store.app.peerError"></span>
</div>
</header>

Expand Down
13 changes: 13 additions & 0 deletions src/main/resources/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading