diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc250db..0ee0fbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: # The build step never fails the job (it only sets an output on success), # so a VSIX build failure can never block the cross-platform app release. - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v2 + uses: microsoft/setup-msbuild@v3 continue-on-error: true - name: Build SSMS extension @@ -127,20 +127,21 @@ jobs: curl -sS -f "https://ssmsgallery.azurewebsites.net/api/upload" \ -F "file=@releases/PlanViewer.Ssms.vsix" - # ── SignPath code signing (Windows only, skipped if secret not configured) ── - - name: Check if signing is configured - id: signing + # ── SignPath code signing (Windows). Signing is REQUIRED for a release: + # if the SignPath token is missing the job fails loudly rather than + # silently shipping unsigned binaries. ── + - name: Verify signing is configured shell: bash + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} run: | - if [ -n "${{ secrets.SIGNPATH_API_TOKEN }}" ]; then - echo "ENABLED=true" >> $GITHUB_OUTPUT - else - echo "ENABLED=false" >> $GITHUB_OUTPUT - echo "::warning::SIGNPATH_API_TOKEN not configured — releasing unsigned binaries" + if [ -z "$SIGNPATH_API_TOKEN" ]; then + echo "::error::SIGNPATH_API_TOKEN missing — signing is required for a release; aborting." + exit 1 fi + echo "SignPath token present — Windows binaries will be signed." - name: Upload Windows build for signing - if: steps.signing.outputs.ENABLED == 'true' id: upload-unsigned uses: actions/upload-artifact@v6 with: @@ -148,7 +149,6 @@ jobs: path: publish/win-x64/ - name: Sign Windows build - if: steps.signing.outputs.ENABLED == 'true' uses: signpath/github-action-submit-signing-request@v2 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' @@ -161,7 +161,6 @@ jobs: output-artifact-directory: 'signed/win-x64' - name: Replace unsigned Windows build with signed - if: steps.signing.outputs.ENABLED == 'true' shell: pwsh run: | Remove-Item -Recurse -Force publish/win-x64 diff --git a/README.md b/README.md index a297fab..f71747c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Each warning includes severity (Info, Warning, or Critical), the operator node I ## Prerequisites -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (required to build and run) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (only needed to build from source — the pre-built binaries below are self-contained and require no SDK) - SQL Server instance (optional — only needed for live plan capture; file analysis works without one) - Docker (optional — macOS/Linux users can run SQL Server locally via Docker) diff --git a/server/PlanShare/Program.cs b/server/PlanShare/Program.cs index 097fea3..446c533 100644 --- a/server/PlanShare/Program.cs +++ b/server/PlanShare/Program.cs @@ -347,8 +347,10 @@ GROUP BY referrer ORDER BY count DESC LIMIT 10 static string GenerateId() { + // The id is the only access control on a shared plan, so it must be + // unpredictable — use a cryptographic RNG, not Random.Shared (xoshiro). const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - return new string(Random.Shared.GetItems(chars.AsSpan(), 8)); + return new string(RandomNumberGenerator.GetItems(chars.AsSpan(), 8)); } static string GenerateDeleteToken() diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 23fe79c..2d49453 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.12.0 + 1.13.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index 9956c40..a747e7f 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -543,7 +543,7 @@ private IBrush FindBrushResource(string key) "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)), "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)), "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), - "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xB0, 0xB6, 0xC0)), _ => Brushes.White }; } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs index eea3f84..e913411 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs @@ -143,6 +143,11 @@ private async void Format_Click(object? sender, RoutedEventArgs e) QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength); SetStatus("Formatted"); } + catch (Exception ex) + { + // async void handler: an unhandled throw here would crash the app. + SetStatus($"Format failed: {ex.Message}"); + } finally { FormatButton.IsEnabled = true; diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml index 38bdec0..ce477f1 100644 --- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml +++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml @@ -2,8 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="PlanViewer.App.Dialogs.ConnectionDialog" Title="Connect to Server" - Width="480" Height="520" - MinWidth="400" MinHeight="480" + Width="480" Height="588" + MinWidth="400" MinHeight="548" WindowStartupLocation="CenterOwner" CanResize="False" Icon="avares://PlanViewer.App/EDD.ico" @@ -85,6 +85,15 @@ Foreground="{DynamicResource ForegroundBrush}" ToolTip.Tip="Sets ApplicationIntent=ReadOnly. Required when connecting via an AG listener or Azure failover group endpoint to route to a readable secondary (including Query Store on secondary replicas)." FontSize="12"/> + + + + + + diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs index 5f32da3..cf0c93f 100644 --- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs +++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs @@ -78,6 +78,7 @@ private void ApplySavedConnection(ServerConnection saved) TrustCertBox.IsChecked = saved.TrustServerCertificate; ReadOnlyIntentCheckBox.IsChecked = saved.ApplicationIntentReadOnly; + DatabaseInputBox.Text = saved.DatabaseName ?? ""; // Load stored credentials var cred = _credentialService.GetCredential(saved.Id); @@ -125,6 +126,11 @@ private async void TestConnection_Click(object? sender, RoutedEventArgs e) return; } + // For Azure SQL DB / JIT access the login often can't open master, so connect + // through the database the user named (if any) instead of the hardcoded master. + var typedDatabase = DatabaseInputBox.Text?.Trim(); + var connectDatabase = string.IsNullOrEmpty(typedDatabase) ? "master" : typedDatabase; + StatusText.Text = "Connecting..."; StatusText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE4, 0xE6, 0xEB)); TestButton.IsEnabled = false; @@ -135,12 +141,13 @@ private async void TestConnection_Click(object? sender, RoutedEventArgs e) var connectionString = connection.GetConnectionString( LoginBox.Text?.Trim(), PasswordBox.Text, - "master"); + connectDatabase); await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); - // Fetch databases + // Fetch databases the login can see. On Azure SQL DB connected to a single user + // database this returns master + that database, which is expected. var databases = new List(); using var cmd = new SqlCommand( "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); @@ -148,13 +155,21 @@ private async void TestConnection_Click(object? sender, RoutedEventArgs e) while (await reader.ReadAsync()) databases.Add(reader.GetString(0)); + // The named database is reachable (OpenAsync succeeded), so make sure it's + // selectable even if enumeration didn't surface it (restricted JIT permissions). + if (!string.IsNullOrEmpty(typedDatabase) && + !databases.Contains(typedDatabase, StringComparer.OrdinalIgnoreCase)) + databases.Insert(0, typedDatabase); + DatabaseBox.ItemsSource = databases; DatabaseBox.IsEnabled = true; ConnectButton.IsEnabled = true; - // Default to master if available - var masterIdx = databases.IndexOf("master"); - if (masterIdx >= 0) DatabaseBox.SelectedIndex = masterIdx; + // Pre-select the named database when given, otherwise default to master. + var preferred = string.IsNullOrEmpty(typedDatabase) ? "master" : typedDatabase; + var preferredIdx = databases.FindIndex(d => d.Equals(preferred, StringComparison.OrdinalIgnoreCase)); + if (preferredIdx >= 0) DatabaseBox.SelectedIndex = preferredIdx; + else if (databases.Count > 0) DatabaseBox.SelectedIndex = 0; StatusText.Text = $"Connected ({databases.Count} databases)"; StatusText.Foreground = Avalonia.Media.Brushes.LimeGreen; @@ -213,11 +228,13 @@ private void Cancel_Click(object? sender, RoutedEventArgs e) private ServerConnection BuildServerConnection() { var serverName = ServerNameBox.Text?.Trim() ?? ""; + var databaseName = DatabaseInputBox.Text?.Trim(); return new ServerConnection { Id = serverName, ServerName = serverName, DisplayName = serverName, + DatabaseName = string.IsNullOrEmpty(databaseName) ? null : databaseName, AuthenticationType = GetSelectedAuthType(), TrustServerCertificate = TrustCertBox.IsChecked == true, EncryptMode = GetSelectedEncryptMode(), diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index f162940..c4ee68f 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -175,7 +175,11 @@ await Dispatcher.UIThread.InvokeAsync(() => } catch { - // Pipe error — restart the listener + // Pipe error (e.g. another instance already holds the single + // server slot). Back off before retrying so we don't spin at + // 100% CPU recreating the listener in a tight loop. + try { await Task.Delay(1000, token); } + catch (OperationCanceledException) { break; } } } }, token); diff --git a/src/PlanViewer.App/Program.cs b/src/PlanViewer.App/Program.cs index ca4b1fd..8c57028 100644 --- a/src/PlanViewer.App/Program.cs +++ b/src/PlanViewer.App/Program.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.IO.Pipes; -using System.Threading; using Velopack; namespace PlanViewer.App; @@ -10,7 +9,6 @@ namespace PlanViewer.App; class Program { private const string PipeName = "SQLPerformanceStudio_OpenFile"; - private const string MutexName = "SQLPerformanceStudio_SingleInstance"; [STAThread] public static void Main(string[] args) @@ -33,26 +31,23 @@ public static AppBuilder BuildAvaloniaApp() .LogToTrace(); /// - /// Tries to connect to an already-running instance and send the file path. - /// Returns true if the message was delivered (caller should exit). + /// Tries to hand the file path to an already-running instance over its named pipe. + /// A failed/timed-out connect means no instance is listening, so the caller should + /// launch normally. Returns true only if the path was actually delivered. /// + /// + /// Detection is via the pipe itself rather than a named mutex: the previous mutex + /// was disposed as soon as this method returned, so no instance ever held it and + /// the forwarding path was never taken. + /// private static bool TrySendToRunningInstance(string filePath) { - bool createdNew; - using var mutex = new Mutex(true, MutexName, out createdNew); - - if (createdNew) - { - // We're the first instance — release and let normal startup proceed - mutex.ReleaseMutex(); - return false; - } - - // Another instance owns the mutex — try sending the file path try { using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); - client.Connect(3000); // 3 second timeout + // Short timeout: a running instance's listener is idle and connects + // immediately; when none is running this is the only added launch delay. + client.Connect(500); using var writer = new StreamWriter(client); writer.WriteLine(filePath); writer.Flush(); @@ -60,7 +55,7 @@ private static bool TrySendToRunningInstance(string filePath) } catch { - // Pipe not available — fall through to launch normally + // No instance listening (or pipe busy) — fall through to launch normally. return false; } } diff --git a/src/PlanViewer.App/Services/ConnectionStore.cs b/src/PlanViewer.App/Services/ConnectionStore.cs index 41123f3..6e15e3a 100644 --- a/src/PlanViewer.App/Services/ConnectionStore.cs +++ b/src/PlanViewer.App/Services/ConnectionStore.cs @@ -55,6 +55,7 @@ public void AddOrUpdate(ServerConnection connection) existing.TrustServerCertificate = connection.TrustServerCertificate; existing.ApplicationIntentReadOnly = connection.ApplicationIntentReadOnly; existing.DisplayName = connection.DisplayName; + existing.DatabaseName = connection.DatabaseName; existing.LastConnected = DateTime.Now; } else diff --git a/src/PlanViewer.App/Themes/DarkTheme.axaml b/src/PlanViewer.App/Themes/DarkTheme.axaml index 65edd0b..6235b22 100644 --- a/src/PlanViewer.App/Themes/DarkTheme.axaml +++ b/src/PlanViewer.App/Themes/DarkTheme.axaml @@ -5,6 +5,7 @@ + diff --git a/src/PlanViewer.Cli/ConnectionHelper.cs b/src/PlanViewer.Cli/ConnectionHelper.cs index 92c6fd2..0358669 100644 --- a/src/PlanViewer.Cli/ConnectionHelper.cs +++ b/src/PlanViewer.Cli/ConnectionHelper.cs @@ -17,7 +17,10 @@ public static string BuildConnectionString( ApplicationName = "PlanViewer", ConnectTimeout = 15, TrustServerCertificate = trustCert, - Encrypt = trustCert ? SqlConnectionEncryptOption.Optional : SqlConnectionEncryptOption.Mandatory + // Keep encryption mandatory regardless of --trust-cert. --trust-cert only + // skips certificate *validation* (for self-signed certs); it must not also + // drop the connection to optional/plaintext, which would allow a MITM. + Encrypt = SqlConnectionEncryptOption.Mandatory }; if (multipleActiveResultSets) diff --git a/src/PlanViewer.Core/Models/ServerConnection.cs b/src/PlanViewer.Core/Models/ServerConnection.cs index 9bf6877..16f1487 100644 --- a/src/PlanViewer.Core/Models/ServerConnection.cs +++ b/src/PlanViewer.Core/Models/ServerConnection.cs @@ -18,6 +18,13 @@ public class ServerConnection public bool TrustServerCertificate { get; set; } = false; public bool ApplicationIntentReadOnly { get; set; } = false; + /// + /// Optional database name for the initial connection. Use for Azure SQL Database or + /// Just-In-Time access where the login can't open master. + /// Leave empty for on-premises SQL Server (defaults to master). + /// + public string? DatabaseName { get; set; } + [JsonIgnore] public string AuthenticationDisplay => AuthenticationType switch { diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index cf8ac8a..4d2b134 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -373,7 +373,9 @@ private static bool HasSpillInTree(OperatorResult? node) if (node == null) return false; foreach (var w in node.Warnings) { - if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) + // Covers "Sort Spill"/"Hash Spill"/"Exchange Spill" as well as the + // standalone "Spill to TempDb" and "Spill Occurred" warning types. + if (w.Type.Contains("Spill", StringComparison.Ordinal)) return true; } foreach (var child in node.Children) @@ -507,7 +509,7 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt) foreach (var w in sorted) { - var sevLower = w.Severity.ToLower(); + var sevLower = w.Severity.ToLowerInvariant(); sb.AppendLine($"
"); sb.AppendLine($"{Encode(w.Severity)}"); if (w.Operator != null) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index 2c0613f..2efb181 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -290,11 +290,15 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) { - // DECLARE [qualifier(s)] CURSOR ... FOR - // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. + // DECLARE [INSENSITIVE|SCROLL] CURSOR [qualifier(s)] FOR ... + // In the T-SQL extended syntax, LOCAL/GLOBAL appear AFTER the CURSOR + // keyword (only INSENSITIVE/SCROLL are legal before it), so the LOCAL + // qualifier must be looked for between CURSOR and the FOR that introduces + // the SELECT. Capturing tokens *before* CURSOR never sees LOCAL and would + // fire on every cursor, including ones already declared LOCAL. var cursorDeclMatch = Regex.Match( stmt.StatementText, - @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", + @"\bDECLARE\s+\w+\s+(?:INSENSITIVE\s+|SCROLL\s+)*CURSOR\b(.*?)\bFOR\b", RegexOptions.IgnoreCase | RegexOptions.Singleline); if (cursorDeclMatch.Success) { diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs index a1d61af..c475ec1 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs @@ -9,8 +9,11 @@ namespace PlanViewer.Core.Services; public static partial class ShowPlanParser { - private static PlanNode ParseRelOp(XElement relOpEl) + private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0) { + if (depth > MaxParseDepth) + throw new InvalidOperationException("Plan operator nesting exceeds the supported depth limit."); + var node = new PlanNode { NodeId = (int)ParseDouble(relOpEl.Attribute("NodeId")?.Value), @@ -762,7 +765,7 @@ private static PlanNode ParseRelOp(XElement relOpEl) // Recurse into child RelOps foreach (var childRelOp in FindChildRelOps(relOpEl)) { - var childNode = ParseRelOp(childRelOp); + var childNode = ParseRelOp(childRelOp, depth + 1); childNode.Parent = node; node.Children.Add(childNode); } diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index d030edb..61cf643 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -11,6 +11,11 @@ public static partial class ShowPlanParser { private static readonly XNamespace Ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; + // Plan XML is untrusted input (opened/pasted/downloaded). Cap recursion depth so a + // maliciously deep tree throws a catchable exception instead of an uncatchable + // StackOverflowException that takes the whole process down. + private const int MaxParseDepth = 1000; + public static ParsedPlan Parse(string xml) { var plan = new ParsedPlan { RawXml = xml }; @@ -26,6 +31,11 @@ public static ParsedPlan Parse(string xml) return plan; } + // The tree walk below can throw on hostile/malformed plans (including the depth + // guards in ParseRelOp/ParseStatementAndChildren that stop unbounded recursion). + // Contain it so a bad plan becomes a ParseError, never a crash for the caller. + try + { var root = doc.Root; if (root == null) return plan; @@ -67,6 +77,11 @@ public static ParsedPlan Parse(string xml) } ComputeOperatorCosts(plan); + } + catch (Exception ex) + { + plan.ParseError = ex.Message; + } return plan; } @@ -74,9 +89,11 @@ public static ParsedPlan Parse(string xml) /// Handles StmtSimple, StmtCond (IF/ELSE), and StmtCursor recursively. /// Returns a flat list of all parseable statements found. /// - private static List ParseStatementAndChildren(XElement stmtEl) + private static List ParseStatementAndChildren(XElement stmtEl, int depth = 0) { var results = new List(); + if (depth > MaxParseDepth) + throw new InvalidOperationException("Plan statement nesting exceeds the supported depth limit."); var localName = stmtEl.Name.LocalName; if (localName == "StmtCond") @@ -86,21 +103,21 @@ private static List ParseStatementAndChildren(XElement stmtEl) if (condEl != null) { foreach (var child in condEl.Elements()) - results.AddRange(ParseStatementAndChildren(child)); + results.AddRange(ParseStatementAndChildren(child, depth + 1)); } var thenStmts = stmtEl.Element(Ns + "Then")?.Element(Ns + "Statements"); if (thenStmts != null) { foreach (var child in thenStmts.Elements()) - results.AddRange(ParseStatementAndChildren(child)); + results.AddRange(ParseStatementAndChildren(child, depth + 1)); } var elseStmts = stmtEl.Element(Ns + "Else")?.Element(Ns + "Statements"); if (elseStmts != null) { foreach (var child in elseStmts.Elements()) - results.AddRange(ParseStatementAndChildren(child)); + results.AddRange(ParseStatementAndChildren(child, depth + 1)); } } else if (localName == "StmtCursor") diff --git a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj index 7f4fdc4..a67373e 100644 --- a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj +++ b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj @@ -62,6 +62,9 @@ + + true + LICENSE true diff --git a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs index ca2570c..c895019 100644 --- a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs +++ b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs @@ -7,5 +7,5 @@ [assembly: AssemblyProduct("Performance Studio for SSMS")] [assembly: AssemblyCopyright("Copyright Darling Data 2026")] [assembly: ComVisible(false)] -[assembly: AssemblyVersion("1.12.0.0")] -[assembly: AssemblyFileVersion("1.12.0.0")] +[assembly: AssemblyVersion("1.13.0.0")] +[assembly: AssemblyFileVersion("1.13.0.0")] diff --git a/src/PlanViewer.Ssms/Resources/PerformanceStudioIcon.png b/src/PlanViewer.Ssms/Resources/PerformanceStudioIcon.png new file mode 100644 index 0000000..559cdf3 Binary files /dev/null and b/src/PlanViewer.Ssms/Resources/PerformanceStudioIcon.png differ diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index 059fd19..be2ecb2 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -3,13 +3,14 @@ xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> Performance Studio for SSMS Adds "Open in Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in Performance Studio for advanced analysis. https://github.com/erikdarlingdata/PerformanceStudio LICENSE + Resources\PerformanceStudioIcon.png SQL Server, Execution Plan, Performance, SSMS @@ -17,6 +18,9 @@ amd64 + + arm64 + + + + + <_CoreIcon Include="..\PlanViewer.Core\Resources\PlanIcons\*.png" /> + <_WebIcon Include="wwwroot\icons\*.png" /> + <_MissingWebIcon Include="@(_CoreIcon->'%(Filename)%(Extension)')" Exclude="@(_WebIcon->'%(Filename)%(Extension)')" /> + + + + diff --git a/src/PlanViewer.Web/wwwroot/icons/parallelism_distribute_streams.png b/src/PlanViewer.Web/wwwroot/icons/parallelism_distribute_streams.png new file mode 100644 index 0000000..f85dd59 Binary files /dev/null and b/src/PlanViewer.Web/wwwroot/icons/parallelism_distribute_streams.png differ diff --git a/src/PlanViewer.Web/wwwroot/icons/parallelism_gather_streams.png b/src/PlanViewer.Web/wwwroot/icons/parallelism_gather_streams.png new file mode 100644 index 0000000..14078f7 Binary files /dev/null and b/src/PlanViewer.Web/wwwroot/icons/parallelism_gather_streams.png differ diff --git a/src/PlanViewer.Web/wwwroot/icons/parallelism_repartition_streams.png b/src/PlanViewer.Web/wwwroot/icons/parallelism_repartition_streams.png new file mode 100644 index 0000000..bda974d Binary files /dev/null and b/src/PlanViewer.Web/wwwroot/icons/parallelism_repartition_streams.png differ