move nomadnetwork page to own vue component

This commit is contained in:
liamcottle
2024-08-05 23:05:26 +12:00
parent 89dab4e68b
commit a91964a463
10 changed files with 1151 additions and 1050 deletions

6
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"electron-prompt": "^1.7.0", "electron-prompt": "^1.7.0",
"mitt": "^3.0.1",
"vue-router": "^4.4.2" "vue-router": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
@@ -3651,6 +3652,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/mkdirp": { "node_modules/mkdirp": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",

View File

@@ -70,6 +70,7 @@
}, },
"dependencies": { "dependencies": {
"electron-prompt": "^1.7.0", "electron-prompt": "^1.7.0",
"mitt": "^3.0.1",
"vue-router": "^4.4.2" "vue-router": "^4.4.2"
} }
} }

View File

@@ -54,14 +54,14 @@
<!-- nomad network --> <!-- nomad network -->
<li> <li>
<button @click="tab = 'nomadnetwork'" type="button" :class="[ tab === 'nomadnetwork' ? 'bg-blue-100 text-blue-800 group:text-blue-800 hover:bg-blue-100' : '']" class="w-full text-gray-800 hover:bg-gray-100 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"> <SidebarLink :to="{ name: 'nomadnetwork' }">
<span class="my-auto"> <template v-slot:icon>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg> </svg>
</span> </template>
<span class="my-auto">Nomad Network</span> <template v-slot:text>Nomad Network</template>
</button> </SidebarLink>
</li> </li>
<!-- interfaces --> <!-- interfaces -->
@@ -241,135 +241,31 @@
</div> </div>
</div> </div>
<!-- messages sidebar --> <RouterView/>
<MessagesSidebar
v-if="tab === 'messages'"
:conversations="conversations"
:peers="peers"
:selected-destination-hash="selectedPeer?.destination_hash"
@conversation-click="onConversationClick"
@peer-click="onPeerClick"/>
<!-- nomadnetwork sidebar --> <!-- &lt;!&ndash; messages sidebar &ndash;&gt;-->
<NomadNetworkSidebar <!-- <MessagesSidebar-->
v-if="tab === 'nomadnetwork'" <!-- v-if="tab === 'messages'"-->
:nodes="nodes" <!-- :conversations="conversations"-->
:selected-destination-hash="selectedNode?.destination_hash" <!-- :peers="peers"-->
@node-click="onNodeClick"/> <!-- :selected-destination-hash="selectedPeer?.destination_hash"-->
<!-- @conversation-click="onConversationClick"-->
<!-- @peer-click="onPeerClick"/>-->
<!-- main view --> <!-- &lt;!&ndash; main view &ndash;&gt;-->
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]"> <!-- <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]">-->
<RouterView/> <!-- &lt;!&ndash; messages tab &ndash;&gt;-->
<!-- <ConversationViewer-->
<!-- v-if="tab === 'messages'"-->
<!-- ref="conversation-viewer"-->
<!-- :my-lxmf-address-hash="config?.lxmf_address_hash"-->
<!-- :selected-peer="selectedPeer"-->
<!-- :conversations="conversations"-->
<!-- @close="selectedPeer = null"-->
<!-- @reload-conversations="getConversations"/>-->
<!-- messages tab --> <!-- </div>-->
<ConversationViewer
v-if="tab === 'messages'"
ref="conversation-viewer"
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
@close="selectedPeer = null"
@reload-conversations="getConversations"/>
<!-- nomadnetwork tab -->
<template v-if="tab === 'nomadnetwork'">
<!-- node -->
<div v-if="selectedNode" class="m-2 flex flex-col h-full border rounded-xl bg-white shadow overflow-hidden">
<!-- header -->
<div class="flex p-2 border-b border-gray-300">
<!-- node info -->
<div class="my-auto">
<span class="font-semibold">{{ selectedNode.name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div>
<!-- close button -->
<div class="my-auto ml-auto mr-2">
<div @click="selectedNode = null" class="cursor-pointer">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- browser navigation -->
<div class="flex w-full border-gray-300 border-b p-2">
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="reloadNodePage" type="button" class="ml-1 my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 bg-gray-200 hover:bg-gray-300' : 'text-gray-400 bg-gray-100']" class="ml-1 my-auto rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
</svg>
</button>
<div class="my-auto mx-2 w-full">
<input v-model="nodePagePathUrlInput" @keyup.enter="onNodePageUrlClick(nodePagePathUrlInput)" type="text" placeholder="Enter Destination URL" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-1.5">
</div>
<button @click="onNodePageUrlClick(nodePagePathUrlInput)" type="button" class="my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- page content -->
<div class="h-full overflow-y-scroll p-3 bg-black text-white">
<div class="flex" v-if="isLoadingNodePage">
<div class="my-auto">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
</div>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
</div>
<!-- file download bottom bar -->
<div v-if="isDownloadingNodeFile" class="flex w-full border-gray-300 border-t p-2">
<div class="my-auto mr-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
</div>
</div>
<!-- no node selected -->
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
<div class="font-semibold">No Active Node</div>
<div>Select a Node to start browsing!</div>
</div>
</template>
</div>
</div> </div>
@@ -379,24 +275,21 @@
<script> <script>
import SidebarLink from "./SidebarLink.vue"; import SidebarLink from "./SidebarLink.vue";
import MessagesSidebar from "./messages/MessagesSidebar.vue"; import MessagesSidebar from "./messages/MessagesSidebar.vue";
import NomadNetworkSidebar from "./nomadnetwork/NomadNetworkSidebar.vue";
import ConversationViewer from "./messages/ConversationViewer.vue"; import ConversationViewer from "./messages/ConversationViewer.vue";
import DialogUtils from "../js/DialogUtils"; import DialogUtils from "../js/DialogUtils";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
export default { export default {
name: 'App', name: 'App',
components: { components: {
ConversationViewer, ConversationViewer,
NomadNetworkSidebar,
MessagesSidebar, MessagesSidebar,
SidebarLink, SidebarLink,
}, },
data() { data() {
return { return {
isWebsocketConnected: false,
autoReconnectWebsocket: true,
isShowingMyIdentitySection: true, isShowingMyIdentitySection: true,
isShowingAnnounceSection: true, isShowingAnnounceSection: true,
isShowingCallsSection: true, isShowingCallsSection: true,
@@ -413,36 +306,22 @@ export default {
peers: {}, peers: {},
selectedPeer: null, selectedPeer: null,
nodes: {},
selectedNode: null,
selectedNodePath: null,
conversations: [], conversations: [],
isLoadingNodePage: false,
nodePageRequestSequence: 0,
nodePagePath: null,
nodePagePathUrlInput: null,
nodePageContent: null,
nodePageProgress: 0,
nodePagePathHistory: [],
nodePageCache: {},
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {},
}; };
}, },
created() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
},
beforeDestroy() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() { mounted() {
this.getAppInfo(); this.getAppInfo();
this.connectWebsocket();
this.getLxmfDeliveryAnnounces(); this.getLxmfDeliveryAnnounces();
this.getNomadnetworkNodeAnnounces();
this.getConversations(); this.getConversations();
// fixme: this is called by the micron-parser.js // fixme: this is called by the micron-parser.js
@@ -461,181 +340,81 @@ export default {
}, },
methods: { methods: {
connectWebsocket: function() { async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
// connect to websocket switch(json.type){
this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + "/ws"); case 'config': {
this.config = json.config;
this.ws.addEventListener('open', () => { this.displayName = json.config.display_name;
this.isWebsocketConnected = true; break;
});
this.ws.addEventListener('close', () => {
this.isWebsocketConnected = false;
if(this.autoReconnectWebsocket){
setTimeout(() => {
this.connectWebsocket();
}, 1000);
} }
}); case 'announce': {
const aspect = json.announce.aspect;
// handle data from reticulum if(aspect === "lxmf.delivery"){
this.ws.onmessage = (message) => { this.updatePeerFromAnnounce(json.announce);
const json = JSON.parse(message.data);
switch(json.type){
case 'config': {
this.config = json.config;
this.displayName = json.config.display_name;
break;
}
case 'announce': {
const aspect = json.announce.aspect;
if(aspect === "lxmf.delivery"){
this.updatePeerFromAnnounce(json.announce);
} else if(aspect === "nomadnetwork.node"){
this.updateNodeFromAnnounce(json.announce);
}
break;
}
case 'announced': {
// we just announced, update config so we can show the new last updated at
this.getConfig();
break;
}
case 'incoming_audio_call': {
Notification.requestPermission().then((result) => {
if(result === "granted"){
new window.Notification("Incoming Call", {
body: "Someone is calling you.",
tag: "new_audio_call", // only ever show one notification at a time
});
}
});
break;
}
case 'lxmf.delivery': {
// pass lxmf message to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageReceived(json.lxmf_message);
}
break;
}
case 'lxmf_message_created': {
// pass lxmf message to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageCreated(json.lxmf_message);
}
break;
}
case 'lxmf_message_state_updated': {
// pass lxmf message to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageUpdated(json.lxmf_message);
}
break;
}
case 'lxmf_message_deleted': {
// pass lxmf message hash to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageDeleted(json.hash);
}
break;
}
case 'nomadnet.page.download': {
// get data from server
const nomadnetPageDownload = json.nomadnet_page_download;
// find download callbacks
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
const nomadnetPageDownloadCallback = this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
if(!nomadnetPageDownloadCallback){
console.log("did not find nomadnet page download callback for key: " + getNomadnetPageDownloadCallbackKey);
return;
}
// handle success
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
return;
}
// handle failure
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
return;
}
// handle progress
if(nomadnetPageDownload.status === "progress" && nomadnetPageDownloadCallback.onProgressCallback){
nomadnetPageDownloadCallback.onProgressCallback(nomadnetPageDownload.progress);
return;
}
break;
}
case 'nomadnet.file.download': {
// get data from server
const nomadnetFileDownload = json.nomadnet_file_download;
// find download callbacks
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
const nomadnetFileDownloadCallback = this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
if(!nomadnetFileDownloadCallback){
console.log("did not find nomadnet file download callback for key: " + getNomadnetFileDownloadCallbackKey);
return;
}
// handle success
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
return;
}
// handle failure
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
return;
}
// handle progress
if(nomadnetFileDownload.status === "progress" && nomadnetFileDownloadCallback.onProgressCallback){
nomadnetFileDownloadCallback.onProgressCallback(nomadnetFileDownload.progress);
return;
}
break;
} }
break;
} }
}; case 'announced': {
// we just announced, update config so we can show the new last updated at
this.getConfig();
break;
}
case 'incoming_audio_call': {
Notification.requestPermission().then((result) => {
if(result === "granted"){
new window.Notification("Incoming Call", {
body: "Someone is calling you.",
tag: "new_audio_call", // only ever show one notification at a time
});
}
});
break;
}
case 'lxmf.delivery': {
}, // pass lxmf message to conversation viewer
disconnectWebsocket: function() { const conversationViewer = this.$refs["conversation-viewer"];
if(this.ws){ if(conversationViewer){
this.ws.close(); conversationViewer.onLxmfMessageReceived(json.lxmf_message);
}
break;
}
case 'lxmf_message_created': {
// pass lxmf message to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageCreated(json.lxmf_message);
}
break;
}
case 'lxmf_message_state_updated': {
// pass lxmf message to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageUpdated(json.lxmf_message);
}
break;
}
case 'lxmf_message_deleted': {
// pass lxmf message hash to conversation viewer
const conversationViewer = this.$refs["conversation-viewer"];
if(conversationViewer){
conversationViewer.onLxmfMessageDeleted(json.hash);
}
break;
}
} }
}, },
async getAppInfo() { async getAppInfo() {
@@ -670,21 +449,14 @@ export default {
}, },
async updateConfig(config) { async updateConfig(config) {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
return;
}
try { try {
this.ws.send(JSON.stringify({ WebSocketConnection.send(JSON.stringify({
"type": "config.set", "type": "config.set",
"config": config, "config": config,
})); }));
} catch(e) { } catch(e) {
console.error(e); console.error(e);
} }
}, },
async saveIdentitySettings() { async saveIdentitySettings() {
await this.updateConfig({ await this.updateConfig({
@@ -728,68 +500,6 @@ export default {
destination_hash: destinationHash, destination_hash: destinationHash,
}); });
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
DialogUtils.alert("Not connected to WebSocket!");
return;
}
try {
// set callbacks for nomadnet filePath download
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
onProgressCallback: onProgressCallback,
};
// ask reticulum to download file from nomadnet
this.ws.send(JSON.stringify({
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"destination_hash": destinationHash,
"file_path": filePath,
},
}));
} catch(e) {
console.error(e);
}
},
downloadNomadNetPage(destinationHash, pagePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
// do nothing if not connected to websocket
if(!this.isWebsocketConnected){
DialogUtils.alert("Not connected to WebSocket!");
return;
}
try {
// set callbacks for nomadnet page download
this.nomadnetPageDownloadCallbacks[this.getNomadnetPageDownloadCallbackKey(destinationHash, pagePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
onProgressCallback: onProgressCallback,
};
// ask reticulum to download page from nomadnet
this.ws.send(JSON.stringify({
"type": "nomadnet.page.download",
"nomadnet_page_download": {
"destination_hash": destinationHash,
"page_path": pagePath,
},
}));
} catch(e) {
console.error(e);
}
}, },
async getLxmfDeliveryAnnounces() { async getLxmfDeliveryAnnounces() {
try { try {
@@ -812,27 +522,6 @@ export default {
console.log(e); console.log(e);
} }
}, },
async getNomadnetworkNodeAnnounces() {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announces
console.log(e);
}
},
async getConversations() { async getConversations() {
try { try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`); const response = await window.axios.get(`/api/v1/lxmf/conversations`);
@@ -842,46 +531,14 @@ export default {
console.log(e); console.log(e);
} }
}, },
async getNodePath(destinationHash) {
// clear previous known path
this.selectedNodePath = null;
try {
// get path to destination
const response = await window.axios.get(`/api/v1/destination/${destinationHash}/path`);
// update ui
this.selectedNodePath = response.data.path;
} catch(e) {
console.log(e);
}
},
decodeBase64ToUtf8String: function(base64) {
// support for decoding base64 as a utf8 string to support emojis and cyrillic characters etc
return decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
},
getPeerNameFromAppData: function(appData) { getPeerNameFromAppData: function(appData) {
try { try {
// app data should be peer name, and our server provides it base64 encoded // app data should be peer name, and our server provides it base64 encoded
return this.decodeBase64ToUtf8String(appData); return Utils.decodeBase64ToUtf8String(appData);
} catch(e){ } catch(e){
return "Anonymous Peer"; return "Anonymous Peer";
} }
}, },
getNodeNameFromAppData: function(appData) {
try {
// app data should be node name, and our server provides it base64 encoded
return this.decodeBase64ToUtf8String(appData);
} catch(e){
return "Anonymous Node";
}
},
updatePeerFromAnnounce: function(announce) { updatePeerFromAnnounce: function(announce) {
this.peers[announce.destination_hash] = { this.peers[announce.destination_hash] = {
...announce, ...announce,
@@ -889,243 +546,6 @@ export default {
name: this.getPeerNameFromAppData(announce.app_data), name: this.getPeerNameFromAppData(announce.app_data),
}; };
}, },
updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = {
...announce,
// helper property for easily grabbing node name from app data
name: this.getNodeNameFromAppData(announce.app_data),
};
},
async loadNodePage(destinationHash, pagePath, addToHistory = true, loadFromCache = true) {
// get new sequence for this page load
const seq = ++this.nodePageRequestSequence;
// get previous page path
const previousNodePagePath = this.nodePagePath;
// update ui
this.isLoadingNodePage = true;
this.nodePagePath = `${destinationHash}:${pagePath}`;
this.nodePageContent = null;
this.nodePageProgress = 0;
// update url bar
this.nodePagePathUrlInput = this.nodePagePath;
// update node path
this.getNodePath(destinationHash);
// add to previous page to history if we are not loading that previous page
if(addToHistory && previousNodePagePath != null && previousNodePagePath !== this.nodePagePath){
this.nodePagePathHistory.push(previousNodePagePath);
}
// check if we can load this page from the cache
if(loadFromCache){
// load from cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
const cachedNodePageContent = this.nodePageCache[nodePagePathCacheKey];
// if page is cache, we can just return it now
if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent;
this.isLoadingNodePage = false;
return;
}
}
this.downloadNomadNetPage(destinationHash, pagePath, (pageContent) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request")
return;
}
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePath.endsWith(".mu")){
this.nodePageContent = MicronParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
// update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
}, (failureReason) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring failure callback for previous page request")
return;
}
// update page content
this.nodePageContent = `Failed loading page: ${failureReason}`;
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
}, (progress) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring progress callback for previous page request")
return;
}
// update page content
this.nodePageProgress = Math.round(progress * 100);
});
},
async reloadNodePage() {
// reload current node page without adding to history and without using cache
this.onNodePageUrlClick(this.nodePagePath, false, false);
},
async loadPreviousNodePage() {
// get the previous path from history, or do nothing
const previousNodePagePath = this.nodePagePathHistory.pop();
if(!previousNodePagePath){
return;
}
// load the page
this.onNodePageUrlClick(previousNodePagePath, false);
},
parseNomadnetworkUrl: function(url) {
// parse relative urls
if(url.startsWith(":")){
return {
destination_hash: null, // node hash was not in provided url
path: url.substring(1), // remove leading ":"
};
}
// parse absolute urls such as 00000000000000000000000000000000:/page/index.mu
if(url.includes(":")){
// parse destination hash and url
const [destinationHash, relativeUrl] = url.split(":");
// ensure destination is expected length
if(destinationHash.length === 32){
return {
destination_hash: destinationHash,
path: relativeUrl,
};
}
}
// parse node id only
if(url.length === 32){
return {
destination_hash: url,
path: "/page/index.mu",
};
}
// unsupported url
return null;
},
onNodePageUrlClick: function(url, addToHistory = true, useCache = true) {
// open http urls in new tab
if(url.startsWith("http://") || url.startsWith("https://")){
window.open(url, "_blank");
return;
}
// lxmf urls should open the conversation
if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){
this.openLXMFConversation(destinationHash);
return;
}
}
// attempt to parse url
const parsedUrl = this.parseNomadnetworkUrl(url);
if(parsedUrl != null){
// use parsed destination hash, or fallback to selected node destination hash
const destinationHash = parsedUrl.destination_hash || this.selectedNode.destination_hash;
// download file
if(parsedUrl.path.startsWith("/file/")){
// prevent simultaneous downloads
if(this.isDownloadingNodeFile){
DialogUtils.alert("An existing download is in progress. Please wait for it to finish beforing starting another download.");
return;
}
// update ui
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0;
// start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64);
}, (failureReason) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => {
this.nodeFileProgress = Math.round(progress * 100);
});
return;
}
// update selected node, so relative urls work correctly when returned by the new node
this.selectedNode = this.nodes[destinationHash] || {
name: "Unknown Node",
destination_hash: destinationHash,
};
// navigate to node page
this.loadNodePage(destinationHash, parsedUrl.path, addToHistory, useCache);
return;
}
// unsupported url
DialogUtils.alert("unsupported url: " + url);
},
downloadFileFromBase64: async function(fileName, fileBytesBase64) { downloadFileFromBase64: async function(fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes // create blob from base64 encoded file bytes
@@ -1161,11 +581,6 @@ export default {
this.selectedPeer = peer; this.selectedPeer = peer;
this.tab = "messages"; this.tab = "messages";
}, },
onNodeClick: function(node) {
this.selectedNode = node;
this.tab = "nomadnetwork";
this.loadNodePage(node.destination_hash, "/page/index.mu");
},
onConversationClick: function(conversation) { onConversationClick: function(conversation) {
// object must stay compatible with format of peers // object must stay compatible with format of peers
@@ -1232,12 +647,7 @@ export default {
} }
}, },
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`;
},
getNomadnetFileDownloadCallbackKey: function(destinationHash, filePath) {
return `${destinationHash}:${filePath}`;
},
formatBytes: function(bytes) { formatBytes: function(bytes) {
if(bytes === 0){ if(bytes === 0){

View File

@@ -1,69 +1,71 @@
<template> <template>
<div class="overflow-y-auto space-y-2 p-2"> <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]">
<div class="overflow-y-auto space-y-2 p-2">
<!-- app info --> <!-- app info -->
<div v-if="appInfo" class="bg-white rounded shadow"> <div v-if="appInfo" class="bg-white rounded shadow">
<div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">App Info</div> <div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">App Info</div>
<div class="divide-y text-gray-900"> <div class="divide-y text-gray-900">
<div class="flex p-1"> <div class="flex p-1">
<div class="mr-auto"> <div class="mr-auto">
<div>Version</div> <div>Version</div>
<div class="text-sm text-gray-700">v{{ appInfo.version }}</div> <div class="text-sm text-gray-700">v{{ appInfo.version }}</div>
</div>
<div class="mx-2 my-auto">
<a target="_blank" href="https://github.com/liamcottle/reticulum-meshchat/releases" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Check for Updates
</a>
</div>
</div> </div>
<div class="mx-2 my-auto"> <div class="flex p-1">
<a target="_blank" href="https://github.com/liamcottle/reticulum-meshchat/releases" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"> <div class="mr-auto">
Check for Updates <div>Reticulum Config Path</div>
</a> <div class="text-sm text-gray-700">{{ appInfo.reticulum_config_path }}</div>
</div>
<div v-if="isElectron" class="mx-2 my-auto">
<button @click="showReticulumConfigFile" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Show in Folder
</button>
</div>
</div> </div>
</div> <div class="flex p-1">
<div class="flex p-1"> <div class="mr-auto">
<div class="mr-auto"> <div>Database Path</div>
<div>Reticulum Config Path</div> <div class="text-sm text-gray-700">{{ appInfo.database_path }}</div>
<div class="text-sm text-gray-700">{{ appInfo.reticulum_config_path }}</div> </div>
<div v-if="isElectron" class="mx-2 my-auto">
<button @click="showDatabaseFile" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Show in Folder
</button>
</div>
</div> </div>
<div v-if="isElectron" class="mx-2 my-auto"> <div class="p-1">
<button @click="showReticulumConfigFile" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"> <div>Database File Size</div>
Show in Folder <div class="text-sm text-gray-700">{{ formatBytes(appInfo.database_file_size) }}</div>
</button>
</div> </div>
</div> </div>
<div class="flex p-1">
<div class="mr-auto">
<div>Database Path</div>
<div class="text-sm text-gray-700">{{ appInfo.database_path }}</div>
</div>
<div v-if="isElectron" class="mx-2 my-auto">
<button @click="showDatabaseFile" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Show in Folder
</button>
</div>
</div>
<div class="p-1">
<div>Database File Size</div>
<div class="text-sm text-gray-700">{{ formatBytes(appInfo.database_file_size) }}</div>
</div>
</div> </div>
</div>
<!-- my addresses --> <!-- my addresses -->
<div v-if="config" class="bg-white rounded shadow"> <div v-if="config" class="bg-white rounded shadow">
<div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">My Addresses</div> <div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">My Addresses</div>
<div class="divide-y text-gray-900"> <div class="divide-y text-gray-900">
<div class="p-1"> <div class="p-1">
<div>Identity Hash</div> <div>Identity Hash</div>
<div class="text-sm text-gray-700">{{ config.identity_hash }}</div> <div class="text-sm text-gray-700">{{ config.identity_hash }}</div>
</div> </div>
<div class="p-1"> <div class="p-1">
<div>LXMF Address</div> <div>LXMF Address</div>
<div class="text-sm text-gray-700">{{ config.lxmf_address_hash }}</div> <div class="text-sm text-gray-700">{{ config.lxmf_address_hash }}</div>
</div> </div>
<div class="p-1"> <div class="p-1">
<div>Audio Call Address</div> <div>Audio Call Address</div>
<div class="text-sm text-gray-700">{{ config.audio_call_address_hash }}</div> <div class="text-sm text-gray-700">{{ config.audio_call_address_hash }}</div>
</div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -1,343 +1,346 @@
<template> <template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]">
<div v-if="tab === 'interfaces'" class="overflow-y-auto p-2 space-y-2"> <!-- interfaces tab -->
<div v-if="tab === 'interfaces'" class="overflow-y-auto p-2 space-y-2">
<!-- warning --> <!-- warning -->
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow"> <div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
<div class="my-auto"> <div class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg> </svg>
</div>
<div class="ml-2 my-auto">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
<button v-if="isElectron" @click="relaunch" type="button" class="ml-auto my-auto inline-flex items-center gap-x-1 rounded-md bg-white px-2 py-1 text-sm font-semibold text-black shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">
<span>Restart Now</span>
</button>
</div> </div>
<div class="ml-2 my-auto">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
<button v-if="isElectron" @click="relaunch" type="button" class="ml-auto my-auto inline-flex items-center gap-x-1 rounded-md bg-white px-2 py-1 text-sm font-semibold text-black shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"> <button @click="showAddInterfaceForm" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<span>Restart Now</span> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Add Interface</span>
</button> </button>
</div>
<button @click="showAddInterfaceForm" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"> <!-- interface list -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <div v-for="iface of interfacesWithStats" class="border rounded bg-white shadow overflow-hidden">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Add Interface</span>
</button>
<!-- interface list --> <!-- IFAC info -->
<div v-for="iface of interfacesWithStats" class="border rounded bg-white shadow overflow-hidden"> <div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b">
<div class="flex text-sm">
<!-- IFAC info --> <div class="my-auto">
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-green-500">
<div class="flex text-sm"> <path fill-rule="evenodd" d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z" clip-rule="evenodd" />
<div class="my-auto"> </svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-green-500"> </div>
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z" clip-rule="evenodd" /> <span class="ml-1 my-auto">
</svg>
</div>
<span class="ml-1 my-auto">
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span> <span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span>
</span> </span>
</div> </div>
</div>
<div class="flex py-2">
<!-- icon -->
<div class="my-auto mx-2">
<svg v-if="iface.type === 'AutoInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"></path>
</svg>
<svg v-else-if="iface.type === 'RNodeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path>
</svg>
<svg v-else-if="iface.type === 'TCPClientInterface' || iface.type === 'TCPServerInterface' || iface.type === 'UDPInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"></path>
</svg>
<svg v-else-if="iface.type === 'SerialInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
</svg>
</div> </div>
<!-- interface details --> <div class="flex py-2">
<div>
<div class="font-semibold leading-5">{{ iface._name }}</div>
<div class="text-sm flex space-x-1">
<!-- auto interface --> <!-- icon -->
<span v-if="iface.type === 'AutoInterface'"> <div class="my-auto mx-2">
<svg v-if="iface.type === 'AutoInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"></path>
</svg>
<svg v-else-if="iface.type === 'RNodeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path>
</svg>
<svg v-else-if="iface.type === 'TCPClientInterface' || iface.type === 'TCPServerInterface' || iface.type === 'UDPInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"></path>
</svg>
<svg v-else-if="iface.type === 'SerialInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
</svg>
</div>
<!-- interface details -->
<div>
<div class="font-semibold leading-5">{{ iface._name }}</div>
<div class="text-sm flex space-x-1">
<!-- auto interface -->
<span v-if="iface.type === 'AutoInterface'">
{{ iface.type }} Ethernet and WiFi {{ iface.type }} Ethernet and WiFi
</span> </span>
<!-- tcp client interface --> <!-- tcp client interface -->
<span v-else-if="iface.type === 'TCPClientInterface'"> <span v-else-if="iface.type === 'TCPClientInterface'">
{{ iface.type }} {{ iface.target_host }}:{{ iface.target_port }} {{ iface.type }} {{ iface.target_host }}:{{ iface.target_port }}
</span> </span>
<!-- tcp server interface --> <!-- tcp server interface -->
<span v-else-if="iface.type === 'TCPServerInterface'"> <span v-else-if="iface.type === 'TCPServerInterface'">
{{ iface.type }} {{ iface.listen_ip }}:{{ iface.listen_port }} {{ iface.type }} {{ iface.listen_ip }}:{{ iface.listen_port }}
</span> </span>
<!-- udp interface --> <!-- udp interface -->
<span v-else-if="iface.type === 'UDPInterface'"> <span v-else-if="iface.type === 'UDPInterface'">
{{ iface.type }} {{ iface.listen_ip }}:{{ iface.listen_port }} {{ iface.forward_ip }}:{{ iface.forward_port }} {{ iface.type }} {{ iface.listen_ip }}:{{ iface.listen_port }} {{ iface.forward_ip }}:{{ iface.forward_port }}
</span> </span>
<!-- rnode interface details --> <!-- rnode interface details -->
<span v-else-if="iface.type === 'RNodeInterface'"> <span v-else-if="iface.type === 'RNodeInterface'">
{{ iface.type }} {{ iface.port }} freq={{ iface.frequency }} bw={{ iface.bandwidth }} power={{ iface.txpower }}dBm sf={{ iface.spreadingfactor }} cr={{ iface.codingrate }} {{ iface.type }} {{ iface.port }} freq={{ iface.frequency }} bw={{ iface.bandwidth }} power={{ iface.txpower }}dBm sf={{ iface.spreadingfactor }} cr={{ iface.codingrate }}
</span> </span>
<!-- unknown interface types --> <!-- unknown interface types -->
<span v-else> <span v-else>
{{ iface.type ?? 'Unknown Interface Type' }} {{ iface.type ?? 'Unknown Interface Type' }}
</span> </span>
</div>
</div> </div>
</div>
<!-- enabled state badge --> <!-- enabled state badge -->
<div class="ml-auto my-auto mr-2"> <div class="ml-auto my-auto mr-2">
<span v-if="isInterfaceEnabled(iface)" class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Enabled</span> <span v-if="isInterfaceEnabled(iface)" class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Enabled</span>
<span v-else class="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20">Disabled</span> <span v-else class="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20">Disabled</span>
</div> </div>
<!-- enable/disable interface button --> <!-- enable/disable interface button -->
<div class="my-auto mr-1"> <div class="my-auto mr-1">
<button v-if="isInterfaceEnabled(iface)" @click="disableInterface(iface._name)" type="button" class="cursor-pointer"> <button v-if="isInterfaceEnabled(iface)" @click="disableInterface(iface._name)" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
</svg> </svg>
</span> </span>
</button> </button>
<button v-else @click="enableInterface(iface._name)" type="button" class="cursor-pointer"> <button v-else @click="enableInterface(iface._name)" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
</svg> </svg>
</span> </span>
</button> </button>
</div> </div>
<!-- edit interface button --> <!-- edit interface button -->
<div class="my-auto mr-1"> <div class="my-auto mr-1">
<button @click="editInterface(iface._name)" type="button" class="cursor-pointer"> <button @click="editInterface(iface._name)" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /> <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg> </svg>
</span> </span>
</button> </button>
</div> </div>
<!-- delete interface button --> <!-- delete interface button -->
<div class="my-auto mr-2"> <div class="my-auto mr-2">
<button @click="deleteInterface(iface._name)" type="button" class="cursor-pointer"> <button @click="deleteInterface(iface._name)" type="button" class="cursor-pointer">
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg> </svg>
</span> </span>
</button>
</div>
</div>
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t">
<!-- status -->
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
<div v-else class="text-sm text-red-500">Disconnected</div>
<!-- stats -->
<div> Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
<div> TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
<div> RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
</div>
</div>
</div>
<!-- add interface tab -->
<div v-if="tab === 'interfaces.add'" class="overflow-y-auto p-2 space-y-2">
<!-- community interfaces -->
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white rounded shadow divide-y divide-gray-200">
<div class="flex p-2">
<div class="my-auto mr-auto">
<div class="font-bold">Community Interfaces</div>
<div class="text-sm">These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.</div>
</div>
<div class="my-auto ml-2">
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>
</div>
<div class="divide-y divide-gray-200">
<div class="flex px-2 py-1">
<div class="my-auto mr-auto">
<div>RNS Testnet Amsterdam</div>
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
</div>
<div class="ml-2 my-auto">
<button @click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<span>Use Interface</span>
</button> </button>
</div> </div>
</div> </div>
<div class="flex px-2 py-1"> <div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t">
<div class="my-auto mr-auto">
<div>RNS Testnet BetweenTheBorders</div> <!-- status -->
<div class="text-xs">betweentheborders.com:4242</div> <div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
</div> <div v-else class="text-sm text-red-500">Disconnected</div>
<div class="ml-2 my-auto">
<button @click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='betweentheborders.com';newInterfaceTargetPort='4242'" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"> <!-- stats -->
<span>Use Interface</span> <div> Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
</button> <div> TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
</div> <div> RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
</div> </div>
</div> </div>
</div> </div>
<!-- add interface form --> <!-- add interface tab -->
<div class="bg-white rounded shadow divide-y divide-gray-200"> <div v-if="tab === 'interfaces.add'" class="overflow-y-auto p-2 space-y-2">
<div class="p-2 font-bold">
<span v-if="isEditingInterface">Edit Interface</span>
<span v-else>Add Interface</span>
</div>
<div class="p-2 space-y-3">
<!-- interface name --> <!-- community interfaces -->
<div> <div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white rounded shadow divide-y divide-gray-200">
<label class="block mb-2 text-sm font-medium text-gray-900">Name</label> <div class="flex p-2">
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name" v-model="newInterfaceName" class="border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]"> <div class="my-auto mr-auto">
<div class="text-xs text-gray-600">Interface names must be unique.</div> <div class="font-bold">Community Interfaces</div>
</div> <div class="text-sm">These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.</div>
</div>
<!-- interface type --> <div class="my-auto ml-2">
<div class="mb-2"> <button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<label class="block mb-2 text-sm font-medium text-gray-900">Type</label> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<select v-model="newInterfaceType" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"> <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
<option disabled selected>--</option> </svg>
<option value="AutoInterface">AutoInterface</option> </button>
<option value="RNodeInterface">RNodeInterface</option>
<option value="TCPClientInterface">TCPClientInterface</option>
<option value="TCPServerInterface">TCPServerInterface</option>
<option value="UDPInterface">UDPInterface</option>
</select>
<div class="text-xs text-gray-600">
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
</div> </div>
</div> </div>
<div class="divide-y divide-gray-200">
<div class="flex px-2 py-1">
<div class="my-auto mr-auto">
<div>RNS Testnet Amsterdam</div>
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
</div>
<div class="ml-2 my-auto">
<button @click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<span>Use Interface</span>
</button>
</div>
</div>
<div class="flex px-2 py-1">
<div class="my-auto mr-auto">
<div>RNS Testnet BetweenTheBorders</div>
<div class="text-xs">betweentheborders.com:4242</div>
</div>
<div class="ml-2 my-auto">
<button @click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='betweentheborders.com';newInterfaceTargetPort='4242'" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<span>Use Interface</span>
</button>
</div>
</div>
<!-- interface target host -->
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Target Host</label>
<input type="text" placeholder="example.com" v-model="newInterfaceTargetHost" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div> </div>
</div>
<!-- interface target port --> <!-- add interface form -->
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2"> <div class="bg-white rounded shadow divide-y divide-gray-200">
<label class="block mb-2 text-sm font-medium text-gray-900">Target Port</label> <div class="p-2 font-bold">
<input type="text" placeholder="1234" v-model="newInterfaceTargetPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"> <span v-if="isEditingInterface">Edit Interface</span>
</div>
<!-- interface listen ip -->
<div v-if="newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Listen IP</label>
<input type="text" placeholder="0.0.0.0" v-model="newInterfaceListenIp" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface listen port -->
<div v-if="newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Listen Port</label>
<input type="text" placeholder="1234" v-model="newInterfaceListenPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface forward ip -->
<div v-if="newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Forward IP</label>
<input type="text" placeholder="255.255.255.255" v-model="newInterfaceForwardIp" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface listen port -->
<div v-if="newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Forward Port</label>
<input type="text" placeholder="1234" v-model="newInterfaceForwardPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface port -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Port</label>
<select v-model="newInterfacePort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="comport of comports" :value="comport.device">{{ comport.device }} (Product: {{ comport.product ?? '?' }}, Serial: {{ comport.serial ?? '?' }})</option>
</select>
</div>
<!-- interface frequency -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Frequency (Hz)</label>
<input type="number" v-model="newInterfaceFrequency" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<div class="text-xs text-gray-600">{{ formatFrequency(newInterfaceFrequency) }}</div>
</div>
<!-- interface bandwidth -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Bandwidth</label>
<select v-model="newInterfaceBandwidth" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="bandwidth in RNodeInterfaceDefaults.bandwidths" :value="bandwidth">{{ bandwidth / 1000 }} KHz</option>
</select>
</div>
<!-- interface txpower -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Transmit Power (dBm)</label>
<input v-model="newInterfaceTxpower" type="number" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface spreading factor -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Spreading Factor</label>
<select v-model="newInterfaceSpreadingFactor" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="spreadingfactor in RNodeInterfaceDefaults.spreadingfactors" :value="spreadingfactor">{{ spreadingfactor }}</option>
</select>
</div>
<!-- interface coding rate -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Coding Rate</label>
<select v-model="newInterfaceCodingRate" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="codingrate in RNodeInterfaceDefaults.codingrates" :value="codingrate">4:{{ codingrate }}</option>
</select>
</div>
<!-- add button -->
<button @click="addInterface" type="button" class="bg-green-500 hover:bg-green-400 focus-visible:outline-green-500 my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<span v-if="isEditingInterface">Save Interface</span>
<span v-else>Add Interface</span> <span v-else>Add Interface</span>
</button> </div>
<div class="p-2 space-y-3">
<!-- interface name -->
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Name</label>
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name" v-model="newInterfaceName" class="border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]">
<div class="text-xs text-gray-600">Interface names must be unique.</div>
</div>
<!-- interface type -->
<div class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Type</label>
<select v-model="newInterfaceType" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option disabled selected>--</option>
<option value="AutoInterface">AutoInterface</option>
<option value="RNodeInterface">RNodeInterface</option>
<option value="TCPClientInterface">TCPClientInterface</option>
<option value="TCPServerInterface">TCPServerInterface</option>
<option value="UDPInterface">UDPInterface</option>
</select>
<div class="text-xs text-gray-600">
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
</div>
</div>
<!-- interface target host -->
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Target Host</label>
<input type="text" placeholder="example.com" v-model="newInterfaceTargetHost" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface target port -->
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Target Port</label>
<input type="text" placeholder="1234" v-model="newInterfaceTargetPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface listen ip -->
<div v-if="newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Listen IP</label>
<input type="text" placeholder="0.0.0.0" v-model="newInterfaceListenIp" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface listen port -->
<div v-if="newInterfaceType === 'TCPServerInterface' || newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Listen Port</label>
<input type="text" placeholder="1234" v-model="newInterfaceListenPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface forward ip -->
<div v-if="newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Forward IP</label>
<input type="text" placeholder="255.255.255.255" v-model="newInterfaceForwardIp" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface listen port -->
<div v-if="newInterfaceType === 'UDPInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Forward Port</label>
<input type="text" placeholder="1234" v-model="newInterfaceForwardPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface port -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Port</label>
<select v-model="newInterfacePort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="comport of comports" :value="comport.device">{{ comport.device }} (Product: {{ comport.product ?? '?' }}, Serial: {{ comport.serial ?? '?' }})</option>
</select>
</div>
<!-- interface frequency -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Frequency (Hz)</label>
<input type="number" v-model="newInterfaceFrequency" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<div class="text-xs text-gray-600">{{ formatFrequency(newInterfaceFrequency) }}</div>
</div>
<!-- interface bandwidth -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Bandwidth</label>
<select v-model="newInterfaceBandwidth" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="bandwidth in RNodeInterfaceDefaults.bandwidths" :value="bandwidth">{{ bandwidth / 1000 }} KHz</option>
</select>
</div>
<!-- interface txpower -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Transmit Power (dBm)</label>
<input v-model="newInterfaceTxpower" type="number" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
</div>
<!-- interface spreading factor -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Spreading Factor</label>
<select v-model="newInterfaceSpreadingFactor" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="spreadingfactor in RNodeInterfaceDefaults.spreadingfactors" :value="spreadingfactor">{{ spreadingfactor }}</option>
</select>
</div>
<!-- interface coding rate -->
<div v-if="newInterfaceType === 'RNodeInterface'" class="mb-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Coding Rate</label>
<select v-model="newInterfaceCodingRate" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option v-for="codingrate in RNodeInterfaceDefaults.codingrates" :value="codingrate">4:{{ codingrate }}</option>
</select>
</div>
<!-- add button -->
<button @click="addInterface" type="button" class="bg-green-500 hover:bg-green-400 focus-visible:outline-green-500 my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<span v-if="isEditingInterface">Save Interface</span>
<span v-else>Add Interface</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>

View File

@@ -0,0 +1,616 @@
<template>
<!-- nomadnetwork sidebar -->
<NomadNetworkSidebar
:nodes="nodes"
:selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]">
<!-- node -->
<div v-if="selectedNode" class="m-2 flex flex-col h-full border rounded-xl bg-white shadow overflow-hidden">
<!-- header -->
<div class="flex p-2 border-b border-gray-300">
<!-- node info -->
<div class="my-auto">
<span class="font-semibold">{{ selectedNode.name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div>
<!-- close button -->
<div class="my-auto ml-auto mr-2">
<div @click="selectedNode = null" class="cursor-pointer">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- browser navigation -->
<div class="flex w-full border-gray-300 border-b p-2">
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="reloadNodePage" type="button" class="ml-1 my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 bg-gray-200 hover:bg-gray-300' : 'text-gray-400 bg-gray-100']" class="ml-1 my-auto rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
</svg>
</button>
<div class="my-auto mx-2 w-full">
<input v-model="nodePagePathUrlInput" @keyup.enter="onNodePageUrlClick(nodePagePathUrlInput)" type="text" placeholder="Enter Destination URL" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-1.5">
</div>
<button @click="onNodePageUrlClick(nodePagePathUrlInput)" type="button" class="my-auto text-gray-500 bg-gray-200 hover:bg-gray-300 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
</svg>
</button>
</div>
<!-- page content -->
<div class="h-full overflow-y-scroll p-3 bg-black text-white">
<div class="flex" v-if="isLoadingNodePage">
<div class="my-auto">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
</div>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
</div>
<!-- file download bottom bar -->
<div v-if="isDownloadingNodeFile" class="flex w-full border-gray-300 border-t p-2">
<div class="my-auto mr-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
</div>
</div>
<!-- no node selected -->
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
<div class="font-semibold">No Active Node</div>
<div>Select a Node to start browsing!</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
export default {
name: 'NomadNetworkPage',
components: {
NomadNetworkSidebar,
},
data() {
return {
nodes: {},
selectedNode: null,
selectedNodePath: null,
isLoadingNodePage: false,
nodePageRequestSequence: 0,
nodePagePath: null,
nodePagePathUrlInput: null,
nodePageContent: null,
nodePageProgress: 0,
nodePagePathHistory: [],
nodePageCache: {},
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {},
};
},
created() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
},
beforeDestroy() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
this.getNomadnetworkNodeAnnounces();
},
methods: {
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
case 'announce': {
const aspect = json.announce.aspect;
if(aspect === "nomadnetwork.node"){
this.updateNodeFromAnnounce(json.announce);
}
break;
}
case 'nomadnet.page.download': {
// get data from server
const nomadnetPageDownload = json.nomadnet_page_download;
// find download callbacks
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
const nomadnetPageDownloadCallback = this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
if(!nomadnetPageDownloadCallback){
console.log("did not find nomadnet page download callback for key: " + getNomadnetPageDownloadCallbackKey);
return;
}
// handle success
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
return;
}
// handle failure
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
return;
}
// handle progress
if(nomadnetPageDownload.status === "progress" && nomadnetPageDownloadCallback.onProgressCallback){
nomadnetPageDownloadCallback.onProgressCallback(nomadnetPageDownload.progress);
return;
}
break;
}
case 'nomadnet.file.download': {
// get data from server
const nomadnetFileDownload = json.nomadnet_file_download;
// find download callbacks
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
const nomadnetFileDownloadCallback = this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
if(!nomadnetFileDownloadCallback){
console.log("did not find nomadnet file download callback for key: " + getNomadnetFileDownloadCallbackKey);
return;
}
// handle success
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
return;
}
// handle failure
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
return;
}
// handle progress
if(nomadnetFileDownload.status === "progress" && nomadnetFileDownloadCallback.onProgressCallback){
nomadnetFileDownloadCallback.onProgressCallback(nomadnetFileDownload.progress);
return;
}
break;
}
}
},
onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
},
async getNomadnetworkNodeAnnounces() {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announces
console.log(e);
}
},
updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = {
...announce,
// helper property for easily grabbing node name from app data
name: this.getNodeNameFromAppData(announce.app_data),
};
},
getNodeNameFromAppData: function(appData) {
try {
// app data should be node name, and our server provides it base64 encoded
return Utils.decodeBase64ToUtf8String(appData);
} catch(e){
return "Anonymous Node";
}
},
async loadNodePage(destinationHash, pagePath, addToHistory = true, loadFromCache = true) {
// get new sequence for this page load
const seq = ++this.nodePageRequestSequence;
// get previous page path
const previousNodePagePath = this.nodePagePath;
// update ui
this.isLoadingNodePage = true;
this.nodePagePath = `${destinationHash}:${pagePath}`;
this.nodePageContent = null;
this.nodePageProgress = 0;
// update url bar
this.nodePagePathUrlInput = this.nodePagePath;
// update node path
this.getNodePath(destinationHash);
// add to previous page to history if we are not loading that previous page
if(addToHistory && previousNodePagePath != null && previousNodePagePath !== this.nodePagePath){
this.nodePagePathHistory.push(previousNodePagePath);
}
// check if we can load this page from the cache
if(loadFromCache){
// load from cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
const cachedNodePageContent = this.nodePageCache[nodePagePathCacheKey];
// if page is cache, we can just return it now
if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent;
this.isLoadingNodePage = false;
return;
}
}
this.downloadNomadNetPage(destinationHash, pagePath, (pageContent) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request")
return;
}
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePath.endsWith(".mu")){
this.nodePageContent = MicronParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
// update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
}, (failureReason) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring failure callback for previous page request")
return;
}
// update page content
this.nodePageContent = `Failed loading page: ${failureReason}`;
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
}, (progress) => {
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring progress callback for previous page request")
return;
}
// update page content
this.nodePageProgress = Math.round(progress * 100);
});
},
async reloadNodePage() {
// reload current node page without adding to history and without using cache
this.onNodePageUrlClick(this.nodePagePath, false, false);
},
async loadPreviousNodePage() {
// get the previous path from history, or do nothing
const previousNodePagePath = this.nodePagePathHistory.pop();
if(!previousNodePagePath){
return;
}
// load the page
this.onNodePageUrlClick(previousNodePagePath, false);
},
parseNomadnetworkUrl: function(url) {
// parse relative urls
if(url.startsWith(":")){
return {
destination_hash: null, // node hash was not in provided url
path: url.substring(1), // remove leading ":"
};
}
// parse absolute urls such as 00000000000000000000000000000000:/page/index.mu
if(url.includes(":")){
// parse destination hash and url
const [destinationHash, relativeUrl] = url.split(":");
// ensure destination is expected length
if(destinationHash.length === 32){
return {
destination_hash: destinationHash,
path: relativeUrl,
};
}
}
// parse node id only
if(url.length === 32){
return {
destination_hash: url,
path: "/page/index.mu",
};
}
// unsupported url
return null;
},
onNodePageUrlClick: function(url, addToHistory = true, useCache = true) {
// open http urls in new tab
if(url.startsWith("http://") || url.startsWith("https://")){
window.open(url, "_blank");
return;
}
// lxmf urls should open the conversation
if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){
this.openLXMFConversation(destinationHash);
return;
}
}
// attempt to parse url
const parsedUrl = this.parseNomadnetworkUrl(url);
if(parsedUrl != null){
// use parsed destination hash, or fallback to selected node destination hash
const destinationHash = parsedUrl.destination_hash || this.selectedNode.destination_hash;
// download file
if(parsedUrl.path.startsWith("/file/")){
// prevent simultaneous downloads
if(this.isDownloadingNodeFile){
DialogUtils.alert("An existing download is in progress. Please wait for it to finish beforing starting another download.");
return;
}
// update ui
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0;
// start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64);
}, (failureReason) => {
// no longer downloading
this.isDownloadingNodeFile = false;
// show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => {
this.nodeFileProgress = Math.round(progress * 100);
});
return;
}
// update selected node, so relative urls work correctly when returned by the new node
this.selectedNode = this.nodes[destinationHash] || {
name: "Unknown Node",
destination_hash: destinationHash,
};
// navigate to node page
this.loadNodePage(destinationHash, parsedUrl.path, addToHistory, useCache);
return;
}
// unsupported url
DialogUtils.alert("unsupported url: " + url);
},
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes
const byteCharacters = atob(fileBytesBase64);
const byteNumbers = new Array(byteCharacters.length);
for(let i = 0; i < byteCharacters.length; i++){
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray]);
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create link element to download blob
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
},
onNodeClick: function(node) {
this.selectedNode = node;
this.loadNodePage(node.destination_hash, "/page/index.mu");
},
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`;
},
getNomadnetFileDownloadCallbackKey: function(destinationHash, filePath) {
return `${destinationHash}:${filePath}`;
},
async getNodePath(destinationHash) {
// clear previous known path
this.selectedNodePath = null;
try {
// get path to destination
const response = await window.axios.get(`/api/v1/destination/${destinationHash}/path`);
// update ui
this.selectedNodePath = response.data.path;
} catch(e) {
console.log(e);
}
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try {
// set callbacks for nomadnet filePath download
this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
onProgressCallback: onProgressCallback,
};
// ask reticulum to download file from nomadnet
WebSocketConnection.send(JSON.stringify({
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"destination_hash": destinationHash,
"file_path": filePath,
},
}));
} catch(e) {
console.error(e);
}
},
downloadNomadNetPage(destinationHash, pagePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try {
// set callbacks for nomadnet page download
this.nomadnetPageDownloadCallbacks[this.getNomadnetPageDownloadCallbackKey(destinationHash, pagePath)] = {
onSuccessCallback: onSuccessCallback,
onFailureCallback: onFailureCallback,
onProgressCallback: onProgressCallback,
};
// ask reticulum to download page from nomadnet
WebSocketConnection.send(JSON.stringify({
"type": "nomadnet.page.download",
"nomadnet_page_download": {
"destination_hash": destinationHash,
"page_path": pagePath,
},
}));
} catch(e) {
console.error(e);
}
},
},
}
</script>

View File

@@ -1,52 +1,54 @@
<template> <template>
<div class="overflow-y-auto space-y-2 p-2"> <div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px]">
<div class="overflow-y-auto space-y-2 p-2">
<!-- failed messages --> <!-- failed messages -->
<div class="bg-white rounded shadow"> <div class="bg-white rounded shadow">
<div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">Failed Messages</div> <div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">Failed Messages</div>
<div class="divide-y text-gray-900"> <div class="divide-y text-gray-900">
<div class="p-2"> <div class="p-2">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300"> <input v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300">
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Auto resend</label>
</div> </div>
<label class="ml-2 text-sm font-medium text-gray-900">Auto resend</label> <div class="text-sm text-gray-700">When enabled, failed messages will auto resend when an announce is received from the intended destination.</div>
</div> </div>
<div class="text-sm text-gray-700">When enabled, failed messages will auto resend when an announce is received from the intended destination.</div>
</div>
<div class="p-2"> <div class="p-2">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300"> <input v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300">
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Allow resending with attachments</label>
</div> </div>
<label class="ml-2 text-sm font-medium text-gray-900">Allow resending with attachments</label> <div class="text-sm text-gray-700">When enabled, failed messages that have attachments are allowed to auto resend.</div>
</div> </div>
<div class="text-sm text-gray-700">When enabled, failed messages that have attachments are allowed to auto resend.</div>
</div>
</div>
</div> </div>
</div>
<!-- interfaces --> <!-- interfaces -->
<div class="bg-white rounded shadow"> <div class="bg-white rounded shadow">
<div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">Interfaces</div> <div class="flex border-b border-gray-300 text-gray-700 p-2 font-semibold">Interfaces</div>
<div class="divide-y text-gray-900"> <div class="divide-y text-gray-900">
<div class="p-2"> <div class="p-2">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300"> <input v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300">
</div>
<label class="ml-2 text-sm font-medium text-gray-900">Show Community Interfaces</label>
</div> </div>
<label class="ml-2 text-sm font-medium text-gray-900">Show Community Interfaces</label> <div class="text-sm text-gray-700">When enabled, community interfaces will be shown on the Add Interface page.</div>
</div> </div>
<div class="text-sm text-gray-700">When enabled, community interfaces will be shown on the Add Interface page.</div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -124,6 +124,13 @@ class Utils {
} }
static decodeBase64ToUtf8String(base64) {
// support for decoding base64 as a utf8 string to support emojis and cyrillic characters etc
return decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
}
} }
export default Utils; export default Utils;

View File

@@ -0,0 +1,52 @@
import mitt from 'mitt';
class WebSocketConnection {
constructor() {
this.emitter = mitt();
this.reconnect();
}
// add event listener
on(event, handler) {
this.emitter.on(event, handler);
}
// remove event listener
off(event, handler) {
this.emitter.off(event, handler);
}
// emit event
emit(type, event) {
this.emitter.emit(type, event);
}
reconnect() {
// connect to websocket
this.ws = new WebSocket(location.origin.replace(/^http/, 'ws') + "/ws");
// auto reconnect when websocket closes
this.ws.addEventListener('close', () => {
setTimeout(() => {
this.reconnect();
}, 1000);
});
// emit data received from websocket
this.ws.onmessage = (message) => {
this.emit("message", message);
};
}
send(message) {
if(this.ws != null && this.ws.readyState === WebSocket.OPEN){
this.ws.send(message);
}
}
}
export default new WebSocketConnection();

View File

@@ -6,12 +6,14 @@ import AboutPage from "./components/about/AboutPage.vue";
import SettingsPage from "./components/settings/SettingsPage.vue"; import SettingsPage from "./components/settings/SettingsPage.vue";
import NetworkVisualiserPage from "./components/network/NetworkVisualiserPage.vue"; import NetworkVisualiserPage from "./components/network/NetworkVisualiserPage.vue";
import InterfacesPage from "./components/interfaces/InterfacesPage.vue"; import InterfacesPage from "./components/interfaces/InterfacesPage.vue";
import NomadNetworkPage from "./components/nomadnetwork/NomadNetworkPage.vue";
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ path: '/' }, { path: '/' },
{ path: '/about', name: "about", component: AboutPage }, { path: '/about', name: "about", component: AboutPage },
{ path: '/nomadnetwork', name: "nomadnetwork", component: NomadNetworkPage },
{ path: '/settings', name: "settings", component: SettingsPage }, { path: '/settings', name: "settings", component: SettingsPage },
{ path: '/interfaces', name: "interfaces", component: InterfacesPage }, { path: '/interfaces', name: "interfaces", component: InterfacesPage },
{ path: '/network-visualiser', name: "network-visualiser", component: NetworkVisualiserPage }, { path: '/network-visualiser', name: "network-visualiser", component: NetworkVisualiserPage },