Skip to content

Commit 145d291

Browse files
SQLAdrianclaude
andcommitted
feat: parallax bg + QueryPlan version-gating + RAG workstream plan
- Parallax background: ambient image now anchors to the monitor instead of the WebView. WPF host pushes throttled (60Hz DispatcherTimer) monitor + window geometry into a dedicated GPU-promoted layer (transform: translate3d with will-change) so dragging the window slides the photo behind it instead of repainting on every tick. Falls back to plain `cover` when running outside WebView2 (server mode). - ConnectionHealthService now probes ProductMajorVersion + Edition alongside EngineEdition on the per-server first-pass. Exposes ServerCapabilities record for feature-gating. - QueryPlanModal: drops the unused ServerVersion/ServerEdition parameters in favour of an internal lookup via ConnectionHealthService. ONLINE checkbox now disabled on Standard/Web/Express; RESUMABLE disabled unless Enterprise + SQL 2017+. Tooltips explain why a checkbox is greyed. - WORKFILE_remaining.md: marks parallax + ServerVersion probe + release pipeline as done; adds "AI ASSISTANT / RAG WORKSTREAM" section describing the split into a separate (local-only) SQLTriage-RAG-Builder repo. - .gitignore + .claudeignore: exclude the sibling SQLTriage-RAG-Builder folder so this repo never tracks or auto-searches it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 02fb0d2 commit 145d291

11 files changed

Lines changed: 407 additions & 16 deletions

File tree

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"Bash(git commit:*)",
2525
"Bash(ls C:/GitHub/LiveMonitor/research_output/LLM1/*.yaml)",
2626
"Bash(ls -la /c/GitHub/LiveMonitor/Pages/*.razor)",
27-
"PowerShell(Start-Sleep -Seconds 3; try { $s = [System.IO.File]::Open\\(\"c:\\\\GitHub\\\\LiveMonitor\\\\bin\\\\Debug\\\\net8.0-windows\\\\win-x64\\\\SQLTriage.exe\", 'Open', 'ReadWrite', 'None'\\); $s.Close\\(\\); \"FREE\" } catch { \"$\\($_.Exception.Message\\)\" })"
27+
"PowerShell(Start-Sleep -Seconds 3; try { $s = [System.IO.File]::Open\\(\"c:\\\\GitHub\\\\LiveMonitor\\\\bin\\\\Debug\\\\net8.0-windows\\\\win-x64\\\\SQLTriage.exe\", 'Open', 'ReadWrite', 'None'\\); $s.Close\\(\\); \"FREE\" } catch { \"$\\($_.Exception.Message\\)\" })",
28+
"Bash(dotnet test *)",
29+
"Read(//c/GitHub/mempalace-3.0.0/**)"
2830
]
2931
},
3032
"hooks": {}

.claudeignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ BPScripts/Ignore/
107107
Deploy/
108108
!Deploy/installer/
109109

110+
# Sibling RAG-Builder repo — never auto-search; ~2GB of cached web pages
111+
SQLTriage-RAG-Builder/
112+
110113
# Test fixtures
111114
Tests/**/TestData/
112115
Tests/**/Fixtures/

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Config/panel-metrics.json
1616
unique-styles.txt
1717
wwwroot/css/appreworked.css
1818

19+
# Sibling repo for RAG corpus building — local-only, never push from this repo
20+
SQLTriage-RAG-Builder/
21+
1922
# User-specific files
2023
*.rsuser
2124
*.suo

Components/Shared/QueryPlanModal.razor

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
@inject SQLTriage.Data.UserSettingsService UserSettings
55
@inject SQLTriage.Data.ToastService Toast
66
@inject ServerConnectionManager ConnectionManager
7+
@inject SQLTriage.Data.Services.ConnectionHealthService HealthService
78
@using SQLTriage.Data.Services
89
@using Microsoft.Data.SqlClient
910

