Monitor Multiple Computers Remotely Using ASP.NET MVC

A while ago I blogged about an optimized method of capturing remote desktop views. The method can be used to get a decent remote desktop viewing experience. This was developed a bit here and here. In the later link, I was exploring the potential to monitor multiple remote desktops. This blog will explore monitoring multiple remote desktops using an ASP.NET MVC web page. Here is a screen shot of the web page monitoring 9 computers and a screen cast:

image

The current application could easily monitor 20-30 computers (and maybe more). With some additional features (chat for instance) this application could be used in a learning center or a computer lab. It could also provide monitoring of younger computer users in a home network setting. The architecture selected does not have any firewall limitations so you can monitor computers over the internet as long as the server (more later) is hosted in the internet zone.

Architecture

The following diagram shows the architecture at a high level:

image

There are a number of remote users that can connect to a server. In the diagram above the users are all shown on the same network. That is not a requirement. They can all be behind their own firewall. They only need to be able to connect to the server. The server is an ASP.NET application that hosts both web services and web pages built using the MVC framework. The computers of the remote users host an application called a Monitor Agent (or Agent). The Monitoring Station will browse to the web pages hosted by the server to monitor the remote computers.

Agent to Server Communication

The Agents and the Server send messages via HTTP traffic as shown below:

image

These message are sent using the polling based message bus that I covered in a recent post. As discussed in that post, all the messages derive from a BaseMessage. The following messages are currently supported by the application:

image

  • Empty Message – Sent when no other messages are available to deliver. (bidirectional)
  • Error Message – Delivers error information. (bidirectional)
  • Register Message – Used to register an agent. (from the agent to the server)
  • Thumbnail Message – Used to configure thumbnail size. (server to agent)
  • Poll Mode Message – Used to set the polling mode of an agent (server to agent)
  • Image No Change Message – Sent when no pixels have changed on the agent (agent to server).
  • Image Data Message – Delivers updated screen shot information (agent to server).
  • Poll Interval Message – Used to dynamically set the polling interval (server to agent).

Most of these are straight-forward. Two deserve additional information.

The Image Data Message is used to transport screen shot information. This information can either be a full screen shot or a partial of just the pixels that have changed (actually it is a rectangle that encompasses the changed pixels). This greatly reduces the traffic from the Agents to the Server. Reducing this traffic is key to being able to scale the number of Agents up.

This MVC web page can switch from an overview mode (can see all desktops) to a details mode (can see one desktop). In details mode the desktop being detailed is switched to “full image mode” by sending a Poll Mode Message with PollMode = PostFullSize and the polling rate is increased by sending a Poll Interval Message.

This message framework can easily be expanded to add new features. For instance a Chat Message could be used to transport chat content from the agent to the server. This content could then be either shared with only the Monitoring Station or with all other agents.

Monitor Agent

The Monitor Agent in this case is a simple WinForms application. This application does four things:

  1. Creates an instance of the Agent class injecting in a unique ID.
  2. Registers for the OnPollEvent of the Agent object. This allows the UI to update with the latest information.
  3. Starts the Agent object running by calling the Start method.
  4. Stops the Agent just before closing.

The WinForm code is provided below:

public partial class Form1 : Form
{
    private readonly Guid _agentId = Guid.NewGuid();
    private readonly Agent _agent;

    /// <summary>
    /// Initializes a new instance of the <see cref="Form1"/> class.
    /// </summary>
    public Form1()
    {
        InitializeComponent();

        try
        {
            _agent = new Agent(_agentId);
            _agent.OnPollEvent += Agent_OnPollEvent;
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.ToString());
        }

