diff --git a/Installer.Core/DependencyInstaller.cs b/Installer.Core/DependencyInstaller.cs
new file mode 100644
index 0000000..13aad3c
--- /dev/null
+++ b/Installer.Core/DependencyInstaller.cs
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Diagnostics;
+using Installer.Core.Models;
+using Microsoft.Data.SqlClient;
+
+namespace Installer.Core;
+
+///
+/// Installs community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)
+/// from GitHub. Requires an HttpClient — create one instance and dispose when done.
+///
+public sealed class DependencyInstaller : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private bool _disposed;
+
+ public DependencyInstaller()
+ {
+ _httpClient = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+ }
+
+ ///
+ /// Install community dependencies from GitHub into the PerformanceMonitor database.
+ /// Returns the number of successfully installed dependencies.
+ ///
+ public async Task InstallDependenciesAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ var dependencies = new List<(string Name, string Url, string Description)>
+ {
+ (
+ "sp_WhoIsActive",
+ "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql",
+ "Query activity monitoring by Adam Machanic (GPLv3)"
+ ),
+ (
+ "DarlingData",
+ "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql",
+ "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)"
+ ),
+ (
+ "First Responder Kit",
+ "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql",
+ "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)"
+ )
+ };
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Installing community dependencies...",
+ Status = "Info"
+ });
+
+ int successCount = 0;
+
+ foreach (var (name, url, description) in dependencies)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Installing {name}...",
+ Status = "Info"
+ });
+
+ try
+ {
+ var depSw = Stopwatch.StartNew();
+ progress?.Report(new InstallationProgress { Message = $"[DEBUG] Downloading {name} from {url}", Status = "Debug" });
+ string sql = await DownloadWithRetryAsync(url, progress, cancellationToken: cancellationToken).ConfigureAwait(false);
+ progress?.Report(new InstallationProgress { Message = $"[DEBUG] {name}: downloaded {sql.Length} chars in {depSw.ElapsedMilliseconds}ms", Status = "Debug" });
+
+ if (string.IsNullOrWhiteSpace(sql))
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{name} - FAILED (empty response)",
+ Status = "Error"
+ });
+ continue;
+ }
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection))
+ {
+ await useDbCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ string[] batches = Patterns.GoBatchSplitter.Split(sql);
+ int nonEmpty = batches.Count(b => !string.IsNullOrWhiteSpace(b));
+ progress?.Report(new InstallationProgress { Message = $"[DEBUG] {name}: executing {nonEmpty} batches", Status = "Debug" });
+
+ foreach (string batch in batches)
+ {
+ string trimmedBatch = batch.Trim();
+ if (string.IsNullOrWhiteSpace(trimmedBatch))
+ continue;
+
+ using var command = new SqlCommand(trimmedBatch, connection);
+ command.CommandTimeout = InstallationService.DependencyTimeoutSeconds;
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{name} - Success ({description})",
+ Status = "Success"
+ });
+
+ successCount++;
+ }
+ catch (HttpRequestException ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{name} - Download failed: {ex.Message}",
+ Status = "Error"
+ });
+ }
+ catch (SqlException ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{name} - SQL execution failed: {ex.Message}",
+ Status = "Error"
+ });
+ }
+ catch (Exception ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{name} - Failed: {ex.Message}",
+ Status = "Error"
+ });
+ }
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Dependencies installed: {successCount}/{dependencies.Count}",
+ Status = successCount == dependencies.Count ? "Success" : "Warning"
+ });
+
+ return successCount;
+ }
+
+ private async Task DownloadWithRetryAsync(
+ string url,
+ IProgress? progress = null,
+ int maxRetries = 3,
+ CancellationToken cancellationToken = default)
+ {
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ return await _httpClient.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException) when (attempt < maxRetries)
+ {
+ int delaySeconds = (int)Math.Pow(2, attempt);
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})...",
+ Status = "Warning"
+ });
+ await Task.Delay(delaySeconds * 1000, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ return await _httpClient.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _httpClient?.Dispose();
+ _disposed = true;
+ }
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs
new file mode 100644
index 0000000..e26f99f
--- /dev/null
+++ b/Installer.Core/InstallationService.cs
@@ -0,0 +1,1160 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Data;
+using System.Diagnostics;
+using System.Text;
+using Installer.Core.Models;
+using Microsoft.Data.SqlClient;
+
+namespace Installer.Core;
+
+///
+/// Core installation service for the Performance Monitor database.
+/// All methods are static — no instance state needed.
+///
+public static class InstallationService
+{
+ private static readonly char[] NewLineChars = ['\r', '\n'];
+
+ ///
+ /// Logs a diagnostic message through the progress reporter.
+ /// Uses "Debug" status so consumers can filter verbose output.
+ ///
+ private static void LogDebug(IProgress? progress, string message)
+ {
+ progress?.Report(new InstallationProgress { Message = $"[DEBUG] {message}", Status = "Debug" });
+ }
+
+ ///
+ /// Timeout for standard SQL file execution (5 minutes).
+ ///
+ public const int StandardTimeoutSeconds = 300;
+
+ ///
+ /// Timeout for upgrade migrations on large tables (1 hour).
+ ///
+ public const int UpgradeTimeoutSeconds = 3600;
+
+ ///
+ /// Timeout for short operations like cleanup (1 minute).
+ ///
+ public const int ShortTimeoutSeconds = 60;
+
+ ///
+ /// Timeout for dependency installation (2 minutes).
+ ///
+ public const int DependencyTimeoutSeconds = 120;
+
+ ///
+ /// Build a connection string from the provided parameters.
+ ///
+ public static string BuildConnectionString(
+ string server,
+ bool useWindowsAuth,
+ string? username = null,
+ string? password = null,
+ string encryption = "Mandatory",
+ bool trustCertificate = false,
+ bool useEntraAuth = false)
+ {
+ var builder = new SqlConnectionStringBuilder
+ {
+ DataSource = server,
+ InitialCatalog = "master",
+ TrustServerCertificate = trustCertificate
+ };
+
+ builder.Encrypt = encryption switch
+ {
+ "Optional" => SqlConnectionEncryptOption.Optional,
+ "Mandatory" => SqlConnectionEncryptOption.Mandatory,
+ "Strict" => SqlConnectionEncryptOption.Strict,
+ _ => SqlConnectionEncryptOption.Mandatory
+ };
+
+ if (useEntraAuth)
+ {
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+ builder.UserID = username;
+ }
+ else if (useWindowsAuth)
+ {
+ builder.IntegratedSecurity = true;
+ }
+ else
+ {
+ builder.UserID = username;
+ builder.Password = password;
+ }
+
+ return builder.ConnectionString;
+ }
+
+ ///
+ /// Test connection to SQL Server and get server information.
+ ///
+ public static async Task TestConnectionAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ var info = new ServerInfo();
+ LogDebug(progress, $"TestConnectionAsync: opening connection");
+ var sw = Stopwatch.StartNew();
+
+ try
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ LogDebug(progress, $"TestConnectionAsync: connected in {sw.ElapsedMilliseconds}ms");
+
+ info.IsConnected = true;
+
+ using var command = new SqlCommand(@"
+ SELECT
+ @@VERSION,
+ SERVERPROPERTY('Edition'),
+ @@SERVERNAME,
+ CONVERT(int, SERVERPROPERTY('EngineEdition')),
+ SERVERPROPERTY('ProductMajorVersion');", connection);
+ using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ info.SqlServerVersion = reader.GetString(0);
+ info.SqlServerEdition = reader.GetString(1);
+ info.ServerName = reader.GetString(2);
+ info.EngineEdition = reader.IsDBNull(3) ? 0 : reader.GetInt32(3);
+ info.ProductMajorVersion = reader.IsDBNull(4) ? 0 : int.TryParse(reader.GetValue(4).ToString(), out var v) ? v : 0;
+ }
+
+ LogDebug(progress, $"TestConnectionAsync: server={info.ServerName}, edition={info.SqlServerEdition}, " +
+ $"engineEdition={info.EngineEdition}, majorVersion={info.ProductMajorVersion}, " +
+ $"supported={info.IsSupportedVersion}, elapsed={sw.ElapsedMilliseconds}ms");
+ }
+ catch (Exception ex)
+ {
+ info.IsConnected = false;
+ info.ErrorMessage = ex.Message;
+ if (ex.InnerException != null)
+ {
+ info.ErrorMessage += $"\n{ex.InnerException.Message}";
+ }
+ LogDebug(progress, $"TestConnectionAsync: FAILED after {sw.ElapsedMilliseconds}ms — " +
+ $"{ex.GetType().Name}: {ex.Message}" +
+ (ex.InnerException != null ? $" → {ex.InnerException.GetType().Name}: {ex.InnerException.Message}" : ""));
+ }
+
+ return info;
+ }
+
+ ///
+ /// Perform clean install (drop existing database and jobs).
+ ///
+ public static async Task CleanInstallAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ LogDebug(progress, "CleanInstallAsync: starting — will drop database, jobs, XE sessions");
+ var sw = Stopwatch.StartNew();
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Performing clean install...",
+ Status = "Info"
+ });
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*Stop any existing traces before dropping database*/
+ try
+ {
+ using var traceCmd = new SqlCommand(
+ "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
+ connection);
+ traceCmd.CommandTimeout = ShortTimeoutSeconds;
+ await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Stopped existing traces",
+ Status = "Success"
+ });
+ }
+ catch (SqlException)
+ {
+ /*Database or procedure doesn't exist - no traces to clean*/
+ }
+
+ /*Remove Agent jobs, XE sessions, and database*/
+ string cleanupSql = @"
+USE msdb;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1;
+END;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1;
+END;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1;
+END;
+
+USE master;
+
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+ ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;
+END;
+
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+ ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;
+END;
+
+IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
+BEGIN
+ ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE PerformanceMonitor;
+END;";
+
+ using var command = new SqlCommand(cleanupSql, connection);
+ command.CommandTimeout = ShortTimeoutSeconds;
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Clean install completed (jobs, XE sessions, and database removed)",
+ Status = "Success"
+ });
+ }
+
+ ///
+ /// Perform complete uninstall (remove database, jobs, XE sessions, and traces).
+ ///
+ public static async Task ExecuteUninstallAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Uninstalling Performance Monitor...",
+ Status = "Info"
+ });
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*Stop existing traces before dropping database*/
+ try
+ {
+ using var traceCmd = new SqlCommand(
+ "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
+ connection);
+ traceCmd.CommandTimeout = ShortTimeoutSeconds;
+ await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Stopped server-side traces",
+ Status = "Success"
+ });
+ }
+ catch (SqlException)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "No traces to stop (database or procedure not found)",
+ Status = "Info"
+ });
+ }
+
+ /*Remove Agent jobs, XE sessions, and database*/
+ await CleanInstallAsync(connectionString, progress, cancellationToken)
+ .ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Uninstall completed successfully",
+ Status = "Success",
+ ProgressPercent = 100
+ });
+
+ return true;
+ }
+
+ ///
+ /// Execute SQL installation files from the given ScriptProvider.
+ ///
+ public static async Task ExecuteInstallationAsync(
+ string connectionString,
+ ScriptProvider provider,
+ bool cleanInstall,
+ bool resetSchedule = false,
+ IProgress? progress = null,
+ Func? preValidationAction = null,
+ CancellationToken cancellationToken = default)
+ {
+ var scriptFiles = provider.GetInstallFiles();
+ ArgumentNullException.ThrowIfNull(scriptFiles);
+
+ LogDebug(progress, $"ExecuteInstallationAsync: cleanInstall={cleanInstall}, resetSchedule={resetSchedule}, " +
+ $"scriptCount={scriptFiles.Count}, providerType={provider.GetType().Name}");
+ LogDebug(progress, $"ExecuteInstallationAsync: scripts=[{string.Join(", ", scriptFiles.Select(f => f.Name))}]");
+
+ var result = new InstallationResult
+ {
+ StartTime = DateTime.Now
+ };
+
+ /*Perform clean install if requested*/
+ if (cleanInstall)
+ {
+ try
+ {
+ await CleanInstallAsync(connectionString, progress, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"CLEAN INSTALL FAILED: {ex.Message}",
+ Status = "Error"
+ });
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Installation aborted - clean install was requested but failed.",
+ Status = "Error"
+ });
+ result.EndTime = DateTime.Now;
+ result.Success = false;
+ result.FilesFailed = 1;
+ result.Errors.Add(("Clean Install", ex.Message));
+ return result;
+ }
+ }
+
+ /*
+ Execute SQL files.
+ Files execute without transaction wrapping because many contain DDL.
+ If installation fails mid-way, use clean install to reset and retry.
+ */
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Starting installation...",
+ Status = "Info",
+ CurrentStep = 0,
+ TotalSteps = scriptFiles.Count,
+ ProgressPercent = 0
+ });
+
+ bool preValidationActionRan = false;
+
+ for (int i = 0; i < scriptFiles.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var scriptFile = scriptFiles[i];
+ string fileName = scriptFile.Name;
+
+ /*Install community dependencies before validation runs.
+ Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/
+ if (!preValidationActionRan &&
+ preValidationAction != null &&
+ fileName.StartsWith("98_", StringComparison.Ordinal))
+ {
+ preValidationActionRan = true;
+ await preValidationAction().ConfigureAwait(false);
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Executing {fileName}...",
+ Status = "Info",
+ CurrentStep = i + 1,
+ TotalSteps = scriptFiles.Count,
+ ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100)
+ });
+
+ try
+ {
+ var fileSw = Stopwatch.StartNew();
+ string sqlContent = await provider.ReadScriptAsync(scriptFile, cancellationToken).ConfigureAwait(false);
+ LogDebug(progress, $" {fileName}: read {sqlContent.Length} chars");
+
+ /*Reset schedule to defaults if requested*/
+ if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal))
+ {
+ sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent;
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Resetting schedule to recommended defaults...",
+ Status = "Info"
+ });
+ }
+
+ /*Remove SQLCMD directives*/
+ sqlContent = Patterns.SqlCmdDirectivePattern.Replace(sqlContent, "");
+
+ /*Execute the SQL batch*/
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*Split by GO statements*/
+ string[] batches = Patterns.GoBatchSplitter.Split(sqlContent);
+ int nonEmptyBatches = batches.Count(b => !string.IsNullOrWhiteSpace(b));
+ LogDebug(progress, $" {fileName}: {nonEmptyBatches} batches to execute");
+
+ int batchNumber = 0;
+ foreach (string batch in batches)
+ {
+ string trimmedBatch = batch.Trim();
+ if (string.IsNullOrWhiteSpace(trimmedBatch))
+ continue;
+
+ batchNumber++;
+
+ using var command = new SqlCommand(trimmedBatch, connection);
+ command.CommandTimeout = StandardTimeoutSeconds;
+
+ try
+ {
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (SqlException ex)
+ {
+ string batchPreview = trimmedBatch.Length > 500
+ ? trimmedBatch[..500] + $"... [truncated, total length: {trimmedBatch.Length}]"
+ : trimmedBatch;
+ throw new InvalidOperationException(
+ $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
+ }
+ }
+
+ LogDebug(progress, $" {fileName}: completed in {fileSw.ElapsedMilliseconds}ms ({batchNumber} batches)");
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{fileName} - Success",
+ Status = "Success",
+ CurrentStep = i + 1,
+ TotalSteps = scriptFiles.Count,
+ ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100)
+ });
+
+ result.FilesSucceeded++;
+ }
+ catch (Exception ex)
+ {
+ LogDebug(progress, $" {fileName}: FAILED — {ex.GetType().Name}: {ex.Message}");
+ if (ex.InnerException != null)
+ LogDebug(progress, $" {fileName}: InnerException — {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"{fileName} - FAILED: {ex.Message}",
+ Status = "Error",
+ CurrentStep = i + 1,
+ TotalSteps = scriptFiles.Count
+ });
+
+ result.FilesFailed++;
+ result.Errors.Add((fileName, ex.Message));
+
+ /*Critical files abort installation*/
+ if (Patterns.IsCriticalFile(fileName))
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Critical installation file failed. Aborting installation.",
+ Status = "Error"
+ });
+ break;
+ }
+ }
+ }
+
+ result.EndTime = DateTime.Now;
+ result.Success = result.FilesFailed == 0;
+
+ var totalDuration = result.EndTime - result.StartTime;
+ LogDebug(progress, $"ExecuteInstallationAsync: finished — success={result.Success}, " +
+ $"succeeded={result.FilesSucceeded}, failed={result.FilesFailed}, " +
+ $"duration={totalDuration.TotalSeconds:F1}s");
+
+ return result;
+ }
+
+ ///
+ /// Run validation (master collector) after installation.
+ ///
+ public static async Task<(int CollectorsSucceeded, int CollectorsFailed)> RunValidationAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Running initial collection to validate installation...",
+ Status = "Info"
+ });
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*Capture timestamp before running so we only check errors from this run.
+ Use SYSDATETIME() (local) because collection_time is stored in server local time.*/
+ DateTime validationStart;
+ using (var command = new SqlCommand("SELECT SYSDATETIME();", connection))
+ {
+ validationStart = (DateTime)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!;
+ }
+
+ /*Run master collector with @force_run_all*/
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Executing master collector...",
+ Status = "Info"
+ });
+
+ using (var command = new SqlCommand(
+ "EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;",
+ connection))
+ {
+ command.CommandTimeout = StandardTimeoutSeconds;
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Master collector completed",
+ Status = "Success"
+ });
+
+ /*Check results - only from this validation run, not historical errors*/
+ int successCount = 0;
+ int errorCount = 0;
+
+ using (var command = new SqlCommand(@"
+ SELECT
+ success_count = COUNT_BIG(DISTINCT CASE WHEN collection_status = 'SUCCESS' THEN collector_name END),
+ error_count = SUM(CASE WHEN collection_status = 'ERROR' THEN 1 ELSE 0 END)
+ FROM PerformanceMonitor.config.collection_log
+ WHERE collection_time >= @validation_start;", connection))
+ {
+ command.Parameters.AddWithValue("@validation_start", validationStart);
+ using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ successCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0);
+ errorCount = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
+ }
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Validation complete: {successCount} collectors succeeded, {errorCount} failed",
+ Status = errorCount == 0 ? "Success" : "Warning"
+ });
+
+ /*Show failed collectors if any*/
+ if (errorCount > 0)
+ {
+ using var command = new SqlCommand(@"
+ SELECT collector_name, error_message
+ FROM PerformanceMonitor.config.collection_log
+ WHERE collection_status = 'ERROR'
+ AND collection_time >= @validation_start
+ ORDER BY collection_time DESC;", connection);
+ command.Parameters.AddWithValue("@validation_start", validationStart);
+
+ using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ string name = reader["collector_name"]?.ToString() ?? "";
+ string error = reader["error_message"] == DBNull.Value
+ ? "(no error message)"
+ : reader["error_message"]?.ToString() ?? "";
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $" {name}: {error}",
+ Status = "Error"
+ });
+ }
+ }
+
+ return (successCount, errorCount);
+ }
+
+ ///
+ /// Run installation verification diagnostics using 99_installer_troubleshooting.sql.
+ ///
+ public static async Task RunTroubleshootingAsync(
+ string connectionString,
+ ScriptProvider provider,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ bool hasErrors = false;
+
+ try
+ {
+ string? scriptContent = provider.ReadTroubleshootingScript();
+
+ if (scriptContent == null)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Troubleshooting script not found: 99_installer_troubleshooting.sql",
+ Status = "Error"
+ });
+ return false;
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Running installation diagnostics...",
+ Status = "Info"
+ });
+
+ /*Remove SQLCMD directives*/
+ scriptContent = Patterns.SqlCmdDirectivePattern.Replace(scriptContent, string.Empty);
+
+ /*Split into batches*/
+ var batches = Patterns.GoBatchSplitter.Split(scriptContent)
+ .Where(b => !string.IsNullOrWhiteSpace(b))
+ .ToList();
+
+ /*Connect to master first (script will USE PerformanceMonitor)*/
+ using var connection = new SqlConnection(connectionString);
+
+ /*Capture PRINT messages and determine status*/
+ connection.InfoMessage += (sender, e) =>
+ {
+ string message = e.Message;
+
+ string status = "Info";
+ if (message.Contains("[OK]", StringComparison.OrdinalIgnoreCase))
+ status = "Success";
+ else if (message.Contains("[WARN]", StringComparison.OrdinalIgnoreCase))
+ {
+ status = "Warning";
+ }
+ else if (message.Contains("[ERROR]", StringComparison.OrdinalIgnoreCase))
+ {
+ status = "Error";
+ hasErrors = true;
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = message,
+ Status = status
+ });
+ };
+
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var batch in batches)
+ {
+ if (string.IsNullOrWhiteSpace(batch))
+ continue;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var cmd = new SqlCommand(batch, connection)
+ {
+ CommandTimeout = DependencyTimeoutSeconds
+ };
+
+ try
+ {
+ await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (SqlException ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"SQL Error: {ex.Message}",
+ Status = "Error"
+ });
+ hasErrors = true;
+ }
+
+ /*Small delay to allow UI to process messages*/
+ await Task.Delay(25, cancellationToken).ConfigureAwait(false);
+ }
+
+ return !hasErrors;
+ }
+ catch (Exception ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Diagnostics failed: {ex.Message}",
+ Status = "Error"
+ });
+ return false;
+ }
+ }
+
+ ///
+ /// Generate installation summary report file.
+ ///
+ /// Directory to write the report. Null defaults to user profile.
+ public static string GenerateSummaryReport(
+ string serverName,
+ string sqlServerVersion,
+ string sqlServerEdition,
+ string installerVersion,
+ InstallationResult result,
+ string? outputDirectory = null)
+ {
+ ArgumentNullException.ThrowIfNull(serverName);
+ ArgumentNullException.ThrowIfNull(result);
+
+ var duration = result.EndTime - result.StartTime;
+
+ string timestamp = result.StartTime.ToString("yyyyMMdd_HHmmss");
+ string fileName = $"PerformanceMonitor_Install_{serverName.Replace("\\", "_", StringComparison.Ordinal)}_{timestamp}.txt";
+ string reportDir = outputDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ string reportPath = Path.Combine(reportDir, fileName);
+
+ var sb = new StringBuilder();
+
+ sb.AppendLine("================================================================================");
+ sb.AppendLine("Performance Monitor Installation Report");
+ sb.AppendLine("================================================================================");
+ sb.AppendLine();
+
+ sb.AppendLine("INSTALLATION SUMMARY");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine($"Status: {(result.Success ? "SUCCESS" : "FAILED")}");
+ sb.AppendLine($"Start Time: {result.StartTime:yyyy-MM-dd HH:mm:ss}");
+ sb.AppendLine($"End Time: {result.EndTime:yyyy-MM-dd HH:mm:ss}");
+ sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds");
+ sb.AppendLine($"Files Executed: {result.FilesSucceeded}");
+ sb.AppendLine($"Files Failed: {result.FilesFailed}");
+ sb.AppendLine();
+
+ sb.AppendLine("SERVER INFORMATION");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine($"Server Name: {serverName}");
+ sb.AppendLine($"SQL Server Edition: {sqlServerEdition}");
+ sb.AppendLine();
+
+ if (!string.IsNullOrEmpty(sqlServerVersion))
+ {
+ string[] versionLines = sqlServerVersion.Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries);
+ if (versionLines.Length > 0)
+ {
+ sb.AppendLine("SQL Server Version:");
+ foreach (var line in versionLines)
+ {
+ sb.AppendLine($" {line.Trim()}");
+ }
+ }
+ }
+ sb.AppendLine();
+
+ sb.AppendLine("INSTALLER INFORMATION");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine($"Installer Version: {installerVersion}");
+ sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}");
+ sb.AppendLine($"Machine Name: {Environment.MachineName}");
+ sb.AppendLine($"User Name: {Environment.UserName}");
+ sb.AppendLine();
+
+ if (result.Errors.Count > 0)
+ {
+ sb.AppendLine("ERRORS");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ foreach (var (file, error) in result.Errors)
+ {
+ sb.AppendLine($"File: {file}");
+ string errorMsg = error.Length > 500 ? error[..500] + "..." : error;
+ sb.AppendLine($"Error: {errorMsg}");
+ sb.AppendLine();
+ }
+ }
+
+ if (result.LogMessages.Count > 0)
+ {
+ sb.AppendLine("DETAILED INSTALLATION LOG");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ foreach (var (message, status) in result.LogMessages)
+ {
+ string prefix = status switch
+ {
+ "Success" => "[OK] ",
+ "Error" => "[ERROR] ",
+ "Warning" => "[WARN] ",
+ _ => ""
+ };
+ sb.AppendLine($"{prefix}{message}");
+ }
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("================================================================================");
+ sb.AppendLine("Generated by Performance Monitor Installer");
+ sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC");
+ sb.AppendLine("================================================================================");
+
+ File.WriteAllText(reportPath, sb.ToString());
+
+ return reportPath;
+ }
+
+ ///
+ /// Get the currently installed version from the database.
+ /// Returns null if database doesn't exist or no successful installation found.
+ ///
+ public static async Task GetInstalledVersionAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ LogDebug(progress, "GetInstalledVersionAsync: checking for existing installation");
+ try
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*Check if PerformanceMonitor database exists*/
+ using var dbCheckCmd = new SqlCommand(@"
+ SELECT database_id
+ FROM sys.databases
+ WHERE name = N'PerformanceMonitor';", connection);
+
+ var dbExists = await dbCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ if (dbExists == null || dbExists == DBNull.Value)
+ {
+ LogDebug(progress, "GetInstalledVersionAsync: database does not exist → clean install");
+ return null;
+ }
+ LogDebug(progress, "GetInstalledVersionAsync: database exists, checking installation_history table");
+
+ /*Check if installation_history table exists*/
+ using var tableCheckCmd = new SqlCommand(@"
+ USE PerformanceMonitor;
+ SELECT OBJECT_ID(N'config.installation_history', N'U');", connection);
+
+ var tableExists = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ if (tableExists == null || tableExists == DBNull.Value)
+ {
+ LogDebug(progress, "GetInstalledVersionAsync: installation_history table does not exist → old or corrupted install");
+ return null;
+ }
+
+ /*Get most recent successful installation version*/
+ using var versionCmd = new SqlCommand(@"
+ SELECT TOP 1 installer_version
+ FROM PerformanceMonitor.config.installation_history
+ WHERE installation_status = 'SUCCESS'
+ ORDER BY installation_date DESC;", connection);
+
+ var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ if (version != null && version != DBNull.Value)
+ {
+ LogDebug(progress, $"GetInstalledVersionAsync: found installed version {version}");
+ return version.ToString();
+ }
+
+ /*
+ Fallback: database and history table exist but no SUCCESS rows.
+ This can happen if a prior install didn't write history (#538/#539).
+ Return "1.0.0" so all idempotent upgrade scripts are attempted
+ rather than treating this as a fresh install (which would drop the database).
+ */
+ LogDebug(progress, "GetInstalledVersionAsync: no SUCCESS rows — fallback to 1.0.0 (#538 guard)");
+ return "1.0.0";
+ }
+ catch (SqlException ex)
+ {
+ LogDebug(progress, $"GetInstalledVersionAsync: SqlException — {ex.Number}: {ex.Message}");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ LogDebug(progress, $"GetInstalledVersionAsync: {ex.GetType().Name} — {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Execute an upgrade's SQL scripts using the ScriptProvider.
+ /// Returns (successCount, failureCount).
+ ///
+ public static async Task<(int successCount, int failureCount)> ExecuteUpgradeAsync(
+ ScriptProvider provider,
+ UpgradeInfo upgrade,
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ int successCount = 0;
+ int failureCount = 0;
+
+ LogDebug(progress, $"ExecuteUpgradeAsync: {upgrade.FolderName} ({upgrade.FromVersion} → {upgrade.ToVersion})");
+ var upgradeSw = Stopwatch.StartNew();
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Applying upgrade: {upgrade.FolderName}",
+ Status = "Info"
+ });
+
+ var sqlFileNames = provider.GetUpgradeManifest(upgrade);
+ LogDebug(progress, $"ExecuteUpgradeAsync: manifest has {sqlFileNames.Count} scripts: [{string.Join(", ", sqlFileNames)}]");
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var fileName in sqlFileNames)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!provider.UpgradeScriptExists(upgrade, fileName))
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $" {fileName} - WARNING: File not found",
+ Status = "Warning"
+ });
+ failureCount++;
+ continue;
+ }
+
+ try
+ {
+ string sql = await provider.ReadUpgradeScriptAsync(upgrade, fileName, cancellationToken).ConfigureAwait(false);
+
+ /*Remove SQLCMD directives*/
+ sql = Patterns.SqlCmdDirectivePattern.Replace(sql, "");
+
+ /*Split by GO statements*/
+ string[] batches = Patterns.GoBatchSplitter.Split(sql);
+
+ int batchNumber = 0;
+ foreach (var batch in batches)
+ {
+ batchNumber++;
+ string trimmedBatch = batch.Trim();
+
+ if (string.IsNullOrWhiteSpace(trimmedBatch))
+ continue;
+
+ using var cmd = new SqlCommand(trimmedBatch, connection);
+ cmd.CommandTimeout = UpgradeTimeoutSeconds;
+
+ try
+ {
+ await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (SqlException ex)
+ {
+ string batchPreview = trimmedBatch.Length > 500
+ ? trimmedBatch[..500] + $"... [truncated, total length: {trimmedBatch.Length}]"
+ : trimmedBatch;
+ throw new InvalidOperationException(
+ $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
+ }
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $" {fileName} - Success",
+ Status = "Success"
+ });
+ successCount++;
+ }
+ catch (Exception ex)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = $" {fileName} - FAILED: {ex.Message}",
+ Status = "Error"
+ });
+ failureCount++;
+ }
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Upgrade {upgrade.FolderName}: {successCount} succeeded, {failureCount} failed",
+ Status = failureCount == 0 ? "Success" : "Warning"
+ });
+
+ return (successCount, failureCount);
+ }
+
+ ///
+ /// Execute all applicable upgrades in order using the ScriptProvider.
+ ///
+ public static async Task<(int totalSuccessCount, int totalFailureCount, int upgradeCount)> ExecuteAllUpgradesAsync(
+ ScriptProvider provider,
+ string connectionString,
+ string? currentVersion,
+ string targetVersion,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ int totalSuccessCount = 0;
+ int totalFailureCount = 0;
+
+ var upgrades = provider.GetApplicableUpgrades(currentVersion, targetVersion,
+ warning => progress?.Report(new InstallationProgress { Message = warning, Status = "Warning" }));
+
+ if (upgrades.Count == 0)
+ {
+ return (0, 0, 0);
+ }
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = $"Found {upgrades.Count} upgrade(s) to apply",
+ Status = "Info"
+ });
+
+ foreach (var upgrade in upgrades)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var (success, failure) = await ExecuteUpgradeAsync(
+ provider,
+ upgrade,
+ connectionString,
+ progress,
+ cancellationToken).ConfigureAwait(false);
+
+ totalSuccessCount += success;
+ totalFailureCount += failure;
+ }
+
+ return (totalSuccessCount, totalFailureCount, upgrades.Count);
+ }
+
+ ///
+ /// Log installation history to config.installation_history.
+ ///
+ public static async Task LogInstallationHistoryAsync(
+ string connectionString,
+ string assemblyVersion,
+ string infoVersion,
+ DateTime startTime,
+ int filesExecuted,
+ int filesFailed,
+ bool isSuccess,
+ IProgress? progress = null)
+ {
+ LogDebug(progress, $"LogInstallationHistoryAsync: version={assemblyVersion}, filesExecuted={filesExecuted}, " +
+ $"filesFailed={filesFailed}, isSuccess={isSuccess}");
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync().ConfigureAwait(false);
+
+ /*Check if this is an upgrade by checking for existing installation*/
+ string? previousVersion = null;
+ string installationType = "INSTALL";
+
+ try
+ {
+ using var checkCmd = new SqlCommand(@"
+ SELECT TOP 1 installer_version
+ FROM PerformanceMonitor.config.installation_history
+ WHERE installation_status = 'SUCCESS'
+ ORDER BY installation_date DESC;", connection);
+
+ var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false);
+ if (result != null && result != DBNull.Value)
+ {
+ previousVersion = result.ToString();
+ bool isSameVersion = Version.TryParse(previousVersion, out var prevVer)
+ && Version.TryParse(assemblyVersion, out var currVer)
+ && prevVer == currVer;
+ installationType = isSameVersion ? "REINSTALL" : "UPGRADE";
+ }
+ }
+ catch (SqlException)
+ {
+ /*Table might not exist yet on first install*/
+ }
+
+ /*Get SQL Server version info*/
+ string sqlVersion = "";
+ string sqlEdition = "";
+
+ using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection))
+ using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false))
+ {
+ if (await reader.ReadAsync().ConfigureAwait(false))
+ {
+ sqlVersion = reader.GetString(0);
+ sqlEdition = reader.GetString(1);
+ }
+ }
+
+ long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds;
+ string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED");
+
+ var insertSql = @"
+ INSERT INTO PerformanceMonitor.config.installation_history
+ (
+ installer_version,
+ installer_info_version,
+ sql_server_version,
+ sql_server_edition,
+ installation_type,
+ previous_version,
+ installation_status,
+ files_executed,
+ files_failed,
+ installation_duration_ms
+ )
+ VALUES
+ (
+ @installer_version,
+ @installer_info_version,
+ @sql_server_version,
+ @sql_server_edition,
+ @installation_type,
+ @previous_version,
+ @installation_status,
+ @files_executed,
+ @files_failed,
+ @installation_duration_ms
+ );";
+
+ using var insertCmd = new SqlCommand(insertSql, connection);
+ insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion });
+ insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value });
+ insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion });
+ insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType });
+ insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status });
+ insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted });
+ insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs });
+
+ await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ LogDebug(progress, $"LogInstallationHistoryAsync: wrote {installationType} record (status={status}, previousVersion={previousVersion ?? "null"})");
+ }
+}
diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj
new file mode 100644
index 0000000..d203bfe
--- /dev/null
+++ b/Installer.Core/Installer.Core.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Installer.Core
+ Installer.Core
+ SQL Server Performance Monitor Installer Core
+ 2.4.1
+ 2.4.1.0
+ 2.4.1.0
+ 2.4.1
+ Darling Data, LLC
+ Copyright (c) 2026 Darling Data, LLC
+ true
+ latest-recommended
+ CA1305;CA1845;CA1861;CA2100
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Installer.Core/Models/InstallationProgress.cs b/Installer.Core/Models/InstallationProgress.cs
new file mode 100644
index 0000000..df97902
--- /dev/null
+++ b/Installer.Core/Models/InstallationProgress.cs
@@ -0,0 +1,13 @@
+namespace Installer.Core.Models;
+
+///
+/// Progress information for installation steps.
+///
+public class InstallationProgress
+{
+ public string Message { get; set; } = string.Empty;
+ public string Status { get; set; } = "Info"; // Info, Success, Error, Warning
+ public int? CurrentStep { get; set; }
+ public int? TotalSteps { get; set; }
+ public int? ProgressPercent { get; set; }
+}
diff --git a/Installer.Core/Models/InstallationResult.cs b/Installer.Core/Models/InstallationResult.cs
new file mode 100644
index 0000000..ee154e6
--- /dev/null
+++ b/Installer.Core/Models/InstallationResult.cs
@@ -0,0 +1,16 @@
+namespace Installer.Core.Models;
+
+///
+/// Installation result summary.
+///
+public class InstallationResult
+{
+ public bool Success { get; set; }
+ public int FilesSucceeded { get; set; }
+ public int FilesFailed { get; set; }
+ public List<(string FileName, string ErrorMessage)> Errors { get; } = new();
+ public List<(string Message, string Status)> LogMessages { get; } = new();
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public string? ReportPath { get; set; }
+}
diff --git a/Installer.Core/Models/InstallationResultCode.cs b/Installer.Core/Models/InstallationResultCode.cs
new file mode 100644
index 0000000..ad1b982
--- /dev/null
+++ b/Installer.Core/Models/InstallationResultCode.cs
@@ -0,0 +1,18 @@
+namespace Installer.Core.Models;
+
+///
+/// Result codes for installation operations.
+/// Maps to CLI exit codes for backward compatibility.
+///
+public enum InstallationResultCode
+{
+ Success = 0,
+ InvalidArguments = 1,
+ ConnectionFailed = 2,
+ CriticalScriptFailed = 3,
+ PartialInstallation = 4,
+ VersionCheckFailed = 5,
+ SqlFilesNotFound = 6,
+ UninstallFailed = 7,
+ UpgradesFailed = 8
+}
diff --git a/Installer.Core/Models/ServerInfo.cs b/Installer.Core/Models/ServerInfo.cs
new file mode 100644
index 0000000..740a31c
--- /dev/null
+++ b/Installer.Core/Models/ServerInfo.cs
@@ -0,0 +1,38 @@
+namespace Installer.Core.Models;
+
+///
+/// Server information returned from connection test.
+///
+public class ServerInfo
+{
+ public string ServerName { get; set; } = string.Empty;
+ public string SqlServerVersion { get; set; } = string.Empty;
+ public string SqlServerEdition { get; set; } = string.Empty;
+ public bool IsConnected { get; set; }
+ public string? ErrorMessage { get; set; }
+ public int EngineEdition { get; set; }
+ public int ProductMajorVersion { get; set; }
+
+ ///
+ /// Returns true if the SQL Server version is supported (2016+).
+ /// Only checked for on-prem Standard (2) and Enterprise (3).
+ /// Azure MI (8) is always current and skips the check.
+ ///
+ public bool IsSupportedVersion =>
+ EngineEdition is 8 || ProductMajorVersion >= 13;
+
+ ///
+ /// Human-readable version name for error messages.
+ ///
+ public string ProductMajorVersionName => ProductMajorVersion switch
+ {
+ 11 => "SQL Server 2012",
+ 12 => "SQL Server 2014",
+ 13 => "SQL Server 2016",
+ 14 => "SQL Server 2017",
+ 15 => "SQL Server 2019",
+ 16 => "SQL Server 2022",
+ 17 => "SQL Server 2025",
+ _ => $"SQL Server (version {ProductMajorVersion})"
+ };
+}
diff --git a/Installer.Core/Models/UpgradeInfo.cs b/Installer.Core/Models/UpgradeInfo.cs
new file mode 100644
index 0000000..ff35ae3
--- /dev/null
+++ b/Installer.Core/Models/UpgradeInfo.cs
@@ -0,0 +1,12 @@
+namespace Installer.Core.Models;
+
+///
+/// Information about an applicable upgrade.
+///
+public class UpgradeInfo
+{
+ public string Path { get; set; } = string.Empty;
+ public string FolderName { get; set; } = string.Empty;
+ public Version? FromVersion { get; set; }
+ public Version? ToVersion { get; set; }
+}
diff --git a/Installer.Core/Patterns.cs b/Installer.Core/Patterns.cs
new file mode 100644
index 0000000..4e5c7fe
--- /dev/null
+++ b/Installer.Core/Patterns.cs
@@ -0,0 +1,60 @@
+using System.Text.RegularExpressions;
+
+namespace Installer.Core;
+
+///
+/// Shared compiled regex patterns for SQL file processing.
+///
+public static partial class Patterns
+{
+ ///
+ /// Matches numbered SQL installation files (e.g., "01_install_database.sql", "41a_extra.sql").
+ ///
+ [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")]
+ public static partial Regex SqlFilePattern();
+
+ ///
+ /// Matches SQLCMD :r include directives for removal before execution.
+ ///
+ public static readonly Regex SqlCmdDirectivePattern = new(
+ @"^:r\s+.*$",
+ RegexOptions.Compiled | RegexOptions.Multiline);
+
+ ///
+ /// Splits SQL content on GO batch separators (case-insensitive, with optional trailing comments).
+ ///
+ public static readonly Regex GoBatchSplitter = new(
+ @"^\s*GO\s*(?:--[^\r\n]*)?\s*$",
+ RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
+
+ ///
+ /// Prefixes that indicate excluded scripts (uninstall, test, troubleshooting).
+ ///
+ public static readonly string[] ExcludedPrefixes = ["00_", "97_", "99_"];
+
+ ///
+ /// Prefixes that indicate critical installation scripts (abort on failure).
+ ///
+ public static readonly string[] CriticalPrefixes = ["01_", "02_", "03_"];
+
+ ///
+ /// Filters and sorts SQL installation files using the standard rules:
+ /// include files matching SqlFilePattern, exclude 00_/97_/99_ prefixes, sort alphabetically.
+ ///
+ public static List FilterInstallFiles(IEnumerable fileNames)
+ {
+ return fileNames
+ .Where(f => SqlFilePattern().IsMatch(f))
+ .Where(f => !ExcludedPrefixes.Any(p => f.StartsWith(p, StringComparison.Ordinal)))
+ .OrderBy(f => f, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ ///
+ /// Returns true if the given file name represents a critical installation script.
+ ///
+ public static bool IsCriticalFile(string fileName)
+ {
+ return CriticalPrefixes.Any(p => fileName.StartsWith(p, StringComparison.Ordinal));
+ }
+}
diff --git a/Installer.Core/ScriptProvider.cs b/Installer.Core/ScriptProvider.cs
new file mode 100644
index 0000000..d07219d
--- /dev/null
+++ b/Installer.Core/ScriptProvider.cs
@@ -0,0 +1,405 @@
+using System.Reflection;
+using System.Text;
+using Installer.Core.Models;
+
+namespace Installer.Core;
+
+///
+/// Identifies an SQL installation script.
+///
+public record ScriptFile(string Name, string Identifier);
+
+///
+/// Abstracts the source of SQL installation and upgrade scripts.
+/// FileSystem mode reads from install/ and upgrades/ directories (CLI, GUI).
+/// Embedded mode reads from assembly resources (Dashboard).
+///
+public abstract class ScriptProvider
+{
+ ///
+ /// Create a provider that reads scripts from the filesystem.
+ ///
+ public static ScriptProvider FromDirectory(string monitorRootDirectory)
+ => new FileSystemScriptProvider(monitorRootDirectory);
+
+ ///
+ /// Create a provider that reads scripts from embedded assembly resources.
+ ///
+ public static ScriptProvider FromEmbeddedResources(Assembly? assembly = null)
+ => new EmbeddedResourceScriptProvider(assembly ?? typeof(ScriptProvider).Assembly);
+
+ ///
+ /// Auto-discover: search filesystem starting from CWD and executable directory,
+ /// walking up to 5 parent directories. Falls back to embedded resources.
+ ///
+ /// Optional logging callback for diagnostics.
+ public static ScriptProvider AutoDiscover(Action? log = null)
+ {
+ var startDirs = new[] { Directory.GetCurrentDirectory(), AppDomain.CurrentDomain.BaseDirectory }
+ .Distinct()
+ .ToList();
+
+ log?.Invoke($"AutoDiscover: searching from [{string.Join(", ", startDirs)}]");
+
+ foreach (string startDir in startDirs)
+ {
+ DirectoryInfo? searchDir = new DirectoryInfo(startDir);
+ for (int i = 0; i < 6 && searchDir != null; i++)
+ {
+ string installFolder = Path.Combine(searchDir.FullName, "install");
+ if (Directory.Exists(installFolder))
+ {
+ var sqlFiles = Directory.GetFiles(installFolder, "*.sql")
+ .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f)))
+ .ToList();
+ if (sqlFiles.Count > 0)
+ {
+ log?.Invoke($"AutoDiscover: found {sqlFiles.Count} scripts in {installFolder}");
+ return new FileSystemScriptProvider(searchDir.FullName);
+ }
+ }
+
+ var rootFiles = Directory.GetFiles(searchDir.FullName, "*.sql")
+ .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f)))
+ .ToList();
+ if (rootFiles.Count > 0)
+ {
+ log?.Invoke($"AutoDiscover: found {rootFiles.Count} scripts in {searchDir.FullName}");
+ return new FileSystemScriptProvider(searchDir.FullName);
+ }
+
+ log?.Invoke($"AutoDiscover: no scripts in {searchDir.FullName}, trying parent");
+ searchDir = searchDir.Parent;
+ }
+ }
+
+ log?.Invoke("AutoDiscover: no filesystem scripts found, falling back to embedded resources");
+ return FromEmbeddedResources();
+ }
+
+ ///
+ /// Returns the filtered, sorted list of install scripts (excludes 00_/97_/99_).
+ ///
+ public abstract List GetInstallFiles();
+
+ ///
+ /// Reads the content of an install script.
+ ///
+ public abstract string ReadScript(ScriptFile file);
+
+ ///
+ /// Reads the content of an install script asynchronously.
+ ///
+ public abstract Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default);
+
+ ///
+ /// Finds applicable upgrades from currentVersion to targetVersion.
+ /// Returns empty list if currentVersion is null (clean install) or no upgrades apply.
+ ///
+ /// Currently installed version, or null for clean install.
+ /// Target version to upgrade to.
+ /// Optional callback for warnings (e.g., missing upgrade.txt).
+ public abstract List GetApplicableUpgrades(
+ string? currentVersion,
+ string targetVersion,
+ Action? onWarning = null);
+
+ ///
+ /// Reads the upgrade manifest (upgrade.txt) for a given upgrade.
+ /// Returns script names in execution order, skipping comments and blank lines.
+ ///
+ public abstract List GetUpgradeManifest(UpgradeInfo upgrade);
+
+ ///
+ /// Reads an upgrade script's content.
+ ///
+ public abstract string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName);
+
+ ///
+ /// Reads an upgrade script's content asynchronously.
+ ///
+ public abstract Task ReadUpgradeScriptAsync(
+ UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns true if the given upgrade script file exists.
+ ///
+ public abstract bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName);
+
+ ///
+ /// Returns the content of the troubleshooting script (99_installer_troubleshooting.sql), or null if not found.
+ ///
+ public abstract string? ReadTroubleshootingScript();
+
+ ///
+ /// Core upgrade-discovery logic shared by both providers.
+ ///
+ protected static List FilterUpgrades(
+ IEnumerable candidates,
+ string? currentVersion,
+ string targetVersion)
+ {
+ if (currentVersion == null)
+ return [];
+
+ if (!Version.TryParse(currentVersion, out var currentRaw))
+ return [];
+ var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build);
+
+ if (!Version.TryParse(targetVersion, out var targetRaw))
+ return [];
+ var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build);
+
+ return candidates
+ .Where(x => x.FromVersion != null && x.ToVersion != null)
+ .Where(x => x.FromVersion >= current)
+ .Where(x => x.ToVersion <= target)
+ .OrderBy(x => x.FromVersion)
+ .ToList();
+ }
+
+ ///
+ /// Parses an upgrade folder name like "1.2.0-to-1.3.0" into an UpgradeInfo.
+ /// Returns null if the name doesn't match the expected pattern.
+ ///
+ protected static UpgradeInfo? ParseUpgradeFolderName(string folderName, string path)
+ {
+ if (!folderName.Contains("-to-", StringComparison.Ordinal))
+ return null;
+
+ var parts = folderName.Split("-to-");
+ var from = Version.TryParse(parts[0], out var f) ? f : null;
+ var to = parts.Length > 1 && Version.TryParse(parts[1], out var t) ? t : null;
+
+ if (from == null || to == null)
+ return null;
+
+ return new UpgradeInfo
+ {
+ Path = path,
+ FolderName = folderName,
+ FromVersion = from,
+ ToVersion = to
+ };
+ }
+
+ ///
+ /// Parses upgrade.txt content into a list of script names.
+ ///
+ protected static List ParseUpgradeManifest(IEnumerable lines)
+ {
+ return lines
+ .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
+ .Select(line => line.Trim())
+ .ToList();
+ }
+}
+
+///
+/// Reads scripts from the filesystem (install/ and upgrades/ directories).
+///
+internal sealed class FileSystemScriptProvider : ScriptProvider
+{
+ private readonly string _rootDirectory;
+ private readonly string _sqlDirectory;
+
+ public FileSystemScriptProvider(string monitorRootDirectory)
+ {
+ _rootDirectory = monitorRootDirectory;
+
+ string installFolder = Path.Combine(monitorRootDirectory, "install");
+ _sqlDirectory = Directory.Exists(installFolder) ? installFolder : monitorRootDirectory;
+ }
+
+ public override List GetInstallFiles()
+ {
+ if (!Directory.Exists(_sqlDirectory))
+ return [];
+
+ return Directory.GetFiles(_sqlDirectory, "*.sql")
+ .Select(f => new ScriptFile(Path.GetFileName(f), f))
+ .Where(f => Patterns.SqlFilePattern().IsMatch(f.Name))
+ .Where(f => !Patterns.ExcludedPrefixes.Any(p => f.Name.StartsWith(p, StringComparison.Ordinal)))
+ .OrderBy(f => f.Name, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ public override string ReadScript(ScriptFile file) =>
+ File.ReadAllText(file.Identifier);
+
+ public override Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) =>
+ File.ReadAllTextAsync(file.Identifier, cancellationToken);
+
+ public override List GetApplicableUpgrades(
+ string? currentVersion,
+ string targetVersion,
+ Action? onWarning = null)
+ {
+ string upgradesDir = Path.Combine(_rootDirectory, "upgrades");
+ if (!Directory.Exists(upgradesDir))
+ return [];
+
+ var allFolders = Directory.GetDirectories(upgradesDir)
+ .Select(d => ParseUpgradeFolderName(Path.GetFileName(d), d))
+ .Where(x => x != null)
+ .Cast()
+ .ToList();
+
+ var filtered = FilterUpgrades(allFolders, currentVersion, targetVersion);
+
+ var result = new List();
+ foreach (var upgrade in filtered)
+ {
+ string manifestPath = Path.Combine(upgrade.Path, "upgrade.txt");
+ if (File.Exists(manifestPath))
+ {
+ result.Add(upgrade);
+ }
+ else
+ {
+ onWarning?.Invoke($"Upgrade folder '{upgrade.FolderName}' has no upgrade.txt — skipped");
+ }
+ }
+ return result;
+ }
+
+ public override List GetUpgradeManifest(UpgradeInfo upgrade)
+ {
+ string manifestPath = Path.Combine(upgrade.Path, "upgrade.txt");
+ return ParseUpgradeManifest(File.ReadAllLines(manifestPath));
+ }
+
+ public override string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName) =>
+ File.ReadAllText(Path.Combine(upgrade.Path, scriptName));
+
+ public override Task ReadUpgradeScriptAsync(
+ UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default) =>
+ File.ReadAllTextAsync(Path.Combine(upgrade.Path, scriptName), cancellationToken);
+
+ public override bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName) =>
+ File.Exists(Path.Combine(upgrade.Path, scriptName));
+
+ public override string? ReadTroubleshootingScript()
+ {
+ string path = Path.Combine(_sqlDirectory, "99_installer_troubleshooting.sql");
+ return File.Exists(path) ? File.ReadAllText(path) : null;
+ }
+}
+
+///
+/// Reads scripts from embedded assembly resources.
+/// Resource names follow: {AssemblyName}.Resources.install.{filename}
+/// and {AssemblyName}.Resources.upgrades.{from}-to-{to}.{filename}
+///
+internal sealed class EmbeddedResourceScriptProvider : ScriptProvider
+{
+ private readonly Assembly _assembly;
+ private readonly string _resourcePrefix;
+
+ public EmbeddedResourceScriptProvider(Assembly assembly)
+ {
+ _assembly = assembly;
+ _resourcePrefix = assembly.GetName().Name ?? "Installer.Core";
+ }
+
+ public override List GetInstallFiles()
+ {
+ string installPrefix = $"{_resourcePrefix}.Resources.install.";
+
+ return _assembly.GetManifestResourceNames()
+ .Where(r => r.StartsWith(installPrefix, StringComparison.Ordinal))
+ .Select(r => new ScriptFile(
+ Name: r[installPrefix.Length..],
+ Identifier: r))
+ .Where(f => Patterns.SqlFilePattern().IsMatch(f.Name))
+ .Where(f => !Patterns.ExcludedPrefixes.Any(p => f.Name.StartsWith(p, StringComparison.Ordinal)))
+ .OrderBy(f => f.Name, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ public override string ReadScript(ScriptFile file) =>
+ ReadResource(file.Identifier);
+
+ public override Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) =>
+ Task.FromResult(ReadResource(file.Identifier));
+
+ public override List GetApplicableUpgrades(
+ string? currentVersion,
+ string targetVersion,
+ Action? onWarning = null)
+ {
+ string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades.";
+
+ var folderNames = _assembly.GetManifestResourceNames()
+ .Where(r => r.StartsWith(upgradesPrefix, StringComparison.Ordinal))
+ .Select(r => r[upgradesPrefix.Length..])
+ .Select(r => r.Split('.')[0])
+ .Distinct()
+ .ToList();
+
+ var allUpgrades = folderNames
+ .Select(f => ParseUpgradeFolderName(f, f))
+ .Where(x => x != null)
+ .Cast()
+ .ToList();
+
+ var filtered = FilterUpgrades(allUpgrades, currentVersion, targetVersion);
+
+ var result = new List();
+ foreach (var upgrade in filtered)
+ {
+ string manifestResource = $"{upgradesPrefix}{upgrade.FolderName}.upgrade.txt";
+ if (_assembly.GetManifestResourceNames().Contains(manifestResource))
+ {
+ result.Add(upgrade);
+ }
+ else
+ {
+ onWarning?.Invoke($"Upgrade folder '{upgrade.FolderName}' has no upgrade.txt — skipped");
+ }
+ }
+ return result;
+ }
+
+ public override List GetUpgradeManifest(UpgradeInfo upgrade)
+ {
+ string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades.";
+ string manifestResource = $"{upgradesPrefix}{upgrade.FolderName}.upgrade.txt";
+ string content = ReadResource(manifestResource);
+ return ParseUpgradeManifest(content.Split('\n'));
+ }
+
+ public override string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName)
+ {
+ string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades.";
+ string resource = $"{upgradesPrefix}{upgrade.FolderName}.{scriptName}";
+ return ReadResource(resource);
+ }
+
+ public override Task ReadUpgradeScriptAsync(
+ UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default) =>
+ Task.FromResult(ReadUpgradeScript(upgrade, scriptName));
+
+ public override bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName)
+ {
+ string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades.";
+ string resource = $"{upgradesPrefix}{upgrade.FolderName}.{scriptName}";
+ return _assembly.GetManifestResourceNames().Contains(resource);
+ }
+
+ public override string? ReadTroubleshootingScript()
+ {
+ string resource = $"{_resourcePrefix}.Resources.install.99_installer_troubleshooting.sql";
+ return _assembly.GetManifestResourceNames().Contains(resource)
+ ? ReadResource(resource)
+ : null;
+ }
+
+ private string ReadResource(string resourceName)
+ {
+ using var stream = _assembly.GetManifestResourceStream(resourceName)
+ ?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+}
diff --git a/Installer.Tests/AdversarialTests.cs b/Installer.Tests/AdversarialTests.cs
index 2908b6b..9565c46 100644
--- a/Installer.Tests/AdversarialTests.cs
+++ b/Installer.Tests/AdversarialTests.cs
@@ -1,6 +1,7 @@
+using Installer.Core;
+using Installer.Core.Models;
using Installer.Tests.Helpers;
using Microsoft.Data.SqlClient;
-using PerformanceMonitorInstallerGui.Services;
namespace Installer.Tests;
@@ -55,7 +56,7 @@ public async Task UpgradeFailure_DoesNotDropDatabase()
// Run upgrades — should fail
var (_, failureCount, _) = await InstallationService.ExecuteAllUpgradesAsync(
- dir.RootPath,
+ ScriptProvider.FromDirectory(dir.RootPath),
TestDatabaseHelper.GetTestDbConnectionString(),
"2.0.0",
"2.1.0",
@@ -188,10 +189,10 @@ public async Task CriticalFileFailure_AbortsInstallation()
File.WriteAllText(Path.Combine(dir.InstallPath, "05_procs.sql"),
"CREATE TABLE dbo.definitely_should_not_exist (id int);");
- var files = dir.GetFilteredInstallFiles();
+ var provider = ScriptProvider.FromDirectory(dir.RootPath);
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
- files,
+ provider,
cleanInstall: false,
cancellationToken: TestContext.Current.CancellationToken
);
@@ -234,7 +235,7 @@ public async Task CancellationMidUpgrade_VersionUnchanged()
try
{
await InstallationService.ExecuteAllUpgradesAsync(
- dir.RootPath,
+ ScriptProvider.FromDirectory(dir.RootPath),
TestDatabaseHelper.GetTestDbConnectionString(),
"2.0.0",
"2.1.0",
@@ -278,10 +279,10 @@ public async Task NonCriticalFileFailure_ContinuesInstallation()
File.WriteAllText(Path.Combine(dir.InstallPath, "05_should_still_run.sql"),
"CREATE TABLE dbo.proof_it_continued (id int);");
- var files = dir.GetFilteredInstallFiles();
+ var provider = ScriptProvider.FromDirectory(dir.RootPath);
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
- files,
+ provider,
cleanInstall: false,
cancellationToken: TestContext.Current.CancellationToken
);
@@ -315,10 +316,10 @@ public async Task CorruptSqlContent_FailsGracefully()
File.WriteAllText(Path.Combine(dir.InstallPath, "04_corrupt.sql"),
"THIS IS NOT SQL AT ALL 🔥 §±∞ DROP TABLE BOBBY;; EXEC(((");
- var files = dir.GetFilteredInstallFiles();
+ var provider = ScriptProvider.FromDirectory(dir.RootPath);
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
- files,
+ provider,
cleanInstall: false,
cancellationToken: TestContext.Current.CancellationToken
);
@@ -341,10 +342,10 @@ public async Task EmptySqlFile_DoesNotCrash()
File.WriteAllText(Path.Combine(dir.InstallPath, "01_empty.sql"), "");
- var files = dir.GetFilteredInstallFiles();
+ var provider = ScriptProvider.FromDirectory(dir.RootPath);
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
- files,
+ provider,
cleanInstall: false,
cancellationToken: TestContext.Current.CancellationToken
);
@@ -463,10 +464,10 @@ IF DB_ID(N'PerformanceMonitor_RestrictedTest') IS NULL
File.WriteAllText(Path.Combine(dir.InstallPath, "02_create_tables.sql"),
"CREATE TABLE dbo.should_not_exist (id int);");
- var files = dir.GetFilteredInstallFiles();
+ var provider = ScriptProvider.FromDirectory(dir.RootPath);
var result = await InstallationService.ExecuteInstallationAsync(
restrictedConnStr,
- files,
+ provider,
cleanInstall: false,
cancellationToken: TestContext.Current.CancellationToken
);
diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj
index c930745..18a446f 100644
--- a/Installer.Tests/Installer.Tests.csproj
+++ b/Installer.Tests/Installer.Tests.csproj
@@ -1,8 +1,7 @@
- net8.0-windows
+ net8.0
enable
- true
false
enable
true
@@ -21,6 +20,6 @@
-
+
diff --git a/Installer.Tests/UpgradeOrderingTests.cs b/Installer.Tests/UpgradeOrderingTests.cs
index ee911df..eace56b 100644
--- a/Installer.Tests/UpgradeOrderingTests.cs
+++ b/Installer.Tests/UpgradeOrderingTests.cs
@@ -1,5 +1,6 @@
+using Installer.Core;
+using Installer.Core.Models;
using Installer.Tests.Helpers;
-using PerformanceMonitorInstallerGui.Services;
namespace Installer.Tests;
@@ -17,7 +18,7 @@ public void ReturnsCorrectUpgradesForVersionRange()
.WithUpgrade("2.0.0", "2.1.0", "01_columns.sql")
.WithUpgrade("2.1.0", "2.2.0", "01_compress.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "1.3.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("1.3.0", "2.2.0");
Assert.Equal(3, upgrades.Count);
Assert.Equal("1.3.0-to-2.0.0", upgrades[0].FolderName);
@@ -33,7 +34,7 @@ public void SkipsAlreadyAppliedUpgrades()
.WithUpgrade("2.0.0", "2.1.0", "01_columns.sql")
.WithUpgrade("2.1.0", "2.2.0", "01_compress.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0");
Assert.Equal(2, upgrades.Count);
Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName);
@@ -47,7 +48,7 @@ public void AlreadyAtTargetVersion_ReturnsEmpty()
.WithUpgrade("2.0.0", "2.1.0", "01_columns.sql")
.WithUpgrade("2.1.0", "2.2.0", "01_compress.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.2.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.2.0", "2.2.0");
Assert.Empty(upgrades);
}
@@ -59,7 +60,7 @@ public void FourPartVersion_NormalizedToThreePart()
using var dir = new TempDirectoryBuilder()
.WithUpgrade("2.1.0", "2.2.0", "01_compress.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.1.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.1.0.0", "2.2.0");
Assert.Single(upgrades);
Assert.Equal("2.1.0-to-2.2.0", upgrades[0].FolderName);
@@ -73,7 +74,7 @@ public void MalformedFolderNames_Skipped()
.WithMalformedUpgradeFolder("not-a-version")
.WithMalformedUpgradeFolder("foo-to-bar");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0");
Assert.Single(upgrades);
Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName);
@@ -86,7 +87,7 @@ public void MissingUpgradeTxt_FolderSkipped()
.WithUpgrade("2.0.0", "2.1.0", "01_columns.sql")
.WithUpgradeNoManifest("2.1.0", "2.2.0");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0");
Assert.Single(upgrades);
Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName);
@@ -98,7 +99,7 @@ public void NoUpgradesFolder_ReturnsEmpty()
using var dir = new TempDirectoryBuilder();
// Don't create any upgrade folders
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0");
Assert.Empty(upgrades);
}
@@ -109,7 +110,7 @@ public void NullCurrentVersion_ReturnsEmpty()
using var dir = new TempDirectoryBuilder()
.WithUpgrade("2.0.0", "2.1.0", "01_columns.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, null, "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades(null, "2.2.0");
Assert.Empty(upgrades);
}
@@ -123,7 +124,7 @@ public void OrderedByFromVersion()
.WithUpgrade("1.3.0", "2.0.0", "01_a.sql")
.WithUpgrade("2.0.0", "2.1.0", "01_b.sql");
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "1.3.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("1.3.0", "2.2.0");
Assert.Equal(3, upgrades.Count);
Assert.Equal(new Version(1, 3, 0), upgrades[0].FromVersion);
@@ -140,7 +141,7 @@ public void DoesNotIncludeFutureUpgrades()
.WithUpgrade("2.2.0", "2.3.0", "01_c.sql");
// Target is 2.2.0, so 2.2.0-to-2.3.0 should NOT be included
- var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0");
+ var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0");
Assert.Equal(2, upgrades.Count);
Assert.DoesNotContain(upgrades, u => u.FolderName == "2.2.0-to-2.3.0");
diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj
index 2624bb4..2986f97 100644
--- a/Installer/PerformanceMonitorInstaller.csproj
+++ b/Installer/PerformanceMonitorInstaller.csproj
@@ -34,6 +34,10 @@
+
+
+
+
PreserveNewest
diff --git a/Installer/Program.cs b/Installer/Program.cs
index 4307c1e..daa4cb9 100644
--- a/Installer/Program.cs
+++ b/Installer/Program.cs
@@ -1,2122 +1,1172 @@
-/*
- * Copyright (c) 2026 Erik Darling, Darling Data LLC
- *
- * This file is part of the SQL Server Performance Monitor.
- *
- * Licensed under the MIT License. See LICENSE file in the project root for full license information.
- */
-
-using System;
-using System.Collections.Generic;
-using System.Data;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using System.Reflection;
-using Microsoft.Data.SqlClient;
-
-namespace PerformanceMonitorInstaller
-{
- partial class Program
- {
- ///
- /// Complete uninstall SQL: stops traces, deletes all 3 Agent jobs,
- /// drops both XE sessions, and drops the database.
- ///
- private const string UninstallSql = @"
-/*
-Remove SQL Agent jobs
-*/
-USE msdb;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1;
- PRINT 'Deleted job: PerformanceMonitor - Collection';
-END;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1;
- PRINT 'Deleted job: PerformanceMonitor - Data Retention';
-END;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1;
- PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor';
-END;
-
-/*
-Drop Extended Events sessions
-*/
-USE master;
-
-IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
-BEGIN
- IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
- ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP;
- DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;
- PRINT 'Dropped XE session: PerformanceMonitor_BlockedProcess';
-END;
-
-IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock')
-BEGIN
- IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock')
- ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP;
- DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;
- PRINT 'Dropped XE session: PerformanceMonitor_Deadlock';
-END;
-
-/*
-Drop the database
-*/
-IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
-BEGIN
- ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
- DROP DATABASE PerformanceMonitor;
- PRINT 'PerformanceMonitor database dropped';
-END
-ELSE
-BEGIN
- PRINT 'PerformanceMonitor database does not exist';
-END;";
-
- /*
- Pre-compiled regex patterns for performance
- */
- private static readonly Regex GoBatchPattern = GoBatchRegExp();
-
- private static readonly Regex SqlFileNamePattern = new Regex(
- @"^\d{2}[a-z]?_.*\.sql$",
- RegexOptions.Compiled);
-
- private static readonly Regex SqlCmdDirectivePattern = new Regex(
- @"^:r\s+.*$",
- RegexOptions.Compiled | RegexOptions.Multiline);
-
- /*
- SQL command timeout constants (in seconds)
- */
- private const int ShortTimeoutSeconds = 60; // Quick operations (cleanup, queries)
- private const int MediumTimeoutSeconds = 120; // Dependency installation
- private const int LongTimeoutSeconds = 300; // SQL file execution (5 minutes)
- private const int UpgradeTimeoutSeconds = 3600; // Upgrade data migrations (1 hour, large tables)
-
- /*
- Exit codes for granular error reporting
- */
- private static class ExitCodes
- {
- public const int Success = 0;
- public const int InvalidArguments = 1;
- public const int ConnectionFailed = 2;
- public const int CriticalFileFailed = 3;
- public const int PartialInstallation = 4;
- public const int VersionCheckFailed = 5;
- public const int SqlFilesNotFound = 6;
- public const int UninstallFailed = 7;
- public const int UpgradesFailed = 8;
- }
-
- static async Task Main(string[] args)
- {
- var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown";
- var infoVersion = Assembly.GetExecutingAssembly()
- .GetCustomAttribute()?.InformationalVersion ?? version;
-
- Console.WriteLine("================================================================================");
- Console.WriteLine($"Performance Monitor Installation Utility v{infoVersion}");
- Console.WriteLine("Copyright © 2026 Darling Data, LLC");
- Console.WriteLine("Licensed under the MIT License");
- Console.WriteLine("https://github.com/erikdarlingdata/PerformanceMonitor");
- Console.WriteLine("================================================================================");
-
- await CheckForInstallerUpdateAsync(version);
-
-
- /*
- Determine if running in automated mode (command-line arguments provided)
- Usage: PerformanceMonitorInstaller.exe [server] [username] [password] [options]
- If server is provided alone, uses Windows Authentication
- If server, username, and password are provided, uses SQL Authentication
-
- Options:
- --reinstall Drop existing database and perform clean install
- --encrypt=X Connection encryption: mandatory (default), optional, strict
- --trust-cert Trust server certificate without validation (default: require valid cert)
- */
- if (args.Any(a => a.Equals("--help", StringComparison.OrdinalIgnoreCase)
- || a.Equals("-h", StringComparison.OrdinalIgnoreCase)))
- {
- Console.WriteLine("Usage:");
- Console.WriteLine(" PerformanceMonitorInstaller.exe Interactive mode");
- Console.WriteLine(" PerformanceMonitorInstaller.exe [options] Windows Auth");
- Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth");
- Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth (password via env var)");
- Console.WriteLine(" PerformanceMonitorInstaller.exe --entra Entra ID (MFA)");
- Console.WriteLine();
- Console.WriteLine("Options:");
- Console.WriteLine(" -h, --help Show this help message");
- Console.WriteLine(" --reinstall Drop existing database and perform clean install");
- Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions");
- Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults");
- Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict");
- Console.WriteLine(" --trust-cert Trust server certificate without validation");
- Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)");
- Console.WriteLine();
- Console.WriteLine("Environment Variables:");
- Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)");
- Console.WriteLine();
- Console.WriteLine("Exit Codes:");
- Console.WriteLine(" 0 Success");
- Console.WriteLine(" 1 Invalid arguments");
- Console.WriteLine(" 2 Connection failed");
- Console.WriteLine(" 3 Critical file failed");
- Console.WriteLine(" 4 Partial installation (non-critical failures)");
- Console.WriteLine(" 5 Version check failed");
- Console.WriteLine(" 6 SQL files not found");
- Console.WriteLine(" 7 Uninstall failed");
- return 0;
- }
-
- bool automatedMode = args.Length > 0;
- bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase));
- bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase));
- bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase));
- bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase));
- bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
-
- /*Parse --entra email (the argument following --entra)*/
- string? entraEmail = null;
- if (entraMode)
- {
- int entraIndex = Array.FindIndex(args, a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
- if (entraIndex >= 0 && entraIndex + 1 < args.Length && !args[entraIndex + 1].StartsWith("--", StringComparison.Ordinal))
- {
- entraEmail = args[entraIndex + 1];
- }
- }
-
- /*Parse encryption option (default: Mandatory)*/
- var encryptArg = args.FirstOrDefault(a => a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase));
- SqlConnectionEncryptOption encryptOption = SqlConnectionEncryptOption.Mandatory;
- if (encryptArg != null)
- {
- string encryptValue = encryptArg.Substring("--encrypt=".Length).ToLowerInvariant();
- encryptOption = encryptValue switch
- {
- "optional" => SqlConnectionEncryptOption.Optional,
- "strict" => SqlConnectionEncryptOption.Strict,
- _ => SqlConnectionEncryptOption.Mandatory
- };
- }
-
- /*Filter out option flags to get positional arguments*/
- /*Filter out option flags and --entra to get positional arguments*/
- var filteredArgsList = args
- .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase))
- .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase))
- .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase))
- .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase))
- .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase))
- .Where(a => !a.Equals("--entra", StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- /*Remove the entra email from positional args if present*/
- if (entraEmail != null)
- {
- filteredArgsList.Remove(entraEmail);
- }
-
- var filteredArgs = filteredArgsList.ToArray();
- string? serverName;
- string? username = null;
- string? password = null;
- bool useWindowsAuth;
- bool useEntraAuth = false;
-
- if (automatedMode)
- {
- /*
- Automated mode with command-line arguments
- */
- serverName = filteredArgs.Length > 0 ? filteredArgs[0] : null;
-
- if (entraMode)
- {
- /*Microsoft Entra ID interactive authentication*/
- useWindowsAuth = false;
- useEntraAuth = true;
- username = entraEmail;
-
- if (string.IsNullOrWhiteSpace(username))
- {
- Console.WriteLine("Error: Email address is required for Entra ID authentication.");
- Console.WriteLine("Usage: PerformanceMonitorInstaller.exe --entra ");
- return ExitCodes.InvalidArguments;
- }
-
- Console.WriteLine($"Server: {serverName}");
- Console.WriteLine($"Authentication: Microsoft Entra ID ({username})");
- Console.WriteLine("A browser window will open for interactive authentication...");
- }
- else if (filteredArgs.Length >= 2)
- {
- /*SQL Authentication - password from env var or command-line*/
- useWindowsAuth = false;
- username = filteredArgs[1];
-
- string? envPassword = Environment.GetEnvironmentVariable("PM_SQL_PASSWORD");
- if (filteredArgs.Length >= 3)
- {
- password = filteredArgs[2];
- if (envPassword == null)
- {
- Console.WriteLine("Note: Password provided via command-line is visible in process listings.");
- Console.WriteLine(" Consider using PM_SQL_PASSWORD environment variable instead.");
- Console.WriteLine();
- }
- }
- else if (envPassword != null)
- {
- password = envPassword;
- }
- else
- {
- Console.WriteLine("Error: Password is required for SQL Server Authentication.");
- Console.WriteLine("Provide password as third argument or set PM_SQL_PASSWORD environment variable.");
- return ExitCodes.InvalidArguments;
- }
-
- Console.WriteLine($"Server: {serverName}");
- Console.WriteLine($"Authentication: SQL Server ({username})");
- }
- else if (filteredArgs.Length == 1)
- {
- /*Windows Authentication*/
- useWindowsAuth = true;
- Console.WriteLine($"Server: {serverName}");
- Console.WriteLine($"Authentication: Windows");
- }
- else
- {
- Console.WriteLine("Error: Invalid arguments.");
- Console.WriteLine("Usage:");
- Console.WriteLine(" Windows Auth: PerformanceMonitorInstaller.exe [options]");
- Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]");
- Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]");
- Console.WriteLine(" (with PM_SQL_PASSWORD environment variable set)");
- Console.WriteLine();
- Console.WriteLine("Options:");
- Console.WriteLine(" --reinstall Drop existing database and perform clean install");
- Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults");
- Console.WriteLine(" --encrypt= Connection encryption: optional (default), mandatory, strict");
- Console.WriteLine(" --trust-cert Trust server certificate without validation (default: require valid cert)");
- return ExitCodes.InvalidArguments;
- }
- }
- else
- {
- /*
- Interactive mode - prompt for connection information
- */
- Console.Write("SQL Server instance (e.g., localhost, SQL2022): ");
- serverName = Console.ReadLine();
- if (string.IsNullOrWhiteSpace(serverName))
- {
- Console.WriteLine("Error: Server name is required.");
- WaitForExit();
- return ExitCodes.InvalidArguments;
- }
-
- Console.WriteLine("Authentication type:");
- Console.WriteLine(" [W] Windows Authentication (default)");
- Console.WriteLine(" [S] SQL Server Authentication");
- Console.WriteLine(" [E] Microsoft Entra ID (interactive MFA)");
- Console.Write("Choice (W/S/E, default W): ");
- string? authResponse = Console.ReadLine()?.Trim();
-
- if (string.IsNullOrWhiteSpace(authResponse) || authResponse.Equals("W", StringComparison.OrdinalIgnoreCase))
- {
- useWindowsAuth = true;
- }
- else if (authResponse.Equals("E", StringComparison.OrdinalIgnoreCase))
- {
- useWindowsAuth = false;
- useEntraAuth = true;
-
- Console.Write("Email address (UPN): ");
- username = Console.ReadLine();
- if (string.IsNullOrWhiteSpace(username))
- {
- Console.WriteLine("Error: Email address is required for Entra ID authentication.");
- WaitForExit();
- return ExitCodes.InvalidArguments;
- }
-
- Console.WriteLine("A browser window will open for interactive authentication...");
- }
- else
- {
- useWindowsAuth = false;
-
- Console.Write("SQL Server login: ");
- username = Console.ReadLine();
- if (string.IsNullOrWhiteSpace(username))
- {
- Console.WriteLine("Error: Login is required for SQL Server Authentication.");
- WaitForExit();
- return ExitCodes.InvalidArguments;
- }
-
- Console.Write("Password: ");
- password = ReadPassword();
- Console.WriteLine();
-
- if (string.IsNullOrWhiteSpace(password))
- {
- Console.WriteLine("Error: Password is required for SQL Server Authentication.");
- WaitForExit();
- return ExitCodes.InvalidArguments;
- }
- }
- }
-
- /*
- Build connection string
- */
- var builder = new SqlConnectionStringBuilder
- {
- DataSource = serverName,
- InitialCatalog = "master",
- Encrypt = encryptOption,
- TrustServerCertificate = trustCert
- };
-
- if (useEntraAuth)
- {
- builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
- builder.UserID = username;
- }
- else if (useWindowsAuth)
- {
- builder.IntegratedSecurity = true;
- }
- else
- {
- builder.UserID = username;
- builder.Password = password;
- }
-
- /*
- Test connection and get SQL Server version
- */
- string sqlServerVersion = "";
- string sqlServerEdition = "";
-
- Console.WriteLine();
- Console.WriteLine("Testing connection...");
- try
- {
- using (var connection = new SqlConnection(builder.ConnectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
- WriteSuccess("Connection successful!");
-
- /*Capture SQL Server version for summary report*/
- using (var versionCmd = new SqlCommand(@"
- SELECT
- @@VERSION,
- SERVERPROPERTY('Edition'),
- CONVERT(int, SERVERPROPERTY('EngineEdition')),
- SERVERPROPERTY('ProductMajorVersion');", connection))
- {
- using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false))
- {
- if (await reader.ReadAsync().ConfigureAwait(false))
- {
- sqlServerVersion = reader.GetString(0);
- sqlServerEdition = reader.GetString(1);
-
- var engineEdition = reader.IsDBNull(2) ? 0 : reader.GetInt32(2);
- var majorVersion = reader.IsDBNull(3) ? 0 : int.TryParse(reader.GetValue(3).ToString(), out var v) ? v : 0;
-
- /*Check minimum SQL Server version — 2016+ required for on-prem (Standard/Enterprise).
- Azure MI (EngineEdition 8) is always current, skip the check.*/
- if (engineEdition is not 8 && majorVersion > 0 && majorVersion < 13)
- {
- string versionName = majorVersion switch
- {
- 11 => "SQL Server 2012",
- 12 => "SQL Server 2014",
- _ => $"SQL Server (version {majorVersion})"
- };
- Console.WriteLine();
- Console.WriteLine($"ERROR: {versionName} is not supported.");
- Console.WriteLine("Performance Monitor requires SQL Server 2016 (13.x) or later.");
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.VersionCheckFailed;
- }
- }
- }
- }
- }
- }
- catch (Exception ex)
- {
- WriteError($"Connection failed: {ex.Message}");
- Console.WriteLine($"Exception type: {ex.GetType().Name}");
- if (ex.InnerException != null)
- {
- Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
- }
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.ConnectionFailed;
- }
-
- /*
- Handle --uninstall mode (no SQL files needed)
- */
- if (uninstallMode)
- {
- return await PerformUninstallAsync(builder.ConnectionString, automatedMode);
- }
-
- /*
- Find SQL files to execute (do this once before the installation loop)
- Search current directory and up to 5 parent directories
- Prefer install/ subfolder if it exists (new structure)
- */
- string? sqlDirectory = null;
- string? monitorRootDirectory = null;
- string currentDirectory = Directory.GetCurrentDirectory();
- DirectoryInfo? searchDir = new DirectoryInfo(currentDirectory);
-
- for (int i = 0; i < 6 && searchDir != null; i++)
- {
- /*Check for install/ subfolder first (new structure)*/
- string installFolder = Path.Combine(searchDir.FullName, "install");
- if (Directory.Exists(installFolder))
- {
- var installFiles = Directory.GetFiles(installFolder, "*.sql")
- .Where(f => SqlFileNamePattern.IsMatch(Path.GetFileName(f)))
- .ToList();
-
- if (installFiles.Count > 0)
- {
- sqlDirectory = installFolder;
- monitorRootDirectory = searchDir.FullName;
- break;
- }
- }
-
- /*Fall back to old structure (SQL files in root)*/
- var files = Directory.GetFiles(searchDir.FullName, "*.sql")
- .Where(f => SqlFileNamePattern.IsMatch(Path.GetFileName(f)))
- .ToList();
-
- if (files.Count > 0)
- {
- sqlDirectory = searchDir.FullName;
- monitorRootDirectory = searchDir.FullName;
- break;
- }
-
- searchDir = searchDir.Parent;
- }
-
- if (sqlDirectory == null)
- {
- Console.WriteLine($"Error: No SQL installation files found.");
- Console.WriteLine($"Searched in: {currentDirectory}");
- Console.WriteLine("Expected files in install/ folder or root directory:");
- Console.WriteLine(" install/01_install_database.sql, install/02_create_tables.sql, etc.");
- Console.WriteLine();
- Console.WriteLine("Make sure the installer is in the Monitor directory or a subdirectory.");
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.SqlFilesNotFound;
- }
-
- var sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql")
- .Where(f =>
- {
- string fileName = Path.GetFileName(f);
- if (!SqlFileNamePattern.IsMatch(fileName))
- return false;
- /*Exclude uninstall, test, and troubleshooting scripts from main install*/
- if (fileName.StartsWith("00_", StringComparison.Ordinal) ||
- fileName.StartsWith("97_", StringComparison.Ordinal) ||
- fileName.StartsWith("99_", StringComparison.Ordinal))
- return false;
- return true;
- })
- .OrderBy(f => Path.GetFileName(f))
- .ToList();
-
- Console.WriteLine();
- Console.WriteLine($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}");
- if (monitorRootDirectory != sqlDirectory)
- {
- Console.WriteLine($"Using new folder structure (install/ subfolder)");
- }
-
- /*
- Main installation loop - allows retry on failure
- */
- int upgradeSuccessCount = 0;
- int upgradeFailureCount = 0;
- int installSuccessCount = 0;
- int installFailureCount = 0;
- int totalSuccessCount = 0;
- int totalFailureCount = 0;
- var installationErrors = new List<(string FileName, string ErrorMessage)>();
- bool installationSuccessful = false;
- bool retry;
- DateTime installationStartTime = DateTime.Now;
- do
- {
- retry = false;
- upgradeSuccessCount = 0;
- upgradeFailureCount = 0;
- installSuccessCount = 0;
- installFailureCount = 0;
- installationErrors.Clear();
- installationSuccessful = false;
- installationStartTime = DateTime.Now;
-
- /*
- Ask about clean install (automated mode preserves database unless --reinstall flag is used)
- */
- bool dropExisting;
- if (automatedMode)
- {
- dropExisting = reinstallMode;
- Console.WriteLine();
- if (reinstallMode)
- {
- Console.WriteLine("Automated mode: Performing clean reinstall (dropping existing database)...");
- }
- else
- {
- Console.WriteLine("Automated mode: Performing upgrade (preserving existing database)...");
- }
- }
- else
- {
- Console.WriteLine();
- Console.Write("Drop existing PerformanceMonitor database if it exists? (Y/N, default N): ");
- string? cleanInstall = Console.ReadLine();
- dropExisting = cleanInstall?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
- }
-
- if (dropExisting)
- {
- Console.WriteLine();
- Console.WriteLine("Performing clean install...");
- try
- {
- using (var connection = new SqlConnection(builder.ConnectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*
- Stop any existing traces before dropping database
- Traces are server-level and persist after database drops
- Use existing procedure if database exists
- */
- try
- {
- using (var command = new SqlCommand("EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", connection))
- {
- command.CommandTimeout = ShortTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- Console.WriteLine("✓ Stopped existing traces");
- }
- }
- catch (SqlException)
- {
- /*Database or procedure doesn't exist - no traces to clean*/
- }
-
- using (var command = new SqlCommand(UninstallSql, connection))
- {
- command.CommandTimeout = ShortTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- }
-
- WriteSuccess("Clean install completed (jobs and database removed)");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Warning: Could not complete cleanup: {ex.Message}");
- Console.WriteLine("Continuing with installation...");
- }
- }
- else
- {
- /*
- Upgrade mode - check for existing installation and apply upgrades
- */
- string? currentVersion = null;
- try
- {
- currentVersion = await GetInstalledVersion(builder.ConnectionString);
- }
- catch (InvalidOperationException ex)
- {
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("ERROR: Failed to check for existing installation");
- Console.WriteLine("================================================================================");
- Console.WriteLine(ex.Message);
- if (ex.InnerException != null)
- {
- Console.WriteLine($"Details: {ex.InnerException.Message}");
- }
- Console.WriteLine();
- Console.WriteLine("This may indicate a permissions issue or database corruption.");
- Console.WriteLine("Please review the error log and report this issue if it persists.");
- Console.WriteLine();
-
- /*Write error log for bug reporting*/
- string errorLogPath = WriteErrorLog(ex, serverName!, infoVersion);
- Console.WriteLine($"Error log written to: {errorLogPath}");
-
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.VersionCheckFailed;
- }
-
- if (currentVersion != null && monitorRootDirectory != null)
- {
- Console.WriteLine();
- Console.WriteLine($"Existing installation detected: v{currentVersion}");
- Console.WriteLine("Checking for applicable upgrades...");
-
- var upgrades = GetApplicableUpgrades(monitorRootDirectory, currentVersion, version);
-
- if (upgrades.Count > 0)
- {
- Console.WriteLine($"Found {upgrades.Count} upgrade(s) to apply.");
- Console.WriteLine();
- Console.WriteLine("================================================================================" );
- Console.WriteLine("Applying upgrades...");
- Console.WriteLine("================================================================================");
-
- using (var connection = new SqlConnection(builder.ConnectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- foreach (var upgradeFolder in upgrades)
- {
- var (upgradeSuccess, upgradeFail) = await ExecuteUpgrade(upgradeFolder, connection);
- upgradeSuccessCount += upgradeSuccess;
- upgradeFailureCount += upgradeFail;
- }
- }
-
- Console.WriteLine();
- Console.WriteLine($"Upgrades complete: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed");
-
- /*Abort if any upgrade scripts failed — proceeding would reinstall over a partially-upgraded database*/
- if (upgradeFailureCount > 0)
- {
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed.");
- Console.WriteLine("Fix the errors above and re-run the installer.");
- Console.WriteLine("================================================================================");
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.UpgradesFailed;
- }
- }
- else
- {
- Console.WriteLine("No pending upgrades found.");
- }
- }
- else
- {
- Console.WriteLine();
- Console.WriteLine("No existing installation detected, proceeding with fresh install...");
- }
- }
-
- /*
- Execute SQL files in order
- */
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("Starting installation...");
- Console.WriteLine("================================================================================");
- Console.WriteLine();
-
- /*
- Open a single connection for all SQL file execution
- Connection pooling handles the underlying socket reuse
- */
- bool communityDepsInstalled = false;
-
- using (var connection = new SqlConnection(builder.ConnectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- foreach (var sqlFile in sqlFiles)
- {
- string fileName = Path.GetFileName(sqlFile);
-
- /*Install community dependencies before validation runs
- Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/
- if (!communityDepsInstalled &&
- fileName.StartsWith("98_", StringComparison.Ordinal))
- {
- communityDepsInstalled = true;
- try
- {
- await InstallDependenciesAsync(builder.ConnectionString);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}");
- Console.WriteLine("Continuing with installation...");
- }
- }
-
- Console.Write($"Executing {fileName}... ");
-
- try
- {
- string sqlContent = await File.ReadAllTextAsync(sqlFile);
-
- /*
- Reset schedule to defaults if requested — truncate before the
- INSERT...WHERE NOT EXISTS re-populates with current recommended values
- */
- if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal))
- {
- sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent;
- Console.Write("(resetting schedule) ");
- }
-
- /*
- Remove SQLCMD directives (:r includes) as we're executing files directly
- */
- sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, "");
-
- /*
- Split by GO statements using pre-compiled regex
- Match GO only when it's a whole word on its own line
- */
- string[] batches = GoBatchPattern.Split(sqlContent);
-
- int batchNumber = 0;
- foreach (string batch in batches)
- {
- string trimmedBatch = batch.Trim();
-
- /*Skip empty batches*/
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- batchNumber++;
-
- using (var command = new SqlCommand(trimmedBatch, connection))
- {
- command.CommandTimeout = LongTimeoutSeconds;
- try
- {
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- catch (SqlException ex)
- {
- /*Add batch info to error message*/
- string batchPreview = trimmedBatch.Length > 500 ?
- trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" :
- trimmedBatch;
- throw new InvalidOperationException($"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
- }
- }
- }
-
- WriteSuccess("Success");
- installSuccessCount++;
- }
- catch (Exception ex)
- {
- WriteError("FAILED");
- Console.WriteLine($" Error: {ex.Message}");
- installFailureCount++;
- installationErrors.Add((fileName, ex.Message));
-
- if (fileName.StartsWith("01_", StringComparison.Ordinal) || fileName.StartsWith("02_", StringComparison.Ordinal) || fileName.StartsWith("03_", StringComparison.Ordinal))
- {
- Console.WriteLine();
- Console.WriteLine("Critical installation file failed. Aborting installation.");
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.CriticalFileFailed;
- }
- }
- }
- }
-
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("File Execution Summary");
- Console.WriteLine("================================================================================");
- if (upgradeSuccessCount > 0 || upgradeFailureCount > 0)
- {
- Console.WriteLine($"Upgrades: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed");
- }
- Console.WriteLine($"Installation: {installSuccessCount} succeeded, {installFailureCount} failed");
- Console.WriteLine();
-
- /*
- Install community dependencies if not already done (no 98_ files in batch)
- */
- if (!communityDepsInstalled && installFailureCount <= 1)
- {
- try
- {
- await InstallDependenciesAsync(builder.ConnectionString);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}");
- Console.WriteLine("Continuing with validation...");
- }
- }
-
- /*
- Run initial collection and retry failed views
- This validates the installation and creates dynamically-generated tables
- */
- if (installFailureCount <= 1 && automatedMode) /* Allow 1 failure for query_snapshots view */
- {
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("Running initial collection to validate installation...");
- Console.WriteLine("================================================================================");
- Console.WriteLine();
-
- try
- {
- using (var connection = new SqlConnection(builder.ConnectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Capture timestamp before running so we only check errors from this run.
- Use SYSDATETIME() (local) because collection_time is stored in server local time.*/
- DateTime validationStart;
- using (var command = new SqlCommand("SELECT SYSDATETIME();", connection))
- {
- validationStart = (DateTime)(await command.ExecuteScalarAsync().ConfigureAwait(false))!;
- }
-
- /*Run master collector once with @force_run_all to collect everything immediately*/
- Console.Write("Executing master collector... ");
- using (var command = new SqlCommand("EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;", connection))
- {
- command.CommandTimeout = LongTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- WriteSuccess("Success");
-
- /*
- Verify data was collected — only from this validation run, not historical errors
- */
- Console.WriteLine();
- Console.Write("Verifying data collection... ");
-
- /* Check successful collections from this run */
- int collectedCount = 0;
- using (var command = new SqlCommand(@"
- SELECT
- COUNT(DISTINCT collector_name)
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_status = 'SUCCESS'
- AND collection_time >= @validation_start;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- collectedCount = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0);
- }
-
- /* Total log entries from this run */
- int totalLogEntries = 0;
- using (var command = new SqlCommand(@"
- SELECT COUNT(*)
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_time >= @validation_start;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- totalLogEntries = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0);
- }
-
- Console.WriteLine($"✓ {collectedCount} collectors ran successfully (total log entries: {totalLogEntries})");
-
- /* Show failed collectors from this run */
- int errorCount = 0;
- using (var command = new SqlCommand(@"
- SELECT COUNT(*)
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_status = 'ERROR'
- AND collection_time >= @validation_start;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- errorCount = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0);
- }
-
- if (errorCount > 0)
- {
- Console.WriteLine();
- Console.WriteLine($"⚠ {errorCount} collector(s) encountered errors:");
- using (var command = new SqlCommand(@"
- SELECT
- collector_name,
- error_message
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_status = 'ERROR'
- AND collection_time >= @validation_start
- ORDER BY collection_time DESC;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- using (var reader = await command.ExecuteReaderAsync().ConfigureAwait(false))
- {
- while (await reader.ReadAsync().ConfigureAwait(false))
- {
- string name = reader["collector_name"]?.ToString() ?? "";
- string error = reader["error_message"] == DBNull.Value ? "(no error message)" : reader["error_message"]?.ToString() ?? "";
- Console.WriteLine($" ✗ {name}");
- Console.WriteLine($" {error}");
- }
- }
- }
- }
-
- /* Show recent log entries for debugging */
- if (totalLogEntries > 0 && errorCount == 0)
- {
- Console.WriteLine();
- Console.WriteLine("Sample collection log entries:");
- using (var command = new SqlCommand(@"
- SELECT TOP 5
- collector_name,
- collection_status,
- rows_collected,
- error_message
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_status = 'SUCCESS'
- AND collection_time >= @validation_start
- ORDER BY collection_time DESC;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- using (var reader = await command.ExecuteReaderAsync().ConfigureAwait(false))
- {
- while (await reader.ReadAsync().ConfigureAwait(false))
- {
- string status = reader["collection_status"]?.ToString() ?? "";
- string name = reader["collector_name"]?.ToString() ?? "";
- int rows = (int)reader["rows_collected"];
- string error = reader["error_message"] == DBNull.Value ? "" : $" - {reader["error_message"]}";
- Console.WriteLine($" {status,10}: {name,-35} ({rows,4} rows){error}");
- }
- }
- }
- }
-
- /*
- Check if sp_WhoIsActive created query_snapshots table
- The collector creates daily tables like query_snapshots_20260102
- */
- if (installFailureCount > 0)
- {
- Console.WriteLine();
- Console.Write("Checking for query_snapshots table... ");
-
- bool tableExists = false;
- using (var command = new SqlCommand(@"
- SELECT TOP (1) 1
- FROM sys.tables AS t
- WHERE t.name LIKE 'query_snapshots_%'
- AND t.schema_id = SCHEMA_ID('collect');", connection))
- {
- var result = await command.ExecuteScalarAsync().ConfigureAwait(false);
- tableExists = result != null && result != DBNull.Value;
- }
-
- if (tableExists)
- {
- Console.WriteLine("✓ Found");
- Console.Write("Retrying query plan views... ");
-
- try
- {
- string viewFile = Path.Combine(sqlDirectory, "46_create_query_plan_views.sql");
- if (File.Exists(viewFile))
- {
- string sqlContent = await File.ReadAllTextAsync(viewFile);
- sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, "");
-
- string[] batches = GoBatchPattern.Split(sqlContent);
-
- foreach (string batch in batches)
- {
- string trimmedBatch = batch.Trim();
- if (string.IsNullOrWhiteSpace(trimmedBatch)) continue;
-
- using (var command = new SqlCommand(trimmedBatch, connection))
- {
- command.CommandTimeout = ShortTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- }
-
- WriteSuccess("Success");
- installFailureCount = 0; /* Reset failure count */
- }
- }
- catch (SqlException)
- {
- Console.WriteLine("✗ Skipped (sp_WhoIsActive not installed or incompatible schema)");
- /*This is expected if sp_WhoIsActive isn't installed - keep installFailureCount = 1 but don't error*/
- }
- catch (IOException)
- {
- Console.WriteLine("✗ Skipped (could not read view file)");
- }
- }
- else
- {
- Console.WriteLine("✗ Not created (sp_WhoIsActive installation may have failed)");
- Console.WriteLine();
- Console.WriteLine("NOTE: The query_snapshots table creation depends on sp_WhoIsActive");
- Console.WriteLine(" The view will be created automatically on next collection if available");
- }
- }
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine($"✗ Failed");
- Console.WriteLine($"Error: {ex.Message}");
- Console.WriteLine();
- Console.WriteLine("Installation completed but initial collection failed.");
- Console.WriteLine("Check PerformanceMonitor.config.collection_log for details.");
- }
- }
-
- /*
- Installation summary
- Calculate totals and determine success
- Treat query_snapshots view failure as a warning, not an error
- */
- totalSuccessCount = upgradeSuccessCount + installSuccessCount;
- totalFailureCount = upgradeFailureCount + installFailureCount;
- installationSuccessful = totalFailureCount == 0;
-
- /*
- Log installation history to database
- */
- try
- {
- await LogInstallationHistory(
- builder.ConnectionString,
- version,
- infoVersion,
- installationStartTime,
- totalSuccessCount,
- totalFailureCount,
- installationSuccessful
- );
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Warning: Could not log installation history: {ex.Message}");
- }
-
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("Installation Summary");
- Console.WriteLine("================================================================================");
-
- if (installationSuccessful)
- {
- WriteSuccess("Installation completed successfully!");
- Console.WriteLine();
- Console.WriteLine("WHAT WAS INSTALLED:");
- Console.WriteLine("✓ PerformanceMonitor database and all collection tables");
- Console.WriteLine("✓ All collector stored procedures");
- Console.WriteLine("✓ Community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)");
- Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Collection (runs every 1 minute)");
- Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Data Retention (runs daily at 2:00 AM)");
- Console.WriteLine("✓ Initial collection completed successfully");
-
- Console.WriteLine();
- Console.WriteLine("NEXT STEPS:");
- Console.WriteLine("1. Ensure SQL Server Agent service is running");
- Console.WriteLine("2. Verify installation: SELECT * FROM PerformanceMonitor.report.collection_health;");
- Console.WriteLine("3. Monitor job history in SQL Server Agent");
- Console.WriteLine();
- Console.WriteLine("See README.md for detailed information.");
- }
- else
- {
- WriteWarning($"Installation completed with {totalFailureCount} error(s).");
- Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details.");
- }
-
- /*
- Ask if user wants to retry or exit (skip in automated mode)
- */
- if (totalFailureCount > 0 && !automatedMode)
- {
- retry = PromptRetryOrExit();
- }
-
- } while (retry);
-
- /*
- Generate installation summary report file
- */
- try
- {
- string reportPath = GenerateSummaryReport(
- serverName!,
- sqlServerVersion,
- sqlServerEdition,
- infoVersion,
- installationStartTime,
- totalSuccessCount,
- totalFailureCount,
- installationSuccessful,
- installationErrors
- );
- Console.WriteLine();
- Console.WriteLine($"Installation report saved to: {reportPath}");
- }
- catch (Exception ex)
- {
- Console.WriteLine();
- Console.WriteLine($"Warning: Could not generate summary report: {ex.Message}");
- }
-
- /*
- Exit message for successful completion or user chose not to retry
- */
- if (!automatedMode)
- {
- Console.WriteLine();
- Console.Write("Press any key to exit...");
- Console.ReadKey(true);
- Console.WriteLine();
- }
-
- return installationSuccessful ? ExitCodes.Success : ExitCodes.PartialInstallation;
- }
-
- /*
- Ask user if they want to retry or exit
- Returns true to retry, false to exit
- */
- private static bool PromptRetryOrExit()
- {
- Console.WriteLine();
- Console.Write("Y to retry installation, N to exit: ");
- string? response = Console.ReadLine();
- return response?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
- }
-
- /*
- Log installation history to database
- Tracks version, duration, success/failure, and upgrade detection
- */
-
- ///
- /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database.
- ///
- private static async Task PerformUninstallAsync(string connectionString, bool automatedMode)
- {
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("UNINSTALL MODE");
- Console.WriteLine("================================================================================");
- Console.WriteLine();
-
- if (!automatedMode)
- {
- Console.WriteLine("This will remove:");
- Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)");
- Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)");
- Console.WriteLine(" - Server-side traces");
- Console.WriteLine(" - PerformanceMonitor database and ALL collected data");
- Console.WriteLine();
- Console.Write("Are you sure you want to continue? (Y/N, default N): ");
- string? confirm = Console.ReadLine();
- if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true)
- {
- Console.WriteLine("Uninstall cancelled.");
- WaitForExit();
- return ExitCodes.Success;
- }
- }
-
- Console.WriteLine();
- Console.WriteLine("Uninstalling Performance Monitor...");
-
- try
- {
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Stop traces first (procedure lives in the database)*/
- try
- {
- using var traceCmd = new SqlCommand(
- "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
- connection);
- traceCmd.CommandTimeout = ShortTimeoutSeconds;
- await traceCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
- Console.WriteLine("✓ Stopped server-side traces");
- }
- catch (SqlException)
- {
- Console.WriteLine(" No traces to stop (database or procedure not found)");
- }
-
- /*Remove jobs, XE sessions, and database*/
- using var command = new SqlCommand(UninstallSql, connection);
- command.CommandTimeout = ShortTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
-
- Console.WriteLine();
- WriteSuccess("Uninstall completed successfully");
- Console.WriteLine();
- Console.WriteLine("Note: blocked process threshold (s) was NOT reset.");
- }
- catch (Exception ex)
- {
- Console.WriteLine();
- Console.WriteLine($"Uninstall failed: {ex.Message}");
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.UninstallFailed;
- }
-
- if (!automatedMode)
- {
- WaitForExit();
- }
- return ExitCodes.Success;
- }
-
- /*
- Get currently installed version from database
- Returns null if not installed (database or table doesn't exist)
- Throws exception for unexpected errors (permissions, network, etc.)
- */
- private static async Task GetInstalledVersion(string connectionString)
- {
- try
- {
- using (var connection = new SqlConnection(connectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Check if PerformanceMonitor database exists*/
- using var dbCheckCmd = new SqlCommand(@"
- SELECT database_id
- FROM sys.databases
- WHERE name = N'PerformanceMonitor';", connection);
-
- var dbExists = await dbCheckCmd.ExecuteScalarAsync().ConfigureAwait(false);
- if (dbExists == null || dbExists == DBNull.Value)
- {
- return null; /*Database doesn't exist - clean install needed*/
- }
-
- /*Check if installation_history table exists*/
- using var tableCheckCmd = new SqlCommand(@"
- USE PerformanceMonitor;
- SELECT OBJECT_ID(N'config.installation_history', N'U');", connection);
-
- var tableExists = await tableCheckCmd.ExecuteScalarAsync().ConfigureAwait(false);
- if (tableExists == null || tableExists == DBNull.Value)
- {
- return null; /*Table doesn't exist - old version or corrupted install*/
- }
-
- /*Get most recent successful installation version*/
- using var versionCmd = new SqlCommand(@"
- SELECT TOP 1 installer_version
- FROM PerformanceMonitor.config.installation_history
- WHERE installation_status = 'SUCCESS'
- ORDER BY installation_date DESC;", connection);
-
- var version = await versionCmd.ExecuteScalarAsync().ConfigureAwait(false);
- if (version != null && version != DBNull.Value)
- {
- return version.ToString();
- }
-
- /*
- Fallback: database and history table exist but no SUCCESS rows.
- This can happen if a prior GUI install didn't write history (#538/#539).
- Return "1.0.0" so all idempotent upgrade scripts are attempted
- rather than treating this as a fresh install (which would drop the database).
- */
- Console.WriteLine("Warning: PerformanceMonitor database exists but installation_history has no records.");
- Console.WriteLine("Treating as v1.0.0 to apply all available upgrades.");
- return "1.0.0";
- }
- }
- catch (SqlException ex)
- {
- throw new InvalidOperationException(
- $"Failed to check installed version. SQL Error {ex.Number}: {ex.Message}", ex);
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException(
- $"Failed to check installed version: {ex.Message}", ex);
- }
- }
-
- /*
- Find upgrade folders that need to be applied
- Returns list of upgrade folder paths in order
- Filters by version: only applies upgrades where FromVersion >= currentVersion and ToVersion <= targetVersion
- */
- private static List GetApplicableUpgrades(
- string monitorRootDirectory,
- string? currentVersion,
- string targetVersion)
- {
- var upgradeFolders = new List();
- string upgradesDirectory = Path.Combine(monitorRootDirectory, "upgrades");
-
- if (!Directory.Exists(upgradesDirectory))
- {
- return upgradeFolders; /*No upgrades folder - return empty list*/
- }
-
- /*If there's no current version, it's a clean install - no upgrades needed*/
- if (currentVersion == null)
- {
- return upgradeFolders;
- }
-
- /*Parse current version - if invalid, skip upgrades
- Normalize to 3-part (Major.Minor.Build) to avoid Revision mismatch:
- folder names use 3-part "1.3.0" but DB stores 4-part "1.3.0.0"
- Version(1,3,0).Revision=-1 which breaks >= comparison with Version(1,3,0,0)*/
- if (!Version.TryParse(currentVersion, out var currentRaw))
- {
- return upgradeFolders;
- }
- var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build);
-
- /*Parse target version - if invalid, skip upgrades*/
- if (!Version.TryParse(targetVersion, out var targetRaw))
- {
- return upgradeFolders;
- }
- var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build);
-
- /*
- Find all upgrade folders matching pattern: {from}-to-{to}
- Parse versions and filter to only applicable upgrades
- */
- var applicableUpgrades = Directory.GetDirectories(upgradesDirectory)
- .Select(d => new
- {
- Path = d,
- FolderName = Path.GetFileName(d)
- })
- .Where(x => x.FolderName.Contains("-to-", StringComparison.Ordinal))
- .Select(x =>
- {
- var parts = x.FolderName.Split("-to-");
- return new
- {
- x.Path,
- FromVersion = Version.TryParse(parts[0], out var from) ? from : null,
- ToVersion = parts.Length > 1 && Version.TryParse(parts[1], out var to) ? to : null
- };
- })
- .Where(x => x.FromVersion != null && x.ToVersion != null)
- .Where(x => x.FromVersion >= current) /*Don't re-apply old upgrades*/
- .Where(x => x.ToVersion <= target) /*Don't apply future upgrades*/
- .OrderBy(x => x.FromVersion)
- .ToList();
-
- foreach (var upgrade in applicableUpgrades)
- {
- string upgradeFile = Path.Combine(upgrade.Path, "upgrade.txt");
- if (File.Exists(upgradeFile))
- {
- upgradeFolders.Add(upgrade.Path);
- }
- }
-
- return upgradeFolders;
- }
-
- /*
- Execute an upgrade folder
- Returns (successCount, failureCount)
- */
- private static async Task<(int successCount, int failureCount)> ExecuteUpgrade(
- string upgradeFolder,
- SqlConnection connection)
- {
- int successCount = 0;
- int failureCount = 0;
-
- string upgradeName = Path.GetFileName(upgradeFolder);
- string upgradeFile = Path.Combine(upgradeFolder, "upgrade.txt");
-
- Console.WriteLine();
- Console.WriteLine($"Applying upgrade: {upgradeName}");
- Console.WriteLine("--------------------------------------------------------------------------------");
-
- /*Read the upgrade.txt file to get ordered list of SQL files*/
- var sqlFileNames = (await File.ReadAllLinesAsync(upgradeFile).ConfigureAwait(false))
- .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
- .Select(line => line.Trim())
- .ToList();
-
- foreach (var fileName in sqlFileNames)
- {
- string filePath = Path.Combine(upgradeFolder, fileName);
-
- if (!File.Exists(filePath))
- {
- Console.WriteLine($" {fileName}... ? WARNING: File not found");
- failureCount++;
- continue;
- }
-
- Console.Write($" {fileName}... ");
-
- try
- {
- string sql = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
- string[] batches = GoBatchPattern.Split(sql);
-
- int batchNumber = 0;
- foreach (var batch in batches)
- {
- batchNumber++;
- string trimmedBatch = batch.Trim();
-
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- using (var cmd = new SqlCommand(trimmedBatch, connection))
- {
- cmd.CommandTimeout = UpgradeTimeoutSeconds;
- try
- {
- await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- catch (SqlException ex)
- {
- /*Add batch info to error message*/
- string batchPreview = trimmedBatch.Length > 500 ?
- trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" :
- trimmedBatch;
- throw new InvalidOperationException($"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
- }
- }
- }
-
- WriteSuccess("Success");
- successCount++;
- }
- catch (Exception ex)
- {
- WriteError("FAILED");
- Console.WriteLine($" Error: {ex.Message}");
- failureCount++;
- }
- }
-
- return (successCount, failureCount);
- }
-
- private static async Task LogInstallationHistory(
- string connectionString,
- string assemblyVersion,
- string infoVersion,
- DateTime startTime,
- int filesExecuted,
- int filesFailed,
- bool isSuccess)
- {
- try
- {
- using (var connection = new SqlConnection(connectionString))
- {
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Check if this is an upgrade by checking for existing installation*/
- string? previousVersion = null;
- string installationType = "INSTALL";
-
- try
- {
- using (var checkCmd = new SqlCommand(@"
- SELECT TOP 1 installer_version
- FROM PerformanceMonitor.config.installation_history
- WHERE installation_status = 'SUCCESS'
- ORDER BY installation_date DESC;", connection))
- {
- var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false);
- if (result != null && result != DBNull.Value)
- {
- previousVersion = result.ToString();
- bool isSameVersion = Version.TryParse(previousVersion, out var prevVer)
- && Version.TryParse(assemblyVersion, out var currVer)
- && prevVer == currVer;
- installationType = isSameVersion ? "REINSTALL" : "UPGRADE";
- }
- }
- }
- catch (SqlException)
- {
- /*Table might not exist yet on first install - that's ok*/
- }
-
- /*Get SQL Server version info*/
- string sqlVersion = "";
- string sqlEdition = "";
-
- using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection))
- {
- using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false))
- {
- if (await reader.ReadAsync().ConfigureAwait(false))
- {
- sqlVersion = reader.GetString(0);
- sqlEdition = reader.GetString(1);
- }
- }
- }
-
- /*Calculate duration*/
- long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds;
-
- /*Determine installation status*/
- string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED");
-
- /*Insert installation record*/
- var insertSql = @"
- INSERT INTO PerformanceMonitor.config.installation_history
- (
- installer_version,
- installer_info_version,
- sql_server_version,
- sql_server_edition,
- installation_type,
- previous_version,
- installation_status,
- files_executed,
- files_failed,
- installation_duration_ms
- )
- VALUES
- (
- @installer_version,
- @installer_info_version,
- @sql_server_version,
- @sql_server_edition,
- @installation_type,
- @previous_version,
- @installation_status,
- @files_executed,
- @files_failed,
- @installation_duration_ms
- );";
-
- using (var insertCmd = new SqlCommand(insertSql, connection))
- {
- insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion });
- insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value });
- insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion });
- insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition });
- insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType });
- insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value });
- insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status });
- insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted });
- insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed });
- insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs });
-
- await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
- }
- }
- catch (Exception ex)
- {
- /*Don't fail installation if logging fails*/
- Console.WriteLine($"Warning: Failed to log installation history: {ex.Message}");
- }
- }
-
- /*
- Write error log file for bug reporting
- Returns the path to the log file
- */
- private static string WriteErrorLog(Exception ex, string serverName, string installerVersion)
- {
- string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
- string sanitizedServer = SanitizeFilename(serverName);
- string fileName = $"PerformanceMonitor_Error_{sanitizedServer}_{timestamp}.log";
- string logPath = Path.Combine(Directory.GetCurrentDirectory(), fileName);
-
- var sb = new System.Text.StringBuilder();
-
- sb.AppendLine("================================================================================");
- sb.AppendLine("Performance Monitor Installer - Error Log");
- sb.AppendLine("================================================================================");
- sb.AppendLine();
- sb.AppendLine($"Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
- sb.AppendLine($"Installer Version: {installerVersion}");
- sb.AppendLine($"Server: {serverName}");
- sb.AppendLine($"Machine: {Environment.MachineName}");
- sb.AppendLine($"User: {Environment.UserName}");
- sb.AppendLine($"OS: {Environment.OSVersion}");
- sb.AppendLine($".NET Version: {Environment.Version}");
- sb.AppendLine();
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine("ERROR DETAILS");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Type: {ex.GetType().FullName}");
- sb.AppendLine($"Message: {ex.Message}");
- sb.AppendLine();
-
- if (ex.InnerException != null)
- {
- sb.AppendLine("Inner Exception:");
- sb.AppendLine($" Type: {ex.InnerException.GetType().FullName}");
- sb.AppendLine($" Message: {ex.InnerException.Message}");
- sb.AppendLine();
- }
-
- sb.AppendLine("Stack Trace:");
- sb.AppendLine(ex.StackTrace ?? "(not available)");
- sb.AppendLine();
-
- if (ex.InnerException?.StackTrace != null)
- {
- sb.AppendLine("Inner Exception Stack Trace:");
- sb.AppendLine(ex.InnerException.StackTrace);
- sb.AppendLine();
- }
-
- sb.AppendLine("================================================================================");
- sb.AppendLine("Please include this file when reporting issues at:");
- sb.AppendLine("https://github.com/erikdarlingdata/PerformanceMonitor/issues");
- sb.AppendLine("================================================================================");
-
- File.WriteAllText(logPath, sb.ToString());
-
- return logPath;
- }
-
- /*
- Sanitize a string for use in a filename
- Replaces invalid characters with underscores
- */
- private static string SanitizeFilename(string input)
- {
- var invalid = Path.GetInvalidFileNameChars();
- return string.Concat(input.Select(c => invalid.Contains(c) ? '_' : c));
- }
-
- /*
- Download content from URL with retry logic for transient failures
- Uses exponential backoff: 2s, 4s, 8s between retries
- */
- private static async Task DownloadWithRetryAsync(
- HttpClient client,
- string url,
- int maxRetries = 3)
- {
- for (int attempt = 1; attempt <= maxRetries; attempt++)
- {
- try
- {
- return await client.GetStringAsync(url).ConfigureAwait(false);
- }
- catch (HttpRequestException) when (attempt < maxRetries)
- {
- int delaySeconds = (int)Math.Pow(2, attempt); /*2s, 4s, 8s*/
- Console.WriteLine($"network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})...");
- Console.Write($"Installing ... ");
- await Task.Delay(delaySeconds * 1000).ConfigureAwait(false);
- }
- }
- /*Final attempt - let exception propagate if it fails*/
- return await client.GetStringAsync(url).ConfigureAwait(false);
- }
-
- /*
- Wait for user input before exiting (prevents window from closing)
- Used for fatal errors where retry doesn't make sense
- */
- private static void WaitForExit()
- {
- Console.WriteLine();
- Console.Write("Press any key to exit...");
- Console.ReadKey(true);
- Console.WriteLine();
- }
-
- /*
- Read password from console, displaying asterisks
- */
- private static string ReadPassword()
- {
- string password = string.Empty;
- ConsoleKeyInfo key;
-
- do
- {
- key = Console.ReadKey(true);
-
- if (key.Key == ConsoleKey.Backspace && password.Length > 0)
- {
- password = password.Substring(0, password.Length - 1);
- Console.Write("\b \b");
- }
- else if (key.Key != ConsoleKey.Enter && !char.IsControl(key.KeyChar))
- {
- password += key.KeyChar;
- Console.Write("*");
- }
- } while (key.Key != ConsoleKey.Enter);
-
- return password;
- }
-
- /*
- Install community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)
- Downloads and installs latest versions in PerformanceMonitor database
- */
- private static async Task InstallDependenciesAsync(string connectionString)
- {
- Console.WriteLine();
- Console.WriteLine("================================================================================");
- Console.WriteLine("Installing community dependencies...");
- Console.WriteLine("================================================================================");
- Console.WriteLine();
-
- var dependencies = new List<(string Name, string Url, string Description)>
- {
- (
- "sp_WhoIsActive",
- "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql",
- "Query activity monitoring by Adam Machanic (GPLv3)"
- ),
- (
- "DarlingData",
- "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql",
- "sp_HealthParser, sp_HumanEventsBlockViewer, and others by Erik Darling (MIT)"
- ),
- (
- "First Responder Kit",
- "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql",
- "sp_BlitzLock and other diagnostic tools by Brent Ozar Unlimited (MIT)"
- )
- };
-
- using var httpClient = new HttpClient();
- httpClient.Timeout = TimeSpan.FromSeconds(30);
-
- int successCount = 0;
- int failureCount = 0;
-
- foreach (var (name, url, description) in dependencies)
- {
- Console.Write($"Installing {name}... ");
-
- try
- {
- /*Download the script with retry for transient failures*/
- string sql = await DownloadWithRetryAsync(httpClient, url).ConfigureAwait(false);
-
- if (string.IsNullOrWhiteSpace(sql))
- {
- Console.WriteLine("✗ FAILED (empty response)");
- failureCount++;
- continue;
- }
-
- /*Execute in PerformanceMonitor database*/
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Switch to PerformanceMonitor database*/
- using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection))
- {
- await useDbCommand.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
-
- /*
- Split by GO statements using pre-compiled regex
- */
- string[] batches = GoBatchPattern.Split(sql);
-
- foreach (string batch in batches)
- {
- string trimmedBatch = batch.Trim();
-
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- using var command = new SqlCommand(trimmedBatch, connection);
- command.CommandTimeout = MediumTimeoutSeconds;
- await command.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
-
- WriteSuccess("Success");
- Console.WriteLine($" {description}");
- successCount++;
- }
- catch (HttpRequestException ex)
- {
- Console.WriteLine($"✗ Download failed: {ex.Message}");
- failureCount++;
- }
- catch (SqlException ex)
- {
- Console.WriteLine($"✗ SQL execution failed: {ex.Message}");
- failureCount++;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"✗ Failed: {ex.Message}");
- failureCount++;
- }
- }
-
- Console.WriteLine();
- Console.WriteLine($"Dependencies installed: {successCount}/{dependencies.Count}");
-
- if (failureCount > 0)
- {
- Console.WriteLine($"Note: {failureCount} dependencies failed to install. The system will work but some");
- Console.WriteLine(" collectors may not function optimally. Check network connectivity and try again.");
- }
- }
-
- /*
- Generate installation summary report file
- Creates a text file with installation details for documentation and troubleshooting
- */
- private static string GenerateSummaryReport(
- string serverName,
- string sqlServerVersion,
- string sqlServerEdition,
- string installerVersion,
- DateTime startTime,
- int filesSucceeded,
- int filesFailed,
- bool overallSuccess,
- List<(string FileName, string ErrorMessage)> errors)
- {
- var endTime = DateTime.Now;
- var duration = endTime - startTime;
-
- /*
- Generate unique filename with timestamp
- */
- string timestamp = startTime.ToString("yyyyMMdd_HHmmss");
- string fileName = $"PerformanceMonitor_Install_{SanitizeFilename(serverName)}_{timestamp}.txt";
- string reportPath = Path.Combine(Directory.GetCurrentDirectory(), fileName);
-
- var sb = new System.Text.StringBuilder();
-
- /*
- Header
- */
- sb.AppendLine("================================================================================");
- sb.AppendLine("Performance Monitor Installation Report");
- sb.AppendLine("================================================================================");
- sb.AppendLine();
-
- /*
- Installation summary
- */
- sb.AppendLine("INSTALLATION SUMMARY");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Status: {(overallSuccess ? "SUCCESS" : "FAILED")}");
- sb.AppendLine($"Start Time: {startTime:yyyy-MM-dd HH:mm:ss}");
- sb.AppendLine($"End Time: {endTime:yyyy-MM-dd HH:mm:ss}");
- sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds");
- sb.AppendLine($"Files Executed: {filesSucceeded}");
- sb.AppendLine($"Files Failed: {filesFailed}");
- sb.AppendLine();
-
- /*
- Server information
- */
- sb.AppendLine("SERVER INFORMATION");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Server Name: {serverName}");
- sb.AppendLine($"SQL Server Edition: {sqlServerEdition}");
- sb.AppendLine();
-
- /*
- Extract version info from @@VERSION (first line only)
- */
- if (!string.IsNullOrEmpty(sqlServerVersion))
- {
- string[] versionLines = sqlServerVersion.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
- if (versionLines.Length > 0)
- {
- sb.AppendLine($"SQL Server Version:");
- foreach (var line in versionLines)
- {
- sb.AppendLine($" {line.Trim()}");
- }
- }
- }
- sb.AppendLine();
-
- /*
- Installer information
- */
- sb.AppendLine("INSTALLER INFORMATION");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Installer Version: {installerVersion}");
- sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}");
- sb.AppendLine($"Machine Name: {Environment.MachineName}");
- sb.AppendLine($"User Name: {Environment.UserName}");
- sb.AppendLine();
-
- /*
- Errors section (if any)
- */
- if (errors.Count > 0)
- {
- sb.AppendLine("ERRORS");
- sb.AppendLine("--------------------------------------------------------------------------------");
- foreach (var (file, error) in errors)
- {
- sb.AppendLine($"File: {file}");
- /*
- Truncate very long error messages
- */
- string errorMsg = error.Length > 500 ? error.Substring(0, 500) + "..." : error;
- sb.AppendLine($"Error: {errorMsg}");
- sb.AppendLine();
- }
- }
-
- /*
- Footer
- */
- sb.AppendLine("================================================================================");
- sb.AppendLine("Generated by Performance Monitor Installer");
- sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC");
- sb.AppendLine("================================================================================");
-
- /*
- Write file
- */
- File.WriteAllText(reportPath, sb.ToString());
-
- return reportPath;
- }
-
- private static void WriteSuccess(string message)
- {
- Console.ForegroundColor = ConsoleColor.Green;
- Console.Write("√ ");
- Console.ResetColor();
- Console.WriteLine(message);
- }
-
- private static void WriteError(string message)
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.Write("✗ ");
- Console.ResetColor();
- Console.WriteLine(message);
- }
-
- private static void WriteWarning(string message)
- {
- Console.ForegroundColor = ConsoleColor.Yellow;
- Console.Write("! ");
- Console.ResetColor();
- Console.WriteLine(message);
- }
-
- private static async Task CheckForInstallerUpdateAsync(string currentVersion)
- {
- try
- {
- using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
- client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor");
- client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
-
- var response = await client.GetAsync(
- "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest")
- .ConfigureAwait(false);
-
- if (!response.IsSuccessStatusCode) return;
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- using var doc = System.Text.Json.JsonDocument.Parse(json);
- var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? "";
- var versionString = tagName.TrimStart('v', 'V');
-
- if (!Version.TryParse(versionString, out var latest)) return;
- if (!Version.TryParse(currentVersion, out var current)) return;
-
- if (latest > current)
- {
- Console.WriteLine();
- Console.ForegroundColor = ConsoleColor.Yellow;
- Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
- Console.WriteLine($"║ A newer version ({tagName}) is available! ");
- Console.WriteLine("║ https://github.com/erikdarlingdata/PerformanceMonitor/releases ");
- Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
- Console.ResetColor();
- Console.WriteLine();
- }
- }
- catch
- {
- /* Best effort — don't block installation if GitHub is unreachable */
- }
- }
-
- [GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
- private static partial Regex GoBatchRegExp();
- }
-}
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using System.Reflection;
+using Installer.Core;
+using Installer.Core.Models;
+
+namespace PerformanceMonitorInstaller
+{
+ class Program
+ {
+ static async Task Main(string[] args)
+ {
+ var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown";
+ var infoVersion = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.InformationalVersion ?? version;
+
+ Console.WriteLine("================================================================================");
+ Console.WriteLine($"Performance Monitor Installation Utility v{infoVersion}");
+ Console.WriteLine("Copyright © 2026 Darling Data, LLC");
+ Console.WriteLine("Licensed under the MIT License");
+ Console.WriteLine("https://github.com/erikdarlingdata/PerformanceMonitor");
+ Console.WriteLine("================================================================================");
+
+ await CheckForInstallerUpdateAsync(version);
+
+
+ /*
+ Determine if running in automated mode (command-line arguments provided)
+ Usage: PerformanceMonitorInstaller.exe [server] [username] [password] [options]
+ If server is provided alone, uses Windows Authentication
+ If server, username, and password are provided, uses SQL Authentication
+
+ Options:
+ --reinstall Drop existing database and perform clean install
+ --encrypt=X Connection encryption: mandatory (default), optional, strict
+ --trust-cert Trust server certificate without validation (default: require valid cert)
+ */
+ if (args.Any(a => a.Equals("--help", StringComparison.OrdinalIgnoreCase)
+ || a.Equals("-h", StringComparison.OrdinalIgnoreCase)))
+ {
+ Console.WriteLine("Usage:");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe Interactive mode");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe [options] Windows Auth");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth (password via env var)");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe --entra Entra ID (MFA)");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" -h, --help Show this help message");
+ Console.WriteLine(" --reinstall Drop existing database and perform clean install");
+ Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions");
+ Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults");
+ Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict");
+ Console.WriteLine(" --trust-cert Trust server certificate without validation");
+ Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)");
+ Console.WriteLine();
+ Console.WriteLine("Environment Variables:");
+ Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)");
+ Console.WriteLine();
+ Console.WriteLine("Exit Codes:");
+ Console.WriteLine(" 0 Success");
+ Console.WriteLine(" 1 Invalid arguments");
+ Console.WriteLine(" 2 Connection failed");
+ Console.WriteLine(" 3 Critical file failed");
+ Console.WriteLine(" 4 Partial installation (non-critical failures)");
+ Console.WriteLine(" 5 Version check failed");
+ Console.WriteLine(" 6 SQL files not found");
+ Console.WriteLine(" 7 Uninstall failed");
+ Console.WriteLine(" 8 Upgrade failed");
+ return 0;
+ }
+
+ bool automatedMode = args.Length > 0;
+ bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase));
+ bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase));
+ bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase));
+ bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase));
+ bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
+
+ /*Parse --entra email (the argument following --entra)*/
+ string? entraEmail = null;
+ if (entraMode)
+ {
+ int entraIndex = Array.FindIndex(args, a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
+ if (entraIndex >= 0 && entraIndex + 1 < args.Length && !args[entraIndex + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ entraEmail = args[entraIndex + 1];
+ }
+ }
+
+ /*Parse encryption option (default: Mandatory)*/
+ var encryptArg = args.FirstOrDefault(a => a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase));
+ string encryptionLevel = "Mandatory";
+ if (encryptArg != null)
+ {
+ string encryptValue = encryptArg.Substring("--encrypt=".Length).ToLowerInvariant();
+ encryptionLevel = encryptValue switch
+ {
+ "optional" => "Optional",
+ "strict" => "Strict",
+ _ => "Mandatory"
+ };
+ }
+
+ /*Filter out option flags and --entra to get positional arguments*/
+ var filteredArgsList = args
+ .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.Equals("--entra", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ /*Remove the entra email from positional args if present*/
+ if (entraEmail != null)
+ {
+ filteredArgsList.Remove(entraEmail);
+ }
+
+ var filteredArgs = filteredArgsList.ToArray();
+ string? serverName;
+ string? username = null;
+ string? password = null;
+ bool useWindowsAuth;
+ bool useEntraAuth = false;
+
+ if (automatedMode)
+ {
+ /*
+ Automated mode with command-line arguments
+ */
+ serverName = filteredArgs.Length > 0 ? filteredArgs[0] : null;
+
+ if (entraMode)
+ {
+ /*Microsoft Entra ID interactive authentication*/
+ useWindowsAuth = false;
+ useEntraAuth = true;
+ username = entraEmail;
+
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ Console.WriteLine("Error: Email address is required for Entra ID authentication.");
+ Console.WriteLine("Usage: PerformanceMonitorInstaller.exe --entra ");
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+
+ Console.WriteLine($"Server: {serverName}");
+ Console.WriteLine($"Authentication: Microsoft Entra ID ({username})");
+ Console.WriteLine("A browser window will open for interactive authentication...");
+ }
+ else if (filteredArgs.Length >= 2)
+ {
+ /*SQL Authentication - password from env var or command-line*/
+ useWindowsAuth = false;
+ username = filteredArgs[1];
+
+ string? envPassword = Environment.GetEnvironmentVariable("PM_SQL_PASSWORD");
+ if (filteredArgs.Length >= 3)
+ {
+ password = filteredArgs[2];
+ if (envPassword == null)
+ {
+ Console.WriteLine("Note: Password provided via command-line is visible in process listings.");
+ Console.WriteLine(" Consider using PM_SQL_PASSWORD environment variable instead.");
+ Console.WriteLine();
+ }
+ }
+ else if (envPassword != null)
+ {
+ password = envPassword;
+ }
+ else
+ {
+ Console.WriteLine("Error: Password is required for SQL Server Authentication.");
+ Console.WriteLine("Provide password as third argument or set PM_SQL_PASSWORD environment variable.");
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+
+ Console.WriteLine($"Server: {serverName}");
+ Console.WriteLine($"Authentication: SQL Server ({username})");
+ }
+ else if (filteredArgs.Length == 1)
+ {
+ /*Windows Authentication*/
+ useWindowsAuth = true;
+ Console.WriteLine($"Server: {serverName}");
+ Console.WriteLine($"Authentication: Windows");
+ }
+ else
+ {
+ Console.WriteLine("Error: Invalid arguments.");
+ Console.WriteLine("Usage:");
+ Console.WriteLine(" Windows Auth: PerformanceMonitorInstaller.exe [options]");
+ Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]");
+ Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]");
+ Console.WriteLine(" (with PM_SQL_PASSWORD environment variable set)");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" --reinstall Drop existing database and perform clean install");
+ Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults");
+ Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict");
+ Console.WriteLine(" --trust-cert Trust server certificate without validation (default: require valid cert)");
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+ }
+ else
+ {
+ /*
+ Interactive mode - prompt for connection information
+ */
+ Console.Write("SQL Server instance (e.g., localhost, SQL2022): ");
+ serverName = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(serverName))
+ {
+ Console.WriteLine("Error: Server name is required.");
+ WaitForExit();
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+
+ Console.WriteLine("Authentication type:");
+ Console.WriteLine(" [W] Windows Authentication (default)");
+ Console.WriteLine(" [S] SQL Server Authentication");
+ Console.WriteLine(" [E] Microsoft Entra ID (interactive MFA)");
+ Console.Write("Choice (W/S/E, default W): ");
+ string? authResponse = Console.ReadLine()?.Trim();
+
+ if (string.IsNullOrWhiteSpace(authResponse) || authResponse.Equals("W", StringComparison.OrdinalIgnoreCase))
+ {
+ useWindowsAuth = true;
+ }
+ else if (authResponse.Equals("E", StringComparison.OrdinalIgnoreCase))
+ {
+ useWindowsAuth = false;
+ useEntraAuth = true;
+
+ Console.Write("Email address (UPN): ");
+ username = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ Console.WriteLine("Error: Email address is required for Entra ID authentication.");
+ WaitForExit();
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+
+ Console.WriteLine("A browser window will open for interactive authentication...");
+ }
+ else
+ {
+ useWindowsAuth = false;
+
+ Console.Write("SQL Server login: ");
+ username = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ Console.WriteLine("Error: Login is required for SQL Server Authentication.");
+ WaitForExit();
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+
+ Console.Write("Password: ");
+ password = ReadPassword();
+ Console.WriteLine();
+
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ Console.WriteLine("Error: Password is required for SQL Server Authentication.");
+ WaitForExit();
+ return (int)InstallationResultCode.InvalidArguments;
+ }
+ }
+ }
+
+ /*
+ Build connection string using Installer.Core
+ */
+ string connectionString = InstallationService.BuildConnectionString(
+ serverName!,
+ useWindowsAuth,
+ username,
+ password,
+ encryptionLevel,
+ trustCert,
+ useEntraAuth);
+
+ /*
+ Test connection and get SQL Server version
+ */
+ string sqlServerVersion = "";
+ string sqlServerEdition = "";
+
+ Console.WriteLine();
+ Console.WriteLine("Testing connection...");
+
+ var serverInfo = await InstallationService.TestConnectionAsync(connectionString).ConfigureAwait(false);
+
+ if (!serverInfo.IsConnected)
+ {
+ WriteError($"Connection failed: {serverInfo.ErrorMessage}");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.ConnectionFailed;
+ }
+
+ WriteSuccess("Connection successful!");
+ sqlServerVersion = serverInfo.SqlServerVersion;
+ sqlServerEdition = serverInfo.SqlServerEdition;
+
+ /*Check minimum SQL Server version -- 2016+ required for on-prem (Standard/Enterprise).
+ Azure MI (EngineEdition 8) is always current, skip the check.*/
+ if (serverInfo.ProductMajorVersion > 0 && !serverInfo.IsSupportedVersion)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"ERROR: {serverInfo.ProductMajorVersionName} is not supported.");
+ Console.WriteLine("Performance Monitor requires SQL Server 2016 (13.x) or later.");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.VersionCheckFailed;
+ }
+
+ /*
+ Handle --uninstall mode (no SQL files needed)
+ */
+ if (uninstallMode)
+ {
+ return await PerformUninstallAsync(connectionString, automatedMode);
+ }
+
+ /*
+ Find SQL files using ScriptProvider.FromDirectory()
+ Search current directory and up to 5 parent directories
+ Prefer install/ subfolder if it exists (new structure)
+ */
+ ScriptProvider? scriptProvider = null;
+ string? sqlDirectory = null;
+ string? monitorRootDirectory = null;
+ string currentDirectory = Directory.GetCurrentDirectory();
+ DirectoryInfo? searchDir = new DirectoryInfo(currentDirectory);
+
+ for (int i = 0; i < 6 && searchDir != null; i++)
+ {
+ /*Check for install/ subfolder first (new structure)*/
+ string installFolder = Path.Combine(searchDir.FullName, "install");
+ if (Directory.Exists(installFolder))
+ {
+ var installFiles = Directory.GetFiles(installFolder, "*.sql")
+ .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f)))
+ .ToList();
+
+ if (installFiles.Count > 0)
+ {
+ sqlDirectory = installFolder;
+ monitorRootDirectory = searchDir.FullName;
+ break;
+ }
+ }
+
+ /*Fall back to old structure (SQL files in root)*/
+ var files = Directory.GetFiles(searchDir.FullName, "*.sql")
+ .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f)))
+ .ToList();
+
+ if (files.Count > 0)
+ {
+ sqlDirectory = searchDir.FullName;
+ monitorRootDirectory = searchDir.FullName;
+ break;
+ }
+
+ searchDir = searchDir.Parent;
+ }
+
+ if (sqlDirectory == null || monitorRootDirectory == null)
+ {
+ Console.WriteLine($"Error: No SQL installation files found.");
+ Console.WriteLine($"Searched in: {currentDirectory}");
+ Console.WriteLine("Expected files in install/ folder or root directory:");
+ Console.WriteLine(" install/01_install_database.sql, install/02_create_tables.sql, etc.");
+ Console.WriteLine();
+ Console.WriteLine("Make sure the installer is in the Monitor directory or a subdirectory.");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.SqlFilesNotFound;
+ }
+
+ scriptProvider = ScriptProvider.FromDirectory(monitorRootDirectory);
+ var sqlFiles = scriptProvider.GetInstallFiles();
+
+ Console.WriteLine();
+ Console.WriteLine($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}");
+ if (monitorRootDirectory != sqlDirectory)
+ {
+ Console.WriteLine($"Using new folder structure (install/ subfolder)");
+ }
+
+ /*
+ Create progress reporter that routes to console helpers
+ */
+ var progress = new Progress(p =>
+ {
+ switch (p.Status)
+ {
+ case "Success":
+ WriteSuccess(p.Message);
+ break;
+ case "Error":
+ WriteError(p.Message);
+ break;
+ case "Warning":
+ WriteWarning(p.Message);
+ break;
+ case "Debug":
+ /*Suppress debug messages in CLI output*/
+ break;
+ default:
+ Console.WriteLine(p.Message);
+ break;
+ }
+ });
+
+ /*
+ Main installation loop - allows retry on failure
+ */
+ int upgradeSuccessCount = 0;
+ int upgradeFailureCount = 0;
+ int installSuccessCount = 0;
+ int installFailureCount = 0;
+ int totalSuccessCount = 0;
+ int totalFailureCount = 0;
+ var installationErrors = new List<(string FileName, string ErrorMessage)>();
+ bool installationSuccessful = false;
+ bool retry;
+ DateTime installationStartTime = DateTime.Now;
+ do
+ {
+ retry = false;
+ upgradeSuccessCount = 0;
+ upgradeFailureCount = 0;
+ installSuccessCount = 0;
+ installFailureCount = 0;
+ installationErrors.Clear();
+ installationSuccessful = false;
+ installationStartTime = DateTime.Now;
+
+ /*
+ Ask about clean install (automated mode preserves database unless --reinstall flag is used)
+ */
+ bool dropExisting;
+ if (automatedMode)
+ {
+ dropExisting = reinstallMode;
+ Console.WriteLine();
+ if (reinstallMode)
+ {
+ Console.WriteLine("Automated mode: Performing clean reinstall (dropping existing database)...");
+ }
+ else
+ {
+ Console.WriteLine("Automated mode: Performing upgrade (preserving existing database)...");
+ }
+ }
+ else
+ {
+ Console.WriteLine();
+ Console.Write("Drop existing PerformanceMonitor database if it exists? (Y/N, default N): ");
+ string? cleanInstall = Console.ReadLine();
+ dropExisting = cleanInstall?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
+ }
+
+ if (dropExisting)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Performing clean install...");
+ try
+ {
+ await InstallationService.CleanInstallAsync(connectionString).ConfigureAwait(false);
+ WriteSuccess("Clean install completed (jobs and database removed)");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: Could not complete cleanup: {ex.Message}");
+ Console.WriteLine("Continuing with installation...");
+ }
+ }
+ else
+ {
+ /*
+ Upgrade mode - check for existing installation and apply upgrades
+ */
+ string? currentVersion = null;
+ try
+ {
+ currentVersion = await InstallationService.GetInstalledVersionAsync(connectionString).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("ERROR: Failed to check for existing installation");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine(ex.Message);
+ if (ex.InnerException != null)
+ {
+ Console.WriteLine($"Details: {ex.InnerException.Message}");
+ }
+ Console.WriteLine();
+ Console.WriteLine("This may indicate a permissions issue or database corruption.");
+ Console.WriteLine("Please review the error log and report this issue if it persists.");
+ Console.WriteLine();
+
+ /*Write error log for bug reporting*/
+ string errorLogPath = WriteErrorLog(ex, serverName!, infoVersion);
+ Console.WriteLine($"Error log written to: {errorLogPath}");
+
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.VersionCheckFailed;
+ }
+
+ if (currentVersion != null)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Existing installation detected: v{currentVersion}");
+ Console.WriteLine("Checking for applicable upgrades...");
+
+ var (upgSuccessCount, upgFailureCount, upgradeCount) =
+ await InstallationService.ExecuteAllUpgradesAsync(
+ scriptProvider,
+ connectionString,
+ currentVersion,
+ version,
+ progress).ConfigureAwait(false);
+
+ upgradeSuccessCount = upgSuccessCount;
+ upgradeFailureCount = upgFailureCount;
+
+ if (upgradeCount > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Upgrades complete: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed");
+
+ /*Abort if any upgrade scripts failed -- proceeding would reinstall over a partially-upgraded database*/
+ if (upgradeFailureCount > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed.");
+ Console.WriteLine("Fix the errors above and re-run the installer.");
+ Console.WriteLine("================================================================================");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.UpgradesFailed;
+ }
+ }
+ else
+ {
+ Console.WriteLine("No pending upgrades found.");
+ }
+ }
+ else
+ {
+ Console.WriteLine();
+ Console.WriteLine("No existing installation detected, proceeding with fresh install...");
+ }
+ }
+
+ /*
+ Execute SQL files in order
+ */
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("Starting installation...");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine();
+
+ /*
+ Execute installation using Installer.Core
+ Use DependencyInstaller for community dependencies before validation
+ */
+ using var dependencyInstaller = new DependencyInstaller();
+
+ var installResult = await InstallationService.ExecuteInstallationAsync(
+ connectionString,
+ scriptProvider,
+ cleanInstall: false, /* Clean install was already handled above if requested */
+ resetSchedule: resetSchedule,
+ progress: new Progress(p =>
+ {
+ switch (p.Status)
+ {
+ case "Success":
+ if (p.Message.EndsWith(" - Success", StringComparison.Ordinal))
+ {
+ /*File success: replicate the original "Executing ... Success" format*/
+ string fileName = p.Message.Replace(" - Success", "", StringComparison.Ordinal);
+ /*The "Executing..." was already printed by the Info message*/
+ WriteSuccess("Success");
+ }
+ else
+ {
+ WriteSuccess(p.Message);
+ }
+ break;
+ case "Error":
+ if (p.Message.Contains(" - FAILED:", StringComparison.Ordinal))
+ {
+ WriteError("FAILED");
+ string errorMsg = p.Message.Substring(p.Message.IndexOf(" - FAILED: ", StringComparison.Ordinal) + 11);
+ Console.WriteLine($" Error: {errorMsg}");
+ }
+ else if (p.Message == "Critical installation file failed. Aborting installation.")
+ {
+ Console.WriteLine();
+ Console.WriteLine(p.Message);
+ }
+ else
+ {
+ WriteError(p.Message);
+ }
+ break;
+ case "Warning":
+ WriteWarning(p.Message);
+ break;
+ case "Info":
+ if (p.Message.StartsWith("Executing ", StringComparison.Ordinal) && p.Message.EndsWith("...", StringComparison.Ordinal))
+ {
+ /*Replicate "Executing ... " format (no newline yet)*/
+ Console.Write(p.Message.Replace("Executing ", "Executing ", StringComparison.Ordinal) + " ");
+ }
+ else if (p.Message == "Resetting schedule to recommended defaults...")
+ {
+ Console.Write("(resetting schedule) ");
+ }
+ else if (p.Message != "Starting installation...")
+ {
+ Console.WriteLine(p.Message);
+ }
+ break;
+ case "Debug":
+ /*Suppress debug messages in CLI output*/
+ break;
+ default:
+ Console.WriteLine(p.Message);
+ break;
+ }
+ }),
+ preValidationAction: async () =>
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("Installing community dependencies...");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine();
+
+ try
+ {
+ await dependencyInstaller.InstallDependenciesAsync(
+ connectionString,
+ new Progress(dp =>
+ {
+ switch (dp.Status)
+ {
+ case "Success":
+ WriteSuccess(dp.Message);
+ break;
+ case "Error":
+ WriteError(dp.Message);
+ break;
+ case "Warning":
+ WriteWarning(dp.Message);
+ break;
+ case "Debug":
+ break;
+ default:
+ Console.WriteLine(dp.Message);
+ break;
+ }
+ })).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}");
+ Console.WriteLine("Continuing with installation...");
+ }
+ }).ConfigureAwait(false);
+
+ installSuccessCount = installResult.FilesSucceeded;
+ installFailureCount = installResult.FilesFailed;
+ installationErrors.AddRange(installResult.Errors);
+
+ /*Check for critical file failure*/
+ if (installResult.FilesFailed > 0 && installResult.Errors.Any(e => Patterns.IsCriticalFile(e.FileName)))
+ {
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.CriticalScriptFailed;
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("File Execution Summary");
+ Console.WriteLine("================================================================================");
+ if (upgradeSuccessCount > 0 || upgradeFailureCount > 0)
+ {
+ Console.WriteLine($"Upgrades: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed");
+ }
+ Console.WriteLine($"Installation: {installSuccessCount} succeeded, {installFailureCount} failed");
+ Console.WriteLine();
+
+ /*
+ Run initial collection and retry failed views
+ This validates the installation and creates dynamically-generated tables
+ */
+ if (installFailureCount <= 1 && automatedMode) /* Allow 1 failure for query_snapshots view */
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("Running initial collection to validate installation...");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine();
+
+ try
+ {
+ Console.Write("Executing master collector... ");
+ var (collectorsSucceeded, collectorsFailed) = await InstallationService.RunValidationAsync(
+ connectionString,
+ new Progress(vp =>
+ {
+ /*Suppress most messages; the method writes detailed results*/
+ if (vp.Status == "Error" && !vp.Message.StartsWith(" ", StringComparison.Ordinal))
+ {
+ WriteError(vp.Message);
+ }
+ })).ConfigureAwait(false);
+
+ WriteSuccess("Success");
+ Console.WriteLine();
+ Console.Write("Verifying data collection... ");
+ Console.WriteLine($"✓ {collectorsSucceeded} collectors ran successfully");
+
+ if (collectorsFailed > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"⚠ {collectorsFailed} collector(s) encountered errors");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"✗ Failed");
+ Console.WriteLine($"Error: {ex.Message}");
+ Console.WriteLine();
+ Console.WriteLine("Installation completed but initial collection failed.");
+ Console.WriteLine("Check PerformanceMonitor.config.collection_log for details.");
+ }
+ }
+
+ /*
+ Installation summary
+ Calculate totals and determine success
+ Treat query_snapshots view failure as a warning, not an error
+ */
+ totalSuccessCount = upgradeSuccessCount + installSuccessCount;
+ totalFailureCount = upgradeFailureCount + installFailureCount;
+ installationSuccessful = totalFailureCount == 0;
+
+ /*
+ Log installation history to database
+ */
+ try
+ {
+ await InstallationService.LogInstallationHistoryAsync(
+ connectionString,
+ version,
+ infoVersion,
+ installationStartTime,
+ totalSuccessCount,
+ totalFailureCount,
+ installationSuccessful
+ ).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Warning: Could not log installation history: {ex.Message}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("Installation Summary");
+ Console.WriteLine("================================================================================");
+
+ if (installationSuccessful)
+ {
+ WriteSuccess("Installation completed successfully!");
+ Console.WriteLine();
+ Console.WriteLine("WHAT WAS INSTALLED:");
+ Console.WriteLine("✓ PerformanceMonitor database and all collection tables");
+ Console.WriteLine("✓ All collector stored procedures");
+ Console.WriteLine("✓ Community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)");
+ Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Collection (runs every 1 minute)");
+ Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Data Retention (runs daily at 2:00 AM)");
+ Console.WriteLine("✓ Initial collection completed successfully");
+
+ Console.WriteLine();
+ Console.WriteLine("NEXT STEPS:");
+ Console.WriteLine("1. Ensure SQL Server Agent service is running");
+ Console.WriteLine("2. Verify installation: SELECT * FROM PerformanceMonitor.report.collection_health;");
+ Console.WriteLine("3. Monitor job history in SQL Server Agent");
+ Console.WriteLine();
+ Console.WriteLine("See README.md for detailed information.");
+ }
+ else
+ {
+ WriteWarning($"Installation completed with {totalFailureCount} error(s).");
+ Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details.");
+ }
+
+ /*
+ Ask if user wants to retry or exit (skip in automated mode)
+ */
+ if (totalFailureCount > 0 && !automatedMode)
+ {
+ retry = PromptRetryOrExit();
+ }
+
+ } while (retry);
+
+ /*
+ Generate installation summary report file
+ */
+ try
+ {
+ var summaryResult = new InstallationResult
+ {
+ Success = installationSuccessful,
+ FilesSucceeded = totalSuccessCount,
+ FilesFailed = totalFailureCount,
+ StartTime = installationStartTime,
+ EndTime = DateTime.Now
+ };
+ foreach (var (fileName, errorMessage) in installationErrors)
+ {
+ summaryResult.Errors.Add((fileName, errorMessage));
+ }
+
+ string reportPath = InstallationService.GenerateSummaryReport(
+ serverName!,
+ sqlServerVersion,
+ sqlServerEdition,
+ infoVersion,
+ summaryResult,
+ outputDirectory: Directory.GetCurrentDirectory());
+
+ Console.WriteLine();
+ Console.WriteLine($"Installation report saved to: {reportPath}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Warning: Could not generate summary report: {ex.Message}");
+ }
+
+ /*
+ Exit message for successful completion or user chose not to retry
+ */
+ if (!automatedMode)
+ {
+ Console.WriteLine();
+ Console.Write("Press any key to exit...");
+ Console.ReadKey(true);
+ Console.WriteLine();
+ }
+
+ return installationSuccessful
+ ? (int)InstallationResultCode.Success
+ : (int)InstallationResultCode.PartialInstallation;
+ }
+
+ /*
+ Ask user if they want to retry or exit
+ Returns true to retry, false to exit
+ */
+ private static bool PromptRetryOrExit()
+ {
+ Console.WriteLine();
+ Console.Write("Y to retry installation, N to exit: ");
+ string? response = Console.ReadLine();
+ return response?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
+ }
+
+ ///
+ /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database.
+ ///
+ private static async Task PerformUninstallAsync(string connectionString, bool automatedMode)
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("UNINSTALL MODE");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine();
+
+ if (!automatedMode)
+ {
+ Console.WriteLine("This will remove:");
+ Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)");
+ Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)");
+ Console.WriteLine(" - Server-side traces");
+ Console.WriteLine(" - PerformanceMonitor database and ALL collected data");
+ Console.WriteLine();
+ Console.Write("Are you sure you want to continue? (Y/N, default N): ");
+ string? confirm = Console.ReadLine();
+ if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true)
+ {
+ Console.WriteLine("Uninstall cancelled.");
+ WaitForExit();
+ return (int)InstallationResultCode.Success;
+ }
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Uninstalling Performance Monitor...");
+
+ try
+ {
+ await InstallationService.ExecuteUninstallAsync(
+ connectionString,
+ new Progress(p =>
+ {
+ switch (p.Status)
+ {
+ case "Success":
+ WriteSuccess(p.Message);
+ break;
+ case "Error":
+ WriteError(p.Message);
+ break;
+ case "Warning":
+ WriteWarning(p.Message);
+ break;
+ case "Info":
+ Console.WriteLine(p.Message);
+ break;
+ case "Debug":
+ break;
+ default:
+ Console.WriteLine(p.Message);
+ break;
+ }
+ })).ConfigureAwait(false);
+
+ Console.WriteLine();
+ WriteSuccess("Uninstall completed successfully");
+ Console.WriteLine();
+ Console.WriteLine("Note: blocked process threshold (s) was NOT reset.");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Uninstall failed: {ex.Message}");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.UninstallFailed;
+ }
+
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return (int)InstallationResultCode.Success;
+ }
+
+ /*
+ Write error log file for bug reporting
+ Returns the path to the log file
+ */
+ private static string WriteErrorLog(Exception ex, string serverName, string installerVersion)
+ {
+ string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ string sanitizedServer = SanitizeFilename(serverName);
+ string fileName = $"PerformanceMonitor_Error_{sanitizedServer}_{timestamp}.log";
+ string logPath = Path.Combine(Directory.GetCurrentDirectory(), fileName);
+
+ var sb = new System.Text.StringBuilder();
+
+ sb.AppendLine("================================================================================");
+ sb.AppendLine("Performance Monitor Installer - Error Log");
+ sb.AppendLine("================================================================================");
+ sb.AppendLine();
+ sb.AppendLine($"Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
+ sb.AppendLine($"Installer Version: {installerVersion}");
+ sb.AppendLine($"Server: {serverName}");
+ sb.AppendLine($"Machine: {Environment.MachineName}");
+ sb.AppendLine($"User: {Environment.UserName}");
+ sb.AppendLine($"OS: {Environment.OSVersion}");
+ sb.AppendLine($".NET Version: {Environment.Version}");
+ sb.AppendLine();
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine("ERROR DETAILS");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine($"Type: {ex.GetType().FullName}");
+ sb.AppendLine($"Message: {ex.Message}");
+ sb.AppendLine();
+
+ if (ex.InnerException != null)
+ {
+ sb.AppendLine("Inner Exception:");
+ sb.AppendLine($" Type: {ex.InnerException.GetType().FullName}");
+ sb.AppendLine($" Message: {ex.InnerException.Message}");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("Stack Trace:");
+ sb.AppendLine(ex.StackTrace ?? "(not available)");
+ sb.AppendLine();
+
+ if (ex.InnerException?.StackTrace != null)
+ {
+ sb.AppendLine("Inner Exception Stack Trace:");
+ sb.AppendLine(ex.InnerException.StackTrace);
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("================================================================================");
+ sb.AppendLine("Please include this file when reporting issues at:");
+ sb.AppendLine("https://github.com/erikdarlingdata/PerformanceMonitor/issues");
+ sb.AppendLine("================================================================================");
+
+ File.WriteAllText(logPath, sb.ToString());
+
+ return logPath;
+ }
+
+ /*
+ Sanitize a string for use in a filename
+ Replaces invalid characters with underscores
+ */
+ private static string SanitizeFilename(string input)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ return string.Concat(input.Select(c => invalid.Contains(c) ? '_' : c));
+ }
+
+ /*
+ Wait for user input before exiting (prevents window from closing)
+ Used for fatal errors where retry doesn't make sense
+ */
+ private static void WaitForExit()
+ {
+ Console.WriteLine();
+ Console.Write("Press any key to exit...");
+ Console.ReadKey(true);
+ Console.WriteLine();
+ }
+
+ /*
+ Read password from console, displaying asterisks
+ */
+ private static string ReadPassword()
+ {
+ string password = string.Empty;
+ ConsoleKeyInfo key;
+
+ do
+ {
+ key = Console.ReadKey(true);
+
+ if (key.Key == ConsoleKey.Backspace && password.Length > 0)
+ {
+ password = password.Substring(0, password.Length - 1);
+ Console.Write("\b \b");
+ }
+ else if (key.Key != ConsoleKey.Enter && !char.IsControl(key.KeyChar))
+ {
+ password += key.KeyChar;
+ Console.Write("*");
+ }
+ } while (key.Key != ConsoleKey.Enter);
+
+ return password;
+ }
+
+ private static void WriteSuccess(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.Write("√ ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static void WriteError(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Write("✗ ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static void WriteWarning(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write("! ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static async Task CheckForInstallerUpdateAsync(string currentVersion)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor");
+ client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
+
+ var response = await client.GetAsync(
+ "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest")
+ .ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode) return;
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
+ var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? "";
+ var versionString = tagName.TrimStart('v', 'V');
+
+ if (!Version.TryParse(versionString, out var latest)) return;
+ if (!Version.TryParse(currentVersion, out var current)) return;
+
+ if (latest > current)
+ {
+ Console.WriteLine();
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
+ Console.WriteLine($"║ A newer version ({tagName}) is available! ");
+ Console.WriteLine("║ https://github.com/erikdarlingdata/PerformanceMonitor/releases ");
+ Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+ Console.WriteLine();
+ }
+ }
+ catch
+ {
+ /* Best effort — don't block installation if GitHub is unreachable */
+ }
+ }
+ }
+}
diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj
index c063f11..61d0342 100644
--- a/InstallerGui/InstallerGui.csproj
+++ b/InstallerGui/InstallerGui.csproj
@@ -30,6 +30,10 @@
+
+
+
+
PreserveNewest
diff --git a/InstallerGui/MainWindow.xaml.cs b/InstallerGui/MainWindow.xaml.cs
index c7d80b7..6103e94 100644
--- a/InstallerGui/MainWindow.xaml.cs
+++ b/InstallerGui/MainWindow.xaml.cs
@@ -15,19 +15,18 @@
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
-using PerformanceMonitorInstallerGui.Services;
+using Installer.Core;
+using Installer.Core.Models;
using PerformanceMonitorInstallerGui.Utilities;
namespace PerformanceMonitorInstallerGui
{
public partial class MainWindow : Window
{
- private readonly InstallationService _installationService;
+ private readonly DependencyInstaller _dependencyInstaller;
private CancellationTokenSource? _cancellationTokenSource;
private string? _connectionString;
- private string? _sqlDirectory;
- private string? _monitorRootDirectory;
- private List? _sqlFiles;
+ private ScriptProvider? _scriptProvider;
private ServerInfo? _serverInfo;
private InstallationResult? _installationResult;
private string? _installedVersion;
@@ -61,7 +60,7 @@ public MainWindow()
try
{
InitializeComponent();
- _installationService = new InstallationService();
+ _dependencyInstaller = new DependencyInstaller();
/*Set window title with version*/
Title = $"Performance Monitor Installer v{AppVersion}";
@@ -108,20 +107,18 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
///
private void FindInstallationFiles()
{
- var (sqlDirectory, monitorRootDirectory, sqlFiles) = InstallationService.FindInstallationFiles();
+ _scriptProvider = ScriptProvider.AutoDiscover();
+ var scriptFiles = _scriptProvider.GetInstallFiles();
- _sqlDirectory = sqlDirectory;
- _monitorRootDirectory = monitorRootDirectory;
- _sqlFiles = sqlFiles;
-
- if (sqlDirectory != null)
+ if (scriptFiles.Count > 0)
{
- LogMessage($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}", "Info");
+ LogMessage($"Found {scriptFiles.Count} SQL installation files", "Info");
}
else
{
LogMessage("WARNING: No SQL installation files found.", "Warning");
LogMessage("Make sure the installer is in the Monitor directory or a subdirectory.", "Warning");
+ _scriptProvider = null;
InstallButton.IsEnabled = false;
MessageBox.Show(this,
@@ -290,10 +287,9 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
LogMessage($"Installed version: {_installedVersion}", "Info");
/*Check for applicable upgrades*/
- if (_monitorRootDirectory != null)
+ if (_scriptProvider != null)
{
- var upgrades = InstallationService.GetApplicableUpgrades(
- _monitorRootDirectory,
+ var upgrades = _scriptProvider.GetApplicableUpgrades(
_installedVersion,
AppAssemblyVersion);
if (upgrades.Count > 0)
@@ -311,7 +307,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
LogMessage("No existing installation detected (clean install)", "Info");
}
- InstallButton.IsEnabled = _sqlFiles != null && _sqlFiles.Count > 0;
+ InstallButton.IsEnabled = _scriptProvider != null;
UninstallButton.IsEnabled = _installedVersion != null;
/*Show confirmation MessageBox*/
@@ -353,7 +349,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
///
private async void Install_Click(object sender, RoutedEventArgs e)
{
- if (_connectionString == null || _sqlFiles == null || _sqlDirectory == null)
+ if (_connectionString == null || _scriptProvider == null)
{
MessageBox.Show(this, "Please test the connection first.", "Validation Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
@@ -407,10 +403,10 @@ private async void Install_Click(object sender, RoutedEventArgs e)
Execute upgrades if applicable (only when not doing clean install)
*/
bool isCleanInstall = CleanInstallCheckBox.IsChecked == true;
- if (!isCleanInstall && _installedVersion != null && _monitorRootDirectory != null)
+ if (!isCleanInstall && _installedVersion != null && _scriptProvider != null)
{
var (upgradeSuccess, upgradeFailure, upgradeCount) = await InstallationService.ExecuteAllUpgradesAsync(
- _monitorRootDirectory,
+ _scriptProvider,
_connectionString,
_installedVersion,
AppAssemblyVersion,
@@ -443,13 +439,13 @@ Community dependencies install automatically before validation (98_validate)
bool resetSchedule = ResetScheduleCheckBox.IsChecked == true;
_installationResult = await InstallationService.ExecuteInstallationAsync(
_connectionString,
- _sqlFiles,
+ _scriptProvider,
isCleanInstall,
resetSchedule,
progress,
preValidationAction: async () =>
{
- await _installationService.InstallDependenciesAsync(
+ await _dependencyInstaller.InstallDependenciesAsync(
_connectionString,
progress,
cancellationToken);
@@ -685,7 +681,7 @@ private async void Uninstall_Click(object sender, RoutedEventArgs e)
///
private async void Troubleshoot_Click(object sender, RoutedEventArgs e)
{
- if (_connectionString == null || _sqlDirectory == null)
+ if (_connectionString == null || _scriptProvider == null)
{
return;
}
@@ -708,7 +704,7 @@ private async void Troubleshoot_Click(object sender, RoutedEventArgs e)
{
bool success = await InstallationService.RunTroubleshootingAsync(
_connectionString,
- _sqlDirectory,
+ _scriptProvider,
progress,
cancellationToken);
@@ -773,7 +769,7 @@ private void Close_Click(object sender, RoutedEventArgs e)
protected override void OnClosed(EventArgs e)
{
_cancellationTokenSource?.Dispose();
- _installationService?.Dispose();
+ _dependencyInstaller?.Dispose();
base.OnClosed(e);
}
diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs
deleted file mode 100644
index 0f0c91a..0000000
--- a/InstallerGui/Services/InstallationService.cs
+++ /dev/null
@@ -1,1552 +0,0 @@
-/*
- * Copyright (c) 2026 Erik Darling, Darling Data LLC
- *
- * This file is part of the SQL Server Performance Monitor.
- *
- * Licensed under the MIT License. See LICENSE file in the project root for full license information.
- */
-
-using System;
-using System.Collections.Generic;
-using System.Data;
-using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Data.SqlClient;
-
-namespace PerformanceMonitorInstallerGui.Services
-{
- ///
- /// Progress information for installation steps
- ///
- public class InstallationProgress
- {
- public string Message { get; set; } = string.Empty;
- public string Status { get; set; } = "Info"; // Info, Success, Error, Warning
- public int? CurrentStep { get; set; }
- public int? TotalSteps { get; set; }
- public int? ProgressPercent { get; set; }
- }
-
- ///
- /// Server information returned from connection test
- ///
- public class ServerInfo
- {
- public string ServerName { get; set; } = string.Empty;
- public string SqlServerVersion { get; set; } = string.Empty;
- public string SqlServerEdition { get; set; } = string.Empty;
- public bool IsConnected { get; set; }
- public string? ErrorMessage { get; set; }
- public int EngineEdition { get; set; }
- public int ProductMajorVersion { get; set; }
-
- ///
- /// Returns true if the SQL Server version is supported (2016+).
- /// Only checked for on-prem Standard (2) and Enterprise (3).
- /// Azure MI (8) is always current and skips the check.
- ///
- public bool IsSupportedVersion =>
- EngineEdition is 8 || ProductMajorVersion >= 13;
-
- ///
- /// Human-readable version name for error messages.
- ///
- public string ProductMajorVersionName => ProductMajorVersion switch
- {
- 11 => "SQL Server 2012",
- 12 => "SQL Server 2014",
- 13 => "SQL Server 2016",
- 14 => "SQL Server 2017",
- 15 => "SQL Server 2019",
- 16 => "SQL Server 2022",
- 17 => "SQL Server 2025",
- _ => $"SQL Server (version {ProductMajorVersion})"
- };
- }
-
- ///
- /// Installation result summary
- ///
- public class InstallationResult
- {
- public bool Success { get; set; }
- public int FilesSucceeded { get; set; }
- public int FilesFailed { get; set; }
- public List<(string FileName, string ErrorMessage)> Errors { get; } = new();
- public List<(string Message, string Status)> LogMessages { get; } = new();
- public DateTime StartTime { get; set; }
- public DateTime EndTime { get; set; }
- public string? ReportPath { get; set; }
- }
-
- ///
- /// Service for installing the Performance Monitor database
- ///
- public partial class InstallationService : IDisposable
- {
- private readonly HttpClient _httpClient;
- private bool _disposed;
-
- /*
- Compiled regex patterns for better performance
- */
- private static readonly Regex SqlFilePattern = SqlFileRegExp();
-
- private static readonly Regex SqlCmdDirectivePattern = new(
- @"^:r\s+.*$",
- RegexOptions.Compiled | RegexOptions.Multiline);
-
- private static readonly Regex GoBatchSplitter = new(
- @"^\s*GO\s*(?:--[^\r\n]*)?\s*$",
- RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
-
- private static readonly char[] NewLineChars = { '\r', '\n' };
-
- public InstallationService()
- {
- _httpClient = new HttpClient
- {
- Timeout = TimeSpan.FromSeconds(30)
- };
- }
-
- ///
- /// Build a connection string from the provided parameters
- ///
- public static string BuildConnectionString(
- string server,
- bool useWindowsAuth,
- string? username = null,
- string? password = null,
- string encryption = "Mandatory",
- bool trustCertificate = false,
- bool useEntraAuth = false)
- {
- var builder = new SqlConnectionStringBuilder
- {
- DataSource = server,
- InitialCatalog = "master",
- TrustServerCertificate = trustCertificate
- };
-
- /*Set encryption mode: Optional, Mandatory, or Strict*/
- builder.Encrypt = encryption switch
- {
- "Optional" => SqlConnectionEncryptOption.Optional,
- "Mandatory" => SqlConnectionEncryptOption.Mandatory,
- "Strict" => SqlConnectionEncryptOption.Strict,
- _ => SqlConnectionEncryptOption.Mandatory
- };
-
- if (useEntraAuth)
- {
- builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
- builder.UserID = username;
- }
- else if (useWindowsAuth)
- {
- builder.IntegratedSecurity = true;
- }
- else
- {
- builder.UserID = username;
- builder.Password = password;
- }
-
- return builder.ConnectionString;
- }
-
- ///
- /// Test connection to SQL Server and get server information
- ///
- public static async Task TestConnectionAsync(string connectionString, CancellationToken cancellationToken = default)
- {
- var info = new ServerInfo();
-
- try
- {
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- info.IsConnected = true;
-
- using var command = new SqlCommand(@"
- SELECT
- @@VERSION,
- SERVERPROPERTY('Edition'),
- @@SERVERNAME,
- CONVERT(int, SERVERPROPERTY('EngineEdition')),
- SERVERPROPERTY('ProductMajorVersion');", connection);
- using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
-
- if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- info.SqlServerVersion = reader.GetString(0);
- info.SqlServerEdition = reader.GetString(1);
- info.ServerName = reader.GetString(2);
- info.EngineEdition = reader.IsDBNull(3) ? 0 : reader.GetInt32(3);
- info.ProductMajorVersion = reader.IsDBNull(4) ? 0 : int.TryParse(reader.GetValue(4).ToString(), out var v) ? v : 0;
- }
- }
- catch (Exception ex)
- {
- info.IsConnected = false;
- info.ErrorMessage = ex.Message;
- if (ex.InnerException != null)
- {
- info.ErrorMessage += $"\n{ex.InnerException.Message}";
- }
- }
-
- return info;
- }
-
- ///
- /// Find SQL installation files
- ///
- public static (string? SqlDirectory, string? MonitorRootDirectory, List SqlFiles) FindInstallationFiles()
- {
- string? sqlDirectory = null;
- string? monitorRootDirectory = null;
- var sqlFiles = new List();
-
- /*Try multiple starting locations: current directory and executable location*/
- var startingDirectories = new List
- {
- Directory.GetCurrentDirectory(),
- AppDomain.CurrentDomain.BaseDirectory
- };
-
- foreach (string startDir in startingDirectories.Distinct())
- {
- if (sqlDirectory != null)
- break;
-
- DirectoryInfo? searchDir = new DirectoryInfo(startDir);
-
- for (int i = 0; i < 6 && searchDir != null; i++)
- {
- /*Check for install/ subfolder first (new structure)*/
- string installFolder = Path.Combine(searchDir.FullName, "install");
- if (Directory.Exists(installFolder))
- {
- var installFiles = Directory.GetFiles(installFolder, "*.sql")
- .Where(f => SqlFilePattern.IsMatch(Path.GetFileName(f)))
- .ToList();
-
- if (installFiles.Count > 0)
- {
- sqlDirectory = installFolder;
- monitorRootDirectory = searchDir.FullName;
- break;
- }
- }
-
- /*Fall back to old structure (SQL files in root)*/
- var files = Directory.GetFiles(searchDir.FullName, "*.sql")
- .Where(f => SqlFilePattern.IsMatch(Path.GetFileName(f)))
- .ToList();
-
- if (files.Count > 0)
- {
- sqlDirectory = searchDir.FullName;
- monitorRootDirectory = searchDir.FullName;
- break;
- }
-
- searchDir = searchDir.Parent;
- }
- }
-
- if (sqlDirectory != null)
- {
- sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql")
- .Where(f =>
- {
- string fileName = Path.GetFileName(f);
- /*Match numbered SQL files but exclude 97 (tests) and 99 (troubleshooting)*/
- if (!SqlFilePattern.IsMatch(fileName))
- return false;
- /*Exclude uninstall, test, and troubleshooting scripts from main install*/
- if (fileName.StartsWith("00_", StringComparison.Ordinal) ||
- fileName.StartsWith("97_", StringComparison.Ordinal) ||
- fileName.StartsWith("99_", StringComparison.Ordinal))
- return false;
- return true;
- })
- .OrderBy(f => Path.GetFileName(f))
- .ToList();
- }
-
- return (sqlDirectory, monitorRootDirectory, sqlFiles);
- }
-
- ///
- /// Perform clean install (drop existing database and jobs)
- ///
- public static async Task CleanInstallAsync(
- string connectionString,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- progress?.Report(new InstallationProgress
- {
- Message = "Performing clean install...",
- Status = "Info"
- });
-
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*
- Stop any existing traces before dropping database
- */
- try
- {
- using var traceCmd = new SqlCommand(
- "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
- connection);
- traceCmd.CommandTimeout = 60;
- await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- progress?.Report(new InstallationProgress
- {
- Message = "Stopped existing traces",
- Status = "Success"
- });
- }
- catch (SqlException)
- {
- /*Database or procedure doesn't exist - no traces to clean*/
- }
-
- /*
- Remove Agent jobs, XE sessions, and database
- */
- string cleanupSql = @"
-USE msdb;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1;
-END;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1;
-END;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1;
-END;
-
-USE master;
-
-IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
-BEGIN
- IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
- ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP;
- DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;
-END;
-
-IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock')
-BEGIN
- IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock')
- ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP;
- DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;
-END;
-
-IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
-BEGIN
- ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
- DROP DATABASE PerformanceMonitor;
-END;";
-
- using var command = new SqlCommand(cleanupSql, connection);
- command.CommandTimeout = 60;
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- progress?.Report(new InstallationProgress
- {
- Message = "Clean install completed (jobs, XE sessions, and database removed)",
- Status = "Success"
- });
- }
-
- ///
- /// Perform complete uninstall (remove database, jobs, XE sessions, and traces)
- ///
- public static async Task ExecuteUninstallAsync(
- string connectionString,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- progress?.Report(new InstallationProgress
- {
- Message = "Uninstalling Performance Monitor...",
- Status = "Info"
- });
-
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*
- Stop existing traces before dropping database
- */
- try
- {
- using var traceCmd = new SqlCommand(
- "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
- connection);
- traceCmd.CommandTimeout = 60;
- await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- progress?.Report(new InstallationProgress
- {
- Message = "Stopped server-side traces",
- Status = "Success"
- });
- }
- catch (SqlException)
- {
- progress?.Report(new InstallationProgress
- {
- Message = "No traces to stop (database or procedure not found)",
- Status = "Info"
- });
- }
-
- /*
- Remove Agent jobs, XE sessions, and database
- */
- await CleanInstallAsync(connectionString, progress, cancellationToken)
- .ConfigureAwait(false);
-
- progress?.Report(new InstallationProgress
- {
- Message = "Uninstall completed successfully",
- Status = "Success",
- ProgressPercent = 100
- });
-
- return true;
- }
-
- ///
- /// Execute SQL installation files
- ///
- public static async Task ExecuteInstallationAsync(
- string connectionString,
- List sqlFiles,
- bool cleanInstall,
- bool resetSchedule = false,
- IProgress? progress = null,
- Func? preValidationAction = null,
- CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(sqlFiles);
-
- var result = new InstallationResult
- {
- StartTime = DateTime.Now
- };
-
- /*
- Perform clean install if requested
- */
- if (cleanInstall)
- {
- try
- {
- await CleanInstallAsync(connectionString, progress, cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"CLEAN INSTALL FAILED: {ex.Message}",
- Status = "Error"
- });
- progress?.Report(new InstallationProgress
- {
- Message = "Installation aborted - clean install was requested but failed.",
- Status = "Error"
- });
- result.EndTime = DateTime.Now;
- result.Success = false;
- result.FilesFailed = 1;
- result.Errors.Add(("Clean Install", ex.Message));
- return result;
- }
- }
-
- /*
- Execute SQL files
- Note: Files execute without transaction wrapping because many contain DDL.
- If installation fails mid-way, use clean install to reset and retry.
- */
- progress?.Report(new InstallationProgress
- {
- Message = "Starting installation...",
- Status = "Info",
- CurrentStep = 0,
- TotalSteps = sqlFiles.Count,
- ProgressPercent = 0
- });
-
- bool preValidationActionRan = false;
-
- for (int i = 0; i < sqlFiles.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- string sqlFile = sqlFiles[i];
- string fileName = Path.GetFileName(sqlFile);
-
- /*Install community dependencies before validation runs
- Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/
- if (!preValidationActionRan &&
- preValidationAction != null &&
- fileName.StartsWith("98_", StringComparison.Ordinal))
- {
- preValidationActionRan = true;
- await preValidationAction().ConfigureAwait(false);
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Executing {fileName}...",
- Status = "Info",
- CurrentStep = i + 1,
- TotalSteps = sqlFiles.Count,
- ProgressPercent = (int)(((i + 1) / (double)sqlFiles.Count) * 100)
- });
-
- try
- {
- string sqlContent = await File.ReadAllTextAsync(sqlFile, cancellationToken).ConfigureAwait(false);
-
- /*Reset schedule to defaults if requested*/
- if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal))
- {
- sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent;
- progress?.Report(new InstallationProgress
- {
- Message = "Resetting schedule to recommended defaults...",
- Status = "Info"
- });
- }
-
- /*Remove SQLCMD directives*/
- sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, "");
-
- /*Execute the SQL batch*/
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*Split by GO statements*/
- string[] batches = GoBatchSplitter.Split(sqlContent);
-
- int batchNumber = 0;
- foreach (string batch in batches)
- {
- string trimmedBatch = batch.Trim();
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- batchNumber++;
-
- using var command = new SqlCommand(trimmedBatch, connection);
- command.CommandTimeout = 300;
-
- try
- {
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
- catch (SqlException ex)
- {
- string batchPreview = trimmedBatch.Length > 500
- ? trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]"
- : trimmedBatch;
- throw new InvalidOperationException(
- $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
- }
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"{fileName} - Success",
- Status = "Success",
- CurrentStep = i + 1,
- TotalSteps = sqlFiles.Count,
- ProgressPercent = (int)(((i + 1) / (double)sqlFiles.Count) * 100)
- });
-
- result.FilesSucceeded++;
- }
- catch (Exception ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"{fileName} - FAILED: {ex.Message}",
- Status = "Error",
- CurrentStep = i + 1,
- TotalSteps = sqlFiles.Count
- });
-
- result.FilesFailed++;
- result.Errors.Add((fileName, ex.Message));
-
- /*Critical files abort installation*/
- if (fileName.StartsWith("01_", StringComparison.Ordinal) ||
- fileName.StartsWith("02_", StringComparison.Ordinal) ||
- fileName.StartsWith("03_", StringComparison.Ordinal))
- {
- progress?.Report(new InstallationProgress
- {
- Message = "Critical installation file failed. Aborting installation.",
- Status = "Error"
- });
- break;
- }
- }
- }
-
- result.EndTime = DateTime.Now;
-
- result.Success = result.FilesFailed == 0;
-
- return result;
- }
-
- ///
- /// Install community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)
- ///
- public async Task InstallDependenciesAsync(
- string connectionString,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- var dependencies = new List<(string Name, string Url, string Description)>
- {
- (
- "sp_WhoIsActive",
- "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql",
- "Query activity monitoring by Adam Machanic (GPLv3)"
- ),
- (
- "DarlingData",
- "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql",
- "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)"
- ),
- (
- "First Responder Kit",
- "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql",
- "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)"
- )
- };
-
- progress?.Report(new InstallationProgress
- {
- Message = "Installing community dependencies...",
- Status = "Info"
- });
-
- int successCount = 0;
-
- foreach (var (name, url, description) in dependencies)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Installing {name}...",
- Status = "Info"
- });
-
- try
- {
- string sql = await DownloadWithRetryAsync(_httpClient, url, progress, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- if (string.IsNullOrWhiteSpace(sql))
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"{name} - FAILED (empty response)",
- Status = "Error"
- });
- continue;
- }
-
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection))
- {
- await useDbCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- string[] batches = GoBatchSplitter.Split(sql);
-
- foreach (string batch in batches)
- {
- string trimmedBatch = batch.Trim();
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- using var command = new SqlCommand(trimmedBatch, connection);
- command.CommandTimeout = 120;
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"{name} - Success ({description})",
- Status = "Success"
- });
-
- successCount++;
- }
- catch (HttpRequestException ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"{name} - Download failed: {ex.Message}",
- Status = "Error"
- });
- }
- catch (SqlException ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"{name} - SQL execution failed: {ex.Message}",
- Status = "Error"
- });
- }
- catch (Exception ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"{name} - Failed: {ex.Message}",
- Status = "Error"
- });
- }
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Dependencies installed: {successCount}/{dependencies.Count}",
- Status = successCount == dependencies.Count ? "Success" : "Warning"
- });
-
- return successCount;
- }
-
- ///
- /// Run validation (master collector) after installation
- ///
- public static async Task<(int CollectorsSucceeded, int CollectorsFailed)> RunValidationAsync(
- string connectionString,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- progress?.Report(new InstallationProgress
- {
- Message = "Running initial collection to validate installation...",
- Status = "Info"
- });
-
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*Capture timestamp before running so we only check errors from this run.
- Use SYSDATETIME() (local) because collection_time is stored in server local time.*/
- DateTime validationStart;
- using (var command = new SqlCommand("SELECT SYSDATETIME();", connection))
- {
- validationStart = (DateTime)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!;
- }
-
- /*Run master collector with @force_run_all*/
- progress?.Report(new InstallationProgress
- {
- Message = "Executing master collector...",
- Status = "Info"
- });
-
- using (var command = new SqlCommand(
- "EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;",
- connection))
- {
- command.CommandTimeout = 300;
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = "Master collector completed",
- Status = "Success"
- });
-
- /*Check results — only from this validation run, not historical errors*/
- int successCount = 0;
- int errorCount = 0;
-
- using (var command = new SqlCommand(@"
- SELECT
- success_count = COUNT_BIG(DISTINCT CASE WHEN collection_status = 'SUCCESS' THEN collector_name END),
- error_count = SUM(CASE WHEN collection_status = 'ERROR' THEN 1 ELSE 0 END)
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_time >= @validation_start;", connection))
- {
- command.Parameters.AddWithValue("@validation_start", validationStart);
- using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
- if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- successCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0);
- errorCount = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
- }
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Validation complete: {successCount} collectors succeeded, {errorCount} failed",
- Status = errorCount == 0 ? "Success" : "Warning"
- });
-
- /*Show failed collectors if any*/
- if (errorCount > 0)
- {
- using var command = new SqlCommand(@"
- SELECT collector_name, error_message
- FROM PerformanceMonitor.config.collection_log
- WHERE collection_status = 'ERROR'
- AND collection_time >= @validation_start
- ORDER BY collection_time DESC;", connection);
- command.Parameters.AddWithValue("@validation_start", validationStart);
-
- using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
- while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- string name = reader["collector_name"]?.ToString() ?? "";
- string error = reader["error_message"] == DBNull.Value
- ? "(no error message)"
- : reader["error_message"]?.ToString() ?? "";
-
- progress?.Report(new InstallationProgress
- {
- Message = $" {name}: {error}",
- Status = "Error"
- });
- }
- }
-
- return (successCount, errorCount);
- }
-
- ///
- /// Run installation verification diagnostics using 99_installer_troubleshooting.sql
- ///
- public static async Task RunTroubleshootingAsync(
- string connectionString,
- string sqlDirectory,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- bool hasErrors = false;
-
- try
- {
- /*Find the troubleshooting script*/
- string scriptPath = Path.Combine(sqlDirectory, "99_installer_troubleshooting.sql");
- if (!File.Exists(scriptPath))
- {
- /*Try parent directory (install folder might be one level up)*/
- string? parentDir = Directory.GetParent(sqlDirectory)?.FullName;
- if (parentDir != null)
- {
- string altPath = Path.Combine(parentDir, "install", "99_installer_troubleshooting.sql");
- if (File.Exists(altPath))
- scriptPath = altPath;
- }
- }
-
- if (!File.Exists(scriptPath))
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"Troubleshooting script not found: 99_installer_troubleshooting.sql",
- Status = "Error"
- });
- return false;
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = "Running installation diagnostics...",
- Status = "Info"
- });
-
- /*Read and prepare the script*/
- string scriptContent = await File.ReadAllTextAsync(scriptPath, cancellationToken).ConfigureAwait(false);
-
- /*Remove SQLCMD directives*/
- scriptContent = SqlCmdDirectivePattern.Replace(scriptContent, string.Empty);
-
- /*Split into batches*/
- var batches = GoBatchSplitter.Split(scriptContent)
- .Where(b => !string.IsNullOrWhiteSpace(b))
- .ToList();
-
- /*Connect to master first (script will USE PerformanceMonitor)*/
- using var connection = new SqlConnection(connectionString);
-
- /*Capture PRINT messages and determine status*/
- connection.InfoMessage += (sender, e) =>
- {
- string message = e.Message;
-
- /*Determine status based on message content*/
- string status = "Info";
- if (message.Contains("[OK]", StringComparison.OrdinalIgnoreCase))
- status = "Success";
- else if (message.Contains("[WARN]", StringComparison.OrdinalIgnoreCase))
- {
- status = "Warning";
- }
- else if (message.Contains("[ERROR]", StringComparison.OrdinalIgnoreCase))
- {
- status = "Error";
- hasErrors = true;
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = message,
- Status = status
- });
- };
-
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*Execute each batch*/
- foreach (var batch in batches)
- {
- if (string.IsNullOrWhiteSpace(batch))
- continue;
-
- cancellationToken.ThrowIfCancellationRequested();
-
- using var cmd = new SqlCommand(batch, connection)
- {
- CommandTimeout = 120
- };
-
- try
- {
- await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
- catch (SqlException ex)
- {
- /*Report SQL errors but continue with remaining batches*/
- progress?.Report(new InstallationProgress
- {
- Message = $"SQL Error: {ex.Message}",
- Status = "Error"
- });
- hasErrors = true;
- }
-
- /*Small delay to allow UI to process messages*/
- await Task.Delay(25, cancellationToken).ConfigureAwait(false);
- }
-
- return !hasErrors;
- }
- catch (Exception ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $"Diagnostics failed: {ex.Message}",
- Status = "Error"
- });
- return false;
- }
- }
-
- ///
- /// Generate installation summary report file
- ///
- public static string GenerateSummaryReport(
- string serverName,
- string sqlServerVersion,
- string sqlServerEdition,
- string installerVersion,
- InstallationResult result)
- {
- ArgumentNullException.ThrowIfNull(serverName);
- ArgumentNullException.ThrowIfNull(result);
-
- var duration = result.EndTime - result.StartTime;
-
- string timestamp = result.StartTime.ToString("yyyyMMdd_HHmmss");
- string fileName = $"PerformanceMonitor_Install_{serverName.Replace("\\", "_", StringComparison.Ordinal)}_{timestamp}.txt";
- string reportPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), fileName);
-
- var sb = new StringBuilder();
-
- sb.AppendLine("================================================================================");
- sb.AppendLine("Performance Monitor Installation Report");
- sb.AppendLine("================================================================================");
- sb.AppendLine();
-
- sb.AppendLine("INSTALLATION SUMMARY");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Status: {(result.Success ? "SUCCESS" : "FAILED")}");
- sb.AppendLine($"Start Time: {result.StartTime:yyyy-MM-dd HH:mm:ss}");
- sb.AppendLine($"End Time: {result.EndTime:yyyy-MM-dd HH:mm:ss}");
- sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds");
- sb.AppendLine($"Files Executed: {result.FilesSucceeded}");
- sb.AppendLine($"Files Failed: {result.FilesFailed}");
- sb.AppendLine();
-
- sb.AppendLine("SERVER INFORMATION");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Server Name: {serverName}");
- sb.AppendLine($"SQL Server Edition: {sqlServerEdition}");
- sb.AppendLine();
-
- if (!string.IsNullOrEmpty(sqlServerVersion))
- {
- string[] versionLines = sqlServerVersion.Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries);
- if (versionLines.Length > 0)
- {
- sb.AppendLine($"SQL Server Version:");
- foreach (var line in versionLines)
- {
- sb.AppendLine($" {line.Trim()}");
- }
- }
- }
- sb.AppendLine();
-
- sb.AppendLine("INSTALLER INFORMATION");
- sb.AppendLine("--------------------------------------------------------------------------------");
- sb.AppendLine($"Installer Version: {installerVersion}");
- sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}");
- sb.AppendLine($"Machine Name: {Environment.MachineName}");
- sb.AppendLine($"User Name: {Environment.UserName}");
- sb.AppendLine();
-
- if (result.Errors.Count > 0)
- {
- sb.AppendLine("ERRORS");
- sb.AppendLine("--------------------------------------------------------------------------------");
- foreach (var (file, error) in result.Errors)
- {
- sb.AppendLine($"File: {file}");
- string errorMsg = error.Length > 500 ? error.Substring(0, 500) + "..." : error;
- sb.AppendLine($"Error: {errorMsg}");
- sb.AppendLine();
- }
- }
-
- if (result.LogMessages.Count > 0)
- {
- sb.AppendLine("DETAILED INSTALLATION LOG");
- sb.AppendLine("--------------------------------------------------------------------------------");
- foreach (var (message, status) in result.LogMessages)
- {
- string prefix = status switch
- {
- "Success" => "[OK] ",
- "Error" => "[ERROR] ",
- "Warning" => "[WARN] ",
- _ => ""
- };
- sb.AppendLine($"{prefix}{message}");
- }
- sb.AppendLine();
- }
-
- sb.AppendLine("================================================================================");
- sb.AppendLine("Generated by Performance Monitor Installer GUI");
- sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC");
- sb.AppendLine("================================================================================");
-
- File.WriteAllText(reportPath, sb.ToString());
-
- return reportPath;
- }
-
- ///
- /// Information about an applicable upgrade
- ///
- public class UpgradeInfo
- {
- public string Path { get; set; } = string.Empty;
- public string FolderName { get; set; } = string.Empty;
- public Version? FromVersion { get; set; }
- public Version? ToVersion { get; set; }
- }
-
- ///
- /// Get the currently installed version from the database
- /// Returns null if database doesn't exist or no successful installation found
- ///
- public static async Task GetInstalledVersionAsync(
- string connectionString,
- CancellationToken cancellationToken = default)
- {
- try
- {
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- /*Check if PerformanceMonitor database exists*/
- using var dbCheckCmd = new SqlCommand(@"
- SELECT database_id
- FROM sys.databases
- WHERE name = N'PerformanceMonitor';", connection);
-
- var dbExists = await dbCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
- if (dbExists == null || dbExists == DBNull.Value)
- {
- return null; /*Database doesn't exist - clean install needed*/
- }
-
- /*Check if installation_history table exists*/
- using var tableCheckCmd = new SqlCommand(@"
- USE PerformanceMonitor;
- SELECT OBJECT_ID(N'config.installation_history', N'U');", connection);
-
- var tableExists = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
- if (tableExists == null || tableExists == DBNull.Value)
- {
- return null; /*Table doesn't exist - old version or corrupted install*/
- }
-
- /*Get most recent successful installation version*/
- using var versionCmd = new SqlCommand(@"
- SELECT TOP 1 installer_version
- FROM PerformanceMonitor.config.installation_history
- WHERE installation_status = 'SUCCESS'
- ORDER BY installation_date DESC;", connection);
-
- var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
- if (version != null && version != DBNull.Value)
- {
- return version.ToString();
- }
-
- /*
- Fallback: database and history table exist but no SUCCESS rows.
- This can happen if a prior GUI install didn't write history (#538/#539).
- Return "1.0.0" so all idempotent upgrade scripts are attempted
- rather than treating this as a fresh install (which would drop the database).
- */
- return "1.0.0";
- }
- catch (SqlException)
- {
- /*Connection or query failed - treat as no version installed*/
- return null;
- }
- catch (Exception)
- {
- /*Any other error - treat as no version installed*/
- return null;
- }
- }
-
- ///
- /// Find upgrade folders that need to be applied
- /// Returns list of upgrade info in order of application
- /// Filters by version: only applies upgrades where FromVersion >= currentVersion and ToVersion <= targetVersion
- ///
- public static List GetApplicableUpgrades(
- string monitorRootDirectory,
- string? currentVersion,
- string targetVersion)
- {
- var upgrades = new List();
- string upgradesDirectory = Path.Combine(monitorRootDirectory, "upgrades");
-
- if (!Directory.Exists(upgradesDirectory))
- {
- return upgrades; /*No upgrades folder - return empty list*/
- }
-
- /*If there's no current version, it's a clean install - no upgrades needed*/
- if (currentVersion == null)
- {
- return upgrades;
- }
-
- /*Parse current version - if invalid, skip upgrades
- Normalize to 3-part (Major.Minor.Build) to avoid Revision mismatch:
- folder names use 3-part "1.3.0" but DB stores 4-part "1.3.0.0"
- Version(1,3,0).Revision=-1 which breaks >= comparison with Version(1,3,0,0)*/
- if (!Version.TryParse(currentVersion, out var currentRaw))
- {
- return upgrades;
- }
- var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build);
-
- /*Parse target version - if invalid, skip upgrades*/
- if (!Version.TryParse(targetVersion, out var targetRaw))
- {
- return upgrades;
- }
- var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build);
-
- /*
- Find all upgrade folders matching pattern: {from}-to-{to}
- Parse versions and filter to only applicable upgrades
- */
- var applicableUpgrades = Directory.GetDirectories(upgradesDirectory)
- .Select(d => new UpgradeInfo
- {
- Path = d,
- FolderName = Path.GetFileName(d)
- })
- .Where(x => x.FolderName.Contains("-to-", StringComparison.Ordinal))
- .Select(x =>
- {
- var parts = x.FolderName.Split("-to-");
- x.FromVersion = Version.TryParse(parts[0], out var from) ? from : null;
- x.ToVersion = parts.Length > 1 && Version.TryParse(parts[1], out var to) ? to : null;
- return x;
- })
- .Where(x => x.FromVersion != null && x.ToVersion != null)
- .Where(x => x.FromVersion >= current) /*Don't re-apply old upgrades*/
- .Where(x => x.ToVersion <= target) /*Don't apply future upgrades*/
- .OrderBy(x => x.FromVersion)
- .ToList();
-
- foreach (var upgrade in applicableUpgrades)
- {
- string upgradeFile = Path.Combine(upgrade.Path, "upgrade.txt");
- if (File.Exists(upgradeFile))
- {
- upgrades.Add(upgrade);
- }
- }
-
- return upgrades;
- }
-
- ///
- /// Execute an upgrade folder's SQL scripts
- /// Returns (successCount, failureCount)
- ///
- public static async Task<(int successCount, int failureCount)> ExecuteUpgradeAsync(
- string upgradeFolder,
- string connectionString,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- int successCount = 0;
- int failureCount = 0;
-
- string upgradeName = Path.GetFileName(upgradeFolder);
- string upgradeFile = Path.Combine(upgradeFolder, "upgrade.txt");
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Applying upgrade: {upgradeName}",
- Status = "Info"
- });
-
- /*Read the upgrade.txt file to get ordered list of SQL files*/
- var sqlFileNames = (await File.ReadAllLinesAsync(upgradeFile, cancellationToken).ConfigureAwait(false))
- .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
- .Select(line => line.Trim())
- .ToList();
-
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
-
- foreach (var fileName in sqlFileNames)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- string filePath = Path.Combine(upgradeFolder, fileName);
-
- if (!File.Exists(filePath))
- {
- progress?.Report(new InstallationProgress
- {
- Message = $" {fileName} - WARNING: File not found",
- Status = "Warning"
- });
- failureCount++;
- continue;
- }
-
- try
- {
- string sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
-
- /*Remove SQLCMD directives*/
- sql = SqlCmdDirectivePattern.Replace(sql, "");
-
- /*Split by GO statements*/
- string[] batches = GoBatchSplitter.Split(sql);
-
- int batchNumber = 0;
- foreach (var batch in batches)
- {
- batchNumber++;
- string trimmedBatch = batch.Trim();
-
- if (string.IsNullOrWhiteSpace(trimmedBatch))
- continue;
-
- using var cmd = new SqlCommand(trimmedBatch, connection);
- cmd.CommandTimeout = 3600; /*1 hour — upgrade migrations on large tables need extended time*/
-
- try
- {
- await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
- catch (SqlException ex)
- {
- /*Add batch info to error message*/
- string batchPreview = trimmedBatch.Length > 500
- ? trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]"
- : trimmedBatch;
- throw new InvalidOperationException(
- $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex);
- }
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $" {fileName} - Success",
- Status = "Success"
- });
- successCount++;
- }
- catch (Exception ex)
- {
- progress?.Report(new InstallationProgress
- {
- Message = $" {fileName} - FAILED: {ex.Message}",
- Status = "Error"
- });
- failureCount++;
- }
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Upgrade {upgradeName}: {successCount} succeeded, {failureCount} failed",
- Status = failureCount == 0 ? "Success" : "Warning"
- });
-
- return (successCount, failureCount);
- }
-
- ///
- /// Execute all applicable upgrades in order
- ///
- public static async Task<(int totalSuccessCount, int totalFailureCount, int upgradeCount)> ExecuteAllUpgradesAsync(
- string monitorRootDirectory,
- string connectionString,
- string? currentVersion,
- string targetVersion,
- IProgress? progress = null,
- CancellationToken cancellationToken = default)
- {
- int totalSuccessCount = 0;
- int totalFailureCount = 0;
-
- var upgrades = GetApplicableUpgrades(monitorRootDirectory, currentVersion, targetVersion);
-
- if (upgrades.Count == 0)
- {
- return (0, 0, 0);
- }
-
- progress?.Report(new InstallationProgress
- {
- Message = $"Found {upgrades.Count} upgrade(s) to apply",
- Status = "Info"
- });
-
- foreach (var upgrade in upgrades)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var (success, failure) = await ExecuteUpgradeAsync(
- upgrade.Path,
- connectionString,
- progress,
- cancellationToken).ConfigureAwait(false);
-
- totalSuccessCount += success;
- totalFailureCount += failure;
- }
-
- return (totalSuccessCount, totalFailureCount, upgrades.Count);
- }
-
- /*
- Download content from URL with retry logic for transient failures
- Uses exponential backoff: 2s, 4s, 8s between retries
- */
- private static async Task DownloadWithRetryAsync(
- HttpClient client,
- string url,
- IProgress? progress = null,
- int maxRetries = 3,
- CancellationToken cancellationToken = default)
- {
- for (int attempt = 1; attempt <= maxRetries; attempt++)
- {
- try
- {
- return await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
- }
- catch (HttpRequestException) when (attempt < maxRetries)
- {
- int delaySeconds = (int)Math.Pow(2, attempt); /*2s, 4s, 8s*/
- progress?.Report(new InstallationProgress
- {
- Message = $"Network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})...",
- Status = "Warning"
- });
- await Task.Delay(delaySeconds * 1000, cancellationToken).ConfigureAwait(false);
- }
- }
- /*Final attempt - let exception propagate if it fails*/
- return await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
- }
-
- ///
- /// Dispose of managed resources
- ///
- public void Dispose()
- {
- if (!_disposed)
- {
- _httpClient?.Dispose();
- _disposed = true;
- }
- GC.SuppressFinalize(this);
- }
-
- ///
- /// Log installation history to config.installation_history
- /// Mirrors CLI installer's LogInstallationHistory method
- ///
- public static async Task LogInstallationHistoryAsync(
- string connectionString,
- string assemblyVersion,
- string infoVersion,
- DateTime startTime,
- int filesExecuted,
- int filesFailed,
- bool isSuccess)
- {
- using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync().ConfigureAwait(false);
-
- /*Check if this is an upgrade by checking for existing installation*/
- string? previousVersion = null;
- string installationType = "INSTALL";
-
- try
- {
- using var checkCmd = new SqlCommand(@"
- SELECT TOP 1 installer_version
- FROM PerformanceMonitor.config.installation_history
- WHERE installation_status = 'SUCCESS'
- ORDER BY installation_date DESC;", connection);
-
- var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false);
- if (result != null && result != DBNull.Value)
- {
- previousVersion = result.ToString();
- bool isSameVersion = Version.TryParse(previousVersion, out var prevVer)
- && Version.TryParse(assemblyVersion, out var currVer)
- && prevVer == currVer;
- installationType = isSameVersion ? "REINSTALL" : "UPGRADE";
- }
- }
- catch (SqlException)
- {
- /*Table might not exist yet on first install*/
- }
-
- /*Get SQL Server version info*/
- string sqlVersion = "";
- string sqlEdition = "";
-
- using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection))
- using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false))
- {
- if (await reader.ReadAsync().ConfigureAwait(false))
- {
- sqlVersion = reader.GetString(0);
- sqlEdition = reader.GetString(1);
- }
- }
-
- long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds;
- string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED");
-
- var insertSql = @"
- INSERT INTO PerformanceMonitor.config.installation_history
- (
- installer_version,
- installer_info_version,
- sql_server_version,
- sql_server_edition,
- installation_type,
- previous_version,
- installation_status,
- files_executed,
- files_failed,
- installation_duration_ms
- )
- VALUES
- (
- @installer_version,
- @installer_info_version,
- @sql_server_version,
- @sql_server_edition,
- @installation_type,
- @previous_version,
- @installation_status,
- @files_executed,
- @files_failed,
- @installation_duration_ms
- );";
-
- using var insertCmd = new SqlCommand(insertSql, connection);
- insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion });
- insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value });
- insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion });
- insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition });
- insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType });
- insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value });
- insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status });
- insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted });
- insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed });
- insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs });
-
- await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
- }
-
- [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")]
- private static partial Regex SqlFileRegExp();
- }
-}
diff --git a/PerformanceMonitor.sln b/PerformanceMonitor.sln
index 3128202..f253ff9 100644
--- a/PerformanceMonitor.sln
+++ b/PerformanceMonitor.sln
@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitorInstaller
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Tests", "Installer.Tests\Installer.Tests.csproj", "{9B2800D2-8F32-450E-A169-86B381EA5560}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Core", "Installer.Core\Installer.Core.csproj", "{AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -45,6 +47,10 @@ Global
{9B2800D2-8F32-450E-A169-86B381EA5560}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9B2800D2-8F32-450E-A169-86B381EA5560}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9B2800D2-8F32-450E-A169-86B381EA5560}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/install/05_delta_framework.sql b/install/05_delta_framework.sql
index b0e87b2..688e87c 100644
--- a/install/05_delta_framework.sql
+++ b/install/05_delta_framework.sql
@@ -1202,38 +1202,43 @@ BEGIN
END TRY
BEGIN CATCH
+ DECLARE
+ @error_message nvarchar(4000) = ERROR_MESSAGE();
+
/*
Only rollback if we started the transaction
Otherwise let the caller handle it
*/
- IF @trancount_at_entry = 0
+ IF @trancount_at_entry = 0
AND @@TRANCOUNT > 0
BEGIN
ROLLBACK TRANSACTION;
END;
- DECLARE
- @error_message nvarchar(4000) = ERROR_MESSAGE();
-
/*
- Log the error
+ Log the error only if the transaction is not doomed
+ When called inside a caller's transaction that is doomed (XACT_STATE = -1),
+ we cannot write to the log — the caller must rollback first
*/
- INSERT INTO
- config.collection_log
- (
- collector_name,
- collection_status,
- duration_ms,
- error_message
- )
- VALUES
- (
- N'calculate_deltas_' + @table_name,
- N'ERROR',
- DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
- @error_message
- );
-
+ IF XACT_STATE() <> -1
+ BEGIN
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'calculate_deltas_' + @table_name,
+ N'ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ @error_message
+ );
+ END;
+
RAISERROR(N'Error calculating deltas for %s: %s', 16, 1, @table_name, @error_message);
END CATCH;
END;
diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql
index c47e67e..414d5b1 100644
--- a/install/06_ensure_collection_table.sql
+++ b/install/06_ensure_collection_table.sql
@@ -1206,8 +1206,14 @@ BEGIN
BEGIN CATCH
SET @error_message = ERROR_MESSAGE();
+ IF @@TRANCOUNT > 0
+ BEGIN
+ ROLLBACK;
+ END;
+
/*
Log errors to collection log
+ Must happen after rollback to avoid doomed transaction writes
*/
INSERT INTO
config.collection_log
@@ -1229,11 +1235,6 @@ BEGIN
@error_message
);
- IF @@TRANCOUNT > 0
- BEGIN
- ROLLBACK;
- END;
-
THROW;
END CATCH;
END;
diff --git a/install/23_process_blocked_process_xml.sql b/install/23_process_blocked_process_xml.sql
index 6def84c..e8c3b2c 100644
--- a/install/23_process_blocked_process_xml.sql
+++ b/install/23_process_blocked_process_xml.sql
@@ -220,6 +220,17 @@ BEGIN
@end_date = @end_date_local,
@debug = @debug;
+ /*
+ If sp_HumanEventsBlockViewer failed internally it may have doomed our transaction
+ Check XACT_STATE and surface the real error before it gets swallowed
+ */
+ IF XACT_STATE() = -1
+ BEGIN
+ ROLLBACK TRANSACTION;
+ RAISERROR(N'sp_HumanEventsBlockViewer failed and doomed the transaction - check procedure version and compatibility', 16, 1);
+ RETURN;
+ END;
+
/*
Verify sp_HumanEventsBlockViewer produced parsed results before marking rows as processed
If no results were inserted, leave rows unprocessed so they are retried next run
diff --git a/install/25_process_deadlock_xml.sql b/install/25_process_deadlock_xml.sql
index 74067fd..48f4bba 100644
--- a/install/25_process_deadlock_xml.sql
+++ b/install/25_process_deadlock_xml.sql
@@ -209,6 +209,17 @@ BEGIN
@end_date_local = @end_date_local,
@debug = @debug;
+ /*
+ If sp_BlitzLock failed internally it may have doomed our transaction
+ Check XACT_STATE and surface the real error before it gets swallowed
+ */
+ IF XACT_STATE() = -1
+ BEGIN
+ ROLLBACK TRANSACTION;
+ RAISERROR(N'sp_BlitzLock failed and doomed the transaction - check sp_BlitzLock version and compatibility', 16, 1);
+ RETURN;
+ END;
+
/*
Verify sp_BlitzLock produced parsed results before marking rows as processed
If no results were inserted, leave rows unprocessed so they are retried next run