Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using PlanViewer.Core.Models;

namespace PlanViewer.App.Controls;

/// <summary>
/// Event args for when a plan is loaded from the history context menu.
/// </summary>
public class HistoryPlanLoadEventArgs : EventArgs
{
public QueryStorePlan Plan { get; }

public HistoryPlanLoadEventArgs(QueryStorePlan plan)
{
Plan = plan;
}
}
228 changes: 137 additions & 91 deletions src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using AvaloniaEdit.TextMate;
using Microsoft.Data.SqlClient;
using PlanViewer.App.Dialogs;
using PlanViewer.App.Helpers;
using PlanViewer.App.Services;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
Expand All @@ -38,31 +39,15 @@ private bool HasQueryStoreTab()

public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs());

private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
/// <summary>
/// Creates a sub-tab with a standard header (label + optional extra buttons + close button).
/// Returns the TabItem. The close button removes the tab from SubTabControl.
/// </summary>
private TabItem CreateSubTab(string label, Control content, Action<TabItem>? onClose = null, params Button[] extraButtons)
{
if (_serverConnection == null || _connectionString == null)
{
await ShowConnectionDialogAsync();
if (_serverConnection == null || _connectionString == null)
return;
}

SetStatus("Loading Query Store Overview...");

var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService,
supportsWaitStats: supportsWaitStats);
overview.DrillDownRequested += async (_, args) =>
{
// Open a single-database Query Store tab directly (no connection dialog)
_selectedDatabase = args.Database;
_connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database);
await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc);
};

var headerText = new TextBlock
{
Text = "QS Overview",
Text = label,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = 12
};
Expand All @@ -73,7 +58,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
Padding = new Avalonia.Thickness(0),
FontSize = 11,
Margin = new Avalonia.Thickness(6, 0, 0, 0),
Margin = new Avalonia.Thickness(2, 0, 0, 0),
Background = Brushes.Transparent,
BorderThickness = new Avalonia.Thickness(0),
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
Expand All @@ -85,17 +70,58 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
var header = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Children = { headerText, closeBtn }
Background = Brushes.Transparent
};
header.Children.Add(headerText);
foreach (var btn in extraButtons)
header.Children.Add(btn);
header.Children.Add(closeBtn);

var tab = new TabItem { Header = header, Content = overview };
var tab = new TabItem { Header = header, Content = content };
closeBtn.Tag = tab;
closeBtn.Click += (s, _) =>
{
if (s is Button btn && btn.Tag is TabItem t)
{
onClose?.Invoke(t);
SubTabControl.Items.Remove(t);
}
};

return tab;
}

/// <summary>Gets the header TextBlock from a sub-tab created via CreateSubTab.</summary>
private static TextBlock? GetSubTabHeaderText(TabItem tab)
{
if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
return tb;
return null;
}

private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
{
if (_serverConnection == null || _connectionString == null)
{
await ShowConnectionDialogAsync();
if (_serverConnection == null || _connectionString == null)
return;
}

SetStatus("Loading Query Store Overview...");

var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService,
supportsWaitStats: supportsWaitStats);
overview.DrillDownRequested += async (_, args) =>
{
// Open a single-database Query Store tab directly (no connection dialog)
_selectedDatabase = args.Database;
_connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database);
await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc);
};

var tab = CreateSubTab("QS Overview", overview);
SubTabControl.Items.Add(tab);
SubTabControl.SelectedItem = tab;

Expand All @@ -106,7 +132,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
}
catch (Exception ex)
{
SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
SetStatus(ex.Message, autoClear: false);
}
}

Expand All @@ -127,7 +153,7 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini
}
catch (Exception ex)
{
SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
SetStatus(ex.Message, autoClear: false);
return;
}

Expand All @@ -152,41 +178,11 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini
grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value);
grid.PlansSelected += OnQueryStorePlansSelected;

var headerText = new TextBlock
{
Text = $"Query Store — {database}",
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = 12
};
grid.DatabaseChanged += (_, db) => headerText.Text = $"Query Store — {db}";

var closeBtn = new Button
{
Content = "\u2715",
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
Padding = new Avalonia.Thickness(0),
FontSize = 11,
Margin = new Avalonia.Thickness(6, 0, 0, 0),
Background = Brushes.Transparent,
BorderThickness = new Avalonia.Thickness(0),
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center
};

var header = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Children = { headerText, closeBtn }
};

var tab = new TabItem { Header = header, Content = grid };
closeBtn.Tag = tab;
closeBtn.Click += (s, _) =>
var tab = CreateSubTab($"Query Store — {database}", grid);
grid.DatabaseChanged += (_, db) =>
{
if (s is Button btn && btn.Tag is TabItem t)
SubTabControl.Items.Remove(t);
if (GetSubTabHeaderText(tab) is TextBlock tb)
tb.Text = $"Query Store — {db}";
};

SubTabControl.Items.Add(tab);
Expand Down Expand Up @@ -244,62 +240,112 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e)
_selectedDatabase!, databases, supportsWaitStats);
grid.PlansSelected += OnQueryStorePlansSelected;

var headerText = new TextBlock
{
Text = $"Query Store — {_selectedDatabase}",
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = 12
};

var tab = CreateSubTab($"Query Store — {_selectedDatabase}", grid);
// Update tab header when database is changed via the grid's picker
grid.DatabaseChanged += (_, db) =>
{
headerText.Text = $"Query Store — {db}";
if (GetSubTabHeaderText(tab) is TextBlock tb)
tb.Text = $"Query Store — {db}";
};

