chore: release v1.7.86-alpha

This commit is contained in:
archipelago 2026-06-12 04:21:18 -04:00
parent e474a2b4c9
commit 146e641b57
21 changed files with 330 additions and 104 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## v1.7.86-alpha (2026-06-12)
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
## v1.7.85-alpha (2026-06-12) ## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up. - ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.

View File

@ -57,6 +57,7 @@ app:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10m
bitcoin_integration: bitcoin_integration:
rpc_access: read-only rpc_access: read-only

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.85-alpha" version = "1.7.86-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.85-alpha" version = "1.7.86-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.85-alpha", "version": "1.7.86-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.85-alpha", "version": "1.7.86-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.85-alpha", "version": "1.7.86-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -5,7 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3' import * as d3 from 'd3'
interface MapNode { interface MapNode {
@ -36,6 +36,11 @@ type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; tar
let simulation: d3.Simulation<SimNode, SimLink> | null = null let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string { function trustColor(level: string): string {
switch (level) { switch (level) {
case 'trusted': return '#4ade80' case 'trusted': return '#4ade80'
@ -50,6 +55,7 @@ function nodeRadius(n: MapNode): number {
} }
function render() { function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!) const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove() svg.selectAll('*').remove()
@ -160,7 +166,7 @@ onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
}) })
watch(() => [props.nodes, props.links], () => render(), { deep: true }) watch(graphSignature, () => render())
</script> </script>
<style scoped> <style scoped>

View File

@ -556,8 +556,8 @@ input[type="radio"]:active + * {
context) stay above the tab bar instead of sliding underneath it. */ context) stay above the tab bar instead of sliding underneath it. */
@media (max-width: 767px) { @media (max-width: 767px) {
.chat-iframe-mobile { .chat-iframe-mobile {
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important; height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important; height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
flex: none; flex: none;
} }
} }

View File

@ -19,11 +19,11 @@
<!-- Registry list --> <!-- Registry list -->
<div class="glass-card p-6 mb-6"> <div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between mb-2">
<h2 class="text-lg font-semibold text-white">Registries</h2> <h2 class="text-lg font-semibold text-white">Registries</h2>
<button <button
type="button" type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors" class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors w-full sm:w-auto"
@click="openAddRegistry" @click="openAddRegistry"
>+ Add registry</button> >+ Add registry</button>
</div> </div>
@ -118,7 +118,7 @@
<!-- Back link --> <!-- Back link -->
<RouterLink <RouterLink
to="/dashboard/settings" to="/dashboard/settings"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2" class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />

View File

