feat(propagation-nodes): propagation node management with new UI elements and API integration

- Added functionality to request paths for preferred propagation nodes during sync.
- Introduced new Material Design icons for better visual representation of node states.
- Updated settings to allow transfer limits in megabytes, improving user experience.
- Enhanced localization support for new features in multiple languages.
- Improved tests to cover new propagation node functionalities and UI interactions.
This commit is contained in:
Ivan
2026-04-16 21:35:12 -05:00
parent 5d87aa3be2
commit ab1be8ea2d
14 changed files with 666 additions and 76 deletions
@@ -1343,6 +1343,10 @@ export default {
// request sync
try {
const preferredHash = this.config?.lxmf_preferred_propagation_node_destination_hash;
if (preferredHash) {
await window.api.post(`/api/v1/destination/${preferredHash}/request-path`);
}
await window.api.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
const errorMessage = e.response?.data?.message ?? this.$t("app.sync_error_generic");
@@ -19,6 +19,11 @@
<script>
import * as mdi from "@mdi/js";
const MDI_ICON_ALIASES = {
mdiRoute: "mdiRoutes",
mdiEmailSendOutline: "mdiSendOutline",
};
export default {
name: "MaterialDesignIcon",
props: {
@@ -58,7 +63,8 @@ export default {
}
const name = this.mdiIconName;
const path = mdi[name];
const aliasName = MDI_ICON_ALIASES[name] || name;
const path = mdi[aliasName];
if (path) return path;
@@ -4,101 +4,217 @@
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gray-50 dark:bg-zinc-950">
<div class="px-4 py-4 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-4">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<div class="font-semibold text-gray-900 dark:text-zinc-100">Hosted Propagation Node</div>
<span
v-if="localPropagationNode"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
:class="
localPropagationNode.is_propagation_enabled
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300'
"
<div class="flex items-start justify-between gap-3">
<button
type="button"
class="min-w-0 text-left"
@click="isLocalManagerCollapsed = !isLocalManagerCollapsed"
>
<div class="flex items-center gap-2 min-w-0">
<MaterialDesignIcon
:icon-name="isLocalManagerCollapsed ? 'chevron-right' : 'chevron-down'"
class="size-5 text-gray-500 dark:text-zinc-400 shrink-0"
/>
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
Hosted Propagation Node
</div>
<span
v-if="localPropagationNode"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
:class="
localNodeIsRunning
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300'
"
>
{{ localNodeIsRunning ? "Running" : "Stopped" }}
</span>
<span
v-if="
localPropagationNode &&
config.lxmf_preferred_propagation_node_destination_hash ===
localPropagationNode.destination_hash
"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300"
>
Preferred
</span>
</div>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-40"
title="Announce now"
:disabled="!localPropagationNode"
@click="announceNow"
>
{{ localPropagationNode.is_propagation_enabled ? "Running" : "Stopped" }}
</span>
<span
v-if="
localPropagationNode &&
config.lxmf_preferred_propagation_node_destination_hash ===
localPropagationNode.destination_hash
"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300"
<MaterialDesignIcon icon-name="bullhorn" class="size-5" />
</button>
<button
v-if="!localNodeIsRunning"
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-emerald-600 dark:hover:text-emerald-400 disabled:opacity-40"
title="Start node"
:disabled="!localPropagationNode"
@click="startLocalPropagationNode"
>
Preferred
</span>
<MaterialDesignIcon icon-name="play" class="size-5" />
</button>
<button
v-if="localNodeIsRunning"
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-amber-600 dark:hover:text-amber-400"
title="Restart node"
@click="restartLocalPropagationNode"
>
<MaterialDesignIcon icon-name="refresh" class="size-5" />
</button>
<button
v-if="localNodeIsRunning"
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
title="Stop node"
@click="stopLocalPropagationNode"
>
<MaterialDesignIcon icon-name="stop" class="size-5" />
</button>
</div>
</div>
<div v-if="!isLocalManagerCollapsed" class="mt-3 space-y-3">
<div
v-if="config.lxmf_local_propagation_node_address_hash"
class="text-xs font-mono text-gray-600 dark:text-zinc-400 break-all"
>
&lt;{{ config.lxmf_local_propagation_node_address_hash }}&gt;
</div>
<div class="text-xs text-gray-600 dark:text-zinc-400 flex items-center gap-2">
<template v-if="nodePathFor(config.lxmf_local_propagation_node_address_hash)">
<span>{{
formatPathLabel(nodePathFor(config.lxmf_local_propagation_node_address_hash))
}}</span>
</template>
<template v-else>
<span>No path yet</span>
</template>
<button
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-40"
title="Find path now"
:disabled="!config.lxmf_local_propagation_node_address_hash"
@click="requestPathForNode(config.lxmf_local_propagation_node_address_hash)"
>
<MaterialDesignIcon icon-name="map-marker-path" class="size-4" />
</button>
</div>
<label class="block text-xs text-gray-600 dark:text-zinc-400">
Display name
<div class="mt-1 flex items-center gap-2">
<input
v-model.trim="localNodeDisplayNameDraft"
type="text"
maxlength="64"
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
placeholder="Anonymous Peer"
@keydown.enter.prevent="saveLocalNodeDisplayName"
/>
<button
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-emerald-600 dark:hover:text-emerald-400"
title="Save name"
@click="saveLocalNodeDisplayName"
>
<MaterialDesignIcon icon-name="check" class="size-5" />
</button>
<button
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-zinc-100"
title="Reset to Anonymous"
@click="resetLocalNodeDisplayName"
>
<MaterialDesignIcon icon-name="restore" class="size-5" />
</button>
</div>
</label>
<div
v-if="localPropagationNode?.local_node_stats"
v-if="localNodeStatsVisible"
class="text-xs text-gray-600 dark:text-zinc-400 flex flex-wrap gap-x-3 gap-y-1"
>
<span>{{ formatSeconds(localPropagationNode.local_node_stats.uptime_seconds) }} uptime</span>
<span>{{ localPropagationNode.local_node_stats.total_peers }} peers</span>
<span>{{ localPropagationNode.local_node_stats.messagestore_count }} messages stored</span>
<span>{{ localPropagationNode.local_node_stats.client_messages_received }} received</span>
<span>{{ localPropagationNode.local_node_stats.client_messages_served }} served</span>
<span>{{ formatStorageUsage(localPropagationNode.local_node_stats) }} storage</span>
<span>RX {{ formatByteSize(localPropagationNode.local_node_stats.rx_bytes) }}</span>
<span>TX {{ formatByteSize(localPropagationNode.local_node_stats.tx_bytes) }}</span>
</div>
<div v-else class="text-xs text-gray-500 dark:text-zinc-500">Node stats appear when running.</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label class="text-xs text-gray-600 dark:text-zinc-400">
Delivery transfer limit (bytes)
Delivery transfer limit (MB)
<input
v-model.number="config.lxmf_delivery_transfer_limit_in_bytes"
v-model.number="deliveryLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onDeliveryTransferLimitChange"
/>
<div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
{{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
</div>
</label>
<label class="text-xs text-gray-600 dark:text-zinc-400">
Propagation transfer limit (bytes)
Propagation transfer limit (MB)
<input
v-model.number="config.lxmf_propagation_transfer_limit_in_bytes"
v-model.number="propagationLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onPropagationTransferLimitChange"
/>
<div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
{{ formatByteSize(config.lxmf_propagation_transfer_limit_in_bytes) }}
</div>
</label>
<label class="text-xs text-gray-600 dark:text-zinc-400">
Propagation sync limit (bytes)
Propagation sync limit (MB)
<input
v-model.number="config.lxmf_propagation_sync_limit_in_bytes"
v-model.number="propagationSyncLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onPropagationSyncLimitChange"
/>
<div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
{{ formatByteSize(config.lxmf_propagation_sync_limit_in_bytes) }}
</div>
</label>
</div>
<label class="block text-xs text-gray-600 dark:text-zinc-400">
Propagation stamp cost
<input
v-model.number="config.lxmf_propagation_node_stamp_cost"
type="number"
min="13"
max="254"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onPropagationStampCostChange"
/>
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors disabled:opacity-40"
:disabled="!localPropagationNode"
@click="useLocalPropagationNode"
>
Use Our Node
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
@click="restartLocalPropagationNode"
>
Restart Node
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors"
@click="stopLocalPropagationNode"
>
Stop Node
</button>
</div>
</div>
</div>
@@ -186,6 +302,22 @@
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
Announced {{ formatTimeAgo(propagationNode.updated_at) }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1 flex items-center gap-2">
<template v-if="nodePathFor(propagationNode.destination_hash)">
<span>{{ formatPathLabel(nodePathFor(propagationNode.destination_hash)) }}</span>
</template>
<template v-else>
<span>No path</span>
</template>
<button
type="button"
class="text-gray-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400"
title="Find path now"
@click="requestPathForNode(propagationNode.destination_hash)"
>
<MaterialDesignIcon icon-name="map-marker-path" class="size-4" />
</button>
</div>
<div
v-if="propagationNode.local_node_stats"
class="text-xs text-gray-500 dark:text-zinc-500 mt-1 flex flex-wrap gap-x-3 gap-y-1"
@@ -193,7 +325,11 @@
<span>{{ formatSeconds(propagationNode.local_node_stats.uptime_seconds) }} uptime</span>
<span>{{ propagationNode.local_node_stats.total_peers }} peers</span>
<span>{{ propagationNode.local_node_stats.messagestore_count }} stored</span>
<span>{{ propagationNode.local_node_stats.client_messages_received }} received</span>
<span>{{ propagationNode.local_node_stats.client_messages_served }} served</span>
<span>{{ formatStorageUsage(propagationNode.local_node_stats) }} storage</span>
<span>RX {{ formatByteSize(propagationNode.local_node_stats.rx_bytes) }}</span>
<span>TX {{ formatByteSize(propagationNode.local_node_stats.tx_bytes) }}</span>
</div>
</div>
<div class="flex-shrink-0">
@@ -349,9 +485,13 @@
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "PropagationNodesPage",
components: {
MaterialDesignIcon,
},
data() {
return {
searchTerm: "",
@@ -363,6 +503,7 @@ export default {
lxmf_delivery_transfer_limit_in_bytes: 1000 * 1000 * 10,
lxmf_propagation_transfer_limit_in_bytes: 1000 * 256,
lxmf_propagation_sync_limit_in_bytes: 1000 * 10240,
lxmf_propagation_node_stamp_cost: 16,
},
currentPage: 1,
itemsPerPage: 20,
@@ -370,13 +511,30 @@ export default {
deliveryLimit: null,
propagationLimit: null,
propagationSyncLimit: null,
propagationStampCost: null,
},
isLocalManagerCollapsed: false,
localNodeDisplayNameDraft: "",
deliveryLimitInputMb: 0,
propagationLimitInputMb: 0,
propagationSyncLimitInputMb: 0,
nodePathsByHash: {},
};
},
computed: {
localPropagationNode() {
return this.propagationNodes.find((node) => node.is_local_node) ?? null;
},
localNodeIsRunning() {
const running = this.localPropagationNode?.local_node_stats?.is_running;
if (typeof running === "boolean") {
return running;
}
return Boolean(this.localPropagationNode?.is_propagation_enabled);
},
localNodeStatsVisible() {
return Boolean(this.localPropagationNode?.local_node_stats && this.localNodeIsRunning);
},
searchedPropagationNodes() {
return this.propagationNodes.filter((propagationNode) => {
const search = this.searchTerm.toLowerCase();
@@ -485,6 +643,9 @@ export default {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
if (window.matchMedia && window.matchMedia("(max-width: 640px)").matches) {
this.isLocalManagerCollapsed = true;
}
this.getConfig();
this.loadPropagationNodes();
},
@@ -494,6 +655,7 @@ export default {
switch (json.type) {
case "config": {
this.config = json.config;
this.syncManagerInputsFromConfig();
break;
}
}
@@ -502,6 +664,7 @@ export default {
try {
const response = await window.api.get("/api/v1/config");
this.config = response.data.config;
this.syncManagerInputsFromConfig();
} catch (e) {
// do nothing if failed to load config
console.log(e);
@@ -511,9 +674,12 @@ export default {
try {
const response = await window.api.patch("/api/v1/config", config);
this.config = response.data.config;
this.syncManagerInputsFromConfig();
return true;
} catch (e) {
ToastUtils.error(this.$t("common.save_failed"));
console.log(e);
return false;
}
},
async loadPropagationNodes() {
@@ -524,6 +690,7 @@ export default {
},
});
this.propagationNodes = response.data.lxmf_propagation_nodes;
await this.refreshPriorityNodePaths();
} catch {
// do nothing if failed to load
}
@@ -532,6 +699,7 @@ export default {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: destination_hash,
});
await this.requestPathForNode(destination_hash);
},
async stopUsingPropagationNode() {
await this.updateConfig({
@@ -541,12 +709,14 @@ export default {
async useLocalPropagationNode() {
if (!this.localPropagationNode) return;
await this.usePropagationNode(this.localPropagationNode.destination_hash);
await this.requestPathForNode(this.localPropagationNode.destination_hash);
},
async restartLocalPropagationNode() {
try {
await window.api.post("/api/v1/lxmf/propagation-node/restart");
ToastUtils.success("Local propagation node restarted");
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
@@ -556,15 +726,132 @@ export default {
await window.api.post("/api/v1/lxmf/propagation-node/stop");
ToastUtils.success("Local propagation node stopped");
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
},
async startLocalPropagationNode() {
try {
const didUpdate = await this.updateConfig({ lxmf_local_propagation_node_enabled: true });
if (!didUpdate) {
return;
}
ToastUtils.success("Local propagation node started");
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
},
async announceNow(showSuccessToast = true) {
try {
await window.api.get("/api/v1/announce");
if (showSuccessToast) {
ToastUtils.success("Announce triggered");
}
await this.loadPropagationNodes();
await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
},
async saveLocalNodeDisplayName() {
const nextName = (this.localNodeDisplayNameDraft || "").trim() || "Anonymous Peer";
try {
const didUpdate = await this.updateConfig({ display_name: nextName });
if (!didUpdate) {
return;
}
this.localNodeDisplayNameDraft = nextName;
await this.announceNow(false);
ToastUtils.success("Name saved and announced");
await this.loadPropagationNodes();
await this.refreshPriorityNodePaths();
} catch {
ToastUtils.error(this.$t("common.save_failed"));
}
},
async resetLocalNodeDisplayName() {
this.localNodeDisplayNameDraft = "Anonymous Peer";
await this.saveLocalNodeDisplayName();
},
syncManagerInputsFromConfig() {
const displayName = (this.config.display_name || "").trim();
this.localNodeDisplayNameDraft = displayName || "Anonymous Peer";
this.deliveryLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.propagationLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_transfer_limit_in_bytes);
this.propagationSyncLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_sync_limit_in_bytes);
},
bytesToMb(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return 0;
}
return Math.max(0.001, Math.round((n / 1000000) * 1000) / 1000);
},
mbToBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return 1000;
}
return Math.max(1000, Math.round(n * 1000000));
},
async refreshPriorityNodePaths() {
const hashes = new Set();
const localHash = this.config.lxmf_local_propagation_node_address_hash;
if (localHash) {
hashes.add(localHash);
}
const preferredHash = this.config.lxmf_preferred_propagation_node_destination_hash;
if (preferredHash) {
hashes.add(preferredHash);
}
for (const hash of hashes) {
await this.requestPathForNode(hash);
}
},
async requestPathForNode(destinationHash) {
const hash = (destinationHash || "").trim();
if (!hash) {
return;
}
try {
const response = await window.api.get(`/api/v1/destination/${hash}/path`, {
params: { request: "1", timeout: 4 },
});
this.nodePathsByHash = {
...this.nodePathsByHash,
[hash]: response.data.path || null,
};
} catch {
this.nodePathsByHash = {
...this.nodePathsByHash,
[hash]: null,
};
}
},
nodePathFor(destinationHash) {
const hash = (destinationHash || "").trim();
if (!hash) {
return null;
}
return this.nodePathsByHash[hash] || null;
},
formatPathLabel(path) {
if (!path) {
return "No path";
}
const hops = Number(path.hops);
const hopsText = Number.isFinite(hops) ? `${hops} ${hops === 1 ? "hop" : "hops"}` : "Unknown hops";
const iface = path.next_hop_interface || "unknown interface";
return `${hopsText} via ${iface}`;
},
onDeliveryTransferLimitChange() {
if (this.saveTimeouts.deliveryLimit) clearTimeout(this.saveTimeouts.deliveryLimit);
this.saveTimeouts.deliveryLimit = setTimeout(async () => {
await this.updateConfig({
lxmf_delivery_transfer_limit_in_bytes: this.config.lxmf_delivery_transfer_limit_in_bytes,
lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.deliveryLimitInputMb),
});
}, 450);
},
@@ -572,7 +859,7 @@ export default {
if (this.saveTimeouts.propagationLimit) clearTimeout(this.saveTimeouts.propagationLimit);
this.saveTimeouts.propagationLimit = setTimeout(async () => {
await this.updateConfig({
lxmf_propagation_transfer_limit_in_bytes: this.config.lxmf_propagation_transfer_limit_in_bytes,
lxmf_propagation_transfer_limit_in_bytes: this.mbToBytes(this.propagationLimitInputMb),
});
}, 450);
},
@@ -580,7 +867,22 @@ export default {
if (this.saveTimeouts.propagationSyncLimit) clearTimeout(this.saveTimeouts.propagationSyncLimit);
this.saveTimeouts.propagationSyncLimit = setTimeout(async () => {
await this.updateConfig({
lxmf_propagation_sync_limit_in_bytes: this.config.lxmf_propagation_sync_limit_in_bytes,
lxmf_propagation_sync_limit_in_bytes: this.mbToBytes(this.propagationSyncLimitInputMb),
});
}, 450);
},
onPropagationStampCostChange() {
if (this.saveTimeouts.propagationStampCost) clearTimeout(this.saveTimeouts.propagationStampCost);
this.saveTimeouts.propagationStampCost = setTimeout(async () => {
let cost = Number(this.config.lxmf_propagation_node_stamp_cost);
if (!Number.isFinite(cost) || cost < 13) {
cost = 13;
} else if (cost > 254) {
cost = 254;
}
this.config.lxmf_propagation_node_stamp_cost = cost;
await this.updateConfig({
lxmf_propagation_node_stamp_cost: cost,
});
}, 450);
},
@@ -598,6 +900,25 @@ export default {
const days = Math.floor(hours / 24);
return `${days}d`;
},
formatByteSize(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value < 0) return "0 B";
if (value < 1000) return `${Math.round(value)} B`;
if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} KB`;
if (value < 1000 * 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} MB`;
return `${(value / (1000 * 1000 * 1000)).toFixed(2)} GB`;
},
formatStorageUsage(stats) {
if (!stats || typeof stats !== "object") {
return "0 B";
}
const used = this.formatByteSize(stats.messagestore_bytes);
const limitValue = Number(stats.messagestore_limit_bytes);
if (!Number.isFinite(limitValue) || limitValue <= 0) {
return used;
}
return `${used} / ${this.formatByteSize(limitValue)}`;
},
},
};
</script>
@@ -1995,39 +1995,51 @@
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Delivery transfer limit (bytes)
Delivery transfer limit (MB)
</div>
<input
v-model.number="config.lxmf_delivery_transfer_limit_in_bytes"
v-model.number="lxmfDeliveryTransferLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="input-field"
@input="onLxmfDeliveryTransferLimitChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Propagation transfer limit (bytes)
Propagation transfer limit (MB)
</div>
<input
v-model.number="config.lxmf_propagation_transfer_limit_in_bytes"
v-model.number="lxmfPropagationTransferLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="input-field"
@input="onLxmfPropagationTransferLimitChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ formatByteSize(config.lxmf_propagation_transfer_limit_in_bytes) }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Propagation sync limit (bytes)
Propagation sync limit (MB)
</div>
<input
v-model.number="config.lxmf_propagation_sync_limit_in_bytes"
v-model.number="lxmfPropagationSyncLimitInputMb"
type="number"
min="1000"
min="0.001"
step="0.01"
class="input-field"
@input="onLxmfPropagationSyncLimitChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ formatByteSize(config.lxmf_propagation_sync_limit_in_bytes) }}
</div>
</div>
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -2236,6 +2248,9 @@ export default {
nomad_default_page_path: "/page/index.mu",
},
saveTimeouts: {},
lxmfDeliveryTransferLimitInputMb: 10,
lxmfPropagationTransferLimitInputMb: 0.256,
lxmfPropagationSyncLimitInputMb: 10.24,
lastRememberedInboundStampCost: 8,
shortcuts: [],
reloadingRns: false,
@@ -2544,6 +2559,7 @@ export default {
if (json.config) {
this.config = { ...this.config, ...json.config };
this.sanitizeColorConfigFields();
this.syncLxmfTransferLimitInputs();
}
break;
}
@@ -2575,6 +2591,7 @@ export default {
if (merged) {
this.config = merged;
normalizeConfigColors(this.config);
this.syncLxmfTransferLimitInputs();
const inbound = Number(this.config.lxmf_inbound_stamp_cost);
if (inbound > 0) {
this.lastRememberedInboundStampCost = Math.min(254, inbound);
@@ -2613,6 +2630,7 @@ export default {
const newConfig = await patchServerConfig(config, window.api);
this.config = newConfig;
normalizeConfigColors(this.config);
this.syncLxmfTransferLimitInputs();
if (label) {
ToastUtils.success(this.$t("app.setting_auto_saved", { label: this.$t(`app.${label}`) }));
}
@@ -2621,6 +2639,35 @@ export default {
console.log(e);
}
},
syncLxmfTransferLimitInputs() {
this.lxmfDeliveryTransferLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfPropagationTransferLimitInputMb = this.bytesToMb(
this.config.lxmf_propagation_transfer_limit_in_bytes
);
this.lxmfPropagationSyncLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_sync_limit_in_bytes);
},
bytesToMb(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return 0;
}
return Math.max(0.001, Math.round((n / 1000000) * 1000) / 1000);
},
mbToBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return 1000;
}
return Math.max(1000, Math.round(n * 1000000));
},
formatByteSize(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value < 0) return "0 B";
if (value < 1000) return `${Math.round(value)} B`;
if (value < 1000 * 1000) return `${(value / 1000).toFixed(1)} KB`;
if (value < 1000 * 1000 * 1000) return `${(value / (1000 * 1000)).toFixed(2)} MB`;
return `${(value / (1000 * 1000 * 1000)).toFixed(2)} GB`;
},
sanitizeColorConfigFields() {
if (!this.config) return;
normalizeConfigColors(this.config);
@@ -2870,7 +2917,7 @@ export default {
}
this.saveTimeouts.delivery_transfer_limit = setTimeout(async () => {
await this.updateConfig({
lxmf_delivery_transfer_limit_in_bytes: this.config.lxmf_delivery_transfer_limit_in_bytes,
lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.lxmfDeliveryTransferLimitInputMb),
});
}, 1000);
},
@@ -2880,7 +2927,7 @@ export default {
}
this.saveTimeouts.propagation_transfer_limit = setTimeout(async () => {
await this.updateConfig({
lxmf_propagation_transfer_limit_in_bytes: this.config.lxmf_propagation_transfer_limit_in_bytes,
lxmf_propagation_transfer_limit_in_bytes: this.mbToBytes(this.lxmfPropagationTransferLimitInputMb),
});
}, 1000);
},
@@ -2890,7 +2937,7 @@ export default {
}
this.saveTimeouts.propagation_sync_limit = setTimeout(async () => {
await this.updateConfig({
lxmf_propagation_sync_limit_in_bytes: this.config.lxmf_propagation_sync_limit_in_bytes,
lxmf_propagation_sync_limit_in_bytes: this.mbToBytes(this.lxmfPropagationSyncLimitInputMb),
});
}, 1000);
},
@@ -193,6 +193,14 @@ export default {
titleKey: "tools.bots.title",
descriptionKey: "tools.bots.description",
},
{
name: "propagation-nodes",
route: { name: "propagation-nodes" },
icon: "mailbox",
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
title: "Propagation Nodes",
description: "Manage preferred and local propagation nodes with live stats and path checks.",
},
{
name: "forwarder",
route: { name: "forwarder" },
+15 -1
View File
@@ -25,7 +25,21 @@
"export_identity": "Identität exportieren",
"bot_deleted": "Bot erfolgreich gelöscht",
"failed_to_delete": "Bot konnte nicht gelöscht werden",
"more_bots_coming": "Weitere Bots folgen in Kürze!"
"more_bots_coming": "Weitere Bots folgen in Kürze!",
"chat_with_bot": "Chat",
"lxmf_address": "LXMF-Adresse",
"last_announce": "Letzter Announce",
"never_announced": "Auf diesem Knoten noch nicht gesehen",
"address_pending": "Erst nach dem ersten Start verfügbar",
"status_running": "Läuft",
"status_stopped": "Gestoppt",
"force_announce": "Jetzt ankündigen",
"edit_name": "Namen bearbeiten",
"bot_renamed": "Name gespeichert",
"rename_failed": "Name konnte nicht gespeichert werden",
"name_required": "Namen eingeben",
"announce_triggered": "Ankündigung angefordert",
"announce_failed": "Ankündigung konnte nicht angefordert werden"
},
"app": {
"name": "Reticulum MeshChatX",
+15 -1
View File
@@ -25,7 +25,21 @@
"export_identity": "Export Identity",
"bot_deleted": "Bot deleted successfully",
"failed_to_delete": "Failed to delete bot",
"more_bots_coming": "More bots coming soon!"
"more_bots_coming": "More bots coming soon!",
"chat_with_bot": "Chat",
"lxmf_address": "LXMF address",
"last_announce": "Last announce",
"never_announced": "Not seen on this node yet",
"address_pending": "Not available until the bot has started once",
"status_running": "Running",
"status_stopped": "Stopped",
"force_announce": "Announce now",
"edit_name": "Edit name",
"bot_renamed": "Bot name saved",
"rename_failed": "Could not save name",
"name_required": "Enter a name",
"announce_triggered": "Announce requested",
"announce_failed": "Could not request announce"
},
"app": {
"name": "Reticulum MeshChatX",
+15 -1
View File
@@ -25,7 +25,21 @@
"export_identity": "Esporta Identità",
"bot_deleted": "Bot eliminato con successo",
"failed_to_delete": "Impossibile eliminare il bot",
"more_bots_coming": "Altri bot in arrivo!"
"more_bots_coming": "Altri bot in arrivo!",
"chat_with_bot": "Chat",
"lxmf_address": "Indirizzo LXMF",
"last_announce": "Ultimo announce",
"never_announced": "Non ancora visto su questo nodo",
"address_pending": "Disponibile dopo il primo avvio del bot",
"status_running": "In esecuzione",
"status_stopped": "Fermato",
"force_announce": "Annuncia ora",
"edit_name": "Modifica nome",
"bot_renamed": "Nome salvato",
"rename_failed": "Impossibile salvare il nome",
"name_required": "Inserisci un nome",
"announce_triggered": "Annuncio richiesto",
"announce_failed": "Impossibile richiedere l'annuncio"
},
"app": {
"name": "Reticulum MeshChatX",
+15 -1
View File
@@ -25,7 +25,21 @@
"export_identity": "Экспорт личности",
"bot_deleted": "Бот успешно удален",
"failed_to_delete": "Не удалось удалить бота",
"more_bots_coming": "Скоро появятся новые боты!"
"more_bots_coming": "Скоро появятся новые боты!",
"chat_with_bot": "Чат",
"lxmf_address": "Адрес LXMF",
"last_announce": "Последний announce",
"never_announced": "На этом узле ещё не виден",
"address_pending": "Появится после первого запуска бота",
"status_running": "Запущен",
"status_stopped": "Остановлен",
"force_announce": "Объявить сейчас",
"edit_name": "Изменить имя",
"bot_renamed": "Имя бота сохранено",
"rename_failed": "Не удалось сохранить имя",
"name_required": "Введите имя",
"announce_triggered": "Запрос announce отправлен",
"announce_failed": "Не удалось запросить announce"
},
"app": {
"name": "Reticulum MeshChatX",
@@ -64,6 +64,10 @@
"method": "DELETE",
"path": "/api/v1/blocked-destinations/{destination_hash}"
},
{
"method": "POST",
"path": "/api/v1/bots/announce"
},
{
"method": "POST",
"path": "/api/v1/bots/delete"
@@ -84,6 +88,10 @@
"method": "GET",
"path": "/api/v1/bots/status"
},
{
"method": "PATCH",
"path": "/api/v1/bots/update"
},
{
"method": "POST",
"path": "/api/v1/bots/stop"
@@ -22,6 +22,9 @@ const syncingStates = [
function makeSyncContext(axiosMock, tOverrides = {}) {
return {
config: {
lxmf_preferred_propagation_node_destination_hash: "deadbeef",
},
propagationNodeStatus: null,
_propagationSyncPollTimer: null,
propagationSyncLiveToastMessage: App.methods.propagationSyncLiveToastMessage,
@@ -77,6 +80,7 @@ function makeSyncContext(axiosMock, tOverrides = {}) {
describe("App propagation sync", () => {
const axiosMock = {
get: vi.fn(),
post: vi.fn(),
};
beforeEach(() => {
@@ -91,6 +95,7 @@ describe("App propagation sync", () => {
});
it("shows detailed success toast with stored, confirmations and hidden counts", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
@@ -122,10 +127,12 @@ describe("App propagation sync", () => {
expect(ToastUtils.success).toHaveBeenCalledWith(
"Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)"
);
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/destination/deadbeef/request-path");
expect(ToastUtils.error).not.toHaveBeenCalled();
});
it("polls status while syncing and updates live loading toast", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
let statusCalls = 0;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
@@ -178,6 +185,7 @@ describe("App propagation sync", () => {
});
it("uses translated status in error toast when sync ends in a failure state", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
+135 -4
View File
@@ -11,7 +11,9 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
describe("PropagationNodesPage", () => {
const axiosMock = {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
};
beforeEach(() => {
@@ -39,20 +41,42 @@ describe("PropagationNodesPage", () => {
const ctx = {
localPropagationNode: { destination_hash: "local-node" },
usePropagationNode: vi.fn(),
requestPathForNode: vi.fn(),
};
await PropagationNodesPage.methods.useLocalPropagationNode.call(ctx);
expect(ctx.usePropagationNode).toHaveBeenCalledWith("local-node");
expect(ctx.requestPathForNode).toHaveBeenCalledWith("local-node");
});
it("prefers runtime local node state for running indicator", () => {
const runningByStats = PropagationNodesPage.computed.localNodeIsRunning.call({
localPropagationNode: {
is_propagation_enabled: true,
local_node_stats: { is_running: false },
},
});
expect(runningByStats).toBe(false);
});
it("formats storage usage with limit when available", () => {
const ctx = {
formatByteSize: PropagationNodesPage.methods.formatByteSize,
};
const text = PropagationNodesPage.methods.formatStorageUsage.call(ctx, {
messagestore_bytes: 76500,
messagestore_limit_bytes: 10240000,
});
expect(text).toBe("76.5 KB / 10.24 MB");
});
it("debounces propagation transfer limit save", async () => {
const ctx = {
config: {
lxmf_propagation_transfer_limit_in_bytes: 123456,
},
propagationLimitInputMb: 1.234,
saveTimeouts: {
propagationLimit: null,
},
mbToBytes: PropagationNodesPage.methods.mbToBytes,
updateConfig: vi.fn().mockResolvedValue(undefined),
};
@@ -61,7 +85,24 @@ describe("PropagationNodesPage", () => {
await vi.advanceTimersByTimeAsync(500);
expect(ctx.updateConfig).toHaveBeenCalledWith({
lxmf_propagation_transfer_limit_in_bytes: 123456,
lxmf_propagation_transfer_limit_in_bytes: 1234000,
});
});
it("debounces propagation stamp cost save with bounds", async () => {
const ctx = {
config: {
lxmf_propagation_node_stamp_cost: 3,
},
saveTimeouts: {
propagationStampCost: null,
},
updateConfig: vi.fn().mockResolvedValue(undefined),
};
await PropagationNodesPage.methods.onPropagationStampCostChange.call(ctx);
await vi.advanceTimersByTimeAsync(500);
expect(ctx.updateConfig).toHaveBeenCalledWith({
lxmf_propagation_node_stamp_cost: 13,
});
});
@@ -70,6 +111,7 @@ describe("PropagationNodesPage", () => {
const ctx = {
getConfig: vi.fn().mockResolvedValue(undefined),
loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
$t: (k) => k,
};
@@ -80,4 +122,93 @@ describe("PropagationNodesPage", () => {
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/propagation-node/restart");
expect(ToastUtils.success).toHaveBeenCalledTimes(2);
});
it("triggers announce via icon action", async () => {
axiosMock.get.mockResolvedValue({ data: {} });
const ctx = {
loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
$t: (k) => k,
};
await PropagationNodesPage.methods.announceNow.call(ctx);
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
expect(ToastUtils.success).toHaveBeenCalledWith("Announce triggered");
});
it("resets local node display name to Anonymous Peer", async () => {
const ctx = {
localNodeDisplayNameDraft: "Custom Name",
saveLocalNodeDisplayName: vi.fn().mockResolvedValue(undefined),
};
await PropagationNodesPage.methods.resetLocalNodeDisplayName.call(ctx);
expect(ctx.localNodeDisplayNameDraft).toBe("Anonymous Peer");
expect(ctx.saveLocalNodeDisplayName).toHaveBeenCalledTimes(1);
});
it("uses collapsed manager on small screens", () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
const ctx = {
isLocalManagerCollapsed: false,
getConfig: vi.fn(),
loadPropagationNodes: vi.fn(),
refreshPriorityNodePaths: vi.fn(),
};
PropagationNodesPage.mounted.call(ctx);
expect(ctx.isLocalManagerCollapsed).toBe(true);
window.matchMedia = originalMatchMedia;
});
it("saves local display name and announces immediately", async () => {
axiosMock.patch.mockResolvedValue({
data: {
config: {
display_name: "Friendly Node",
lxmf_delivery_transfer_limit_in_bytes: 10000000,
lxmf_propagation_transfer_limit_in_bytes: 256000,
lxmf_propagation_sync_limit_in_bytes: 10240000,
},
},
});
axiosMock.get.mockResolvedValue({ data: {} });
const ctx = {
localNodeDisplayNameDraft: " Friendly Node ",
config: {
lxmf_delivery_transfer_limit_in_bytes: 10000000,
lxmf_propagation_transfer_limit_in_bytes: 256000,
lxmf_propagation_sync_limit_in_bytes: 10240000,
},
syncManagerInputsFromConfig: vi.fn(),
loadPropagationNodes: vi.fn().mockResolvedValue(undefined),
refreshPriorityNodePaths: vi.fn().mockResolvedValue(undefined),
announceNow: PropagationNodesPage.methods.announceNow,
updateConfig: PropagationNodesPage.methods.updateConfig,
$t: (k) => k,
};
await PropagationNodesPage.methods.saveLocalNodeDisplayName.call(ctx);
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", {
display_name: "Friendly Node",
});
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce");
expect(ToastUtils.success).toHaveBeenCalledWith("Name saved and announced");
});
it("fetches path for a destination hash", async () => {
axiosMock.get.mockResolvedValueOnce({
data: {
path: { hops: 2, next_hop_interface: "TCP Client" },
},
});
const ctx = {
nodePathsByHash: {},
};
await PropagationNodesPage.methods.requestPathForNode.call(ctx, "abcd");
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
params: { request: "1", timeout: 4 },
});
expect(ctx.nodePathsByHash.abcd).toEqual({ hops: 2, next_hop_interface: "TCP Client" });
});
});
@@ -258,21 +258,21 @@ describe("SettingsPage — config persistence (PATCH and related)", () => {
it("LXMF transfer/sync limits PATCH after debounce", async () => {
const w = await mountSettingsPage(api);
w.vm.config.lxmf_delivery_transfer_limit_in_bytes = 9_000_000;
w.vm.lxmfDeliveryTransferLimitInputMb = 9;
await w.vm.onLxmfDeliveryTransferLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
lxmf_delivery_transfer_limit_in_bytes: 9_000_000,
});
w.vm.config.lxmf_propagation_transfer_limit_in_bytes = 300_000;
w.vm.lxmfPropagationTransferLimitInputMb = 0.3;
await w.vm.onLxmfPropagationTransferLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
lxmf_propagation_transfer_limit_in_bytes: 300_000,
});
w.vm.config.lxmf_propagation_sync_limit_in_bytes = 9_000_000;
w.vm.lxmfPropagationSyncLimitInputMb = 9;
await w.vm.onLxmfPropagationSyncLimitChange();
await vi.advanceTimersByTimeAsync(1000);
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", {
+3 -2
View File
@@ -15,6 +15,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } },
{ path: "/translator", name: "translator", component: { template: "div" } },
{ path: "/bots", name: "bots", component: { template: "div" } },
{ path: "/propagation-nodes", name: "propagation-nodes", component: { template: "div" } },
{ path: "/forwarder", name: "forwarder", component: { template: "div" } },
{ path: "/documentation", name: "documentation", component: { template: "div" } },
{ path: "/micron-editor", name: "micron-editor", component: { template: "div" } },
@@ -51,7 +52,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
expect(toolRows.length).toBe(17);
expect(toolRows.length).toBe(18);
});
it("filters tools based on search query", async () => {
@@ -76,6 +77,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
expect(wrapper.vm.filteredTools.length).toBe(17);
expect(wrapper.vm.filteredTools.length).toBe(18);
});
});