feat: TASK-31 nav header cleanup, TASK-38 Bitcoin sync gauge on homepage

TASK-31: Cleaned up Apps page nav header structure (tabs + categories + search).
TASK-38: Added Bitcoin Core sync progress gauge to homepage System Stats card —
shows sync percentage, block height, and green/orange color coding. Only
appears when Bitcoin is running. Grid expands to 4 columns when visible.

Updated MASTER_PLAN.md — cleaned up completed sections, moved done items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-18 22:22:39 +00:00
parent 1a31c33ae8
commit 2c5180bfdc
3 changed files with 72 additions and 141 deletions

View File

@ -12,21 +12,13 @@
| ID | Title | Priority | Status | Dependencies |
|----|-------|----------|--------|--------------|
| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - |
| **TASK-8** | **Security hardening (9/12 fixed, H2/H3/H4 remain)** | **P0** | IN PROGRESS | - |
| **FEATURE-4** | **Onboarding loading screen with progress** | **P1** | IN PROGRESS | - |
| **TASK-9** | **Full feature testing sweep** | **P1** | PLANNED | - |
| **TASK-10** | **ISO build verification + multi-hardware test** | **P1** | PLANNED | - |
| **TASK-12** | **Beta telemetry — node reporting + monitoring panel** | **P1** | PLANNED | - |
| **BUG-20** | **ElectrumX always shows "Building..." not height** | **P2** | PLANNED | - |
| **TASK-31** | **Sticky nav header (My Apps/App Store/Services + categories + search)** | **P2** | PLANNED | - |
| **BUG-37** | **Apps flicker Start/Launch during container scan** | **P2** | PLANNED | - |
| **TASK-38** | **Add blockchain sync info to homepage System card** | **P2** | PLANNED | - |
| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 |
| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - |
| **TASK-17** | **Alpha version tags + rollback strategy** | **P2** | PLANNED | - |
| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 |
| **BUG-40** | **Uninstall dialog not full-screen modal** | **P2** | PLANNED | - |
| **BUG-41** | **Uninstall loader ends but app card persists** | **P2** | PLANNED | - |
### Phase 2: User Testing (controlled, real hardware)
@ -53,60 +45,6 @@
## Active Work
### BUG-1: Random logout / CSRF mismatch (PLANNED)
**Priority**: P0 — Critical
**Status**: PLANNED (2026-03-15)
Sessions expire unexpectedly during normal use. Backend sessions now persist to disk (`/var/lib/archipelago/sessions.json`) but CSRF token mismatch (403) still causes logouts. Need to investigate CSRF token lifecycle and fix the mismatch between cookie and header values.
**Root cause analysis so far**:
- Sessions were purely in-memory — fixed with disk persistence
- CSRF validation compares cookie value vs `X-CSRF-Token` header — both present but don't match
- Log: `403 CSRF mismatch — rejecting RPC call ... has_cookie=true has_header=true`
- Possible cause: cookie value rotated (e.g., new login in another tab) but frontend cached old value
**Key files**:
- `core/archipelago/src/session.rs` — session store (now persisted)
- `core/archipelago/src/api/rpc/mod.rs:273-307` — CSRF validation
- `neode-ui/src/api/rpc-client.ts:18-45` — frontend CSRF extraction from cookie
**Tasks**:
- [ ] Investigate CSRF token rotation — when/why cookie and header diverge
- [ ] Add logging to CSRF validation to capture actual cookie vs header values
- [ ] Consider returning CSRF token in response body (not just cookie) for explicit client storage
- [ ] Test multi-tab scenario where one tab's login rotates the CSRF token
- [ ] Verify session persistence survives deploys (second deploy test)
### TASK-2: Roll incoming-tx into deploy & ISO (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-16)
The incoming transactions feature (lnd.gettransactions RPC + wallet badge UI + auto-refresh) is working on .228. Roll changes into deploy-to-target.sh and build-auto-installer-iso.sh so fresh installs and deploys get it automatically. Do not break existing changes.
**Key files changed**:
- `core/archipelago/src/api/rpc/lnd.rs` — new `handle_lnd_gettransactions` method
- `core/archipelago/src/api/rpc/mod.rs` — registered `lnd.gettransactions` route
- `neode-ui/src/views/Web5.vue` — incoming tx badge, panel, auto-refresh polling
- `neode-ui/src/style.css` — incoming-tx-badge, incoming-tx-row, incoming-tx-slide classes
**Tasks**:
- [ ] Verify changes are already captured by existing deploy (backend build + frontend build)
- [ ] Ensure ISO build captures the updated Rust binary and frontend dist
- [ ] Test that no existing deploy/build logic is broken
### BUG-3: IndeedHub WebSocket spam in console (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-16)
`ws://localhost:7777/` connection refused fills browser console endlessly when IndeedHub is loaded in iframe. IndeedHub's compiled frontend bundle hardcodes `localhost` for WebSocket connections. When loaded from a remote host, `localhost` resolves to the user's machine, not the server.
**Root cause**: IndeedHub's Next.js build bakes `localhost:7777` into the WebSocket URL. The nginx WebSocket proxy at `/app/indeedhub/ws/` exists but is unused because IndeedHub loads via direct port 7777, not through the proxy path.
**Tasks**:
- [ ] Rebuild IndeedHub with `NEXT_PUBLIC_WS_URL` env var pointing to relative URL or actual server address
- [ ] Alternatively, configure IndeedHub to use relative WebSocket URLs (`/ws/` instead of `ws://localhost:7777/`)
- [ ] Test that WebSocket reconnection works after the fix
### FEATURE-4: Onboarding loading screen with progress (IN PROGRESS)
**Priority**: P1 — High
**Status**: IN PROGRESS (2026-03-17)
@ -139,28 +77,6 @@ Users hit the onboarding screen before the backend is ready, resulting in "Serve
- [ ] Handle edge cases: very slow starts, partial service failures, timeout fallback
- [ ] Test on fresh ISO install (first-boot scenario)
### TASK-8: Security hardening — 9/12 findings fixed (IN PROGRESS)
**Priority**: P0 — Critical
**Status**: IN PROGRESS (2026-03-18) — 9 of 12 pentest findings fixed
**Reference**: `docs/security-audit-2026-03-11.md`
**Fixed** (commits `27f205f`, `c1db74e`):
- [x] C1: /lnd-connect-info requires session auth
- [x] C3: DEV_MODE removed from production service
- [x] H1: node-message verifies ed25519 signatures
- [x] M1: content.add rejects `..` path traversal
- [x] M2: NIP-07 postMessage uses specific origin
- [x] M3: AIUI nginx checks session_id cookie
- [x] L2: Strict v3 onion validation
- [x] MED-03: Shell injection in bitcoin.conf generation
- [x] MED-07: No body size limit on /rpc/
**Remaining**:
- [ ] H2: Federation peer-joined signature verification
- [ ] H3: Federation address-changed signature verification
- [ ] H4: Bind service ports to 127.0.0.1 (Bitcoin RPC, LND, etc.)
### TASK-9: Full app testing matrix on fresh install (PLANNED)
**Priority**: P1 — High
**Status**: PLANNED (2026-03-18)
@ -191,29 +107,6 @@ Tag every significant alpha version with git tags for easy rollback. Each tag sh
---
### BUG-40: Uninstall dialog not full-screen modal (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-18)
The uninstall confirmation dialog renders as a small centered card instead of a full-screen modal overlay like all other modals. The sidebar and background content are fully visible behind it — should use the same full-screen backdrop pattern.
**Tasks**:
- [ ] Find the uninstall confirmation component and add full-screen backdrop
- [ ] Match the modal pattern used by other dialogs (e.g., send/receive modals)
### BUG-41: Uninstall loader ends but app card persists (PLANNED)
**Priority**: P2 — Medium
**Status**: PLANNED (2026-03-18)
After clicking Uninstall, the loading spinner finishes but the app card remains visible. Need an "Uninstalling..." state on the card that persists until the card is actually removed from the list.
**Tasks**:
- [ ] Add `uninstalling` state to app cards
- [ ] Show "Uninstalling..." overlay on the card after confirm
- [ ] Keep state until container is fully removed and card disappears from the list
---
## Post-Beta (FROZEN)
*These tasks are deferred until after beta ships. Do not start.*
@ -237,4 +130,12 @@ After clicking Uninstall, the loading spinner finishes but the app card remains
| **TASK-30** | On-Chain as first tab in receive Bitcoin modals | 2026-03-18 |
| **TASK-35** | Federation node names (show name not DID, hover for key) | 2026-03-18 |
| **TASK-36** | Cleaner iframe error screen with remediation | 2026-03-18 |
| **BUG-1** | Random logout / CSRF mismatch — HMAC-derived tokens | 2026-03-18 |
| **TASK-8** | Security hardening — 12/12 pentest findings fixed | 2026-03-18 |
| **BUG-20** | ElectrumX index estimate string ~55→~130 GB | 2026-03-18 |
| **BUG-37** | App card Start/Launch flicker during container scan | 2026-03-18 |
| **BUG-40** | Uninstall dialog not full-screen modal | 2026-03-18 |
| **BUG-41** | Uninstall loader ends but app card persists | 2026-03-18 |
| **BUG-33** | CPU load alert threshold too low (8 = 2x cores) | 2026-03-18 |
| **TASK-31** | Sticky nav header (Apps page) | 2026-03-18 |
| **TASK-38** | Blockchain sync info on homepage System card | 2026-03-18 |

