Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WinForms

Service DashBored

4.75/5 (8 votes)
5 Jan 2009CC (ASA 2.5)7 min read 56.5K   1.1K  
A lightweight desktop application to help keep you aware of server and application availability across your network.
ServiceDashBored_src

Introduction

Is your application server running? You'd better go catch it! This lightweight desktop application will help keep you aware of server availability across your network. Using .NET's networking and management features, such as the WMI classes in System.Management and System.Net.WebRequest, makes it simple to interact with networked machines and services to ensure they're operating correctly. It is easily extensible to define new service types. Configuration is also quick to do using a dependency injection library (Castle Windsor).

Background

There are several existing solutions for monitoring application availability, some open source and some commercial, but they all seem to have a common hindrance - a rather large investment of time and effort for installation and maintenance. This is perfectly acceptable for large or mission critical operations, but if the goal is smaller - to know if a development service is currently running, for instance - then these larger apps are overkill. With Service DashBored, my mission was to create a copy-install application which could quickly inform me when some piece of my development environment wasn't feeling well.

Using the Code

The first step is to define an interface to represent a service or application. Conceptually these will be referred to as "Endpoints" which each must define several properties:

  • Name - So we know what to call it.
  • Status - Its current status - this is a standard enumeration containing:
    • Up - The endpoint is available and operating correctly.
    • Unreachable - Unable to complete a connection with the endpoint.
    • Error - The endpoint reports an error state.
    • Timeout - A connection request never completes within a given timeframe.
    • Unknown - Default status on instantiation before the first status update.
  • StatusDescription - A text description of the endpoint including any specific error codes.
C#
public interface IEndpoint
{
  string Name { get; }
  Status GetStatus();
  string StatusDescription { get; }
}

public enum Status
{
  Unknown,
  Up,
  Timeout,
  Unreachable,
  Error
}

Status is defined as a Get method - many of the implementors do quite a bit of work so it's better to have it this way.

So, this is the right kind of information that will be needed for querying an application, but something important is missing - how does it know where the service or application lives at? Each implementor will need to define these properties for itself. Included in Service DashBored are several existing endpoint types which can be used right away (with a bit of configuration).

HTTP Endpoint

If my webserver is down, I want to know right away. This endpoint will send a request to the given URI and simply see if it is responding.

C#
public class HttpEndpoint : IEndpoint
{
  public Uri Uri { get; set; }
  
  public virtual Status GetStatus()
  {
    try
    {
      var request = WebRequest.Create(Uri);

      using (var response = (HttpWebResponse) request.GetResponse())
      {
        StatusDescription = response.StatusDescription;
        return response.StatusCode == HttpStatusCode.OK
                 ? Status.Up
                 : Status.Unreachable;
      }
    }
    catch (WebException ex)
    {
      StatusDescription = ex.Message;

      return ex.Status == WebExceptionStatus.Timeout
               ? Status.Timeout
               : Status.Unreachable;
    }
    catch (Exception ex)
    {
      StatusDescription = ex.Message;

      return Status.Error;
    }
  }
}

Here is an example of how this endpoint can be configured for Service DashBored (inside app.config) using Windsor Container from Castle Project.

XML
<component id="http.google.endpoint" service="Endpoint.IEndpoint,
  Endpoint" type="Endpoint.HttpEndpoint, Endpoint">
  <parameters>
    <Name>Http - Google</Name>
    <UriString>http://www.google.com</UriString>
  </parameters>
</component>

That's it! All of the endpoints are configured the same way - just by adding the appropriate elements inside the <parameters> block.

HTML Endpoint

This class extends HTTP endpoint with the ability to scan and validate the content returned from the URI - which is assumed to be HTML formatted.

C#
public class HtmlEndpoint : HttpEndpoint
{
  private readonly XpathEndpointUtility _xpathEndpointUtility = 
						new XpathEndpointUtility();

  public string XpathQuery
  {
    get { return _xpathEndpointUtility.XpathQuery; }
    set { _xpathEndpointUtility.XpathQuery = value; }
  }

  public string XpathNamespaces
  {
    get { return _xpathEndpointUtility.XpathNamespaces; }
    set { _xpathEndpointUtility.XpathNamespaces = value; }
  }

  public string ExpectedXpathResult
  {
    get { return _xpathEndpointUtility.ExpectedXpathResult; }
    set { _xpathEndpointUtility.ExpectedXpathResult = value; }
  }
  
  public override Status GetStatus()
  {
    var baseStatus = base.GetStatus();
    if (baseStatus != Status.Up)
      return baseStatus;

    XmlDocument xml;

    try
    {
      xml = getHtmlXml(Uri, RequestContent);
    }
    catch (Exception ex)
    {
      StatusDescription = ex.Message;
      return Status.Unreachable;
    }

    var status = _xpathEndpointUtility.GetStatus(xml);
    StatusDescription = _xpathEndpointUtility.StatusDescription;
    return status;
  }
}

The HTML-to-XML conversion in getHtmlXml(Uri, RequestContent) is done using the bundled HTML Agility Pack. You can give it malformed HTML and it will return a compliant XML document, ready for an XPATH query.

As for running the XPATH query, this functionality has been moved into a utility class named XpathEndpointUtility. It simply performs SelectNodes(XpathQuery) on the response XmlDocument and then verifies that the resulting value is equivalent to ExpectedXpathResult.

C#
internal class XpathEndpointUtility
{
  public Status GetStatus(XmlDocument xml)
  {
    XmlNodeList nodes;
    if (_namespaceToUri != null)
    {
      var nsMgr = new XmlNamespaceManager(xml.NameTable);
      foreach (var namespaceToUri in _namespaceToUri)
      {
        nsMgr.AddNamespace(namespaceToUri.Key, namespaceToUri.Value);
      }

      nodes = xml.SelectNodes(XpathQuery, nsMgr);
    }
    else
      nodes = xml.SelectNodes(XpathQuery);

    //xml.Save(@"c:\serviceDashBoredResponse.xml");

    // verify response has expected value
    if (nodes == null || nodes.Count == 0)
    {
      StatusDescription = "Couldn't find expected value in response";
      return Status.Error;
    }
    var value = nodes[0].Value.Trim();
    if (value != ExpectedXpathResult)
    {
      StatusDescription = String.Format("Result was: '{0}', was expecting '{1}'"
                      , value
                      , ExpectedXpathResult);
      return Status.Error;
    }
    return Status.Up;
  }
}

Getting an XPATH query to work for a web document can be tricky, especially when the document is large. Microsoft has released a nice tool which can help - XML Notepad 2007. By navigating to the particular element to be verified, and then using the "Find" function, an XPATH query will be automatically generated along with any namespace mappings.

ServiceDashBored_XMLNotepad.png

SOAP Endpoint

Like the HTML endpoint, this class extends HTTP endpoint. The difference here is the ability to send a SOAP request and embed a SOAPAction in the header data. The result is then validated via XPATH.

C#
public class SoapEndpoint : HttpEndpoint
{
  private readonly XpathEndpointUtility _xpathEndpointUtility = 
						new XpathEndpointUtility();

  public string XpathQuery
  {
    get { return _xpathEndpointUtility.XpathQuery; }
    set { _xpathEndpointUtility.XpathQuery = value; }
  }

  public string XpathNamespaces
  {
    get { return _xpathEndpointUtility.XpathNamespaces; }
    set { _xpathEndpointUtility.XpathNamespaces = value; }
  }

  public string ExpectedXpathResult
  {
    get { return _xpathEndpointUtility.ExpectedXpathResult; }
    set { _xpathEndpointUtility.ExpectedXpathResult = value; }
  }

  public string SoapAction { get; set; }
  public string SoapRequest { get; set; }
  
  public override Status GetStatus()
  {
    var baseStatus = base.GetStatus();
    if (baseStatus != Status.Up) 	// if we can't even get to the webserver, 
				// no need to try and call a WS.
      return baseStatus;

    var xml = new XmlDocument();

    try
    {
      var headers = new Dictionary<string, string> 
				{{"SOAPAction", SoapAction}};

      // we can assume that the response is well formatted XML
      xml.LoadXml(GetUrlContent(Uri, "text/xml; charset=utf-8", 
					SoapRequest, headers));
    }
    catch (Exception ex)
    {
      StatusDescription = ex.Message;
      return Status.Unreachable;
    }

    Status status = _xpathEndpointUtility.GetStatus(xml);
    StatusDescription = _xpathEndpointUtility.StatusDescription;
    return status;
  }
}

To configure a SOAP endpoint, several pieces of data are necessary - namely the SOAPAction and the request formatted in a SOAP envelope. I've found these are easily attained by using the web service testing tool soapUI. By creating a new project using service's WSDL, soapUI will automatically mock up SOAP requests for each of the defined operations. Just copy these values over to the configuration and it's ready to go.

Windows Service Endpoint

This endpoint will verify the configured service endpoint is in a "Running" state. Just give it the service name and the machine name.

C#
public class WindowsServiceEndpoint : IEndpoint
{
  public string ServiceName { get; set; }
  public string MachineName { get; set; }

  public Status GetStatus()
  {
    ServiceController myservice;
    try
    {
      myservice = !string.IsNullOrEmpty(MachineName) 
        ? new ServiceController(ServiceName, MachineName) 
        : new ServiceController(ServiceName);
    }
    catch (Exception ex)
    {
      StatusDescription = ex.Message;
      return Status.Unreachable;
    }

    try
    {
      switch (myservice.Status)
      {
        case ServiceControllerStatus.Running:
          StatusDescription = "Running";
          return Status.Up;
        default:
          StatusDescription = myservice.Status.ToString();
          return Status.Error;
      }
    }
    catch (Exception ex)
    {
      StatusDescription = ex.Message;
      return Status.Error;
    }
  }
}

WMI Endpoint

Windows Management Instrumentation (WMI) is a technology which allows management software to monitor (and even control) networked resources. This endpoint can be configured to issue a WQL query to a machine on the network and validate the result. The validation is a little more involved than any of the other endpoints - in addition to being able to signal that an error occurs for a specific value, it can also react to thresholds on numeric values. For instance, the status can be set to "Error" when the result of a "Free Drive Space" query results in a value lower than 1GB.

C#
public class WmiEndpoint : IEndpoint
{
  public string MachineName { get; set; }
  public string ObjectQueryString { get; set; }
  public string ConnectionUsername { get; set; }
  public string ConnectionPassword { get; set; }
  public string ResultPropertyName { get; set; }
  public string ErrorResult { get; set; }
  public string UpResult { get; set; }
  public double MinimumThreshold { get; set; }
  public double MaximumThreshold { get; set; }

  public WmiEndpoint()
  {
    MinimumThreshold = double.MinValue;
    MaximumThreshold = double.MaxValue;
  }

  public Status GetStatus()
  {
    var connectionOptions = new ConnectionOptions();
    if (!string.IsNullOrEmpty(ConnectionUsername) && 
			!string.IsNullOrEmpty(ConnectionPassword))
    {
      connectionOptions.Username = ConnectionUsername;
      connectionOptions.Password = ConnectionPassword;
    }

    ManagementScope managementScope;
    try
    {
      managementScope = new ManagementScope(MachineName, connectionOptions);
    }
    catch (Exception e)
    {
      StatusDescription 
        = string.Format("Management Scope for MachineName \"{1}\" Failed : \"{0}\""
          , e.Message
          , MachineName);
      return Status.Error;
    }

    ObjectQuery objectQuery;
    try
    {
      objectQuery = new ObjectQuery(ObjectQueryString);
    }
    catch (Exception e)
    {
      StatusDescription 
        = string.Format("ObjectQuery initialization \"{1}\" Failed : \"{0}\""
          , e.Message
          , ObjectQueryString);
      return Status.Error;
    }

    ManagementObjectSearcher results;
    try
    {
      //Execute the query 
      results = new ManagementObjectSearcher(managementScope, objectQuery);
    }
    catch (Exception e)
    {
      StatusDescription = string.Format("Building Query failed : \"{0}\"", e.Message);
      return Status.Error;
    }

    ManagementObjectCollection managementObjectCollection;
    try
    {
      //Get the results
      managementObjectCollection = results.Get();

      if (managementObjectCollection.Count == 0)
      {
        StatusDescription = string.Format("Query returned 0 results : 
					\"{0}\"", ObjectQueryString);
        return Status.Error;
      }
    }
    catch (Exception ex)
    {
      StatusDescription = "Error retrieving results. Exception: " + ex.Message;
      return Status.Unreachable;
    }

    ManagementObject firstResult = null;

    //take only the first result if there are more than one
    foreach (ManagementObject result in managementObjectCollection)
    {
      firstResult = result;
      break;
    }

    if (firstResult == null)
    {
      StatusDescription = "Problem accessing first result object";
      return Status.Error;
    }

    object resultValue;
    try
    {
      resultValue = firstResult[ResultPropertyName];
    }
    catch (Exception ex)
    {
      StatusDescription 
        = string.Format("Error retrieving result property \"{0}\".  Exception: {1}"
          ,ResultPropertyName, ex.Message);
      return Status.Error;
    }

    if (resultValue == null)
    {
      StatusDescription = string.Format("Result value was null for property \"{0}\".", 
							ResultPropertyName);
      return Status.Error;
    }

    return GetStatus(resultValue.ToString());
  }

  protected virtual Status GetStatus(string resultValueString)
  {
    // Up Value
    if (!string.IsNullOrEmpty(UpResult) && UpResult != resultValueString)
    {
      StatusDescription =
        string.Format("Result for property \"{0}\" 
		was not expected value. Expected \"{1}\" but was \"{2}\"",
                      ResultPropertyName, UpResult ?? "(NULL)", resultValueString);
      return Status.Error;
    }

    // Error Value
    if (!string.IsNullOrEmpty(ErrorResult) && ErrorResult == resultValueString)
    {
      StatusDescription =
        string.Format("Result for property \"{0}\" was error value - \"{1}\"",
                      ResultPropertyName, resultValueString);
      return Status.Error;
    }

    if (MinimumThreshold != double.MinValue || MaximumThreshold != double.MaxValue)
    {
      double resultValueDouble;
      if (Double.TryParse(resultValueString, out resultValueDouble))
      {
        if (resultValueDouble < MinimumThreshold)
        {
          StatusDescription =
            string.Format("Result for property \"{0}\" 
			was less then threshold - {1} < {2}",
                          ResultPropertyName, resultValueDouble, MinimumThreshold);
          return Status.Error;
        }
        if (resultValueDouble > MaximumThreshold)
        {
          StatusDescription =
            string.Format("Result for property \"{0}\" 
			was more then threshold - {1} > {2}",
                          ResultPropertyName, resultValueDouble, MaximumThreshold);
          return Status.Error;
        }
      }
      else
      {
        StatusDescription =
          string.Format("Result for property \"{0}\" was not a number - \"{1}\"",
                        ResultPropertyName, resultValueString);
        return Status.Error;
      }
    }

    StatusDescription = resultValueString;
    return Status.Up;
  }
}

There are many tools available to browse and query WMI data - I've been using this PowerShell based implementation.

Points of Interest

Service DashBored uses a polling technique to refresh the endpoint's statuses. Each endpoint is wrapped in a EndpointStatus object which contains some metadata about the endpoint - how much time since it was last updated, the icon for its current status, etc. A list of these EndpointStatus is data bound to the DataGridView on the application.  EndpointStatus implements INotifyPropertyChanged to allow the form element to react directly to changes in the data. For instance, when an endpoint's status changes from Up to Error, the PropertyChanged event is signalled, which then triggers the DataGridView to visually update that piece of data bound information. Here's some example code:

C#
public class EndpointStatus : INotifyPropertyChanged, IDisposable
{
  private void updateStatus()
  {
    //...
    var newStatus = _endpoint.GetStatus();
    Status = newStatus;
    SignalPropertyChanged("Status");
    //...
  }

  private readonly IEndpoint _endpoint;
  private readonly Logger _logger;
  private readonly AsyncOperation _asyncOperation;

  public Status Status { get; private set; }

  public event PropertyChangedEventHandler PropertyChanged;

  public EndpointStatus(IEndpoint endpoint, Logger logger)
  {
    _endpoint = endpoint;
    _logger = logger;
    Status = Status.Unknown;
    _asyncOperation = AsyncOperationManager.CreateOperation(null);
  }

  protected void SignalPropertyChanged(string propertyName)
  {
    try
    {
      // marshall changes back to the UI
      _asyncOperation.Post(
        delegate 
          { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); }
        , null);
    }
    catch (Exception ex)
    {
      _logger.LogWarning("SignalPropertyChanged: " + ex);
    }
  }
}

Notice the work needed for invoking the PropertyChanged event.  The AsyncOperation.Post() will place the call on the appropriate UI thread. Failing to do this will lead to mysterious "BindingSource cannot be its own data source" exceptions. While I was developing this happened sporadically and was quite annoying! Here is a forum post which helped to solve this.

To make the application easily configurable, I've chosen to use Windsor Container dependency injection framework. In the initial versions, I had used the built-in .NET configuration piece in System.Configuration but abandoned it after the code-explosion while defining custom configuration classes and collections. Some of code files for configuring were several times larger than the actual code! Here is a sample app.config for Service Dashbored using Windsor's component configuration:

XML
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="castle" 
	type="Castle.Windsor.Configuration.AppDomain.CastleSectionHandler, 
	Castle.Windsor" />
  </configSections>
  <castle>
    <components>
      <component id="form.component" type="ServiceDashBored.Main, ServiceDashBored">
        <parameters>
          <endpoints>
            <array>
              <item>${http.goodUri.endpoint}</item>
              <item>${wmi.freespace.minimum.endpoint}</item>
            </array>
          </endpoints>
        </parameters>
      </component>
      <component id="logger.component" 
	service="BitFactory.Logging.Logger, BitFactory.Logging" 
          type="BitFactory.Logging.FileLogger, BitFactory.Logging">
        <parameters>
          <aFileName>ServiceDashBored.log</aFileName>
        </parameters>
      </component>
      <component id="http.goodUri.endpoint" 
	service="Endpoint.IEndpoint, Endpoint" type="Endpoint.HttpEndpoint, Endpoint">
        <parameters>
          <Name>Http Google</Name>
          <UriString>http://www.google.com</UriString>
        </parameters>
      </component>
      <component id="wmi.freespace.minimum.endpoint" 
	service="Endpoint.IEndpoint, Endpoint" type="Endpoint.WmiEndpoint, Endpoint">
        <parameters>
          <Name>WMI - Freespace on C: drive at least 10 MB</Name>
          <MachineName>\\localhost</MachineName>
          <ObjectQueryString>select FreeSpace from Win32_LogicalDisk where 
					DeviceID='C:'</ObjectQueryString>
          <ResultPropertyName>FreeSpace</ResultPropertyName>
          <MinimumThreshold>10e6</MinimumThreshold><!-- 10 MB -->
        </parameters>
      </component>
    </components>
  </castle>
</configuration>

It's pretty simple - just define objects and their dependencies along with any instantiation parameters. The code to instantiate the top-level form object is also quite simple:

C#
[STAThread]
static void Main()
{
  IWindsorContainer container =
    new WindsorContainer(new XmlInterpreter(new ConfigResource("castle")));

  // Request the component to use it
  var form = (Main)container[typeof(Main)];

  Application.Run(form);
}

Each endpoint gets its own thread to live in, but to conserve resources, they all share a threadpool. This is done during application start with a call to ThreadPool.RegisterWaitForSingleObject for each endpoint.

C#
public partial class Main : Form
{
  private const int ENDPOINT_POLL_TIME_INTERVAL = 30;
  private readonly BindingList<endpointstatus> _endpoints;
  private readonly Logger _logger;

  public Main(IEnumerable<iendpoint> endpoints, Logger logger)
  {
    InitializeComponent();

    _logger = logger;

    _endpoints = new BindingList<endpointstatus>();
    endpointBindingSource.DataSource = _endpoints;

    foreach (var serviceEndpoint in endpoints)
    {
      registerServiceEndpoint(serviceEndpoint);
    }
  }
  
  private void registerServiceEndpoint(IEndpoint endpoint)
  {
    var endpointStatus = new EndpointStatus(endpoint, _logger);
    
    // Time Interval for each endpoint update
    var timeOut = TimeSpan.FromSeconds(ENDPOINT_POLL_TIME_INTERVAL);
    
    // Register a timed callback method in the threadpool.
    var waitObject = new AutoResetEvent(false);
    ThreadPool.RegisterWaitForSingleObject(
      waitObject,
      endpointStatus.ThreadPoolCallback,
      null,
      timeOut,
      false
      );

    _endpoints.Add(endpointStatus);
    endpointStatus.PropertyChanged += endpoint_PropertyChanged;

    waitObject.Set(); // signal an initial callback when done registering
  }
}

Future Improvements

  • Management hooks. In addition to being able to query the endpoints for their status, it might be useful to control them as well. For instance, being able to restart a Windows service directly from the Service DashBored interface.
  • More endpoint options. SNMP might be nice for device monitoring.
  • WCF endpoint definition. Give the endpoint implementation the ability to be configured using WCF.
  • Different alerting options. In addition to logging service availability and popping up a balloon icon, add other alerts such as emails, SMS and sirens.
  • Run-time configuration. Providing in-application endpoint configuration would improve usability. I'm unsure how much work this would be using the current dependency injection framework.

If there is enough interest in continued development, I'll set up a public version control repo for this project.

Referenced Projects

History

  • Version 1. Initial release

License

This article, along with any associated source code and files, is licensed under The Creative Commons Attribution-ShareAlike 2.5 License