Updated logging plus more resilient coding to allow better run experience

This commit is contained in:
2026-06-29 13:34:05 +02:00
parent 105f5ac7d5
commit 2dab6f970a
19 changed files with 2052 additions and 60 deletions
+7
View File
@@ -24,6 +24,13 @@ _ReSharper*/
[TestResult*/
artifacts/
publish/
*.zip
*.zip.txt
*.zip.b64
*.zip.b64.txt
*.base64
*.base64.txt
*.certutil.txt
HIP-VMs.txt
hostcheck.ps
@@ -9,12 +9,32 @@ using Microsoft.BizTalk.ExplorerOM;
namespace BizTalkMonitorConfigGenerator.BizTalk
{
/// <summary>
/// Discovers monitorable endpoints from the BizTalk management catalog.
/// </summary>
public sealed class BizTalkCatalogDiscoveryService
{
/// <summary>
/// BizTalk discovery options.
/// </summary>
private readonly BizTalkOptions _options;
/// <summary>
/// Output defaults used while normalizing endpoints.
/// </summary>
private readonly OutputOptions _outputOptions;
/// <summary>
/// Logger used for discovery decisions.
/// </summary>
private readonly GeneratorFileLogger _logger;
/// <summary>
/// Initializes a new BizTalk catalog discovery service.
/// </summary>
/// <param name="options">BizTalk discovery options.</param>
/// <param name="outputOptions">Output defaults for generated targets.</param>
/// <param name="logger">Discovery logger.</param>
public BizTalkCatalogDiscoveryService(BizTalkOptions options, OutputOptions outputOptions, GeneratorFileLogger logger)
{
_options = options ?? new BizTalkOptions();
@@ -22,10 +42,17 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
_logger = logger;
}
/// <summary>
/// Discovers all configured BizTalk artifact endpoints and returns monitor-compatible endpoints.
/// </summary>
/// <returns>The discovered endpoints sorted by application, target name and endpoint.</returns>
public IReadOnlyCollection<DiscoveredEndpoint> Discover()
{
var discovered = new List<DiscoveredEndpoint>();
var inspectedApplications = 0;
var skippedApplications = 0;
_logger.Info("BizTalk-Katalog wird geöffnet. IncludeSendPorts=" + _options.IncludeSendPorts + ", IncludeReceiveLocations=" + _options.IncludeReceiveLocations + ".");
var catalog = new BtsCatalogExplorer();
catalog.ConnectionString = _options.ConnectionString;
@@ -38,10 +65,14 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
if (ShouldSkipApplication(application))
{
skippedApplications++;
_logger.Info("BizTalk-Anwendung übersprungen: " + application.Name);
continue;
}
inspectedApplications++;
_logger.Info("BizTalk-Anwendung wird analysiert: " + application.Name);
if (_options.IncludeSendPorts)
{
DiscoverSendPorts(application, discovered);
@@ -53,6 +84,8 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
_logger.Info("BizTalk-Discovery beendet. AnalysierteAnwendungen=" + inspectedApplications + ", UebersprungeneAnwendungen=" + skippedApplications + ", UnterstuetzteTargets=" + discovered.Count + ".");
return discovered
.OrderBy(x => x.MappedApplicationName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.TargetName, StringComparer.OrdinalIgnoreCase)
@@ -60,6 +93,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
.ToList();
}
/// <summary>
/// Discovers send port transports from one BizTalk application.
/// </summary>
/// <param name="application">The BizTalk application to inspect.</param>
/// <param name="discovered">The target collection receiving supported endpoints.</param>
private void DiscoverSendPorts(Application application, List<DiscoveredEndpoint> discovered)
{
foreach (SendPort sendPort in application.SendPorts)
@@ -90,6 +128,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
/// <summary>
/// Discovers receive locations from one BizTalk application.
/// </summary>
/// <param name="application">The BizTalk application to inspect.</param>
/// <param name="discovered">The target collection receiving supported endpoints.</param>
private void DiscoverReceiveLocations(Application application, List<DiscoveredEndpoint> discovered)
{
foreach (ReceivePort receivePort in application.ReceivePorts)
@@ -131,6 +174,16 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
/// <summary>
/// Adds a send port transport when it exists and can be normalized.
/// </summary>
/// <param name="discovered">The target collection receiving supported endpoints.</param>
/// <param name="bizTalkApplicationName">The source BizTalk application name.</param>
/// <param name="artifactType">The artifact type label.</param>
/// <param name="targetNameBase">The base name for the generated target.</param>
/// <param name="artifactName">The source artifact name.</param>
/// <param name="transport">The BizTalk transport information.</param>
/// <param name="isSecondary">True when the transport is a secondary send port transport.</param>
private void AddTransportIfSupported(List<DiscoveredEndpoint> discovered, string bizTalkApplicationName, string artifactType, string targetNameBase, string artifactName, TransportInfo transport, bool isSecondary)
{
if (transport == null)
@@ -150,6 +203,17 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
isSecondary);
}
/// <summary>
/// Adds a discovered endpoint when artifact filters and endpoint normalization allow it.
/// </summary>
/// <param name="discovered">The target collection receiving supported endpoints.</param>
/// <param name="bizTalkApplicationName">The source BizTalk application name.</param>
/// <param name="artifactType">The artifact type label.</param>
/// <param name="targetNameBase">The base name for the generated target.</param>
/// <param name="artifactName">The source artifact name.</param>
/// <param name="adapterName">The BizTalk adapter name.</param>
/// <param name="sourceAddress">The raw address read from BizTalk.</param>
/// <param name="isSecondary">True when the endpoint is from a secondary transport.</param>
private void AddEndpointIfSupported(List<DiscoveredEndpoint> discovered, string bizTalkApplicationName, string artifactType, string targetNameBase, string artifactName, string adapterName, string sourceAddress, bool isSecondary)
{
if (ShouldSkipArtifact(artifactName))
@@ -190,6 +254,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
_logger.Discovery("ADD", artifactType, bizTalkApplicationName, artifactName, adapterName, sourceAddress, normalizedEndpoint, "Zuordnung zu ApplicationName='" + mappedApplication + "'.");
}
/// <summary>
/// Determines whether a BizTalk application should be skipped.
/// </summary>
/// <param name="application">The BizTalk application to inspect.</param>
/// <returns>True when the application should be skipped.</returns>
private bool ShouldSkipApplication(Application application)
{
if (application == null)
@@ -205,6 +274,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return _options.IgnoreApplications.Any(pattern => WildcardMatch(application.Name, pattern));
}
/// <summary>
/// Determines whether a BizTalk artifact name matches a configured ignore pattern.
/// </summary>
/// <param name="artifactName">The artifact name to inspect.</param>
/// <returns>True when the artifact should be skipped.</returns>
private bool ShouldSkipArtifact(string artifactName)
{
if (string.IsNullOrWhiteSpace(artifactName))
@@ -215,6 +289,13 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return _options.IgnoreArtifactNamePatterns.Any(pattern => WildcardMatch(artifactName, pattern));
}
/// <summary>
/// Resolves the generated monitor application name for a BizTalk artifact.
/// </summary>
/// <param name="bizTalkApplicationName">The source BizTalk application name.</param>
/// <param name="artifactType">The source artifact type.</param>
/// <param name="artifactName">The source artifact name.</param>
/// <returns>The generated monitor application name.</returns>
private string ResolveMappedApplicationName(string bizTalkApplicationName, string artifactType, string artifactName)
{
foreach (var rule in _options.ArtifactMappings.Where(r => r != null && !string.IsNullOrWhiteSpace(r.ApplicationName)))
@@ -241,6 +322,14 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return string.IsNullOrWhiteSpace(bizTalkApplicationName) ? "Unassigned Application" : bizTalkApplicationName.Trim();
}
/// <summary>
/// Builds a generated monitor target name from BizTalk artifact metadata.
/// </summary>
/// <param name="artifactType">The source artifact type.</param>
/// <param name="baseName">The base artifact display name.</param>
/// <param name="adapterName">The BizTalk adapter name.</param>
/// <param name="isSecondary">True when the endpoint is from a secondary transport.</param>
/// <returns>The generated target name.</returns>
private string BuildTargetName(string artifactType, string baseName, string adapterName, bool isSecondary)
{
var prefix = string.Equals(artifactType, "ReceiveLocation", StringComparison.OrdinalIgnoreCase)
@@ -261,6 +350,16 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return name;
}
/// <summary>
/// Normalizes a BizTalk adapter address into a monitor-compatible endpoint.
/// </summary>
/// <param name="sourceAddress">The raw address read from BizTalk.</param>
/// <param name="adapterName">The BizTalk adapter name.</param>
/// <param name="normalizedEndpoint">The normalized monitor endpoint when successful.</param>
/// <param name="port">The resolved explicit port when available.</param>
/// <param name="httpMethod">The generated HTTP method when applicable.</param>
/// <param name="reason">The skip reason when normalization fails.</param>
/// <returns>True when the address can be monitored; otherwise false.</returns>
private bool TryNormalizeEndpoint(string sourceAddress, string adapterName, out string normalizedEndpoint, out int? port, out string httpMethod, out string reason)
{
normalizedEndpoint = string.Empty;
@@ -386,6 +485,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return true;
}
/// <summary>
/// Safely resolves the adapter name from a BizTalk transport.
/// </summary>
/// <param name="transport">The BizTalk transport information.</param>
/// <returns>The adapter name or an empty string.</returns>
private static string ResolveAdapterName(TransportInfo transport)
{
try
@@ -398,6 +502,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
/// <summary>
/// Safely resolves the adapter name from a BizTalk protocol type.
/// </summary>
/// <param name="transportType">The BizTalk protocol type.</param>
/// <returns>The adapter name or an empty string.</returns>
private static string ResolveAdapterName(ProtocolType transportType)
{
try
@@ -410,6 +519,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
/// <summary>
/// Safely reads the address from a BizTalk transport.
/// </summary>
/// <param name="transport">The BizTalk transport information.</param>
/// <returns>The transport address or an empty string.</returns>
private static string SafeAddress(TransportInfo transport)
{
try
@@ -422,12 +536,23 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
}
}
/// <summary>
/// Checks whether an adapter name contains a fragment.
/// </summary>
/// <param name="adapterName">The adapter name to inspect.</param>
/// <param name="fragment">The fragment to search for.</param>
/// <returns>True when the adapter name contains the fragment.</returns>
private static bool AdapterContains(string adapterName, string fragment)
{
return !string.IsNullOrWhiteSpace(adapterName)
&& adapterName.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Detects semicolon-separated email recipient lists that are not monitorable endpoints.
/// </summary>
/// <param name="value">The address value to inspect.</param>
/// <returns>True when the value looks like an email address list.</returns>
private static bool LooksLikeEmailAddressList(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -475,6 +600,12 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return true;
}
/// <summary>
/// Applies simple wildcard matching with asterisks and question marks.
/// </summary>
/// <param name="input">The input value.</param>
/// <param name="pattern">The wildcard pattern.</param>
/// <returns>True when the input matches the pattern.</returns>
private static bool WildcardMatch(string input, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
@@ -487,6 +618,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
/// <summary>
/// Determines whether a value looks like a host:port endpoint.
/// </summary>
/// <param name="value">The value to inspect.</param>
/// <returns>True when the value contains a host and numeric port.</returns>
private static bool LooksLikeHostPort(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -497,6 +633,11 @@ namespace BizTalkMonitorConfigGenerator.BizTalk
return Regex.IsMatch(value.Trim(), @"^[a-zA-Z0-9._-]+:\d{1,5}($|/.*$)");
}
/// <summary>
/// Parses a port from a host:port value.
/// </summary>
/// <param name="value">The value containing a port.</param>
/// <returns>The parsed port, or null when no port is found.</returns>
private static int? ParsePort(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -1,18 +1,68 @@
namespace BizTalkMonitorConfigGenerator.BizTalk
{
/// <summary>
/// Represents one BizTalk artifact endpoint that can be written as a monitor target.
/// </summary>
public sealed class DiscoveredEndpoint
{
/// <summary>
/// Gets or sets the BizTalk artifact type.
/// </summary>
public string ArtifactType { get; set; }
/// <summary>
/// Gets or sets the source BizTalk application name.
/// </summary>
public string BizTalkApplicationName { get; set; }
/// <summary>
/// Gets or sets the generated monitor application name.
/// </summary>
public string MappedApplicationName { get; set; }
/// <summary>
/// Gets or sets the source BizTalk artifact name.
/// </summary>
public string ArtifactName { get; set; }
/// <summary>
/// Gets or sets the BizTalk adapter name.
/// </summary>
public string AdapterName { get; set; }
/// <summary>
/// Gets or sets the raw address read from BizTalk.
/// </summary>
public string SourceAddress { get; set; }
/// <summary>
/// Gets or sets the monitor-compatible endpoint.
/// </summary>
public string NormalizedEndpoint { get; set; }
/// <summary>
/// Gets or sets the generated monitor target name.
/// </summary>
public string TargetName { get; set; }
/// <summary>
/// Gets or sets the HTTP method for generated HTTP targets.
/// </summary>
public string HttpMethod { get; set; }
/// <summary>
/// Gets or sets the explicit target port, when resolved.
/// </summary>
public int? Port { get; set; }
/// <summary>
/// Gets or sets an optional UNC path validation override.
/// </summary>
public bool? ValidateUncPathAccess { get; set; }
/// <summary>
/// Gets or sets discovery detail text for diagnostics.
/// </summary>
public string Detail { get; set; }
}
}
@@ -7,16 +7,51 @@ using HostAvailabilityMonitor.Configuration;
namespace BizTalkMonitorConfigGenerator.Configuration
{
/// <summary>
/// Root configuration for the BizTalk monitor configuration generator.
/// </summary>
public sealed class GeneratorConfiguration
{
/// <summary>
/// Gets or sets the generator name shown in logs and Event Log entries.
/// </summary>
public string GeneratorName { get; set; } = "BizTalkMonitorConfigGenerator";
/// <summary>
/// Gets or sets the environment label used in logs and generated output.
/// </summary>
public string EnvironmentName { get; set; } = "Unspecified Environment";
/// <summary>
/// Gets or sets rolling file log settings.
/// </summary>
public GeneratorLoggingOptions Logging { get; set; } = new GeneratorLoggingOptions();
/// <summary>
/// Gets or sets Windows Event Log settings.
/// </summary>
public GeneratorEventLogOptions EventLog { get; set; } = new GeneratorEventLogOptions();
/// <summary>
/// Gets or sets BizTalk discovery settings.
/// </summary>
public BizTalkOptions BizTalk { get; set; } = new BizTalkOptions();
/// <summary>
/// Gets or sets output file and target defaults.
/// </summary>
public OutputOptions Output { get; set; } = new OutputOptions();
/// <summary>
/// Gets or sets the monitor configuration template used for generated output.
/// </summary>
public MonitorConfiguration GeneratedMonitorConfig { get; set; } = new MonitorConfiguration();
/// <summary>
/// Loads and normalizes generator configuration from a JSON file.
/// </summary>
/// <param name="path">The JSON configuration path.</param>
/// <returns>The normalized generator configuration.</returns>
public static GeneratorConfiguration Load(string path)
{
if (!File.Exists(path))
@@ -74,6 +109,9 @@ namespace BizTalkMonitorConfigGenerator.Configuration
return config;
}
/// <summary>
/// Validates generator settings and applies safe defaults where possible.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(BizTalk.ConnectionString))
@@ -115,8 +153,16 @@ namespace BizTalkMonitorConfigGenerator.Configuration
}
}
/// <summary>
/// Provides recipient list normalization helpers shared by generator configuration validation.
/// </summary>
internal static class RecipientListHelper
{
/// <summary>
/// Removes null, empty and duplicate recipient entries.
/// </summary>
/// <param name="values">The recipient list from configuration.</param>
/// <returns>A normalized recipient list.</returns>
internal static List<string> SanitizeRecipientList(List<string> values)
{
if (values == null)
@@ -131,6 +177,11 @@ namespace BizTalkMonitorConfigGenerator.Configuration
.ToList();
}
/// <summary>
/// Counts all configured email recipients across To, Cc and Bcc.
/// </summary>
/// <param name="email">The email options to inspect.</param>
/// <returns>The number of configured recipients.</returns>
internal static int GetConfiguredRecipientCount(EmailOptions email)
{
if (email == null)
@@ -144,65 +195,216 @@ namespace BizTalkMonitorConfigGenerator.Configuration
}
}
/// <summary>
/// Defines rolling file log settings for the generator.
/// </summary>
public sealed class GeneratorLoggingOptions
{
/// <summary>
/// Gets or sets the directory where generator log files are written.
/// </summary>
public string LogDirectory { get; set; } = "logs";
/// <summary>
/// Gets or sets the daily generator log file prefix.
/// </summary>
public string FilePrefix { get; set; } = "biztalk-monitor-config-generator";
/// <summary>
/// Gets or sets the number of daily generator log files to retain.
/// </summary>
public int RetentionDays { get; set; } = 5;
}
/// <summary>
/// Defines Windows Event Log behavior for the generator.
/// </summary>
public sealed class GeneratorEventLogOptions
{
/// <summary>
/// Gets or sets a value indicating whether Event Log writes are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the Windows Event Log name.
/// </summary>
public string LogName { get; set; } = "Application";
/// <summary>
/// Gets or sets the Event Log source name.
/// </summary>
public string Source { get; set; } = "BizTalkMonitorConfigGenerator";
/// <summary>
/// Gets or sets whether the generator should try to create the Event Log source.
/// </summary>
public bool CreateSourceIfMissing { get; set; } = false;
/// <summary>
/// Gets or sets whether successful generator events should be written.
/// </summary>
public bool WriteSuccessEntries { get; set; } = false;
/// <summary>
/// Gets or sets whether generator summary events should be written.
/// </summary>
public bool WriteSummaryEntries { get; set; } = true;
}
/// <summary>
/// Defines BizTalk artifact discovery behavior.
/// </summary>
public sealed class BizTalkOptions
{
/// <summary>
/// Gets or sets the BizTalk management database connection string.
/// </summary>
public string ConnectionString { get; set; } = "SERVER=.;DATABASE=BizTalkMgmtDb;Integrated Security=SSPI";
/// <summary>
/// Gets or sets whether send ports should be discovered.
/// </summary>
public bool IncludeSendPorts { get; set; } = true;
/// <summary>
/// Gets or sets whether receive locations should be discovered.
/// </summary>
public bool IncludeReceiveLocations { get; set; } = true;
/// <summary>
/// Gets or sets whether only started send ports should be included.
/// </summary>
public bool OnlyStartedSendPorts { get; set; } = true;
/// <summary>
/// Gets or sets whether only enabled receive locations should be included.
/// </summary>
public bool OnlyEnabledReceiveLocations { get; set; } = true;
/// <summary>
/// Gets or sets whether secondary send port transports should be included.
/// </summary>
public bool IncludeSecondaryTransportOnSendPorts { get; set; } = true;
/// <summary>
/// Gets or sets whether dynamic send ports should be included.
/// </summary>
public bool IncludeDynamicSendPorts { get; set; } = false;
/// <summary>
/// Gets or sets whether local file paths should be emitted as monitor targets.
/// </summary>
public bool IncludeLocalFilePaths { get; set; } = false;
/// <summary>
/// Gets or sets whether BizTalk system applications should be skipped.
/// </summary>
public bool IgnoreSystemApplications { get; set; } = true;
/// <summary>
/// Gets or sets application name wildcard patterns to skip.
/// </summary>
public List<string> IgnoreApplications { get; set; } = new List<string>();
/// <summary>
/// Gets or sets artifact name wildcard patterns to skip.
/// </summary>
public List<string> IgnoreArtifactNamePatterns { get; set; } = new List<string>();
/// <summary>
/// Gets or sets application-level mapping rules for generated ApplicationName values.
/// </summary>
public List<ApplicationMappingRule> ApplicationMappings { get; set; } = new List<ApplicationMappingRule>();
/// <summary>
/// Gets or sets artifact-level mapping rules for generated ApplicationName values.
/// </summary>
public List<ArtifactMappingRule> ArtifactMappings { get; set; } = new List<ArtifactMappingRule>();
}
/// <summary>
/// Defines output file behavior and defaults for generated monitor targets.
/// </summary>
public sealed class OutputOptions
{
/// <summary>
/// Gets or sets the generated monitor configuration output path.
/// </summary>
public string Path { get; set; } = "generated-monitor-appsettings.json";
/// <summary>
/// Gets or sets whether an existing output file may be overwritten.
/// </summary>
public bool OverwriteExisting { get; set; } = true;
/// <summary>
/// Gets or sets whether no discovered targets should produce a failing exit code.
/// </summary>
public bool FailIfNoTargetsFound { get; set; } = false;
/// <summary>
/// Gets or sets the timeout assigned to generated targets.
/// </summary>
public int TargetTimeoutSeconds { get; set; } = 10;
/// <summary>
/// Gets or sets whether generated targets are enabled by default.
/// </summary>
public bool TargetEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP method assigned to generated HTTP targets.
/// </summary>
public string DefaultHttpMethod { get; set; } = "HEAD";
}
/// <summary>
/// Maps a BizTalk application name pattern to a monitor application name.
/// </summary>
public sealed class ApplicationMappingRule
{
/// <summary>
/// Gets or sets the BizTalk application wildcard pattern.
/// </summary>
public string BizTalkApplicationName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the generated monitor application name.
/// </summary>
public string ApplicationName { get; set; } = string.Empty;
}
/// <summary>
/// Maps a BizTalk artifact pattern to a monitor application name.
/// </summary>
public sealed class ArtifactMappingRule
{
/// <summary>
/// Gets or sets the optional BizTalk artifact type filter.
/// </summary>
public string ArtifactType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the BizTalk artifact name wildcard pattern.
/// </summary>
public string ArtifactNamePattern { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the generated monitor application name.
/// </summary>
public string ApplicationName { get; set; } = string.Empty;
}
/// <summary>
/// Provides monitor configuration template helpers used by the generator.
/// </summary>
internal static class MonitorConfigurationTemplateExtensions
{
/// <summary>
/// Validates the monitor template without requiring generated targets.
/// </summary>
/// <param name="configuration">The monitor template to validate.</param>
public static void ValidateTemplateWithoutTargets(this MonitorConfiguration configuration)
{
if (configuration == null)
@@ -274,6 +476,11 @@ namespace BizTalkMonitorConfigGenerator.Configuration
}
}
/// <summary>
/// Clones the monitor template while leaving the target list empty for generated output.
/// </summary>
/// <param name="configuration">The monitor template to clone.</param>
/// <returns>A monitor configuration clone without targets.</returns>
public static MonitorConfiguration CloneWithoutTargets(this MonitorConfiguration configuration)
{
var clone = new MonitorConfiguration
@@ -5,12 +5,31 @@ using BizTalkMonitorConfigGenerator.Configuration;
namespace BizTalkMonitorConfigGenerator.Logging
{
/// <summary>
/// Best-effort Windows Event Log writer for generator lifecycle and summary events.
/// </summary>
public sealed class GeneratorEventLogger
{
/// <summary>
/// Event Log settings from generator configuration.
/// </summary>
private readonly GeneratorEventLogOptions _options;
/// <summary>
/// File logger used when Event Log writes fail.
/// </summary>
private readonly GeneratorFileLogger _fallbackLogger;
/// <summary>
/// Indicates that Event Log writing has been disabled for this process.
/// </summary>
private bool _disabled;
/// <summary>
/// Initializes a new generator Event Log writer.
/// </summary>
/// <param name="options">The Event Log configuration.</param>
/// <param name="fallbackLogger">The fallback file logger.</param>
public GeneratorEventLogger(GeneratorEventLogOptions options, GeneratorFileLogger fallbackLogger)
{
_options = options ?? new GeneratorEventLogOptions();
@@ -18,6 +37,9 @@ namespace BizTalkMonitorConfigGenerator.Logging
_disabled = !_options.Enabled;
}
/// <summary>
/// Optionally creates the configured Event Log source.
/// </summary>
public void TryInitialize()
{
if (_disabled || !_options.CreateSourceIfMissing)
@@ -43,11 +65,21 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Writes an informational event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Info(string message, int eventId)
{
Write(message, EventLogEntryType.Information, eventId, true);
}
/// <summary>
/// Writes an informational event only when success entries are enabled.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void InfoIfSuccessEnabled(string message, int eventId)
{
if (_options.WriteSuccessEntries)
@@ -56,16 +88,31 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Writes a warning event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Warning(string message, int eventId)
{
Write(message, EventLogEntryType.Warning, eventId, true);
}
/// <summary>
/// Writes an error event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Error(string message, int eventId)
{
Write(message, EventLogEntryType.Error, eventId, true);
}
/// <summary>
/// Writes a generator summary event when summary entries are enabled.
/// </summary>
/// <param name="message">The summary message.</param>
/// <param name="hasWarnings">True when the summary should be written as a warning.</param>
public void Summary(string message, bool hasWarnings)
{
if (!_options.WriteSummaryEntries)
@@ -76,6 +123,13 @@ namespace BizTalkMonitorConfigGenerator.Logging
Write(message, hasWarnings ? EventLogEntryType.Warning : EventLogEntryType.Information, hasWarnings ? 2100 : 1100, true);
}
/// <summary>
/// Writes one Event Log entry and disables future Event Log writes on failure.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="entryType">The Windows Event Log entry type.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="forceWrite">True to write the event when the logger is enabled.</param>
private void Write(string message, EventLogEntryType entryType, int eventId, bool forceWrite)
{
if (_disabled || !forceWrite)
@@ -101,6 +155,10 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Disables Event Log writes and records the reason in the fallback log.
/// </summary>
/// <param name="reason">The reason Event Log writes were disabled.</param>
private void DisableAndFallback(string reason)
{
if (_disabled)
@@ -5,15 +5,49 @@ using System.Threading;
namespace BizTalkMonitorConfigGenerator.Logging
{
/// <summary>
/// Writes rolling log files for BizTalk monitor configuration generation.
/// </summary>
public sealed class GeneratorFileLogger
{
/// <summary>
/// Synchronizes file writes within the current process.
/// </summary>
private readonly object _sync = new object();
/// <summary>
/// Directory where generator logs are stored.
/// </summary>
private readonly string _directory;
/// <summary>
/// Prefix used for daily generator log files.
/// </summary>
private readonly string _filePrefix;
/// <summary>
/// Number of daily log files to retain.
/// </summary>
private readonly int _retentionDays;
/// <summary>
/// Generator name written to log lines.
/// </summary>
private readonly string _generatorName;
/// <summary>
/// Environment name written to log lines.
/// </summary>
private readonly string _environmentName;
/// <summary>
/// Initializes a new generator file logger.
/// </summary>
/// <param name="directory">The target log directory.</param>
/// <param name="filePrefix">The daily log file prefix.</param>
/// <param name="retentionDays">The number of daily log files to retain.</param>
/// <param name="generatorName">The generator name written to log lines.</param>
/// <param name="environmentName">The environment name written to log lines.</param>
public GeneratorFileLogger(string directory, string filePrefix, int retentionDays, string generatorName, string environmentName)
{
_directory = directory;
@@ -24,6 +58,9 @@ namespace BizTalkMonitorConfigGenerator.Logging
Directory.CreateDirectory(_directory);
}
/// <summary>
/// Deletes old rolling log files according to the retention setting.
/// </summary>
public void CleanupOldFiles()
{
if (_retentionDays <= 0)
@@ -55,21 +92,44 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Writes an informational log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Info(string message)
{
Write("INFO", message);
}
/// <summary>
/// Writes a warning log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Warn(string message)
{
Write("WARN", message);
}
/// <summary>
/// Writes an error log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Error(string message)
{
Write("ERROR", message);
}
/// <summary>
/// Writes one structured discovery decision.
/// </summary>
/// <param name="action">The discovery action, such as ADD or SKIP.</param>
/// <param name="artifactType">The BizTalk artifact type.</param>
/// <param name="applicationName">The BizTalk application name.</param>
/// <param name="artifactName">The artifact name.</param>
/// <param name="adapterName">The adapter name.</param>
/// <param name="sourceAddress">The source address read from BizTalk.</param>
/// <param name="normalizedEndpoint">The generated monitor endpoint, if any.</param>
/// <param name="detail">The discovery detail or skip reason.</param>
public void Discovery(string action, string artifactType, string applicationName, string artifactName, string adapterName, string sourceAddress, string normalizedEndpoint, string detail)
{
Write("DISCOVERY", string.Format(
@@ -85,6 +145,11 @@ namespace BizTalkMonitorConfigGenerator.Logging
Escape(detail)));
}
/// <summary>
/// Formats and appends a generator log line.
/// </summary>
/// <param name="level">The log level text.</param>
/// <param name="message">The formatted message payload.</param>
private void Write(string level, string message)
{
var line = string.Format(
@@ -104,6 +169,11 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Appends a line to a file and retries transient file access failures.
/// </summary>
/// <param name="path">The log file path.</param>
/// <param name="line">The full line to append.</param>
private void AppendWithRetries(string path, string line)
{
Exception lastException = null;
@@ -132,12 +202,22 @@ namespace BizTalkMonitorConfigGenerator.Logging
}
}
/// <summary>
/// Builds the daily generator log file path for the current local date.
/// </summary>
/// <returns>The current log file path.</returns>
private string GetCurrentLogFilePath()
{
var fileName = _filePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log";
return Path.Combine(_directory, fileName);
}
/// <summary>
/// Extracts the date encoded in a rolling generator log file name.
/// </summary>
/// <param name="path">The log file path to inspect.</param>
/// <param name="date">The parsed file date when parsing succeeds.</param>
/// <returns>True when the date could be parsed; otherwise false.</returns>
private bool TryGetDateFromFileName(string path, out DateTime date)
{
date = DateTime.MinValue;
@@ -156,6 +236,11 @@ namespace BizTalkMonitorConfigGenerator.Logging
out date);
}
/// <summary>
/// Removes line breaks from values before they are written to one-line logs.
/// </summary>
/// <param name="value">The value to escape.</param>
/// <returns>The log-safe value.</returns>
private static string Escape(string value)
{
return (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
+143 -2
View File
@@ -10,48 +10,71 @@ using HostAvailabilityMonitor.Configuration;
namespace BizTalkMonitorConfigGenerator
{
/// <summary>
/// Console entry point and orchestration workflow for the BizTalk monitor config generator.
/// </summary>
internal static class Program
{
/// <summary>
/// Runs the generator once.
/// </summary>
/// <param name="args">Optional command-line arguments. The first argument may be the generator configuration path.</param>
/// <returns>The process exit code.</returns>
private static int Main(string[] args)
{
GeneratorFileLogger logger = null;
GeneratorEventLogger eventLogger = null;
string baseDirectory = null;
string configPath = null;
try
{
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
? args[0]
: Path.Combine(baseDirectory, "generator-settings.json");
configPath = Path.GetFullPath(configPath);
WriteConsoleBanner("BizTalk Monitor Config Generator", configPath);
WriteConsoleInfo("Lade Generator-Konfiguration...");
var configuration = GeneratorConfiguration.Load(configPath);
configuration.Validate();
WriteConsoleInfo("Konfiguration geladen. Umgebung=" + configuration.EnvironmentName + ".");
var logDirectory = ResolvePath(baseDirectory, configuration.Logging.LogDirectory);
WriteConsoleInfo("Initialisiere Dateilog: " + BuildLogFilePreview(logDirectory, configuration.Logging.FilePrefix));
logger = new GeneratorFileLogger(logDirectory, configuration.Logging.FilePrefix, configuration.Logging.RetentionDays, configuration.GeneratorName, configuration.EnvironmentName);
logger.CleanupOldFiles();
logger.Info(configuration.GeneratorName + " gestartet. Umgebung=" + configuration.EnvironmentName + ", Server=" + Environment.MachineName + ".");
logger.Info("Generator-Konfiguration geladen: " + Path.GetFullPath(configPath));
logger.Info("Ausgabeziel: " + ResolvePath(baseDirectory, configuration.Output.Path));
WriteConsoleInfo("Initialisiere Windows Event Log. Quelle=" + configuration.EventLog.Source + ", Log=" + configuration.EventLog.LogName + ", Aktiv=" + configuration.EventLog.Enabled + ".");
eventLogger = new GeneratorEventLogger(configuration.EventLog, logger);
eventLogger.TryInitialize();
eventLogger.Info(configuration.GeneratorName + " gestartet. Umgebung=" + configuration.EnvironmentName + ", Server=" + Environment.MachineName + ".", 1000);
WriteConsoleInfo("Starte BizTalk-Discovery. SendPorts=" + configuration.BizTalk.IncludeSendPorts + ", ReceiveLocations=" + configuration.BizTalk.IncludeReceiveLocations + ".");
var discoveryService = new BizTalkCatalogDiscoveryService(configuration.BizTalk, configuration.Output, logger);
var discovered = discoveryService.Discover();
logger.Info("Discovery abgeschlossen. Gefundene unterstützte Targets=" + discovered.Count + ".");
WriteConsoleInfo("Discovery abgeschlossen. Gefundene unterstuetzte Targets=" + discovered.Count + ".");
var outputConfiguration = BuildMonitorConfiguration(configuration, discovered);
var outputPath = ResolvePath(baseDirectory, configuration.Output.Path);
WriteConsoleInfo("Schreibe Monitor-Konfiguration: " + outputPath);
JsonFileWriter.WritePrettyJsonAtomic(outputPath, outputConfiguration, configuration.Output.OverwriteExisting);
logger.Info("Monitor-Konfiguration geschrieben: " + outputPath);
eventLogger.InfoIfSuccessEnabled("Monitor-Konfiguration erfolgreich generiert: " + outputPath, 1001);
WriteConsoleInfo("Monitor-Konfiguration geschrieben.");
if (discovered.Count == 0)
{
var message = "Es wurden keine aktiven und unterstützten BizTalk-Endpunkte gefunden. Ausgabe wurde dennoch geschrieben: " + outputPath;
logger.Warn(message);
eventLogger.Summary(message, true);
WriteConsoleWarn(message);
return configuration.Output.FailIfNoTargetsFound ? 2 : 0;
}
@@ -62,13 +85,21 @@ namespace BizTalkMonitorConfigGenerator
outputPath);
logger.Info(summary);
eventLogger.Summary(summary, false);
WriteConsoleInfo(summary);
return 0;
}
catch (Exception ex)
{
WriteConsoleFatal("Generatorlauf fehlgeschlagen.", configPath, ex);
if (logger != null)
{
logger.Error("Fataler Fehler: " + ex.GetType().Name + ": " + ex.Message);
logger.Error("Details: " + ex);
}
else
{
TryWriteFallbackFailureLog(baseDirectory, configPath, ex);
}
if (eventLogger != null)
@@ -80,6 +111,12 @@ namespace BizTalkMonitorConfigGenerator
}
}
/// <summary>
/// Builds the final monitor configuration from the template and discovered endpoints.
/// </summary>
/// <param name="configuration">The generator configuration.</param>
/// <param name="discovered">The discovered BizTalk endpoints.</param>
/// <returns>The monitor configuration to write.</returns>
private static MonitorConfiguration BuildMonitorConfiguration(GeneratorConfiguration configuration, IReadOnlyCollection<DiscoveredEndpoint> discovered)
{
var result = configuration.GeneratedMonitorConfig.CloneWithoutTargets();
@@ -114,6 +151,12 @@ namespace BizTalkMonitorConfigGenerator
return result;
}
/// <summary>
/// Resolves a configured path against the application base directory.
/// </summary>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="configuredPath">The configured relative or absolute path.</param>
/// <returns>The resolved path.</returns>
private static string ResolvePath(string baseDirectory, string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
@@ -123,5 +166,103 @@ namespace BizTalkMonitorConfigGenerator
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
/// <summary>
/// Writes the initial console banner before configuration and file logging are available.
/// </summary>
/// <param name="title">The program title.</param>
/// <param name="configPath">The resolved configuration path.</param>
private static void WriteConsoleBanner(string title, string configPath)
{
TryWriteConsoleLine(Console.Out, "============================================================");
TryWriteConsoleLine(Console.Out, title);
TryWriteConsoleLine(Console.Out, "Server: " + Environment.MachineName);
TryWriteConsoleLine(Console.Out, "Start: " + DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"));
TryWriteConsoleLine(Console.Out, "Config: " + (configPath ?? "(nicht aufgeloest)"));
TryWriteConsoleLine(Console.Out, "============================================================");
}
/// <summary>
/// Writes a console information message.
/// </summary>
/// <param name="message">The message to write.</param>
private static void WriteConsoleInfo(string message)
{
TryWriteConsoleLine(Console.Out, "[INFO] " + message);
}
/// <summary>
/// Writes a console warning message.
/// </summary>
/// <param name="message">The message to write.</param>
private static void WriteConsoleWarn(string message)
{
TryWriteConsoleLine(Console.Out, "[WARN] " + message);
}
/// <summary>
/// Writes a fatal console error with actionable context.
/// </summary>
/// <param name="message">The user-facing failure message.</param>
/// <param name="configPath">The configuration path used for the run.</param>
/// <param name="exception">The exception that ended the run.</param>
private static void WriteConsoleFatal(string message, string configPath, Exception exception)
{
TryWriteConsoleLine(Console.Error, "[ERROR] " + message);
TryWriteConsoleLine(Console.Error, "[ERROR] Konfiguration: " + (configPath ?? "(nicht aufgeloest)"));
TryWriteConsoleLine(Console.Error, "[ERROR] Ursache: " + exception.GetType().Name + ": " + exception.Message);
TryWriteConsoleLine(Console.Error, "[ERROR] Details: " + exception);
}
/// <summary>
/// Writes one line to a console writer and ignores secondary console failures.
/// </summary>
/// <param name="writer">The console writer.</param>
/// <param name="message">The line to write.</param>
private static void TryWriteConsoleLine(TextWriter writer, string message)
{
try
{
writer.WriteLine(message);
}
catch
{
}
}
/// <summary>
/// Writes a fallback fatal log when configured logging could not be initialized.
/// </summary>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="configPath">The configuration path used for the run.</param>
/// <param name="exception">The exception that ended the run.</param>
private static void TryWriteFallbackFailureLog(string baseDirectory, string configPath, Exception exception)
{
try
{
var directory = ResolvePath(baseDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "logs");
var fallbackLogger = new GeneratorFileLogger(directory, "biztalk-monitor-config-generator-startup", 5, "BizTalkMonitorConfigGenerator", "Startup");
fallbackLogger.Error("Generator konnte nicht gestartet werden. Konfiguration=" + (configPath ?? "(nicht aufgeloest)") + ". Fehler=" + exception.GetType().Name + ": " + exception.Message);
fallbackLogger.Error("Details: " + exception);
WriteConsoleInfo("Fallback-Log geschrieben: " + BuildLogFilePreview(directory, "biztalk-monitor-config-generator-startup"));
}
catch (Exception fallbackEx)
{
TryWriteConsoleLine(Console.Error, "[ERROR] Fallback-Log konnte nicht geschrieben werden: " + fallbackEx.GetType().Name + ": " + fallbackEx.Message);
}
}
/// <summary>
/// Builds the expected daily log file path for console diagnostics.
/// </summary>
/// <param name="directory">The log directory.</param>
/// <param name="filePrefix">The rolling file prefix.</param>
/// <returns>The expected log file path for the current local date.</returns>
private static string BuildLogFilePreview(string directory, string filePrefix)
{
var safeDirectory = string.IsNullOrWhiteSpace(directory) ? AppDomain.CurrentDomain.BaseDirectory : directory;
var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "biztalk-monitor-config-generator" : filePrefix;
return Path.Combine(safeDirectory, safePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd") + ".log");
}
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Runtime.InteropServices;
// Assembly metadata for the BizTalkMonitorConfigGenerator console application.
[assembly: AssemblyTitle("BizTalkMonitorConfigGenerator")]
[assembly: AssemblyDescription("Generates HostAvailabilityMonitor JSON config from active BizTalk send ports and receive locations.")]
[assembly: AssemblyConfiguration("")]
@@ -5,8 +5,17 @@ using System.Web.Script.Serialization;
namespace BizTalkMonitorConfigGenerator.Serialization
{
/// <summary>
/// Writes generator output JSON files with stable formatting and atomic replacement.
/// </summary>
public static class JsonFileWriter
{
/// <summary>
/// Serializes an object as pretty JSON and replaces the target file atomically.
/// </summary>
/// <param name="path">The target JSON file path.</param>
/// <param name="value">The value to serialize.</param>
/// <param name="overwriteExisting">True to replace an existing file; otherwise false.</param>
public static void WritePrettyJsonAtomic(string path, object value, bool overwriteExisting)
{
if (string.IsNullOrWhiteSpace(path))
@@ -44,6 +53,11 @@ namespace BizTalkMonitorConfigGenerator.Serialization
File.Move(tempPath, fullPath);
}
/// <summary>
/// Formats compact JavaScriptSerializer JSON with indentation.
/// </summary>
/// <param name="json">The compact JSON string to format.</param>
/// <returns>The formatted JSON string.</returns>
private static string PrettyPrint(string json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -126,6 +140,11 @@ namespace BizTalkMonitorConfigGenerator.Serialization
return sb.ToString();
}
/// <summary>
/// Appends indentation spaces for the current JSON nesting level.
/// </summary>
/// <param name="sb">The output string builder.</param>
/// <param name="indent">The indentation level.</param>
private static void AppendIndent(StringBuilder sb, int indent)
{
for (var i = 0; i < indent; i++)
@@ -5,10 +5,21 @@ using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.Configuration
{
/// <summary>
/// Represents the external HIP virtual machine configuration file.
/// </summary>
public sealed class HipVirtualMachineConfiguration
{
/// <summary>
/// Gets or sets the configured virtual machines.
/// </summary>
public List<HipVirtualMachine> VirtualMachines { get; set; } = new List<HipVirtualMachine>();
/// <summary>
/// Loads and deserializes a HIP virtual machine configuration file.
/// </summary>
/// <param name="path">The JSON file path to load.</param>
/// <returns>The normalized HIP virtual machine configuration.</returns>
public static HipVirtualMachineConfiguration Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -34,12 +45,34 @@ namespace HostAvailabilityMonitor.Configuration
}
}
/// <summary>
/// Describes one HIP virtual machine and the checks that should be generated for it.
/// </summary>
public sealed class HipVirtualMachine
{
/// <summary>
/// Gets or sets the logical group used for reporting.
/// </summary>
public string Group { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the virtual machine display name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the host name or IP address to probe.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an optional description for mail and summary output.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether monitor targets should be generated for this VM.
/// </summary>
public bool Enabled { get; set; } = true;
}
}
@@ -6,17 +6,56 @@ using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.Configuration
{
/// <summary>
/// Root configuration for the host availability monitor.
/// </summary>
public sealed class MonitorConfiguration
{
/// <summary>
/// Gets or sets the monitor name shown in logs, event log entries and email.
/// </summary>
public string ApplicationName { get; set; } = "HostAvailabilityMonitor";
/// <summary>
/// Gets or sets the environment label shown in logs, event log entries and email.
/// </summary>
public string EnvironmentName { get; set; } = "Unspecified Environment";
/// <summary>
/// Gets or sets runtime behavior such as concurrency and state persistence.
/// </summary>
public RuntimeOptions Runtime { get; set; } = new RuntimeOptions();
/// <summary>
/// Gets or sets rolling file log settings.
/// </summary>
public LoggingOptions Logging { get; set; } = new LoggingOptions();
/// <summary>
/// Gets or sets Windows Application Event Log settings.
/// </summary>
public EventLogOptions EventLog { get; set; } = new EventLogOptions();
/// <summary>
/// Gets or sets SMTP and notification settings.
/// </summary>
public EmailOptions Email { get; set; } = new EmailOptions();
/// <summary>
/// Gets or sets optional static HIP virtual machine monitoring settings.
/// </summary>
public HipVirtualMachineOptions HipVirtualMachines { get; set; } = new HipVirtualMachineOptions();
/// <summary>
/// Gets or sets the endpoint targets to probe.
/// </summary>
public List<TargetOptions> Targets { get; set; } = new List<TargetOptions>();
/// <summary>
/// Loads and normalizes monitor configuration from a JSON file.
/// </summary>
/// <param name="path">The JSON configuration path.</param>
/// <returns>The normalized monitor configuration.</returns>
public static MonitorConfiguration Load(string path)
{
if (!File.Exists(path))
@@ -84,6 +123,9 @@ namespace HostAvailabilityMonitor.Configuration
return config;
}
/// <summary>
/// Validates monitor settings and applies safe defaults where possible.
/// </summary>
public void Validate()
{
Targets = Targets ?? new List<TargetOptions>();
@@ -177,6 +219,12 @@ namespace HostAvailabilityMonitor.Configuration
}
}
}
/// <summary>
/// Removes null, empty and duplicate recipient entries.
/// </summary>
/// <param name="values">The recipient list from configuration.</param>
/// <returns>A normalized recipient list.</returns>
private static List<string> SanitizeRecipientList(List<string> values)
{
if (values == null)
@@ -191,6 +239,11 @@ namespace HostAvailabilityMonitor.Configuration
.ToList();
}
/// <summary>
/// Counts all configured email recipients across To, Cc and Bcc.
/// </summary>
/// <param name="email">The email options to inspect.</param>
/// <returns>The number of configured recipients.</returns>
private static int GetConfiguredRecipientCount(EmailOptions email)
{
if (email == null)
@@ -204,93 +257,330 @@ namespace HostAvailabilityMonitor.Configuration
}
}
/// <summary>
/// Defines monitor runtime behavior.
/// </summary>
public sealed class RuntimeOptions
{
/// <summary>
/// Gets or sets the maximum number of concurrent endpoint probes.
/// </summary>
public int MaxConcurrency { get; set; } = 4;
/// <summary>
/// Gets or sets whether UNC checks should validate path access after the SMB port check.
/// </summary>
public bool DefaultValidateUncPathAccess { get; set; } = false;
/// <summary>
/// Gets or sets whether endpoint failures should produce a non-zero process exit code.
/// </summary>
public bool NonZeroExitCodeOnAnyFailure { get; set; } = false;
/// <summary>
/// Gets or sets whether execution errors should produce a non-zero process exit code.
/// </summary>
public bool NonZeroExitCodeOnExecutionError { get; set; } = true;
/// <summary>
/// Gets or sets the state directory path.
/// </summary>
public string StateDirectory { get; set; } = "state";
}
/// <summary>
/// Defines rolling file log settings.
/// </summary>
public sealed class LoggingOptions
{
/// <summary>
/// Gets or sets the directory where monitor log files are written.
/// </summary>
public string LogDirectory { get; set; } = "logs";
/// <summary>
/// Gets or sets the daily log file prefix.
/// </summary>
public string FilePrefix { get; set; } = "host-availability-monitor";
/// <summary>
/// Gets or sets the number of daily log files to retain.
/// </summary>
public int RetentionDays { get; set; } = 5;
}
/// <summary>
/// Defines Windows Event Log behavior.
/// </summary>
public sealed class EventLogOptions
{
/// <summary>
/// Gets or sets a value indicating whether Event Log writes are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the Windows Event Log name.
/// </summary>
public string LogName { get; set; } = "Application";
/// <summary>
/// Gets or sets the Event Log source name.
/// </summary>
public string Source { get; set; } = "HostAvailabilityMonitor";
/// <summary>
/// Gets or sets whether the application should try to create the Event Log source.
/// </summary>
public bool CreateSourceIfMissing { get; set; } = false;
/// <summary>
/// Gets or sets whether successful endpoint checks should be written to Event Log.
/// </summary>
public bool WriteSuccessEntries { get; set; } = false;
/// <summary>
/// Gets or sets whether the run summary should be written to Event Log.
/// </summary>
public bool WriteSummaryEntries { get; set; } = true;
}
/// <summary>
/// Defines SMTP and notification behavior.
/// </summary>
public sealed class EmailOptions
{
/// <summary>
/// Gets or sets a value indicating whether email delivery is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the SMTP server host name.
/// </summary>
public string SmtpHost { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the SMTP server port.
/// </summary>
public int SmtpPort { get; set; } = 25;
/// <summary>
/// Gets or sets whether SMTP should use SSL/TLS.
/// </summary>
public bool UseSsl { get; set; } = false;
/// <summary>
/// Gets or sets whether default Windows credentials should be used for SMTP.
/// </summary>
public bool UseDefaultCredentials { get; set; } = false;
/// <summary>
/// Gets or sets the SMTP user name when default credentials are disabled.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the SMTP password when default credentials are disabled.
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sender email address.
/// </summary>
public string From { get; set; } = string.Empty;
/// <summary>
/// Gets or sets primary email recipients.
/// </summary>
public List<string> To { get; set; } = new List<string>();
/// <summary>
/// Gets or sets CC email recipients.
/// </summary>
public List<string> Cc { get; set; } = new List<string>();
/// <summary>
/// Gets or sets BCC email recipients.
/// </summary>
public List<string> Bcc { get; set; } = new List<string>();
/// <summary>
/// Gets or sets the subject prefix used for all notification emails.
/// </summary>
public string SubjectPrefix { get; set; } = "[HostAvailabilityMonitor]";
/// <summary>
/// Gets or sets whether failure notification emails should be sent.
/// </summary>
public bool SendOnFailure { get; set; } = true;
/// <summary>
/// Gets or sets whether recovery notification emails should be sent.
/// </summary>
public bool SendOnRecovery { get; set; } = true;
/// <summary>
/// Gets or sets whether the once-per-day summary email should be sent.
/// </summary>
public bool SendDailySummary { get; set; } = false;
/// <summary>
/// Gets or sets the local server hour at or after which the daily summary may be sent.
/// </summary>
public int DailySummaryHourLocal { get; set; } = 14;
/// <summary>
/// Gets or sets the local server minute at or after which the daily summary may be sent.
/// </summary>
public int DailySummaryMinuteLocal { get; set; } = 0;
/// <summary>
/// Gets or sets whether failure and recovery emails should only be sent on state changes.
/// </summary>
public bool OnlyOnStateChange { get; set; } = true;
/// <summary>
/// Gets or sets the cooldown in minutes for repeated failure emails.
/// </summary>
public int CooldownMinutes { get; set; } = 0;
/// <summary>
/// Gets or sets the SMTP timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
}
/// <summary>
/// Defines optional static HIP virtual machine probe generation.
/// </summary>
public sealed class HipVirtualMachineOptions
{
/// <summary>
/// Gets or sets whether HIP virtual machine checks are enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the HIP VM JSON configuration path.
/// </summary>
public string ConfigPath { get; set; } = "hip-vms.json";
/// <summary>
/// Gets or sets the application name assigned to generated HIP VM targets.
/// </summary>
public string ApplicationName { get; set; } = "HIP Virtual Machines";
/// <summary>
/// Gets or sets the timeout in seconds for generated HIP VM targets.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the RDP port used for generated RDP checks.
/// </summary>
public int RdpPort { get; set; } = 3389;
/// <summary>
/// Gets or sets whether ICMP network checks should be generated.
/// </summary>
public bool CheckNetwork { get; set; } = true;
/// <summary>
/// Gets or sets whether RDP TCP checks should be generated.
/// </summary>
public bool CheckRdp { get; set; } = true;
}
/// <summary>
/// Defines one endpoint target that can be probed by the monitor.
/// </summary>
public sealed class TargetOptions
{
/// <summary>
/// Gets or sets the configured target name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the logical application owning the target.
/// </summary>
public string ApplicationName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the endpoint address to probe.
/// </summary>
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the probe timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 10;
/// <summary>
/// Gets or sets an optional explicit port override.
/// </summary>
public int? Port { get; set; }
/// <summary>
/// Gets or sets an optional UNC path access validation override.
/// </summary>
public bool? ValidateUncPathAccess { get; set; }
/// <summary>
/// Gets or sets the HTTP method used for HTTP and HTTPS probes.
/// </summary>
public string HttpMethod { get; set; }
/// <summary>
/// Gets or sets the source that created the target.
/// </summary>
public string Source { get; set; }
/// <summary>
/// Gets or sets an optional group label for reporting.
/// </summary>
public string Group { get; set; }
/// <summary>
/// Gets or sets an optional target description.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets or sets an optional machine name for generated VM targets.
/// </summary>
public string MachineName { get; set; }
/// <summary>
/// Gets or sets an optional check name for generated targets.
/// </summary>
public string CheckName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the target should be probed.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets the display name used in logs, status and notification output.
/// </summary>
public string DisplayName
{
get { return string.IsNullOrWhiteSpace(Name) ? Endpoint : Name; }
}
/// <summary>
/// Gets the normalized application name used for grouping.
/// </summary>
public string EffectiveApplicationName
{
get { return string.IsNullOrWhiteSpace(ApplicationName) ? "Unassigned Application" : ApplicationName.Trim(); }
}
/// <summary>
/// Gets the stable state key used to persist target state across runs.
/// </summary>
public string StateKey
{
get
@@ -307,6 +597,11 @@ namespace HostAvailabilityMonitor.Configuration
}
}
/// <summary>
/// Trims a string for state-key generation.
/// </summary>
/// <param name="value">The configured value to normalize.</param>
/// <returns>The trimmed value, or an empty string for null and whitespace.</returns>
private static string Normalize(string value)
{
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
@@ -7,15 +7,49 @@ using HostAvailabilityMonitor.Monitoring;
namespace HostAvailabilityMonitor.Logging
{
/// <summary>
/// Writes rolling monitor log files with daily file names and probe-specific fields.
/// </summary>
public sealed class FileLogger
{
/// <summary>
/// Synchronizes writes within the current process.
/// </summary>
private readonly object _sync = new object();
/// <summary>
/// Directory where log files are stored.
/// </summary>
private readonly string _directory;
/// <summary>
/// Prefix used for daily log file names.
/// </summary>
private readonly string _filePrefix;
/// <summary>
/// Number of daily log files to retain.
/// </summary>
private readonly int _retentionDays;
/// <summary>
/// Monitor name included in structured probe log fields.
/// </summary>
private readonly string _monitorName;
/// <summary>
/// Environment name included in structured probe log fields.
/// </summary>
private readonly string _environmentName;
/// <summary>
/// Initializes a new rolling file logger.
/// </summary>
/// <param name="directory">The target log directory.</param>
/// <param name="filePrefix">The daily log file prefix.</param>
/// <param name="retentionDays">The number of daily log files to retain.</param>
/// <param name="monitorName">The monitor name written to probe log lines.</param>
/// <param name="environmentName">The environment name written to probe log lines.</param>
public FileLogger(string directory, string filePrefix, int retentionDays, string monitorName, string environmentName)
{
_directory = directory;
@@ -27,6 +61,9 @@ namespace HostAvailabilityMonitor.Logging
Directory.CreateDirectory(_directory);
}
/// <summary>
/// Deletes old rolling log files according to the retention setting.
/// </summary>
public void CleanupOldFiles()
{
if (_retentionDays <= 0)
@@ -63,21 +100,37 @@ namespace HostAvailabilityMonitor.Logging
}
}
/// <summary>
/// Writes an informational log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Info(string message)
{
Write("INFO", message);
}
/// <summary>
/// Writes a warning log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Warn(string message)
{
Write("WARN", message);
}
/// <summary>
/// Writes an error log entry.
/// </summary>
/// <param name="message">The message to write.</param>
public void Error(string message)
{
Write("ERROR", message);
}
/// <summary>
/// Writes a structured probe result entry.
/// </summary>
/// <param name="result">The probe result to write.</param>
public void Probe(ProbeResult result)
{
var level = result.IsSkipped ? "SKIP" : (result.IsSuccess ? "SUCCESS" : "FAIL");
@@ -137,6 +190,11 @@ namespace HostAvailabilityMonitor.Logging
Write(level, string.Join(" | ", parts));
}
/// <summary>
/// Formats and appends a log line.
/// </summary>
/// <param name="level">The log level text.</param>
/// <param name="message">The formatted message payload.</param>
private void Write(string level, string message)
{
var line = string.Format(
@@ -155,6 +213,11 @@ namespace HostAvailabilityMonitor.Logging
}
}
/// <summary>
/// Appends a line to a file and retries transient file access failures.
/// </summary>
/// <param name="path">The log file path.</param>
/// <param name="line">The full line to append.</param>
private void AppendWithRetries(string path, string line)
{
Exception lastException = null;
@@ -184,12 +247,22 @@ namespace HostAvailabilityMonitor.Logging
}
}
/// <summary>
/// Builds the daily log file path for the current local date.
/// </summary>
/// <returns>The current log file path.</returns>
private string GetCurrentLogFilePath()
{
var fileName = _filePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log";
return Path.Combine(_directory, fileName);
}
/// <summary>
/// Extracts the date encoded in a rolling log file name.
/// </summary>
/// <param name="path">The log file path to inspect.</param>
/// <param name="date">The parsed file date when parsing succeeds.</param>
/// <returns>True when the date could be parsed; otherwise false.</returns>
private bool TryGetDateFromFileName(string path, out DateTime date)
{
date = DateTime.MinValue;
@@ -204,6 +277,11 @@ namespace HostAvailabilityMonitor.Logging
return DateTime.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
}
/// <summary>
/// Removes line breaks from values before they are written to one-line logs.
/// </summary>
/// <param name="value">The value to escape.</param>
/// <returns>The log-safe value.</returns>
private static string Escape(string value)
{
return (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
@@ -3,24 +3,46 @@ using System.Diagnostics;
using System.Security;
using HostAvailabilityMonitor.Configuration;
namespace HostAvailabilityMonitor.Logging
{
public sealed class WindowsEventLogger
{
private readonly EventLogOptions _options;
private readonly FileLogger _fallbackLogger;
private bool _disabled;
public WindowsEventLogger(EventLogOptions options, FileLogger fallbackLogger)
{
_options = options;
namespace HostAvailabilityMonitor.Logging
{
/// <summary>
/// Best-effort Windows Event Log writer for monitor lifecycle, probe and summary events.
/// </summary>
public sealed class WindowsEventLogger
{
/// <summary>
/// Event Log settings from monitor configuration.
/// </summary>
private readonly EventLogOptions _options;
/// <summary>
/// File logger used when Event Log writes fail.
/// </summary>
private readonly FileLogger _fallbackLogger;
/// <summary>
/// Indicates that Event Log writing has been disabled for this process.
/// </summary>
private bool _disabled;
/// <summary>
/// Initializes a new Windows Event Log writer.
/// </summary>
/// <param name="options">The Event Log configuration.</param>
/// <param name="fallbackLogger">The fallback file logger.</param>
public WindowsEventLogger(EventLogOptions options, FileLogger fallbackLogger)
{
_options = options;
_fallbackLogger = fallbackLogger;
_disabled = !_options.Enabled;
}
public void TryInitialize()
{
if (_disabled)
_disabled = !_options.Enabled;
}
/// <summary>
/// Optionally creates the configured Event Log source.
/// </summary>
public void TryInitialize()
{
if (_disabled)
{
return;
}
@@ -46,35 +68,60 @@ namespace HostAvailabilityMonitor.Logging
catch (Exception ex)
{
_fallbackLogger.Warn("EventLog-Initialisierung konnte die Quelle nicht anlegen. " + ex.GetType().Name + ": " + ex.Message);
}
}
public void Info(string message, int eventId)
{
Write(message, EventLogEntryType.Information, eventId, forceWrite: true);
}
public void InfoIfSuccessEnabled(string message, int eventId)
{
if (_options.WriteSuccessEntries)
}
}
/// <summary>
/// Writes an informational event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Info(string message, int eventId)
{
Write(message, EventLogEntryType.Information, eventId, forceWrite: true);
}
/// <summary>
/// Writes an informational event only when success entries are enabled.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void InfoIfSuccessEnabled(string message, int eventId)
{
if (_options.WriteSuccessEntries)
{
Write(message, EventLogEntryType.Information, eventId, forceWrite: true);
}
}
public void Warning(string message, int eventId)
{
Write(message, EventLogEntryType.Warning, eventId, forceWrite: true);
}
public void Error(string message, int eventId)
{
Write(message, EventLogEntryType.Error, eventId, forceWrite: true);
}
public void Summary(string message, bool hasFailures)
{
if (!_options.WriteSummaryEntries)
}
}
/// <summary>
/// Writes a warning event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Warning(string message, int eventId)
{
Write(message, EventLogEntryType.Warning, eventId, forceWrite: true);
}
/// <summary>
/// Writes an error event.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="eventId">The event identifier.</param>
public void Error(string message, int eventId)
{
Write(message, EventLogEntryType.Error, eventId, forceWrite: true);
}
/// <summary>
/// Writes a run summary event when summary entries are enabled.
/// </summary>
/// <param name="message">The summary message.</param>
/// <param name="hasFailures">True when the summary should be written as a warning.</param>
public void Summary(string message, bool hasFailures)
{
if (!_options.WriteSummaryEntries)
{
return;
}
@@ -83,12 +130,19 @@ namespace HostAvailabilityMonitor.Logging
message,
hasFailures ? EventLogEntryType.Warning : EventLogEntryType.Information,
hasFailures ? 2100 : 1100,
forceWrite: true);
}
private void Write(string message, EventLogEntryType entryType, int eventId, bool forceWrite)
{
if (_disabled || !forceWrite)
forceWrite: true);
}
/// <summary>
/// Writes one Event Log entry and disables future Event Log writes on failure.
/// </summary>
/// <param name="message">The event message.</param>
/// <param name="entryType">The Windows Event Log entry type.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="forceWrite">True to write the event when the logger is enabled.</param>
private void Write(string message, EventLogEntryType entryType, int eventId, bool forceWrite)
{
if (_disabled || !forceWrite)
{
return;
}
@@ -108,12 +162,16 @@ namespace HostAvailabilityMonitor.Logging
catch (Exception ex)
{
DisableAndFallback("EventLog-Schreiben fehlgeschlagen. " + ex.GetType().Name + ": " + ex.Message);
}
}
private void DisableAndFallback(string reason)
{
if (_disabled)
}
}
/// <summary>
/// Disables Event Log writes and records the reason in the fallback log.
/// </summary>
/// <param name="reason">The reason Event Log writes were disabled.</param>
private void DisableAndFallback(string reason)
{
if (_disabled)
{
return;
}
@@ -10,17 +10,37 @@ using HostAvailabilityMonitor.Configuration;
namespace HostAvailabilityMonitor.Monitoring
{
/// <summary>
/// Executes endpoint probes for all supported target kinds.
/// </summary>
public sealed class EndpointProbeService
{
/// <summary>
/// Shared HTTP client with per-request timeout handling through cancellation tokens.
/// </summary>
private static readonly HttpClient HttpClient = CreateHttpClient();
/// <summary>
/// Runtime options that influence probe behavior.
/// </summary>
private readonly RuntimeOptions _runtimeOptions;
/// <summary>
/// Initializes a new endpoint probe service.
/// </summary>
/// <param name="runtimeOptions">Runtime options used for defaults such as UNC validation.</param>
public EndpointProbeService(RuntimeOptions runtimeOptions)
{
_runtimeOptions = runtimeOptions ?? new RuntimeOptions();
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
}
/// <summary>
/// Probes one target by resolving its endpoint syntax to the appropriate check implementation.
/// </summary>
/// <param name="target">The target configuration to probe.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result for the target.</returns>
public async Task<ProbeResult> ProbeAsync(TargetOptions target, CancellationToken cancellationToken)
{
var startedAt = DateTimeOffset.Now;
@@ -88,6 +108,14 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Probes a plain host value by using TCP when a port is configured, otherwise ICMP.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="host">The host name or address.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckPlainHostAsync(TargetOptions target, string host, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
if (target.Port.HasValue)
@@ -98,6 +126,14 @@ namespace HostAvailabilityMonitor.Monitoring
return await CheckIcmpAsync(target, host, startedAt, host, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Probes a file URI by routing UNC-like URIs to UNC checks and local paths to file checks.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="uri">The file URI to probe.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckFileUriAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
if (uri.IsUnc || !string.IsNullOrWhiteSpace(uri.Host))
@@ -115,6 +151,14 @@ namespace HostAvailabilityMonitor.Monitoring
return await CheckLocalPathAsync(target, localPath, startedAt, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Checks whether a local file or directory exists within the configured timeout.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="path">The local file system path.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckLocalPathAsync(TargetOptions target, string path, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
try
@@ -139,6 +183,14 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Checks a UNC target by validating SMB reachability and optionally path access.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="endpoint">The UNC endpoint path.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckUncAsync(TargetOptions target, string endpoint, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
var host = ExtractUncServer(endpoint);
@@ -178,6 +230,15 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Checks an HTTP or HTTPS endpoint and falls back from HEAD to GET when required.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="uri">The HTTP or HTTPS URI.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="kind">The resolved HTTP target kind.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckHttpAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken)
{
var methodText = string.IsNullOrWhiteSpace(target.HttpMethod) ? "HEAD" : target.HttpMethod.Trim().ToUpperInvariant();
@@ -220,6 +281,15 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Retries an HTTP endpoint with GET after HEAD was rejected or failed.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="uri">The HTTP or HTTPS URI.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="kind">The resolved HTTP target kind.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> RetryHttpWithGetAsync(TargetOptions target, Uri uri, DateTimeOffset startedAt, TargetKind kind, CancellationToken cancellationToken)
{
using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
@@ -244,6 +314,17 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Checks TCP connectivity to a host and port.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="host">The host name or address.</param>
/// <param name="port">The target TCP port.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="kind">The resolved target kind.</param>
/// <param name="endpoint">The endpoint string reported in the result.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckTcpAsync(TargetOptions target, string host, int? port, DateTimeOffset startedAt, TargetKind kind, string endpoint, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(host))
@@ -299,6 +380,15 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Checks ICMP reachability with the configured timeout.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="host">The host name or address.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="endpoint">The endpoint string reported in the result.</param>
/// <param name="cancellationToken">A token that can cancel the probe.</param>
/// <returns>The probe result.</returns>
private async Task<ProbeResult> CheckIcmpAsync(TargetOptions target, string host, DateTimeOffset startedAt, string endpoint, CancellationToken cancellationToken)
{
using (var ping = new Ping())
@@ -332,6 +422,12 @@ namespace HostAvailabilityMonitor.Monitoring
}
}
/// <summary>
/// Resolves the effective port for a URI.
/// </summary>
/// <param name="uri">The URI being probed.</param>
/// <param name="defaultPort">The default port for the target kind.</param>
/// <returns>The explicit URI port, the default port, or null.</returns>
private static int? ResolvePort(Uri uri, int? defaultPort)
{
if (uri == null)
@@ -347,6 +443,11 @@ namespace HostAvailabilityMonitor.Monitoring
return defaultPort;
}
/// <summary>
/// Extracts the server component from a UNC path.
/// </summary>
/// <param name="endpoint">The UNC path.</param>
/// <returns>The server component, or an empty string.</returns>
private static string ExtractUncServer(string endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
@@ -359,6 +460,10 @@ namespace HostAvailabilityMonitor.Monitoring
return firstSeparator >= 0 ? trimmed.Substring(0, firstSeparator) : trimmed;
}
/// <summary>
/// Creates the shared HTTP client used by all HTTP probes.
/// </summary>
/// <returns>A configured HTTP client.</returns>
private static HttpClient CreateHttpClient()
{
var handler = new HttpClientHandler();
@@ -367,21 +472,70 @@ namespace HostAvailabilityMonitor.Monitoring
return client;
}
/// <summary>
/// Creates a successful probe result.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="kind">The resolved target kind.</param>
/// <param name="endpoint">The endpoint reported in the result.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="host">The resolved host.</param>
/// <param name="port">The resolved port.</param>
/// <param name="detail">The result detail text.</param>
/// <param name="httpStatusCode">The HTTP status code, when applicable.</param>
/// <returns>The successful probe result.</returns>
private static ProbeResult Success(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string host, int? port, string detail, int? httpStatusCode)
{
return BuildResult(target, kind, endpoint, startedAt, true, false, "Reachable", detail, host, port, httpStatusCode, null);
}
/// <summary>
/// Creates a failed probe result.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="kind">The resolved target kind.</param>
/// <param name="endpoint">The endpoint reported in the result.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="detail">The result detail text.</param>
/// <param name="ex">The exception that caused the failure, if any.</param>
/// <param name="host">The resolved host.</param>
/// <param name="port">The resolved port.</param>
/// <returns>The failed probe result.</returns>
private static ProbeResult Fail(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail, Exception ex, string host, int? port)
{
return BuildResult(target, kind, endpoint, startedAt, false, false, "Unreachable", detail, host, port, null, ex == null ? null : ex.GetType().Name);
}
/// <summary>
/// Creates a skipped probe result.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="kind">The resolved target kind.</param>
/// <param name="endpoint">The endpoint reported in the result.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="detail">The skip reason.</param>
/// <returns>The skipped probe result.</returns>
private static ProbeResult Skip(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, string detail)
{
return BuildResult(target, kind, endpoint, startedAt, false, true, "Skipped", detail, null, null, null, null);
}
/// <summary>
/// Creates the common probe result object.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="kind">The resolved target kind.</param>
/// <param name="endpoint">The endpoint reported in the result.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="isSuccess">True when the probe succeeded.</param>
/// <param name="isSkipped">True when the target was intentionally skipped.</param>
/// <param name="statusText">The high-level status text.</param>
/// <param name="detail">The detailed result text.</param>
/// <param name="host">The resolved host.</param>
/// <param name="port">The resolved port.</param>
/// <param name="httpStatusCode">The HTTP status code, when applicable.</param>
/// <param name="errorType">The exception type, when applicable.</param>
/// <returns>The populated probe result.</returns>
private static ProbeResult BuildResult(TargetOptions target, TargetKind kind, string endpoint, DateTimeOffset startedAt, bool isSuccess, bool isSkipped, string statusText, string detail, string host, int? port, int? httpStatusCode, string errorType)
{
return new ProbeResult
@@ -409,6 +563,14 @@ namespace HostAvailabilityMonitor.Monitoring
};
}
/// <summary>
/// Creates a skipped result for endpoints that should not be network-probed.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="endpoint">The endpoint value to inspect.</param>
/// <param name="startedAt">The local start time of the probe.</param>
/// <param name="result">The skipped result when a skip rule matches.</param>
/// <returns>True when a skip result was created; otherwise false.</returns>
private static bool TryCreateSkipResult(TargetOptions target, string endpoint, DateTimeOffset startedAt, out ProbeResult result)
{
result = null;
@@ -422,6 +584,12 @@ namespace HostAvailabilityMonitor.Monitoring
return false;
}
/// <summary>
/// Detects BizTalk SMTP adapter targets that contain recipient lists instead of network endpoints.
/// </summary>
/// <param name="target">The target configuration.</param>
/// <param name="endpoint">The endpoint value to inspect.</param>
/// <returns>True when the target looks like an SMTP recipient list.</returns>
private static bool LooksLikeSmtpAdapterTarget(TargetOptions target, string endpoint)
{
var name = target == null ? string.Empty : (target.Name ?? string.Empty);
@@ -435,6 +603,11 @@ namespace HostAvailabilityMonitor.Monitoring
return LooksLikeEmailAddressList(value);
}
/// <summary>
/// Detects semicolon-separated email recipient lists that are not monitorable endpoints.
/// </summary>
/// <param name="value">The endpoint value to inspect.</param>
/// <returns>True when the value looks like an email address list.</returns>
private static bool LooksLikeEmailAddressList(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -482,6 +655,14 @@ namespace HostAvailabilityMonitor.Monitoring
return true;
}
/// <summary>
/// Awaits a task until it completes or the timeout expires.
/// </summary>
/// <typeparam name="T">The task result type.</typeparam>
/// <param name="task">The task to await.</param>
/// <param name="timeout">The maximum wait time.</param>
/// <param name="cancellationToken">A token that can cancel the wait.</param>
/// <returns>A completion result containing the task result when it completed in time.</returns>
private static async Task<CompletionResult<T>> CompleteWithinAsync<T>(Task<T> task, TimeSpan timeout, CancellationToken cancellationToken)
{
var delayTask = Task.Delay(timeout, cancellationToken);
@@ -494,6 +675,13 @@ namespace HostAvailabilityMonitor.Monitoring
return new CompletionResult<T>(true, await task.ConfigureAwait(false));
}
/// <summary>
/// Awaits a non-generic task until it completes or the timeout expires.
/// </summary>
/// <param name="task">The task to await.</param>
/// <param name="timeout">The maximum wait time.</param>
/// <param name="cancellationToken">A token that can cancel the wait.</param>
/// <returns>A completion result indicating whether the task completed in time.</returns>
private static async Task<CompletionResult<bool>> CompleteWithinAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
var delayTask = Task.Delay(timeout, cancellationToken);
@@ -507,15 +695,31 @@ namespace HostAvailabilityMonitor.Monitoring
return new CompletionResult<bool>(true, true);
}
/// <summary>
/// Holds the outcome of a timeout-wrapped task.
/// </summary>
/// <typeparam name="T">The wrapped task result type.</typeparam>
private struct CompletionResult<T>
{
/// <summary>
/// Initializes a completion result.
/// </summary>
/// <param name="completed">True when the task completed before the timeout.</param>
/// <param name="result">The task result or a default value.</param>
public CompletionResult(bool completed, T result)
{
Completed = completed;
Result = result;
}
/// <summary>
/// Gets a value indicating whether the wrapped task completed in time.
/// </summary>
public bool Completed { get; private set; }
/// <summary>
/// Gets the wrapped task result.
/// </summary>
public T Result { get; private set; }
}
}
@@ -2,43 +2,175 @@ using System;
namespace HostAvailabilityMonitor.Monitoring
{
/// <summary>
/// Identifies the endpoint probe strategy used for a target.
/// </summary>
public enum TargetKind
{
/// <summary>
/// The target type could not be resolved.
/// </summary>
Unknown = 0,
/// <summary>
/// A UNC or SMB path target.
/// </summary>
Unc = 1,
/// <summary>
/// An HTTP endpoint target.
/// </summary>
Http = 2,
/// <summary>
/// An HTTPS endpoint target.
/// </summary>
Https = 3,
/// <summary>
/// An SFTP endpoint target.
/// </summary>
Sftp = 4,
/// <summary>
/// A generic TCP endpoint target.
/// </summary>
Tcp = 5,
/// <summary>
/// An ICMP ping target.
/// </summary>
Icmp = 6,
/// <summary>
/// A plain host target resolved through target options.
/// </summary>
Host = 7,
/// <summary>
/// An FTP endpoint target.
/// </summary>
Ftp = 8,
/// <summary>
/// A local or file URI target.
/// </summary>
File = 9,
/// <summary>
/// An RDP TCP endpoint target.
/// </summary>
Rdp = 10
}
/// <summary>
/// Contains the result of probing one configured target.
/// </summary>
public sealed class ProbeResult
{
/// <summary>
/// Gets or sets the stable state key for the target.
/// </summary>
public string StateKey { get; set; }
/// <summary>
/// Gets or sets the display name of the target.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the logical application name.
/// </summary>
public string ApplicationName { get; set; }
/// <summary>
/// Gets or sets the endpoint that was probed.
/// </summary>
public string Endpoint { get; set; }
/// <summary>
/// Gets or sets the resolved target kind.
/// </summary>
public TargetKind Kind { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the probe succeeded.
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the target was intentionally skipped.
/// </summary>
public bool IsSkipped { get; set; }
/// <summary>
/// Gets or sets the high-level status text.
/// </summary>
public string StatusText { get; set; }
/// <summary>
/// Gets or sets the detailed probe result message.
/// </summary>
public string Detail { get; set; }
/// <summary>
/// Gets or sets the resolved host name or address.
/// </summary>
public string Host { get; set; }
/// <summary>
/// Gets or sets the resolved port, when applicable.
/// </summary>
public int? Port { get; set; }
/// <summary>
/// Gets or sets the source that created the target.
/// </summary>
public string Source { get; set; }
/// <summary>
/// Gets or sets the group label used in reports.
/// </summary>
public string Group { get; set; }
/// <summary>
/// Gets or sets the target description.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Gets or sets the machine name for generated VM checks.
/// </summary>
public string MachineName { get; set; }
/// <summary>
/// Gets or sets the check name for generated checks.
/// </summary>
public string CheckName { get; set; }
/// <summary>
/// Gets or sets the HTTP status code returned by HTTP probes.
/// </summary>
public int? HttpStatusCode { get; set; }
/// <summary>
/// Gets or sets the exception type observed during failed probes.
/// </summary>
public string ErrorType { get; set; }
/// <summary>
/// Gets or sets the local start time of the probe.
/// </summary>
public DateTimeOffset StartedAtLocal { get; set; }
/// <summary>
/// Gets or sets the local finish time of the probe.
/// </summary>
public DateTimeOffset FinishedAtLocal { get; set; }
/// <summary>
/// Gets the elapsed probe duration.
/// </summary>
public TimeSpan Duration
{
get { return FinishedAtLocal - StartedAtLocal; }
@@ -12,12 +12,32 @@ using HostAvailabilityMonitor.State;
namespace HostAvailabilityMonitor.Notifications
{
/// <summary>
/// Sends failure, recovery and daily summary emails for monitor results.
/// </summary>
public sealed class EmailNotifier
{
/// <summary>
/// SMTP and notification settings.
/// </summary>
private readonly EmailOptions _options;
/// <summary>
/// Monitor name written into subjects and message bodies.
/// </summary>
private readonly string _monitorName;
/// <summary>
/// Environment name written into subjects and message bodies.
/// </summary>
private readonly string _environmentName;
/// <summary>
/// Initializes a new email notifier.
/// </summary>
/// <param name="options">SMTP and notification options.</param>
/// <param name="monitorName">The monitor name for outgoing messages.</param>
/// <param name="environmentName">The environment name for outgoing messages.</param>
public EmailNotifier(EmailOptions options, string monitorName, string environmentName)
{
_options = options ?? new EmailOptions();
@@ -25,11 +45,20 @@ namespace HostAvailabilityMonitor.Notifications
_environmentName = string.IsNullOrWhiteSpace(environmentName) ? "Unspecified Environment" : environmentName.Trim();
}
/// <summary>
/// Gets a value indicating whether email delivery is enabled.
/// </summary>
public bool IsEnabled
{
get { return _options.Enabled; }
}
/// <summary>
/// Determines whether a probe result should trigger a failure or recovery notification.
/// </summary>
/// <param name="result">The current probe result.</param>
/// <param name="previousState">The previous persisted state for the target.</param>
/// <returns>True when a notification should be sent.</returns>
public bool ShouldNotify(ProbeResult result, TargetState previousState)
{
if (!IsEnabled || result == null)
@@ -72,6 +101,12 @@ namespace HostAvailabilityMonitor.Notifications
return cooldownPassed;
}
/// <summary>
/// Determines whether the daily summary should be sent on this run.
/// </summary>
/// <param name="metadata">The persisted monitor metadata.</param>
/// <param name="nowLocal">The current local server time.</param>
/// <returns>True when the daily summary should be sent.</returns>
public bool ShouldSendDailySummary(MonitorStateMetadata metadata, DateTime nowLocal)
{
if (!IsEnabled || !_options.SendDailySummary)
@@ -89,6 +124,14 @@ namespace HostAvailabilityMonitor.Notifications
return metadata == null || !string.Equals(metadata.LastDailySummarySentLocalDate, localDate, StringComparison.Ordinal);
}
/// <summary>
/// Sends one notification email for the supplied failures and recoveries.
/// </summary>
/// <param name="failures">The failed probe results to include.</param>
/// <param name="recoveries">The recovered probe results to include.</param>
/// <param name="currentState">The current target state dictionary.</param>
/// <param name="cancellationToken">A token that can cancel SMTP delivery.</param>
/// <returns>A task that completes when delivery has finished.</returns>
public async Task SendAsync(
IReadOnlyCollection<ProbeResult> failures,
IReadOnlyCollection<ProbeResult> recoveries,
@@ -112,6 +155,12 @@ namespace HostAvailabilityMonitor.Notifications
await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends the once-per-day status summary email.
/// </summary>
/// <param name="summary">The summary data to render.</param>
/// <param name="cancellationToken">A token that can cancel SMTP delivery.</param>
/// <returns>A task that completes when delivery has finished.</returns>
public async Task SendDailySummaryAsync(DailySummaryEmailData summary, CancellationToken cancellationToken)
{
if (!IsEnabled || summary == null)
@@ -124,6 +173,13 @@ namespace HostAvailabilityMonitor.Notifications
await SendMailAsync(subject, body, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends one SMTP email.
/// </summary>
/// <param name="subject">The email subject.</param>
/// <param name="body">The plain-text email body.</param>
/// <param name="cancellationToken">A token that can cancel SMTP delivery before sending starts.</param>
/// <returns>A task that completes when SMTP delivery has finished.</returns>
private async Task SendMailAsync(string subject, string body, CancellationToken cancellationToken)
{
using (var message = new MailMessage())
@@ -161,6 +217,11 @@ namespace HostAvailabilityMonitor.Notifications
}
}
/// <summary>
/// Adds normalized recipient addresses to a mail address collection.
/// </summary>
/// <param name="collection">The destination mail address collection.</param>
/// <param name="recipients">The configured recipient strings.</param>
private static void AddRecipients(MailAddressCollection collection, IEnumerable<string> recipients)
{
if (collection == null || recipients == null)
@@ -174,6 +235,12 @@ namespace HostAvailabilityMonitor.Notifications
}
}
/// <summary>
/// Detects a transition from previously down to currently up.
/// </summary>
/// <param name="result">The current probe result.</param>
/// <param name="previousState">The previous persisted state for the target.</param>
/// <returns>True when the result is a recovery transition.</returns>
private static bool IsRecoveryTransition(ProbeResult result, TargetState previousState)
{
return result != null
@@ -182,6 +249,12 @@ namespace HostAvailabilityMonitor.Notifications
&& !previousState.IsUp;
}
/// <summary>
/// Builds the subject for failure and recovery notification emails.
/// </summary>
/// <param name="failureCount">The number of failures in the message.</param>
/// <param name="recoveryCount">The number of recoveries in the message.</param>
/// <returns>The notification subject.</returns>
private string BuildSubject(int failureCount, int recoveryCount)
{
var prefix = string.IsNullOrWhiteSpace(_options.SubjectPrefix) ? "[HostAvailabilityMonitor]" : _options.SubjectPrefix.Trim();
@@ -200,6 +273,11 @@ namespace HostAvailabilityMonitor.Notifications
return string.Format("{0} {1} {2} Endpoint(s) wieder erreichbar", prefix, envPart, recoveryCount);
}
/// <summary>
/// Builds the subject for a daily summary email.
/// </summary>
/// <param name="summary">The summary data.</param>
/// <returns>The daily summary subject.</returns>
private string BuildDailySummarySubject(DailySummaryEmailData summary)
{
var prefix = string.IsNullOrWhiteSpace(_options.SubjectPrefix) ? "[HostAvailabilityMonitor]" : _options.SubjectPrefix.Trim();
@@ -207,6 +285,13 @@ namespace HostAvailabilityMonitor.Notifications
return string.Format("{0} {1} Tagesstatus {2:yyyy-MM-dd}", prefix, envPart, summary.GeneratedAtLocal.LocalDateTime.Date);
}
/// <summary>
/// Builds the plain-text body for failure and recovery notifications.
/// </summary>
/// <param name="failures">The failed probe results to include.</param>
/// <param name="recoveries">The recovered probe results to include.</param>
/// <param name="currentState">The current target state dictionary.</param>
/// <returns>The email body.</returns>
private string BuildBody(IReadOnlyCollection<ProbeResult> failures, IReadOnlyCollection<ProbeResult> recoveries, IDictionary<string, TargetState> currentState)
{
var sb = new StringBuilder();
@@ -239,6 +324,11 @@ namespace HostAvailabilityMonitor.Notifications
return sb.ToString();
}
/// <summary>
/// Builds the plain-text body for a daily summary email.
/// </summary>
/// <param name="summary">The summary data to render.</param>
/// <returns>The email body.</returns>
private string BuildDailySummaryBody(DailySummaryEmailData summary)
{
var sb = new StringBuilder();
@@ -281,6 +371,13 @@ namespace HostAvailabilityMonitor.Notifications
return sb.ToString();
}
/// <summary>
/// Appends one probe result to an email body.
/// </summary>
/// <param name="sb">The message body builder.</param>
/// <param name="result">The result to append.</param>
/// <param name="currentState">The current target state dictionary.</param>
/// <param name="transitionText">The transition text to show.</param>
private static void AppendResult(StringBuilder sb, ProbeResult result, IDictionary<string, TargetState> currentState, string transitionText)
{
sb.AppendLine("- Anwendung / Application: " + Safe(result.ApplicationName));
@@ -328,6 +425,11 @@ namespace HostAvailabilityMonitor.Notifications
}
}
/// <summary>
/// Appends a compact HIP VM network and RDP snapshot to the daily summary.
/// </summary>
/// <param name="sb">The message body builder.</param>
/// <param name="results">The current probe results.</param>
private static void AppendHipVirtualMachineSnapshot(StringBuilder sb, IReadOnlyCollection<ProbeResult> results)
{
if (results == null)
@@ -371,6 +473,11 @@ namespace HostAvailabilityMonitor.Notifications
}
}
/// <summary>
/// Builds a grouping key for HIP VM snapshot rows.
/// </summary>
/// <param name="result">The probe result to key.</param>
/// <returns>The grouping key.</returns>
private static string BuildVmSnapshotKey(ProbeResult result)
{
return string.Join("|", new[]
@@ -381,6 +488,11 @@ namespace HostAvailabilityMonitor.Notifications
});
}
/// <summary>
/// Formats one probe result as a compact status string.
/// </summary>
/// <param name="result">The result to format.</param>
/// <returns>A compact status string.</returns>
private static string FormatCompactStatus(ProbeResult result)
{
if (result == null)
@@ -396,6 +508,11 @@ namespace HostAvailabilityMonitor.Notifications
return (result.IsSuccess ? "UP" : "DOWN") + " - " + Safe(result.Detail);
}
/// <summary>
/// Returns the first non-empty value from a sequence.
/// </summary>
/// <param name="values">The values to inspect.</param>
/// <returns>The first non-empty value, or an empty string.</returns>
private static string FirstNonEmpty(IEnumerable<string> values)
{
if (values == null)
@@ -406,26 +523,85 @@ namespace HostAvailabilityMonitor.Notifications
return values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}
/// <summary>
/// Converts null or whitespace values into a readable placeholder.
/// </summary>
/// <param name="value">The value to normalize.</param>
/// <returns>The original value or n/a.</returns>
private static string Safe(string value)
{
return string.IsNullOrWhiteSpace(value) ? "n/a" : value;
}
}
/// <summary>
/// Contains all data required to render a daily status summary email.
/// </summary>
public sealed class DailySummaryEmailData
{
/// <summary>
/// Gets or sets the local timestamp when the summary is generated.
/// </summary>
public DateTimeOffset GeneratedAtLocal { get; set; }
/// <summary>
/// Gets or sets the local start timestamp of the summary window.
/// </summary>
public DateTimeOffset WindowStartLocal { get; set; }
/// <summary>
/// Gets or sets today's total probe count from the rolling log.
/// </summary>
public int ProbesTodayTotal { get; set; }
/// <summary>
/// Gets or sets today's successful probe count from the rolling log.
/// </summary>
public int ProbesTodaySuccess { get; set; }
/// <summary>
/// Gets or sets today's failed probe count from the rolling log.
/// </summary>
public int ProbesTodayFailure { get; set; }
/// <summary>
/// Gets or sets today's skipped probe count from the rolling log.
/// </summary>
public int ProbesTodaySkipped { get; set; }
/// <summary>
/// Gets or sets the number of targets in the latest run.
/// </summary>
public int CurrentTargetsTotal { get; set; }
/// <summary>
/// Gets or sets the number of currently reachable targets.
/// </summary>
public int CurrentTargetsUp { get; set; }
/// <summary>
/// Gets or sets the number of currently unreachable targets.
/// </summary>
public int CurrentTargetsDown { get; set; }
/// <summary>
/// Gets or sets the number of currently skipped targets.
/// </summary>
public int CurrentTargetsSkipped { get; set; }
/// <summary>
/// Gets or sets the currently failed probe results.
/// </summary>
public List<ProbeResult> CurrentFailures { get; set; } = new List<ProbeResult>();
/// <summary>
/// Gets or sets all current probe results.
/// </summary>
public List<ProbeResult> CurrentResults { get; set; } = new List<ProbeResult>();
/// <summary>
/// Gets or sets the current target state dictionary.
/// </summary>
public IDictionary<string, TargetState> CurrentState { get; set; } = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
}
}
+235 -2
View File
@@ -12,44 +12,67 @@ using HostAvailabilityMonitor.State;
namespace HostAvailabilityMonitor
{
/// <summary>
/// Console entry point and orchestration workflow for one monitor run.
/// </summary>
internal static class Program
{
/// <summary>
/// Starts the monitor synchronously for the console host.
/// </summary>
/// <param name="args">Optional command-line arguments. The first argument may be the configuration path.</param>
/// <returns>The process exit code.</returns>
private static int Main(string[] args)
{
return MainAsync(args).GetAwaiter().GetResult();
}
/// <summary>
/// Executes one complete monitor run.
/// </summary>
/// <param name="args">Optional command-line arguments. The first argument may be the configuration path.</param>
/// <returns>The process exit code.</returns>
private static async Task<int> MainAsync(string[] args)
{
FileLogger logger = null;
WindowsEventLogger eventLogger = null;
var nonZeroExitCodeOnExecutionError = true;
string baseDirectory = null;
string configPath = null;
try
{
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
var configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
configPath = args != null && args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])
? args[0]
: Path.Combine(baseDirectory, "appsettings.json");
var fullConfigPath = Path.GetFullPath(configPath);
WriteConsoleBanner("Host Availability Monitor", fullConfigPath);
WriteConsoleInfo("Lade Monitor-Konfiguration...");
var config = MonitorConfiguration.Load(configPath);
config.Validate();
nonZeroExitCodeOnExecutionError = config.Runtime.NonZeroExitCodeOnExecutionError;
WriteConsoleInfo("Konfiguration geladen. Umgebung=" + config.EnvironmentName + ".");
var logDirectory = ResolvePath(baseDirectory, config.Logging.LogDirectory);
var stateDirectory = ResolvePath(baseDirectory, config.Runtime.StateDirectory);
var stateFilePath = Path.Combine(stateDirectory, "monitor-state.json");
WriteConsoleInfo("Initialisiere Dateilog: " + BuildLogFilePreview(logDirectory, config.Logging.FilePrefix));
logger = new FileLogger(logDirectory, config.Logging.FilePrefix, config.Logging.RetentionDays, config.ApplicationName, config.EnvironmentName);
logger.CleanupOldFiles();
logger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".");
logger.Info("Konfiguration geladen: " + fullConfigPath);
logger.Info("Statusdatei: " + stateFilePath);
WriteConsoleInfo("Initialisiere Windows Event Log. Quelle=" + config.EventLog.Source + ", Log=" + config.EventLog.LogName + ", Aktiv=" + config.EventLog.Enabled + ".");
eventLogger = new WindowsEventLogger(config.EventLog, logger);
eventLogger.TryInitialize();
eventLogger.Info(config.ApplicationName + " gestartet. Umgebung=" + config.EnvironmentName + ", Server=" + Environment.MachineName + ".", 1000);
WriteConsoleInfo("Lade Statusdatei: " + stateFilePath);
Directory.CreateDirectory(stateDirectory);
var stateStore = new MonitorStateStore(stateFilePath);
MonitorStateDocument stateDocument;
@@ -69,15 +92,23 @@ namespace HostAvailabilityMonitor
var currentState = new Dictionary<string, TargetState>(previousState, StringComparer.OrdinalIgnoreCase);
var probeService = new EndpointProbeService(config.Runtime);
var enabledTargets = BuildEnabledTargets(config, fullConfigPath, baseDirectory, logger);
logger.Info("Aktivierte Targets vorbereitet. Anzahl=" + enabledTargets.Count + ", MaxConcurrency=" + config.Runtime.MaxConcurrency + ".");
eventLogger.Info("Monitor-Targets vorbereitet. Umgebung=" + config.EnvironmentName + ", AktivierteTargets=" + enabledTargets.Count + ", MaxConcurrency=" + config.Runtime.MaxConcurrency + ".", 1002);
WriteConsoleInfo("Aktivierte Targets vorbereitet: " + enabledTargets.Count + ".");
if (enabledTargets.Count == 0)
{
logger.Warn("Es sind keine aktivierten Targets vorhanden. Der Lauf endet ohne Checks.");
eventLogger.Summary(config.ApplicationName + " ausgeführt, aber es waren keine aktivierten Targets vorhanden. Umgebung=" + config.EnvironmentName + ".", false);
WriteConsoleWarn("Keine aktivierten Targets vorhanden. Der Lauf endet ohne Checks.");
return 0;
}
WriteConsoleInfo("Starte Endpoint-Checks...");
logger.Info("Endpoint-Checks starten. Targets=" + enabledTargets.Count + ".");
eventLogger.Info("Endpoint-Checks gestartet. Umgebung=" + config.EnvironmentName + ", Targets=" + enabledTargets.Count + ".", 1003);
var results = await ExecuteChecksAsync(enabledTargets, probeService, config.Runtime.MaxConcurrency).ConfigureAwait(false);
WriteConsoleInfo("Endpoint-Checks abgeschlossen. Ergebnisse=" + results.Count + ".");
foreach (var result in results.OrderBy(r => r.ApplicationName, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase))
{
@@ -168,6 +199,9 @@ namespace HostAvailabilityMonitor
await TrySendDailySummaryAsync(config, notifier, stateDocument, currentState, results, logDirectory, logger, eventLogger).ConfigureAwait(false);
stateStore.Save(stateDocument);
logger.Info("Statusdatei gespeichert: " + stateFilePath);
eventLogger.Info("Monitor-Status gespeichert. Umgebung=" + config.EnvironmentName + ", Pfad=" + stateFilePath + ".", 1004);
WriteConsoleInfo("Statusdatei gespeichert: " + stateFilePath);
var total = results.Count(r => !r.IsSkipped);
var ok = results.Count(r => r.IsSuccess);
@@ -176,14 +210,22 @@ namespace HostAvailabilityMonitor
var summary = config.ApplicationName + " Lauf beendet. Umgebung=" + config.EnvironmentName + ", Gesamt=" + total + ", Erfolgreich=" + ok + ", Fehlgeschlagen=" + failed + ", Uebersprungen=" + skipped + ".";
logger.Info(summary);
eventLogger.Summary(summary, failed > 0);
WriteConsoleInfo(summary);
return failed > 0 && config.Runtime.NonZeroExitCodeOnAnyFailure ? 2 : 0;
}
catch (Exception ex)
{
WriteConsoleFatal("Monitorlauf fehlgeschlagen.", configPath, ex);
if (logger != null)
{
logger.Error("Fataler Fehler: " + ex.GetType().Name + ": " + ex.Message);
logger.Error("Details: " + ex);
}
else
{
TryWriteFallbackFailureLog(baseDirectory, configPath, ex);
}
if (eventLogger != null)
@@ -195,6 +237,13 @@ namespace HostAvailabilityMonitor
}
}
/// <summary>
/// Executes endpoint checks with the configured concurrency limit.
/// </summary>
/// <param name="targets">The enabled targets to probe.</param>
/// <param name="probeService">The endpoint probe service.</param>
/// <param name="maxConcurrency">The maximum number of concurrent probes.</param>
/// <returns>The collected probe results.</returns>
private static async Task<List<ProbeResult>> ExecuteChecksAsync(IReadOnlyCollection<TargetOptions> targets, EndpointProbeService probeService, int maxConcurrency)
{
var results = new List<ProbeResult>();
@@ -220,6 +269,14 @@ namespace HostAvailabilityMonitor
return results;
}
/// <summary>
/// Builds the enabled target list from static configuration and optional HIP VM configuration.
/// </summary>
/// <param name="config">The monitor configuration.</param>
/// <param name="configPath">The full monitor configuration path.</param>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="logger">The file logger for operational messages.</param>
/// <returns>The enabled targets to probe.</returns>
private static List<TargetOptions> BuildEnabledTargets(MonitorConfiguration config, string configPath, string baseDirectory, FileLogger logger)
{
var targets = config.Targets == null
@@ -242,6 +299,13 @@ namespace HostAvailabilityMonitor
return targets;
}
/// <summary>
/// Creates monitor targets for enabled HIP virtual machines.
/// </summary>
/// <param name="vmConfig">The HIP virtual machine configuration.</param>
/// <param name="options">The HIP virtual machine monitor options.</param>
/// <param name="logger">The file logger for skipped VM messages.</param>
/// <returns>The generated monitor targets.</returns>
private static List<TargetOptions> CreateHipVirtualMachineTargets(HipVirtualMachineConfiguration vmConfig, HipVirtualMachineOptions options, FileLogger logger)
{
var targets = new List<TargetOptions>();
@@ -297,6 +361,18 @@ namespace HostAvailabilityMonitor
return targets;
}
/// <summary>
/// Creates one generated target for a HIP virtual machine check.
/// </summary>
/// <param name="vm">The source HIP virtual machine.</param>
/// <param name="machineName">The display machine name.</param>
/// <param name="applicationName">The application name assigned to generated targets.</param>
/// <param name="checkName">The technical check name.</param>
/// <param name="displayCheckName">The display check name.</param>
/// <param name="endpoint">The generated endpoint.</param>
/// <param name="port">The generated port, when applicable.</param>
/// <param name="timeoutSeconds">The generated target timeout.</param>
/// <returns>The generated target.</returns>
private static TargetOptions CreateHipVirtualMachineTarget(
HipVirtualMachine vm,
string machineName,
@@ -323,6 +399,13 @@ namespace HostAvailabilityMonitor
};
}
/// <summary>
/// Resolves a configuration-relative path against the configuration directory first, then the application base directory.
/// </summary>
/// <param name="configPath">The main configuration path.</param>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="configuredPath">The configured relative or absolute path.</param>
/// <returns>The resolved path.</returns>
private static string ResolveConfigurationPath(string configPath, string baseDirectory, string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
@@ -340,6 +423,18 @@ namespace HostAvailabilityMonitor
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
/// <summary>
/// Sends the daily summary email when it is due and updates state metadata only after successful delivery.
/// </summary>
/// <param name="config">The monitor configuration.</param>
/// <param name="notifier">The email notifier.</param>
/// <param name="stateDocument">The mutable state document.</param>
/// <param name="currentState">The current target state dictionary.</param>
/// <param name="results">The latest probe results.</param>
/// <param name="logDirectory">The log directory used to calculate daily counters.</param>
/// <param name="logger">The file logger.</param>
/// <param name="eventLogger">The Event Log writer.</param>
/// <returns>A task that completes when the summary check has finished.</returns>
private static async Task TrySendDailySummaryAsync(
MonitorConfiguration config,
EmailNotifier notifier,
@@ -395,6 +490,14 @@ namespace HostAvailabilityMonitor
}
}
/// <summary>
/// Reads today's probe counters from the current rolling log file.
/// </summary>
/// <param name="logDirectory">The log directory.</param>
/// <param name="filePrefix">The monitor log file prefix.</param>
/// <param name="nowLocal">The current local server time.</param>
/// <param name="logger">The file logger used for fallback warnings.</param>
/// <returns>The counters found in today's log file.</returns>
private static ProbeLogCounters ReadTodayProbeCounters(string logDirectory, string filePrefix, DateTime nowLocal, FileLogger logger)
{
var counters = new ProbeLogCounters();
@@ -435,6 +538,12 @@ namespace HostAvailabilityMonitor
return counters;
}
/// <summary>
/// Updates persisted state for one non-skipped probe result.
/// </summary>
/// <param name="currentState">The mutable current state dictionary.</param>
/// <param name="result">The latest probe result.</param>
/// <param name="previousState">The previously persisted target state.</param>
private static void UpdateState(Dictionary<string, TargetState> currentState, ProbeResult result, TargetState previousState)
{
var nowUtc = DateTime.UtcNow;
@@ -477,6 +586,12 @@ namespace HostAvailabilityMonitor
currentState[result.StateKey] = state;
}
/// <summary>
/// Resolves a configured path against the application base directory.
/// </summary>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="configuredPath">The configured relative or absolute path.</param>
/// <returns>The resolved path.</returns>
private static string ResolvePath(string baseDirectory, string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
@@ -487,6 +602,12 @@ namespace HostAvailabilityMonitor
return Path.GetFullPath(Path.Combine(baseDirectory, configuredPath));
}
/// <summary>
/// Builds a structured Event Log message for one probe result.
/// </summary>
/// <param name="config">The monitor configuration.</param>
/// <param name="result">The probe result.</param>
/// <returns>The formatted Event Log message.</returns>
private static string BuildEventMessage(MonitorConfiguration config, ProbeResult result)
{
return string.Format(
@@ -504,10 +625,122 @@ namespace HostAvailabilityMonitor
result.Detail);
}
/// <summary>
/// Writes the initial console banner before configuration and file logging are available.
/// </summary>
/// <param name="title">The program title.</param>
/// <param name="configPath">The resolved configuration path.</param>
private static void WriteConsoleBanner(string title, string configPath)
{
TryWriteConsoleLine(Console.Out, "============================================================");
TryWriteConsoleLine(Console.Out, title);
TryWriteConsoleLine(Console.Out, "Server: " + Environment.MachineName);
TryWriteConsoleLine(Console.Out, "Start: " + DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"));
TryWriteConsoleLine(Console.Out, "Config: " + (configPath ?? "(nicht aufgeloest)"));
TryWriteConsoleLine(Console.Out, "============================================================");
}
/// <summary>
/// Writes a console information message.
/// </summary>
/// <param name="message">The message to write.</param>
private static void WriteConsoleInfo(string message)
{
TryWriteConsoleLine(Console.Out, "[INFO] " + message);
}
/// <summary>
/// Writes a console warning message.
/// </summary>
/// <param name="message">The message to write.</param>
private static void WriteConsoleWarn(string message)
{
TryWriteConsoleLine(Console.Out, "[WARN] " + message);
}
/// <summary>
/// Writes a fatal console error with actionable context.
/// </summary>
/// <param name="message">The user-facing failure message.</param>
/// <param name="configPath">The configuration path used for the run.</param>
/// <param name="exception">The exception that ended the run.</param>
private static void WriteConsoleFatal(string message, string configPath, Exception exception)
{
TryWriteConsoleLine(Console.Error, "[ERROR] " + message);
TryWriteConsoleLine(Console.Error, "[ERROR] Konfiguration: " + (configPath ?? "(nicht aufgeloest)"));
TryWriteConsoleLine(Console.Error, "[ERROR] Ursache: " + exception.GetType().Name + ": " + exception.Message);
TryWriteConsoleLine(Console.Error, "[ERROR] Details: " + exception);
}
/// <summary>
/// Writes one line to a console writer and ignores secondary console failures.
/// </summary>
/// <param name="writer">The console writer.</param>
/// <param name="message">The line to write.</param>
private static void TryWriteConsoleLine(TextWriter writer, string message)
{
try
{
writer.WriteLine(message);
}
catch
{
}
}
/// <summary>
/// Writes a fallback fatal log when configured logging could not be initialized.
/// </summary>
/// <param name="baseDirectory">The application base directory.</param>
/// <param name="configPath">The configuration path used for the run.</param>
/// <param name="exception">The exception that ended the run.</param>
private static void TryWriteFallbackFailureLog(string baseDirectory, string configPath, Exception exception)
{
try
{
var directory = ResolvePath(baseDirectory ?? AppDomain.CurrentDomain.BaseDirectory, "logs");
var fallbackLogger = new FileLogger(directory, "host-availability-monitor-startup", 5, "HostAvailabilityMonitor", "Startup");
fallbackLogger.Error("Monitor konnte nicht gestartet werden. Konfiguration=" + (configPath ?? "(nicht aufgeloest)") + ". Fehler=" + exception.GetType().Name + ": " + exception.Message);
fallbackLogger.Error("Details: " + exception);
WriteConsoleInfo("Fallback-Log geschrieben: " + BuildLogFilePreview(directory, "host-availability-monitor-startup"));
}
catch (Exception fallbackEx)
{
TryWriteConsoleLine(Console.Error, "[ERROR] Fallback-Log konnte nicht geschrieben werden: " + fallbackEx.GetType().Name + ": " + fallbackEx.Message);
}
}
/// <summary>
/// Builds the expected daily log file path for console diagnostics.
/// </summary>
/// <param name="directory">The log directory.</param>
/// <param name="filePrefix">The rolling file prefix.</param>
/// <returns>The expected log file path for the current local date.</returns>
private static string BuildLogFilePreview(string directory, string filePrefix)
{
var safeDirectory = string.IsNullOrWhiteSpace(directory) ? AppDomain.CurrentDomain.BaseDirectory : directory;
var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "host-availability-monitor" : filePrefix;
return Path.Combine(safeDirectory, safePrefix + "-" + DateTime.Now.ToString("yyyy-MM-dd") + ".log");
}
/// <summary>
/// Holds daily probe counters parsed from the rolling log.
/// </summary>
private sealed class ProbeLogCounters
{
/// <summary>
/// Gets or sets today's successful probe count.
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// Gets or sets today's failed probe count.
/// </summary>
public int FailureCount { get; set; }
/// <summary>
/// Gets or sets today's skipped probe count.
/// </summary>
public int SkipCount { get; set; }
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Runtime.InteropServices;
// Assembly metadata for the HostAvailabilityMonitor console application.
[assembly: AssemblyTitle("HostAvailabilityMonitor")]
[assembly: AssemblyDescription("Windows Server 2019 kompatibler Host Availability Monitor auf .NET Framework 4.7.2")]
[assembly: AssemblyConfiguration("")]
@@ -5,15 +5,29 @@ using System.Web.Script.Serialization;
namespace HostAvailabilityMonitor.State
{
/// <summary>
/// Loads and saves the persisted monitor state file.
/// </summary>
public sealed class MonitorStateStore
{
/// <summary>
/// Full path to the JSON state file.
/// </summary>
private readonly string _filePath;
/// <summary>
/// Initializes a new state store.
/// </summary>
/// <param name="filePath">The state JSON file path.</param>
public MonitorStateStore(string filePath)
{
_filePath = filePath;
}
/// <summary>
/// Loads the current state document and supports the legacy dictionary-only format.
/// </summary>
/// <returns>The loaded and normalized state document.</returns>
public MonitorStateDocument LoadDocument()
{
if (!File.Exists(_filePath))
@@ -54,11 +68,19 @@ namespace HostAvailabilityMonitor.State
}
}
/// <summary>
/// Loads only the target state dictionary.
/// </summary>
/// <returns>The target states keyed by target state key.</returns>
public Dictionary<string, TargetState> Load()
{
return LoadDocument().TargetStates;
}
/// <summary>
/// Saves a complete monitor state document atomically.
/// </summary>
/// <param name="document">The document to persist.</param>
public void Save(MonitorStateDocument document)
{
var directory = Path.GetDirectoryName(_filePath);
@@ -90,6 +112,10 @@ namespace HostAvailabilityMonitor.State
}
}
/// <summary>
/// Saves a target state dictionary using an empty metadata object.
/// </summary>
/// <param name="states">The target states to persist.</param>
public void Save(Dictionary<string, TargetState> states)
{
Save(new MonitorStateDocument
@@ -101,6 +127,11 @@ namespace HostAvailabilityMonitor.State
});
}
/// <summary>
/// Ensures that state document collections and metadata are initialized.
/// </summary>
/// <param name="document">The document to normalize.</param>
/// <returns>The normalized document.</returns>
private static MonitorStateDocument Normalize(MonitorStateDocument document)
{
if (document == null)
@@ -116,24 +147,66 @@ namespace HostAvailabilityMonitor.State
}
}
/// <summary>
/// Root object persisted in the monitor state file.
/// </summary>
public sealed class MonitorStateDocument
{
/// <summary>
/// Gets or sets target states keyed by target state key.
/// </summary>
public Dictionary<string, TargetState> TargetStates { get; set; } = new Dictionary<string, TargetState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets state metadata that is not tied to one target.
/// </summary>
public MonitorStateMetadata Metadata { get; set; } = new MonitorStateMetadata();
}
/// <summary>
/// Contains monitor state metadata shared across targets.
/// </summary>
public sealed class MonitorStateMetadata
{
/// <summary>
/// Gets or sets the local date for the most recent successfully sent daily summary email.
/// </summary>
public string LastDailySummarySentLocalDate { get; set; } = string.Empty;
}
/// <summary>
/// Persists the last known status and notification metadata for one target.
/// </summary>
public sealed class TargetState
{
/// <summary>
/// Gets or sets whether the target was up during the most recent non-skipped probe.
/// </summary>
public bool IsUp { get; set; }
/// <summary>
/// Gets or sets the number of consecutive failed probes.
/// </summary>
public int ConsecutiveFailures { get; set; }
/// <summary>
/// Gets or sets the UTC time of the most recent check.
/// </summary>
public DateTime LastCheckedUtc { get; set; }
/// <summary>
/// Gets or sets the UTC time when the target last changed between up and down.
/// </summary>
public DateTime LastChangedUtc { get; set; }
/// <summary>
/// Gets or sets the UTC time when the most recent notification was sent.
/// </summary>
public DateTime? LastNotificationUtc { get; set; }
/// <summary>
/// Gets or sets the most recent probe detail text.
/// </summary>
public string LastDetail { get; set; } = string.Empty;
}
}