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/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ private async Task ShowConnectionDialogAsync()

await PopulateDatabases();
await FetchServerMetadataAsync();
await FetchServerUtcOffset();

if (_selectedDatabase != null)
{
Expand Down Expand Up @@ -439,6 +440,22 @@ private async Task FetchServerMetadataAsync()
}
}

private async Task FetchServerUtcOffset()
{
if (_connectionString == null) return;
try
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync();
await using var cmd = new SqlCommand(
"SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn);
var offset = await cmd.ExecuteScalarAsync();
if (offset is int mins)
PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins;
}
catch { }
}

private async Task FetchDatabaseMetadataAsync()
{
if (_connectionString == null || _serverMetadata == null) return;
Expand Down
26 changes: 16 additions & 10 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
xmlns:local="using:PlanViewer.App.Controls"
x:Class="PlanViewer.App.Controls.QueryStoreGridControl"
Background="{DynamicResource BackgroundBrush}">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<!-- Time Range Slicer -->
<local:TimeRangeSlicerControl x:Name="TimeRangeSlicer" Grid.Row="0"/>

<!-- Toolbar -->
<Border Grid.Row="0" Background="{DynamicResource BackgroundDarkBrush}" Padding="8,6"
<Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" Padding="8,6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="12">
<StackPanel Spacing="4">
Expand All @@ -27,16 +30,10 @@
Width="120" Height="36" FontSize="14" FormatString="0"
HorizontalContentAlignment="Center"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Hours back" Foreground="{DynamicResource ForegroundBrush}" FontSize="11"/>
<NumericUpDown x:Name="HoursBackBox" Value="24" Minimum="1" Maximum="168"
Width="120" Height="36" FontSize="14" FormatString="0"
HorizontalContentAlignment="Center"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Order by" Foreground="{DynamicResource ForegroundBrush}" FontSize="11"/>
<ComboBox x:Name="OrderByBox" Width="160" Height="36" FontSize="13"
SelectedIndex="0">
SelectedIndex="0" SelectionChanged="OrderBy_SelectionChanged">
<ComboBoxItem Content="Total CPU" Tag="cpu"/>
<ComboBoxItem Content="Avg CPU" Tag="avg-cpu"/>
<ComboBoxItem Content="Total Duration" Tag="duration"/>
Expand Down Expand Up @@ -70,6 +67,15 @@
Watermark="0x1AB2C3, dbo.MyProc"
KeyDown="SearchValue_KeyDown"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Time display" Foreground="{DynamicResource ForegroundBrush}" FontSize="11"/>
<ComboBox x:Name="TimeDisplayBox" Width="100" Height="36" FontSize="13"
SelectedIndex="0" SelectionChanged="TimeDisplay_SelectionChanged">
<ComboBoxItem Content="Local" Tag="Local"/>
<ComboBoxItem Content="UTC" Tag="Utc"/>
<ComboBoxItem Content="Server" Tag="Server"/>
</ComboBox>
</StackPanel>
<StackPanel Spacing="2" VerticalAlignment="Center">
<Button x:Name="FetchButton" Content="Fetch" Click="Fetch_Click"
Height="28" Padding="16,0" FontSize="12"
Expand All @@ -86,7 +92,7 @@
</Border>

<!-- DataGrid -->
<DataGrid Grid.Row="1" x:Name="ResultsGrid"
<DataGrid Grid.Row="2" x:Name="ResultsGrid"
AutoGenerateColumns="False"
CanUserSortColumns="True"
CanUserReorderColumns="True"
Expand Down
176 changes: 165 additions & 11 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
using PlanViewer.App.Dialogs;
using PlanViewer.App.Services;
using PlanViewer.Core.Services;

namespace PlanViewer.App.Controls;
Expand All @@ -32,6 +33,12 @@ public partial class QueryStoreGridControl : UserControl
private ColumnFilterPopup? _filterPopupContent;
private string? _sortedColumnTag;
private bool _sortAscending;
private DateTime? _slicerStartUtc;
private DateTime? _slicerEndUtc;
private int _slicerDaysBack = 30;
private string _lastFetchedOrderBy = "cpu";
private bool _initialOrderByLoaded;
private bool _suppressRangeChanged;

public event EventHandler<List<QueryStorePlan>>? PlansSelected;
public event EventHandler<string>? DatabaseChanged;
Expand All @@ -45,11 +52,21 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
_credentialService = credentialService;
_database = initialDatabase;
_connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
_slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
InitializeComponent();
ResultsGrid.ItemsSource = _filteredRows;
EnsureFilterPopup();
SetupColumnHeaders();
PopulateDatabaseBox(databases, initialDatabase);
TimeRangeSlicer.RangeChanged += OnTimeRangeChanged;
TimeRangeSlicer.IsExpanded = true;

// Auto-fetch with default settings on connect
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Fetch_Click(null, new RoutedEventArgs());
_initialOrderByLoaded = true;
}, Avalonia.Threading.DispatcherPriority.Loaded);
}

