feat(ui): mesh Refresh/Broadcast feedback, Set Recommended modal with probe progress bar, live list refresh
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m43s

- Refresh button: real handler — calls the new mesh.refresh (radio
  re-query) plus contacts/federation/outbox re-reads, disabled with a
  spinner while running (was an unawaited cache repaint with no feedback
  that skipped half the list's data sources).
- Broadcast button: success ('Sent ✓') and failure states with the error
  in the tooltip; failures no longer vanish as unhandled rejections.
- The store's 5s status poll no longer wipes the error banner each tick.
- Contacts/aliases, federation nodes and the outbox badge refresh every
  ~30s (were mount-only and went permanently stale).
- Peer-list empty state keys on the merged list, so federation rows and
  the channel rows still render with no radio attached.
- Device setup modal: 'Set Recommended' naming, probe progress bar with
  stage labels instead of an anonymous spinner.
- Device panel: name save clears properly (empty = fall back to server
  name) and the confirmation reflects the new live apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-28 16:37:16 -04:00
parent a8c4694c36
commit 3f76b4960a
5 changed files with 138 additions and 17 deletions

View File

@ -1,7 +1,7 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
:title="step === 1 ? 'Mesh Radio Detected' : 'Set Recommended'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
@ -35,9 +35,17 @@
<!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
Reading what's on the radio
<div v-if="probing" class="py-1">
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
<span>{{ probeStage }}</span>
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
:style="{ width: probeProgress + '%' }"
></div>
</div>
</div>
<template v-else-if="probe">
<div class="flex items-center gap-2">
@ -94,7 +102,7 @@
:disabled="!!connecting"
@click="step = 2"
>
Set Up with Archipelago Settings
Set Recommended
</button>
</div>
<p class="text-white/40 text-[11px] mt-3">
@ -107,8 +115,8 @@
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the
radio until you confirm.
These are the recommended Archipelago settings nothing is written to
the radio until you confirm.
</p>
<!-- Summary of what will be applied -->
@ -206,6 +214,30 @@ const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('')
// Time-driven probe progress: the probe RPC is a single opaque call that can
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
// advances on a clock toward 92% and snaps to 100% when the result lands.
const probeProgress = ref(0)
const probeStage = ref('Waiting for the radio to boot…')
let probeTicker: ReturnType<typeof setInterval> | null = null
function startProbeProgress() {
stopProbeProgress()
probeProgress.value = 0
probeStage.value = 'Waiting for the radio to boot…'
const startedAt = Date.now()
probeTicker = setInterval(() => {
const elapsed = (Date.now() - startedAt) / 1000
// ~92% at 30s, decelerating never looks stuck, never lies "done".
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
}, 400)
}
function stopProbeProgress(done = false) {
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
if (done) probeProgress.value = 100
}
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
@ -275,6 +307,7 @@ watch([show, devicePath], async ([visible]) => {
probe.value = null
probeError.value = ''
probing.value = true
startProbeProgress()
const path = devicePath.value
try {
const res = await mesh.probeDevice(path)
@ -284,6 +317,7 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = e instanceof Error ? e.message : String(e)
}
} finally {
stopProbeProgress(true)
if (devicePath.value === path) probing.value = false
}
}, { immediate: false })

View File