        if(!_agent.Start())
        {
            MessageBox.Show("Unable to start the agent.");
        }
    }

    protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
    {
        if(!_agent.Stop())
        {
            MessageBox.Show("Unable to stop the agent.");
        }
        base.OnClosing(e);
    }

    private void Agent_OnPollEvent(Agent agent)
    {
        if (IsHandleCreated && InvokeRequired)
        {
            BeginInvoke(new MethodInvoker(delegate
                    {
                           // Update the UI
                           //
                           fullImage.Text = agent.ImageFullSize.Width + "x" + agent.ImageFullSize.Height;
                           double percent = 100.0*agent.PartialImageSize.Width*
                                            agent.PartialImageSize.Height/
                                            (agent.ImageFullSize.Width*agent.ImageFullSize.Height);
                           partialImage.Text = agent.PartialImageSize.Width + "x" +
                                               agent.PartialImageSize.Height + " [" +
                                               percent.ToString("0.0") + "%]";
                           lastUpdate.Text = agent.LastPoll.ToLongTimeString();
                           pollMode.Text = agent.PollMode;
                           pollInterval.Text = agent.PollInterval.ToString();
                           Text = "Monitor Agent: " + _agentId;

                       }));
        }


    }
}

The real work on the user computers is done by the Agent object. The constructor takes a Guid as a parameter. This Guid is the unique identifier for each Monitor Agent. In the constructor an instance of the web services proxy is created. This will throw exceptions if there are any connectivity issues. Those are being caught by the WinForms app and displayed to the user. Also created in when you instantiate this object is a System.Timers.Timer object. The Elapsed event is used to trigger a polling call to the server. The only other public methods are a start and stop methods. These methods turn the timer on and off. The following code shows the public methods of the Agent object.

public Agent(Guid agentId)
{
    _agentId = agentId;
    _webService = new CommandHandlerSoapClient();
    _timer.Elapsed += Poll;
}

public bool Start()
{
    lock(_startStopLock)
    {
        if(_timer.Enabled)
        {
            return false;
        }
        _timer.Enabled = true;
    }
    return true;
}

public bool Stop()
{
    lock(_startStopLock)
    {
        if(!_timer.Enabled)
        {
            return false;
        }
        _timer.Enabled = false;
    }
    return true;
}

The timer Elapsed event determines the rate at which the Agent exchanges messages with the Server. This event is shown below:

private void Poll(object sender, System.Timers.ElapsedEventArgs e)
{
    switch (_pollMode)
    {
        case PollModeOptions.Register:
            {
                Register();
                break;
            }
        case PollModeOptions.Poll:
            {
                CheckForMessages();
                break;
            }
        case PollModeOptions.Thumb:
        case PollModeOptions.Image:
            {
                PostImage();
                break;
            }
    }
    LastPoll = DateTime.Now;

    if(OnPollEvent!=null)
    {
        OnPollEvent(this);
    }
}

Notice the agent can be put into various modes. The following are the currently supported modes:

  • Register – This forces the agent to register with the server. The agent starts in this mode. The server can also force an agent to re-register by setting the agent into this mode. At this point there is no authentication / authorization of the Agent. That functionality can be inserted into the registration process.
  • CheckForMessages – This mode does not send any content (Empty Message only) to the server. However, messages can still be received by the agent. This is essentially a ‘stand-by mode’.
  • Thumb – In this mode, the agent will send thumbnails of the full size screen to the server. These thumbnails are used in ‘overview’ mode when monitoring multiple desktops. The thumbnails allow a reduction in data that is transported. This mode also support sending partial screens (only the region that has changed in the thumbnail). Further reduction in size is achieved by sending a PNG data instead of BMP data over the wire.
  • Image – In this mode, the agent will send full size screen images. Everything else is the same as Thumb mode.

The poll mode is set by the server by sending a Poll Mode Message to the agent (more in a bit). Depending on the mode, the agent then calls one of three methods. The Register method is shown below:

private void Register()
{
    // Register with the server.
    //
    _previousScreenShot = null;
    RegisterMessage registerMessage = new RegisterMessage
    {
        AgentId = _agentId,
        ComputerName = Environment.MachineName,
        TimeStamp = DateTime.Now,
        UserInfo = Environment.UserDomainName + "\"
                        + Environment.UserName
    };
    PostMessage(registerMessage);
}

This method posts a RegisterMessage back to the server. The following CheckForMessages method sends an EmptyMessage back to the server:

private void CheckForMessages()
{
    EmptyMessage emptyMessage = new EmptyMessage { AgentId = _agentId, TimeStamp = DateTime.Now };
    PostMessage(emptyMessage);
}

The PostImage method could stand a bit of refactoring and is shown in all of its glory below:

private void PostImage()
{
    // Capture a new screen image.
    //
    Bitmap screenShot = ScreenCapture.ScreenCapture.CaptureDesktop();

    if (_pollMode == PollModeOptions.Thumb)
    {
        // Scale the image
        //
        screenShot = screenShot.Resize(
            RotateFlipType.RotateNoneFlipNone,
            _thumbNailSize.Width,
            _thumbNailSize.Height);
    }
    ImageFullSize = new Size(screenShot.Width, screenShot.Height);

    // Compare to the previous bitmap to determine the
    //    bounding box for changed pixels. This helps minimize
    //    the number of bytes that have to send.
    //
    Rectangle rect = ScreenCapture.ScreenCapture.GetBoundingBoxForChanges(_previousScreenShot, screenShot);
    PartialImageSize = new Size(rect.Width, rect.Height);
    _previousScreenShot = screenShot;
    if (rect != Rectangle.Empty)
    {
        // Create an initialize an image data message.
        //
        ImageDataMessage imageDataMessage = new ImageDataMessage
        {
            AgentId = _agentId,
            TimeStamp = DateTime.Now,
            IsThumbnail = (_pollMode == PollModeOptions.Thumb) ? true : false,
            FullWidth = screenShot.Width,
            FullHeight = screenShot.Height
        };

        if (rect.Width == screenShot.Width &&
            rect.Height == screenShot.Height)
        {
            // Post the whole screen
            //
            imageDataMessage.ImageData = screenShot.ConvertToByteArray();
            imageDataMessage.IsPartial = false;
            imageDataMessage.X = 0;
            imageDataMessage.Y = 0;
        }
        else
        {
            // Post only the part of the screen that has changed.
            //
            Bitmap changedPart = Images.Crop(screenShot, rect);
            imageDataMessage.ImageData = changedPart.ConvertToByteArray();
            imageDataMessage.IsPartial = true;
            imageDataMessage.X = rect.X;
            imageDataMessage.Y = rect.Y;
        }

        PostMessage(imageDataMessage);
    }
    else
    {
        ImageNoChangeMessage imageNoChangeMessage = new ImageNoChangeMessage
        {
            AgentId = _agentId,
            TimeStamp = DateTime.Now
        };
        PostMessage(imageNoChangeMessage);
    }
}

Most of the complexity is code that collects a screenshot, compares it to the previous one to determine the changed region, and extracts the changed pixels. The guts of this is pretty much the same as what I posted previously. So go there to learn the screen capture details.

The PostMessage method was talked about in the previous post on polling based message bus for ASP.NET web services. This method simply uses our proxy to the web services to send the message to the server and receives an array of messages back. These are then handled by one of the following methods:

private void ProcessPollModeMessage(PollModeMessage message)
{
    if (message.PollMode == WebService.PollModeOptions.PollForCommands)
    {
        _pollMode = PollModeOptions.Poll;
    }
    else if (message.PollMode == WebService.PollModeOptions.PostFullsize)
    {
        _pollMode = PollModeOptions.Image;
    }
    else if (message.PollMode == WebService.PollModeOptions.PostThumbnail)
    {
        _pollMode = PollModeOptions.Thumb;
    }
    else if (message.PollMode == WebService.PollModeOptions.Register)
    {
        _pollMode = PollModeOptions.Register;
    }
}

private void ProcessThumbsizeMessage(ThumbsizeMessage message)
{
    Size size = new Size(message.Width, message.Height);
    _thumbNailSize = size;
    return;
}

private void ProcessPollIntervalMessage(PollIntervalMessage message)
{
    _timer.Enabled = false;
    _timer.Interval = message.IntervalInMilliseconds;
    _timer.Enabled = true;
    return;
}

All three of these are pretty straight-forward. The importance of the message bus is that it provides an extensible platform for communication between the Agent and the Server.

This particular implementation of the Agent is a WinForm application. This application could easily be ported to a Windows Service. A Windows Service would allow an administrator to install the application and have it run in the background.

Monitoring Station to Server Communication

The Monitor Station can be any computer that has a web browser and has access to the ASP.NET server hosting the MVC web application. I have monitored Agents on my workstation, wife’s computer, netbook, and iPhone without any issues.