private void PopulateDatabaseBox(List<string> databases, string selectedDatabase)
Expand Down Expand Up @@ -101,25 +118,61 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;

var topN = (int)(TopNBox.Value ?? 25);
var hoursBack = (int)(HoursBackBox.Value ?? 24);
var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
_lastFetchedOrderBy = orderBy;

FetchButton.IsEnabled = false;
LoadButton.IsEnabled = false;
StatusText.Text = "Loading time slicer...";
_rows.Clear();
_filteredRows.Clear();

try
{
// Load slicer data first — LoadData sets a default 24h selection and
// fires RangeChanged which triggers FetchPlansForRangeAsync.
await LoadTimeSlicerDataAsync(orderBy, ct);
}
catch (OperationCanceledException)
{
StatusText.Text = "Cancelled.";
}
catch (Exception ex)
{
StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
}
finally
{
FetchButton.IsEnabled = true;
}
}

private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
{
_fetchCts?.Cancel();
_fetchCts?.Dispose();
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;

var topN = (int)(TopNBox.Value ?? 25);
var orderBy = _lastFetchedOrderBy;
var filter = BuildSearchFilter();

FetchButton.IsEnabled = false;
LoadButton.IsEnabled = false;
StatusText.Text = "Fetching...";
StatusText.Text = "Fetching plans...";
_rows.Clear();
_filteredRows.Clear();

try
{
var plans = await QueryStoreService.FetchTopPlansAsync(
_connectionString, topN, orderBy, hoursBack, filter, ct);
_connectionString, topN, orderBy, ct: ct,
startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);

if (plans.Count == 0)
{
StatusText.Text = "No Query Store data found.";
StatusText.Text = "No Query Store data found for the selected range.";
return;
}

Expand Down Expand Up @@ -194,12 +247,114 @@ private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
}
}

private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (!_initialOrderByLoaded) return;
var newOrderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
if (newOrderBy == _lastFetchedOrderBy) return;

_lastFetchedOrderBy = newOrderBy;

_fetchCts?.Cancel();
_fetchCts?.Dispose();
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;

// Capture the current slicer selection so it survives the reload
var selStart = TimeRangeSlicer.SelectionStart;
var selEnd = TimeRangeSlicer.SelectionEnd;

FetchButton.IsEnabled = false;
StatusText.Text = "Refreshing metric...";

try
{
var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
_connectionString, newOrderBy, _slicerDaysBack, ct);
if (ct.IsCancellationRequested) return;

if (sliceData.Count > 0)
{
// Suppress the implicit RangeChanged fetch — we will refresh the grid explicitly below
_suppressRangeChanged = true;
try { TimeRangeSlicer.LoadData(sliceData, newOrderBy, selStart, selEnd); }
finally { _suppressRangeChanged = false; }

// Explicitly refresh the grid with the new metric and current time range
await FetchPlansForRangeAsync();
}
else
{
StatusText.Text = "No time-slicer data available.";
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
}
finally
{
FetchButton.IsEnabled = true;
}
}