@ -69,12 +69,12 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-3"> <div class="flex flex-col gap-3 sm:flex-row">
<!-- Git path: one-shot pull+rebuild+restart --> <!-- Git path: one-shot pull+rebuild+restart -->
<button <button
v-if="updateMethod === 'git' && !applying" v-if="updateMethod === 'git' && !applying"
@click="requestGitApply" @click="requestGitApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30" class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
> >
{{ t('systemUpdate.pullAndRebuild') }} {{ t('systemUpdate.pullAndRebuild') }}
</button> </button>
@ -82,14 +82,14 @@
<button <button
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded" v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
@click="downloadUpdate" @click="downloadUpdate"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium" class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium sm:w-auto"
> >
{{ t('systemUpdate.downloadUpdate') }} {{ t('systemUpdate.downloadUpdate') }}
</button> </button>
<button <button
v-if="updateMethod !== 'git' && downloaded && !applying" v-if="updateMethod !== 'git' && downloaded && !applying"
@click="requestApply" @click="requestApply"
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30" class="glass-button w-full rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 sm:w-auto"
> >
{{ t('systemUpdate.applyUpdate') }} {{ t('systemUpdate.applyUpdate') }}
</button> </button>
@ -145,8 +145,16 @@
<!-- Applying --> <!-- Applying -->
<div v-if="applying" class="glass-card p-6 mb-6"> <div v-if="applying" class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2> <h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
<div class="flex items-center gap-3"> <div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div> <svg
class="w-5 h-5 shrink-0 animate-spin text-orange-400"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.2"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p> <p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
</div> </div>
</div> </div>
@ -262,22 +270,25 @@
<!-- Actions row --> <!-- Actions row -->
<div class="glass-card p-6"> <div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2> <h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
<div class="flex flex-wrap gap-3"> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button <button
@click="checkForUpdates" @click="checkForUpdates"
:disabled="loading" :disabled="loading"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40" class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40 sm:w-auto"
> >
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }} {{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
</button> </button>
<button <button
v-if="rollbackAvailable" v-if="rollbackAvailable"
@click="requestRollback" @click="requestRollback"
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20" class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20 sm:w-auto"
> >
{{ t('systemUpdate.rollback') }} {{ t('systemUpdate.rollback') }}
</button> </button>
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center"> <RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
>
{{ t('systemUpdate.backToSettings') }} {{ t('systemUpdate.backToSettings') }}
</RouterLink> </RouterLink>
</div> </div>
@ -650,12 +661,28 @@ const installStartedAt = ref<number>(0)
const installElapsedSec = ref(0) const installElapsedSec = ref(0)
let installPollTimer: ReturnType<typeof setInterval> | null = null let installPollTimer: ReturnType<typeof setInterval> | null = null
let installElapsedTimer: ReturnType<typeof setInterval> | null = null let installElapsedTimer: ReturnType<typeof setInterval> | null = null
let installReadyTimer: ReturnType<typeof setTimeout> | null = null
const installElapsedLabel = computed(() => { const installElapsedLabel = computed(() => {
const s = installElapsedSec.value const s = installElapsedSec.value
if (s < 60) return `Elapsed: ${s}s` if (s < 60) return `Elapsed: ${s}s`
return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s` return `Elapsed: ${Math.floor(s / 60)}m${s % 60 < 10 ? '0' : ''}${s % 60}s`
}) })
function clearInstallTimers() {
if (installPollTimer) {
clearInterval(installPollTimer)
installPollTimer = null
}
if (installElapsedTimer) {
clearInterval(installElapsedTimer)
installElapsedTimer = null
}
if (installReadyTimer) {
clearTimeout(installReadyTimer)
installReadyTimer = null
}
}
function startInstallOverlay(targetVersion: string) { function startInstallOverlay(targetVersion: string) {
clearInstallTimers()
installing.value = true installing.value = true
installStage.value = 'applying' installStage.value = 'applying'
installTargetVersion.value = targetVersion installTargetVersion.value = targetVersion
@ -672,7 +699,7 @@ function startInstallOverlay(targetVersion: string) {
// Start polling /health after a short delay the backend restarts 2s // Start polling /health after a short delay the backend restarts 2s
// after replying to update.apply, so an immediate poll would see the // after replying to update.apply, so an immediate poll would see the
// old backend and conclude nothing happened. // old backend and conclude nothing happened.
setTimeout(() => { installReadyTimer = setTimeout(() => {
installStage.value = 'restarting' installStage.value = 'restarting'
installPollTimer = setInterval(pollHealth, 1500) installPollTimer = setInterval(pollHealth, 1500)
}, 2500) }, 2500)
@ -687,22 +714,37 @@ async function pollHealth() {
installStage.value = 'ready' installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null } if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
// Brief pause so the user sees the "Ready" state before the reload. // Brief pause so the user sees the "Ready" state before the reload.
setTimeout(() => { window.location.reload() }, 1200) installReadyTimer = setTimeout(() => { window.location.reload() }, 1200)
} else { } else {
// Backend is up but still reporting the old version frontend // Backend is up but still reporting the old version frontend
// and backend are mid-swap. Signal to the user. // and backend are mid-swap. Signal to the user.
installStage.value = 'reconnecting' installStage.value = 'reconnecting'
void confirmBackendUpdateSettled()
} }
} catch { } catch {
// Fetch fails while the server is mid-restart. Stay in 'restarting'. // Fetch fails while the server is mid-restart. Stay in 'restarting'.
} }
} }
async function confirmBackendUpdateSettled() {
try {
const res = await rpcClient.call<{
current_version: string
update_in_progress: boolean
}>({ method: 'update.status' })
if (!res.update_in_progress && installStage.value !== 'ready' && installStage.value !== 'stalled') {
installStage.value = 'ready'
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
installReadyTimer = setTimeout(() => { window.location.reload() }, 800)
}
} catch {
// Keep waiting on /health.
}
}
function reloadNow() { window.location.reload() } function reloadNow() { window.location.reload() }
// Cleanup if the component is torn down mid-install (unlikely but safe). // Cleanup if the component is torn down mid-install (unlikely but safe).
import { onBeforeUnmount } from 'vue' import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (installPollTimer) clearInterval(installPollTimer) clearInstallTimers()
if (installElapsedTimer) clearInterval(installElapsedTimer)
}) })
const lastCheckDisplay = computed(() => { const lastCheckDisplay = computed(() => {

View File

@ -16,7 +16,7 @@
</div> </div>
<button <button
@click="$emit('generate-invite', 'trusted')" @click="$emit('generate-invite', 'trusted')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite" :disabled="generatingInvite"
> >
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }} {{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
@ -36,7 +36,7 @@
</div> </div>
<button <button
@click="$emit('generate-invite', 'observer')" @click="$emit('generate-invite', 'observer')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="generatingInvite" :disabled="generatingInvite"
> >
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }} {{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
@ -56,7 +56,7 @@
</div> </div>
<button <button
@click="$emit('show-join')" @click="$emit('show-join')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
Enter Code Enter Code
</button> </button>
@ -75,7 +75,7 @@
</div> </div>
<button <button
@click="$emit('sync')" @click="$emit('sync')"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="w-full sm:w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
:disabled="syncing" :disabled="syncing"
> >
{{ syncing ? 'Syncing...' : 'Sync Now' }} {{ syncing ? 'Syncing...' : 'Sync Now' }}

View File

@ -13,9 +13,10 @@
<th <th
v-for="node in sortedNodes" v-for="node in sortedNodes"
:key="node.node_id" :key="node.node_id"
class="fleet-matrix-header-cell font-mono" class="fleet-matrix-header-cell"
:title="fleetNodeSubtitle(node)"
> >
{{ node.node_id.slice(0, 6) }} {{ fleetNodeDisplayName(node) }}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -39,7 +40,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type FleetNode, getContainerState } from './useFleetData' import { type FleetNode, getContainerState, fleetNodeDisplayName, fleetNodeSubtitle } from './useFleetData'
defineProps<{ defineProps<{
nodes: FleetNode[] nodes: FleetNode[]

View File

@ -11,7 +11,7 @@
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card"> <div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p> <p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p>
<p class="text-lg font-bold text-white truncate">{{ node.hostname || 'Unknown' }}</p> <p class="text-lg font-bold text-white truncate">{{ node.hostname || fleetNodeDisplayName(node) }}</p>
</div> </div>
<div class="monitoring-stat-card"> <div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p> <p class="text-xs text-white/50 uppercase tracking-wide">Address</p>

View File

@ -187,21 +187,59 @@ export function normalizeNodeHistoryResponse(data: {
return [] return []
} }
type FleetCache = {
nodes: FleetNode[]
fleetAlerts: FleetAlert[]
lastRefreshed: string
selectedNodeId: string | null
sortBy: SortOption
}
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
function readFleetCache(): Partial<FleetCache> {
if (typeof window === 'undefined') return {}
try {
const raw = window.sessionStorage.getItem(FLEET_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<FleetCache>
return {
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
}
} catch {
return {}
}
}
function writeFleetCache(state: FleetCache) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(FLEET_CACHE_KEY, JSON.stringify(state))
} catch {
// Cache is opportunistic only.
}
}
// --- Composable --- // --- Composable ---
export function useFleetData() { export function useFleetData() {
const loading = ref(true) const cached = readFleetCache()
const loading = ref(!(cached.nodes?.length ?? 0))
const errorMessage = ref('') const errorMessage = ref('')
const nodes = ref<FleetNode[]>([]) const nodes = ref<FleetNode[]>(cached.nodes ?? [])
const fleetAlerts = ref<FleetAlert[]>([]) const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
const refreshing = ref(false) const refreshing = ref(false)
const alertsLoading = ref(false) const alertsLoading = ref(false)
const selectedNodeId = ref<string | null>(null) const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
const nodeHistory = ref<NodeHistoryEntry[]>([]) const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false) const nodeHistoryLoading = ref(false)
const autoRefresh = ref(true) const autoRefresh = ref(true)
const lastRefreshed = ref('') const lastRefreshed = ref(cached.lastRefreshed ?? '')
const sortBy = ref<SortOption>('status') const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
const chartWidth = ref(300) const chartWidth = ref(300)
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
@ -284,6 +322,13 @@ export function useFleetData() {
if (data?.nodes) { if (data?.nodes) {
nodes.value = data.nodes.map(normalizeFleetNode) nodes.value = data.nodes.map(normalizeFleetNode)
lastRefreshed.value = new Date().toISOString() lastRefreshed.value = new Date().toISOString()
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
} }
} catch (err) { } catch (err) {
if (loading.value) { if (loading.value) {
@ -300,6 +345,13 @@ export function useFleetData() {
}) })
if (data?.alerts) { if (data?.alerts) {
fleetAlerts.value = data.alerts fleetAlerts.value = data.alerts
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
} }
} catch { } catch {
// Non-critical, retry on next poll // Non-critical, retry on next poll
@ -342,6 +394,13 @@ export function useFleetData() {
} else { } else {
selectedNodeId.value = nodeId selectedNodeId.value = nodeId
} }
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
} }
function toggleAutoRefresh() { function toggleAutoRefresh() {
@ -400,6 +459,23 @@ export function useFleetData() {
} else { } else {
nodeHistory.value = [] nodeHistory.value = []
} }
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
})
watch(sortBy, () => {
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
lastRefreshed: lastRefreshed.value,
selectedNodeId: selectedNodeId.value,
sortBy: sortBy.value,
})
}) })
// --- Lifecycle --- // --- Lifecycle ---

View File

@ -5,7 +5,7 @@ import { RouterLink } from 'vue-router'
<template> <template>
<!-- App Registries Section --> <!-- App Registries Section -->
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/96">App registries</h2> <h2 class="text-xl font-semibold text-white/96">App registries</h2>
<p class="text-sm text-white/60 mt-1"> <p class="text-sm text-white/60 mt-1">
@ -14,7 +14,7 @@ import { RouterLink } from 'vue-router'
</div> </div>
<RouterLink <RouterLink
to="/dashboard/settings/registries" to="/dashboard/settings/registries"
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2" class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"

View File

@ -7,12 +7,15 @@ const { t } = useI18n()
<template> <template>
<!-- System Updates Section --> <!-- System Updates Section -->
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2> <h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p> <p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
</div> </div>
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"> <RouterLink
to="/dashboard/settings/update"
class="glass-button px-4 py-2 rounded-lg text-sm flex w-full items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>

View File

@ -324,14 +324,49 @@ const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore() const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore() const appStore = useAppStore()
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
type ConnectedNodesCache = {
peers: Peer[]
observers: Peer[]
peerReachable: Record<string, boolean>
connectionRequests: ConnectionRequest[]
}
function readConnectedNodesCache(): Partial<ConnectedNodesCache> {
if (typeof window === 'undefined') return {}
try {
const raw = window.sessionStorage.getItem(CONNECTED_NODES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Partial<ConnectedNodesCache>
return {
peers: Array.isArray(parsed.peers) ? parsed.peers : [],
observers: Array.isArray(parsed.observers) ? parsed.observers : [],
peerReachable: parsed.peerReachable && typeof parsed.peerReachable === 'object' ? parsed.peerReachable : {},
connectionRequests: Array.isArray(parsed.connectionRequests) ? parsed.connectionRequests : [],
}
} catch {
return {}
}
}
function writeConnectedNodesCache(state: ConnectedNodesCache) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(CONNECTED_NODES_CACHE_KEY, JSON.stringify(state))
} catch {
// Cache is best-effort.
}
}
const nodesContainerRef = ref<HTMLElement | null>(null) const nodesContainerRef = ref<HTMLElement | null>(null)
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted') const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
const peers = ref<Peer[]>([]) const cached = readConnectedNodesCache()
const observers = ref<Peer[]>([]) const peers = ref<Peer[]>(cached.peers ?? [])
const observers = ref<Peer[]>(cached.observers ?? [])
const loadingPeers = ref(false) const loadingPeers = ref(false)
const peerReachableLocal = ref<Record<string, boolean>>({}) const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value })) const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const discovering = ref(false) const discovering = ref(false)
@ -351,7 +386,7 @@ const sendMessageError = ref('')
const sendMessageSuccess = ref('') const sendMessageSuccess = ref('')
// Connection requests // Connection requests
const connectionRequests = ref<ConnectionRequest[]>([]) const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
const loadingRequests = ref(false) const loadingRequests = ref(false)
const processingRequestId = ref<string | null>(null) const processingRequestId = ref<string | null>(null)
@ -388,6 +423,7 @@ function switchToRequestsTab() {
} }
async function loadPeers() { async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true loadingPeers.value = true
try { try {
const res = await rpcClient.listPeers() const res = await rpcClient.listPeers()
@ -427,8 +463,18 @@ async function loadPeers() {
peerReachableLocal.value[p.onion] = false peerReachableLocal.value[p.onion] = false
} }
} }
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e) if (import.meta.env.DEV) console.error('Failed to load peers:', e)
if (!hadPeers) {
peers.value = []
observers.value = []
}
} finally { } finally {
loadingPeers.value = false loadingPeers.value = false
} }
@ -483,6 +529,12 @@ async function loadConnectionRequests() {
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' }) const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
connectionRequests.value = res.requests || [] connectionRequests.value = res.requests || []
web5Badge.pendingRequestCount = connectionRequests.value.length web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
} catch { } catch {
if (!hadRequests) connectionRequests.value = [] if (!hadRequests) connectionRequests.value = []
} finally { } finally {
@ -496,6 +548,12 @@ async function acceptRequest(requestId: string) {
await rpcClient.call({ method: 'network.accept-request', params: { request_id: requestId } }) await rpcClient.call({ method: 'network.accept-request', params: { request_id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId) connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
await loadPeers() await loadPeers()
emit('toast', t('web5.connectionAccepted')) emit('toast', t('web5.connectionAccepted'))
} catch { } catch {
@ -511,6 +569,12 @@ async function rejectRequest(requestId: string) {
await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } }) await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } })
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId) connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
web5Badge.pendingRequestCount = connectionRequests.value.length web5Badge.pendingRequestCount = connectionRequests.value.length
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
emit('toast', t('web5.requestRejected')) emit('toast', t('web5.requestRejected'))
} catch { } catch {
emit('toast', t('web5.failedToRejectRequest')) emit('toast', t('web5.failedToRejectRequest'))

View File

@ -389,6 +389,29 @@ import type { ManagedIdentity, IdentityProfile } from './types'
const { t } = useI18n() const { t } = useI18n()
const IDENTITIES_CACHE_KEY = 'archipelago.web5.identities.v1'
function readIdentitiesCache(): ManagedIdentity[] {
if (typeof window === 'undefined') return []
try {
const raw = window.sessionStorage.getItem(IDENTITIES_CACHE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as ManagedIdentity[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeIdentitiesCache(identities: ManagedIdentity[]) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(IDENTITIES_CACHE_KEY, JSON.stringify(identities))
} catch {
// Cache is opportunistic only.
}
}
defineProps<{ defineProps<{
showStagger: boolean showStagger: boolean
}>() }>()
@ -397,7 +420,7 @@ const emit = defineEmits<{
toast: [text: string] toast: [text: string]
}>() }>()
const managedIdentities = ref<ManagedIdentity[]>([]) const managedIdentities = ref<ManagedIdentity[]>(readIdentitiesCache())
const identitiesLoading = ref(false) const identitiesLoading = ref(false)
const showCreateIdentityModal = ref(false) const showCreateIdentityModal = ref(false)
const newIdentityName = ref('Personal') const newIdentityName = ref('Personal')
@ -508,6 +531,7 @@ async function loadIdentities() {
try { try {
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' }) const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
managedIdentities.value = res.identities || [] managedIdentities.value = res.identities || []
writeIdentitiesCache(managedIdentities.value)
} catch { } catch {
if (!hadIdentities) managedIdentities.value = [] if (!hadIdentities) managedIdentities.value = []
} finally { } finally {

View File

@ -33,12 +33,12 @@
</div> </div>
</div> </div>
<div v-if="userDid" class="flex gap-2 mt-auto"> <div v-if="userDid" class="flex gap-2 mt-auto">
<button <button
@click="$emit('copyDid')" @click="$emit('copyDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }} {{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
</button> </button>
<button <button
@click="$emit('showDidDocument')" @click="$emit('showDidDocument')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
@ -69,19 +69,19 @@
</div> </div>
</div> </div>
<div v-if="dhtDid" class="flex gap-2 mt-auto"> <div v-if="dhtDid" class="flex gap-2 mt-auto">
<button <button
@click="$emit('copyDhtDid')" @click="$emit('copyDhtDid')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
> >
{{ dhtDidCopied ? 'Copied!' : 'Copy' }} {{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button> </button>
<button <button
@click="$emit('refreshDhtDid')" @click="$emit('refreshDhtDid')"
:disabled="publishingDht" :disabled="publishingDht"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50" class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
> >
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }} {{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button> </button>
</div> </div>
<button <button
v-else-if="userDid" v-else-if="userDid"

View File

@ -1,30 +1,30 @@
{ {
"version": "1.7.85-alpha", "version": "1.7.86-alpha",
"release_date": "2026-06-12", "release_date": "2026-06-12",
"changelog": [ "changelog": [
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.", "Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.",
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.", "Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.",
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.", "The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.",
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.", "The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.",
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.", "Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.",
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting." "Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.85-alpha", "current_version": "1.7.86-alpha",
"new_version": "1.7.85-alpha", "new_version": "1.7.86-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago",
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde", "sha256": "2eb936fe188df25947a2d626af1b22be2f7ef9dcbc927542389de3b38e80f9e6",
"size_bytes": 44049488 "size_bytes": 44050232
}, },
{ {
"name": "archipelago-frontend-1.7.85-alpha.tar.gz", "name": "archipelago-frontend-1.7.86-alpha.tar.gz",
"current_version": "1.7.85-alpha", "current_version": "1.7.86-alpha",
"new_version": "1.7.85-alpha", "new_version": "1.7.86-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago-frontend-1.7.85-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago-frontend-1.7.86-alpha.tar.gz",
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76", "sha256": "9f6f146eaf709cd3e778550bb7a56877dce551cb6d631bccf08e80ed977dd17b",
"size_bytes": 184057997 "size_bytes": 184060614
} }
] ]
} }

View File

@ -1,30 +1,30 @@
{ {
"version": "1.7.85-alpha", "version": "1.7.86-alpha",
"release_date": "2026-06-12", "release_date": "2026-06-12",
"changelog": [ "changelog": [
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.", "Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.",
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.", "Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.",
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.", "The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.",
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.", "The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.",
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.", "Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.",
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting." "Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.85-alpha", "current_version": "1.7.86-alpha",
"new_version": "1.7.85-alpha", "new_version": "1.7.86-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago",
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde", "sha256": "2eb936fe188df25947a2d626af1b22be2f7ef9dcbc927542389de3b38e80f9e6",
"size_bytes": 44049488 "size_bytes": 44050232
}, },
{ {
"name": "archipelago-frontend-1.7.85-alpha.tar.gz", "name": "archipelago-frontend-1.7.86-alpha.tar.gz",
"current_version": "1.7.85-alpha", "current_version": "1.7.86-alpha",
"new_version": "1.7.85-alpha", "new_version": "1.7.86-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago-frontend-1.7.85-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago-frontend-1.7.86-alpha.tar.gz",
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76", "sha256": "9f6f146eaf709cd3e778550bb7a56877dce551cb6d631bccf08e80ed977dd17b",
"size_bytes": 184057997 "size_bytes": 184060614
} }
] ]
} }