The data is stored in memory on the web server. I was concerned that the disk I/O would degrade performance of the application. In addition, I do not think the amount of data required to support 20-30 computers was too much. To further scale up either additional server memory (RAM) would be necessary or disk I/O could be introduced. For each registered Agent an instance of the following class is saved to the application cache:

public class AgentData
{
    public Guid AgentId { get; set; }
    public string ComputerName { get; set; }
    public string UserName { get; set; }
    public Bitmap ThumbNail { get; set; }
    public DateTime LastUpdate { get; set; }

    public List<BaseMessage> MessagesToDeliver { get; set; }

    public AgentData()
    {
        MessagesToDeliver = new List<BaseMessage>();
    }
}

By far the largest consumer of memory is the screenshot. Most of the time you will have all the Agents in thumbnail mode. Using the default thumbnail size (300×225 pixels) each Agent will have 0.26MB of RAM in the image. So 30 Agents would require around 8MB. This is very acceptable.

Monitoring Station

The application uses the ASP.NET MVC framework. I put a thin wrapper around the data above and created a HomeViewModel model object for use with my Home Controller and Home View. The following code is my HomeViewModel and the Home Controller:

public class HomeViewModel
{
    public ReadOnlyCollection<AgentData> Agents { get; set; }

    public HomeViewModel()
    {
        Agents = ModelHelpers.Agents;
    }
}

public class HomeController : Controller
{
    public ActionResult Index()
    {
        HomeViewModel hvm = new HomeViewModel();
        return View(hvm);
    }

    public ActionResult ClearAgentData()
    {
        ModelHelpers.ClearData();
        return RedirectToAction("Index");
    }

    public ActionResult About()
    {
        return View();
    }
}

These are both very thin. The Home Controller really has two interesting methods: Index and ClearAgentData. The Index action method displays the web page showing all the remote desktops. The ClearAgentData clears the Agent data from the application cache, sets all the Agents in Register mode, and the redirects to the Index action method.

The following important HTML is the Index view of the Home controller:

<%= Html.ActionLink("Clear Agent Data", "ClearAgentData") %>

<h2>Monitored Computers</h2>

<div id="overlay">
    <div>
        <span>
            <a id="overlay-close" href="#">close</a>
        </span>
        <img id="detail-img" height="100%" src="#" alt="details" />
    </div>
</div>

<div id="screens">
    <h3>Screen Shots</h3>
    <% foreach (var item in Model.Agents) { %>

        <span class="screen_shot">
            <img src="<%= Url.Action("Thumb", "Image", new { agentId = item.AgentId }) %>" alt="screen shot" />
            <% Html.RenderPartial("ComputerThumbView", item); %>
            <a href="#" onclick="return showDetails('<%= item.AgentId %>');">details</a>
        </span>

    <% } %>
</div>

At the top of this markup is the anchor to clear the Agent data. Then a div to hold the contents of a “details” view. The “details” section is hidden with some JavaScript (details below). The last div is used to display the thumbnails of the remote computers.

The img for the screen shots (and the img for the overlay) both use the MVC controller for serving up images that I posted about previously. The code for that post was developed to support this application and all 18 lines are used verbatim.

Here is the partial view that I use to render textual data about each Agent:

<div class="data">
    <span class="info"><%= Html.Encode(Model.UserName) %></span>
    <span class="info"><%= Html.Encode(Model.ComputerName)%></span>
    <span class="info"><%= Html.Encode(Model.AgentId)%></span>
    <span class="info<%= Model.LastUpdate<DateTime.Now.AddMinutes(-1.0)?" offline":"" %>"><%= Html.Encode(Model.LastUpdate.ToString())%></span>
</div>

This was put in a partial view so that it could be called by JavaScript and updated asynchronously.

Speaking of JavaScript here is the complete listing for the Index view for the Home Controller:

<script language="javascript" type="text/javascript">
    var intervalId;
    var stopDetails = true;
    $(document).ready(function() {
        $("#overlay").hide();
        $("#overlay-close").click(hideDetails);
        intervalId = setInterval(reloadScreen, 1000);

        $("#detail-img").load(function() {
            if (!stopDetails) {
                refreshDetails();
            }
        });
    });

    var detailsId;
    function showDetails(id) {
        // stop updating the other images
        clearInterval(intervalId);

        // set the url of the image
        detailsId = id;

        // set agent to details mode
        var url = "/Monitor/Image/DetailsMode?agentId=" + detailsId;
        $.getJSON(url, null, null);

        // start updating the details image
        stopDetails = false;
        refreshDetails();

        // show the overlay
        $("#overlay").show();
        return false;
    }

    function refreshDetails() {
        var now = new Date();
        var url = "/Monitor/Image/Thumb?agentId=" + detailsId + "&t=" + now.getTime();
        $("#detail-img").attr("src", url);
    }

    function hideDetails() {
        // stop refreshing details
        stopDetails = true;

        // hide the overlay
        $("#overlay").hide();

        // set agent to details mode
        var url = "/Monitor/Image/ThumbMode?agentId=" + detailsId;
        $.getJSON(url, null, null);
        detailsId = null;

        // start the interval for the thumbs
        intervalId = setInterval(reloadScreen, 1000);

        return false;
    }

    var currentIndex = 0;
    function reloadScreen() {
        var screens = $("span.screen_shot");
        if (screens == undefined || screens == null || screens.size() == 0) {
            return;
        }
        var span = screens[currentIndex];
        currentIndex++;
        if (currentIndex >= screens.size()) {
            currentIndex = 0;
        }
        var url = $(span).find("img").attr("src");
        var parts = url.split("&");
        var urlData = parts[0].replace("Thumb", "ThumbHtml");
        $(span).find("div.data").load(urlData);
        var now = new Date();
        var urlImg = parts[0] + "&t=" + now.getTime();
        $(span).find("img").attr("src", urlImg);
    }
</script>

This page used jQuery (don’t all pages?). The jQuery ready event is used to do the following:

  • hide the detail view overlay div
  • add a click handler to the ‘close overlay’ anchor
  • start an interval timer that updates the thumbnails (more below)
  • add a load event to the detail img element (more below)

The reloadScreen function is called in a timer once per second to refresh one of the thumbnails on the page. Refreshing them all at once does not work. You will run into limitation on the number of concurrent requests for the browser. The code in the reloadScreen function sequentially updates the thumbnail of each Agent and then repeats for ever. The image data is updated using the trick of adding a unique parameter to the URL to force the browser to fetch it for us. Also buried in this function is a jQuery load call to the partial view to update the textual data.

The ‘show details’ anchor is hooked up (just noticed in HTML…tsk…tsk…tsk) to call the ‘showDetails’ function. This function stops the interval timer that is updating the thumbnails, sets the Agent of interest in ‘Details Mode’ (full size image / faster polling rate), and then calls ‘refreshDetails’. The ‘refreshDetails’ function updates the details img. Remember that this img had the ‘refreshDetails’ function tied to the ‘load’ event. So in effect, this image will refresh, immediately refresh, and repeat until the process is stopped by closing the details view. The ‘hideDetails’ function stops updating the details view, sets the Agent back into ‘Thumb Mode’ (thumbnail image / slower polling rate) and restarts the interval that updates the thumbs.

Summary

Sorry for the long post. I tried to cover many of the fine points in previous posts leading up to this one. At this point, I have a functional application that allows review of multiple remote desktops. I have a few more things that I want to do with this application before I post the code. As always any comments are welcome.

Comments
  1. asd123
    • Carlos
  2. Bob Cravens
    • sandip prajapti
      • sandip prajapti
    • Sandeep
    • musti
  3. Bob Cravens
  4. Gman
  5. tarun
  6. guna sekar
    • rcravens
  7. gunasekar
  8. pradip chavhan
  9. pradip chavhan
  10. Sulabh Agrawal
  11. P Mishra
  12. Ianos
  13. praneeth
  14. niranjan
  15. watz
  16. Troy Makaro
  17. Sandeep
  18. Joe Cosmides
  19. minisha
  20. Antony Parakka

Leave a Reply to Ianos Cancel reply

Your email address will not be published. Required fields are marked *

*