@@ -79,17 +80,17 @@
7980
{
8081
<div class="plan-options-panel" style="display:flex;gap:12px;padding:6px 12px;border-bottom:1px solid var(--border);align-items:center;flex-wrap:wrap;background:rgba(239,68,68,0.05);">
8182
<span style="font-size:11px;font-weight:600;color:var(--red);opacity:0.8;">NO-PANTS</span>
82-
<div style="display:flex;align-items:center;gap:4px;">
83-
<input type="checkbox" id="idx-opt-online" @bind="_indexOptionOnline" />
84-
<label for="idx-opt-online" style="margin:0;font-size:12px;">ONLINE</label>
83+
<div style="display:flex;align-items:center;gap:4px;" title="@(CanUseOnline ? "Build the index without taking the table offline" : $"ONLINE not supported on this edition (Enterprise/Developer only). Detected: {_serverEdition ?? "unknown"}")">
84+
<input type="checkbox" id="idx-opt-online" @bind="_indexOptionOnline" disabled="@(!CanUseOnline)" />
85+
<label for="idx-opt-online" style="margin:0;font-size:12px;@(CanUseOnline ? "" : "opacity:0.4;")">ONLINE</label>
8586
</div>
8687
<div style="display:flex;align-items:center;gap:4px;">
8788
<input type="checkbox" id="idx-opt-sorttemp" @bind="_indexOptionSortInTempDb" />
8889
<label for="idx-opt-sorttemp" style="margin:0;font-size:12px;">SORT_IN_TEMPDB</label>
8990
</div>
90-
<div style="display:flex;align-items:center;gap:4px;">
91-
<input type="checkbox" id="idx-opt-resumable" @bind="_indexOptionResumable" />
92-
<label for="idx-opt-resumable" style="margin:0;font-size:12px;">RESUMABLE</label>
91+
<div style="display:flex;align-items:center;gap:4px;" title="@(CanUseResumable ? "Pause/resume during long index builds" : $"RESUMABLE requires Enterprise + SQL 2017+. Detected: v{_serverVersion?.ToString() ?? "?"} {_serverEdition ?? "edition unknown"}")">
92+
<input type="checkbox" id="idx-opt-resumable" @bind="_indexOptionResumable" disabled="@(!CanUseResumable)" />
93+
<label for="idx-opt-resumable" style="margin:0;font-size:12px;@(CanUseResumable ? "" : "opacity:0.4;")">RESUMABLE</label>
9394
</div>
9495
<div style="display:flex;align-items:center;gap:4px;">
9596
<label style="margin:0;font-size:12px;">MAXDOP:</label>
@@ -123,10 +124,18 @@
123124
[Parameter] public string? QueryText { get; set; }
124125
[Parameter] public string? ConnectionId { get; set; }
125126
[Parameter] public string? DatabaseName { get; set; }
126-
[Parameter] public int? ServerVersion { get; set; }
127-
[Parameter] public string? ServerEdition { get; set; }
128127
[Parameter] public EventCallback OnClose { get; set; }
129128

129+
// Resolved from ConnectionHealthService when the modal opens. Used to
130+
// gate ONLINE / RESUMABLE checkboxes:
131+
// ONLINE — Enterprise / Developer / Evaluation only
132+
// RESUMABLE — Enterprise-class AND SQL Server 2017+ (major version >= 14)
133+
private int? _serverVersion;
134+
private string? _serverEdition;
135+
private bool _serverIsEnterpriseClass;
136+
private bool CanUseOnline => _serverIsEnterpriseClass;
137+
private bool CanUseResumable => _serverIsEnterpriseClass && (_serverVersion ?? 0) >= 14;
138+
130139
public bool IsVisible { get; private set; }
131140

132141
private bool _showQueryText = false;
@@ -164,6 +173,13 @@
164173
QueryText = queryText;
165174
_showQueryText = false;
166175
IsVisible = true;
176+
177+
// Resolve server capabilities from the live health-check cache so
178+
// we can gate ONLINE/RESUMABLE checkboxes appropriately. Falls back
179+
// to "unknown" (everything disabled) if the server hasn't been
180+
// probed yet — better than silently emitting invalid index DDL.
181+
ResolveServerCapabilities();
182+
167183
StateHasChanged();
168184
await Task.Delay(50); // allow DOM to render
169185
@@ -404,6 +420,39 @@
404420
_indexExecutionCts?.Cancel();
405421
}
406422

