diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ec6498ba..8c33f6d8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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(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. @@ -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 diff --git a/Darling/Darling.Tests/BulkServerListParserTests.cs b/Darling/Darling.Tests/BulkServerListParserTests.cs new file mode 100644 index 0000000000..bccca076c9 --- /dev/null +++ b/Darling/Darling.Tests/BulkServerListParserTests.cs @@ -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; + +/// +/// The shared "Add Multiple Servers" paste-box grammar (). 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. +/// +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); + } +} diff --git a/Darling/Darling.Tests/ViewerServerManagementTests.cs b/Darling/Darling.Tests/ViewerServerManagementTests.cs index 6a8a661f5a..e6af794e56 100644 --- a/Darling/Darling.Tests/ViewerServerManagementTests.cs +++ b/Darling/Darling.Tests/ViewerServerManagementTests.cs @@ -469,3 +469,178 @@ public void Save(string id, string username, string password) { } public void Delete(string id) { } } } + +/// +/// The Darling "Add Multiple Servers" pure mapping + gate helpers (WPF-free statics on +/// ): the row → / → +/// projection, the service-honors-only-Windows/SQL belt (mirroring the single dialog's MapAuth block), the +/// blank-db → NULL trap, the single-Protect blob reused per row, and the ratified OrdinalIgnoreCase dedupe gate +/// over the shared storage name (mirrors Lite's B7 pins). +/// +public sealed class BulkServerOnboardingMappingTests +{ + private static BulkServerParseLine Line(string server, string? display = null, string? database = null) => + new(1, server, server, display, database); + + [Fact] + public void BuildMonitoredServerRow_WindowsAuth_MapsToIntegrated_NoSecret() + { + var (row, error) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Null(error); + Assert.NotNull(row); + Assert.Equal("integrated", row!.Auth); + Assert.Null(row.EncryptedPassword); + Assert.True(row.IsEnabled); + Assert.False(row.ReadOnlyIntent); + } + + [Fact] + public void BuildMonitoredServerRow_SqlAuth_MapsToSql_CarriesBlobAndUsername() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "monitor", EncryptedPassword = "DPAPI-BLOB==" }); + + Assert.Equal("sql", row!.Auth); + Assert.Equal("monitor", row.Username); + Assert.Equal("DPAPI-BLOB==", row.EncryptedPassword); + } + + [Theory] + [InlineData(AuthenticationTypes.EntraMFA)] + [InlineData(AuthenticationTypes.ServicePrincipal)] + [InlineData(AuthenticationTypes.ManagedIdentity)] + public void BuildMonitoredServerRow_AzureAuth_IsRejected_TheBelt(string authType) + { + // The trimmed radios never offer these, but a picked profile could resolve to one — the mapping helper + // still rejects (mirrors the single dialog's MapAuth-null block). + var (row, error) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("azure.database.windows.net"), + new BulkSharedSettings { AuthType = authType }); + + Assert.Null(row); + Assert.False(string.IsNullOrEmpty(error)); + } + + [Fact] + public void BuildMonitoredServerRow_ServerId_MatchesComputeServerId() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("myazure.database.windows.net", null, "AdventureWorks"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Equal(ViewerDataService.ComputeServerId("myazure.database.windows.net", "AdventureWorks", false), row!.ServerId); + } + + [Fact] + public void BuildMonitoredServerRow_BlankDatabase_IsNull_NeverMaster() + { + // The builder trap: a blank/whitespace database must map to NULL, not "master". Coercing would derive a + // MISMATCHED server_id (master's), breaking dedupe against single-added rows and splitting collected data. + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022", null, " "), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Null(row!.Database); + Assert.Equal(ViewerDataService.ComputeServerId("SQL2022", null, false), row.ServerId); + } + + [Fact] + public void BuildMonitoredServerRow_CarriesEncryptTrustDatabase_AndDisplayFallback() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022", null, " tpcc "), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows, EncryptMode = "Strict", TrustServerCertificate = true }); + + Assert.Equal("Strict", row!.EncryptMode); + Assert.True(row.TrustServerCertificate); + Assert.Equal("tpcc", row.Database); + Assert.Equal("SQL2022", row.Name); // display name falls back to host + } + + [Fact] + public void SharedBlob_IsReusedAcrossRows_ProtectNotCalledPerRow() + { + // The shared settings carry ONE pre-Protected blob (Protect is called once at resolve time); the mapping + // helper reuses it verbatim on every row rather than re-encrypting per server. + var shared = new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "u", EncryptedPassword = "ONE-BLOB==" }; + var (a, _) = AddMultipleServersDialog.BuildMonitoredServerRow(Line("SQL2022"), shared); + var (b, _) = AddMultipleServersDialog.BuildMonitoredServerRow(Line("SQL2019"), shared); + + Assert.Equal("ONE-BLOB==", a!.EncryptedPassword); + Assert.Same(a.EncryptedPassword, b!.EncryptedPassword); + } + + [Fact] + public void BuildTestConnectServer_ProjectsTheSameFields() + { + var (test, error) = AddMultipleServersDialog.BuildTestConnectServer( + Line("SQL2022", "Prod", "tpcc"), + new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "monitor", EncryptedPassword = "BLOB==", EncryptMode = "Strict", TrustServerCertificate = true }); + + Assert.Null(error); + Assert.NotNull(test); + Assert.Equal("SQL2022", test!.Host); + Assert.Equal("Prod", test.Name); + Assert.Equal("tpcc", test.Database); + Assert.Equal("sql", test.Auth); + Assert.Equal("monitor", test.Username); + Assert.Equal("BLOB==", test.EncryptedPassword); + Assert.Equal("Strict", test.EncryptMode); + Assert.True(test.TrustServerCertificate); + } + + [Fact] + public void BuildTestConnectServer_AzureAuth_IsRejected() + { + var (test, error) = AddMultipleServersDialog.BuildTestConnectServer( + Line("azure"), + new BulkSharedSettings { AuthType = AuthenticationTypes.ServicePrincipal }); + + Assert.Null(test); + Assert.False(string.IsNullOrEmpty(error)); + } + + [Fact] + public void DedupeGate_CaseVariant_IsSkipped_EvenThoughStoredHashesDiffer() + { + // Hostnames are case-insensitive in reality, so an existing SQL01 must block a bulk 'sql01' even though + // the stored server_id differs by case. Pins against a "simplify back to the raw hash" regression. + Assert.NotEqual( + ViewerDataService.ComputeServerId("SQL01", null, false), + ViewerDataService.ComputeServerId("sql01", null, false)); + + var seen = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, false) }); + Assert.False(seen.Add(AddMultipleServersDialog.GateKey(Row("sql01", null, false)))); + } + + [Fact] + public void DedupeGate_ReadOnlyIntent_SeedDoesNotBlockReadWrite_ButReadWriteDoes() + { + // An existing read-only SQL01:RO does NOT block a bulk read-write SQL01 (bulk rows are read-write)… + var seenRo = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, true) }); + Assert.True(seenRo.Add(AddMultipleServersDialog.GateKey(Row("SQL01", null, false)))); + + // …but an existing read-write SQL01 DOES. + var seenRw = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, false) }); + Assert.False(seenRw.Add(AddMultipleServersDialog.GateKey(Row("SQL01", null, false)))); + } + + [Fact] + public void DedupeGate_DifferentDatabases_StayDistinct() + { + var seen = AddMultipleServersDialog.SeedGate(new[] { Row("host", "db1", false) }); + Assert.True(seen.Add(AddMultipleServersDialog.GateKey(Row("host", "db2", false)))); + } + + private static MonitoredServerRow Row(string host, string? database, bool readOnlyIntent) => new() + { + ServerId = ViewerDataService.ComputeServerId(host, database, readOnlyIntent), + Host = host, + Database = database, + ReadOnlyIntent = readOnlyIntent, + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml new file mode 100644 index 0000000000..6244fa53f3 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +