# HG changeset patch # User Brad Greco # Date 1558811666 14400 # Node ID 68d7834dc28ec58dfa51e0cf97b2b5dc1464c88a # Parent 7626b099aefd8d6e03ca834d74d7917b8c94f198 More comments. diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Attributes.cs --- 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 { + /// + /// Attribute for use by check controls to indicate + /// the type of check they correspond to. + /// internal class CheckTypeAttribute : Attribute { public Type CheckType { get; private set; } @@ -12,6 +13,10 @@ public CheckTypeAttribute(Type checkType) { CheckType = checkType; } } + /// + /// Attribute for use by checks to determine the order + /// they appear in the check type selection combo box. + /// internal class DisplayWeightAttribute : Attribute { public int DisplayWeight { get; private set; } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Controls/SizeUnitsComboBox.cs --- 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 @@ } /// Size units. + /// The integer values must equal the power of 1024 needed to convert from bytes to each unit. public enum SizeUnits { B = 0, KB = 1, MB = 2, GB = 3 } } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Helpers.cs --- 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 { + /// Resizes the image on a button to fit inside the button's dimensions. + /// A button containing an image. public static void FormatImageButton(Button button) { button.Image = new Bitmap(button.Image, button.Height - 8, button.Height - 8); } + /// Recursively collects the messages for an exception and its inner exceptions. + /// The parent exception. + /// A string containing the messages for the exception and its inner exceptions. public static string GetAllMessages(this Exception ex) { return "\t" + (ex.InnerException == null ? ex.Message : ex.Message + Environment.NewLine + ex.InnerException.GetAllMessages()); } + /// Gets the value of the DisplayNameAttribute for a type. + /// The type to get the display name of. + /// The type's display name, or the type name if no display name is defined. public static string GetDisplayName(Type type) { return type?.GetAttribute()?.DisplayName ?? type?.Name ?? "null"; } + /// Checks whether a string is null, an empty string, or only contains white space. + /// The string to test. + /// True if the string is null, an empty string, or only contains white space. False otherwise. public static bool IsNullOrEmpty(this string aString) { return aString == null || aString.Trim() == string.Empty; } + /// Converts all newlines in a string to unix format. + /// The string to convert. + /// The string with all newlines converted to unix format. public static string ConvertNewlines(this string aString) { return aString.Replace("\r\n", "\n").Replace('\r', '\n'); } + /// Gets an attribute on a class. + /// The type of the attribute to return. + /// The type of the class the attribute is on. + /// The attribute, or null if the attribute does not exist on the class. public static T GetAttribute(this Type type) where T : Attribute { return type.GetCustomAttributes(typeof(T), false).SingleOrDefault() as T; } + /// Gets an image associated with a check status for use in the UI. + /// The check status. + /// The image associated with the check status. public static Image GetImage(this CheckStatus checkStatus) { switch (checkStatus) @@ -58,6 +75,9 @@ } } + /// Gets a program icon associated with a check status. + /// The check status. + /// The program icon associated with the check status. public static Icon GetIcon(this CheckStatus checkStatus) { switch (checkStatus) @@ -69,6 +89,10 @@ } } + /// Returns whether an enum is in a list of values. + /// The value to check. + /// The list of possible values. + /// True if the value was in the list, false otherwise. public static bool In(this Enum value, params Enum[] values) { return values.Contains(value); } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/CheckResult.cs --- 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 { + /// + /// The result of an executed check. + /// Contains data about the check's last execution including status, time, and log message. + /// 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"; + /// The originating check of this check result. public Check Check { get; private set; } + /// The result status of the check execution. public CheckStatus CheckStatus { get; private set; } + /// The message generated by the check execution. public string Message { get; set; } + /// The time the check execution began. public DateTime StartTime { get; set; } + /// The time the check execution ended. public DateTime EndTime { get; set; } + /// Whether the check execution resulted in success or failure. public bool Failed => CheckStatus.In(CheckStatus.Error, CheckStatus.Warning, CheckStatus.Information); + /// Action to perform when the check fails. 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; - + /// CheckResult constructor. + /// The originating check of this check result. + /// The result status of the check execution. + /// The message generated by the check execution. public CheckResult(Check check, CheckStatus status, string message) { Check = check; @@ -43,22 +58,42 @@ Message = message; } + /// Generates a string representation of the check result that can be logged. + /// A string representation of the check result that can be logged. + /// + /// 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. + /// 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")); } + /// Parses a log string to create a check result object. + /// The originating check for the check result. + /// The log string to parse. + /// A check result object. 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 }; } } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Checks/DiskSpaceCheck.cs --- 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 { + /// Checks the available disk space on a remote server. [DisplayName("Disk space check"), Description("Check the remaining free disk space"), DisplayWeight(11)] public class DiskSpaceCheck : SshCheck { + /// The command to execute. Must return the POSIX output format of df(1). public override string Command => string.Format(DiskSpaceCommand, Device); + /// The command to execute, with a placeholder of {0} for the device to check. protected string DiskSpaceCommand => "df -P -k {0}"; + /// The device or file on the device to check. public string Device { get; set; } + /// The minimum free space allowed for the check to pass. public double MinFreeSpace { get; set; } + /// The storage units or percentage for MinFreeSpace. public FreeSpaceUnits FreeSpaceUnits { get; set; } public DiskSpaceCheck() { + // Set general SSH check settings for disk space checks. CheckExitCode = true; ExitCode = 0; } + /// Processes the output of the disk space check command. protected override List ProcessCommandResult(string output, int exitCode) { + // Check for general SSH failures. List 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 + + */ + // Split the output into lines and remove the header row. List 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; } + /// Validates disk space check options. public override string Validate(bool saving = true) { string message = base.Validate(); @@ -92,5 +112,14 @@ } } - public enum FreeSpaceUnits { MB = 0, GB = 1, percent = 2 } + /// The units to use when testing available disk space. + public enum FreeSpaceUnits + { + /// Tests available disk space in megabytes. + MB = 0, + /// Tests available disk space in gigabytes. + GB = 1, + /// Tests available disk space as a percentage of the total space. + percent = 2 + } } \ No newline at end of file diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Checks/FileCheck.cs --- 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 { + /// Checks file metadata on a remote server. [DisplayName("File check"), Description("Check file size or modified time"), DisplayWeight(12)] public class FileCheck : SshCheck { + /// The command to execute. Must return the output format of GNU ls(1) with the long-iso time style. + /// Would be better to not rely on the output of ls. public override string Command => string.Format(FileCommand, Regex.Replace(File, "^~", "$HOME")); + /// The command to execute, with a placeholder of {0} for the file to check. 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}\""; } } + /// The path of the file to check. public string File { get; set; } + /// Whether the file size will be checked. public bool CheckFileSize { get; set; } + /// Whether the file is expected to be less than or greater than a certain size. public bool FileSizeLessThan { get; set; } + /// The size to compare the file against in bytes. public int FileSize { get; set; } + /// The size to compare the file against in the selected size units. public double FileSizeInSelectedUnits { get => Math.Round(ConvertBytesToSelectedUnits(FileSize), 1); set => FileSize = ConvertSelectedUnitsToBytes(value); } + /// The units of the file size. public SizeUnits FileSizeUnits { get; set; } + /// Whether the file modified time will be checked. public bool CheckDateModified { get; set; } + /// Whether the file is expected to be older than or newer than a certain date. public bool DateModifiedOlderThan { get; set; } + /// The number of time units to compare then file modified time against. public double DateModified { get; set; } + /// The units of the file date modified. public TimeUnits DateModifiedUnits { get; set; } public FileCheck() { + // Set general SSH check settings for file checks. CheckExitCode = true; ExitCode = 0; } + /// Processes the output of the file check command. protected override List ProcessCommandResult(string output, int exitCode) { + // Check for general SSH failures. List 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; } + /// Validates file check options. public override string Validate(bool saving = true) { string message = base.Validate(); @@ -119,11 +145,17 @@ return message; } + /// Converts bytes to a different size unit. + /// The size to convert in bytes. + /// The size in the units used by this file check. private double ConvertBytesToSelectedUnits(int sizeInBytes) { return sizeInBytes / Math.Pow(1024, (double)FileSizeUnits); } + /// Converts a size in a different unit to bytes. + /// The size to convert in the units used by this file check. + /// The size in bytes. private int ConvertSelectedUnitsToBytes(double sizeInSelectedUnits) { return (int)(sizeInSelectedUnits * Math.Pow(1024, (int)FileSizeUnits)); diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Checks/HttpCheck.cs --- 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 ExecuteCheckAsync(CancellationToken token) { throw new NotImplementedException(); + + + + + // Cancellable version that doesn't actulaly work + // might be useful as a TaskCompletionSource example though + // + //TaskCompletionSource tcs = new TaskCompletionSource + // { + // 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) diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Checks/PingCheck.cs --- 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 { + /// Checks that a server responds to ping. [DisplayName("Ping check"), Description("Check if the server responds to a ping request"), DisplayWeight(0)] public class PingCheck : Check { + /// Sends a ping and waits for a response. protected async override Task 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 tcs = new TaskCompletionSource - // { - // 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; } } } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Checks/SshCheck.cs --- 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 { + /// Executes an SSH command and checks the output or exit code. [DisplayName("SSH check"), Description("Check the result of a command run over SSH"), DisplayWeight(10)] public class SshCheck : Check { + /// The command to execute on the server. public virtual string Command { get; set; } + /// Whether the exit code of the command should be checked. public bool CheckExitCode { get; set; } + /// The required exit code of the command if CheckExitCode is true. public int ExitCode { get; set; } + /// Whether the text output of the command should be checked. public bool CheckCommandOutput { get; set; } + /// The method to use when checking the command output against the pattern. public MatchType CommandOutputMatchType { get; set; } + /// The string or pattern that the command output must match if CommandOutputMatchType is true. public string CommandOutputPattern { get; set; } + /// Whether the CommandOutputPattern should be interpreted as a regular expression. public bool CommandOutputUseRegex { get; set; } + /// Executes the SSH command on the server. protected override Task 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); } + /// Processes the command results and checks they match the expected values. + /// The command output. + /// The command exit code. + /// A list of check results according to user preferences. protected virtual List ProcessCommandResult(string output, int exitCode) { List results = new List(); + // 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; } + /// Validates SSH check options. public override string Validate(bool saving = true) { string message = base.Validate(); diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Logger.cs --- 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 { + /// Manages reading and writing check results to a log file. public class Logger { private const int logVersion = 1; private readonly string logFile; + /// Logger constructor. + /// The path of the log file to use. public Logger(string file) { logFile = file; } + /// Appends a string to the log file, creating the log file if it does not exist. + /// The string to log. 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 @@ } } + /// Appends a check result to the log file, creating the log file if it does not exist. + /// The check result to log. public void Log(CheckResult result) { Log(result.ToLogString()); } + /// Reads all check results from the log for a server. + /// The server whose check results should be read. + /// A list of all check results found in the log file for the given server. public IList Read(Server server) { + // Store the checks by ID. Dictionary checks = server.Checks.ToDictionary(c => c.Id); List results = new List(); 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; } + /// Removes old entries from the log. 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); } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Schedule.cs --- 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 { + /// Schedule to control when a check is automatically executed. public class Schedule { + // Required empty constructor for XML serialization. public Schedule() { } + /// Schedule constructor + /// The time units to use. + /// How frequently to run the check. + /// Time of day the check should begin running. + /// Time of day the check should stop running. + /// + /// 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. + /// public Schedule(FrequencyUnits units, int frequency, TimeSpan startTime, TimeSpan endTime) { Units = units; @@ -14,32 +26,57 @@ EndTime = endTime; } + /// How often the check should be executed. public int Frequency { get; set; } + /// The time units used to interpret the frequency. public FrequencyUnits Units { get; set; } + /// Time of day the check should begin running. public TimeSpan StartTime { get; set; } + /// Time of day the check should stop running. public TimeSpan EndTime { get; set; } + /// Given the last time a check was scheduled to run, calculates the next time in the future it should run. + /// The last time a check was scheduled to run. public DateTime GetNextTime(DateTime lastScheduledTime) { return GetNextTime(lastScheduledTime, DateTime.Now); } + /// Given the last time a check was scheduled to run, calculates the next time after the given date it should run. + /// The last time a check was scheduled to run. + /// The earliest allowed time to return. + /// + /// 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. + /// 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 @@ } } + /// Units of time that a check can be scheduled to run in intervals of. public enum FrequencyUnits { Second, Minute, Hour, Day } } \ No newline at end of file diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/Server.cs --- 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 { + /// Types of SSH logins supported by the server monitor. public enum LoginType { PrivateKey = 0, Password = 1 }; + /// Remote server that checks can be run against. public class Server { private string _host; @@ -24,59 +24,78 @@ private byte[] passwordHash; private PrivateKeyFile _privateKeyFile; + /// Fires when a check belonging to this server is modifed. public event EventHandler CheckModified; + /// Fires when the server enabled state changes. public event EventHandler EnabledChanged; + /// The checks that belong to this server. public readonly BindingList Checks = new BindingList(); + /// Internal ID of the server. public int Id { get; set; } + /// Name of the server. public string Name { get; set; } + /// Hostname of the server. public string Host { get { return _host; } set { _host = value; InvalidateSshConnection(); } } + /// Port to use when connecting using SSH. public int Port { get { return _port; } set { _port = value; InvalidateSshConnection(); } } + /// Username to use when connecting using SSH. public string Username { get { return _username; } set { _username = value; InvalidateSshConnection(); } } + /// Login type to use when connecting using SSH. public LoginType LoginType { get { return _loginType; } set { _loginType = value; InvalidateSshConnection(); } } + /// Path to the private key file to use when connecting using SSH. public string KeyFile { get { return _keyFile; } set { _keyFile = value; InvalidateSshConnection(); } } + /// Password to use when connecting using SSH. + /// The password is encrypted using the current Windows user account. 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); } } + /// Private key file to use when connecting using SSH. + /// + /// If the private key file is encrypted, will be null until the user enters + /// the decryption key. + /// [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 @@ } } + /// The current status of the private key file. public KeyStatus KeyStatus { get; set; } + /// Whether this server's checks will be automatically executed on their schedules. 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; } - + /// The status of the server. + /// + /// 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. + /// public CheckStatus Status => !Enabled ? CheckStatus.Disabled : Checks .Where(c => c.Enabled) .Select(c => c.LastRunStatus) .DefaultIfEmpty(CheckStatus.Success) .Max(); + /// The SSH client to use when running checks on the server. + /// + /// The connection is stored and kept open at the server level so it can be reused + /// by all SSH checks. + /// 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; - }*/ - + /// Deletes a check from the server. + /// The check to delete. public void DeleteCheck(Check check) { Checks.Remove(check); @@ -156,6 +185,8 @@ CheckModified?.Invoke(check, new EventArgs()); } + /// Validates server settings. + /// An empty string if the server is valid, or the reason the server is invalid. public string Validate() { string message = string.Empty; @@ -166,11 +197,15 @@ return message.Length > 0 ? message : null; } + /// Updates a check. 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()); } + /// Returns true if the server looks empty (no user data has been entered). public bool IsEmpty() { return Name.IsNullOrEmpty() @@ -187,6 +223,7 @@ && Checks.Count == 0; } + /// Generates the authentication method based on user preferences. private AuthenticationMethod GetAuthentication() { if (LoginType == LoginType.Password) @@ -195,6 +232,7 @@ return new PrivateKeyAuthenticationMethod(Username, PrivateKeyFile); } + /// Releases the open SSH connection. private void InvalidateSshConnection() { _sshClient?.Dispose(); @@ -207,13 +245,17 @@ } } + /// Possible statuses of the private key file. public enum KeyStatus { + /// The private key file is closed for an unspecified reason. Closed, + /// The private key file is accessible and open. Open, + /// The private key file is not accessible (missing, access denied, etc). NotAccessible, + /// The private key file is encrypted and the user has not entered the password yet. NeedPassword, } - } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/ServerMonitor.cs --- 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 { + /// Central class for scheduling and executing checks against remote servers. public class ServerMonitor { private readonly string configFileDir; private readonly Logger logger; + // Cancellation tokens for executing checks, keyed by check ID. private readonly Dictionary tokens = new Dictionary(); + // 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 privateKeys = new Dictionary(); + // 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 pausedChecks = new List(); 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, int> tasks; private ServerSummaryForm mainForm; - //private List> tasks; - + /// Fires when the status of a check changes. public event EventHandler CheckStatusChanged; + /// The collection of registered servers. public List Servers { get; private set; } = new List(); + /// A collection of all checks belonging to all registerd servers. public IEnumerable Checks => Servers.SelectMany(s => s.Checks); + /// Path to the file that stores server and check configuration. public string ConfigFile { get; private set; } + /// Path to the file that stores server and check configuration. public IEnumerable LockedKeys { get { return privateKeys.Where(kvp => kvp.Value == null).Select(kvp => kvp.Key); } } + /// ServerMonitor constructor. + /// A reference to the main form. 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")); } + /// Registers a new server with the server monitor. + /// The server to be added. public void AddServer(Server server) { Servers.Add(server); SaveServers(); } + /// Deletes a server from the server monitor. + /// The server to be deleted. public void DeleteServer(Server server) { Servers.Remove(server); SaveServers(); } + /// Loads all servers and checks from the config file. public void LoadServers() { + bool triedBackup = false; + Read: TextReader reader = null; try @@ -69,16 +90,23 @@ XmlSerializer serializer = CreateXmlSerializer(); Servers.Clear(); Servers.AddRange((List)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(); } + /// Saves all servers and checks to the config file. 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 @@ } } + /// Main server monitor loop. Schedules and executes checks. 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 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; } + /// Schedules a check to be run on its schedule. + /// The check to execute. + /// The async check result. + private async Task 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)); + } + + /// Executes a check asynchronously. + /// The check to execute. + /// A chancellation token that may be used to cancel the check execution. + /// The async check result. public async Task 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; } + /// Handles the result of a check execution. + /// The result. 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 @@ } } + /// Reads all check results from the log for a server. + /// The server whose check results should be read. + /// A list of all check results found in the log file for the given server. public IList GetLog(Server server) { return logger.Read(server); } + /// Saves the check settings and notifies event subscribers when the status of a check changes. + /// The check whose status has changed. + /// The check result that caused the status to change, if any. private void OnCheckStatusChanged(Check check, CheckResult result = null) { SaveServers(); CheckStatusChanged?.Invoke(check, new CheckStatusChangedEventArgs(check, result)); } + /// Handles user modifications to a check's settings. + /// The check that was modified. private void Server_CheckModified(object sender, EventArgs e) { Check check = (Check)sender; - Task task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key; + + // No need to mess with the task queue if not currently running. if (running) { + Task 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(); } + /// Handles the enabled state of a server changing. + /// The server that was enabled or disabled. 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 @@ } } + /// Cancels a check that may be executing. + /// The check to cancel. private void CancelCheck(Check check) { if (tasks == null) return; + + // Find the waiting or executing task for the check and remove it. Task 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 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)); - } - } - + /// Handles network state changing. 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())); } + /// Handles system power mode changes. 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(); } } + /// Unregister system events when exiting. private void Application_ApplicationExit(object sender, EventArgs e) { NetworkChange.NetworkAddressChanged -= NetworkChange_NetworkAddressChanged; SystemEvents.PowerModeChanged -= SystemEvents_PowerModeChanged; } + /// Attempts to read a private file. + /// The path to the private key file. + /// The password used to encrypt the key. + /// A status indicating the result of the attempt. 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; } + /// Generates internal IDs for servers and checks. 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 @@ } } + /// Creates an XML serializer that can handle servers and all check types. + /// An XML serializer that can handle servers and all check types. private XmlSerializer CreateXmlSerializer() { return new XmlSerializer(typeof(List), Check.CheckTypes); } } + /// Event arguments for when a check status changes. public class CheckStatusChangedEventArgs : EventArgs { + /// The check whose status changed. public Check Check { get; private set; } + /// The check result that caused the status to change, if any. 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 } + /// Possible actions that may be taken when a check fails. + public enum FailAction + { + /// Flashes the Server Monitor tasbar program icon. + FlashTaskbar = 0, + /// Shows a balloon in the notification area. + NotificationBalloon = 1, + /// Take no action. + None = 10 + } + } diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Objects/UpdateCheckException.cs --- 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 { + /// Thrown when invalid data is entered on the check settings form. public class UpdateCheckException : Exception { public UpdateCheckException(string message) : base(message) diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Program.cs --- 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 @@ } } + /// Shows unhandled exceptions in a message box. static void Application_ThreadException(object sender, ThreadExceptionEventArgs e) { - //if (!System.Diagnostics.Debugger.IsAttached) + if (!System.Diagnostics.Debugger.IsAttached) ShowError(e.Exception); } + /// Shows unhandled exceptions in a message box. static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { - //if (!System.Diagnostics.Debugger.IsAttached) + if (!System.Diagnostics.Debugger.IsAttached) ShowError(e.ExceptionObject as Exception); } + /// Shows exception details in a message box. + /// The exception to show. static void ShowError(Exception e) { string simpleStackTrace = string.Join(Environment.NewLine, new System.Diagnostics.StackTrace(e).GetFrames() diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/ServerMonitor.csproj --- 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 @@ - - - - - - diff -r 7626b099aefd -r 68d7834dc28e ServerMonitor/Win32Helpers.cs --- 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 { + /// Methods for interacting with the Win32 API. class Win32Helpers { [DllImport("user32.dll")] @@ -65,6 +62,8 @@ FLASHW_TIMERNOFG = 12 } + /// Flashes a window icon in the taskbar. + /// The form to flash. public static bool FlashWindowEx(Form form) { IntPtr hWnd = form.Handle; @@ -79,6 +78,8 @@ return FlashWindowEx(ref fInfo); } + /// Stops flashing a window icon in the taskbar. + /// The form to stop flashing. public static bool StopFlashWindowEx(Form form) { IntPtr hWnd = form.Handle;