changeset 17:68d7834dc28e

More comments.
author Brad Greco <brad@bgreco.net>
date Sat, 25 May 2019 15:14:26 -0400
parents 7626b099aefd
children b713b9db4c82
files ServerMonitor/Attributes.cs ServerMonitor/Controls/SizeUnitsComboBox.cs ServerMonitor/Helpers.cs ServerMonitor/Objects/CheckResult.cs ServerMonitor/Objects/Checks/DiskSpaceCheck.cs ServerMonitor/Objects/Checks/FileCheck.cs ServerMonitor/Objects/Checks/HttpCheck.cs ServerMonitor/Objects/Checks/PingCheck.cs ServerMonitor/Objects/Checks/SshCheck.cs ServerMonitor/Objects/Logger.cs ServerMonitor/Objects/Schedule.cs ServerMonitor/Objects/Server.cs ServerMonitor/Objects/ServerMonitor.cs ServerMonitor/Objects/UpdateCheckException.cs ServerMonitor/Program.cs ServerMonitor/ServerMonitor.csproj ServerMonitor/Win32Helpers.cs
diffstat 17 files changed, 580 insertions(+), 141 deletions(-) [+]
line wrap: on
line diff
--- a/ServerMonitor/Attributes.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Attributes.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,10 +1,11 @@
 using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
 
 namespace ServerMonitorApp
 {
+    /// <summary>
+    /// Attribute for use by check controls to indicate
+    /// the type of check they correspond to.
+    /// </summary>
     internal class CheckTypeAttribute : Attribute
     {
         public Type CheckType { get; private set; }
@@ -12,6 +13,10 @@
         public CheckTypeAttribute(Type checkType) { CheckType = checkType; }
     }
 
+    /// <summary>
+    /// Attribute for use by checks to determine the order
+    /// they appear in the check type selection combo box.
+    /// </summary>
     internal class DisplayWeightAttribute : Attribute
     {
         public int DisplayWeight { get; private set; }
--- a/ServerMonitor/Controls/SizeUnitsComboBox.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Controls/SizeUnitsComboBox.cs	Sat May 25 15:14:26 2019 -0400
@@ -19,5 +19,6 @@
     }
 
     /// <summary>Size units.</summary>
+    /// <remarks>The integer values must equal the power of 1024 needed to convert from bytes to each unit.</remarks>
     public enum SizeUnits { B = 0, KB = 1, MB = 2, GB = 3 }
 }
--- a/ServerMonitor/Helpers.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Helpers.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,49 +1,66 @@
-using Renci.SshNet;
-using Renci.SshNet.Common;
-using ServerMonitorApp.Properties;
+using ServerMonitorApp.Properties;
 using System;
-using System.Collections.Generic;
 using System.ComponentModel;
 using System.Drawing;
 using System.Linq;
 using System.Net.NetworkInformation;
-using System.Text;
 using System.Windows.Forms;
 
 namespace ServerMonitorApp
 {
     static class Helpers
     {
+        /// <summary>Resizes the image on a button to fit inside the button's dimensions.</summary>
+        /// <param name="button">A button containing an image.</param>
         public static void FormatImageButton(Button button)
         {
             button.Image = new Bitmap(button.Image, button.Height - 8, button.Height - 8);
         }
 
+        /// <summary>Recursively collects the messages for an exception and its inner exceptions.</summary>
+        /// <param name="ex">The parent exception.</param>
+        /// <returns>A string containing the messages for the exception and its inner exceptions.</returns>
         public static string GetAllMessages(this Exception ex)
         {
             return "\t" + (ex.InnerException == null ? ex.Message : ex.Message + Environment.NewLine + ex.InnerException.GetAllMessages());
         }
 
+        /// <summary>Gets the value of the DisplayNameAttribute for a type.</summary>
+        /// <param name="type">The type to get the display name of.</param>
+        /// <returns>The type's display name, or the type name if no display name is defined.</returns>
         public static string GetDisplayName(Type type)
         {
             return type?.GetAttribute<DisplayNameAttribute>()?.DisplayName ?? type?.Name ?? "null";
         }
 
+        /// <summary>Checks whether a string is null, an empty string, or only contains white space.</summary>
+        /// <param name="aString">The string to test.</param>
+        /// <returns>True if the string is null, an empty string, or only contains white space. False otherwise.</returns>
         public static bool IsNullOrEmpty(this string aString)
         {
             return aString == null || aString.Trim() == string.Empty;
         }
 
+        /// <summary>Converts all newlines in a string to unix format.</summary>
+        /// <param name="aString">The string to convert.</param>
+        /// <returns>The string with all newlines converted to unix format.</returns>
         public static string ConvertNewlines(this string aString)
         {
             return aString.Replace("\r\n", "\n").Replace('\r', '\n');
         }
 
+        /// <summary>Gets an attribute on a class.</summary>
+        /// <typeparam name="T">The type of the attribute to return.</typeparam>
+        /// <param name="type">The type of the class the attribute is on.</param>
+        /// <returns>The attribute, or null if the attribute does not exist on the class.</returns>
         public static T GetAttribute<T>(this Type type) where T : Attribute
         {
             return type.GetCustomAttributes(typeof(T), false).SingleOrDefault() as T;
         }
 
+        /// <summary>Gets an image associated with a check status for use in the UI.</summary>
+        /// <param name="checkStatus">The check status.</param>
+        /// <returns>The image associated with the check status.</returns>
         public static Image GetImage(this CheckStatus checkStatus)
         {
             switch (checkStatus)
@@ -58,6 +75,9 @@
             }
         }
 
+        /// <summary>Gets a program icon associated with a check status.</summary>
+        /// <param name="checkStatus">The check status.</param>
+        /// <returns>The program icon associated with the check status.</returns>
         public static Icon GetIcon(this CheckStatus checkStatus)
         {
             switch (checkStatus)
@@ -69,6 +89,10 @@
             }
         }
 
+        /// <summary>Returns whether an enum is in a list of values.</summary>
+        /// <param name="value">The value to check.</param>
+        /// <param name="values">The list of possible values.</param>
+        /// <returns>True if the value was in the list, false otherwise.</returns>
         public static bool In(this Enum value, params Enum[] values) {
             return values.Contains(value);
         }
--- a/ServerMonitor/Objects/CheckResult.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/CheckResult.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,41 +1,56 @@
 using ServerMonitorApp.Properties;
 using System;
-using System.Drawing;
 
 namespace ServerMonitorApp
 {
+    /// <summary>
+    /// The result of an executed check.
+    /// Contains data about the check's last execution including status, time, and log message.
+    /// </summary>
     public class CheckResult
     {
-        public const string dateFormat = "yyyy-MM-dd HH:mm:ss.fff";
+        // The date format to use in log files.
+        private const string dateFormat = "yyyy-MM-dd HH:mm:ss.fff";
 
+        /// <summary>The originating check of this check result.</summary>
         public Check Check { get; private set; }
 
+        /// <summary>The result status of the check execution.</summary>
         public CheckStatus CheckStatus { get; private set; }
 
+        /// <summary>The message generated by the check execution.</summary>
         public string Message { get; set; }
 
+        /// <summary>The time the check execution began.</summary>
         public DateTime StartTime { get; set; }
 
+        /// <summary>The time the check execution ended.</summary>
         public DateTime EndTime { get; set; }
 
+        /// <summary>Whether the check execution resulted in success or failure.</summary>
         public bool Failed => CheckStatus.In(CheckStatus.Error, CheckStatus.Warning, CheckStatus.Information);
 
+        /// <summary>Action to perform when the check fails.</summary>
         public FailAction FailAction
         {
             get
             {
+                // Use the global preferences for each status to determine the action to take.
                 switch (CheckStatus)
                 {
                     case CheckStatus.Error: return Settings.Default.ErrorAction;
                     case CheckStatus.Warning: return Settings.Default.WarningAction;
                     case CheckStatus.Information: return Settings.Default.InformationAction;
+                    // On success (or any other status), do nothing.
                     default: return FailAction.None;
                 }
             }
         }
 
-        public bool FlashTaskbar => FailAction == FailAction.FlashTaskbar;
-
+        /// <summary>CheckResult constructor.</summary>
+        /// <param name="check">The originating check of this check result.</param>
+        /// <param name="status">The result status of the check execution.</param>
+        /// <param name="message">The message generated by the check execution.</param>
         public CheckResult(Check check, CheckStatus status, string message)
         {
             Check = check;
@@ -43,22 +58,42 @@
             Message = message;
         }
 
+        /// <summary>Generates a string representation of the check result that can be logged.</summary>
+        /// <returns>A string representation of the check result that can be logged.</returns>
+        /// <remarks>
+        /// The log string is in the format:
+        /// [Check ID] [Start time] [End time] [Check status] [Check output]
+        /// 
+        /// The check ID is left-padded with zeros to simplify log parsing and filtering by check ID.
+        /// Dates are formatted according to the dateFormat defined in this class.
+        /// Newlines in check output are escaped so the log string contains no literal newline characters.
+        /// </remarks>
         public string ToLogString()
         {
             return string.Format("{0:00000} {1} {2} {3} {4}",
                 Check.Id,
-                StartTime.ToString(dateFormat).Replace("T", " "),
-                EndTime.ToString(dateFormat).Replace("T", " "),
+                StartTime.ToString(dateFormat),
+                EndTime.ToString(dateFormat),
                 CheckStatus,
                 Message.ConvertNewlines().Replace("\n", "\\n"));
         }
 
+        /// <summary>Parses a log string to create a check result object.</summary>
+        /// <param name="check">The originating check for the check result.</param>
+        /// <param name="logString">The log string to parse.</param>
+        /// <returns>A check result object.</returns>
         public static CheckResult FromLogString(Check check, string logString)
         {
+            // The check ID, start time, and end time are fixed in length, so no pattern matching is needed.
             DateTime startTime = DateTime.Parse(logString.Substring(6, 23));
             DateTime endTime = DateTime.Parse(logString.Substring(30, 23));
+            // The check status is not fixed in length, but will not contain any spaces.
+            // So, the first space following the beginning of the checks status will
+            // mark the start of the result message.
             int messageStartPos = logString.IndexOf(' ', 54);
+            // Now we know the length of the status token, so we can extract and parse it.
             Enum.TryParse(logString.Substring(54, messageStartPos - 54), out CheckStatus status);
+            // Put it all together.
             return new CheckResult(check, status, logString.Substring(messageStartPos + 1)) { StartTime = startTime, EndTime = endTime };
         }
     }
--- a/ServerMonitor/Objects/Checks/DiskSpaceCheck.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Checks/DiskSpaceCheck.cs	Sat May 25 15:14:26 2019 -0400
@@ -2,47 +2,63 @@
 using System.Collections.Generic;
 using System.ComponentModel;
 using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Checks the available disk space on a remote server.</summary>
     [DisplayName("Disk space check"), Description("Check the remaining free disk space"), DisplayWeight(11)]
     public class DiskSpaceCheck : SshCheck
     {
+        /// <summary>The command to execute. Must return the POSIX output format of df(1).</summary>
         public override string Command => string.Format(DiskSpaceCommand, Device);
 
+        /// <summary>The command to execute, with a placeholder of {0} for the device to check.</summary>
         protected string DiskSpaceCommand => "df -P -k {0}";
 
+        /// <summary>The device or file on the device to check.</summary>
         public string Device { get; set; }
 
+        /// <summary>The minimum free space allowed for the check to pass.</summary>
         public double MinFreeSpace { get; set; }
 
+        /// <summary>The storage units or percentage for MinFreeSpace.</summary>
         public FreeSpaceUnits FreeSpaceUnits { get; set; }
 
         public DiskSpaceCheck()
         {
+            // Set general SSH check settings for disk space checks.
             CheckExitCode = true;
             ExitCode = 0;
         }
 
+        /// <summary>Processes the output of the disk space check command.</summary>
         protected override List<CheckResult> ProcessCommandResult(string output, int exitCode)
         {
+            // Check for general SSH failures.
             List<CheckResult> results = base.ProcessCommandResult(output, exitCode);
+            // If there was an error running the command, fail immediately.
             if (results.Any(r => r.Failed))
                 return results;
 
+            /* Parse the command results, expected in the POSIX output format of df(1):
+               Filesystem 1024-blocks Used Available Capacity Mounted on
+               <file system name> <total space> <space used> <space free> <percentage used> <file system root>
+            */
+            // Split the output into lines and remove the header row.
             List<string> lines = output.Split('\n').ToList();
             lines.RemoveAt(0);
+            // Make sure there is only a single line of output remaining.
             if (lines.Count > 1)
             {
                 results.Add(Fail("df output was more than one line: " + string.Join("\n", lines)));
             }
             else
             {
+                // Split the string into tokens on whitespace.
                 string[] tokens = lines[0].Split(new char[0], StringSplitOptions.RemoveEmptyEntries);
                 if (FreeSpaceUnits == FreeSpaceUnits.percent)
                 {
+                    // Test on percentage: calculate the capacity free percent from the capacity used percent reported.
                     if (int.TryParse(tokens[4].Replace("%", ""), out int percent))
                     {
                         percent = 100 - percent;
@@ -59,9 +75,12 @@
                 }
                 else
                 {
+                    // Test on bytes: calculate the remaining available space from the reported available space.
                     if (int.TryParse(tokens[3], out int freeSpace))
                     {
+                        // Available space is returned in KB. Convert to MB (our default unit).
                         freeSpace /= 1024;
+                        // If the unit is GB, convert MB to GB.
                         if (FreeSpaceUnits == FreeSpaceUnits.GB)
                             freeSpace /= 1024;
                         string message = string.Format("Free disk space is {0} {1}", freeSpace, FreeSpaceUnits);
@@ -79,6 +98,7 @@
             return results;
         }
 
+        /// <summary>Validates disk space check options.</summary>
         public override string Validate(bool saving = true)
         {
             string message = base.Validate();
@@ -92,5 +112,14 @@
         }
     }
 
-    public enum FreeSpaceUnits { MB = 0, GB = 1, percent = 2 }
+    /// <summary>The units to use when testing available disk space.</summary>
+    public enum FreeSpaceUnits
+    {
+        /// <summary>Tests available disk space in megabytes.</summary>
+        MB = 0,
+        /// <summary>Tests available disk space in gigabytes.</summary>
+        GB = 1,
+        /// <summary>Tests available disk space as a percentage of the total space.</summary>
+        percent = 2
+    }
 }
\ No newline at end of file
--- a/ServerMonitor/Objects/Checks/FileCheck.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Checks/FileCheck.cs	Sat May 25 15:14:26 2019 -0400
@@ -2,74 +2,96 @@
 using System.Collections.Generic;
 using System.ComponentModel;
 using System.Linq;
-using System.Text;
 using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using System.Xml.Serialization;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Checks file metadata on a remote server.</summary>
     [DisplayName("File check"), Description("Check file size or modified time"), DisplayWeight(12)]
     public class FileCheck : SshCheck
     {
+        /// <summary>The command to execute. Must return the output format of GNU ls(1) with the long-iso time style.</summary>
+        /// <remarks>Would be better to not rely on the output of ls.</remarks>
         public override string Command => string.Format(FileCommand, Regex.Replace(File, "^~", "$HOME"));
 
+        /// <summary>The command to execute, with a placeholder of {0} for the file to check.</summary>
         protected string FileCommand
         {
             get
             {
-                int timeZoneOffset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now).Hours * -1; // Invert because POSIX says so.
+                // Invert because POSIX says so.
+                int timeZoneOffset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now).Hours * -1;
+                // Set the date format to long-iso since it's easy to parse.
+                // Set the time zone to the match the client time zone so comparisons are reliable.
                 return "export TIME_STYLE=long-iso; export TZ=UTC" + timeZoneOffset + "; ls -l \"{0}\"";
             }
         }
 
+        /// <summary>The path of the file to check.</summary>
         public string File { get; set; }
 
+        /// <summary>Whether the file size will be checked.</summary>
         public bool CheckFileSize { get; set; }
 
+        /// <summary>Whether the file is expected to be less than or greater than a certain size.</summary>
         public bool FileSizeLessThan { get; set; }
 
+        /// <summary>The size to compare the file against in bytes.</summary>
         public int FileSize { get; set; }
 
+        /// <summary>The size to compare the file against in the selected size units.</summary>
         public double FileSizeInSelectedUnits
         {
             get => Math.Round(ConvertBytesToSelectedUnits(FileSize), 1);
             set => FileSize = ConvertSelectedUnitsToBytes(value);
         }
 
+        /// <summary>The units of the file size.</summary>
         public SizeUnits FileSizeUnits { get; set; }
 
+        /// <summary>Whether the file modified time will be checked.</summary>
         public bool CheckDateModified { get; set; }
 
+        /// <summary>Whether the file is expected to be older than or newer than a certain date.</summary>
         public bool DateModifiedOlderThan { get; set; }
 
+        /// <summary>The number of time units to compare then file modified time against.</summary>
         public double DateModified { get; set; }
 
+        /// <summary>The units of the file date modified.</summary>
         public TimeUnits DateModifiedUnits { get; set; }
 
         public FileCheck()
         {
+            // Set general SSH check settings for file checks.
             CheckExitCode = true;
             ExitCode = 0;
         }
 
+        /// <summary>Processes the output of the file check command.</summary>
         protected override List<CheckResult> ProcessCommandResult(string output, int exitCode)
         {
+            // Check for general SSH failures.
             List<CheckResult> results = base.ProcessCommandResult(output, exitCode);
+            // If there was an error running the command, fail immediately.
             if (results.Any(r => r.Failed))
                 return results;
 
+            // Make sure there is only a single line of output.
             if (output.Split('\n').Length > 1)
             {
                 results.Add(Fail("ls output was more than one line: " + output));
             }
             else
             {
+                // Split the string into tokens on whitespace.
                 string[] tokens = output.Split(new char[0], StringSplitOptions.RemoveEmptyEntries);
                 if (CheckFileSize)
                 {
+                    // Check file size.
                     if (int.TryParse(tokens[4], out int bytes))
                     {
+                        // Prepare a human-readable message with the comparison results.
                         string message = string.Format("File size is {0} {1}", Math.Round(ConvertBytesToSelectedUnits(bytes), 1), FileSizeUnits);
                         if ((bytes < FileSize && FileSizeLessThan) || (bytes > FileSize && !FileSizeLessThan))
                             results.Add(Pass(message));
@@ -83,10 +105,13 @@
                 }
                 if (CheckDateModified)
                 {
+                    // Check date modified.
+                    // Tokens[5] contains the date, tokens[6] contains the time.
                     string dateString = tokens[5] + " " + tokens[6];
                     if (DateTime.TryParse(dateString, out DateTime modified))
                     {
                         string message = string.Format("Last modified date is {0}", modified);
+                        // Determine how old the file is. The command output is in the client time zone.
                         TimeSpan age = DateTime.Now.Subtract(modified);
                         double ageCompare = DateModifiedUnits == TimeUnits.Minute ? age.TotalMinutes :
                                          DateModifiedUnits == TimeUnits.Hour ? age.TotalHours :
@@ -105,6 +130,7 @@
             return results;
         }
 
+        /// <summary>Validates file check options.</summary>
         public override string Validate(bool saving = true)
         {
             string message = base.Validate();
@@ -119,11 +145,17 @@
             return message;
         }
 
+        /// <summary>Converts bytes to a different size unit.</summary>
+        /// <param name="sizeInBytes">The size to convert in bytes.</param>
+        /// <returns>The size in the units used by this file check.</returns>
         private double ConvertBytesToSelectedUnits(int sizeInBytes)
         {
             return sizeInBytes / Math.Pow(1024, (double)FileSizeUnits);
         }
 
+        /// <summary>Converts a size in a different unit to bytes.</summary>
+        /// <param name="sizeInSelectedUnits">The size to convert in the units used by this file check.</param>
+        /// <returns>The size in bytes.</returns>
         private int ConvertSelectedUnitsToBytes(double sizeInSelectedUnits)
         {
             return (int)(sizeInSelectedUnits * Math.Pow(1024, (int)FileSizeUnits));
--- a/ServerMonitor/Objects/Checks/HttpCheck.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Checks/HttpCheck.cs	Sat May 25 15:14:26 2019 -0400
@@ -35,6 +35,32 @@
         protected override Task<CheckResult> ExecuteCheckAsync(CancellationToken token)
         {
             throw new NotImplementedException();
+
+
+
+
+            // Cancellable version that doesn't actulaly work
+            // might be useful as a TaskCompletionSource example though
+            //
+            //TaskCompletionSource<CheckResult> tcs = new TaskCompletionSource<CheckResult
+            //
+            //using (Ping ping = new Ping())
+            //{
+            //    token.Register(ping.SendAsyncCancel);
+            //    ping.PingCompleted += (sender, e) =>
+            //    {
+            //        if (e.Error != null)
+            //            tcs.SetResult(Fail("Ping failed: " + e.Error.GetBaseException().Message));
+            //        else if (e.Reply.Status != IPStatus.Success)
+            //            tcs.SetResult(Fail("Ping failed: " + e.Reply.Status.ToString()));
+            //        else
+            //            tcs.SetResult(Pass("Ping completed in " + e.Reply.RoundtripTime + "ms"));
+            //    };
+            //    ping.SendAsync(Server.Host, Timeout, null);
+            //}
+
+            //return tcs.Task;
+
         }
 
         public override string Validate(bool saving = true)
--- a/ServerMonitor/Objects/Checks/PingCheck.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Checks/PingCheck.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,17 +1,15 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.Linq;
+using System.ComponentModel;
 using System.Net.NetworkInformation;
-using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Checks that a server responds to ping.</summary>
     [DisplayName("Ping check"), Description("Check if the server responds to a ping request"), DisplayWeight(0)]
     public class PingCheck : Check
     {
+        /// <summary>Sends a ping and waits for a response.</summary>
         protected async override Task<CheckResult> ExecuteCheckAsync(CancellationToken token)
         {
             using (Ping ping = new Ping())
@@ -29,28 +27,6 @@
                     return Fail(e);
                 }
             }
-
-            // Cancellable version that doesn't actulaly work
-            // might be useful as a TaskCompletionSource example though
-            //
-            //TaskCompletionSource<CheckResult> tcs = new TaskCompletionSource<CheckResult
-            //
-            //using (Ping ping = new Ping())
-            //{
-            //    token.Register(ping.SendAsyncCancel);
-            //    ping.PingCompleted += (sender, e) =>
-            //    {
-            //        if (e.Error != null)
-            //            tcs.SetResult(Fail("Ping failed: " + e.Error.GetBaseException().Message));
-            //        else if (e.Reply.Status != IPStatus.Success)
-            //            tcs.SetResult(Fail("Ping failed: " + e.Reply.Status.ToString()));
-            //        else
-            //            tcs.SetResult(Pass("Ping completed in " + e.Reply.RoundtripTime + "ms"));
-            //    };
-            //    ping.SendAsync(Server.Host, Timeout, null);
-            //}
-
-            //return tcs.Task;
         }
     }
 }
--- a/ServerMonitor/Objects/Checks/SshCheck.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Checks/SshCheck.cs	Sat May 25 15:14:26 2019 -0400
@@ -2,57 +2,70 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel;
-using System.Linq;
-using System.Text;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Executes an SSH command and checks the output or exit code.</summary>
     [DisplayName("SSH check"), Description("Check the result of a command run over SSH"), DisplayWeight(10)]
     public class SshCheck : Check
     {
+        /// <summary>The command to execute on the server.</summary>
         public virtual string Command { get; set; }
 
+        /// <summary>Whether the exit code of the command should be checked.</summary>
         public bool CheckExitCode { get; set; }
 
+        /// <summary>The required exit code of the command if CheckExitCode is true.</summary>
         public int ExitCode { get; set; }
 
+        /// <summary>Whether the text output of the command should be checked.</summary>
         public bool CheckCommandOutput { get; set; }
 
+        /// <summary>The method to use when checking the command output against the pattern.</summary>
         public MatchType CommandOutputMatchType { get; set; }
 
+        /// <summary>The string or pattern that the command output must match if CommandOutputMatchType is true.</summary>
         public string CommandOutputPattern { get; set; }
 
+        /// <summary>Whether the CommandOutputPattern should be interpreted as a regular expression.</summary>
         public bool CommandOutputUseRegex { get; set; }
 
+        /// <summary>Executes the SSH command on the server.</summary>
         protected override Task<CheckResult> ExecuteCheckAsync(CancellationToken token)
         {
-            try
-            {
-                if (!Server.SshClient.IsConnected)
-                {
-                    if (Server.LoginType == LoginType.PrivateKey && Server.PrivateKeyFile == null)
-                        return Task.FromResult(Fail(string.Format("Private key '{0}' is locked or not accessible", Server.KeyFile)));
-                    Server.SshClient.Connect();
-                }
-            }
-            catch (Exception e)
-            {
-                return Task.FromResult(Fail(e));
-            }
             return Task.Run(() =>
             {
                 try
                 {
+                    // Exit now if the user cancelled the execution.
                     token.ThrowIfCancellationRequested();
+
+                    // Connect to the server if needed.
+                    if (!Server.SshClient.IsConnected)
+                    {
+                        // If the server private key file has not been opened, it is probably encrypted.
+                        // Report failure until the user enters the password.
+                        if (Server.LoginType == LoginType.PrivateKey && Server.PrivateKeyFile == null)
+                            return Fail(string.Format("Private key '{0}' is locked or not accessible", Server.KeyFile));
+                        Server.SshClient.Connect();
+                    }
+
+                    // Exit now if the user cancelled the execution.
+                    token.ThrowIfCancellationRequested();
+
                     using (SshCommand command = Server.SshClient.CreateCommand(Command))
                     {
+                        // Execute the command.
                         token.Register(command.CancelAsync);
                         IAsyncResult ar = command.BeginExecute();
                         token.ThrowIfCancellationRequested();
+                        // Store both the command output and the error streams so they can
+                        // be logged and checked.
                         string output = (command.EndExecute(ar).Trim() + command.Error.Trim()).ConvertNewlines();
+                        // Process the results (exit code and command output) and merge them into a single result.
                         return MergeResults(ProcessCommandResult(output, command.ExitStatus).ToArray());
                     }
                 }
@@ -63,11 +76,17 @@
             }, token);
         }
 
+        /// <summary>Processes the command results and checks they match the expected values.</summary>
+        /// <param name="output">The command output.</param>
+        /// <param name="exitCode">The command exit code.</param>
+        /// <returns>A list of check results according to user preferences.</returns>
         protected virtual List<CheckResult> ProcessCommandResult(string output, int exitCode)
         {
             List<CheckResult> results = new List<CheckResult>();
+            // Check the actual output against the expected output if command output checking is enabled.
             if (CheckCommandOutput)
                 results.Add(GetStringResult(CommandOutputMatchType, CommandOutputPattern, CommandOutputUseRegex, output, "Command output"));
+            // Check the actual exit code against the expected exit code if exit code checking is enabled.
             if (CheckExitCode)
             {
                 CheckResult result = GetIntResult(ExitCode, exitCode, "Exit code");
@@ -78,6 +97,7 @@
             return results;
         }
 
+        /// <summary>Validates SSH check options.</summary>
         public override string Validate(bool saving = true)
         {
             string message = base.Validate();
--- a/ServerMonitor/Objects/Logger.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Logger.cs	Sat May 25 15:14:26 2019 -0400
@@ -3,26 +3,32 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using System.Reflection;
 using System.Text;
-using System.Threading.Tasks;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Manages reading and writing check results to a log file.</summary>
     public class Logger
     {
         private const int logVersion = 1;
         private readonly string logFile;
         
+        /// <summary>Logger constructor.</summary>
+        /// <param name="file">The path of the log file to use.</param>
         public Logger(string file)
         {
             logFile = file;
         }
 
+        /// <summary>Appends a string to the log file, creating the log file if it does not exist.</summary>
+        /// <param name="logString">The string to log.</param>
         public async void Log(string logString)
         {
+            // Create the file if it does not exist.
+            // Write the current version at the beginning in case we ever change the format.
             if (!File.Exists(logFile))
                 logString = "Server Monitor log version " + logVersion + Environment.NewLine + logString;
+            // Write the message.
             using (FileStream stream = new FileStream(logFile, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, true))
             using (StreamWriter writer = new StreamWriter(stream, Encoding.UTF8))
             {
@@ -30,43 +36,61 @@
             }
         }
 
+        /// <summary>Appends a check result to the log file, creating the log file if it does not exist.</summary>
+        /// <param name="result">The check result to log.</param>
         public void Log(CheckResult result)
         {
             Log(result.ToLogString());
         }
 
+        /// <summary>Reads all check results from the log for a server.</summary>
+        /// <param name="server">The server whose check results should be read.</param>
+        /// <returns>A list of all check results found in the log file for the given server.</returns>
         public IList<CheckResult> Read(Server server)
         {
+            // Store the checks by ID.
             Dictionary<int, Check> checks = server.Checks.ToDictionary(c => c.Id);
             List<CheckResult> results = new List<CheckResult>();
             using (FileStream stream = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.Read))
             using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
             {
+                // Read through the whole file, searching for results belonging to the server.
                 while (true)
                 {
                     try
                     {
                         string line = reader.ReadLine();
+                        // Exit the loop when we reach the end.
                         if (line == null)
                             break;
+                        // The check ID is a fixed length.
+                        // Parse it and see if it matches any of the checks we are interested in.
                         if (int.TryParse(line.Substring(0, 5), out int id) && checks.TryGetValue(id, out Check check))
                         {
+                            // If it is, add it to our results list.
                             results.Add(CheckResult.FromLogString(check, line));
                         }
                     }
+                    // Don't know why this catch block is empty.
+                    // Maybe to ignore errors if the file does not exist?
                     catch (Exception) { }
                 }
             }
+            // Return the newest results first since that's how they are displayed.
             results.Reverse();
             return results;
         }
 
+        /// <summary>Removes old entries from the log.</summary>
         public async void TrimLog()
         {
+            // Delete entries older than this date.
             DateTime maxAge = DateTime.Now.AddDays(-1 * Settings.Default.KeepLogDays);
             string tempFile = logFile + ".tmp";
             try
             {
+                // Read through the log file and check the date of each entry.
+                // If it is newer than the max age, copy it to a temporary file.
                 using (FileStream stream = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.Read))
                 using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
                 using (FileStream outStream = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None))
@@ -87,6 +111,7 @@
                         catch (Exception) { }
                     }
                 }
+                // Delete the original file and rename the temporary file that contains only recent entries.
                 File.Delete(logFile);
                 File.Move(tempFile, logFile);
             }
--- a/ServerMonitor/Objects/Schedule.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Schedule.cs	Sat May 25 15:14:26 2019 -0400
@@ -2,10 +2,22 @@
 
 namespace ServerMonitorApp
 {
+    /// <summary>Schedule to control when a check is automatically executed.<summary>
     public class Schedule
     {
+        // Required empty constructor for XML serialization.
         public Schedule() { }
 
+        /// <summary>Schedule constructor</summary>
+        /// <param name="units">The time units to use.</param>
+        /// <param name="frequency">How frequently to run the check.</param>
+        /// <param name="startTime">Time of day the check should begin running.</param>
+        /// <param name="endTime">Time of day the check should stop running.</param>
+        /// <remarks>
+        /// If endTime is before startTime, then the endTime is interpreted as being on the next day.
+        /// For example, if startTime = 17:00 and endTime is 8:00, then the check will start running
+        /// at 17:00 and stop running at 8:00 the next day.
+        /// </remarks>
         public Schedule(FrequencyUnits units, int frequency, TimeSpan startTime, TimeSpan endTime)
         {
             Units = units;
@@ -14,32 +26,57 @@
             EndTime = endTime;
         }
 
+        /// <summary>How often the check should be executed.</summary>
         public int Frequency { get; set; }
 
+        /// <summary>The time units used to interpret the frequency.</summary>
         public FrequencyUnits Units { get; set; }
 
+        /// <summary>Time of day the check should begin running.</summary>
         public TimeSpan StartTime { get; set; }
 
+        /// <summary>Time of day the check should stop running.</summary>
         public TimeSpan EndTime { get; set; }
 
+        /// <summary>Given the last time a check was scheduled to run, calculates the next time in the future it should run.</summary>
+        /// <param name="lastScheduledTime">The last time a check was scheduled to run.</param>
         public DateTime GetNextTime(DateTime lastScheduledTime)
         {
             return GetNextTime(lastScheduledTime, DateTime.Now);
         }
 
+        /// <summary>Given the last time a check was scheduled to run, calculates the next time after the given date it should run.</summary>
+        /// <param name="lastScheduledTime">The last time a check was scheduled to run.</param>
+        /// <param name="minStartTime">The earliest allowed time to return.</param>
+        /// <remarks>
+        /// The next execution time of a check cannot necessarily be determined by taking the
+        /// last execution time and adding the configured number of time units. The computer might
+        /// have been asleep or the program might have not been running, so the next date might
+        /// be in the past.
+        /// 
+        /// To best follow the schedule, we take the last execution time and fast-forward time
+        /// by adding the configured time interval until we get a resulting time that is in the future.
+        /// For example, suppose a check is scheduled to run every 5 minutes starting at 7:00.
+        /// The check last ran at 7:40, and the computer was suspended shortly thereafter and resumed
+        /// at 8:28. The next execution time is determined by adding 5 minutes to 7:40 until we obtain
+        /// a time after 8:28, in this case, 8:30.
+        /// </remarks>
         public DateTime GetNextTime(DateTime lastScheduledTime, DateTime minStartTime)
         {
+            // Start by setting the next time to the last time the check was run.
             DateTime nextTime = lastScheduledTime;
             if (Units == FrequencyUnits.Day)
             {
+                // If the check is scheduled only once a day, simply add days until we find a time in the future.
                 while (nextTime < minStartTime)
                     nextTime = nextTime.AddDays(Frequency).Date.Add(StartTime);
             }
             else
             {
-                // If the last run time was more than a day ago, fast-forward to reduce the number of loops
+                // If the last run time was more than a day ago, fast-forward a day at a time to reduce the number of loops.
                 if (nextTime < minStartTime.AddHours(-24))
                     nextTime = minStartTime.Date.Add(StartTime).AddHours(-24);
+                // Add the configured time interval to the last run time until we obtain a time in the future.
                 while (nextTime < minStartTime)
                 {
                     switch (Units)
@@ -50,15 +87,25 @@
                         default: throw new InvalidOperationException("Unexpected frequency units: " + Units);
                     }
                 }
+                // Now we have the next date and time, but we don't know yet if it is within
+                // the active times of day the check is allowed to run between.
                 if (StartTime < EndTime)
                 {
+                    // The allowed start and end times are on the same day.
+                    // If the next scheduled time is too early (before the allowed start time),
+                    // wait to run it until the start time happens.
                     if (nextTime.TimeOfDay < StartTime)
                         nextTime = nextTime.Date + StartTime;
+                    // If the next scheduled time is too late (after the allowed end time),
+                    // wait to run it until the start time happens on the following day.
                     else if (nextTime.TimeOfDay > EndTime)
                         nextTime = nextTime.Date.AddDays(1) + StartTime;
                 }
                 else if (nextTime.TimeOfDay > EndTime && nextTime.TimeOfDay < StartTime)
                 {
+                    // The allowed start time is on the day after the allowed end time,
+                    // and the next scheduled time is too early (before the allowed start time).
+                    // Wait to run the check until the allowed start time happens.
                     nextTime = nextTime.Date + StartTime;
                 }
             }
@@ -71,5 +118,6 @@
         }
     }
 
+    /// <summary>Units of time that a check can be scheduled to run in intervals of.</summary>
     public enum FrequencyUnits { Second, Minute, Hour, Day }
 }
\ No newline at end of file
--- a/ServerMonitor/Objects/Server.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/Server.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,17 +1,17 @@
 using System;
-using System.Collections.Generic;
 using System.Linq;
 using System.Text;
 using System.Security.Cryptography;
 using System.ComponentModel;
 using Renci.SshNet;
-using System.Runtime.Serialization;
 using System.Xml.Serialization;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Types of SSH logins supported by the server monitor.</summary>
     public enum LoginType { PrivateKey = 0, Password = 1 };
 
+    /// <summary>Remote server that checks can be run against.</summary>
     public class Server
     {
         private string _host;
@@ -24,59 +24,78 @@
         private byte[] passwordHash;
         private PrivateKeyFile _privateKeyFile;
 
+        /// <summary>Fires when a check belonging to this server is modifed.</summary>
         public event EventHandler CheckModified;
+        /// <summary>Fires when the server enabled state changes.</summary>
         public event EventHandler EnabledChanged;
 
+        /// <summary>The checks that belong to this server.</summary>
         public readonly BindingList<Check> Checks = new BindingList<Check>();
 
+        /// <summary>Internal ID of the server.</summary>
         public int Id { get; set; }
 
+        /// <summary>Name of the server.</summary>
         public string Name { get; set; }
 
+        /// <summary>Hostname of the server.</summary>
         public string Host
         {
             get { return _host; }
             set { _host = value; InvalidateSshConnection(); }
         }
 
+        /// <summary>Port to use when connecting using SSH.</summary>
         public int Port
         {
             get { return _port; }
             set { _port = value; InvalidateSshConnection(); }
         }
 
+        /// <summary>Username to use when connecting using SSH.</summary>
         public string Username
         {
             get { return _username; }
             set { _username = value; InvalidateSshConnection(); }
         }
 
+        /// <summary>Login type to use when connecting using SSH.</summary>
         public LoginType LoginType
         {
             get { return _loginType; }
             set { _loginType = value; InvalidateSshConnection(); }
         }
 
+        /// <summary>Path to the private key file to use when connecting using SSH.</summary>
         public string KeyFile
         {
             get { return _keyFile; }
             set { _keyFile = value; InvalidateSshConnection(); }
         }
 
+        /// <summary>Password to use when connecting using SSH.</summary>
+        /// <remarks>The password is encrypted using the current Windows user account.</remarks>
         public string Password
         {
             get {
                 return passwordHash == null ? null :
-                    Encoding.UTF8.GetString(ProtectedData.Unprotect(passwordHash, Encoding.UTF8.GetBytes("Server".Reverse().ToString()), DataProtectionScope.CurrentUser));
+                    Encoding.UTF8.GetString(ProtectedData.Unprotect(passwordHash
+                    , Encoding.UTF8.GetBytes("Server".Reverse().ToString()) // Super-secure obfuscation of additional entropy
+                    , DataProtectionScope.CurrentUser));
             }
             set
             {
                 passwordHash = ProtectedData.Protect(Encoding.UTF8.GetBytes(value),
-                                    Encoding.UTF8.GetBytes("Server".Reverse().ToString()), // Minor obfuscation of additional entropy
+                                    Encoding.UTF8.GetBytes("Server".Reverse().ToString()), // Super-secure obfuscation of additional entropy
                                     DataProtectionScope.CurrentUser);
             }
         }
 
+        /// <summary>Private key file to use when connecting using SSH.</summary>
+        /// <remarks>
+        /// If the private key file is encrypted, will be null until the user enters
+        /// the decryption key.
+        /// </remarks>
         [XmlIgnore]
         public PrivateKeyFile PrivateKeyFile
         {
@@ -88,11 +107,18 @@
                 {
                     if (_privateKeyFile == null)
                     {
+                        // The private key has not been opened yet.
+                        // Disable the server until the user enters the decryption key,
+                        // and set the KeyStatus to indicate why the server was disabled.
                         KeyStatus = KeyStatus.Closed;
                         Enabled = false;
                     }
                     else
                     {
+                        // The private key is open and accessible.
+                        // Automatically re-enable the server if it was previously disabled
+                        // due to a locked or inaccessible private key (i.e. disabled
+                        // programatically and not by user request).
                         if (!KeyStatus.In(KeyStatus.Open, KeyStatus.Closed))
                             Enabled = true;
                         KeyStatus = KeyStatus.Open;
@@ -101,13 +127,18 @@
             }
         }
 
+        /// <summary>The current status of the private key file.</summary>
         public KeyStatus KeyStatus { get; set; }
 
+        /// <summary>Whether this server's checks will be automatically executed on their schedules.</summary>
         public bool Enabled
         {
             get { return _enabled; }
             set
             {
+                // Do not allow enabling the server if the private key is not accessible.
+                // Do not fire the EnabledChanged event if the Enabled state is not actually changing
+                // from its existing value.
                 if ((LoginType == LoginType.PrivateKey && PrivateKeyFile == null && value == true) || value == _enabled)
                     return;
                 _enabled = value;
@@ -115,14 +146,23 @@
             }
         }
 
-        //public bool WaitingForUser { get; set; }
-
+        /// <summary>The status of the server.</summary>
+        /// <remarks>
+        /// The status of the server is the most severe status of all its enabled checks.
+        /// The integer value of the CheckStatus enum increases with the severity,
+        /// so the maximum value of all checks gives the most severe status.
+        /// </remarks>
         public CheckStatus Status => !Enabled ? CheckStatus.Disabled : Checks
             .Where(c => c.Enabled)
             .Select(c => c.LastRunStatus)
             .DefaultIfEmpty(CheckStatus.Success)
             .Max();
 
+        /// <summary>The SSH client to use when running checks on the server.</summary>
+        /// <remarks>
+        /// The connection is stored and kept open at the server level so it can be reused
+        /// by all SSH checks.
+        /// </remarks>
         public SshClient SshClient
         {
             get
@@ -136,19 +176,8 @@
             }
         }
 
-        /*public Server() { }
-
-        public Server(Server server)
-        {
-            Name = server.Name;
-            Host = server.Host;
-            Port = server.Port;
-            Username = server.Username;
-            LoginType = server.LoginType;
-            KeyFile = server.KeyFile;
-            Enabled = server.Enabled;
-        }*/
-
+        /// <summary>Deletes a check from the server.</summary>
+        /// <param name="check">The check to delete.</param>
         public void DeleteCheck(Check check)
         {
             Checks.Remove(check);
@@ -156,6 +185,8 @@
             CheckModified?.Invoke(check, new EventArgs());
         }
 
+        /// <summary>Validates server settings.</summary>
+        /// <returns>An empty string if the server is valid, or the reason the server is invalid.</returns>
         public string Validate()
         {
             string message = string.Empty;
@@ -166,11 +197,15 @@
             return message.Length > 0 ? message : null;
         }
 
+        /// <summary>Updates a check.</summary>
         public void UpdateCheck(Check check)
         {
+            // See if there is already a check with this ID.
             Check oldCheck = Checks.FirstOrDefault(c => c.Id == check.Id);
             if (!ReferenceEquals(check, oldCheck))
             {
+                // If there is already a check, but it is a different object instance,
+                // replace the old check with the new one (or add it if it is new).
                 int index = Checks.IndexOf(oldCheck);
                 if (index == -1)
                     Checks.Add(check);
@@ -180,6 +215,7 @@
             CheckModified?.Invoke(check, new EventArgs());
         }
 
+        /// <summary>Returns true if the server looks empty (no user data has been entered).</summary>
         public bool IsEmpty()
         {
             return Name.IsNullOrEmpty()
@@ -187,6 +223,7 @@
                 && Checks.Count == 0;
         }
 
+        /// <summary>Generates the authentication method based on user preferences.</summary>
         private AuthenticationMethod GetAuthentication()
         {
             if (LoginType == LoginType.Password)
@@ -195,6 +232,7 @@
                 return new PrivateKeyAuthenticationMethod(Username, PrivateKeyFile);
         }
 
+        /// <summary>Releases the open SSH connection.</summary>
         private void InvalidateSshConnection()
         {
             _sshClient?.Dispose();
@@ -207,13 +245,17 @@
         }
     }
 
+    /// <summary>Possible statuses of the private key file.</summary>
     public enum KeyStatus
     {
+        /// <summary>The private key file is closed for an unspecified reason.</summary>
         Closed,
+        /// <summary>The private key file is accessible and open.</summary>
         Open,
+        /// <summary>The private key file is not accessible (missing, access denied, etc).</summary>
         NotAccessible,
+        /// <summary>The private key file is encrypted and the user has not entered the password yet.</summary>
         NeedPassword,
     }
 
-
 }
--- a/ServerMonitor/Objects/ServerMonitor.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/ServerMonitor.cs	Sat May 25 15:14:26 2019 -0400
@@ -4,11 +4,9 @@
 using ServerMonitorApp.Properties;
 using System;
 using System.Collections.Generic;
-using System.Diagnostics;
 using System.IO;
 using System.Linq;
 using System.Net.NetworkInformation;
-using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 using System.Windows.Forms;
@@ -16,51 +14,74 @@
 
 namespace ServerMonitorApp
 {
+    /// <summary>Central class for scheduling and executing checks against remote servers.</summary>
     public class ServerMonitor
     {
         private readonly string configFileDir;
         private readonly Logger logger;
+        // Cancellation tokens for executing checks, keyed by check ID.
         private readonly Dictionary<int, CancellationTokenSource> tokens = new Dictionary<int, CancellationTokenSource>();
+        // SSH private keys, keyed by the path to the private key file.
+        // A value of NULL indicates that the private key is inaccessible or encrypted.
         private readonly Dictionary<string, PrivateKeyFile> privateKeys = new Dictionary<string, PrivateKeyFile>();
+        // IDs of all checks that have been paused due to network unavailability,
+        // or due to the system being suspended.
+        // Not to be confused with checks that have been disabled by the user.
         private readonly List<int> pausedChecks = new List<int>();
         private bool running, networkAvailable, suspend;
+        // List of check execution tasks that have been started.
+        // A check task begins by sleeping until the next scheduled execution time,
+        // then executes.
         private Dictionary<Task<CheckResult>, int> tasks;
         private ServerSummaryForm mainForm;
 
-        //private List<Task<CheckResult>> tasks;
-
+        /// <summary>Fires when the status of a check changes.</summary>
         public event EventHandler<CheckStatusChangedEventArgs> CheckStatusChanged;
 
+        /// <summary>The collection of registered servers.</summary>
         public List<Server> Servers { get; private set; } = new List<Server>();
 
+        /// <summary>A collection of all checks belonging to all registerd servers.</summary>
         public IEnumerable<Check> Checks => Servers.SelectMany(s => s.Checks);
 
+        /// <summary>Path to the file that stores server and check configuration.</summary>
         public string ConfigFile { get; private set; }
 
+        /// <summary>Path to the file that stores server and check configuration.</summary>
         public IEnumerable<string> LockedKeys { get { return privateKeys.Where(kvp => kvp.Value == null).Select(kvp => kvp.Key); } }
 
+        /// <summary>ServerMonitor constructor.</summary>
+        /// <param name="mainForm">A reference to the main form.</param>
         public ServerMonitor(ServerSummaryForm mainForm)
         {
             this.mainForm = mainForm;
+            // Store configuration in %appdata%\ServerMonitor
             configFileDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ServerMonitor");
             ConfigFile = Path.Combine(configFileDir, "servers.xml");
             logger = new Logger(Path.Combine(configFileDir, "monitor.log"));
         }
 
+        /// <summary>Registers a new server with the server monitor.</summary>
+        /// <param name="server">The server to be added.</param>
         public void AddServer(Server server)
         {
             Servers.Add(server);
             SaveServers();
         }
 
+        /// <summary>Deletes a server from the server monitor.</summary>
+        /// <param name="server">The server to be deleted.</param>
         public void DeleteServer(Server server)
         {
             Servers.Remove(server);
             SaveServers();
         }
 
+        /// <summary>Loads all servers and checks from the config file.</summary>
         public void LoadServers()
         {
+            bool triedBackup = false;
+
             Read:
             TextReader reader = null;
             try
@@ -69,16 +90,23 @@
                 XmlSerializer serializer = CreateXmlSerializer();
                 Servers.Clear();
                 Servers.AddRange((List<Server>)serializer.Deserialize(reader));
-                // Update the Checks so they know what Server they belong to.
-                // Would rather do this in the Server object on deserialization, but
-                // that doesn't work when using the XML serializer for some reason.
+                // Do some more set-up now that the servers and checks have been loaded.
                 foreach (Server server in Servers)
                 {
+                    // Read private keys into memory if they are accessible and not encrypted.
+                    // If PrivateKeyFile != null, it means same the key has already been loaded for
+                    // a different server and nothing more needs to be done.
                     if (server.LoginType == LoginType.PrivateKey && server.PrivateKeyFile == null)
                         OpenPrivateKey(server.KeyFile);
                     foreach (Check check in server.Checks)
                     {
+                        // Update the checks so they know what server they belong to.
+                        // Would rather do this in the Server object on deserialization, but
+                        // that doesn't work when using the XML serializer for some reason.
                         check.Server = server;
+                        // If the program last exited while the check was running, change its status
+                        // to the result of its last execution (since, at this point, the check is
+                        // not running).
                         if (check.Status == CheckStatus.Running)
                             check.Status = check.LastRunStatus;
                     }
@@ -92,9 +120,23 @@
             catch (InvalidOperationException)
             {
                 reader?.Close();
-                File.Copy(ConfigFile, ConfigFile + ".error", true);
-                File.Copy(ConfigFile + ".bak", ConfigFile, true);
-                goto Read;
+                // If there was an error parsing the XML, try again with the backup config file.
+                if (!triedBackup)
+                {
+                    File.Copy(ConfigFile, ConfigFile + ".error", true);
+                    string backupConfig = ConfigFile + ".bak";
+                    if (File.Exists(backupConfig))
+                    {
+                        File.Copy(backupConfig, ConfigFile, true);
+                    }
+                    triedBackup = true;
+                    goto Read;
+                }
+                else
+                {
+                    // If there was an error reading the backup file too, give up.
+                    throw;
+                }
             }
             finally
             {
@@ -103,10 +145,14 @@
             Application.ApplicationExit += Application_ApplicationExit;
             NetworkChange.NetworkAddressChanged += NetworkChange_NetworkAddressChanged;
             SystemEvents.PowerModeChanged += SystemEvents_PowerModeChanged;
+
+            // Remove old entries from the log file according to user preferences.
             logger.TrimLog();
+
             Run();
         }
 
+        /// <summary>Saves all servers and checks to the config file.</summary>
         public void SaveServers()
         {
             GenerateIds();
@@ -114,6 +160,7 @@
             XmlSerializer serializer = null;
             try
             {
+                // Make a backup first in case something goes wrong in the middle of writing.
                 File.Copy(ConfigFile, ConfigFile + ".bak", true);
             }
             catch { }
@@ -125,6 +172,7 @@
             }
             catch (DirectoryNotFoundException)
             {
+                // If the directory does not exist, create it and try again.
                 Directory.CreateDirectory(configFileDir);
                 writer = new StreamWriter(ConfigFile);
                 serializer = CreateXmlSerializer();
@@ -136,11 +184,17 @@
             }
         }
 
+        /// <summary>Main server monitor loop. Schedules and executes checks.</summary>
         private async void Run()
         {
+            // Do not run again if already running or if the system is suspending or resuming.
             if (running || suspend)
                 return;
+
             running = true;
+
+            // If the network is available, immediately execute checks that were supposed to run
+            // earlier but could not due to network unavailability or the system being suspended.
             networkAvailable = Helpers.IsNetworkAvailable();
             if (networkAvailable)
             {
@@ -150,41 +204,110 @@
                 }
                 pausedChecks.Clear();
             }
+
+            // Schedule all checks to run according to their schedules.
+            // Each check will sleep until it is scheduled to run, then execute.
             tasks = Checks.ToDictionary(c => ScheduleExecuteCheckAsync(c), c => c.Id);
             while (tasks.Count > 0)
             {
+                // When any check is done sleeping and executing, remove the completed
+                // task and queue a new task to schedule it again.
                 Task<CheckResult> task = await Task.WhenAny(tasks.Keys);
                 tasks.Remove(task);
                 try
                 {
                     CheckResult result = await task;
-                    // Result will be null if a scheduled check was disabled
+                    // Do not schedule the task again if it is now disabled.
+                    // Result will be null if a scheduled check was disabled.
                     if (result != null && result.CheckStatus != CheckStatus.Disabled)
                         tasks.Add(ScheduleExecuteCheckAsync(result.Check), result.Check.Id);
                 }
                 catch (OperationCanceledException)
                 {
-
+                    // When a server's state changes to Disabled, any checks that are executing
+                    // are immediately cancelled. Silently catch these expected exceptions.
                 }
             }
+            // If there are no enabled checks scheduled, exit the main loop.
+            // It will be restarted when a check or server is enabled.
             running = false;
         }
 
+        /// <summary>Schedules a check to be run on its schedule.</summary>
+        /// <param name="check">The check to execute.</param>
+        /// <returns>The async check result.</returns>
+        private async Task<CheckResult> ScheduleExecuteCheckAsync(Check check)
+        {
+            // Do not schedule or execute the check if it or its server is disabled.
+            if (!check.Enabled || !check.Server.Enabled)
+                return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null));
+
+            // Create a cancellation token that will be used to cancel the check if it or
+            // its server is disabled while it is executing.
+            CancellationTokenSource cts = new CancellationTokenSource();
+            tokens[check.Id] = cts;
+
+            // Sleep until next time the check is supposed to be executed.
+            // Use the LastScheduledRunTime so manual executions by the user do not
+            // interfere with the schedule.
+            check.NextRunTime = check.Schedule.GetNextTime(check.LastScheduledRunTime);
+            int delay = Math.Max(0, (int)(check.NextRunTime - DateTime.Now).TotalMilliseconds);
+            await Task.Delay(delay, cts.Token);
+            check.LastScheduledRunTime = check.NextRunTime;
+
+            // Execute the check if not cancelled.
+            if (!cts.IsCancellationRequested)
+            {
+                // If the network is available, execute the check.
+                // Otherwise, add it to the list of paused checks to be executed
+                // when the network becomes available again.
+                if (networkAvailable)
+                {
+                    return await ExecuteCheckAsync(check, cts.Token);
+                }
+                else
+                {
+                    if (!pausedChecks.Contains(check.Id))
+                        pausedChecks.Add(check.Id);
+                }
+            }
+            return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null));
+        }
+
+        /// <summary>Executes a check asynchronously.</summary>
+        /// <param name="check">The check to execute.</param>
+        /// <param name="token">A chancellation token that may be used to cancel the check execution.</param>
+        /// <returns>The async check result.</returns>
         public async Task<CheckResult> ExecuteCheckAsync(Check check, CancellationToken token = default(CancellationToken))
         {
+            // Update the status.
             check.Status = CheckStatus.Running;
             OnCheckStatusChanged(check);
+
+            // Execute the check.
             CheckResult result = await check.ExecuteAsync(token);
+
+            // Increment the consecutive failure counter on failue, or reset
+            // the counter on success.
             if (result.Failed)
                 check.ConsecutiveFailures++;
+            else
+                check.ConsecutiveFailures = 0;
+
             OnCheckStatusChanged(check, result);
             HandleResultAsync(result);
             return result;
         }
 
+        /// <summary>Handles the result of a check execution.</summary>
+        /// <param name="result">The result.</param>
         private void HandleResultAsync(CheckResult result)
         {
+            // Log the result.
             logger.Log(result);
+
+            // Notify the user of failure according to user preferences.
+            // If the check succeeded, result.FailAction will be None.
             if (result.Check.ConsecutiveFailures >= result.Check.MaxConsecutiveFailures)
             {
                 if (result.FailAction == FailAction.FlashTaskbar)
@@ -194,31 +317,41 @@
             }
         }
 
+        /// <summary>Reads all check results from the log for a server.</summary>
+        /// <param name="server">The server whose check results should be read.</param>
+        /// <returns>A list of all check results found in the log file for the given server.</returns>
         public IList<CheckResult> GetLog(Server server)
         {
             return logger.Read(server);
         }
 
+        /// <summary>Saves the check settings and notifies event subscribers when the status of a check changes.</summary>
+        /// <param name="check">The check whose status has changed.</param>
+        /// <param name="result">The check result that caused the status to change, if any.</param>
         private void OnCheckStatusChanged(Check check, CheckResult result = null)
         {
             SaveServers();
             CheckStatusChanged?.Invoke(check, new CheckStatusChangedEventArgs(check, result));
         }
 
+        /// <summary>Handles user modifications to a check's settings.</summary>
+        /// <param name="sender">The check that was modified.</param>
         private void Server_CheckModified(object sender, EventArgs e)
         {
             Check check = (Check)sender;
-            Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key;
+
+            // No need to mess with the task queue if not currently running.
             if (running)
             {
+                Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key;
                 if (task == null)
                 {
-                    // No tasks associated with the check, so schedule a new one
+                    // No tasks associated with the check, so schedule a new one.
                     tasks.Add(ScheduleExecuteCheckAsync(check), check.Id);
                 }
                 else
                 {
-                    // Check was modified or deleted, so remove any waiting tasks
+                    // Check was modified or deleted, so remove any waiting tasks.
                     CancelCheck(check);
                     if (check.Server != null)
                     {
@@ -230,19 +363,26 @@
                     }
                 }
             }
-            // Run again in case removing a task above caused it to stop
+            // Run again in case removing a task above caused it to stop.
             Run();
         }
 
+        /// <summary>Handles the enabled state of a server changing.</summary>
+        /// <param name="sender">The server that was enabled or disabled.</param>
         private void Server_EnabledChanged(object sender, EventArgs e)
         {
             Server server = (Server)sender;
+
+            // Make sure the monitor is running. If no servers were enabled before this
+            // one was enabled, it is not running.
             if (server.Enabled)
             {
                 Run();
             }
             else
             {
+                // Cancel all queued and executing checks belonging to a
+                // server that was disabled.
                 foreach (Check check in server.Checks)
                 {
                     CancelCheck(check);
@@ -250,50 +390,42 @@
             }
         }
 
+        /// <summary>Cancels a check that may be executing.</summary>
+        /// <param name="check">The check to cancel.</param>
         private void CancelCheck(Check check)
         {
             if (tasks == null)
                 return;
+
+            // Find the waiting or executing task for the check and remove it.
             Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key;
             if (task != null)
                 tasks.Remove(task);
+
+            // Remove it from the list of paused checks so it doesn't get restarted later.
             pausedChecks.RemoveAll(id => id == check.Id);
+
+            // Cancel the current execution.
             if (tokens.TryGetValue(check.Id, out CancellationTokenSource cts))
                 cts.Cancel();
         }
 
-        private async Task<CheckResult> ScheduleExecuteCheckAsync(Check check)
-        {
-            if (!check.Enabled || !check.Server.Enabled)
-                return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null));
-
-            CancellationTokenSource cts = new CancellationTokenSource();
-            tokens[check.Id] = cts;
-            check.NextRunTime = check.Schedule.GetNextTime(check.LastScheduledRunTime);
-            int delay = Math.Max(0, (int)(check.NextRunTime - DateTime.Now).TotalMilliseconds);
-            await Task.Delay(delay, cts.Token);
-            check.LastScheduledRunTime = check.NextRunTime;
-            if (networkAvailable && !cts.IsCancellationRequested)
-            {
-                return await ExecuteCheckAsync(check, cts.Token);
-            }
-            else
-            {
-                if (!pausedChecks.Contains(check.Id))
-                    pausedChecks.Add(check.Id);
-                return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null));
-            }
-        }
-
+        /// <summary>Handles network state changing.</summary>
         private void NetworkChange_NetworkAddressChanged(object sender, EventArgs e)
         {
             networkAvailable = Helpers.IsNetworkAvailable();
+            // If the network is available, it might not have been before.
+            // This method is not called from the correct thread, so special
+            // handling is needed to start it on the UI thread again.
             if (networkAvailable)
                 mainForm.Invoke((MethodInvoker)(() => Run()));
         }
 
+        /// <summary>Handles system power mode changes.</summary>
         private async void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e)
         {
+            // If the system is being suspended, cancel all waiting and executing checks.
+            // Once all the checks are removed, the main loop will exit.
             if (e.Mode == PowerModes.Suspend)
             {
                 foreach (Check check in Checks)
@@ -304,32 +436,45 @@
             }
             else if (e.Mode == PowerModes.Resume)
             {
+                // When resuming from suspend, examine each check to find out if it was
+                // scheduled to be executed during the time period when the systems was
+                // suspended. Add them to the paused checks list, to be executed almost
+                // immediately.
+                // Make sure the list is empty to start.
                 pausedChecks.Clear();
                 foreach (Check check in Checks)
                 {
-                    //CancelCheck(check);
                     if (check.Enabled && check.Server.Enabled && check.NextRunTime < DateTime.Now)
                     {
                         pausedChecks.Add(check.Id);
                     }
                 }
+                // Wait 10 seconds to give things time to quiet down after resuming.
                 await Task.Delay(10000);
                 suspend = false;
                 Run();
             }
         }
 
+        /// <summary>Unregister system events when exiting.</summary>
         private void Application_ApplicationExit(object sender, EventArgs e)
         {
             NetworkChange.NetworkAddressChanged -= NetworkChange_NetworkAddressChanged;
             SystemEvents.PowerModeChanged -= SystemEvents_PowerModeChanged;
         }
 
+        /// <summary>Attempts to read a private file.</summary>
+        /// <param name="path">The path to the private key file.</param>
+        /// <param name="password">The password used to encrypt the key.</param>
+        /// <returns>A status indicating the result of the attempt.</returns>
         public KeyStatus OpenPrivateKey(string path, string password = null)
         {
             KeyStatus keyStatus;
+
             if (path == null)
                 keyStatus = KeyStatus.NotAccessible;
+
+            // Check if the key has already been open and read.
             if (privateKeys.TryGetValue(path, out PrivateKeyFile key) && key != null)
                 keyStatus = KeyStatus.Open;
             else
@@ -339,15 +484,20 @@
                     key = new PrivateKeyFile(path, password);
                     keyStatus = KeyStatus.Open;
                 }
+                // If the key is encrypted and the password is empty or incorrect,
+                // return the NeedPassword status.
                 catch (Exception e) when (e is SshPassPhraseNullOrEmptyException || e is InvalidOperationException)
                 {
                     keyStatus = KeyStatus.NeedPassword;
                 }
+                // For any other failure reason, return the NotAccessible status.
                 catch (Exception)
                 {
                     keyStatus = KeyStatus.NotAccessible;
                 }
             }
+            // A single private key may be used by multiple servers. Update all servers
+            // that use this private key with the results of the above operations.
             foreach (Server server in Servers)
             {
                 if (server.KeyFile == path)
@@ -356,14 +506,21 @@
                     server.KeyStatus = keyStatus;
                 }
             }
+            // Keep a reference to this private key so we don't have to re-open
+            // it later if the same key is used on a different server.
             privateKeys[path] = key;
+
             return keyStatus;
         }
 
+        /// <summary>Generates internal IDs for servers and checks.</summary>
         private void GenerateIds()
         {
             if (Servers.Any())
             {
+                // Start at the maximum ID to make sure IDs are not reused
+                // if a server was deleted so old log entries do not get associated
+                // with a new server.
                 int id = Servers.Max(s => s.Id);
                 foreach (Server server in Servers)
                 {
@@ -374,6 +531,8 @@
 
             if (Checks.Any())
             {
+                // Start with the max check ID, same reasons as above.
+                // Is there a reason this is stored in a setting?
                 int id = Math.Max(Settings.Default.MaxCheckId, Checks.Max(c => c.Id));
                 foreach (Check check in Checks)
                 {
@@ -385,16 +544,21 @@
             }
         }
 
+        /// <summary>Creates an XML serializer that can handle servers and all check types.</summary>
+        /// <returns>An XML serializer that can handle servers and all check types.</returns>
         private XmlSerializer CreateXmlSerializer()
         {
             return new XmlSerializer(typeof(List<Server>), Check.CheckTypes);
         }
     }
 
+    /// <summary>Event arguments for when a check status changes.</summary>
     public class CheckStatusChangedEventArgs : EventArgs
     {
+        /// <summary>The check whose status changed.</summary>
         public Check Check { get; private set; }
 
+        /// <summary>The check result that caused the status to change, if any.</summary>
         public CheckResult CheckResult { get; private set; }
 
         public CheckStatusChangedEventArgs(Check check, CheckResult result)
@@ -404,5 +568,15 @@
         }
     }
 
-    public enum FailAction { FlashTaskbar = 0, NotificationBalloon = 1, None = 10 }
+    /// <summary>Possible actions that may be taken when a check fails.</summary>
+    public enum FailAction
+    {
+        /// <summary>Flashes the Server Monitor tasbar program icon.</summary>
+        FlashTaskbar = 0,
+        /// <summary>Shows a balloon in the notification area.</summary>
+        NotificationBalloon = 1,
+        /// <summary>Take no action.</summary>
+        None = 10
+    }
+
 }
--- a/ServerMonitor/Objects/UpdateCheckException.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Objects/UpdateCheckException.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,8 +1,8 @@
 using System;
-using System.Runtime.Serialization;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Thrown when invalid data is entered on the check settings form.</summary>
     public class UpdateCheckException : Exception
     {
         public UpdateCheckException(string message) : base(message)
--- a/ServerMonitor/Program.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Program.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,5 +1,4 @@
 using System;
-using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using System.Windows.Forms;
@@ -16,8 +15,10 @@
         [STAThread]
         static void Main()
         {
+            // Check if the program is already running.
             if (mutex.WaitOne(TimeSpan.Zero, true))
             {
+                // Show unhandled exceptions to the user.
                 Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException);
                 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
                 AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
@@ -28,6 +29,8 @@
             }
             else
             {
+                // If the program is already running, send a message to the main form
+                // asking it to show itself, then exit.
                 Win32Helpers.PostMessage(
                     (IntPtr)Win32Helpers.HWND_BROADCAST,
                     Win32Helpers.WM_SHOWMONITOR,
@@ -36,18 +39,22 @@
             }
         }
 
+        /// <summary>Shows unhandled exceptions in a message box.</summary>
         static void Application_ThreadException(object sender, ThreadExceptionEventArgs e)
         {
-            //if (!System.Diagnostics.Debugger.IsAttached)
+            if (!System.Diagnostics.Debugger.IsAttached)
                 ShowError(e.Exception);
         }
 
+        /// <summary>Shows unhandled exceptions in a message box.</summary>
         static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
         {
-            //if (!System.Diagnostics.Debugger.IsAttached)
+            if (!System.Diagnostics.Debugger.IsAttached)
                 ShowError(e.ExceptionObject as Exception);
         }
 
+        /// <summary>Shows exception details in a message box.</summary>
+        /// <param name="e">The exception to show.</param>
         static void ShowError(Exception e)
         {
             string simpleStackTrace = string.Join(Environment.NewLine, new System.Diagnostics.StackTrace(e).GetFrames()
--- a/ServerMonitor/ServerMonitor.csproj	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/ServerMonitor.csproj	Sat May 25 15:14:26 2019 -0400
@@ -236,12 +236,6 @@
     <None Include="App.config" />
   </ItemGroup>
   <ItemGroup>
-    <None Include="Resources\Icon1.ico" />
-  </ItemGroup>
-  <ItemGroup>
-    <None Include="Resources\Image1.png" />
-  </ItemGroup>
-  <ItemGroup>
     <None Include="Resources\delete.png" />
   </ItemGroup>
   <ItemGroup>
--- a/ServerMonitor/Win32Helpers.cs	Tue Apr 30 20:40:58 2019 -0400
+++ b/ServerMonitor/Win32Helpers.cs	Sat May 25 15:14:26 2019 -0400
@@ -1,13 +1,10 @@
 using System;
-using System.Collections.Generic;
-using System.Linq;
 using System.Runtime.InteropServices;
-using System.Text;
-using System.Threading.Tasks;
 using System.Windows.Forms;
 
 namespace ServerMonitorApp
 {
+    /// <summary>Methods for interacting with the Win32 API.</summary>
     class Win32Helpers
     {
         [DllImport("user32.dll")]
@@ -65,6 +62,8 @@
             FLASHW_TIMERNOFG = 12
         }
 
+        /// <summary>Flashes a window icon in the taskbar.</summary>
+        /// <param name="form">The form to flash.</param>
         public static bool FlashWindowEx(Form form)
         {
             IntPtr hWnd = form.Handle;
@@ -79,6 +78,8 @@
             return FlashWindowEx(ref fInfo);
         }
 
+        /// <summary>Stops flashing a window icon in the taskbar.</summary>
+        /// <param name="form">The form to stop flashing.</param>
         public static bool StopFlashWindowEx(Form form)
         {
             IntPtr hWnd = form.Handle;