@ -274,12 +274,16 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
trackDetectedDevices(res)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
// Don't clobber a user-action error (broadcast/configure/send) — this
// runs on a 5s poll, and the old `error.value = null` on entry meant
// any real error banner survived at most one poll tick.
if (!error.value) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
}
} finally {
loading.value = false
}
@ -975,6 +979,18 @@ export const useMeshStore = defineStore('mesh', () => {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
/** Ask the backend to actively re-query the radio's contact table (and by
* extension re-drain daemon events for Reticulum) the server-side half
* of the Refresh button; refreshAll() alone only re-reads caches. */
async function refreshRadio(): Promise<boolean> {
try {
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
return !!res.refreshed
} catch {
return false
}
}
return {
status,
peers,
@ -1002,6 +1018,7 @@ export const useMeshStore = defineStore('mesh', () => {
broadcastIdentity,
configure,
refreshAll,
refreshRadio,
markChatRead,
clearViewingChat,
sendInvoice,

View File

@ -38,6 +38,8 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
const messageText = ref('')
const sendError = ref('')
const broadcasting = ref(false)
const broadcastResult = ref<string | null>(null) // 'ok' | error message
const refreshing = ref(false)
const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
@ -383,12 +385,22 @@ onMounted(async () => {
archPollInterval = setInterval(loadArchMessages, 15000)
}
if (!pollInterval) {
let tick = 0
pollInterval = setInterval(() => {
mesh.fetchStatus()
mesh.fetchPeers()
mesh.fetchMessages()
mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders()
// Contacts/aliases, federation nodes and the outbox badge previously
// loaded ONCE at mount and went permanently stale new federation
// peers or renames never appeared without a full page reload. Every
// 6th tick (~30s) keeps them fresh without adding per-5s load.
if (++tick % 6 === 0) {
void refreshContacts()
void refreshFederationNodes()
void refreshOutboxCount()
}
}, 5000)
}
@ -1021,7 +1033,37 @@ function onChatWheel(e: WheelEvent) {
async function handleBroadcast() {
broadcasting.value = true
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
broadcastResult.value = null
try {
await mesh.broadcastIdentity()
broadcastResult.value = 'ok'
} catch (e) {
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
} finally {
broadcasting.value = false
setTimeout(() => { broadcastResult.value = null }, 4000)
}
}
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
// Backend first: re-query the radio's contact table (mesh.refresh), then
// re-read EVERYTHING the list is built from peers, contacts/aliases,
// federation nodes, outbox not just the mesh caches.
await Promise.allSettled([
mesh.refreshRadio(),
mesh.refreshAll(),
refreshContacts(),
refreshFederationNodes(),
refreshOutboxCount(),
])
// Radio contact refresh is async on the backend pick up its result.
await mesh.fetchPeers()
} finally {
refreshing.value = false
}
}
async function handleToggleEnabled() {
@ -1830,8 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
</button>
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
<button
class="glass-button mesh-action-btn"
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
:disabled="!mesh.status?.device_connected || broadcasting"
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
@click="handleBroadcast"
>
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
</button>
<button
class="glass-button mesh-action-btn"
@ -1841,7 +1889,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
</button>
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
</button>
</div>
<!-- Peers list -->
@ -1871,7 +1922,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>&times;</button>
</div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
<!-- Only claim "no peers" when the MERGED list (radio + federation)
is truly empty with no radio attached the federation rows and
the two channel rows must still render. -->
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet.
</div>

View File

@ -142,12 +142,15 @@ async function saveSettings() {
lora_region: form.value.region,
device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
// Always sent: an empty string CLEARS the custom mesh name (backend
// maps "" -> None -> fall back to the server name). The old omit-when-
// empty made clearing impossible once a name was ever set.
advert_name: form.value.name.trim(),
broadcast_identity: form.value.broadcastIdentity,
...(rfParams ? { lora_radio_params: rfParams } : {}),
})
saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000)
setTimeout(() => { saveDone.value = false }, 5000)
} catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
} finally {
@ -286,7 +289,7 @@ async function saveSettings() {
>
{{ saving ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="saveDone" class="text-xs text-green-400">Saved applies on next radio session</span>
<span v-if="saveDone" class="text-xs text-green-400">Saved applying to the radio now</span>
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
</div>
</div>

View File

@ -83,6 +83,19 @@
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
.mesh-refresh-spinner {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 9999px;
border: 2px solid rgba(251, 146, 60, 0.7);
border-top-color: transparent;
animation: mesh-refresh-spin 0.8s linear infinite;
vertical-align: -1px;
}
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }