diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..7d14671
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,21 @@
+cff-version: 1.2.0
+title: "SQL Server Performance Studio"
+message: "If you use this software, please cite it as below."
+type: software
+authors:
+ - given-names: Erik
+ family-names: Darling
+ affiliation: "Darling Data, LLC"
+ website: "https://erikdarling.com"
+repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
+license: MIT
+version: "1.2.0"
+date-released: "2026-03-18"
+keywords:
+ - sql-server
+ - execution-plan
+ - query-plan-analyzer
+ - query-optimization
+ - mcp-server
+ - cross-platform
+ - dba-tools
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 0000000..bdf2bf0
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,21 @@
+# SQL Server Performance Studio
+
+> Free, open-source SQL Server execution plan analyzer by Erik Darling (Darling Data, LLC). Cross-platform desktop GUI (Avalonia) and CLI tool with 30 analysis rules that identify memory grants, row estimate mismatches, missing indexes, spills, parallel skew, parameter sniffing, implicit conversions, and more. Built-in MCP server for AI-assisted plan analysis. SSMS extension for one-click plan transfer. Runs on Windows, macOS, and Linux. MIT licensed.
+
+- Analyzes .sqlplan XML files or captures plans live from SQL Server
+- 30 rules covering memory, estimates, indexes, parallelism, joins, filters, functions, parameters, patterns, compilation, objects, and operators
+- Query Store browser with search by identifier, multi-plan history charting, and in-line bar charts
+- CLI supports batch processing of .sql file directories with JSON and text output
+- Built-in MCP server with 13 tools for plan analysis and Query Store data
+- SSMS 18-22 extension adds "Open in Performance Studio" context menu
+
+## Documentation
+
+- [README](https://github.com/erikdarlingdata/PerformanceStudio/blob/main/README.md): Complete documentation including quick start, CLI reference, analysis rules, MCP server setup, and platform support
+- [Releases](https://github.com/erikdarlingdata/PerformanceStudio/releases): Download pre-built binaries for Windows, macOS, and Linux
+- [Examples](https://github.com/erikdarlingdata/PerformanceStudio/tree/main/examples): Sample queries, plans, and analysis output
+
+## Optional
+
+- [License](https://github.com/erikdarlingdata/PerformanceStudio/blob/main/LICENSE): MIT License
+- [Third-party notices](https://github.com/erikdarlingdata/PerformanceStudio/blob/main/THIRD_PARTY_NOTICES.md): License information for bundled components
diff --git a/src/PlanViewer.App/App.axaml b/src/PlanViewer.App/App.axaml
index 1188cfa..6241169 100644
--- a/src/PlanViewer.App/App.axaml
+++ b/src/PlanViewer.App/App.axaml
@@ -20,6 +20,7 @@
+
diff --git a/src/PlanViewer.App/Controls/BarChartCell.axaml b/src/PlanViewer.App/Controls/BarChartCell.axaml
new file mode 100644
index 0000000..13c5090
--- /dev/null
+++ b/src/PlanViewer.App/Controls/BarChartCell.axaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/BarChartCell.axaml.cs b/src/PlanViewer.App/Controls/BarChartCell.axaml.cs
new file mode 100644
index 0000000..85735a3
--- /dev/null
+++ b/src/PlanViewer.App/Controls/BarChartCell.axaml.cs
@@ -0,0 +1,140 @@
+using Avalonia;
+using System;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace PlanViewer.App.Controls;
+
+///
+/// A DataGrid cell control that shows a text value above a proportional bar.
+/// The bar colour is resolved from application resources using the priority:
+/// BarChart.<ColumnId> → BarChart.Sorted (when isSorted) → BarChart.Default
+///
+public partial class BarChartCell : UserControl
+{
+ // ── Avalonia properties ────────────────────────────────────────────────
+
+ public static readonly StyledProperty DisplayTextProperty =
+ AvaloniaProperty.Register(nameof(DisplayText), string.Empty);
+
+ /// Value in [0..1] that controls the bar width.
+ public static readonly StyledProperty RatioProperty =
+ AvaloniaProperty.Register(nameof(Ratio), 0.0);
+
+ /// Column identifier used to look up per-column brush overrides.
+ public static readonly StyledProperty ColumnIdProperty =
+ AvaloniaProperty.Register(nameof(ColumnId), string.Empty);
+
+ /// When true the "Sorted" brush is preferred over "Default".
+ public static readonly StyledProperty IsSortedColumnProperty =
+ AvaloniaProperty.Register(nameof(IsSortedColumn), false);
+
+ // ── CLR accessors ──────────────────────────────────────────────────────
+
+ public string DisplayText
+ {
+ get => GetValue(DisplayTextProperty);
+ set => SetValue(DisplayTextProperty, value);
+ }
+
+ public double Ratio
+ {
+ get => GetValue(RatioProperty);
+ set => SetValue(RatioProperty, value);
+ }
+
+ public string ColumnId
+ {
+ get => GetValue(ColumnIdProperty);
+ set => SetValue(ColumnIdProperty, value);
+ }
+
+ public bool IsSortedColumn
+ {
+ get => GetValue(IsSortedColumnProperty);
+ set => SetValue(IsSortedColumnProperty, value);
+ }
+
+ // ── Constructor ────────────────────────────────────────────────────────
+
+ public BarChartCell()
+ {
+ InitializeComponent();
+ }
+
+ // ── Property-change overrides ──────────────────────────────────────────
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == DisplayTextProperty)
+ UpdateText();
+ else if (change.Property == RatioProperty || change.Property == BoundsProperty)
+ UpdateBar();
+ else if (change.Property == ColumnIdProperty || change.Property == IsSortedColumnProperty)
+ UpdateBarColor();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ UpdateText();
+ UpdateBar();
+ UpdateBarColor();
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ private void UpdateText()
+ {
+ if (ValueText != null)
+ ValueText.Text = DisplayText;
+ }
+
+ private void UpdateBar()
+ {
+ if (BarTrack == null || BarFill == null) return;
+
+ var ratio = global::System.Math.Max(0.0, global::System.Math.Min(1.0, Ratio));
+ var trackWidth = BarTrack.Bounds.Width;
+
+ // Fall back to control width when layout hasn't run yet
+ if (trackWidth <= 0)
+ trackWidth = Bounds.Width - 4; // subtract Margin="2,1" × 2
+
+ BarFill.Width = trackWidth > 0 ? ratio * trackWidth : 0;
+ }
+
+ private void UpdateBarColor()
+ {
+ if (BarFill == null) return;
+
+ // 1. Per-column override
+ if (!string.IsNullOrEmpty(ColumnId) &&
+ Application.Current!.TryFindResource($"BarChart.{ColumnId}", out var colRes) &&
+ colRes is IBrush colBrush)
+ {
+ BarFill.Background = colBrush;
+ return;
+ }
+
+ // 2. Sorted vs Default
+ var key = IsSortedColumn ? "BarChart.Sorted" : "BarChart.Default";
+ if (Application.Current!.TryFindResource(key, out var res) && res is IBrush brush)
+ BarFill.Background = brush;
+ else
+ BarFill.Background = IsSortedColumn
+ ? new SolidColorBrush(Color.FromRgb(0x2E, 0xAE, 0xF1))
+ : new SolidColorBrush(Color.FromRgb(0x3A, 0x6E, 0xA8));
+ }
+
+ // ── Layout override to update bar after measure ─────────────────────--
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var result = base.ArrangeOverride(finalSize);
+ UpdateBar();
+ return result;
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index fc29d39..3cb0528 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -973,6 +973,8 @@ private bool HasQueryStoreTab()
.Any(t => t.Content is QueryStoreGridControl);
}
+ public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs());
+
private async void QueryStore_Click(object? sender, RoutedEventArgs e)
{
// If a QS tab already exists, always show connection dialog for a fresh tab
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 02cbd46..c4a6e11 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -3,9 +3,12 @@
xmlns:local="using:PlanViewer.App.Controls"
x:Class="PlanViewer.App.Controls.QueryStoreGridControl"
Background="{DynamicResource BackgroundBrush}">
-
+
+
+
+
-
@@ -27,12 +30,6 @@
Width="120" Height="36" FontSize="14" FormatString="0"
HorizontalContentAlignment="Center"/>
-
-
-
-
-
@@ -122,25 +120,142 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 218e151..b2e5d2b 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -30,6 +30,10 @@ public partial class QueryStoreGridControl : UserControl
private readonly Dictionary _activeFilters = new();
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
+ private string? _sortedColumnTag;
+ private bool _sortAscending;
+ private DateTime? _slicerStartUtc;
+ private DateTime? _slicerEndUtc;
public event EventHandler>? PlansSelected;
public event EventHandler? DatabaseChanged;
@@ -48,6 +52,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
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()), Avalonia.Threading.DispatcherPriority.Loaded);
}
private void PopulateDatabaseBox(List databases, string selectedDatabase)
@@ -100,7 +109,6 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
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";
var filter = BuildSearchFilter();
@@ -113,7 +121,7 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
try
{
var plans = await QueryStoreService.FetchTopPlansAsync(
- _connectionString, topN, orderBy, hoursBack, filter, ct);
+ _connectionString, topN, orderBy, 8760, filter, ct);
if (plans.Count == 0)
{
@@ -127,6 +135,9 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
ApplyFilters();
LoadButton.IsEnabled = true;
SelectToggleButton.Content = "Select None";
+
+ // Load time-slicer data in the background
+ _ = LoadTimeSlicerDataAsync(orderBy, ct);
}
catch (OperationCanceledException)
{
@@ -198,6 +209,24 @@ private void ClearSearch_Click(object? sender, RoutedEventArgs e)
SearchValueBox.Text = "";
}
+ private async System.Threading.Tasks.Task LoadTimeSlicerDataAsync(string metric, CancellationToken ct)
+ {
+ try
+ {
+ var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(_connectionString, metric, ct);
+ if (!ct.IsCancellationRequested && sliceData.Count > 0)
+ TimeRangeSlicer.LoadData(sliceData, metric);
+ }
+ catch { /* non-critical — slicer just stays empty */ }
+ }
+
+ private void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs e)
+ {
+ _slicerStartUtc = e.StartUtc;
+ _slicerEndUtc = e.EndUtc;
+ ApplySortAndFilters();
+ }
+
private void SelectToggle_Click(object? sender, RoutedEventArgs e)
{
var allSelected = _filteredRows.Count > 0 && _filteredRows.All(r => r.IsSelected);
@@ -223,14 +252,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)
@@ -425,7 +451,7 @@ private void OnFilterApplied(object? sender, FilterAppliedEventArgs e)
_activeFilters[e.FilterState.ColumnName] = e.FilterState;
else
_activeFilters.Remove(e.FilterState.ColumnName);
- ApplyFilters();
+ ApplySortAndFilters();
UpdateFilterButtonStyles();
}
@@ -458,13 +484,7 @@ private void UpdateFilterButtonStyles()
private void ApplyFilters()
{
- _filteredRows.Clear();
- foreach (var row in _rows)
- {
- if (RowMatchesAllFilters(row))
- _filteredRows.Add(row);
- }
- UpdateStatusText();
+ ApplySortAndFilters();
}
private bool RowMatchesAllFilters(QueryStoreRow row)
@@ -524,12 +544,209 @@ private void UpdateStatusText()
? $"{_rows.Count} plans"
: $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
}
+
+ private void ResultsGrid_Sorting(object? sender, DataGridColumnEventArgs e)
+ {
+ e.Handled = true;
+
+ var colTag = e.Column.Tag as string ?? e.Column.SortMemberPath;
+ if (colTag == null) return;
+
+ // Toggle: first click on a new column → descending; second click → ascending; third → clear
+ if (_sortedColumnTag == colTag)
+ {
+ if (!_sortAscending)
+ _sortAscending = true; // descending → ascending
+ else
+ {
+ // ascending → clear sort
+ _sortedColumnTag = null;
+ foreach (var col in ResultsGrid.Columns)
+ col.Tag = col.Tag; // no-op, just reset indicator below
+ UpdateSortIndicators(null);
+ ApplySortAndFilters();
+ return;
+ }
+ }
+ else
+ {
+ _sortedColumnTag = colTag;
+ _sortAscending = false; // first click → descending
+ }
+
+ UpdateSortIndicators(e.Column);
+ ApplySortAndFilters();
+ }
+
+ private void UpdateSortIndicators(DataGridColumn? activeColumn)
+ {
+ foreach (var col in ResultsGrid.Columns)
+ {
+ if (col.Header is not StackPanel sp) continue;
+ var label = sp.Children.OfType().LastOrDefault();
+ if (label == null) continue;
+
+ if (col == activeColumn)
+ label.Text = _sortAscending ? $"{GetColumnLabel(sp)} ▲" : $"{GetColumnLabel(sp)} ▼";
+ else
+ label.Text = GetColumnLabel(sp);
+ }
+ }
+
+ private static string GetColumnLabel(StackPanel header)
+ {
+ var tb = header.Children.OfType().LastOrDefault();
+ if (tb == null) return string.Empty;
+ // Strip any existing sort indicator
+ return tb.Text?.TrimEnd(' ', '▲', '▼') ?? string.Empty;
+ }
+
+ private void ApplySortAndFilters()
+ {
+ IEnumerable source = _rows.Where(RowMatchesAllFilters);
+
+ // Apply time-range slicer filter
+ if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ {
+ var start = _slicerStartUtc.Value;
+ var end = _slicerEndUtc.Value;
+ source = source.Where(r => r.Plan.LastExecutedUtc >= start && r.Plan.LastExecutedUtc <= end);
+ }
+
+ if (_sortedColumnTag != null)
+ {
+ source = _sortAscending
+ ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
+ : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
+ }
+
+ _filteredRows.Clear();
+ foreach (var row in source)
+ _filteredRows.Add(row);
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ // ── Bar chart ratio computation ────────────────────────────────────────
+
+ // Maps a ColumnId (used in BarChartConfig) to the accessor that returns the raw sort value.
+ private static readonly (string ColumnId, Func Accessor)[] BarColumns =
+ [
+ ("Executions", r => r.ExecsSort),
+ ("TotalCpu", r => r.TotalCpuSort),
+ ("AvgCpu", r => r.AvgCpuSort),
+ ("TotalDuration", r => r.TotalDurSort),
+ ("AvgDuration", r => r.AvgDurSort),
+ ("TotalReads", r => r.TotalReadsSort),
+ ("AvgReads", r => r.AvgReadsSort),
+ ("TotalWrites", r => r.TotalWritesSort),
+ ("AvgWrites", r => r.AvgWritesSort),
+ ("TotalPhysReads",r => r.TotalPhysReadsSort),
+ ("AvgPhysReads", r => r.AvgPhysReadsSort),
+ ("TotalMemory", r => r.TotalMemSort),
+ ("AvgMemory", r => r.AvgMemSort),
+ ];
+
+ // Maps a SortMemberPath tag (used in the sort dictionary) → ColumnId
+ private static readonly Dictionary SortTagToColumnId = new()
+ {
+ ["ExecsSort"] = "Executions",
+ ["TotalCpuSort"] = "TotalCpu",
+ ["AvgCpuSort"] = "AvgCpu",
+ ["TotalDurSort"] = "TotalDuration",
+ ["AvgDurSort"] = "AvgDuration",
+ ["TotalReadsSort"] = "TotalReads",
+ ["AvgReadsSort"] = "AvgReads",
+ ["TotalWritesSort"] = "TotalWrites",
+ ["AvgWritesSort"] = "AvgWrites",
+ ["TotalPhysReadsSort"] = "TotalPhysReads",
+ ["AvgPhysReadsSort"] = "AvgPhysReads",
+ ["TotalMemSort"] = "TotalMemory",
+ ["AvgMemSort"] = "AvgMemory",
+ };
+
+ private void UpdateBarRatios()
+ {
+ if (_filteredRows.Count == 0) return;
+
+ var sortedColumnId = _sortedColumnTag != null &&
+ SortTagToColumnId.TryGetValue(_sortedColumnTag, out var sid) ? sid : null;
+
+ foreach (var (columnId, accessor) in BarColumns)
+ {
+ var max = _filteredRows.Max(r => accessor(r));
+ var isSorted = columnId == sortedColumnId;
+ foreach (var row in _filteredRows)
+ {
+ var ratio = max > 0 ? accessor(row) / max : 0.0;
+ row.SetBar(columnId, ratio, isSorted);
+ }
+ }
+ }
+
+ private static IComparable GetSortKey(string columnTag, QueryStoreRow r) =>
+ columnTag switch
+ {
+ // Columns with no SortMemberPath: Avalonia uses the binding property name as key
+ "QueryId" => (IComparable)r.QueryId,
+ "PlanId" => r.PlanId,
+ "QueryHash" => r.QueryHash,
+ "QueryPlanHash" => r.QueryPlanHash,
+ "ModuleName" => r.ModuleName,
+ "LastExecutedLocal" => r.LastExecutedLocal,
+ // Columns with explicit SortMemberPath
+ "ExecsSort" => r.ExecsSort,
+ "TotalCpuSort" => r.TotalCpuSort,
+ "AvgCpuSort" => r.AvgCpuSort,
+ "TotalDurSort" => r.TotalDurSort,
+ "AvgDurSort" => r.AvgDurSort,
+ "TotalReadsSort" => r.TotalReadsSort,
+ "AvgReadsSort" => r.AvgReadsSort,
+ "TotalWritesSort" => r.TotalWritesSort,
+ "AvgWritesSort" => r.AvgWritesSort,
+ "TotalPhysReadsSort" => r.TotalPhysReadsSort,
+ "AvgPhysReadsSort" => r.AvgPhysReadsSort,
+ "TotalMemSort" => r.TotalMemSort,
+ "AvgMemSort" => r.AvgMemSort,
+ _ => r.LastExecutedLocal,
+ };
}
public class QueryStoreRow : INotifyPropertyChanged
{
private bool _isSelected = true;
+ // Bar ratios [0..1] per column
+ private double _execsRatio;
+ private double _totalCpuRatio;
+ private double _avgCpuRatio;
+ private double _totalDurRatio;
+ private double _avgDurRatio;
+ private double _totalReadsRatio;
+ private double _avgReadsRatio;
+ private double _totalWritesRatio;
+ private double _avgWritesRatio;
+ private double _totalPhysReadsRatio;
+ private double _avgPhysReadsRatio;
+ private double _totalMemRatio;
+ private double _avgMemRatio;
+
+ // IsSortedColumn flags
+ private bool _isSorted_Executions;
+ private bool _isSorted_TotalCpu;
+ private bool _isSorted_AvgCpu;
+ private bool _isSorted_TotalDuration;
+ private bool _isSorted_AvgDuration;
+ private bool _isSorted_TotalReads;
+ private bool _isSorted_AvgReads;
+ private bool _isSorted_TotalWrites;
+ private bool _isSorted_AvgWrites;
+ private bool _isSorted_TotalPhysReads;
+ private bool _isSorted_AvgPhysReads;
+ private bool _isSorted_TotalMemory;
+ private bool _isSorted_AvgMemory;
+
public QueryStoreRow(QueryStorePlan plan)
{
Plan = plan;
@@ -543,6 +760,57 @@ public bool IsSelected
set { _isSelected = value; OnPropertyChanged(); }
}
+ // ── Bar ratio properties ───────────────────────────────────────────────
+ public double ExecsRatio { get => _execsRatio; private set { _execsRatio = value; OnPropertyChanged(); } }
+ public double TotalCpuRatio { get => _totalCpuRatio; private set { _totalCpuRatio = value; OnPropertyChanged(); } }
+ public double AvgCpuRatio { get => _avgCpuRatio; private set { _avgCpuRatio = value; OnPropertyChanged(); } }
+ public double TotalDurRatio { get => _totalDurRatio; private set { _totalDurRatio = value; OnPropertyChanged(); } }
+ public double AvgDurRatio { get => _avgDurRatio; private set { _avgDurRatio = value; OnPropertyChanged(); } }
+ public double TotalReadsRatio { get => _totalReadsRatio; private set { _totalReadsRatio = value; OnPropertyChanged(); } }
+ public double AvgReadsRatio { get => _avgReadsRatio; private set { _avgReadsRatio = value; OnPropertyChanged(); } }
+ public double TotalWritesRatio { get => _totalWritesRatio; private set { _totalWritesRatio = value; OnPropertyChanged(); } }
+ public double AvgWritesRatio { get => _avgWritesRatio; private set { _avgWritesRatio = value; OnPropertyChanged(); } }
+ public double TotalPhysReadsRatio{ get => _totalPhysReadsRatio; private set { _totalPhysReadsRatio = value; OnPropertyChanged(); } }
+ public double AvgPhysReadsRatio { get => _avgPhysReadsRatio; private set { _avgPhysReadsRatio = value; OnPropertyChanged(); } }
+ public double TotalMemRatio { get => _totalMemRatio; private set { _totalMemRatio = value; OnPropertyChanged(); } }
+ public double AvgMemRatio { get => _avgMemRatio; private set { _avgMemRatio = value; OnPropertyChanged(); } }
+
+ // ── IsSortedColumn properties ──────────────────────────────────────────
+ public bool IsSortedColumn_Executions { get => _isSorted_Executions; private set { _isSorted_Executions = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalCpu { get => _isSorted_TotalCpu; private set { _isSorted_TotalCpu = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgCpu { get => _isSorted_AvgCpu; private set { _isSorted_AvgCpu = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalDuration { get => _isSorted_TotalDuration; private set { _isSorted_TotalDuration = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgDuration { get => _isSorted_AvgDuration; private set { _isSorted_AvgDuration = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalReads { get => _isSorted_TotalReads; private set { _isSorted_TotalReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgReads { get => _isSorted_AvgReads; private set { _isSorted_AvgReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalWrites { get => _isSorted_TotalWrites; private set { _isSorted_TotalWrites = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgWrites { get => _isSorted_AvgWrites; private set { _isSorted_AvgWrites = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalPhysReads{ get => _isSorted_TotalPhysReads;private set { _isSorted_TotalPhysReads = value;OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgPhysReads { get => _isSorted_AvgPhysReads; private set { _isSorted_AvgPhysReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalMemory { get => _isSorted_TotalMemory; private set { _isSorted_TotalMemory = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgMemory { get => _isSorted_AvgMemory; private set { _isSorted_AvgMemory = value; OnPropertyChanged(); } }
+
+ /// Called by the grid after each sort/filter pass to update bar rendering.
+ public void SetBar(string columnId, double ratio, bool isSorted)
+ {
+ switch (columnId)
+ {
+ case "Executions": ExecsRatio = ratio; IsSortedColumn_Executions = isSorted; break;
+ case "TotalCpu": TotalCpuRatio = ratio; IsSortedColumn_TotalCpu = isSorted; break;
+ case "AvgCpu": AvgCpuRatio = ratio; IsSortedColumn_AvgCpu = isSorted; break;
+ case "TotalDuration": TotalDurRatio = ratio; IsSortedColumn_TotalDuration = isSorted; break;
+ case "AvgDuration": AvgDurRatio = ratio; IsSortedColumn_AvgDuration = isSorted; break;
+ case "TotalReads": TotalReadsRatio = ratio; IsSortedColumn_TotalReads = isSorted; break;
+ case "AvgReads": AvgReadsRatio = ratio; IsSortedColumn_AvgReads = isSorted; break;
+ case "TotalWrites": TotalWritesRatio = ratio; IsSortedColumn_TotalWrites = isSorted; break;
+ case "AvgWrites": AvgWritesRatio = ratio; IsSortedColumn_AvgWrites = isSorted; break;
+ case "TotalPhysReads": TotalPhysReadsRatio = ratio; IsSortedColumn_TotalPhysReads = isSorted; break;
+ case "AvgPhysReads": AvgPhysReadsRatio = ratio; IsSortedColumn_AvgPhysReads = isSorted; break;
+ case "TotalMemory": TotalMemRatio = ratio; IsSortedColumn_TotalMemory = isSorted; break;
+ case "AvgMemory": AvgMemRatio = ratio; IsSortedColumn_AvgMemory = isSorted; break;
+ }
+ }
+
public long QueryId => Plan.QueryId;
public long PlanId => Plan.PlanId;
public string QueryHash => Plan.QueryHash;
diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml
new file mode 100644
index 0000000..ef28a01
--- /dev/null
+++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
new file mode 100644
index 0000000..c368c5b
--- /dev/null
+++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
@@ -0,0 +1,537 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Controls;
+
+public partial class TimeRangeSlicerControl : UserControl
+{
+ private List _data = new();
+ private string _metric = "cpu";
+ private bool _isExpanded = true;
+
+ // Range as normalised [0..1] positions within _data
+ private double _rangeStart;
+ private double _rangeEnd = 1.0;
+
+ private const double HandleWidthPx = 8;
+ private const double HandleGripWidthPx = 20; // extended hit-test area for easier grabbing
+ private const double MinIntervalHours = 3;
+ private const double ChartPaddingTop = 16;
+ private const double ChartPaddingBottom = 20;
+
+ private enum DragMode { None, MoveRange, DragStart, DragEnd }
+ private DragMode _dragMode = DragMode.None;
+ private double _dragOriginX;
+ private double _dragOriginRangeStart;
+ private double _dragOriginRangeEnd;
+
+ public event EventHandler? RangeChanged;
+
+ public TimeRangeSlicerControl()
+ {
+ InitializeComponent();
+ SlicerBorder.SizeChanged += (_, _) => Redraw();
+ }
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ _isExpanded = value;
+ SlicerBorder.IsVisible = _isExpanded;
+ ToggleIcon.Text = _isExpanded ? "▾" : "▸";
+ }
+ }
+
+ public void LoadData(List data, string metric)
+ {
+ _data = data;
+ _metric = metric;
+ _rangeStart = 0;
+ _rangeEnd = 1;
+ UpdateRangeLabel();
+ Redraw();
+ FireRangeChanged();
+ }
+
+ public void SetMetric(string metric)
+ {
+ _metric = metric;
+ Redraw();
+ }
+
+ public DateTime? SelectionStart => _data.Count > 0
+ ? GetDateTimeAtNorm(_rangeStart)
+ : null;
+
+ public DateTime? SelectionEnd => _data.Count > 0
+ ? GetDateTimeAtNorm(_rangeEnd)
+ : null;
+
+ private DateTime GetDateTimeAtNorm(double norm)
+ {
+ if (_data.Count == 0) return DateTime.UtcNow;
+ var first = _data[0].IntervalStartUtc;
+ var last = _data[^1].IntervalStartUtc.AddHours(1);
+ var ticks = first.Ticks + (long)((last.Ticks - first.Ticks) * norm);
+ return new DateTime(Math.Clamp(ticks, first.Ticks, last.Ticks), DateTimeKind.Utc);
+ }
+
+ private double GetNormFromDateTime(DateTime dt)
+ {
+ if (_data.Count == 0) return 0;
+ var first = _data[0].IntervalStartUtc;
+ var last = _data[^1].IntervalStartUtc.AddHours(1);
+ if (last <= first) return 0;
+ return Math.Clamp((double)(dt.Ticks - first.Ticks) / (last.Ticks - first.Ticks), 0, 1);
+ }
+
+ private double MinNormInterval
+ {
+ get
+ {
+ if (_data.Count == 0) return 0;
+ var first = _data[0].IntervalStartUtc;
+ var last = _data[^1].IntervalStartUtc.AddHours(1);
+ var totalHours = (last - first).TotalHours;
+ if (totalHours <= 0) return 1;
+ return Math.Min(MinIntervalHours / totalHours, 1);
+ }
+ }
+
+ private void Toggle_Click(object? sender, RoutedEventArgs e)
+ {
+ IsExpanded = !IsExpanded;
+ }
+
+ private double[] GetMetricValues()
+ {
+ return _metric switch
+ {
+ "cpu" or "avg-cpu" => _data.Select(d => d.TotalCpu).ToArray(),
+ "duration" or "avg-duration" => _data.Select(d => d.TotalDuration).ToArray(),
+ "reads" or "avg-reads" => _data.Select(d => d.TotalReads).ToArray(),
+ "writes" or "avg-writes" => _data.Select(d => d.TotalWrites).ToArray(),
+ "physical-reads" or "avg-physical-reads" => _data.Select(d => d.TotalPhysicalReads).ToArray(),
+ "memory" or "avg-memory" => _data.Select(d => d.TotalMemory).ToArray(),
+ "executions" => _data.Select(d => (double)d.TotalExecutions).ToArray(),
+ _ => _data.Select(d => d.TotalCpu).ToArray(),
+ };
+ }
+
+ private string GetMetricLabel()
+ {
+ return _metric switch
+ {
+ "cpu" or "avg-cpu" => "Total CPU (ms)",
+ "duration" or "avg-duration" => "Total Duration (ms)",
+ "reads" or "avg-reads" => "Total Reads",
+ "writes" or "avg-writes" => "Total Writes",
+ "physical-reads" or "avg-physical-reads" => "Total Physical Reads",
+ "memory" or "avg-memory" => "Total Memory (MB)",
+ "executions" => "Executions",
+ _ => "Total CPU (ms)",
+ };
+ }
+
+ // ── Drawing ────────────────────────────────────────────────────────────
+
+ private void Redraw()
+ {
+ SlicerCanvas.Children.Clear();
+ if (_data.Count < 2) return;
+
+ // Use the parent Border bounds — Canvas has no intrinsic size
+ var w = SlicerBorder.Bounds.Width;
+ var h = SlicerBorder.Bounds.Height;
+ if (w <= 0 || h <= 0) return;
+
+ var values = GetMetricValues();
+ var max = values.Max();
+ if (max <= 0) max = 1;
+
+ var chartTop = ChartPaddingTop;
+ var chartBottom = h - ChartPaddingBottom;
+ var chartHeight = chartBottom - chartTop;
+ if (chartHeight <= 0) return;
+
+ var n = values.Length;
+ var stepX = w / n;
+
+ // Draw filled area + line for the chart
+ var linePoints = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var x = i * stepX + stepX / 2;
+ var y = chartBottom - (values[i] / max) * chartHeight;
+ linePoints.Add(new Point(x, y));
+ }
+
+ // Area fill
+ var fillBrush = TryFindBrush("SlicerChartFillBrush", new SolidColorBrush(Color.Parse("#332EAEF1")));
+ var areaGeometry = new StreamGeometry();
+ using (var ctx = areaGeometry.Open())
+ {
+ ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true);
+ foreach (var pt in linePoints)
+ ctx.LineTo(pt);
+ ctx.LineTo(new Point(linePoints[^1].X, chartBottom));
+ ctx.EndFigure(true);
+ }
+ SlicerCanvas.Children.Add(new Path
+ {
+ Data = areaGeometry,
+ Fill = fillBrush,
+ });
+
+ // Line
+ var lineBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1")));
+ var lineGeometry = new StreamGeometry();
+ using (var ctx = lineGeometry.Open())
+ {
+ ctx.BeginFigure(linePoints[0], false);
+ for (int i = 1; i < linePoints.Count; i++)
+ ctx.LineTo(linePoints[i]);
+ ctx.EndFigure(false);
+ }
+ SlicerCanvas.Children.Add(new Path
+ {
+ Data = lineGeometry,
+ Stroke = lineBrush,
+ StrokeThickness = 1.5,
+ });
+
+ // X-axis labels (show a few ticks)
+ var labelBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#99E4E6EB")));
+ int labelInterval = Math.Max(1, n / 8);
+ for (int i = 0; i < n; i += labelInterval)
+ {
+ var x = i * stepX + stepX / 2;
+ var dt = _data[i].IntervalStartUtc.ToLocalTime();
+ var label = dt.ToString("MM/dd HH:mm");
+ var tb = new TextBlock
+ {
+ Text = label,
+ FontSize = 9,
+ Foreground = labelBrush,
+ };
+ Canvas.SetLeft(tb, x - 25);
+ Canvas.SetTop(tb, chartBottom + 2);
+ SlicerCanvas.Children.Add(tb);
+ }
+
+ // Metric label top-right
+ var metricTb = new TextBlock
+ {
+ Text = GetMetricLabel(),
+ FontSize = 9,
+ Foreground = labelBrush,
+ };
+ Canvas.SetRight(metricTb, 4);
+ Canvas.SetTop(metricTb, 2);
+ SlicerCanvas.Children.Add(metricTb);
+
+ // ── Overlays + selection ───────────────────────────────────────────
+ var overlayBrush = TryFindBrush("SlicerOverlayBrush", new SolidColorBrush(Color.Parse("#99000000")));
+ var selectedBrush = TryFindBrush("SlicerSelectedBrush", new SolidColorBrush(Color.Parse("#22FFFFFF")));
+ var handleBrush = TryFindBrush("SlicerHandleBrush", new SolidColorBrush(Color.Parse("#E4E6EB")));
+
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ // Left overlay
+ if (selLeft > 0)
+ {
+ SlicerCanvas.Children.Add(new Rectangle
+ {
+ Width = selLeft,
+ Height = h,
+ Fill = overlayBrush,
+ });
+ Canvas.SetLeft(SlicerCanvas.Children[^1], 0);
+ Canvas.SetTop(SlicerCanvas.Children[^1], 0);
+ }
+
+ // Right overlay
+ if (selRight < w)
+ {
+ SlicerCanvas.Children.Add(new Rectangle
+ {
+ Width = w - selRight,
+ Height = h,
+ Fill = overlayBrush,
+ });
+ Canvas.SetLeft(SlicerCanvas.Children[^1], selRight);
+ Canvas.SetTop(SlicerCanvas.Children[^1], 0);
+ }
+
+ // Selected region (darker = more visible)
+ SlicerCanvas.Children.Add(new Rectangle
+ {
+ Width = Math.Max(0, selRight - selLeft),
+ Height = h,
+ Fill = selectedBrush,
+ });
+ Canvas.SetLeft(SlicerCanvas.Children[^1], selLeft);
+ Canvas.SetTop(SlicerCanvas.Children[^1], 0);
+
+ // Left handle
+ DrawHandle(selLeft, h, handleBrush);
+ // Right handle
+ DrawHandle(selRight - HandleWidthPx, h, handleBrush);
+
+ // Selection border top and bottom lines
+ var borderBrush = TryFindBrush("SlicerHandleBrush", handleBrush);
+ // Top border of selection
+ SlicerCanvas.Children.Add(new Line
+ {
+ StartPoint = new Point(selLeft, 0),
+ EndPoint = new Point(selRight, 0),
+ Stroke = borderBrush,
+ StrokeThickness = 1,
+ Opacity = 0.5,
+ });
+ // Bottom border of selection
+ SlicerCanvas.Children.Add(new Line
+ {
+ StartPoint = new Point(selLeft, h),
+ EndPoint = new Point(selRight, h),
+ Stroke = borderBrush,
+ StrokeThickness = 1,
+ Opacity = 0.5,
+ });
+ }
+
+ private void DrawHandle(double x, double canvasHeight, IBrush brush)
+ {
+ // Handle bar
+ SlicerCanvas.Children.Add(new Rectangle
+ {
+ Width = HandleWidthPx,
+ Height = canvasHeight,
+ Fill = brush,
+ Opacity = 0.7,
+ });
+ Canvas.SetLeft(SlicerCanvas.Children[^1], x);
+ Canvas.SetTop(SlicerCanvas.Children[^1], 0);
+
+ // Grip lines (3 short horizontal lines in the middle of the handle)
+ var midY = canvasHeight / 2;
+ for (int i = -1; i <= 1; i++)
+ {
+ var gy = midY + i * 5;
+ SlicerCanvas.Children.Add(new Line
+ {
+ StartPoint = new Point(x + 2, gy),
+ EndPoint = new Point(x + HandleWidthPx - 2, gy),
+ Stroke = Brushes.Black,
+ StrokeThickness = 1,
+ Opacity = 0.6,
+ });
+ }
+ }
+
+ private IBrush TryFindBrush(string key, IBrush fallback)
+ {
+ if (this.TryFindResource(key, this.ActualThemeVariant, out var resource) && resource is IBrush brush)
+ return brush;
+ return fallback;
+ }
+
+ // ── Interaction ────────────────────────────────────────────────────────
+
+ private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var pos = e.GetPosition(SlicerCanvas);
+ var w = SlicerBorder.Bounds.Width;
+ if (w <= 0) return;
+
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ _dragOriginX = pos.X;
+ _dragOriginRangeStart = _rangeStart;
+ _dragOriginRangeEnd = _rangeEnd;
+
+ // Check if near left handle
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx)
+ {
+ _dragMode = DragMode.DragStart;
+ e.Pointer.Capture(SlicerCanvas);
+ e.Handled = true;
+ return;
+ }
+
+ // Check if near right handle
+ if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ {
+ _dragMode = DragMode.DragEnd;
+ e.Pointer.Capture(SlicerCanvas);
+ e.Handled = true;
+ return;
+ }
+
+ // Check if inside selection → move
+ if (pos.X >= selLeft && pos.X <= selRight)
+ {
+ _dragMode = DragMode.MoveRange;
+ e.Pointer.Capture(SlicerCanvas);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ private void Canvas_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var w = SlicerBorder.Bounds.Width;
+ if (w <= 0) return;
+
+ var pos = e.GetPosition(SlicerCanvas);
+
+ if (_dragMode == DragMode.None)
+ {
+ // Update cursor
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx ||
+ Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ {
+ SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast);
+ }
+ else if (pos.X >= selLeft && pos.X <= selRight)
+ {
+ SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeAll);
+ }
+ else
+ {
+ SlicerCanvas.Cursor = Cursor.Default;
+ }
+ return;
+ }
+
+ var deltaX = pos.X - _dragOriginX;
+ var deltaNorm = deltaX / w;
+ var minInterval = MinNormInterval;
+
+ switch (_dragMode)
+ {
+ case DragMode.DragStart:
+ {
+ var newStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - minInterval);
+ _rangeStart = newStart;
+ break;
+ }
+ case DragMode.DragEnd:
+ {
+ var newEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + minInterval, 1);
+ _rangeEnd = newEnd;
+ break;
+ }
+ case DragMode.MoveRange:
+ {
+ var span = _dragOriginRangeEnd - _dragOriginRangeStart;
+ var newStart = _dragOriginRangeStart + deltaNorm;
+ if (newStart < 0) newStart = 0;
+ if (newStart + span > 1) newStart = 1 - span;
+ _rangeStart = newStart;
+ _rangeEnd = newStart + span;
+ break;
+ }
+ }
+
+ UpdateRangeLabel();
+ Redraw();
+ e.Handled = true;
+ }
+
+ private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (_dragMode != DragMode.None)
+ {
+ _dragMode = DragMode.None;
+ e.Pointer.Capture(null);
+ FireRangeChanged();
+ e.Handled = true;
+ }
+ }
+
+ private void Canvas_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ // Only zoom if Ctrl is held
+ if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) return;
+
+ var w = SlicerBorder.Bounds.Width;
+ if (w <= 0) return;
+
+ var pos = e.GetPosition(SlicerCanvas);
+ var pivot = Math.Clamp(pos.X / w, 0, 1);
+ var span = _rangeEnd - _rangeStart;
+ var minInterval = MinNormInterval;
+
+ // Zoom in (wheel up) → shrink span; zoom out (wheel down) → expand span
+ var zoomFactor = e.Delta.Y > 0 ? 0.85 : 1.0 / 0.85;
+ var newSpan = Math.Clamp(span * zoomFactor, minInterval, 1.0);
+
+ // Keep the pivot point stable in the range
+ var pivotInRange = (pivot - _rangeStart) / span;
+ var newStart = pivot - pivotInRange * newSpan;
+ var newEnd = newStart + newSpan;
+
+ if (newStart < 0) { newStart = 0; newEnd = newSpan; }
+ if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; }
+
+ _rangeStart = Math.Max(0, newStart);
+ _rangeEnd = Math.Min(1, newEnd);
+
+ UpdateRangeLabel();
+ Redraw();
+ FireRangeChanged();
+ e.Handled = true;
+ }
+
+ private void UpdateRangeLabel()
+ {
+ if (_data.Count == 0)
+ {
+ RangeLabel.Text = "";
+ return;
+ }
+ var start = GetDateTimeAtNorm(_rangeStart).ToLocalTime();
+ var end = GetDateTimeAtNorm(_rangeEnd).ToLocalTime();
+ var span = end - start;
+ RangeLabel.Text = $"{start:yyyy-MM-dd HH:mm} → {end:yyyy-MM-dd HH:mm} ({span.TotalHours:F0}h)";
+ }
+
+ private void FireRangeChanged()
+ {
+ if (_data.Count == 0) return;
+ RangeChanged?.Invoke(this, new TimeRangeChangedEventArgs(
+ GetDateTimeAtNorm(_rangeStart),
+ GetDateTimeAtNorm(_rangeEnd)));
+ }
+}
+
+public class TimeRangeChangedEventArgs : EventArgs
+{
+ public DateTime StartUtc { get; }
+ public DateTime EndUtc { get; }
+
+ public TimeRangeChangedEventArgs(DateTime startUtc, DateTime endUtc)
+ {
+ StartUtc = startUtc;
+ EndUtc = endUtc;
+ }
+}
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 1adb87d..9254409 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -562,10 +562,19 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
Margin = new Avalonia.Thickness(6, 0, 0, 0),
VerticalContentAlignment = VerticalAlignment.Center,
HorizontalContentAlignment = HorizontalAlignment.Center,
- IsEnabled = false,
Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
};
- ToolTip.SetTip(queryStoreBtn, "Connect to a server (Ctrl+N) to use Query Store");
+ ToolTip.SetTip(queryStoreBtn, "Open a Query Store session");
+ queryStoreBtn.Click += (_, _) =>
+ {
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ var tab = CreateTab($"Query {_queryCounter}", session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ session.TriggerQueryStore();
+ };
var toolbar = new StackPanel
{
diff --git a/src/PlanViewer.App/Themes/BarChartConfig.axaml b/src/PlanViewer.App/Themes/BarChartConfig.axaml
new file mode 100644
index 0000000..86c5e11
--- /dev/null
+++ b/src/PlanViewer.App/Themes/BarChartConfig.axaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Themes/DarkTheme.axaml b/src/PlanViewer.App/Themes/DarkTheme.axaml
index 05c8b14..45b56d9 100644
--- a/src/PlanViewer.App/Themes/DarkTheme.axaml
+++ b/src/PlanViewer.App/Themes/DarkTheme.axaml
@@ -52,4 +52,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs b/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs
new file mode 100644
index 0000000..b27ad4f
--- /dev/null
+++ b/src/PlanViewer.Core/Models/QueryStoreTimeSlice.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace PlanViewer.Core.Models;
+
+///
+/// One hourly bucket of aggregated Query Store metrics, used by the time-range slicer.
+///
+public class QueryStoreTimeSlice
+{
+ public DateTime IntervalStartUtc { get; set; }
+ public double TotalCpu { get; set; }
+ public double TotalDuration { get; set; }
+ public double TotalReads { get; set; }
+ public double TotalWrites { get; set; }
+ public double TotalPhysicalReads { get; set; }
+ public double TotalMemory { get; set; }
+ public long TotalExecutions { get; set; }
+}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index b435d92..aaab776 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -346,4 +346,59 @@ JOIN sys.query_store_plan p
return rows;
}
+
+ ///
+ /// Fetches hourly-aggregated metric data for the time-range slicer.
+ /// Returns up to 1000 hourly buckets ordered by interval start descending.
+ ///
+ public static async Task> FetchTimeSliceDataAsync(
+ string connectionString, string orderByMetric = "cpu",
+ CancellationToken ct = default)
+ {
+ const string sql = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT TOP (1000)
+ DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0) AS bucket_hour,
+ SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0 AS total_cpu_ms,
+ SUM(rs.avg_duration * rs.count_executions) / 1000.0 AS total_duration_ms,
+ SUM(rs.avg_logical_io_reads * rs.count_executions) AS total_reads,
+ SUM(rs.avg_logical_io_writes * rs.count_executions) AS total_writes,
+ SUM(rs.avg_physical_io_reads * rs.count_executions) AS total_physical_reads,
+ SUM(rs.avg_query_max_used_memory * rs.count_executions) * 8.0 / 1024.0 AS total_memory_mb,
+ SUM(rs.count_executions) AS total_executions
+FROM sys.query_store_runtime_stats rs
+JOIN sys.query_store_runtime_stats_interval rsi
+ ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
+GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, rsi.start_time), 0)
+ORDER BY bucket_hour DESC;";
+
+ var rows = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ // DATEADD returns plain datetime (not datetimeoffset) — read accordingly
+ var bucketHour = reader.GetDateTime(0);
+ rows.Add(new QueryStoreTimeSlice
+ {
+ IntervalStartUtc = DateTime.SpecifyKind(bucketHour, DateTimeKind.Utc),
+ TotalCpu = reader.GetDouble(1),
+ TotalDuration = reader.GetDouble(2),
+ TotalReads = reader.GetDouble(3),
+ TotalWrites = reader.GetDouble(4),
+ TotalPhysicalReads = reader.GetDouble(5),
+ TotalMemory = reader.GetDouble(6),
+ TotalExecutions = reader.GetInt64(7),
+ });
+ }
+
+ // Return in chronological order
+ rows.Reverse();
+ return rows;
+ }
}