docs: commit phase pattern maps + discuss checkpoint for portability
This commit is contained in:
parent
62c99a60b2
commit
ee8e60a37c
398
.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
Normal file
398
.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
Normal file
@ -0,0 +1,398 @@
|
||||
# Phase 1: Federation & Mesh Hardening - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-29
|
||||
**Files analyzed:** 11 (backend touch points) + 5 (frontend/mock touch points)
|
||||
**Analogs found:** 15 / 16
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `core/archipelago/src/federation/storage.rs` (add lock) | model/store | CRUD (file-backed) | `core/archipelago/src/update.rs` `UPDATE_OP_LOCK` (`try_lock` guard on a mutating op) | role-match (same "serialize file-mutating ops" problem, different domain) |
|
||||
| `core/archipelago/src/server.rs` (~L497, ~L840 loops) | service (periodic loop) | event-driven/batch | itself (Tor-refresh loop at ~L480) + `mesh/listener/session.rs:386` `PORT_OPEN_LOCK` for the coordination primitive | exact (loop shape) / role-match (lock) |
|
||||
| `core/archipelago/src/federation/types.rs` (NodeStateSnapshot + FederationPeerHint additions) | model | transform (serde) | itself — `shared_location` (`lat`/`lon`) opt-in field, same struct | exact |
|
||||
| `core/archipelago/src/api/rpc/federation/handlers.rs` (build_local_state call site) | controller/RPC handler | request-response | itself — `shared_location` gating block (L476-479) | exact |
|
||||
| `core/archipelago/src/api/rpc/lnd/info.rs` (`handle_lnd_getinfo` extension) | controller/RPC handler | request-response | `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (adjacent LND REST handler, validation + response shaping style) | role-match |
|
||||
| `core/archipelago/src/api/rpc/dispatcher.rs` (new method registration) | route/dispatcher | request-response | itself — `"lnd.getinfo"`/`"lnd.openchannel"`/`"federation.list-nodes"` match arms | exact |
|
||||
| `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (new mesh capability / channel-open-request message type) | controller/RPC handler | event-driven | itself — `handle_mesh_contacts_list` (L1215) and reaction/reply handlers (L637-976) | exact |
|
||||
| `neode-ui/mock-backend.js` (`mesh.contacts-list`/`contacts-save`, stateful reaction/reply/edit/delete/forward) | mock RPC handler | CRUD (in-memory per-session store) | itself — `mesh.send-content-inline` (L4355) for stateful `meshStore.dynamic` mutation; `mesh.transport-advice` (L4318) for "mirror the daemon comment" convention | exact |
|
||||
| `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05) | component/modal | request-response | `neode-ui/src/components/LightningChannelsPanel.vue` (open-channel form + error handling) + `neode-ui/src/components/BaseModal.vue` (Teleport shell) + `neode-ui/src/components/federation/PeerRequestModal.vue` (request flow) | exact (composite of 3 analogs) |
|
||||
| `neode-ui/src/views/federation/NodeList.vue` (picker-row pattern reused inside new modal) | component | request-response | itself | exact |
|
||||
| `neode-ui/src/components/ScreensaverRing.vue` (new `badge` size variant) | component | transform (pure CSS/SVG) | itself — existing `compact`/`default` size-class pattern | exact |
|
||||
| `neode-ui/src/components/SendBitcoinModal.vue` / `WalletScanModal.vue` (swap ring) | component | transform | `neode-ui/src/components/Screensaver.vue` (existing `ScreensaverRing` + centered-content layering pattern) | exact |
|
||||
| `neode-ui/src/api/rpc-client.ts` (new method wrappers: own LN URI, channel-open-request) | service (API client) | request-response | itself — `mesh.contacts-list`/`contacts-save` wrappers (L804, L813) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `core/archipelago/src/federation/storage.rs` (locking fix)
|
||||
|
||||
**Analog:** `core/archipelago/src/update.rs:35` (`UPDATE_OP_LOCK`)
|
||||
|
||||
**Core pattern** (lines 25-35, `update.rs`):
|
||||
```rust
|
||||
/// Serializes the mutating update operations (download, apply, and the
|
||||
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
|
||||
/// exactly this race: two concurrent `update.download` RPCs shared one
|
||||
/// staging file, a cancel wiped staging mid-flight, a third download began
|
||||
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
|
||||
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
|
||||
/// via `try_lock` so a concurrent caller gets an explicit "already running"
|
||||
/// error instead of silently interleaving.
|
||||
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
```
|
||||
This is the closest documented precedent in the codebase for "two async
|
||||
call sites race on the same on-disk resource, fix with a static
|
||||
`tokio::sync::Mutex::const_new(())`" — same shape of bug as Pitfall 1 in
|
||||
RESEARCH.md, already root-caused and fixed once before. Copy the doc-comment
|
||||
style (explain *why*, cite the historical incident) and the `try_lock`
|
||||
vs. `.lock().await` decision: prefer plain `.lock().await` (blocking wait,
|
||||
not `try_lock`-reject) for the federation case, since federation writes are
|
||||
infrequent and a caller silently failing "already syncing" would reintroduce
|
||||
the original bug's symptom (lost writes) rather than fix it — unlike
|
||||
`update.rs`'s deliberate reject-on-contention UX.
|
||||
|
||||
**Secondary reference:** `core/archipelago/src/container/app_ops.rs:17-24` — a
|
||||
`HashMap<String, Arc<tokio::sync::Mutex<()>>>` keyed per-app-id, for when a
|
||||
single global lock is too coarse. Not needed here (one `data_dir` = one
|
||||
federation store = one lock is fine), but note this pattern exists if the
|
||||
planner decides per-node-id granularity is warranted.
|
||||
|
||||
**Also apply:** `federation::storage::save_nodes` is a direct `fs::write()`,
|
||||
not atomic temp+rename. No existing atomic-write helper was found elsewhere
|
||||
in `core/archipelago/src/` (grepped, none present) — this will be genuinely
|
||||
new code; keep it minimal (`fs::write` to `nodes.json.tmp` then `fs::rename`).
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/server.rs` (two federation sync loops, ~L497 / ~L840)
|
||||
|
||||
**Analog:** itself (the Tor-refresh loop pattern immediately above, ~L479) and `mesh/listener/session.rs:386`'s `PORT_OPEN_LOCK` for how a shared static lock is threaded through an async loop body.
|
||||
|
||||
**Loop skeleton pattern** (both existing loops share this shape — `server.rs:497-515` and `server.rs:840-853`):
|
||||
```rust
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(20)).await; // startup settle delay
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(90));
|
||||
// 1800s loop additionally sets:
|
||||
// interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let nodes = match crate::federation::load_nodes(&data_dir).await {
|
||||
Ok(n) if !n.is_empty() => n,
|
||||
_ => continue,
|
||||
};
|
||||
// ... snapshot local identity, iterate `nodes`, call sync_with_peer ...
|
||||
}
|
||||
});
|
||||
```
|
||||
**Error handling pattern:** both loops only `debug!()` on failure (RESEARCH.md
|
||||
Anti-Pattern flagged this — FED-02 requires operator-visible errors). Do not
|
||||
copy this part; extend to persist `last_sync_error` on the node record
|
||||
(mirrors how `record_peer_transport` already persists `last_transport`/
|
||||
`last_transport_at` in `federation/storage.rs:120-147` — same "write a
|
||||
result field back to the node struct after each attempt" shape, just for the
|
||||
error side instead of the success side).
|
||||
|
||||
**If collapsing the two loops:** the 1800s loop's unique tail call is
|
||||
`refresh_federation_mesh_peers()` (per RESEARCH.md Open Question 1) — move
|
||||
that single call to the end of the 90s loop's per-pass completion rather
|
||||
than deleting the loop body wholesale, preserving whatever roster-propagation
|
||||
behavior it provides.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/federation/types.rs` (NodeStateSnapshot Lightning fields)
|
||||
|
||||
**Analog:** itself — the existing `shared_location` (`lat`/`lon`) opt-in field on the same struct (lines 124-131).
|
||||
|
||||
**Exact pattern to mirror** (`federation/types.rs:124-131`):
|
||||
```rust
|
||||
/// This node's own location, for the Mesh Map — only present when the
|
||||
/// sender has opted in via `server.set-location`'s `share` flag. Absent
|
||||
/// (not just null) for nodes that haven't opted in, so older receivers
|
||||
/// and the map's "no location shared" state both fall out naturally.
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
```
|
||||
Add `lightning_uri: Option<String>` (or `lightning_pubkey` + `lightning_host`
|
||||
split, matching `FederationPeerHint`'s `pubkey`/`onion` split style at
|
||||
line 137-145) with the same `#[serde(default)]` back-compat annotation and a
|
||||
doc comment explaining the opt-in gating (per CONTEXT.md's locked decision:
|
||||
default ON for federation, unlike `shared_location`'s default-off — call
|
||||
this out explicitly in the doc comment since it deviates from the analog).
|
||||
|
||||
**Gating call site analog** (`api/rpc/federation/handlers.rs:476-479`):
|
||||
```rust
|
||||
let shared_location = if data.server_info.share_location {
|
||||
data.server_info.lat.zip(data.server_info.lon)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
```
|
||||
Mirror this shape for the Lightning URI gate, then thread it through
|
||||
`federation::build_local_state(...)` the same way `shared_location` is
|
||||
threaded (`sync.rs:230,258-259` — accepted as a parameter, mapped into the
|
||||
snapshot fields at construction time). If FED-05 lands the "default ON"
|
||||
decision, this becomes a simpler unconditional read (no `if`), but keep the
|
||||
struct-level `Option` + `#[serde(default)]` regardless so a future opt-out
|
||||
setting is a pure additive change.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/api/rpc/lnd/info.rs` (own-node Lightning URI RPC)
|
||||
|
||||
**Analog:** `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (`channels.rs:238-336`) for response/validation style in the same file family — reuse verbatim, do not modify; new code only needs to *read* `identity_pubkey`/`uris` out of the same LND REST response `handle_lnd_getinfo` already fetches but doesn't forward. Read `info.rs`'s current struct/response shape directly before editing (not excerpted here — small, single-file change, one Read call is enough at implementation time).
|
||||
|
||||
**Dispatcher registration analog** (`api/rpc/dispatcher.rs:125,128`):
|
||||
```rust
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
...
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
```
|
||||
Any new RPC (e.g. a dedicated `lnd.own-uri` if the planner decides not to
|
||||
extend `getinfo`) follows this exact one-line match-arm registration
|
||||
convention — no separate route table, no middleware wiring beyond this.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (mesh capability advertisement / channel-open-request message type)
|
||||
|
||||
**No direct analog exists** — RESEARCH.md confirms this is greenfield (no
|
||||
capability-advertisement field on mesh peers/contacts today). Closest
|
||||
structural analogs for *how to add a new field to a broadcast peer struct*
|
||||
and *how to add a new mesh message type*:
|
||||
|
||||
**Analog A — read/return handler shape** (`typed_messages.rs:1215-1234`,
|
||||
`handle_mesh_contacts_list`):
|
||||
```rust
|
||||
pub(in crate::api::rpc) async fn handle_mesh_contacts_list(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let contacts = state.contacts.read().await;
|
||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||
// ... merge/collapse logic ...
|
||||
}
|
||||
```
|
||||
Use this shape (`mesh_service.read().await` → `shared_state()` →
|
||||
`.read().await` on the relevant map) for any new "list peers with lightning
|
||||
capability" RPC.
|
||||
|
||||
**Analog B — event-driven send handlers** (`typed_messages.rs:637-976`,
|
||||
reaction/reply/receipt/forward family) — mirror for a new
|
||||
"channel-open-request" mesh message type: same struct-per-message-type,
|
||||
serialize-and-broadcast pattern already used for reactions/replies.
|
||||
|
||||
**Security note (carries from RESEARCH.md V4):** any new RPC meant to be
|
||||
peer-reachable (not just locally-authenticated) must be added to
|
||||
`is_peer_allowed_path()` (`server.rs:1270`) explicitly — don't assume it
|
||||
inherits reachability.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/mock-backend.js` (contacts-list/save + stateful reaction/reply/edit/delete/forward)
|
||||
|
||||
**Analog — stateful mutation pattern** (`mock-backend.js:4355-4370`,
|
||||
`mesh.send-content-inline`):
|
||||
```javascript
|
||||
case 'mesh.send-content-inline': {
|
||||
// ... validate params ...
|
||||
const meshStore = currentStore().mesh
|
||||
const id = 100 + meshStore.dynamic.length
|
||||
meshStore.dynamic.push({
|
||||
// ... message shape matching real daemon's mesh message schema ...
|
||||
})
|
||||
return res.json({ result: { ... } })
|
||||
}
|
||||
```
|
||||
**Analog — "mirror the daemon, cite the source" comment convention**
|
||||
(`mock-backend.js:4312-4317`, immediately above `mesh.transport-advice`):
|
||||
```javascript
|
||||
// Mirrors the real daemon's size-based tier logic
|
||||
// (typed_messages.rs handle_mesh_transport_advice) so the demo shows the
|
||||
// SAME modals a real node would — the chooser only appears in the narrow
|
||||
// fits-both band, never unconditionally.
|
||||
```
|
||||
Apply both patterns verbatim to the FED-04 gaps:
|
||||
- `mesh.contacts-list`/`mesh.contacts-save` — currently **absent** (404s),
|
||||
add cases that read/write a per-session contacts bucket the same way
|
||||
`meshStore.dynamic` is a per-session bucket (`currentStore().mesh`), citing
|
||||
`typed_messages.rs:1180-1371` as source of truth per the comment convention.
|
||||
- `mesh.send-reaction`/`send-reply`/`edit-message`/`delete-message`/
|
||||
`forward-message` — currently bare `{ok:true}` acks
|
||||
(`mock-backend.js:4479-4490`, cited verbatim in RESEARCH.md) — replace each
|
||||
with a `meshStore.dynamic` mutation (find message by id, mutate reactions
|
||||
array / set edited text / mark deleted / push a forwarded copy), citing
|
||||
`typed_messages.rs:637-976` (reply/reaction) and `:1065-1180` (edit/delete)
|
||||
as source of truth.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05)
|
||||
|
||||
**Analog 1 — modal shell:** `neode-ui/src/components/BaseModal.vue:1-40`
|
||||
```vue
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="fixed inset-0 flex items-center justify-center p-4" @click.self="close">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="glass-card p-6 w-full relative z-10 flex flex-col" role="dialog" aria-modal="true" @click.stop>
|
||||
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
|
||||
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
||||
<button @click="close" aria-label="Close">...</button>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="shrink-0"><slot name="header" /></div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">...</div>
|
||||
```
|
||||
Hard rule per CONTEXT.md/UI-SPEC.md: every new modal MUST use `BaseModal.vue`
|
||||
or replicate this exact `Teleport` + `fixed inset-0` + `@click.self="close"`
|
||||
structure — never nest inside a `transform`-affected ancestor.
|
||||
|
||||
**Analog 2 — manual URI form + error/startup-notice treatment:**
|
||||
`neode-ui/src/components/LightningChannelsPanel.vue`
|
||||
```vue
|
||||
<!-- line 258-261 -->
|
||||
placeholder="pubkey@host:port"
|
||||
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
|
||||
```
|
||||
```vue
|
||||
<!-- line 329-336 -->
|
||||
<div v-if="openError" :class="isStartupNotice(openError) ? amberClasses : 'alert-error'">
|
||||
<span v-if="isStartupNotice(openError)" class="mr-1">⏳</span>{{ openError }}
|
||||
</div>
|
||||
```
|
||||
```js
|
||||
// line 599-624 (validation-before-RPC pattern)
|
||||
if (!uri) { openError.value = 'Peer URI is required'; return }
|
||||
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
|
||||
```
|
||||
Copy this validate-before-RPC-call, `openError` ref, `isStartupNotice()`
|
||||
amber-vs-red distinction pattern verbatim into the new modal's "Paste URI
|
||||
Manually" fallback path.
|
||||
|
||||
**Analog 3 — request flow (meshed peer "Request Channel"):**
|
||||
`neode-ui/src/components/federation/PeerRequestModal.vue`
|
||||
```vue
|
||||
<!-- line 34-37 -->
|
||||
<button :disabled="sending" @click="$emit('send', message.trim() || undefined)">
|
||||
{{ sending ? 'Sending…' : 'Send Request' }}
|
||||
</button>
|
||||
```
|
||||
Per UI-SPEC.md's Copywriting Contract, reuse this component's pattern
|
||||
directly (optional message field, `sending`/`Sending…` busy state,
|
||||
`$emit('send', ...)` / `$emit('cancel')` contract) rather than building a new
|
||||
request-modal component.
|
||||
|
||||
**Analog 4 — picker row layout (trusted-nodes / meshed-LN-peers lists):**
|
||||
`neode-ui/src/views/federation/NodeList.vue`
|
||||
```vue
|
||||
<!-- line 56-65 -->
|
||||
<span v-if="transportBadge(node)" :class="transportBadge(node)!.cls" :title="transportBadge(node)!.title">
|
||||
{{ transportBadge(node)!.label }}
|
||||
</span>
|
||||
<span :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
|
||||
```
|
||||
```js
|
||||
// line 158-159
|
||||
const trustedNodes = computed(() => props.nodes.filter(n => n.trust_level === 'trusted'))
|
||||
const peerNodes = computed(() => props.nodes.filter(n => n.trust_level !== 'trusted'))
|
||||
```
|
||||
Mirror this row layout (name + transport badge + trust/status badge +
|
||||
action button) for both the trusted-nodes and meshed-LN-peers picker
|
||||
columns; reuse `transportBadge()`'s FIPS/Tor logic as-is per
|
||||
`Don't Hand-Roll` in RESEARCH.md (no new transport-tracking needed).
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/components/ScreensaverRing.vue` (new `badge` size variant)
|
||||
|
||||
**Analog:** itself — existing `compact`/`default` size-class pattern (lines 15-66).
|
||||
```vue
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: 'default' | 'compact'
|
||||
...
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
|
||||
```
|
||||
```css
|
||||
.viz-ring-compact { /* diameter/--viz-radius rules, lines 60-66 incl. breakpoint */ }
|
||||
```
|
||||
Add a third `'badge'` union member + `viz-ring-badge` CSS class following the
|
||||
exact same shape (mobile diameter, `≥768px` breakpoint diameter,
|
||||
`--viz-radius` custom property), sized per UI-SPEC.md's table (160px/192px,
|
||||
`--viz-radius` 80px/96px). Also add the missing
|
||||
`@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }`
|
||||
guard inside this component (UI-SPEC.md flagged this as a real, currently
|
||||
absent gap — contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`
|
||||
reduced-motion guard, which should be the copy source for the exact media
|
||||
query syntax).
|
||||
|
||||
**Composition analog (how to layer center content over the ring):**
|
||||
`neode-ui/src/components/Screensaver.vue`'s existing
|
||||
`ScreensaverRing` + `ScreensaverLogo` centered-absolute layering — reuse this
|
||||
`position: relative` wrapper + `position: absolute; inset: 0` inner-content
|
||||
pattern for both `SendBitcoinModal.vue`'s `.burst-core` and
|
||||
`WalletScanModal.vue`'s success-ring inner content.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/api/rpc-client.ts` (new method wrappers)
|
||||
|
||||
**Analog:** existing `mesh.contacts-list`/`contacts-save` wrappers (lines 804, 813):
|
||||
```typescript
|
||||
return this.call({ method: 'mesh.contacts-list', params: {} })
|
||||
...
|
||||
return this.call({ method: 'mesh.contacts-save', params })
|
||||
```
|
||||
New wrappers (own Lightning URI fetch, channel-open-request send) follow this
|
||||
exact `this.call({ method: '<namespace>.<verb>', params })` one-liner
|
||||
convention — no custom fetch/axios logic, no new client class.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Async static lock for a racy on-disk resource
|
||||
**Source:** `core/archipelago/src/update.rs:35` (`UPDATE_OP_LOCK`)
|
||||
**Apply to:** `federation/storage.rs`'s `load_nodes`/`save_nodes`/`remove_node`/`update_node_state` call sites (FED-01/FED-02 core fix)
|
||||
```rust
|
||||
static <NAME>_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
// acquire with .lock().await (not try_lock — federation writes should queue, not reject)
|
||||
```
|
||||
|
||||
### Optional opt-in shared field on `NodeStateSnapshot`
|
||||
**Source:** `core/archipelago/src/federation/types.rs:124-131` (`shared_location`)
|
||||
**Apply to:** New `lightning_uri`/`lightning_pubkey` field (FED-05)
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
```
|
||||
|
||||
### Teleport-to-body modal shell
|
||||
**Source:** `neode-ui/src/components/BaseModal.vue:1-40`
|
||||
**Apply to:** All new FED-05 UI (hard rule per CONTEXT.md/UI-SPEC.md)
|
||||
|
||||
### Mock backend must mirror real daemon logic, with a comment citing the source file/lines
|
||||
**Source:** `neode-ui/mock-backend.js:4312-4317` (comment above `mesh.transport-advice`)
|
||||
**Apply to:** All FED-04 mock-backend.js gap fills (contacts-list/save, reaction/reply/edit/delete/forward)
|
||||
|
||||
### One-line RPC dispatcher registration
|
||||
**Source:** `core/archipelago/src/api/rpc/dispatcher.rs:125,128,349`
|
||||
**Apply to:** Any new backend RPC method added for FED-05 (own LN URI, channel-open-request)
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| Mesh peer "has lightning" capability advertisement (new field on mesh peer/contact struct + propagation) | model + event-driven | No existing capability-advertisement mechanism for mesh (as opposed to federation) peers exists in the codebase — RESEARCH.md confirms this is genuinely greenfield. Nearest structural precedent is the read/broadcast handler shapes in `typed_messages.rs` (see Pattern Assignments above), not a field-level analog. Planner should design this as a new optional field on whatever struct already carries mesh peer capability info (check `mesh/mod.rs` peer struct at implementation time), following the same `#[serde(default)] Option<T>` back-compat convention used everywhere else in this codebase. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `core/archipelago/src/{federation,mesh,api/rpc,server.rs,update.rs,container,content_invoice.rs}`, `neode-ui/src/{components,views/federation,api}`, `neode-ui/mock-backend.js`
|
||||
**Files scanned:** ~25 (targeted reads/greps; RESEARCH.md's existing file:line citations reused where already verified)
|
||||
**Pattern extraction date:** 2026-07-29
|
||||
@ -0,0 +1,41 @@
|
||||
{
|
||||
"phase": "2",
|
||||
"phase_name": "UI Performance",
|
||||
"timestamp": "2026-07-30T00:00:00Z",
|
||||
"areas_completed": ["Caching approach", "Refresh/staleness UX", "Profiling targets", "Fix scope: backend RPC"],
|
||||
"areas_remaining": [],
|
||||
"decisions": {
|
||||
"Caching approach": [
|
||||
{"question": "How should main tabs stay warm across switches?", "answer": "KeepAlive + SWR", "options_presented": ["KeepAlive + SWR (Recommended)", "SWR only", "KeepAlive only"]},
|
||||
{"question": "Which views get converted this phase?", "answer": "Profiling decides", "options_presented": ["Profiling decides (Recommended)", "All main tabs"]},
|
||||
{"question": "Keep heavy views (Mesh D3+Leaflet) alive?", "answer": "Keep alive, cap KeepAlive max instances", "options_presented": ["Keep alive, cap count (Recommended)", "Exclude heavy views", "You decide"]},
|
||||
{"question": "Secondary screens treatment?", "answer": "SWR keyed per item, no KeepAlive", "options_presented": ["SWR, no KeepAlive (Recommended)", "Same as main tabs", "You decide"]}
|
||||
],
|
||||
"Refresh/staleness UX": [
|
||||
{"question": "How visible should background refresh be on a cached tab?", "answer": "Subtle indicator (small spinner/shimmer while loadState === 'refreshing')", "options_presented": ["Invisible (Recommended)", "Subtle indicator", "Stale-age badges"]},
|
||||
{"question": "Default staleness TTL?", "answer": "30s default, per-view tuning at Claude's discretion", "options_presented": ["30s, per-view tuning (Recommended)", "Short (10s) everywhere", "Long (60s+) everywhere"]},
|
||||
{"question": "Background refresh failure behavior?", "answer": "Silent keep-last; retry on next focus/TTL", "options_presented": ["Silent keep-last (Recommended)", "Toast on failure"]},
|
||||
{"question": "sessionStorage persistence?", "answer": "Yes, except large payloads", "options_presented": ["Yes, except large payloads (Recommended)", "Memory only"]}
|
||||
],
|
||||
"Profiling targets": [
|
||||
{"question": "Which surfaces feel slowest?", "answer": "All offered: Apps tab + AppDetails, Mesh, Wallet + send flows, Cloud/Files + Server; user added: Network, Web5, and 'often app store'", "options_presented": ["Apps tab + AppDetails", "Mesh tab", "Wallet tab + send flows", "Cloud/Files + Server"]},
|
||||
{"question": "Which real hardware verifies fixes?", "answer": "archi-dev-box (user-specified)", "options_presented": [".228 (Recommended)", ".198 dev pair", "Whichever node showed it"]},
|
||||
{"question": "PERF-01 profiling evidence?", "answer": "Committed findings doc in phase dir (surface -> measured cause -> intended fix)", "options_presented": ["Committed findings doc (Recommended)", "Findings in plans only"]},
|
||||
{"question": "Pass bar on-device?", "answer": "No visible spinner on revisit (subjective-but-crisp)", "options_presented": ["No visible spinner on revisit (Recommended)", "Numeric budgets"]}
|
||||
],
|
||||
"Fix scope: backend RPC": [
|
||||
{"question": "May this phase touch core/ RPC handlers?", "answer": "Additive changes only — new aggregate/batch endpoints and cheap response-shaping; no refactors of existing handlers or orchestrator", "options_presented": ["Additive changes only (Recommended)", "Frontend only", "Whatever profiling justifies"]},
|
||||
{"question": "Client-side waterfall fix pattern?", "answer": "Parallelize (Promise.all) + rpc-client dedup", "options_presented": ["Parallelize + dedup (Recommended)", "Aggregate endpoints", "You decide per view"]},
|
||||
{"question": "Deploy discipline?", "answer": "Dev pair only, no OTA this phase", "options_presented": ["Dev pair only, no OTA (Recommended)", "OTA when verified"]}
|
||||
]
|
||||
},
|
||||
"deferred_ideas": [
|
||||
"AIUI: permissioned node access — AI assistant talks to the node safely when permissioned, without leaking data, using the same command surface as Pine and everything else enableable in settings",
|
||||
"AIUI: chat starts expanded; on mobile it must start on chat, not context (current start-on-context is wrong)",
|
||||
"AIUI: deep dive into the content it finds and serves — make it perfect",
|
||||
"AIUI: show peer videos and files",
|
||||
"IndeeHub cross-node content source: nodes serve uploaded films/music/files to every IndeeHub install (an 'archipelago content source'), with payments",
|
||||
"Nostr integration in AIUI made more beautiful"
|
||||
],
|
||||
"canonical_refs": []
|
||||
}
|
||||
322
.planning/phases/02-ui-performance/02-PATTERNS.md
Normal file
322
.planning/phases/02-ui-performance/02-PATTERNS.md
Normal file
@ -0,0 +1,322 @@
|
||||
# Phase 2: UI Performance - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-30
|
||||
**Files analyzed:** 9 (new/modified)
|
||||
**Analogs found:** 9 / 9 (all analogs are in-repo; several files ARE their own analog — modify-in-place)
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|--------------------|------|-----------|-----------------|----------------|
|
||||
| `neode-ui/src/views/Dashboard.vue` (nested RouterView, ~line 87) | route/container (KeepAlive host) | request-response (render orchestration) | itself (template restructure) — pattern source: Vue Router 4 `v-slot` docs example | exact-pattern, no in-repo KeepAlive precedent |
|
||||
| `neode-ui/src/composables/useCachedResource.ts` | composable (SWR hook) | CRUD (cache read/refresh) | itself — extend with `onActivated` | exact (self-modify) |
|
||||
| `neode-ui/src/views/dashboard/useRouteTransitions.ts` | utility (route classification) | transform | itself — `isDetailRoute`/`TAB_ORDER` extended into an explicit main-tab-name list | exact (self-modify) |
|
||||
| `.planning/phases/02-ui-performance/02-FINDINGS.md` (new, D-10 deliverable) | doc/config | batch (one-time profiling report) | no code analog — doc-only deliverable | n/a |
|
||||
| `neode-ui/src/views/Apps.vue`, `Mesh.vue`, `Cloud.vue`, `Server.vue`, `web5/Web5.vue`, marketplace/discover view (candidates, D-02-gated) | component (main-tab view) | request-response + CRUD (fetch-on-mount → converted to SWR) | `neode-ui/src/views/Cloud.vue` (already partially on `useCachedResource`) | role-match, best-in-class |
|
||||
| `neode-ui/src/views/ContainerAppDetails.vue` (`onMounted`, lines 169-172) | component (secondary screen) | request-response (serial waterfall → parallel) | itself — Pattern 3 fix in place | exact (self-modify) |
|
||||
| `neode-ui/src/views/AppDetails.vue` and other secondary screens (D-04 candidates) | component (secondary screen) | CRUD (keyed cache, no KeepAlive) | `neode-ui/src/views/Cloud.vue`'s `peersResource`/`countsResource` usage | role-match |
|
||||
| `neode-ui/src/views/Chat.vue` (`aiuiUrl` computed, lines 74-82) | component (iframe URL builder) | transform (query-param construction) | itself — extend query string for D-14 | exact (self-modify) |
|
||||
| `neode-ui/src/stores/resources.ts` | store | CRUD (cache backing store) | itself — unchanged, referenced only | n/a (no changes expected) |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `neode-ui/src/views/Dashboard.vue` (route/container, KeepAlive host)
|
||||
|
||||
**Analog:** No in-repo KeepAlive precedent exists (`grep -r "KeepAlive" neode-ui/src` returns nothing) — the pattern to introduce is a Vue Router 4 official pattern, applied to this file's own existing nested `<RouterView>` structure.
|
||||
|
||||
**Current structure to modify** (`neode-ui/src/views/Dashboard.vue:87-118`):
|
||||
```vue
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'" ...>
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<div v-else ...>
|
||||
<component :is="Component" class="view-container flex-none" />
|
||||
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
```
|
||||
Two branches both wrap `<component :is="Component" />` — `<KeepAlive>` must be inserted around `<component :is="Component" />` in BOTH branches (or the two wrapper `<div>`s must be unified behind a single KeepAlive so classes/padding still differ per route). The `:key="route.path"` is currently on the outer wrapper `<div>`, not on `<component>` itself — leave it there (it only drives the `<Transition>`, not KeepAlive's cache identity) per RESEARCH.md's note that keying `<component>` by `route.path` is only a footgun for *varying-param* detail routes, which are excluded from KeepAlive entirely (D-04).
|
||||
|
||||
**Target pattern** (Vue Router 4 official form, RESEARCH.md Pattern 1):
|
||||
```vue
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :name="transitionName">
|
||||
<keep-alive :include="mainTabComponentNames" :max="8">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
```
|
||||
`mainTabComponentNames` should be built explicitly from the `TAB_ORDER` main-tab set in `useRouteTransitions.ts` (Home, Apps, Mesh, Cloud, Server, Web5, Marketplace/Discover, Chat, Settings, Fleet) — NOT derived from `isDetailRoute()` (Pitfall 7: that helper under-covers secondary screens like `cloud/:folderId`, `server/openwrt`, `web5/credentials`, etc.).
|
||||
|
||||
**Fallback if `include`/`exclude` name-matching misbehaves** (Pitfall 1 — confirm via manual smoke test first): drop `include` entirely and rely on `:max="8"` alone with LRU eviction (documented as working correctly with async components even when `include`/`exclude` name-matching does not).
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/composables/useCachedResource.ts` (composable, self-modify)
|
||||
|
||||
**Analog:** itself (full file already read, 107 lines — no re-read needed)
|
||||
|
||||
**Current imports** (lines 25-26):
|
||||
```typescript
|
||||
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
|
||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||
```
|
||||
|
||||
**Existing lifecycle-scope pattern to mirror** (lines 84-94):
|
||||
```typescript
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
aborter.abort()
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.immediate ?? true) refreshIfStale()
|
||||
```
|
||||
|
||||
**Required addition** (Pattern 2, RESEARCH.md) — add alongside the `onScopeDispose` block:
|
||||
```typescript
|
||||
import { onActivated } from 'vue'
|
||||
// ...
|
||||
if (getCurrentScope()) {
|
||||
onActivated(() => refreshIfStale())
|
||||
}
|
||||
```
|
||||
Safe unconditionally — Vue no-ops `onActivated` outside a `<KeepAlive>` boundary, so this benefits main tabs (real fix) and secondary screens (harmless no-op) with one shared change. `refreshIfStale()` (lines 71-74) and `stale()` are already defined and reusable as-is — no new staleness logic needed.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/views/dashboard/useRouteTransitions.ts` (utility, self-modify)
|
||||
|
||||
**Analog:** itself (full 183 lines read)
|
||||
|
||||
**Existing (insufficient) classification helper** (lines 38-41):
|
||||
```typescript
|
||||
export function isDetailRoute(path: string): boolean {
|
||||
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
|
||||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
|
||||
}
|
||||
```
|
||||
Do NOT reuse this as the KeepAlive `include` source (Pitfall 7 — misses `cloud/:folderId`, `server/openwrt`, `web5/credentials`, `goals/:goalId`, `app-session/:appId`, `web5/networking-profits`, `apps/lnd/channels`). Instead build the KeepAlive include list from `TAB_ORDER` (lines 4-15), which already enumerates exactly the main-tab paths:
|
||||
```typescript
|
||||
const TAB_ORDER = [
|
||||
'/dashboard', '/dashboard/apps', '/dashboard/marketplace', '/dashboard/cloud',
|
||||
'/dashboard/mesh', '/dashboard/server', '/dashboard/web5', '/dashboard/fleet',
|
||||
'/dashboard/chat', '/dashboard/settings'
|
||||
]
|
||||
```
|
||||
Map each path to its route component's registered `name` (check `router/index.ts` route defs) to produce `mainTabComponentNames` for Dashboard.vue's `<KeepAlive :include>`.
|
||||
|
||||
---
|
||||
|
||||
### Main-tab view conversions (Apps.vue, Mesh.vue, Server.vue, Web5.vue, marketplace/discover — D-02-gated)
|
||||
|
||||
**Analog:** `neode-ui/src/views/Cloud.vue` (already the most SWR-converted main-tab-adjacent view; 1029 lines, use Grep-then-targeted-Read for any further detail, not a full read)
|
||||
|
||||
**Imports pattern** (`Cloud.vue:405,410`):
|
||||
```typescript
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useCachedResource } from '../composables/useCachedResource'
|
||||
```
|
||||
|
||||
**Core SWR resource-definition pattern** (`Cloud.vue:510-524`):
|
||||
```typescript
|
||||
// Federation peers — cached so the Folders tab's peer cards paint instantly
|
||||
// on revisit while the list revalidates behind them.
|
||||
const peersResource = useCachedResource<PeerNode[]>({
|
||||
key: 'cloud.peer-nodes',
|
||||
fetcher: async (signal) => {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
void signal
|
||||
return result?.nodes ?? []
|
||||
},
|
||||
ttlMs: 30_000,
|
||||
immediate: false, // kicked from onMounted (keeps the legacy load order)
|
||||
})
|
||||
const peerNodes = computed(() => peersResource.entry.data ?? [])
|
||||
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
|
||||
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
|
||||
```
|
||||
|
||||
**`onMounted` orchestration + error-keep-last-value pattern** (`Cloud.vue:949-968`, excerpted from grep hits at those lines):
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
// ... prior setup ...
|
||||
await peersResource.refresh()
|
||||
const e = peersResource.entry
|
||||
if (e.error) loadError.value = e.error // keep-last-known-value; surface error in a banner, not a toast
|
||||
})
|
||||
```
|
||||
This mirrors D-07 exactly: silent keep-last-value on background failure, explicit-refresh-only error surfacing.
|
||||
|
||||
**Per-view conversion checklist derived from RESEARCH.md's Concrete Findings table** (use during D-02 profiling, not blanket):
|
||||
- Mesh.vue: `onMounted` already does `await Promise.all([...6 calls...])` — NOT a waterfall; convert each of the 6 fetches into a `useCachedResource` key (nothing cached today) rather than touching the parallelization.
|
||||
- Server.vue (889 lines): 7 fire-and-forget calls in `onMounted` (line ~831) — already parallel; convert each to `useCachedResource` if profiling confirms uncached-refetch is the cause.
|
||||
- Apps.vue: WebSocket-pushed store data, no per-mount RPC — likely KeepAlive-only fix (remount storm), not a caching conversion.
|
||||
- Marketplace/Discover: not yet code-inspected — needs its own profiling pass before assigning a fix category.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/views/ContainerAppDetails.vue` (secondary screen, serial-waterfall fix)
|
||||
|
||||
**Analog:** itself — the exact anti-pattern to fix in place (Pattern 3, RESEARCH.md)
|
||||
|
||||
**Current serial waterfall** (lines 169-173):
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
```
|
||||
|
||||
**Independent load functions confirming no cross-dependency** (lines 175-202):
|
||||
```typescript
|
||||
async function loadContainer() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthStatus() {
|
||||
await store.fetchHealthStatus()
|
||||
healthStatus.value = store.getHealthStatus(appId.value)
|
||||
}
|
||||
```
|
||||
Each sets its own `loading`/`logsLoading` ref independently and reads no shared intermediate result — safe to parallelize:
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadContainer(), loadLogs(), loadHealthStatus()])
|
||||
})
|
||||
```
|
||||
Note other call sites in the same file (lines ~205-240) also `await loadContainer(); await loadHealthStatus()` sequentially in refresh/action handlers — check each for the same independence property before parallelizing (don't blanket-apply beyond the `onMounted` case without verifying).
|
||||
|
||||
---
|
||||
|
||||
### Secondary screens converting to keyed `useCachedResource` (AppDetails.vue and similar, D-04)
|
||||
|
||||
**Analog:** `neode-ui/src/views/Cloud.vue`'s resource pattern above, keyed per item instead of globally.
|
||||
|
||||
**Key-naming convention to follow** (per CONTEXT.md D-04 and RESEARCH.md's Don't-Hand-Roll table):
|
||||
```typescript
|
||||
const detailsResource = useCachedResource<AppDetails>({
|
||||
key: `app-details:${appId.value}`,
|
||||
fetcher: (signal) => rpcClient.call({ method: 'apps.get-details', params: { appId: appId.value }, signal, dedup: true }),
|
||||
ttlMs: 30_000, // tune per D-06; shorter for fast-moving sub-resources
|
||||
})
|
||||
```
|
||||
No `<KeepAlive>` for these components (D-04) — component instance is NOT persisted, only the data cache is, so repeat opens still fully mount/unmount but paint instantly from `entry.data` before any refetch resolves.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/views/Chat.vue` (AIUI iframe URL builder, D-14 UX defaults)
|
||||
|
||||
**Analog:** itself — extend the existing query-string builder pattern.
|
||||
|
||||
**Current URL-builder pattern** (lines 74-82):
|
||||
```typescript
|
||||
const aiuiUrl = computed(() => {
|
||||
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
|
||||
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
|
||||
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
|
||||
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
|
||||
return ''
|
||||
})
|
||||
```
|
||||
D-14's two defaults (chat starts expanded; mobile starts on chat view not context view) would append additional query params here (e.g. `&expanded=1`, `&mobileView=chat`) following the exact same string-concatenation convention — IF AIUI's own source (sibling repo, NOT in this checkout per RESEARCH.md's Open Question 1) already reads such params. **Blocking dependency**: confirm AIUI source location/support before scoping this as line-of-code work — this file's pattern is ready, but the receiving side is unverified.
|
||||
|
||||
**Origin-validated postMessage listener pattern** (lines 92-103, useful if D-14 needs a postMessage handshake instead of/in addition to query params):
|
||||
```typescript
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
try {
|
||||
const expected = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
if (event.origin !== expected) return
|
||||
} catch { return }
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Stale-while-revalidate data cache
|
||||
**Source:** `neode-ui/src/composables/useCachedResource.ts` (full file, 107 lines)
|
||||
**Apply to:** Every main-tab conversion (D-02) and every secondary-screen conversion (D-04)
|
||||
```typescript
|
||||
const resource = useCachedResource<T>({
|
||||
key: 'some.unique.key', // or `templated:${id}` for per-item secondary screens
|
||||
fetcher: (signal) => rpcClient.call({ method: '...', signal, dedup: true }),
|
||||
ttlMs: 30_000, // default; tune per D-06
|
||||
})
|
||||
```
|
||||
Do not hand-roll a second `ref` + manual `sessionStorage` cache per view — this is the exact duplication RESEARCH.md's canonical_refs warns against.
|
||||
|
||||
### Request dedup for parallelized fetch groups
|
||||
**Source:** `neode-ui/src/api/rpc-client.ts` (`dedup: true` option, already used in `Cloud.vue`/`Server.vue`)
|
||||
**Apply to:** Any newly-parallelized `Promise.all`/`Promise.allSettled` group (Pattern 3 fixes) so concurrent identical calls from multiple mounted consumers collapse into one request.
|
||||
```typescript
|
||||
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
signal,
|
||||
dedup: true,
|
||||
maxRetries: 1,
|
||||
})
|
||||
```
|
||||
|
||||
### Background-refresh error handling (silent keep-last-value, D-07)
|
||||
**Source:** `neode-ui/src/views/Cloud.vue:966-968`
|
||||
**Apply to:** All `useCachedResource` consumers — never toast on background refresh failure; only surface errors via an explicit-refresh path.
|
||||
```typescript
|
||||
const e = peersResource.entry
|
||||
if (e.error) loadError.value = e.error // banner, not a toast
|
||||
```
|
||||
|
||||
### KeepAlive reactivation revalidation (new shared extension)
|
||||
**Source:** `neode-ui/src/composables/useCachedResource.ts` (extend, see above)
|
||||
**Apply to:** All views, automatically, once the hook is extended — no per-view code changes needed to get D-01's "background refresh on revisit" behavior.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `.planning/phases/02-ui-performance/<findings-doc>.md` (D-10 deliverable) | doc/config | batch | Documentation deliverable, not code — no code analog applicable; format is Claude's discretion per RESEARCH.md Open Question 2 (narrative + DevTools/performance.mark numbers recommended) |
|
||||
| KeepAlive template wiring in `Dashboard.vue` | route/container | request-response | No existing `<KeepAlive>` usage anywhere in the codebase (`grep -r "KeepAlive" neode-ui/src` = 0 hits) — pattern comes from Vue Router 4 official docs, not an in-repo analog |
|
||||
| AIUI-side D-14 param handling | external app | event-driven | AIUI source not present in this checkout (sibling repo, unconfirmed location) — cannot pattern-map code that isn't accessible; planner must add a precondition/checkpoint task per RESEARCH.md Open Question 1 |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `neode-ui/src/{views,composables,stores,api,views/dashboard}`
|
||||
**Files scanned:** Dashboard.vue, useCachedResource.ts, useRouteTransitions.ts, rpc-client.ts, resources.ts, Cloud.vue, ContainerAppDetails.vue, Chat.vue, App.vue (partial), plus grep sweeps across `views/*.vue` for `onMounted`/`useCachedResource`/`KeepAlive` usage (per RESEARCH.md's own prior codebase pass, cross-checked)
|
||||
**Pattern extraction date:** 2026-07-30
|
||||
Loading…
x
Reference in New Issue
Block a user