feat: complete Phase 2 transport layer — off-grid mode, transport icons, federation sync
- Add off-grid (mesh only) toggle to Mesh.vue with orange OFF-GRID banner - Add per-peer transport indicator in Federation.vue (mesh/lan/tor icons) - Add sync_with_peer_via_transport() for CBOR delta sync via transport router - Fetch transport store on mount in both Mesh and Federation views Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -428,6 +428,54 @@ pub async fn sync_with_peer(
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync with a peer using the transport router (Mesh > LAN > Tor).
|
||||||
|
/// Uses CBOR delta encoding for compact payloads over constrained links.
|
||||||
|
/// Falls back to `sync_with_peer()` if no transport router is available.
|
||||||
|
pub async fn sync_with_peer_via_transport(
|
||||||
|
data_dir: &Path,
|
||||||
|
peer: &FederatedNode,
|
||||||
|
local_did: &str,
|
||||||
|
previous_state: Option<&NodeStateSnapshot>,
|
||||||
|
router: &crate::transport::TransportRouter,
|
||||||
|
) -> Result<()> {
|
||||||
|
use crate::transport::{MessageType, TransportMessage};
|
||||||
|
use crate::transport::delta;
|
||||||
|
|
||||||
|
// Build the sync request payload — if we have a previous state from this peer,
|
||||||
|
// send a delta request (tells the peer we only need changes since timestamp).
|
||||||
|
let payload = if let Some(prev) = previous_state {
|
||||||
|
// Request delta since our last known state
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"type": "state_sync_request",
|
||||||
|
"since": prev.timestamp,
|
||||||
|
});
|
||||||
|
delta::encode_cbor(&delta::StateDelta {
|
||||||
|
ts: prev.timestamp.clone(),
|
||||||
|
v: 1,
|
||||||
|
..Default::default()
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
// First sync — request full state
|
||||||
|
let request = serde_json::json!({ "type": "state_sync_request" });
|
||||||
|
serde_json::to_vec(&request)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = TransportMessage {
|
||||||
|
from_did: local_did.to_string(),
|
||||||
|
payload,
|
||||||
|
message_type: MessageType::StateSync,
|
||||||
|
};
|
||||||
|
|
||||||
|
let transport_used = router.send_to_peer(&peer.did, &message).await?;
|
||||||
|
tracing::info!(
|
||||||
|
peer = %peer.did,
|
||||||
|
transport = %transport_used,
|
||||||
|
"Federation sync sent via transport"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the local node's state snapshot for sharing with peers.
|
/// Build the local node's state snapshot for sharing with peers.
|
||||||
pub fn build_local_state(
|
pub fn build_local_state(
|
||||||
apps: Vec<AppStatus>,
|
apps: Vec<AppStatus>,
|
||||||
|
|||||||
@@ -163,6 +163,11 @@
|
|||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
|
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="isOnline(node) ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||||
<span class="text-sm font-medium text-white truncate">{{ node.name || shortDid(node.did) }}</span>
|
<span class="text-sm font-medium text-white truncate">{{ node.name || shortDid(node.did) }}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs shrink-0"
|
||||||
|
:class="nodeTransportIcon(node.did).color"
|
||||||
|
:title="'Transport: ' + nodeTransportIcon(node.did).label"
|
||||||
|
>{{ nodeTransportIcon(node.did).icon }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||||
@@ -368,9 +373,12 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useTransportStore } from '@/stores/transport'
|
||||||
|
import type { TransportPeer } from '@/stores/transport'
|
||||||
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
import NetworkMap from '@/components/federation/NetworkMap.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const transportStore = useTransportStore()
|
||||||
|
|
||||||
interface AppStatus {
|
interface AppStatus {
|
||||||
id: string
|
id: string
|
||||||
@@ -689,9 +697,22 @@ function trustBadgeClass(level: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the preferred transport icon for a federated node by DID. */
|
||||||
|
function nodeTransportIcon(did: string): { icon: string; color: string; label: string } {
|
||||||
|
const peer = transportStore.peers.find(p => p.did === did)
|
||||||
|
if (!peer) return { icon: '?', color: 'text-white/30', label: 'unknown' }
|
||||||
|
switch (peer.preferred_transport) {
|
||||||
|
case 'mesh': return { icon: '📡', color: 'text-orange-400', label: 'mesh' }
|
||||||
|
case 'lan': return { icon: '🌐', color: 'text-green-400', label: 'lan' }
|
||||||
|
case 'tor': return { icon: '🧅', color: 'text-purple-400', label: 'tor' }
|
||||||
|
default: return { icon: '?', color: 'text-white/30', label: 'unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadNodes()
|
loadNodes()
|
||||||
loadDwnStatus()
|
loadDwnStatus()
|
||||||
|
transportStore.fetchPeers()
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.getNodeDid()
|
const result = await rpcClient.getNodeDid()
|
||||||
selfDid.value = result.did
|
selfDid.value = result.did
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
|
import { useTransportStore } from '@/stores/transport'
|
||||||
import type { MeshPeer } from '@/stores/mesh'
|
import type { MeshPeer } from '@/stores/mesh'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
|
|
||||||
const mesh = useMeshStore()
|
const mesh = useMeshStore()
|
||||||
|
const transport = useTransportStore()
|
||||||
|
|
||||||
// Active chat: either a peer or a channel
|
// Active chat: either a peer or a channel
|
||||||
const activeChatPeer = ref<MeshPeer | null>(null)
|
const activeChatPeer = ref<MeshPeer | null>(null)
|
||||||
@@ -19,8 +21,17 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
|||||||
// The Public channel (always available on Meshcore)
|
// The Public channel (always available on Meshcore)
|
||||||
const publicChannel = { index: 0, name: 'Public' }
|
const publicChannel = { index: 0, name: 'Public' }
|
||||||
|
|
||||||
|
const togglingOffGrid = ref(false)
|
||||||
|
|
||||||
|
async function handleToggleOffGrid() {
|
||||||
|
togglingOffGrid.value = true
|
||||||
|
try {
|
||||||
|
await transport.setMeshOnly(!transport.meshOnly)
|
||||||
|
} finally { togglingOffGrid.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await mesh.refreshAll()
|
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
||||||
pollInterval = setInterval(() => {
|
pollInterval = setInterval(() => {
|
||||||
mesh.fetchStatus()
|
mesh.fetchStatus()
|
||||||
mesh.fetchPeers()
|
mesh.fetchPeers()
|
||||||
@@ -229,6 +240,15 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Off-grid mode banner -->
|
||||||
|
<div v-if="transport.meshOnly" class="mesh-offgrid-banner">
|
||||||
|
<svg class="w-4 h-4 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-orange-300">OFF-GRID</span>
|
||||||
|
<span class="text-xs text-white/50">Tor disabled — mesh only</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions row -->
|
<!-- Actions row -->
|
||||||
<div class="mesh-actions">
|
<div class="mesh-actions">
|
||||||
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
||||||
@@ -237,6 +257,14 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
|
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
|
||||||
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
|
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="glass-button mesh-action-btn"
|
||||||
|
:class="transport.meshOnly ? 'mesh-offgrid-active' : ''"
|
||||||
|
:disabled="togglingOffGrid"
|
||||||
|
@click="handleToggleOffGrid"
|
||||||
|
>
|
||||||
|
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
||||||
|
</button>
|
||||||
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
|
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -570,6 +598,22 @@ function truncatePubkey(hex: string | null): string {
|
|||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Off-grid banner ─── */
|
||||||
|
.mesh-offgrid-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(251, 146, 60, 0.1);
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mesh-offgrid-active {
|
||||||
|
border-color: rgba(251, 146, 60, 0.4) !important;
|
||||||
|
color: #fb923c !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Actions ─── */
|
/* ─── Actions ─── */
|
||||||
.mesh-actions {
|
.mesh-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user