423+
private void ResolveServerCapabilities()
424+
{
425+
_serverVersion = null;
426+
_serverEdition = null;
427+
_serverIsEnterpriseClass = false;
428+
429+
if (string.IsNullOrEmpty(ConnectionId)) return;
430+
var conn = ConnectionManager.GetConnection(ConnectionId);
431+
if (conn == null) return;
432+
433+
// ServerNames is comma-separated; the health service keys per-server,
434+
// so probe the first one (DDL targets a single instance via the
435+
// SqlConnection passed to ExecuteCreateIndexAsync, so this matches
436+
// what the user will actually run against).
437+
var firstServer = conn.ServerNames.Split(',', StringSplitOptions.RemoveEmptyEntries
438+
| StringSplitOptions.TrimEntries)
439+
.FirstOrDefault();
440+
if (string.IsNullOrEmpty(firstServer)) return;
441+
442+
var caps = HealthService.GetCapabilities(firstServer);
443+
if (caps == null) return;
444+
_serverVersion = caps.MajorVersion > 0 ? caps.MajorVersion : null;
445+
_serverEdition = caps.Edition;
446+
_serverIsEnterpriseClass = caps.IsEnterpriseClass;
447+
448+
// Force-clear flags whose checkbox is now disabled, so the generated
449+
// DDL doesn't carry over a stale "ON" from a previous server with
450+
// different capabilities. The user can re-enable manually if the
451+
// checkbox is available.
452+
if (!CanUseOnline) _indexOptionOnline = false;
453+
if (!CanUseResumable) _indexOptionResumable = false;
454+
}
455+
407456
public async ValueTask DisposeAsync()
408457
{
409458
_indexExecutionCts?.Cancel();

Config/version.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": "0.85.3",
3-
"buildNumber": 1410,
4-
"buildDate": "2026-04-26",
3+
"buildNumber": 1414,
4+
"buildDate": "2026-04-27",
55
"releaseType": "Production",
66
"updateCheckUrl": "https://api.github.com/repos/SQLAdrian/SQLTriage/releases/latest",
77
"whatsnew": [

Data/Services/ConnectionHealthService.cs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ public record HealthEntry(ServerStatus Status, DateTime LastChecked, string? Err
3232
// EngineEdition 5 = Azure SQL DB, 8 = Azure SQL Managed Instance
3333
private readonly ConcurrentDictionary<string, bool> _isAzure = new(StringComparer.OrdinalIgnoreCase);
3434

35+
// Cached SQL Server major version + edition string per server.
36+
// Probed once on the first successful health-check; consumers (Query
37+
// Plan modal, NoPants index-create UI) read these to gate features
38+
// like ONLINE / RESUMABLE that have version/edition requirements.
39+
private readonly ConcurrentDictionary<string, ServerCapabilities> _caps =
40+
new(StringComparer.OrdinalIgnoreCase);
41+
42+
/// <summary>SQL Server version + edition info exposed for feature-gating.</summary>
43+
/// <param name="MajorVersion">SQL Server major version (e.g. 14 = 2017, 15 = 2019, 16 = 2022). 0 if unknown.</param>
44+
/// <param name="Edition">Full edition string (e.g. "Enterprise Edition (64-bit)"). null if unknown.</param>
45+
/// <param name="IsEnterpriseClass">True for Enterprise / Developer / Evaluation editions, which support online/resumable index ops.</param>
46+
public record ServerCapabilities(int MajorVersion, string? Edition, bool IsEnterpriseClass);
47+
48+
/// <summary>Returns capabilities for a server, or null if not yet probed.</summary>
49+
public ServerCapabilities? GetCapabilities(string serverName)
50+
=> _caps.TryGetValue(serverName, out var v) ? v : null;
51+
3552
private readonly CancellationTokenSource _cts = new();
3653
private Task? _loopTask;
3754
private bool _disposed;
@@ -126,21 +143,53 @@ private async Task CheckServerAsync(ServerConnection conn, string serverName, Ca
126143
cts.CancelAfter(TimeSpan.FromSeconds(6));
127144
await sql.OpenAsync(cts.Token);
128145

129-
// Probe EngineEdition to detect Azure SQL DB (5) or Azure SQL MI (8) once per session
146+
// Probe SERVERPROPERTY values once per session: EngineEdition
147+
// (Azure detection), ProductMajorVersion, and Edition.
148+
// EngineEdition: 3 = Enterprise, 5 = Azure SQL DB, 6 = DataWarehouse,
149+
// 8 = Azure SQL MI; Enterprise/Developer/Evaluation
150+
// support online + resumable index operations.
130151
if (!_isAzure.ContainsKey(serverName))
131152
{
132153
try
133154
{
134155
using var cmd = sql.CreateCommand();
135-
cmd.CommandText = "SELECT SERVERPROPERTY('EngineEdition')";
156+
cmd.CommandText = @"
157+
SELECT
158+
CAST(SERVERPROPERTY('EngineEdition') AS int) AS EngineEdition,
159+
CAST(SERVERPROPERTY('ProductMajorVersion') AS int) AS ProductMajorVersion,
160+
CAST(SERVERPROPERTY('Edition') AS nvarchar(256)) AS Edition";
136161
cmd.CommandTimeout = 4;
137-
var edition = await cmd.ExecuteScalarAsync(cts.Token);
138-
var ee = edition is DBNull || edition == null ? 0 : Convert.ToInt32(edition);
162+
using var rdr = await cmd.ExecuteReaderAsync(cts.Token);
163+
int ee = 0, pmv = 0;
164+
string? ed = null;
165+
if (await rdr.ReadAsync(cts.Token))
166+
{
167+
ee = rdr.IsDBNull(0) ? 0 : rdr.GetInt32(0);
168+
pmv = rdr.IsDBNull(1) ? 0 : rdr.GetInt32(1);
169+
ed = rdr.IsDBNull(2) ? null : rdr.GetString(2);
170+
}
139171
_isAzure[serverName] = ee == 5 || ee == 8;
172+
173+
// Enterprise-class engines: 3 = Enterprise (incl. Developer/Evaluation),
174+
// 5 = Azure SQL DB Premium tiers also expose ONLINE; 8 = Azure MI Business Critical.
175+
// Conservative gate: anything except Standard/Web/Express.
176+
bool isEnterpriseClass = ee == 3 || ee == 5 || ee == 8 ||
177+
(ed != null && (ed.Contains("Enterprise", StringComparison.OrdinalIgnoreCase)
178+
|| ed.Contains("Developer", StringComparison.OrdinalIgnoreCase)
179+
|| ed.Contains("Evaluation", StringComparison.OrdinalIgnoreCase)));
180+
181+
_caps[serverName] = new ServerCapabilities(pmv, ed, isEnterpriseClass);
182+
140183
if (_isAzure[serverName])
141184
_logger.LogInformation("Azure SQL detected: {Server} (EngineEdition={E})", serverName, ee);
185+
_logger.LogDebug("Server caps probed: {Server} v{Ver} edition='{Ed}' enterpriseClass={Ent}",
186+
serverName, pmv, ed, isEnterpriseClass);
187+
}
188+
catch (Exception ex)
189+
{
190+
_isAzure[serverName] = false;
191+
_logger.LogDebug(ex, "Server capability probe failed for {Server}", serverName);
142192
}
143-
catch { _isAzure[serverName] = false; }
144193
}
145194

146195
_status[serverName] = new HealthEntry(ServerStatus.Online, DateTime.UtcNow, null);

MainWindow.xaml.cs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
using System.Diagnostics;
44
using System.IO;
5+
using System.Runtime.InteropServices;
56
using System.Windows;
7+
using System.Windows.Interop;
68
using Microsoft.Extensions.Logging;
79
using Microsoft.Extensions.DependencyInjection;
810
using SQLTriage.Data;
@@ -495,6 +497,18 @@ private void OnBlazorWebViewInitialized(object? sender, Microsoft.AspNetCore.Com
495497
_webView2Initialized = true;
496498
_coreWebView2 = e.WebView.CoreWebView2;
497499

500+
// Wire up parallax background: push monitor+window geometry into
501+
// the WebView whenever the window moves, resizes, or changes DPI.
502+
LocationChanged += OnParallaxGeometryChanged;
503+
SizeChanged += OnParallaxGeometryChanged;
504+
DpiChanged += OnParallaxGeometryChanged;
505+
// Push initial state once navigation completes (host page must be
506+
// loaded before postMessage can reach the JS listener).
507+
e.WebView.NavigationCompleted += (_, args) =>
508+
{
509+
if (args.IsSuccess) PushParallaxGeometry();
510+
};
511+
498512
// Wire up PrintService with the live CoreWebView2 instance
499513
var printService = App.Services?.GetService<Data.Services.PrintService>();
500514
printService?.SetWebView(_coreWebView2);
@@ -527,6 +541,116 @@ private void OnBlazorWebViewInitialized(object? sender, Microsoft.AspNetCore.Com
527541
}
528542
}
529543

544+
// ── Parallax background plumbing ────────────────────────────────────
545+
// Anchors the ambient background to the monitor (not the window) by
546+
// pushing geometry into the WebView. Two layers of throttling:
547+
// 1) WPF events (LocationChanged etc.) just flip a dirty flag —
548+
// they fire at mouse-move rate (often >120 Hz on modern mice)
549+
// and a JSON-serialize + cross-process PostWebMessageAsString
550+
// per tick was the source of drag jank.
551+
// 2) A DispatcherTimer at ~60 Hz drains the flag, so we IPC at most
552+
// once per frame regardless of input rate.
553+
// The browser side has its own rAF coalescing on top of this.
554+
private DispatcherTimer? _parallaxTimer;
555+
private bool _parallaxDirty;
556+
private int _lastMonitorW, _lastMonitorH, _lastWindowX, _lastWindowY;
557+
558+
private void OnParallaxGeometryChanged(object? sender, EventArgs e)
559+
{
560+
_parallaxDirty = true;
561+
// Lazy-start the timer on first event so we don't pay the tick
562+
// cost during sessions where the window never moves.
563+
if (_parallaxTimer == null)
564+
{
565+
_parallaxTimer = new DispatcherTimer(DispatcherPriority.Render)
566+
{
567+
Interval = TimeSpan.FromMilliseconds(16)
568+
};
569+
_parallaxTimer.Tick += (_, _) =>
570+
{
571+
if (_parallaxDirty) PushParallaxGeometry();
572+
};
573+
_parallaxTimer.Start();
574+
}
575+
}
576+
577+
private void PushParallaxGeometry()
578+
{
579+
_parallaxDirty = false;
580+
if (_coreWebView2 == null) return;
581+
582+
try
583+
{
584+
var hwnd = new WindowInteropHelper(this).Handle;
585+
if (hwnd == IntPtr.Zero) return;
586+
587+
var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
588+
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
589+
if (!GetMonitorInfo(hMonitor, ref mi)) return;
590+
591+
// Convert monitor rect (device pixels) → CSS pixels using this
592+
// window's current DPI scale. WPF's CompositionTarget gives us
593+
// a transform that already accounts for per-monitor DPI.
594+
var src = PresentationSource.FromVisual(this);
595+
double dpiX = 1.0, dpiY = 1.0;
596+
if (src?.CompositionTarget != null)
597+
{
598+
var m = src.CompositionTarget.TransformToDevice;
599+
dpiX = m.M11;
600+
dpiY = m.M22;
601+
}
602+
603+
int monitorW = (int)((mi.rcMonitor.Right - mi.rcMonitor.Left) / dpiX);
604+
int monitorH = (int)((mi.rcMonitor.Bottom - mi.rcMonitor.Top) / dpiY);
605+
// Window.Left/Top are already in CSS-equivalent DIPs at the
606+
// window's DPI; subtract monitor origin (also DIPs after divide).
607+
int windowX = (int)(this.Left - (mi.rcMonitor.Left / dpiX));
608+
int windowY = (int)(this.Top - (mi.rcMonitor.Top / dpiY));
609+
610+
// Clamp to non-negative so a window slightly off-screen (e.g.
611+
// mid-drag across monitors) doesn't push the image outside.
612+
if (windowX < 0) windowX = 0;
613+
if (windowY < 0) windowY = 0;
614+
615+
// Skip identical ticks — saves an IPC + JSON parse on the
616+
// browser side when the OS reports the same coords twice.
617+
if (monitorW == _lastMonitorW && monitorH == _lastMonitorH &&
618+
windowX == _lastWindowX && windowY == _lastWindowY) return;
619+
_lastMonitorW = monitorW; _lastMonitorH = monitorH;
620+
_lastWindowX = windowX; _lastWindowY = windowY;
621+
622+
var json = $"{{\"type\":\"parallax\",\"monitorW\":{monitorW},\"monitorH\":{monitorH},\"windowX\":{windowX},\"windowY\":{windowY}}}";
623+
_coreWebView2.PostWebMessageAsString(json);
624+
}
625+
catch (Exception ex)
626+
{
627+
_logger?.LogDebug(ex, "Parallax geometry push failed");
628+
}
629+
}
630+
631+
// P/Invoke for monitor info — needed because WPF's SystemParameters
632+
// only describe the *primary* screen, not the monitor the window is
633+
// currently on.
634+
private const int MONITOR_DEFAULTTONEAREST = 0x00000002;
635+
636+
[DllImport("user32.dll")]
637+
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int dwFlags);
638+
639+
[DllImport("user32.dll", CharSet = CharSet.Auto)]
640+
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
641+
642+
[StructLayout(LayoutKind.Sequential)]
643+
private struct RECT { public int Left, Top, Right, Bottom; }
644+
645+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
646+
private struct MONITORINFO
647+
{
648+
public int cbSize;
649+
public RECT rcMonitor;
650+
public RECT rcWork;
651+
public uint dwFlags;
652+
}
653+
530654
private void ApplyZoom(int zoomPercent)
531655
{
532656
if (BlazorWebView == null) return;

0 commit comments

Comments
 (0)