Support ScheduledTask control and persistent logs

This commit is contained in:
2026-08-26 10:23:45 +02:00
parent 3b4a621cd3
commit 999f69bcb9
24 changed files with 1091 additions and 84 deletions
@@ -23,7 +23,7 @@ namespace BizTalkPlatformManagementTool.Setup
private const string ProductName = "BizTalk Platform Management Tool";
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
private const string ProductVersion = "2.2.4";
private const string ProductVersion = "2.3.0";
/// <summary>
/// Wartezeiten zwischen Wiederholungen atomarer Verzeichnisverschiebungen.
@@ -65,7 +65,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.2.4"
Text = "BizTalk Platform Management Tool 2.3.0"
});
root.Controls.Add(new Label
{
@@ -8,6 +8,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.2.4.0")]
[assembly: AssemblyFileVersion("2.2.4.0")]
[assembly: AssemblyVersion("2.3.0.0")]
[assembly: AssemblyFileVersion("2.3.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.2.4.0" name="BizTalkPlatformManagementTool.Setup" />
<assemblyIdentity version="2.3.0.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<!-- Semicolon-delimited optional override. Normal BizTalk 2020 and ScheduledTask 7.x folders are auto-discovered. -->
<add key="AdapterAssemblySearchPaths" value="%ProgramFiles(x86)%\Microsoft BizTalk Server 2020;%ProgramFiles(x86)%\BizTalk ScheduledTask Adapter 7.0.2" />
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
@@ -39,10 +39,12 @@
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Configuration" />
<Reference Include="System.Data" />
<Reference Include="System.Drawing" />
<Reference Include="System.Management" />
<Reference Include="System.Runtime.Serialization" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.ServiceProcess" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
@@ -56,10 +58,12 @@
<Compile Include="Models\DiffModels.cs" />
<Compile Include="Models\OperationModels.cs" />
<Compile Include="Services\BizTalkWmiClient.cs" />
<Compile Include="Services\AdapterAssemblyResolver.cs" />
<Compile Include="Services\CsvWriter.cs" />
<Compile Include="Services\HtmlReportWriter.cs" />
<Compile Include="Services\JsonFileStore.cs" />
<Compile Include="Services\OperationLogger.cs" />
<Compile Include="Services\ExceptionDiagnostics.cs" />
<Compile Include="Services\OperationPlanExecutor.cs" />
<Compile Include="Services\SnapshotComparer.cs" />
<Compile Include="Services\SnapshotValidator.cs" />
@@ -184,6 +184,14 @@ namespace BizTalkPlatformManagementTool.Models
/// </summary>
[DataMember(Order = 13)]
public string Warning { get; set; }
/// <summary>Gets or sets the receive adapter name used for dependency preflight.</summary>
[DataMember(Order = 14, EmitDefaultValue = false)]
public string AdapterName { get; set; }
/// <summary>Gets or sets the receive transport address used to identify scheduler URIs.</summary>
[DataMember(Order = 15, EmitDefaultValue = false)]
public string Address { get; set; }
}
/// <summary>
@@ -9,6 +9,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.2.4.0")]
[assembly: AssemblyFileVersion("2.2.4.0")]
[assembly: AssemblyVersion("2.3.0.0")]
[assembly: AssemblyFileVersion("2.3.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -0,0 +1,395 @@
using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Win32;
namespace BizTalkPlatformManagementTool.Services
{
/// <summary>
/// Resolves custom-adapter and BizTalk support assemblies from installed product
/// directories for the lifetime of one real operation-plan execution.
/// </summary>
internal sealed class AdapterAssemblyResolver : IDisposable
{
/// <summary>The dependency whose absence breaks ScheduledTask Enable/Disable validation.</summary>
internal const string SchedulerAssemblyName = "Microsoft.BizTalk.Scheduler";
/// <summary>Logger receiving all search-path and load diagnostics.</summary>
private readonly OperationLogger _logger;
/// <summary>Trusted directories searched after normal CLR resolution has failed.</summary>
private readonly List<string> _searchDirectories;
/// <summary>Prevents duplicate event registration and unregisters safely.</summary>
private bool _attached;
/// <summary>Prevents repeated ScheduledTask dependency preflight in one plan.</summary>
private bool _schedulerPrepared;
/// <summary>
/// Initializes and attaches the process-local resolver.
/// </summary>
/// <param name="logger">The operation logger used for support diagnostics.</param>
public AdapterAssemblyResolver(OperationLogger logger)
: this(logger, DiscoverSearchDirectories())
{
}
/// <summary>Initializes a resolver with explicit directories for regression tests.</summary>
/// <param name="logger">Optional logger.</param>
/// <param name="searchDirectories">Directories that may contain dependencies.</param>
internal AdapterAssemblyResolver(OperationLogger logger, IEnumerable<string> searchDirectories)
{
_logger = logger;
_searchDirectories = NormalizeDirectories(searchDirectories).ToList();
AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly;
_attached = true;
}
/// <summary>Gets the immutable ordered dependency search path.</summary>
internal IList<string> SearchDirectories
{
get { return _searchDirectories.AsReadOnly(); }
}
/// <summary>
/// Preloads the BizTalk Scheduler dependency before WMI validates a Scheduler
/// receive location and emits actionable diagnostics when it cannot be found.
/// </summary>
public void PrepareScheduledTaskAdapter()
{
if (_schedulerPrepared)
{
return;
}
_schedulerPrepared = true;
Info("ScheduledTask adapter preflight. ProcessBitness=" + (Environment.Is64BitProcess ? "64" : "32")
+ "; SearchDirectories=" + string.Join(" | ", _searchDirectories.ToArray()));
var alreadyLoaded = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(x => string.Equals(x.GetName().Name, SchedulerAssemblyName, StringComparison.OrdinalIgnoreCase));
if (alreadyLoaded != null)
{
Success("ScheduledTask dependency already loaded: " + DescribeAssembly(alreadyLoaded));
return;
}
try
{
// Normal CLR/GAC resolution remains authoritative. If it fails, the attached
// resolver gets the same request and may satisfy it from an installed product path.
var normallyResolved = Assembly.Load(new AssemblyName(SchedulerAssemblyName));
Success("ScheduledTask dependency resolved through CLR/GAC: " + DescribeAssembly(normallyResolved));
return;
}
catch (FileNotFoundException)
{
// The explicit path diagnostic below reports every searched installation directory.
}
catch (FileLoadException ex)
{
Warning("ScheduledTask dependency CLR/GAC resolution found an incompatible or unloadable assembly. " + ExceptionDiagnostics.Format(ex));
}
var path = FindCandidateFile(SchedulerAssemblyName, null, _searchDirectories);
if (path == null)
{
throw new FileNotFoundException(
"ScheduledTask receive-location control requires Microsoft.BizTalk.Scheduler.dll. "
+ "The assembly was not found in the BizTalk installation directory, the ScheduledTask Adapter directory, "
+ "or configured AdapterAssemblySearchPaths. Search directories: "
+ string.Join(" | ", _searchDirectories.ToArray())
+ ". Install the matching BizTalk Scheduler assembly in the GAC or add its existing directory to "
+ "BizTalkPlatformManagementTool.exe.config; do not copy an assembly from another BizTalk version.");
}
try
{
var assembly = Assembly.LoadFrom(path);
Success("ScheduledTask dependency loaded process-locally: " + DescribeAssembly(assembly));
}
catch (Exception ex)
{
throw new InvalidOperationException(
"ScheduledTask dependency was found but could not be loaded from '" + path + "'. "
+ ExceptionDiagnostics.Format(ex), ex);
}
}
/// <summary>Unregisters the process-local resolver.</summary>
public void Dispose()
{
if (_attached)
{
AppDomain.CurrentDomain.AssemblyResolve -= ResolveAssembly;
_attached = false;
}
}
/// <summary>Handles unresolved CLR assembly requests using identity-checked files.</summary>
private Assembly ResolveAssembly(object sender, ResolveEventArgs args)
{
try
{
var requested = new AssemblyName(args.Name);
var loaded = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(x => AssemblyIdentityMatches(requested, x.GetName()));
if (loaded != null)
{
return loaded;
}
var path = FindCandidateFile(requested.Name, requested, _searchDirectories);
if (path == null)
{
Warning("Assembly resolution failed. Requested=" + args.Name
+ "; RequestingAssembly=" + (args.RequestingAssembly == null ? "(unknown)" : args.RequestingAssembly.FullName)
+ "; SearchDirectories=" + string.Join(" | ", _searchDirectories.ToArray()));
return null;
}
var assembly = Assembly.LoadFrom(path);
Success("Resolved adapter dependency process-locally. Requested=" + args.Name
+ "; Loaded=" + DescribeAssembly(assembly));
return assembly;
}
catch (Exception ex)
{
Warning("Assembly resolution raised an error. Requested=" + args.Name + "; " + ExceptionDiagnostics.Format(ex));
return null;
}
}
/// <summary>
/// Finds an identity-compatible DLL without loading it into the current AppDomain.
/// </summary>
/// <param name="simpleName">Requested simple assembly name.</param>
/// <param name="requested">Full requested identity, or null to accept any installed version.</param>
/// <param name="directories">Trusted directories to search.</param>
/// <returns>The matching DLL path, or null.</returns>
internal static string FindCandidateFile(string simpleName, AssemblyName requested, IEnumerable<string> directories)
{
if (string.IsNullOrWhiteSpace(simpleName))
{
return null;
}
foreach (var directory in NormalizeDirectories(directories))
{
var candidate = Path.Combine(directory, simpleName + ".dll");
if (!File.Exists(candidate))
{
continue;
}
try
{
var actual = AssemblyName.GetAssemblyName(candidate);
if (requested == null
? string.Equals(actual.Name, simpleName, StringComparison.OrdinalIgnoreCase)
: AssemblyIdentityMatches(requested, actual))
{
return candidate;
}
}
catch
{
// A native, corrupt or unrelated file is never loaded merely because its file name matches.
}
}
return null;
}
/// <summary>Compares simple name, requested version, culture and public key token.</summary>
private static bool AssemblyIdentityMatches(AssemblyName requested, AssemblyName actual)
{
if (requested == null || actual == null
|| !string.Equals(requested.Name, actual.Name, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (requested.Version != null && actual.Version != requested.Version)
{
return false;
}
if (!string.IsNullOrEmpty(requested.CultureName)
&& !string.Equals(requested.CultureName, actual.CultureName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var expectedToken = requested.GetPublicKeyToken();
var actualToken = actual.GetPublicKeyToken();
return expectedToken == null || expectedToken.Length == 0 || TokensEqual(expectedToken, actualToken);
}
/// <summary>Compares strong-name public key tokens.</summary>
private static bool TokensEqual(byte[] left, byte[] right)
{
if (left == null || right == null || left.Length != right.Length)
{
return false;
}
for (var i = 0; i < left.Length; i++)
{
if (left[i] != right[i])
{
return false;
}
}
return true;
}
/// <summary>Discovers BizTalk, ScheduledTask and explicitly configured directories.</summary>
private static IEnumerable<string> DiscoverSearchDirectories()
{
var result = new List<string>();
AddConfiguredDirectories(result);
AddRegistryDirectories(result);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
AddMatchingDirectories(result, programFilesX86, "Microsoft BizTalk Server*");
AddMatchingDirectories(result, programFilesX86, "BizTalk ScheduledTask Adapter*");
AddMatchingDirectories(result, programFilesX86, "Biztalk ScheduledTask Adapter*");
return result;
}
/// <summary>Adds semicolon-delimited paths from App.config after environment expansion.</summary>
private static void AddConfiguredDirectories(ICollection<string> result)
{
try
{
var configured = ConfigurationManager.AppSettings["AdapterAssemblySearchPaths"];
if (string.IsNullOrWhiteSpace(configured))
{
return;
}
foreach (var value in configured.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
result.Add(Environment.ExpandEnvironmentVariables(value.Trim()));
}
}
catch
{
// Registry and conventional installation directories remain available.
}
}
/// <summary>Adds known assembly-path values from both registry views.</summary>
private static void AddRegistryDirectories(ICollection<string> result)
{
var views = new[] { RegistryView.Registry32, RegistryView.Registry64 };
foreach (var view in views)
{
try
{
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view))
{
AddRegistryTreeDirectories(baseKey, @"SOFTWARE\Microsoft\BizTalk Server\3.0", result, 0);
}
}
catch
{
// Registry discovery is best-effort and never blocks normal BizTalk adapters.
}
}
}
/// <summary>Reads installation and assembly directories from a shallow BizTalk registry tree.</summary>
private static void AddRegistryTreeDirectories(RegistryKey baseKey, string subKeyName, ICollection<string> result, int depth)
{
if (depth > 3)
{
return;
}
using (var key = baseKey.OpenSubKey(subKeyName, false))
{
if (key == null)
{
return;
}
foreach (var valueName in key.GetValueNames())
{
var value = key.GetValue(valueName) as string;
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
value = Environment.ExpandEnvironmentVariables(value.Trim().Trim('"'));
if (value.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
result.Add(Path.GetDirectoryName(value));
}
else if (Directory.Exists(value))
{
result.Add(value);
}
}
foreach (var child in key.GetSubKeyNames())
{
AddRegistryTreeDirectories(baseKey, subKeyName + "\\" + child, result, depth + 1);
}
}
}
/// <summary>Adds directories matching one non-recursive product-folder pattern.</summary>
private static void AddMatchingDirectories(ICollection<string> result, string parent, string pattern)
{
try
{
if (Directory.Exists(parent))
{
foreach (var directory in Directory.GetDirectories(parent, pattern, SearchOption.TopDirectoryOnly))
{
result.Add(directory);
}
}
}
catch
{
// A denied optional discovery path is reported later if Scheduler preflight is required.
}
}
/// <summary>Normalizes, de-duplicates and filters existing directories.</summary>
private static IEnumerable<string> NormalizeDirectories(IEnumerable<string> directories)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in directories ?? Enumerable.Empty<string>())
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
string fullPath;
try
{
fullPath = Path.GetFullPath(value.Trim().Trim('"')).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
catch
{
continue;
}
if (Directory.Exists(fullPath) && seen.Add(fullPath))
{
yield return fullPath;
}
}
}
/// <summary>Builds a diagnostic assembly description.</summary>
private static string DescribeAssembly(Assembly assembly)
{
return assembly.FullName + "; Location=" + assembly.Location;
}
/// <summary>Writes informational diagnostics when a logger is available.</summary>
private void Info(string message) { if (_logger != null) _logger.Info(message); }
/// <summary>Writes warning diagnostics when a logger is available.</summary>
private void Warning(string message) { if (_logger != null) _logger.Warning(message); }
/// <summary>Writes successful dependency diagnostics when a logger is available.</summary>
private void Success(string message) { if (_logger != null) _logger.Success(message); }
}
}
@@ -16,7 +16,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.2.4-net461";
public const string Version = "2.3.0-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
@@ -192,7 +192,9 @@ namespace BizTalkPlatformManagementTool.Services
{
foreach (var item in app.ReceiveLocations.Where(x => x.Enabled))
{
plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, "Disable", null, null));
var step = Step("ReceiveLocation", app.Application, item.Name, null, "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, "Disable", null, null);
SetReceiveTransport(step, item);
plan.Steps.Add(step);
}
}
@@ -306,7 +308,9 @@ namespace BizTalkPlatformManagementTool.Services
{
foreach (var item in app.ReceiveLocations)
{
plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, item.Enabled ? "Enable receive location" : "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, item.Enabled ? "Enable" : "Disable", null, null));
var step = Step("ReceiveLocation", app.Application, item.Name, null, item.Enabled ? "Enable receive location" : "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, item.Enabled ? "Enable" : "Disable", null, null);
SetReceiveTransport(step, item);
plan.Steps.Add(step);
}
}
@@ -465,6 +469,9 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>Operation logger.</summary>
private readonly OperationLogger _logger;
/// <summary>Process-local resolver for adapter validation dependencies.</summary>
private readonly AdapterAssemblyResolver _assemblyResolver;
/// <summary>Lazily connected BizTalk WMI client.</summary>
private BizTalkWmiClient _client;
@@ -475,6 +482,7 @@ namespace BizTalkPlatformManagementTool.Services
{
_server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim();
_logger = logger;
_assemblyResolver = new AdapterAssemblyResolver(logger);
}
/// <summary>Executes one Windows-service or BizTalk-WMI step state-aware.</summary>
@@ -499,6 +507,10 @@ namespace BizTalkPlatformManagementTool.Services
{
return RuntimeStepOutcome.AlreadySatisfied;
}
if (IsScheduledTaskReceiveLocation(step))
{
_assemblyResolver.PrepareScheduledTaskAdapter();
}
ExecuteWmiStep(instance, step, options);
return RuntimeStepOutcome.Succeeded;
}
@@ -514,6 +526,20 @@ namespace BizTalkPlatformManagementTool.Services
_client.Dispose();
_client = null;
}
_assemblyResolver.Dispose();
}
/// <summary>Detects the third-party ScheduledTask adapter from captured plan metadata.</summary>
/// <param name="step">The receive-location step.</param>
/// <returns>True when Scheduler dependency preflight is required.</returns>
private static bool IsScheduledTaskReceiveLocation(OperationStep step)
{
return step != null
&& string.Equals(step.Kind, "ReceiveLocation", StringComparison.OrdinalIgnoreCase)
&& ((!string.IsNullOrWhiteSpace(step.Address)
&& step.Address.TrimStart().StartsWith("scheduler:", StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrWhiteSpace(step.AdapterName)
&& step.AdapterName.IndexOf("schedul", StringComparison.OrdinalIgnoreCase) >= 0));
}
/// <summary>
@@ -735,6 +761,15 @@ namespace BizTalkPlatformManagementTool.Services
};
}
/// <summary>Copies receive transport metadata into a durable operation step.</summary>
/// <param name="step">The plan step to enrich.</param>
/// <param name="receiveLocation">The captured receive location.</param>
private static void SetReceiveTransport(OperationStep step, ReceiveLocationState receiveLocation)
{
step.AdapterName = receiveLocation == null ? null : receiveLocation.AdapterName;
step.Address = receiveLocation == null ? null : receiveLocation.Address;
}
/// <summary>
/// Converts integer method arguments into the object array required by WMI.
/// </summary>
@@ -773,6 +808,8 @@ namespace BizTalkPlatformManagementTool.Services
+ ", class=" + step.WmiClass
+ ", key=" + step.KeyProperty + "=" + (step.KeyValue ?? string.Empty)
+ ", method=" + step.MethodName
+ (string.IsNullOrWhiteSpace(step.AdapterName) ? string.Empty : ", adapter=" + step.AdapterName)
+ (string.IsNullOrWhiteSpace(step.Address) ? string.Empty : ", address=" + step.Address)
+ (string.IsNullOrWhiteSpace(step.Server) ? string.Empty : ", server=" + step.Server)
+ "]";
}
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
namespace BizTalkPlatformManagementTool.Services
{
/// <summary>
/// Creates support-ready, single-line exception diagnostics for the GUI, durable
/// execution reports and rolling log files.
/// </summary>
internal static class ExceptionDiagnostics
{
/// <summary>
/// Formats the complete exception chain including type, HRESULT, fusion details
/// and stack trace without embedding physical line breaks in the daily log.
/// </summary>
/// <param name="exception">The exception to format.</param>
/// <returns>A detailed single-line diagnostic string.</returns>
public static string Format(Exception exception)
{
if (exception == null)
{
return "Unknown operation error.";
}
var parts = new List<string>();
var current = exception;
var depth = 0;
while (current != null)
{
var label = depth == 0 ? "Exception" : "InnerException[" + depth.ToString(CultureInfo.InvariantCulture) + "]";
var part = label
+ " Type=" + current.GetType().FullName
+ "; HResult=0x" + current.HResult.ToString("X8", CultureInfo.InvariantCulture)
+ "; Message=" + Flatten(current.Message);
var fileNotFound = current as FileNotFoundException;
if (fileNotFound != null && !string.IsNullOrWhiteSpace(fileNotFound.FusionLog))
{
part += "; FusionLog=" + Flatten(fileNotFound.FusionLog);
}
var fileLoad = current as FileLoadException;
if (fileLoad != null && !string.IsNullOrWhiteSpace(fileLoad.FusionLog))
{
part += "; FusionLog=" + Flatten(fileLoad.FusionLog);
}
if (!string.IsNullOrWhiteSpace(current.StackTrace))
{
part += "; StackTrace=" + Flatten(current.StackTrace);
}
parts.Add(part);
current = current.InnerException;
depth++;
}
return string.Join(" | ", parts.ToArray());
}
/// <summary>Replaces physical control characters with readable escape sequences.</summary>
/// <param name="value">The text to flatten.</param>
/// <returns>Single-line text suitable for one durable log record.</returns>
private static string Flatten(string value)
{
return (value ?? string.Empty)
.Replace("\r", "\\r")
.Replace("\n", "\\n")
.Replace("\t", "\\t");
}
}
}
@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using System.IO.Compression;
using System.Linq;
using System.Text;
namespace BizTalkPlatformManagementTool.Services
{
@@ -68,20 +71,18 @@ namespace BizTalkPlatformManagementTool.Services
private const string LogFileExtension = ".log";
/// <summary>
/// Number of daily log files retained, including the current day.
/// Number of calendar days retained, including the current day.
/// </summary>
private const int RetentionDays = 5;
internal const int RetentionDays = 30;
/// <summary>Maximum number of historical records restored into the GUI by default.</summary>
public const int DefaultGridHistoryLimit = 10000;
/// <summary>
/// Process-wide lock that serializes log file appends.
/// </summary>
private static readonly object FileLock = new object();
/// <summary>
/// Process-wide flag that ensures log cleanup runs only once.
/// </summary>
private static int _cleanupDone;
/// <summary>
/// Optional callback for forwarding entries to the UI.
/// </summary>
@@ -97,12 +98,24 @@ namespace BizTalkPlatformManagementTool.Services
/// </summary>
/// <param name="sink">Optional callback that receives entries for display.</param>
public OperationLogger(Action<LogEntry> sink)
: this(sink, ResolveLogDirectory())
{
}
/// <summary>Initializes a logger with an explicit directory for regression tests.</summary>
/// <param name="sink">Optional callback that receives new entries.</param>
/// <param name="logDirectory">Directory used for plain and compressed logs.</param>
internal OperationLogger(Action<LogEntry> sink, string logDirectory)
{
_sink = sink;
_logDirectory = ResolveLogDirectory();
CleanupOldLogs();
_logDirectory = Path.GetFullPath(logDirectory);
Directory.CreateDirectory(_logDirectory);
MaintainLogs(DateTime.Now.Date);
}
/// <summary>Gets the directory containing active and compressed runtime logs.</summary>
public string LogDirectory { get { return _logDirectory; } }
/// <summary>
/// Gets the path of the daily log file for the current date.
/// </summary>
@@ -114,6 +127,42 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Reads retained plain and GZip-compressed records for restoring the operation grid.
/// Malformed legacy lines are ignored without affecting current logging.
/// </summary>
/// <param name="maximumEntries">Maximum newest entries to return.</param>
/// <returns>Chronologically ordered retained log entries.</returns>
public IList<LogEntry> ReadRecentEntries(int maximumEntries)
{
if (maximumEntries <= 0)
{
return new List<LogEntry>();
}
var entries = new List<LogEntry>();
try
{
var files = Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension + "*")
.Where(IsSupportedLogFile)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var file in files)
{
ReadEntries(file, entries);
}
}
catch
{
// Historical display is optional; current operations and file logging continue.
}
return entries
.OrderBy(x => x.Timestamp)
.Skip(Math.Max(0, entries.Count - maximumEntries))
.ToList();
}
/// <summary>
/// Writes an informational entry.
/// </summary>
@@ -202,7 +251,7 @@ namespace BizTalkPlatformManagementTool.Services
"[{0:yyyy-MM-dd HH:mm:ss}][{1}] {2}{3}",
entry.Timestamp,
entry.Level.ToString().ToUpperInvariant(),
entry.Message,
(entry.Message ?? string.Empty).Replace("\r", "\\r").Replace("\n", "\\n"),
Environment.NewLine);
lock (FileLock)
@@ -217,24 +266,29 @@ namespace BizTalkPlatformManagementTool.Services
}
/// <summary>
/// Deletes log files older than the configured retention window.
/// Compresses completed daily logs and removes all records outside retention.
/// </summary>
private void CleanupOldLogs()
/// <param name="today">The current local date.</param>
internal void MaintainLogs(DateTime today)
{
if (Interlocked.Exchange(ref _cleanupDone, 1) == 1)
{
return;
}
try
{
var cutoff = DateTime.Now.Date.AddDays(-(RetentionDays - 1));
foreach (var file in Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension))
var cutoff = today.Date.AddDays(-(RetentionDays - 1));
foreach (var file in Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension + "*"))
{
var lastWrite = File.GetLastWriteTime(file);
if (lastWrite.Date < cutoff)
DateTime fileDate;
if (!TryGetLogDate(file, out fileDate))
{
continue;
}
if (fileDate < cutoff)
{
File.Delete(file);
continue;
}
if (fileDate < today.Date && file.EndsWith(LogFileExtension, StringComparison.OrdinalIgnoreCase))
{
Compress(file);
}
}
}
@@ -244,6 +298,117 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>Compresses a completed log atomically and removes the plain source afterward.</summary>
/// <param name="source">The completed plain log.</param>
private static void Compress(string source)
{
var target = source + ".gz";
if (File.Exists(target))
{
return;
}
var temporary = target + ".tmp." + Guid.NewGuid().ToString("N");
try
{
using (var input = File.OpenRead(source))
using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
using (var gzip = new GZipStream(output, CompressionLevel.Optimal))
{
input.CopyTo(gzip);
}
File.Move(temporary, target);
File.Delete(source);
}
finally
{
if (File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
/// <summary>Reads one supported plain or compressed file into an entry collection.</summary>
private static void ReadEntries(string path, ICollection<LogEntry> entries)
{
try
{
using (var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
using (var payload = path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase)
? (Stream)new GZipStream(file, CompressionMode.Decompress)
: file)
using (var reader = new StreamReader(payload, Encoding.UTF8, true))
{
string line;
while ((line = reader.ReadLine()) != null)
{
LogEntry entry;
if (TryParse(line, out entry))
{
entries.Add(entry);
}
}
}
}
catch
{
// One unreadable archive must not hide all other retained history.
}
}
/// <summary>Parses the stable daily log line format.</summary>
/// <param name="line">One physical line.</param>
/// <param name="entry">Parsed entry when successful.</param>
/// <returns>True for a valid record.</returns>
internal static bool TryParse(string line, out LogEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(line) || line.Length < 29 || line[0] != '[' || line[21] != '[')
{
return false;
}
var timestampEnd = line.IndexOf(']');
var levelEnd = line.IndexOf(']', timestampEnd + 1);
DateTime timestamp;
LogLevel level;
if (timestampEnd != 20 || levelEnd < 0
|| !DateTime.TryParseExact(line.Substring(1, 19), "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out timestamp)
|| !Enum.TryParse(line.Substring(timestampEnd + 2, levelEnd - timestampEnd - 2), true, out level))
{
return false;
}
var messageStart = levelEnd + 1;
if (messageStart < line.Length && line[messageStart] == ' ')
{
messageStart++;
}
entry = new LogEntry { Timestamp = timestamp, Level = level, Message = line.Substring(messageStart) };
return true;
}
/// <summary>Checks the supported exact file suffixes.</summary>
private static bool IsSupportedLogFile(string path)
{
return path.EndsWith(LogFileExtension, StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(LogFileExtension + ".gz", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Extracts the calendar date from a stable log file name.</summary>
private static bool TryGetLogDate(string path, out DateTime date)
{
var name = Path.GetFileName(path);
var suffixLength = name.EndsWith(LogFileExtension + ".gz", StringComparison.OrdinalIgnoreCase)
? (LogFileExtension + ".gz").Length
: name.EndsWith(LogFileExtension, StringComparison.OrdinalIgnoreCase) ? LogFileExtension.Length : 0;
if (suffixLength == 0 || !name.StartsWith(LogFilePrefix, StringComparison.OrdinalIgnoreCase))
{
date = default(DateTime);
return false;
}
var value = name.Substring(LogFilePrefix.Length, name.Length - LogFilePrefix.Length - suffixLength);
return DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
}
/// <summary>
/// Ermittelt das bevorzugte maschinenweite Logverzeichnis mit Rückfall auf das EXE-Verzeichnis.
/// </summary>
@@ -179,6 +179,8 @@ namespace BizTalkPlatformManagementTool.Services
+ ", class=" + step.WmiClass
+ ", key=" + step.KeyProperty + "=" + (step.KeyValue ?? string.Empty)
+ ", method=" + step.MethodName
+ (string.IsNullOrWhiteSpace(step.AdapterName) ? string.Empty : ", adapter=" + step.AdapterName)
+ (string.IsNullOrWhiteSpace(step.Address) ? string.Empty : ", address=" + step.Address)
+ (string.IsNullOrWhiteSpace(step.Server) ? string.Empty : ", server=" + step.Server)
+ "]";
}
@@ -188,21 +190,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <returns>A single diagnostic message.</returns>
internal static string FormatException(Exception exception)
{
if (exception == null)
{
return "Unknown operation error.";
}
var message = exception.Message;
var inner = exception.InnerException;
while (inner != null)
{
if (!string.IsNullOrWhiteSpace(inner.Message) && message.IndexOf(inner.Message, StringComparison.OrdinalIgnoreCase) < 0)
{
message += " Inner error: " + inner.Message;
}
inner = inner.InnerException;
}
return message;
return ExceptionDiagnostics.Format(exception);
}
/// <summary>Writes an informational message when a logger is available.</summary>
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
@@ -125,6 +126,9 @@ namespace BizTalkPlatformManagementTool.Ui
/// </summary>
private Button _clearButton;
/// <summary>Button that opens the persistent runtime log directory.</summary>
private Button _openLogsButton;
/// <summary>
/// Button that closes the application.
/// </summary>
@@ -150,6 +154,7 @@ namespace BizTalkPlatformManagementTool.Ui
_service = new BizTalkOperationService(_logger);
BuildUi();
FormClosing += MainFormClosing;
LoadLogHistory();
_logger.Info("Log file: " + _logger.LogFilePath);
}
@@ -272,6 +277,7 @@ namespace BizTalkPlatformManagementTool.Ui
_emergencyRestoreButton = ActionButton("Emergency Restore", EmergencyRestoreClick);
_emergencyRestoreButton.Width = 142;
_clearButton = ActionButton("Clear", ClearClick);
_openLogsButton = ActionButton("Log Folder", OpenLogsClick);
_closeButton = ActionButton("Close", CloseClick);
panel.Controls.Add(_diagnoseButton);
@@ -283,6 +289,7 @@ namespace BizTalkPlatformManagementTool.Ui
panel.Controls.Add(_validateStateButton);
panel.Controls.Add(_emergencyRestoreButton);
panel.Controls.Add(_clearButton);
panel.Controls.Add(_openLogsButton);
panel.Controls.Add(_closeButton);
return panel;
}
@@ -306,7 +313,7 @@ namespace BizTalkPlatformManagementTool.Ui
statusPage.Controls.Add(_statusGrid);
ConfigureGrid(_logGrid);
_logGrid.Columns.Add("Time", "Time");
_logGrid.Columns.Add("Timestamp", "Timestamp");
_logGrid.Columns.Add("Level", "Level");
_logGrid.Columns.Add("Message", "Message");
_logGrid.Columns[2].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
@@ -577,6 +584,22 @@ namespace BizTalkPlatformManagementTool.Ui
_statusLabel.Text = "Ready.";
}
/// <summary>Opens the persistent local runtime log directory in Windows Explorer.</summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void OpenLogsClick(object sender, EventArgs e)
{
try
{
Directory.CreateDirectory(_logger.LogDirectory);
Process.Start("explorer.exe", _logger.LogDirectory);
}
catch (Exception ex)
{
_logger.Error("Could not open runtime log directory. " + ExceptionDiagnostics.Format(ex));
}
}
/// <summary>
/// Handles the Close button click.
/// </summary>
@@ -816,24 +839,53 @@ namespace BizTalkPlatformManagementTool.Ui
{
InvokeIfRequired(() =>
{
var index = _logGrid.Rows.Add(entry.Timestamp.ToString("HH:mm:ss"), entry.Level.ToString(), entry.Message);
var row = _logGrid.Rows[index];
if (entry.Level == LogLevel.Error)
{
row.DefaultCellStyle.ForeColor = Color.DarkRed;
}
else if (entry.Level == LogLevel.Warning)
{
row.DefaultCellStyle.ForeColor = Color.DarkOrange;
}
else if (entry.Level == LogLevel.Success)
{
row.DefaultCellStyle.ForeColor = Color.DarkGreen;
}
AddLogRow(entry);
_logGrid.FirstDisplayedScrollingRowIndex = Math.Max(0, _logGrid.Rows.Count - 1);
});
}
/// <summary>Restores retained plain and compressed log records into the grid at startup.</summary>
private void LoadLogHistory()
{
var entries = _logger.ReadRecentEntries(OperationLogger.DefaultGridHistoryLimit);
_logGrid.SuspendLayout();
try
{
foreach (var entry in entries)
{
AddLogRow(entry);
}
}
finally
{
_logGrid.ResumeLayout();
}
if (_logGrid.Rows.Count > 0)
{
_logGrid.FirstDisplayedScrollingRowIndex = _logGrid.Rows.Count - 1;
}
}
/// <summary>Adds and colors one retained or live log row.</summary>
/// <param name="entry">The log entry to display.</param>
private void AddLogRow(LogEntry entry)
{
var index = _logGrid.Rows.Add(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), entry.Level.ToString(), entry.Message);
var row = _logGrid.Rows[index];
if (entry.Level == LogLevel.Error)
{
row.DefaultCellStyle.ForeColor = Color.DarkRed;
}
else if (entry.Level == LogLevel.Warning)
{
row.DefaultCellStyle.ForeColor = Color.DarkOrange;
}
else if (entry.Level == LogLevel.Success)
{
row.DefaultCellStyle.ForeColor = Color.DarkGreen;
}
}
/// <summary>
/// Enables or disables action buttons and updates the status strip.
/// </summary>
@@ -853,6 +905,7 @@ namespace BizTalkPlatformManagementTool.Ui
_validateStateButton.Enabled = !busy;
_emergencyRestoreButton.Enabled = !busy;
_clearButton.Enabled = !busy;
_openLogsButton.Enabled = !busy;
_closeButton.Enabled = !busy;
_statusLabel.Text = status;
});
@@ -1071,19 +1124,7 @@ namespace BizTalkPlatformManagementTool.Ui
return "Operation failed.";
}
var message = ex.Message;
var inner = ex.InnerException;
while (inner != null)
{
if (!string.IsNullOrWhiteSpace(inner.Message) && message.IndexOf(inner.Message, StringComparison.OrdinalIgnoreCase) < 0)
{
message += " Inner error: " + inner.Message;
}
inner = inner.InnerException;
}
return message;
return ExceptionDiagnostics.Format(ex);
}
}
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.2.4.0" name="BizTalkPlatformManagementTool" />
<assemblyIdentity version="2.3.0.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>