View File

@ -1,39 +1,42 @@
<template>
<div class="pb-6">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
<!-- Nav header tabs + categories + search -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
</div>
<!-- Loading Skeleton -->

View File

@ -391,7 +391,7 @@
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-3 gap-4 flex-1 min-h-0">
<div class="home-card-stats grid grid-cols-1 gap-4 flex-1 min-h-0" :class="systemStats.bitcoinAvailable ? 'sm:grid-cols-4' : 'sm:grid-cols-3'">
<template v-if="!systemStatsLoaded">
<div v-for="i in 3" :key="i" class="p-4 bg-white/5 rounded-lg animate-pulse">
<div class="flex items-center justify-between mb-2">
@ -429,6 +429,18 @@
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(systemStats.diskPercent)" :style="{ width: systemStats.diskPercent + '%' }"></div>
</div>
</div>
<div v-if="systemStats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-orange-400/80">Bitcoin</p>
<p class="text-sm font-medium" :class="systemStats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400'">
{{ systemStats.bitcoinSyncPercent >= 99.9 ? 'Synced' : systemStats.bitcoinSyncPercent.toFixed(1) + '%' }}
</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="systemStats.bitcoinSyncPercent >= 99.9 ? 'bg-green-400' : 'bg-orange-400'" :style="{ width: Math.min(systemStats.bitcoinSyncPercent, 100) + '%' }"></div>
</div>
<p class="text-xs text-white/40 mt-1">Block {{ systemStats.bitcoinBlockHeight.toLocaleString() }}</p>
</div>
</template>
</div>
</div>
@ -832,6 +844,9 @@ const systemStats = reactive({
diskTotal: 0,
diskPercent: 0,
uptimeSecs: 0,
bitcoinSyncPercent: 0,
bitcoinBlockHeight: 0,
bitcoinAvailable: false,
})
const systemUptimeDisplay = computed(() => {
@ -880,6 +895,18 @@ async function loadSystemStats() {
if (import.meta.env.DEV) console.warn('RPC unavailable — keeping defaults', e)
systemStatsLoaded.value = true
}
// Fetch Bitcoin sync info (best-effort, only if Bitcoin is running)
try {
const btc = await rpcClient.call<{
block_height: number
sync_progress: number
}>({ method: 'bitcoin.getinfo', timeout: 5000 })
systemStats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
systemStats.bitcoinBlockHeight = btc.block_height ?? 0
systemStats.bitcoinAvailable = true
} catch {
systemStats.bitcoinAvailable = false
}
}
function uploadFiles() {