Enhance Network Visualiser UI and functionality

- Redesigned control panel for better accessibility and aesthetics.
- Added loading state for update button and improved tooltip styles.
- Introduced conversation fetching and icon generation for user nodes.
- Updated node and edge styling for improved visibility and user experience.
- Enhanced sidebar search functionality to display dynamic placeholder text.
This commit is contained in:
2025-11-30 20:13:41 -06:00
parent d48a6d9620
commit c83b90f4f8
2 changed files with 173 additions and 54 deletions

View File

@@ -3,38 +3,41 @@
<!-- network -->
<div id="network" class="w-full h-full"></div>
<!-- controls -->
<div class="absolute flex bottom-0 left-0 bg-gray-100 dark:bg-zinc-900 p-2">
<div class="bg-white dark:bg-zinc-800 rounded shadow min-w-52">
<div @click="isShowingControls = !isShowingControls" class="flex text-gray-700 dark:text-gray-300 p-2 cursor-pointer">
<div class="my-auto">Reticulum Network</div>
<div class="flex ml-auto">
<button
@click.stop="update"
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-1 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-white">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</div>
<div class="absolute bottom-4 left-4 z-10">
<div class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-lg overflow-hidden min-w-[240px]">
<div @click="isShowingControls = !isShowingControls" class="flex items-center px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors">
<div class="flex-1 font-semibold text-gray-900 dark:text-zinc-100">Reticulum Network</div>
<button
@click.stop="update"
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-sm transition-colors"
:disabled="isUpdating"
>
<svg v-if="!isUpdating" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<svg v-else class="animate-spin h-4 w-4" 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>
</button>
</div>
<div v-if="isShowingControls" class="divide-y dark:divide-zinc-700 text-gray-900 dark:text-white border-t border-gray-300 dark:border-zinc-700">
<div class="px-1 py-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
v-model="autoReload"
type="checkbox"
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-900 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-800"
>
</div>
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Auto Update (5 sec)</label>
</div>
<div v-if="isShowingControls" class="px-4 py-3 space-y-3">
<div class="flex items-center gap-2">
<input
v-model="autoReload"
type="checkbox"
id="auto-reload"
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-white dark:bg-zinc-900 text-blue-600 focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-0"
>
<label for="auto-reload" class="text-sm font-medium text-gray-900 dark:text-zinc-100 cursor-pointer">Auto Update (5 sec)</label>
</div>
<div class="p-1">
<div class="text-black dark:text-white">Interfaces</div>
<div class="text-sm text-gray-700 dark:text-gray-300">{{ onlineInterfaces.length }} Online, {{ offlineInterfaces.length }} Offline</div>
<div class="pt-2 border-t border-gray-200 dark:border-zinc-800">
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 mb-1">Interfaces</div>
<div class="text-xs text-gray-600 dark:text-zinc-400">
<span class="text-green-600 dark:text-green-400 font-medium">{{ onlineInterfaces.length }}</span> Online,
<span class="text-red-600 dark:text-red-400 font-medium">{{ offlineInterfaces.length }}</span> Offline
</div>
</div>
</div>
</div>
@@ -45,7 +48,24 @@
<style>
.vis-tooltip {
color: white !important;
background: rgba(0, 0, 0, 0.75) !important;
background: rgba(0, 0, 0, 0.85) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 0.75rem !important;
font-size: 0.875rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
}
.dark .vis-tooltip {
background: rgba(24, 24, 27, 0.95) !important;
border: 1px solid rgba(63, 63, 70, 0.5) !important;
}
#network {
background-color: rgb(249, 250, 251);
}
.dark #network {
background-color: rgb(9, 9, 11);
}
</style>
@@ -53,6 +73,7 @@
import "vis-network/styles/vis-network.css";
import { Network } from "vis-network";
import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
export default {
name: 'NetworkVisualiser',
@@ -62,12 +83,15 @@ export default {
autoReload: false,
reloadInterval: null,
isShowingControls: true,
isUpdating: false,
interfaces: [],
pathTable: [],
announces: {},
conversations: {},
network: null,
nodes: new DataSet(),
edges: new DataSet(),
iconCache: {},
};
},
beforeUnmount() {
@@ -118,6 +142,70 @@ export default {
console.log(e);
}
},
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
this.conversations = {};
for(const conversation of response.data.conversations){
this.conversations[conversation.destination_hash] = conversation;
}
} catch(e) {
console.log(e);
}
},
async createIconImage(iconName, foregroundColor, backgroundColor, size = 32) {
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
if(this.iconCache[cacheKey]){
return this.iconCache[cacheKey];
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// draw background circle
ctx.fillStyle = backgroundColor;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, 2 * Math.PI);
ctx.fill();
// load MDI icon SVG
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
const img = new Image();
const svgBlob = new Blob([iconSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, size * 0.2, size * 0.2, size * 0.6, size * 0.6);
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.onerror = () => {
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.src = url;
});
},
getMdiIconSvg(iconName, foregroundColor) {
const mdiIconName = "mdi" + iconName.split("-").map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).join("");
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
async createMdiIconImage(iconName, size = 32) {
const foregroundColor = '#ffffff';
const backgroundColor = '#6b7280';
return await this.createIconImage(iconName, foregroundColor, backgroundColor, size);
},
async init() {
// create network ui
@@ -135,11 +223,23 @@ export default {
},
nodes: {
color: {
border: "#000000",
border: "#e5e7eb",
highlight: {
border: "#000000",
border: "#3b82f6",
},
},
font: {
color: "#111827",
size: 14,
background: "rgba(255, 255, 255, 0.9)",
},
},
edges: {
color: {
color: "#9ca3af",
highlight: "#3b82f6",
},
width: 2,
},
physics: {
barnesHut: {
@@ -256,14 +356,24 @@ export default {
},
async update() {
await this.getConfig();
await this.getInterfaceStats();
await this.getPathTable();
await this.getAnnounces();
this.isUpdating = true;
try {
await this.getConfig();
await this.getInterfaceStats();
await this.getPathTable();
await this.getAnnounces();
await this.getConversations();
} finally {
this.isUpdating = false;
}
const nodes = [];
const edges = [];
const isDarkMode = document.documentElement.classList.contains('dark');
const fontColor = isDarkMode ? "#f4f4f5" : "#111827";
const fontBackground = isDarkMode ? "rgba(24, 24, 27, 0.9)" : "rgba(255, 255, 255, 0.9)";
// add me
nodes.push({
id: "me",
@@ -275,8 +385,8 @@ export default {
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
].join("\n"),
font: {
color: "#000000",
background: "#ffffff",
color: fontColor,
background: fontBackground,
},
});
@@ -305,8 +415,8 @@ export default {
].join("\n"),
size: 30,
font: {
color: "#000000",
background: '#ffffff',
color: fontColor,
background: fontBackground,
},
shape: "circularImage",
image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png",
@@ -322,12 +432,9 @@ export default {
id: `${entry.parent_interface_name}~${entry.name}`,
from: entry.parent_interface_name,
to: entry.name,
color: "transparent",
color: entry.status ? "#22c55e" : "#ef4444",
width: 3,
length: 300,
background: {
enabled: true,
color: entry.status ? "#22c55e" : "#ef4444",
},
});
} else {
// add edge from me to interface
@@ -335,12 +442,9 @@ export default {
id: `me~${entry.name}`,
from: "me",
to: entry.name,
color: "transparent",
color: entry.status ? "#22c55e" : "#ef4444",
width: 3,
length: 300,
background: {
enabled: true,
color: entry.status ? "#22c55e" : "#ef4444",
},
});
}
@@ -375,9 +479,22 @@ export default {
if(announce.aspect === "lxmf.delivery"){
const name = announce.custom_display_name ?? announce.display_name;
const conversation = this.conversations[announce.destination_hash];
node.shape = "circularImage";
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
if(conversation?.lxmf_user_icon){
const iconImage = await this.createIconImage(
conversation.lxmf_user_icon.icon_name,
conversation.lxmf_user_icon.foreground_colour,
conversation.lxmf_user_icon.background_colour,
40
);
node.image = iconImage;
node.size = 30;
} else {
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
}
node.label = name;
node.title = [
@@ -423,7 +540,8 @@ export default {
id: `${entry.interface}~${entry.hash}`,
from: entry.interface,
to: entry.hash,
color: "gray",
color: isDarkMode ? "#71717a" : "#9ca3af",
width: 2,
});
}
@@ -504,3 +622,4 @@ export default {
},
}
</script>

View File

@@ -12,7 +12,7 @@
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input v-model="favouritesSearchTerm" type="text" placeholder="Search favourites" class="input-field"/>
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} favourites...`" class="input-field"/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">