more cleanup
This commit is contained in:
@ -1,8 +1,8 @@
|
||||
|
||||
<script setup>
|
||||
import { selectedNodeLatestPowerMetric } from '@/store';
|
||||
const props = defineProps({
|
||||
channel: Number, // Channel number (1, 2, or 3)
|
||||
channel: Number, // Channel number (1, 2, or 3),
|
||||
latest: Array,
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -10,13 +10,13 @@
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Channel {{ channel }}</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="selectedNodeLatestPowerMetric">
|
||||
<span v-if="selectedNodeLatestPowerMetric[`ch${channel}_voltage`]" >
|
||||
{{ Number(selectedNodeLatestPowerMetric[`ch${channel}_voltage`]).toFixed(2) }}V
|
||||
<span v-if="latest">
|
||||
<span v-if="latest[`ch${channel}_voltage`]" >
|
||||
{{ Number(latest[`ch${channel}_voltage`]).toFixed(2) }}V
|
||||
</span>
|
||||
<span v-else>???</span>
|
||||
<span v-if="selectedNodeLatestPowerMetric[`ch${channel}_current`]">
|
||||
/ {{ Number(selectedNodeLatestPowerMetric[`ch${channel}_current`]).toFixed(2) }}mA
|
||||
<span v-if="latest[`ch${channel}_current`]">
|
||||
/ {{ Number(latest[`ch${channel}_current`]).toFixed(2) }}mA
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>???</span>
|
||||
|
@ -20,16 +20,62 @@ import { copyShareLinkForNode, getTimeSpan, buildPath } from '@/utils';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const emit = defineEmits(['showPositionHistory']);
|
||||
const emit = defineEmits(['showPositionHistory', 'showTraceRoute']);
|
||||
const mapData = useMapStore();
|
||||
|
||||
function showPositionHistory(id) {
|
||||
emit('showPositionHistory', id);
|
||||
const mqttMetrics = ref([]);
|
||||
const traceroutes = ref([]);
|
||||
const deviceMetrics = ref([]);
|
||||
const environmentMetrics = ref([]);
|
||||
const powerMetrics = ref([]);
|
||||
|
||||
// Generalized function for loading data
|
||||
async function loadData(path, params = {}, targetRef, metrics = false) {
|
||||
try {
|
||||
const response = await axios.get(buildPath(path), { params });
|
||||
|
||||
// Grab the first key from the response (assuming it's always an object with a single data array)
|
||||
const key = Object.keys(response.data)[0];
|
||||
const rawData = response.data[key] ?? [];
|
||||
|
||||
// Reverse if it's metric data, otherwise just assign
|
||||
targetRef.value = metrics ? rawData.slice().reverse() : rawData;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error loading data from ${path}:`, error);
|
||||
targetRef.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function showTraceRoute(traceroute) {
|
||||
emit('showTraceRoute', traceroute);
|
||||
state.selectedTraceRoute = traceroute;
|
||||
// Utility function to calculate the timeFrom value based on a given time range
|
||||
function calculateTimeFrom(timeRange) {
|
||||
const time = getTimeSpan(timeRange).amount;
|
||||
return new Date().getTime() - (time * 1000);
|
||||
}
|
||||
|
||||
// Load MQTT metrics
|
||||
function loadNodeMqttMetrics(nodeId) {
|
||||
loadData(`/api/v1/nodes/${nodeId}/mqtt-metrics`, {}, mqttMetrics);
|
||||
}
|
||||
|
||||
// Load Traceroutes
|
||||
function loadNodeTraceroutes(nodeId) {
|
||||
loadData(`/api/v1/nodes/${nodeId}/traceroutes`, { count: 5 }, traceroutes);
|
||||
}
|
||||
|
||||
// Load Device Metrics
|
||||
function loadNodeDeviceMetrics(nodeId) {
|
||||
loadData(`/api/v1/nodes/${nodeId}/device-metrics`, { time_from: calculateTimeFrom(configStore.deviceMetricsTimeRange) }, deviceMetrics, true);
|
||||
}
|
||||
|
||||
// Load Environment Metrics
|
||||
function loadNodeEnvironmentMetrics(nodeId) {
|
||||
loadData(`/api/v1/nodes/${nodeId}/environment-metrics`, { time_from: calculateTimeFrom(configStore.environmentMetricsTimeRange) }, environmentMetrics, true);
|
||||
}
|
||||
|
||||
// Load Power Metrics
|
||||
function loadNodePowerMetrics(nodeId) {
|
||||
loadData(`/api/v1/nodes/${nodeId}/power-metrics`, { time_from: calculateTimeFrom(configStore.powerMetricsTimeRange) }, powerMetrics, true);
|
||||
}
|
||||
|
||||
function loadNode(nodeId) {
|
||||
@ -40,73 +86,6 @@ function loadNode(nodeId) {
|
||||
loadNodePowerMetrics(nodeId);
|
||||
}
|
||||
|
||||
function loadNodeMqttMetrics(nodeId) {
|
||||
state.selectedNodeMqttMetrics = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/mqtt-metrics`)).then((response) => {
|
||||
state.selectedNodeMqttMetrics = response.data.mqtt_metrics;
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeTraceroutes(nodeId) {
|
||||
state.selectedNodeTraceroutes = [];
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/traceroutes`), {
|
||||
params: {
|
||||
count: 5,
|
||||
},
|
||||
}).then((response) => {
|
||||
state.selectedNodeTraceroutes = response.data.traceroutes;
|
||||
}).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeDeviceMetrics(nodeId) {
|
||||
const time = getTimeSpan(configStore.deviceMetricsTimeRange).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/device-metrics`), {
|
||||
params: {
|
||||
time_from: timeFrom,
|
||||
},
|
||||
}).then((response) => {
|
||||
// reverse response, as it's newest to oldest, but we want oldest to newest
|
||||
state.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodeDeviceMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodeEnvironmentMetrics(nodeId) {
|
||||
const time = getTimeSpan(configStore.environmentMetricsTimeRange).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/environment-metrics`), {
|
||||
params: {
|
||||
time_from: timeFrom,
|
||||
},
|
||||
}).then((response) => {
|
||||
// reverse response, as it's newest to oldest, but we want oldest to newest
|
||||
state.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodeEnvironmentMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
function loadNodePowerMetrics(nodeId) {
|
||||
const time = getTimeSpan(configStore.powerMetricsTimeRange).amount
|
||||
const timeFrom = new Date().getTime() - (time * 1000);
|
||||
axios.get(buildPath(`/api/v1/nodes/${nodeId}/power-metrics`), {
|
||||
params: {
|
||||
time_from: timeFrom,
|
||||
},
|
||||
}).then((response) => {
|
||||
// reverse response, as it's newest to oldest, but we want oldest to newest
|
||||
state.selectedNodePowerMetrics = response.data.power_metrics.reverse();
|
||||
}).catch(() => {
|
||||
state.selectedNodePowerMetrics = [];
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.selectedNode,
|
||||
(newValue) => {
|
||||
@ -263,17 +242,17 @@ watch(
|
||||
<!-- lora config -->
|
||||
<LoraConfig :node="state.selectedNode"/>
|
||||
<!-- position -->
|
||||
<Position @show-position-history="showPositionHistory" :node="state.selectedNode"/>
|
||||
<Position @show-position-history="(nodeId) => $emit('showPositionHistory', nodeId)" :node="state.selectedNode"/>
|
||||
<!-- device metrics -->
|
||||
<DeviceMetricsChart :node="state.selectedNode"/>
|
||||
<DeviceMetricsChart :node="state.selectedNode" :data="deviceMetrics"/>
|
||||
<!-- environment metrics -->
|
||||
<EnvironmentMetricsChart :node="state.selectedNode"/>
|
||||
<EnvironmentMetricsChart :node="state.selectedNode" :data="environmentMetrics"/>
|
||||
<!-- power metrics -->
|
||||
<PowerMetricsChart/>
|
||||
<PowerMetricsChart :data="powerMetrics"/>
|
||||
<!-- mqtt -->
|
||||
<MqttHistory />
|
||||
<MqttHistory :data="mqttMetrics"/>
|
||||
<!-- traceroutes -->
|
||||
<Traceroutes @show-trace-route="showTraceRoute"/>
|
||||
<Traceroutes @show-trace-route="(traceroute) => $emit('showTraceRoute', traceroute)" :data="traceroutes"/>
|
||||
<!-- other -->
|
||||
<OtherInfo :node="state.selectedNode"/>
|
||||
<!-- share -->
|
||||
|
@ -1,12 +1,10 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
|
||||
const props = defineProps(['node', 'data']);
|
||||
import { computed } from 'vue';
|
||||
import { state } from '@/store';
|
||||
import MetricsChart from '@/components/Chart/Metrics.vue';
|
||||
|
||||
const chartData = computed(() => {
|
||||
const metrics = state.selectedNodeDeviceMetrics;
|
||||
const metrics = props.data;
|
||||
|
||||
return {
|
||||
labels: metrics.map(m => m.created_at),
|
||||
|
@ -1,16 +1,14 @@
|
||||
<script setup>
|
||||
const props = defineProps(['node']);
|
||||
|
||||
const props = defineProps(['node', 'data']);
|
||||
import { computed } from 'vue';
|
||||
import { state } from '@/store';
|
||||
import { formatTemperature } from '@/utils';
|
||||
import MetricsChart from '@/components/Chart/Metrics.vue';
|
||||
|
||||
// Chart data prep
|
||||
const labels = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.created_at));
|
||||
const temperatureMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.temperature));
|
||||
const relativeHumidityMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.relative_humidity));
|
||||
const barometricPressureMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.barometric_pressure));
|
||||
const labels = computed(() => props.data.map(m => m.created_at));
|
||||
const temperatureMetrics = computed(() => props.data.map(m => m.temperature));
|
||||
const relativeHumidityMetrics = computed(() => props.data.map(m => m.relative_humidity));
|
||||
const barometricPressureMetrics = computed(() => props.data.map(m => m.barometric_pressure));
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: labels.value,
|
||||
@ -109,7 +107,7 @@ const legendData = {
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Temperature</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="state.selectedNode?.temperature">{{ formatTemperature(state.selectedNode.temperature) }}</span>
|
||||
<span v-if="props.node?.temperature">{{ formatTemperature(props.node.temperature) }}</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
</li>
|
||||
@ -117,8 +115,8 @@ const legendData = {
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Relative Humidity</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="state.selectedNode?.relative_humidity">
|
||||
{{ Number(state.selectedNode.relative_humidity).toFixed(0) }}%
|
||||
<span v-if="props.node?.relative_humidity">
|
||||
{{ Number(props.node.relative_humidity).toFixed(0) }}%
|
||||
</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
@ -127,8 +125,8 @@ const legendData = {
|
||||
<li class="flex p-3">
|
||||
<div class="text-sm font-medium text-gray-900">Barometric Pressure</div>
|
||||
<div class="ml-auto text-sm text-gray-700">
|
||||
<span v-if="state.selectedNode?.barometric_pressure">
|
||||
{{ Number(state.selectedNode.barometric_pressure).toFixed(1) }} hPa
|
||||
<span v-if="props.node?.barometric_pressure">
|
||||
{{ Number(props.node.barometric_pressure).toFixed(1) }} hPa
|
||||
</span>
|
||||
<span v-else>???</span>
|
||||
</div>
|
||||
|
@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { state } from '@/store';
|
||||
const props = defineProps(['data']);
|
||||
import moment from 'moment';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const mqttMetrics = computed(() => state.selectedNodeMqttMetrics);
|
||||
const mqttMetrics = computed(() => props.data);
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { state, selectedNodeLatestPowerMetric } from '@/store';
|
||||
import MetricsChart from '@/components/Chart/Metrics.vue';
|
||||
import ChannelData from '@/components/Chart/PowerMetrics/ChannelData.vue';
|
||||
const props = defineProps(['data']);
|
||||
|
||||
const legendData = {
|
||||
'Channel 1': 'bg-blue-500',
|
||||
@ -10,9 +10,14 @@ const legendData = {
|
||||
'Channel 3': 'bg-orange-500',
|
||||
};
|
||||
|
||||
const latestMetric = computed(() => {
|
||||
const [ latest ] = (props.data ?? []).slice(-1);
|
||||
return latest;
|
||||
});
|
||||
|
||||
// Computed dataset for the chart container
|
||||
const chartData = computed(() => {
|
||||
const metrics = state.selectedNodePowerMetrics;
|
||||
const metrics = props.data;
|
||||
const labels = metrics.map(m => m.created_at);
|
||||
|
||||
const colors = ['#3b82f6', '#22c55e', '#f97316'];
|
||||
@ -109,6 +114,6 @@ const chartConfig = {
|
||||
:legendData="legendData"
|
||||
:chartConfig="chartConfig"
|
||||
>
|
||||
<ChannelData v-for="i in [1, 2, 3]" :key="i" :channel="i" />
|
||||
<ChannelData v-for="i in [1, 2, 3]" :key="i" :channel="i" :latest="latestMetric"/>
|
||||
</MetricsChart>
|
||||
</template>
|
@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
const props = defineProps(['data']);
|
||||
const emit = defineEmits(['showTraceRoute']);
|
||||
import moment from 'moment';
|
||||
import { state } from '@/store';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
const mapData = useMapStore();
|
||||
</script>
|
||||
@ -12,8 +12,8 @@ const mapData = useMapStore();
|
||||
<div class="text-sm text-gray-600">Only 5 most recent are shown</div>
|
||||
</div>
|
||||
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
||||
<template v-if="state.selectedNodeTraceroutes.length > 0">
|
||||
<li @click="$emit('showTraceRoute', traceroute)" v-for="traceroute of state.selectedNodeTraceroutes">
|
||||
<template v-if="props.data.length > 0">
|
||||
<li @click="$emit('showTraceRoute', traceroute)" v-for="traceroute of props.data">
|
||||
<div class="relative flex items-center">
|
||||
<div class="block flex-1 px-4 py-2">
|
||||
<div class="relative flex min-w-0 flex-1 items-center">
|
||||
|
@ -1,18 +1,18 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['goTo']);
|
||||
const emit = defineEmits(['goTo', 'dismiss']);
|
||||
const props = defineProps(['data']);
|
||||
import moment from 'moment';
|
||||
import { computed } from 'vue';
|
||||
import { state } from '@/store';
|
||||
import { useMapStore } from '@/stores/mapStore';
|
||||
import NodeEntry from '@/components/Traceroute/NodeEntry.vue';
|
||||
const mapData = useMapStore();
|
||||
// selected node IDs
|
||||
const toNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.to));
|
||||
const fromNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.from));
|
||||
const gatewayNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.gateway_id));
|
||||
const toNode = computed(() => mapData.findNodeById(props.data.to));
|
||||
const fromNode = computed(() => mapData.findNodeById(props.data.from));
|
||||
const gatewayNode = computed(() => mapData.findNodeById(props.data.gateway_id));
|
||||
// pre-resolve the route nodes into an array
|
||||
const routeNodes = computed(() =>
|
||||
state.selectedTraceRoute.route?.map(id => ({
|
||||
props.data.route?.map(id => ({
|
||||
id,
|
||||
node: mapData.findNodeById(id),
|
||||
})) ?? []
|
||||
@ -29,7 +29,7 @@ const routeNodes = computed(() =>
|
||||
leave-active-class="transition-opacity duration-300 ease-linear"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-show="state.selectedTraceRoute != null" @click="state.selectedTraceRoute = null" class="fixed inset-0 bg-gray-900/75"></div>
|
||||
<div v-show="props.data != null" @click="$emit('dismiss')" class="fixed inset-0 bg-gray-900/75"></div>
|
||||
</transition>
|
||||
|
||||
<!-- sidebar -->
|
||||
@ -40,19 +40,19 @@ const routeNodes = computed(() =>
|
||||
leave-active-class="transition duration-300 ease-in-out transform"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="-translate-x-full">
|
||||
<div v-show="state.selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0">
|
||||
<div v-if="state.selectedTraceRoute != null" class="w-screen h-full max-w-md overflow-hidden">
|
||||
<div v-show="props.data != null" class="fixed top-0 left-0 bottom-0">
|
||||
<div v-if="props.data != null" class="w-screen h-full max-w-md overflow-hidden">
|
||||
<div class="flex h-full flex-col bg-white shadow-xl">
|
||||
|
||||
<!-- slideover header -->
|
||||
<div class="p-2 border-b border-gray-200 shadow-sm">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="font-bold">Traceroute #{{ state.selectedTraceRoute.id }}</h2>
|
||||
<h3 class="text-sm">{{ moment(new Date(state.selectedTraceRoute.updated_at)).fromNow() }} - {{ state.selectedTraceRoute.route.length }} hops {{ state.selectedTraceRoute.channel_id ? `on ${state.selectedTraceRoute.channel_id}` : '' }}</h3>
|
||||
<h2 class="font-bold">Traceroute #{{ props.data.id }}</h2>
|
||||
<h3 class="text-sm">{{ moment(new Date(props.data.updated_at)).fromNow() }} - {{ props.data.route.length }} hops {{ props.data.channel_id ? `on ${props.data.channel_id}` : '' }}</h3>
|
||||
</div>
|
||||
<div class="my-auto ml-3 flex h-7 items-center">
|
||||
<a href="javascript:void(0)" class="rounded-full" @click="state.selectedTraceRoute = null">
|
||||
<a href="javascript:void(0)" class="rounded-full" @click="$emit('dismiss')">
|
||||
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@ -83,7 +83,7 @@ const routeNodes = computed(() =>
|
||||
<div>
|
||||
<div class="bg-gray-200 p-2 font-semibold">Raw Data</div>
|
||||
<div class="text-sm text-gray-700">
|
||||
<pre class="bg-gray-100 rounded-sm p-2 overflow-x-auto">{{ JSON.stringify(state.selectedTraceRoute, null, 4) }}</pre>
|
||||
<pre class="bg-gray-100 rounded-sm p-2 overflow-x-auto">{{ JSON.stringify(props.data, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,18 +1,14 @@
|
||||
import { reactive, computed } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export const state = reactive({
|
||||
// state
|
||||
searchText: '', // moved to ui store, maybe should not be there though?
|
||||
selectedNodeOutlineCircle: null,
|
||||
selectedNodeMqttMetrics: [],
|
||||
selectedNodeTraceroutes: [],
|
||||
selectedNodeDeviceMetrics: [],
|
||||
selectedNodePowerMetrics: [],
|
||||
selectedNodeEnvironmentMetrics: [],
|
||||
|
||||
// new selected node stuff
|
||||
// new selected node/data stuff
|
||||
positionHistoryNode: null,
|
||||
neighborsNode: null,
|
||||
traceRouteData: null,
|
||||
|
||||
// new modal specific stuff
|
||||
neighborsModalType: null,
|
||||
@ -21,8 +17,3 @@ export const state = reactive({
|
||||
positionHistoryDateTimeTo: null,
|
||||
positionHistoryDateTimeFrom: null,
|
||||
});
|
||||
|
||||
export const selectedNodeLatestPowerMetric = computed(() => {
|
||||
const [ latestPowerMetric ] = state.selectedNodePowerMetrics.slice(-1);
|
||||
return latestPowerMetric;
|
||||
});
|
@ -469,6 +469,14 @@ function goToNode(id, animate, zoom){
|
||||
|
||||
}
|
||||
|
||||
function showTraceRoute(traceroute) {
|
||||
state.traceRouteData = traceroute;
|
||||
}
|
||||
|
||||
function resetTraceRoute() {
|
||||
state.traceRouteData = null;
|
||||
}
|
||||
|
||||
function onSearchResultNodeClick(node) {
|
||||
// clear search
|
||||
ui.search('');
|
||||
@ -967,10 +975,10 @@ onMounted(() => {
|
||||
<InfoModal />
|
||||
<HardwareModelList />
|
||||
<Settings />
|
||||
<NodeInfo @show-position-history="showNodePositionHistory"/>
|
||||
<NodeInfo @show-position-history="showNodePositionHistory" @show-trace-route="showTraceRoute"/>
|
||||
<NodeNeighborsModal @dismiss="resetNodeNeighbors" :node="state.neighborsNode"/>
|
||||
<NodePositionHistoryModal @dismiss="resetPositionHistory" :node="state.positionHistoryNode"/>
|
||||
<TracerouteInfo @go-to="goToNode" />
|
||||
<TracerouteInfo @go-to="goToNode" @dismiss="resetTraceRoute" :data="state.traceRouteData"/>
|
||||
<Teleport v-if="popupTarget && selectedNode" :to="popupTarget">
|
||||
<NodeTooltip :node="selectedNode" @show-neighbors="showNodeNeighbors"/>
|
||||
</Teleport>
|
||||
|
Reference in New Issue
Block a user