private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (!IsInitialized) return;
var tag = (TimeDisplayBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
if (tag == null) return;
TimeDisplayHelper.Current = tag switch
{
"Utc" => TimeDisplayMode.Utc,
"Server" => TimeDisplayMode.Server,
_ => TimeDisplayMode.Local
};
// Refresh grid display
if (_filteredRows.Count > 0)
{
foreach (var row in _filteredRows)
row.NotifyTimeDisplayChanged();
ResultsGrid.ItemsSource = null;
ResultsGrid.ItemsSource = _filteredRows;
}
// Refresh slicer labels
TimeRangeSlicer.Redraw();
}

private void ClearSearch_Click(object? sender, RoutedEventArgs e)
{
SearchTypeBox.SelectedIndex = 0;
SearchValueBox.Text = "";
}

private async System.Threading.Tasks.Task LoadTimeSlicerDataAsync(string metric, CancellationToken ct)
{
try
{
var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
_connectionString, metric, _slicerDaysBack, ct);
if (ct.IsCancellationRequested) return;
if (sliceData.Count > 0)
TimeRangeSlicer.LoadData(sliceData, metric);
else
StatusText.Text = "No time-slicer data available.";
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
StatusText.Text = $"Slicer: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}";
}
}

private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs e)
{
_slicerStartUtc = e.StartUtc;
_slicerEndUtc = e.EndUtc;
if (_suppressRangeChanged) return;
await FetchPlansForRangeAsync();
}

private void SelectToggle_Click(object? sender, RoutedEventArgs e)
{
var allSelected = _filteredRows.Count > 0 && _filteredRows.All(r => r.IsSelected);
Expand All @@ -225,14 +380,11 @@ private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
{
if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;

var hoursBack = (int)(HoursBackBox.Value ?? 24);

var window = new QueryStoreHistoryWindow(
_connectionString,
row.QueryId,
row.FullQueryText,
_database,
hoursBack);
_database);

var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
Expand Down Expand Up @@ -588,7 +740,6 @@ private void ApplySortAndFilters()
: source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
}


_filteredRows.Clear();
foreach (var row in source)
_filteredRows.Add(row);
Expand Down Expand Up @@ -815,7 +966,10 @@ public void SetBar(string columnId, double ratio, bool isSorted)
public long TotalMemSort => Plan.TotalMemoryGrantPages;
public double AvgMemSort => Plan.AvgMemoryGrantPages;

public string LastExecutedLocal => Plan.LastExecutedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc);

public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal));

public string QueryPreview => Plan.QueryText.Length > 80
? Plan.QueryText[..80].Replace("\n", " ").Replace("\r", "") + "..."
: Plan.QueryText.Replace("\n", " ").Replace("\r", "");
Expand Down
41 changes: 41 additions & 0 deletions src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PlanViewer.App.Controls.TimeRangeSlicerControl">
<Border Background="{DynamicResource SlicerBackgroundBrush}"
BorderBrush="{DynamicResource SlicerBorderBrush}"
BorderThickness="0,0,0,1">
<Grid RowDefinitions="Auto,Auto">
<!-- Toggle header -->
<Button x:Name="ToggleButton" Grid.Row="0"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="8,4" Cursor="Hand"
Click="Toggle_Click">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock x:Name="ToggleIcon" Text="▾" FontSize="12"
Foreground="{DynamicResource SlicerToggleBrush}"
VerticalAlignment="Center"/>
<TextBlock x:Name="ToggleLabel" Text="Time Range"
FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SlicerToggleBrush}"
VerticalAlignment="Center"/>
<TextBlock x:Name="RangeLabel" Text=""
FontSize="11"
Foreground="{DynamicResource SlicerLabelBrush}"
VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
</Button>
<!-- Slicer canvas area -->
<Border x:Name="SlicerBorder" Grid.Row="1" Height="120" Margin="8,0,8,6"
Background="{DynamicResource SlicerBackgroundBrush}"
CornerRadius="4" ClipToBounds="True">
<Canvas x:Name="SlicerCanvas"
Background="Transparent"
PointerPressed="Canvas_PointerPressed"
PointerMoved="Canvas_PointerMoved"
PointerReleased="Canvas_PointerReleased"
PointerWheelChanged="Canvas_PointerWheelChanged"/>
</Border>
</Grid>
</Border>
</UserControl>
Loading
Loading