chore: release v1.7.86-alpha
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# 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)
|
||||
|
||||
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
|
||||
|
||||
@@ -57,6 +57,7 @@ app:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10m
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
|
||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.85-alpha"
|
||||
version = "1.7.86-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.85-alpha"
|
||||
version = "1.7.86-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
4
neode-ui/package-lock.json
generated
4
neode-ui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.85-alpha",
|
||||
"version": "1.7.86-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.85-alpha",
|
||||
"version": "1.7.86-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.85-alpha",
|
||||
"version": "1.7.86-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
@@ -36,6 +36,11 @@ type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; tar
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | 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 {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
@@ -50,6 +55,7 @@ function nodeRadius(n: MapNode): number {
|
||||
}
|
||||
|
||||
function render() {
|
||||
simulation?.stop()
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
@@ -160,7 +166,7 @@ onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(() => [props.nodes, props.links], () => render(), { deep: true })
|
||||
watch(graphSignature, () => render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -556,8 +556,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.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(100dvh - 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))) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
<!-- Registry list -->
|
||||
<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>
|
||||
<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"
|
||||
>+ Add registry</button>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@
|
||||
<!-- Back link -->
|
||||
<RouterLink
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
|
||||
@@ -69,12 +69,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<!-- Git path: one-shot pull+rebuild+restart -->
|
||||
<button
|
||||
v-if="updateMethod === 'git' && !applying"
|
||||
@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') }}
|
||||
</button>
|
||||
@@ -82,14 +82,14 @@
|
||||
<button
|
||||
v-if="updateMethod !== 'git' && !downloading && !applying && !downloaded"
|
||||
@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') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="updateMethod !== 'git' && downloaded && !applying"
|
||||
@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') }}
|
||||
</button>
|
||||
@@ -145,8 +145,16 @@
|
||||
<!-- Applying -->
|
||||
<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>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,22 +270,25 @@
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<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
|
||||
@click="checkForUpdates"
|
||||
: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') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="rollbackAvailable"
|
||||
@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') }}
|
||||
</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') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -650,12 +661,28 @@ const installStartedAt = ref<number>(0)
|
||||
const installElapsedSec = ref(0)
|
||||
let installPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installElapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let installReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const installElapsedLabel = computed(() => {
|
||||
const s = installElapsedSec.value
|
||||
if (s < 60) return `Elapsed: ${s}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) {
|
||||
clearInstallTimers()
|
||||
installing.value = true
|
||||
installStage.value = 'applying'
|
||||
installTargetVersion.value = targetVersion
|
||||
@@ -672,7 +699,7 @@ function startInstallOverlay(targetVersion: string) {
|
||||
// Start polling /health after a short delay — the backend restarts 2s
|
||||
// after replying to update.apply, so an immediate poll would see the
|
||||
// old backend and conclude nothing happened.
|
||||
setTimeout(() => {
|
||||
installReadyTimer = setTimeout(() => {
|
||||
installStage.value = 'restarting'
|
||||
installPollTimer = setInterval(pollHealth, 1500)
|
||||
}, 2500)
|
||||
@@ -687,22 +714,37 @@ async function pollHealth() {
|
||||
installStage.value = 'ready'
|
||||
if (installPollTimer) { clearInterval(installPollTimer); installPollTimer = null }
|
||||
// Brief pause so the user sees the "Ready" state before the reload.
|
||||
setTimeout(() => { window.location.reload() }, 1200)
|
||||
installReadyTimer = setTimeout(() => { window.location.reload() }, 1200)
|
||||
} else {
|
||||
// Backend is up but still reporting the old version — frontend
|
||||
// and backend are mid-swap. Signal to the user.
|
||||
installStage.value = 'reconnecting'
|
||||
void confirmBackendUpdateSettled()
|
||||
}
|
||||
} catch {
|
||||
// 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() }
|
||||
// Cleanup if the component is torn down mid-install (unlikely but safe).
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
if (installPollTimer) clearInterval(installPollTimer)
|
||||
if (installElapsedTimer) clearInterval(installElapsedTimer)
|
||||
clearInstallTimers()
|
||||
})
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ generatingInvite && inviteType === 'trusted' ? 'Generating...' : 'Generate Code' }}
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ generatingInvite && inviteType === 'observer' ? 'Generating...' : 'Generate Code' }}
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<button
|
||||
@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
|
||||
</button>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ syncing ? 'Syncing...' : 'Sync Now' }}
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
<th
|
||||
v-for="node in sortedNodes"
|
||||
: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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -39,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FleetNode, getContainerState } from './useFleetData'
|
||||
import { type FleetNode, getContainerState, fleetNodeDisplayName, fleetNodeSubtitle } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodes: FleetNode[]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<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 class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
|
||||
|
||||
@@ -187,21 +187,59 @@ export function normalizeNodeHistoryResponse(data: {
|
||||
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 ---
|
||||
|
||||
export function useFleetData() {
|
||||
const loading = ref(true)
|
||||
const cached = readFleetCache()
|
||||
const loading = ref(!(cached.nodes?.length ?? 0))
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>([])
|
||||
const fleetAlerts = ref<FleetAlert[]>([])
|
||||
const nodes = ref<FleetNode[]>(cached.nodes ?? [])
|
||||
const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
|
||||
const refreshing = 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 nodeHistoryLoading = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const lastRefreshed = ref('')
|
||||
const sortBy = ref<SortOption>('status')
|
||||
const lastRefreshed = ref(cached.lastRefreshed ?? '')
|
||||
const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
|
||||
const chartWidth = ref(300)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -284,6 +322,13 @@ export function useFleetData() {
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (loading.value) {
|
||||
@@ -300,6 +345,13 @@ export function useFleetData() {
|
||||
})
|
||||
if (data?.alerts) {
|
||||
fleetAlerts.value = data.alerts
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, retry on next poll
|
||||
@@ -342,6 +394,13 @@ export function useFleetData() {
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
@@ -400,6 +459,23 @@ export function useFleetData() {
|
||||
} else {
|
||||
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 ---
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RouterLink } from 'vue-router'
|
||||
<template>
|
||||
<!-- App Registries Section -->
|
||||
<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>
|
||||
<h2 class="text-xl font-semibold text-white/96">App registries</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
@@ -14,7 +14,7 @@ import { RouterLink } from 'vue-router'
|
||||
</div>
|
||||
<RouterLink
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
|
||||
@@ -7,12 +7,15 @@ const { t } = useI18n()
|
||||
<template>
|
||||
<!-- System Updates Section -->
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -324,14 +324,49 @@ const messageToast = useMessageToast()
|
||||
const web5Badge = useWeb5BadgeStore()
|
||||
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 nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
|
||||
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||
|
||||
const peers = ref<Peer[]>([])
|
||||
const observers = ref<Peer[]>([])
|
||||
const cached = readConnectedNodesCache()
|
||||
const peers = ref<Peer[]>(cached.peers ?? [])
|
||||
const observers = ref<Peer[]>(cached.observers ?? [])
|
||||
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 discovering = ref(false)
|
||||
|
||||
@@ -351,7 +386,7 @@ const sendMessageError = ref('')
|
||||
const sendMessageSuccess = ref('')
|
||||
|
||||
// Connection requests
|
||||
const connectionRequests = ref<ConnectionRequest[]>([])
|
||||
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
|
||||
const loadingRequests = ref(false)
|
||||
const processingRequestId = ref<string | null>(null)
|
||||
|
||||
@@ -388,6 +423,7 @@ function switchToRequestsTab() {
|
||||
}
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peers.value.length > 0 || observers.value.length > 0
|
||||
loadingPeers.value = true
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
@@ -427,8 +463,18 @@ async function loadPeers() {
|
||||
peerReachableLocal.value[p.onion] = false
|
||||
}
|
||||
}
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
})
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
|
||||
if (!hadPeers) {
|
||||
peers.value = []
|
||||
observers.value = []
|
||||
}
|
||||
} finally {
|
||||
loadingPeers.value = false
|
||||
}
|
||||
@@ -483,6 +529,12 @@ async function loadConnectionRequests() {
|
||||
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
|
||||
connectionRequests.value = res.requests || []
|
||||
web5Badge.pendingRequestCount = connectionRequests.value.length
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
})
|
||||
} catch {
|
||||
if (!hadRequests) connectionRequests.value = []
|
||||
} finally {
|
||||
@@ -496,6 +548,12 @@ async function acceptRequest(requestId: string) {
|
||||
await rpcClient.call({ method: 'network.accept-request', params: { request_id: requestId } })
|
||||
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
|
||||
web5Badge.pendingRequestCount = connectionRequests.value.length
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
})
|
||||
await loadPeers()
|
||||
emit('toast', t('web5.connectionAccepted'))
|
||||
} catch {
|
||||
@@ -511,6 +569,12 @@ async function rejectRequest(requestId: string) {
|
||||
await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } })
|
||||
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
|
||||
web5Badge.pendingRequestCount = connectionRequests.value.length
|
||||
writeConnectedNodesCache({
|
||||
peers: peers.value,
|
||||
observers: observers.value,
|
||||
peerReachable: peerReachableLocal.value,
|
||||
connectionRequests: connectionRequests.value,
|
||||
})
|
||||
emit('toast', t('web5.requestRejected'))
|
||||
} catch {
|
||||
emit('toast', t('web5.failedToRejectRequest'))
|
||||
|
||||
@@ -389,6 +389,29 @@ import type { ManagedIdentity, IdentityProfile } from './types'
|
||||
|
||||
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<{
|
||||
showStagger: boolean
|
||||
}>()
|
||||
@@ -397,7 +420,7 @@ const emit = defineEmits<{
|
||||
toast: [text: string]
|
||||
}>()
|
||||
|
||||
const managedIdentities = ref<ManagedIdentity[]>([])
|
||||
const managedIdentities = ref<ManagedIdentity[]>(readIdentitiesCache())
|
||||
const identitiesLoading = ref(false)
|
||||
const showCreateIdentityModal = ref(false)
|
||||
const newIdentityName = ref('Personal')
|
||||
@@ -508,6 +531,7 @@ async function loadIdentities() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
|
||||
managedIdentities.value = res.identities || []
|
||||
writeIdentitiesCache(managedIdentities.value)
|
||||
} catch {
|
||||
if (!hadIdentities) managedIdentities.value = []
|
||||
} finally {
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userDid" class="flex gap-2 mt-auto">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
|
||||
</button>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ didCopied ? t('common.copiedBang') : t('web5.copyDid') }}
|
||||
</button>
|
||||
<button
|
||||
@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"
|
||||
@@ -69,19 +69,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dhtDid" class="flex gap-2 mt-auto">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('refreshDhtDid')"
|
||||
: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"
|
||||
>
|
||||
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
|
||||
</button>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('refreshDhtDid')"
|
||||
: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"
|
||||
>
|
||||
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="userDid"
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"version": "1.7.85-alpha",
|
||||
"version": "1.7.86-alpha",
|
||||
"release_date": "2026-06-12",
|
||||
"changelog": [
|
||||
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.",
|
||||
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.",
|
||||
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.",
|
||||
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.",
|
||||
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.",
|
||||
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting."
|
||||
"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`."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.85-alpha",
|
||||
"new_version": "1.7.85-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago",
|
||||
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde",
|
||||
"size_bytes": 44049488
|
||||
"current_version": "1.7.86-alpha",
|
||||
"new_version": "1.7.86-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago",
|
||||
"sha256": "2eb936fe188df25947a2d626af1b22be2f7ef9dcbc927542389de3b38e80f9e6",
|
||||
"size_bytes": 44050232
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.85-alpha.tar.gz",
|
||||
"current_version": "1.7.85-alpha",
|
||||
"new_version": "1.7.85-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",
|
||||
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76",
|
||||
"size_bytes": 184057997
|
||||
"name": "archipelago-frontend-1.7.86-alpha.tar.gz",
|
||||
"current_version": "1.7.86-alpha",
|
||||
"new_version": "1.7.86-alpha",
|
||||
"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": "9f6f146eaf709cd3e778550bb7a56877dce551cb6d631bccf08e80ed977dd17b",
|
||||
"size_bytes": 184060614
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"version": "1.7.85-alpha",
|
||||
"version": "1.7.86-alpha",
|
||||
"release_date": "2026-06-12",
|
||||
"changelog": [
|
||||
"ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.",
|
||||
"Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.",
|
||||
"LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.",
|
||||
"Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.",
|
||||
"Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.",
|
||||
"Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting."
|
||||
"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`."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.85-alpha",
|
||||
"new_version": "1.7.85-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.85-alpha/archipelago",
|
||||
"sha256": "06a6fe6e8f2e50bcda6c152c2de1a874edc84b2e65377f6e06d195c4eebc9cde",
|
||||
"size_bytes": 44049488
|
||||
"current_version": "1.7.86-alpha",
|
||||
"new_version": "1.7.86-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.86-alpha/archipelago",
|
||||
"sha256": "2eb936fe188df25947a2d626af1b22be2f7ef9dcbc927542389de3b38e80f9e6",
|
||||
"size_bytes": 44050232
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.85-alpha.tar.gz",
|
||||
"current_version": "1.7.85-alpha",
|
||||
"new_version": "1.7.85-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",
|
||||
"sha256": "c809fb27772773925d89b711236a81834465229d9f544bd65cf5816776cfda76",
|
||||
"size_bytes": 184057997
|
||||
"name": "archipelago-frontend-1.7.86-alpha.tar.gz",
|
||||
"current_version": "1.7.86-alpha",
|
||||
"new_version": "1.7.86-alpha",
|
||||
"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": "9f6f146eaf709cd3e778550bb7a56877dce551cb6d631bccf08e80ed977dd17b",
|
||||
"size_bytes": 184060614
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user