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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Add Multiple Servers: onboard a whole fleet from one pasted list, in both apps** ([#1549]) — adding a monitoring estate one server at a time is the onboarding wall: forty boxes meant opening the single Add-Server dialog forty times and re-typing the same SQL login into every one. Both Lite and the Darling viewer now have an **"Add Multiple Servers"** button beside Add Server that opens a paste-a-list dialog. **Grammar** (a new shared `PerformanceMonitor.Common.BulkServerListParser`, pinned by a mirrored test file in both suites so the two apps can never drift): one server per line as `server[, display name][, database]`; fields separate on a comma, or on a **tab** when the line contains one (so a spreadsheet paste keeps commas inside a field, and a display name with a comma just needs the tab form); every field is trimmed and trailing empty fields are dropped (tolerating trailing separators and Excel's trailing empty cells); blank lines and `#` comments are skipped with line numbers still reported correctly; an empty server name or a fourth field is a per-line error shown in the preview, not a silent drop. **One secret, never N** — the shared authentication block is entered ONCE and applied to every row: in Lite a SQL or service-principal credential mints a *single* auto-named `CredentialProfile` (unique-suffixed on a name clash) that every added server references, created only when at least one new row survives dedupe so a fully-duplicate paste leaves no orphan profile; in the Darling viewer (which has no store-side profile table) the shared credential is resolved ONCE to a single DPAPI-LocalMachine blob — `ViewerServerSecret.Protect` called exactly once — and that same blob is stamped onto every server row and every `test_connect` probe. No plaintext credential is ever written, and there is never a per-server secret to rotate. **Duplicates are skipped, not silently merged** — both apps gate candidates through the ratified `HashSet<string>(OrdinalIgnoreCase)` over the shared `ServerIdHelper.BuildStorageName(name, db, readOnlyIntent)` identity, seeded from the servers you already monitor and derived from the BUILT row so the dedupe key and the stored `server_id` are one composition. Case-folding is the point: an existing `SQL01` skips a pasted `sql01` even though their raw hashes differ (hostnames are case-insensitive in reality), while a per-database Azure entry (`host` vs `host, , AdventureWorks`) and a read-only-intent connection stay correctly distinct — this prevents both the exact-case silent-UPDATE (which would clobber an existing server's excluded-databases / capture settings through the upsert) and the case-variant double-INSERT (a second row collecting the same host). A **blank database maps to NULL, never `master`** — coercing it would mint a mismatched identity and split a server's collected data from its single-added twin. **Test All** is informational only (no server is added) and its shape is honest per app: Lite probes directly, so it runs up to 8 in parallel, cancellable, with each probe catching every error internally (so cancelling never crashes the app) and honoring your configured connection timeout; the Darling viewer routes probes through the service's `test_connect` command, and because the service drains its command queue serially, Test All is a **sequential** sweep with live per-row status and a Cancel that stops between rows (the in-flight probe finishes and cleans up its own credential-bearing command row — no orphaned rows, no parallelism theater). **Entra MFA is deliberately excluded from bulk** in both apps — an interactive auth popup per server is a popup storm at first collection — and a defensive belt in each app's row-mapping helper rejects any resolved un-honorable auth (Lite: a hand-edited MFA profile; Darling: an Azure profile the service can't connect with) at the single choke point that feeds both Test All and Add, so it can never leak in through a profile. Add writes every valid non-duplicate row (per-row failures are reported and the batch continues), reports an added / skipped / failed summary, and does not auto-connect — the background collector picks the new rows up on its next cycle. The single Add-Server dialog is unchanged (it remains the path for MFA and the per-server niceties bulk omits), and the deprecated Full Dashboard is untouched. Verified: Lite and the Darling viewer build with 0 errors; the full Lite.Tests and Darling.Tests suites (including the mirrored parser grammar cases, the row-mapping + dedupe-gate pins, and both apps' ThemeCompletenessTests) pass; no capability-pin baselines changed.

- **NOC Overview: sort the server tiles by CPU% descending, with a persisted CPU%/Name selector (both apps)** ([#1547]) — the Overview server-tile grid now leads with the busiest server instead of reshuffling itself every cycle. Lite ordered the tiles favorite-then-last-connected, so the REPORTED problem was that each 30-second collection cycle re-sorted them and you couldn't tell where anything was unless its colour band happened to change ("can't tell where things are unless they're changing colours"); the Darling viewer ordered them alphabetically. Neither surfaced the hot server. Both apps now default to sorting on `CpuPercentForAlert` DESCENDING — the exact value each tile's CPU colour band reads — so the sort order matches the tile colours, with no-CPU-sample servers (Azure, or a server that has never reported) sinking to the bottom and a deterministic tiebreak (display name case-insensitive, then a stable server id) so identical data yields the identical sequence on every refresh and the tiles never jitter. A small **Sort** selector on each Overview ("CPU %" default / "Name") switches to alphabetical; the choice persists per app (Lite `settings.json`, the Darling viewer's `viewer-settings.json`) and survives restart. The ordering is one shared, pure, total-order comparer — `PerformanceMonitor.Ui.ServerOverviewSort`, used by both front ends (the `ChartStyle` shared-pure-function precedent) and unit-tested identically in `Lite.Tests` and `Darling.Tests` — so the two apps can never drift apart. No other surface changes order: the server lists, favourites, and the fleet roll-up are untouched.

- **Front-end shell collapse: the standalone Plan Viewer's tab management hoisted to `Ui.StandalonePlanViewerController`** ([#1536]) — the standalone Plan Viewer surface (the closable "Plan Viewer" aggregate tab where a user opens a `.sqlplan` / plan-XML file, pastes plan XML, or drags a file in, each rendering as a closable sub-tab hosting the shared `PlanViewerControl`) carried ~300 near-verbatim lines duplicated between Lite `MainWindow.PlanViewer.cs` (425 lines) and the Darling viewer's `MainWindow.PlanViewer.cs` (467 lines): the `"__PLAN_ADD_TAB__"` "+"-tab sentinel and its `SelectionChanged` spawn, the empty "New Plan" sub-tab construction (dashed-rect empty state, the Open/Paste buttons and their file-dialog / clipboard handlers, the per-sub-tab close button), `LoadPlanIntoSubTab` (async plan render + invalid-XML / load-error dialogs + header relabel), unique sub-tab labelling, active-sub-tab tracking, the drag-over / drop / Ctrl+V-paste logic, and `IsPlanFile` — differing ONLY in the sentinel const name (`LitePlanAddTabId` vs `DarlingPlanAddTabId`, same VALUE) and the outer server-tabs container the entry/close touches. It collapses into a new `PerformanceMonitor.Ui.StandalonePlanViewerController` constructed with the app's inner plan `TabControl`; the two resource lookups (`ForegroundMutedBrush` / `TabCloseButton`) resolve through the injected control's logical tree, so each app's own theme dictionaries still apply — no visual change. Each app KEEPS only what is app-specific: the outer-container reveal/close (Lite's `ServerTabControl` + select-first-visible vs the viewer's `MainTabs` + the no-servers empty-state restore), the Darling-only `OpenStoredPlanInPlanViewer` store→viewer entry (the FinOps "View Plan" destination, fed by `FinOpsTab.PlanRequested`), and the three XAML-wired drag/drop/key handlers as one-line forwarders to the controller. FAITHFUL extraction — a statement-level equivalence diff (original shared core, mechanical renames applied, vs the controller) confirms every prior body statement survives verbatim; the only controller additions are the ctor + null-guard and `Reset()` (whose body is exactly the original close handler's cleanup-loop + clear + init-flag reset). Net: Lite `MainWindow.PlanViewer.cs` 425→72, Darling 467→118 (~700 duplicated lines removed, ~340 shared). A new `PlanViewerCapabilityPinTests` ratchet (in BOTH test projects, mirroring the `ServerTab` pins — a fixed probe table matched against the combined {app `MainWindow.xaml` + thin `MainWindow.PlanViewer.cs` + shared controller} surface: 23 probes Lite / 24 Darling incl. the store→viewer entry) guards every plan-loading / drag-drop / paste / open-file capability and every per-app XAML shell wire against a silent drop; each capability was verified present in the pre-hoist source, so the baseline proves the hoist dropped nothing. The deprecated Full Dashboard has no standalone Plan Viewer and is untouched. Verified: all three apps build with 0 errors and the capability pins stay green in BOTH Lite (7/7) and Darling (7/7). Refactor only — no behavior change.
Expand Down Expand Up @@ -366,6 +368,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1548]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1548
[#1546]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1546
[#1547]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1547
[#1549]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1549
[#1536]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1536
[#1534]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1534
[#1533]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1533
Expand Down
206 changes: 206 additions & 0 deletions Darling/Darling.Tests/BulkServerListParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System.Linq;
using PerformanceMonitor.Common;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// The shared "Add Multiple Servers" paste-box grammar (<see cref="BulkServerListParser"/>). This test file
/// is MIRRORED byte-for-byte in Lite.Tests so the two bulk dialogs are pinned to the identical grammar —
/// separators, trailing-field tolerance, comment/blank skipping with correct line numbers, the field-count
/// rules, and the null-safe empty-input contract.
/// </summary>
public sealed class BulkServerListParserTests
{
[Fact]
public void BareHostnames_ParseAsServerNameOnly()
{
var result = BulkServerListParser.Parse("SQL2022\nSQL2019");

Assert.Empty(result.Errors);
Assert.Equal(2, result.Servers.Count);
Assert.Equal("SQL2022", result.Servers[0].ServerName);
Assert.Null(result.Servers[0].DisplayName);
Assert.Null(result.Servers[0].DatabaseName);
Assert.Equal(1, result.Servers[0].LineNumber);
Assert.Equal("SQL2019", result.Servers[1].ServerName);
Assert.Equal(2, result.Servers[1].LineNumber);
}

[Fact]
public void CommaForm_TwoFields_IsServerAndDisplay()
{
var result = BulkServerListParser.Parse("SQL2022, Production");

var line = Assert.Single(result.Servers);
Assert.Equal("SQL2022", line.ServerName);
Assert.Equal("Production", line.DisplayName);
Assert.Null(line.DatabaseName);
}

[Fact]
public void CommaForm_ThreeFields_IsServerDisplayDatabase()
{
var result = BulkServerListParser.Parse("myazure.database.windows.net, Azure Prod, AdventureWorks");

var line = Assert.Single(result.Servers);
Assert.Equal("myazure.database.windows.net", line.ServerName);
Assert.Equal("Azure Prod", line.DisplayName);
Assert.Equal("AdventureWorks", line.DatabaseName);
}

[Fact]
public void TabForm_TwoAndThreeFields_SplitOnTab()
{
var two = BulkServerListParser.Parse("SQL2022\tProduction");
var lineTwo = Assert.Single(two.Servers);
Assert.Equal("SQL2022", lineTwo.ServerName);
Assert.Equal("Production", lineTwo.DisplayName);
Assert.Null(lineTwo.DatabaseName);

var three = BulkServerListParser.Parse("host\tName\tdb1");
var lineThree = Assert.Single(three.Servers);
Assert.Equal("host", lineThree.ServerName);
Assert.Equal("Name", lineThree.DisplayName);
Assert.Equal("db1", lineThree.DatabaseName);
}

[Fact]
public void TabTakesPrecedence_CommaStaysInsideTheField()
{
// A line containing BOTH a tab and a comma splits on the tab; the comma is part of the field.
var result = BulkServerListParser.Parse("SQL2022\tContoso, Inc.");

var line = Assert.Single(result.Servers);
Assert.Equal("SQL2022", line.ServerName);
Assert.Equal("Contoso, Inc.", line.DisplayName);
}

[Fact]
public void InteriorEmptyField_IsNull_NotAnError()
{
var result = BulkServerListParser.Parse("host,,db1");

Assert.Empty(result.Errors);
var line = Assert.Single(result.Servers);
Assert.Equal("host", line.ServerName);
Assert.Null(line.DisplayName);
Assert.Equal("db1", line.DatabaseName);
}

[Fact]
public void TrailingSeparators_AreDropped_CommaAndTab()
{
var comma = BulkServerListParser.Parse("SQL2022, Prod,");
var commaLine = Assert.Single(comma.Servers);
Assert.Equal("SQL2022", commaLine.ServerName);
Assert.Equal("Prod", commaLine.DisplayName);
Assert.Null(commaLine.DatabaseName);
Assert.Empty(comma.Errors);

var tab = BulkServerListParser.Parse("SQL2022\tProd\t");
var tabLine = Assert.Single(tab.Servers);
Assert.Equal("SQL2022", tabLine.ServerName);
Assert.Equal("Prod", tabLine.DisplayName);
Assert.Null(tabLine.DatabaseName);
Assert.Empty(tab.Errors);
}

[Fact]
public void CommentAndBlankLines_AreSkipped_WithLineNumbersStillCorrect()
{
var result = BulkServerListParser.Parse("# header comment\n\nSQL2022\n \n# another\nSQL2019");

Assert.Empty(result.Errors);
Assert.Equal(2, result.Servers.Count);
Assert.Equal("SQL2022", result.Servers[0].ServerName);
Assert.Equal(3, result.Servers[0].LineNumber);
Assert.Equal("SQL2019", result.Servers[1].ServerName);
Assert.Equal(6, result.Servers[1].LineNumber);
}

[Fact]
public void MoreThanThreeFields_IsAnError_WithTheRightLineNumber()
{
var result = BulkServerListParser.Parse("SQL2022\nhost, a, b, c");

Assert.Single(result.Servers);
var error = Assert.Single(result.Errors);
Assert.Equal(2, error.LineNumber);
Assert.Contains("too many fields", error.Message, System.StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void EmptyServerToken_IsAnError()
{
var result = BulkServerListParser.Parse(", Production, db");

Assert.Empty(result.Servers);
var error = Assert.Single(result.Errors);
Assert.Equal(1, error.LineNumber);
Assert.Contains("server name is empty", error.Message, System.StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void CrlfAndLf_AreEquivalent()
{
var lf = BulkServerListParser.Parse("SQL2022\nSQL2019\nSQL2016");
var crlf = BulkServerListParser.Parse("SQL2022\r\nSQL2019\r\nSQL2016");

Assert.Equal(
lf.Servers.Select(s => (s.LineNumber, s.ServerName)),
crlf.Servers.Select(s => (s.LineNumber, s.ServerName)));
Assert.Equal(3, crlf.Servers.Count);
// A CRLF line leaves no stray carriage return on the trailing field.
Assert.Equal("SQL2016", crlf.Servers[2].ServerName);
}

[Fact]
public void ExcelPaste_TabsWithTrailingEmptyCells_ParseCleanly()
{
// Spreadsheet copy: tab-separated with a trailing empty cell (and a trailing tab on the row).
var result = BulkServerListParser.Parse("SQL2022\tProduction\t\nSQL2019\tStaging\tReportServer\t");

Assert.Empty(result.Errors);
Assert.Equal(2, result.Servers.Count);
Assert.Equal("SQL2022", result.Servers[0].ServerName);
Assert.Equal("Production", result.Servers[0].DisplayName);
Assert.Null(result.Servers[0].DatabaseName);
Assert.Equal("SQL2019", result.Servers[1].ServerName);
Assert.Equal("Staging", result.Servers[1].DisplayName);
Assert.Equal("ReportServer", result.Servers[1].DatabaseName);
}

[Fact]
public void RawText_IsPreservedPreTrim()
{
var result = BulkServerListParser.Parse(" SQL2022 , Production ");

var line = Assert.Single(result.Servers);
Assert.Equal(" SQL2022 , Production ", line.RawText);
Assert.Equal("SQL2022", line.ServerName);
Assert.Equal("Production", line.DisplayName);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\n\n")]
[InlineData("# only a comment")]
public void NullEmptyOrWhitespaceInput_YieldsEmptyResult_NoThrow(string? text)
{
var result = BulkServerListParser.Parse(text);

Assert.Empty(result.Servers);
Assert.Empty(result.Errors);
}
}
Loading
Loading