var closeBtn = new Button
SubTabControl.Items.Add(tab);
SubTabControl.SelectedItem = tab;
}

private void OnQueryStorePlansSelected(object? sender, List<QueryStorePlan> plans)
{
foreach (var qsPlan in plans)
{
Content = "\u2715",
var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
}

SetStatus($"{plans.Count} Query Store plans loaded");
HumanAdviceButton.IsEnabled = true;
RobotAdviceButton.IsEnabled = true;
}

/// <summary>
/// Adds a Query Store History control as a sub-tab in this session.
/// Supports long-press to detach into a free-floating window.
/// </summary>
public void AddHistorySubTab(string label, QueryStoreHistoryControl control)
{
// Wire up plan load from context menu (unsubscribe first to prevent leaks on re-dock)
control.PlanLoadRequested -= OnHistoryPlanLoadRequested;
control.PlanLoadRequested += OnHistoryPlanLoadRequested;

var detachBtn = new Button
{
Content = "↗",
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
Padding = new Avalonia.Thickness(0),
FontSize = 11,
Margin = new Avalonia.Thickness(6, 0, 0, 0),
Margin = new Avalonia.Thickness(4, 0, 0, 0),
Background = Brushes.Transparent,
BorderThickness = new Avalonia.Thickness(0),
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
Foreground = new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA0)),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center
};
Avalonia.Controls.ToolTip.SetTip(detachBtn, "Detach to Window");

var header = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Children = { headerText, closeBtn }
};
var tab = CreateSubTab(label, control,
onClose: t => { if (t.Content is QueryStoreHistoryControl hc) hc.CancelFetch(); },
detachBtn);

var tab = new TabItem { Header = header, Content = grid };
closeBtn.Tag = tab;
closeBtn.Click += (s, _) =>
detachBtn.Tag = tab;
detachBtn.Click += (s, _) =>
{
if (s is Button btn && btn.Tag is TabItem t)
SubTabControl.Items.Remove(t);
DetachHistorySubTabToWindow(t);
};

SubTabControl.Items.Add(tab);
SubTabControl.SelectedItem = tab;
}

private void OnQueryStorePlansSelected(object? sender, List<QueryStorePlan> plans)
private void OnHistoryPlanLoadRequested(object? sender, HistoryPlanLoadEventArgs e)
{
foreach (var qsPlan in plans)
{
var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
}
var plan = e.Plan;
var tabLabel = $"QS {plan.QueryId} / {plan.PlanId}";
AddPlanTab(plan.PlanXml, plan.QueryText, estimated: true, labelOverride: tabLabel);
}

SetStatus($"{plans.Count} Query Store plans loaded");
HumanAdviceButton.IsEnabled = true;
RobotAdviceButton.IsEnabled = true;
/// <summary>
/// Detaches a history sub-tab into a standalone free-floating window.
/// Close = destroy. A Re-dock button allows explicit return to sub-tabs.
/// </summary>
private void DetachHistorySubTabToWindow(TabItem tab)
{
var content = tab.Content as QueryStoreHistoryControl;
if (content == null) return;

var tabLabel = GetSubTabHeaderText(tab)?.Text ?? "History";

// Remove from sub-tabs
SubTabControl.Items.Remove(tab);
tab.Content = null;

var mainWindow = Avalonia.Controls.TopLevel.GetTopLevel(this) as Window;

content.ShowCloseButton(false);

DetachedWindowHelper.ShowDetached(
content,
title: tabLabel,
icon: mainWindow?.Icon,
backgroundBrush: (Avalonia.Media.IBrush?)this.FindResource("BackgroundBrush"),
onRedock: c =>
{
if (mainWindow is not MainWindow mw || !mw.IsShuttingDown)
AddHistorySubTab(tabLabel, (QueryStoreHistoryControl)c);
},
onClosing: c =>
{
if (c is QueryStoreHistoryControl hc)
hc.CancelFetch();
});
}
}
27 changes: 22 additions & 5 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ private static List<QueryStorePlan> CollectLeafPlans(QueryStoreRow row)
return plans;
}

private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
private void ViewHistory_Click(object? sender, RoutedEventArgs e)
{
if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
if (string.IsNullOrEmpty(row.QueryHash)) return;

var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);

var window = new QueryStoreHistoryWindow(
var control = new QueryStoreHistoryControl(
_connectionString,
row.QueryHash,
row.FullQueryText,
Expand All @@ -104,11 +104,28 @@ private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
slicerEndUtc: _slicerEndUtc,
slicerDaysBack: _slicerDaysBack);

var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
await window.ShowDialog(parentWindow);
var shortHash = row.QueryHash.Length > 8 ? row.QueryHash[..8] + "…" : row.QueryHash;

// Walk up the visual tree to find the parent QuerySessionControl
var session = this.FindAncestorOfType<QuerySessionControl>();
if (session != null)
{
session.AddHistorySubTab($"History: {shortHash}", control);
}
else
{
// Fallback: open as standalone window
var window = new QueryStoreHistoryWindow(
_connectionString,
row.QueryHash,
row.FullQueryText,
_database,
initialMetricTag: metricTag,
slicerStartUtc: _slicerStartUtc,
slicerEndUtc: _slicerEndUtc,
slicerDaysBack: _slicerDaysBack);
window.Show();
}
}

private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
Expand Down
Loading
Loading