diff --git a/SW.Bitween.Api/Resources/Documents/Search.cs b/SW.Bitween.Api/Resources/Documents/Search.cs index 1b2938f..7a18569 100644 --- a/SW.Bitween.Api/Resources/Documents/Search.cs +++ b/SW.Bitween.Api/Resources/Documents/Search.cs @@ -40,7 +40,11 @@ async public Task Handle(SearchyRequest searchyRequest, bool lookup = fa BusEnabled = document.BusEnabled, DuplicateInterval = document.DuplicateInterval, PromotedProperties = document.PromotedProperties.ToKeyAndValueCollection(), - DocumentFormat = document.DocumentFormat + DocumentFormat = document.DocumentFormat, + // A correlated count, so the "used by" column the UI shows costs one + // subquery per row instead of the whole Subscription table over the wire. + UsedByCount = dbContext.Set() + .Count(subscription => subscription.DocumentId == document.Id) }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs index 39ab5e7..21a0820 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs @@ -31,7 +31,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa { Id = policy.Id, Name = policy.Name, - GroupCount = policy.Groups.Count + GroupCount = policy.Groups.Count, + // A correlated count, so the "used by" column the UI shows costs one subquery per + // row instead of the whole Subscription table over the wire. + UsedByCount = _dbContext.Set() + .Count(subscription => subscription.RetryPolicyId == policy.Id) }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/WorkGroups/Search.cs b/SW.Bitween.Api/Resources/WorkGroups/Search.cs index 43a5a5f..67a791c 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Search.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Search.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -35,6 +36,14 @@ public async Task Handle(SearchWorkGroupModel request) var workGroups = await dbContext.Set().AsNoTracking() .Where(w => request.Name == null || w.Name.Contains(request.Name)) .ToArrayAsync(); + // One grouped count for every group at once, so the "used by" column the UI shows costs a + // single aggregate query instead of the whole Subscription table over the wire. + var usedByWorkGroup = await dbContext.Set().AsNoTracking() + .Where(subscription => subscription.WorkGroupId != null) + .GroupBy(subscription => subscription.WorkGroupId!.Value) + .Select(group => new { WorkGroupId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(row => row.WorkGroupId, row => row.Count); + var consumerCounts = Array.Empty(); try @@ -82,6 +91,7 @@ public async Task Handle(SearchWorkGroupModel request) NotifierProcessingCount = notifiersCounts?.ProcessingCount, NotifierQueueCount = notifiersCounts?.QueueCount, ProcessorNodeCount = processorsCounts?.TotalNodes, + UsedByCount = usedByWorkGroup.GetValueOrDefault(workGroup.Id), }; }).ToList(); diff --git a/SW.Bitween.Sdk/Model/Document.cs b/SW.Bitween.Sdk/Model/Document.cs index 40b0eba..bec888e 100644 --- a/SW.Bitween.Sdk/Model/Document.cs +++ b/SW.Bitween.Sdk/Model/Document.cs @@ -47,5 +47,11 @@ public class DocumentUpdate : DocumentCreate public class DocumentRow : DocumentUpdate { + /// + /// How many subscriptions carry this information type. Counted here because the admin UI + /// shows it in the list: computing it client-side meant downloading every subscription + /// alongside every page of this list. + /// + public int UsedByCount { get; set; } } } \ No newline at end of file diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 20983ec..d2bd61b 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -25,6 +25,13 @@ public class RetryPolicyRow public int Id { get; set; } public string Name { get; set; } public int GroupCount { get; set; } + + /// + /// How many subscriptions this policy is assigned to. Counted here because the admin UI shows + /// it in the list: computing it client-side meant downloading every subscription alongside + /// every page of this list. + /// + public int UsedByCount { get; set; } } /// diff --git a/SW.Bitween.Sdk/Model/Workgroups.cs b/SW.Bitween.Sdk/Model/Workgroups.cs index 1cc88d4..ecaf2c1 100644 --- a/SW.Bitween.Sdk/Model/Workgroups.cs +++ b/SW.Bitween.Sdk/Model/Workgroups.cs @@ -28,6 +28,13 @@ public class WorkGroupModel public long? NotifierQueueCount { get; set; } /// Live count of active RabbitMQ consumer instances for this group's queue. public long? ProcessorNodeCount { get; set; } + + /// + /// How many subscriptions run in this work group. Counted here because the admin UI shows it + /// in the list: computing it client-side meant downloading every subscription alongside every + /// page of this list. + /// + public int UsedByCount { get; set; } } public class CreateWorkGroupModel diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts index 2129929..d2bff95 100644 --- a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -14,14 +14,18 @@ test.beforeEach(async ({ page }) => { test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { test.setTimeout(45000); await page.goto("exchanges"); - await expect(page.getByRole("row", { name: /azureBlob test sub/ }).first()).toBeVisible({ timeout: 15000 }); + // Whatever the database holds: this used to pin to particular exchange ids and subscription + // names from a seed that no longer exists, which made it a test of the fixtures, not the page. + await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 }); await expect(page.getByText("undefined")).toHaveCount(0); // Avoid the background refetch racing with row selection below. await page.getByLabel("Refresh interval").selectOption("0"); // Filter down to failed exchanges only. await page.getByRole("button", { name: "Failed" }).click(); - const row = page.getByRole("row", { name: "36b3e2b1003048ff8dec1573b2f752c5" }); + // Pick by content, not position: the filter re-renders the table, and an index would race it + // and land on whichever row was showing before. + const row = page.getByRole("row").filter({ hasText: "Failed" }).first(); await expect(row).toBeVisible({ timeout: 10000 }); // Expand the row (click the chevron cell — other cells stop propagation) @@ -34,11 +38,15 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { // Bulk retry a couple of specific rows (not the whole page — each retry does // real file I/O against storage, so keep this fast and deterministic). await page.getByRole("button", { name: "All" }).click(); - await expect(page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" })).toBeVisible({ - timeout: 10000, - }); - await page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" }).check(); - await page.getByRole("checkbox", { name: "Select a1b088fffe9e4993ac75736576a826cb" }).check(); + // Row checkboxes only. "Select all on this page" also starts with "Select", and its checked + // state is derived from every row on the page — so a refetch landing mid-click (the filter + // above triggers one) flips it back and reads as a click that did nothing. A row checkbox is + // keyed by its own id and survives that. It also keeps the bulk retry to two rows, which is + // what this test says it wants: each retry is real file I/O. + const rowCheckbox = page.getByRole("checkbox", { name: /^Select (?!all\b)/ }); + await expect(rowCheckbox.first()).toBeVisible({ timeout: 10000 }); + await rowCheckbox.nth(0).check(); + await rowCheckbox.nth(1).check(); await expect(page.getByText(/\d+ selected/)).toBeVisible(); await page.getByRole("button", { name: "Retry selected…" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click(); @@ -47,7 +55,7 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { // Manually create an exchange addressed at a subscription. await page.goto("exchanges/new"); await page.getByRole("combobox", { name: "Pick a subscription…" }).click(); - await page.getByRole("option", { name: "s3 test sub" }).click(); + await page.getByRole("option").first().click(); // Dismiss the dropdown panel via an outside click (it sits above the panel's // anchor point, so it can't itself be covered) rather than Escape, which // doesn't close this Headless UI combobox instance. diff --git a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts index 12b9617..bde39fd 100644 --- a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from "@playwright/test"; +/** A seeded partner, used for the attachment this test makes and then removes. */ +const PARTNER = "Acme Retail"; + const ADMIN_EMAIL = "admin@Bitween.systems"; const ADMIN_PASSWORD = "Mtm@dmin!2"; @@ -22,49 +25,50 @@ test("API gateway: create, attach partner, create subscription detour, edit atta await expect(page).toHaveURL(/\/api-gateways\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - // Attach a partner, detouring to create the required GatewayApiCall subscription inline. + // Attach a partner, detouring to create the required GatewayApiCall subscription. The + // detour is a nested route off the attach page, and both pickers are comboboxes now — + // there is no wizard to "Continue" through. await page.getByRole("button", { name: "Attach partner" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); - await page.getByRole("button", { name: "acme" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("combobox", { name: "Partner" }).click(); + await page.getByRole("option", { name: new RegExp(PARTNER) }).click(); + await expect(page.getByRole("combobox", { name: "Partner" })).toHaveValue(PARTNER); const subscriptionName = `Playwright GW Subscription ${Date.now()}`; - await page.getByRole("link", { name: "New subscription" }).click(); - await expect(page).toHaveURL(/\/subscriptions\/new\?type=GatewayApiCall/); - await page.fill("#ni-name", subscriptionName); - await page.getByRole("button", { name: "test doc" }).click(); - await page.getByLabel("handler adapter").click(); + await page.getByRole("button", { name: "New subscription" }).click(); + // The picked partner rides along as a query param through the detour. + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach\/new-subscription/); + await page.fill("#ngi-name", subscriptionName); + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Shipment order/ }).click(); + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); await page.locator("#prop-Url").fill("https://example.com/sink"); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); await page.getByRole("button", { name: "Create subscription" }).click(); - // This page itself renders a ReturnBanner with a "Continue" button before - // the mutation resolves (inherited from the detour link) — wait for the - // create to actually land on the new subscription's own page first, or the - // click races and hits that stale button instead. - await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); - await page.getByRole("button", { name: "Continue" }).click(); + // Back on the attach form with the new subscription already chosen. + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach(\?|$)/); + // It comes back selected in the picker, so it is the combobox's value, not page text. + await expect(page.getByRole("combobox", { name: "Subscription" })).toHaveValue(subscriptionName); await page.getByRole("button", { name: "Attach partner" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+$/); - await expect(page.getByText("acme").first()).toBeVisible(); - await expect(page.getByText(subscriptionName)).toBeVisible(); + await expect(page.getByText(PARTNER).first()).toBeVisible(); + await expect(page.getByText(subscriptionName).first()).toBeVisible(); // Edit the attachment — exercises the remove-then-add path (backend's // updatepartner can't mutate a composite-key column in place). - await page.getByRole("button", { name: "Edit attachment for acme" }).click(); + await page.getByRole("button", { name: `Edit attachment for ${PARTNER}` }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+\/attachments\/\d+$/); await page.getByRole("button", { name: "Save" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+$/); await expect(page.getByText(subscriptionName)).toBeVisible(); // Detach. - await page.getByRole("button", { name: "Detach acme" }).click(); + await page.getByRole("button", { name: `Detach ${PARTNER}` }).click(); await page .getByRole("dialog", { name: "Detach this partner?" }) .getByRole("button", { name: "Detach partner" }) @@ -79,9 +83,8 @@ test("API gateway: create, attach partner, create subscription detour, edit atta .getByRole("dialog", { name: "Delete this API gateway?" }) .getByRole("button", { name: "Delete gateway" }) .click(); - // ApiGatewayPage navigates to /api-gateways, which the router redirects to - // the unified subscriptions list. - await expect(page).toHaveURL(/\/subscriptions\?types=api-gateways$/); + // API gateways have their own list page now, which is where deleting one lands. + await expect(page).toHaveURL(/\/api-gateways$/); }); test("Bus gateway: create, add route with match expression, edit route, remove, delete", async ({ page }) => { @@ -89,62 +92,58 @@ test("Bus gateway: create, add route with match expression, edit route, remove, await page.goto("bus-gateways/new"); await page.fill("#nbg-name", name); - await page.getByRole("button", { name: "test-hh" }).click(); + // Bus-enabled types only, and this one is the one no seeded gateway already listens for. + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Delivery proof/ }).click(); + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Delivery proof/); await page.getByRole("button", { name: "Create gateway" }).click(); await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - await page.getByRole("button", { name: "Add route" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); + // Routes are built on the gateway's own canvas now — no separate add-route page, and no + // wizard: the route, its subscription and that subscription's delivery are all edited in + // place, and "Create route" saves the lot. + // An empty gateway offers both "Add a route" in the header and "Add the first route" + // in the empty canvas; either does the same thing. + await page.getByRole("button", { name: /^Add (a|the first) route/ }).first().click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\?route=new/); - // Filter step — leave the match expression empty (null = matches everything). - await page.getByRole("button", { name: "Continue" }).click(); - // Partner step — no partner. - await page.getByRole("button", { name: "No partner" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); - - // Subscription step — detour to create the required BusGateway subscription. + // No partner and no filter: an empty match expression means "matches everything". const subscriptionName = `Playwright Bus Subscription ${Date.now()}`; - await page.getByRole("link", { name: "New subscription" }).click(); - await expect(page).toHaveURL(/\/subscriptions\/new\?type=BusGateway/); - await page.fill("#ni-name", subscriptionName); - await page.getByLabel("handler adapter").click(); + await page.getByRole("button", { name: "New subscription" }).click(); + await page.fill("#bs-int-name", subscriptionName); + + // Its delivery is a node on the same canvas. + await page.getByRole("button", { name: /^Delivery/ }).click(); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); await page.locator("#prop-Url").fill("https://example.com/sink"); - await page.getByRole("button", { name: "Create subscription" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); - // Wait for the create to actually land (see the comment in the API gateway - // test above) before clicking the ReturnBanner's "Continue". - await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("button", { name: "Add route" }).click(); - - await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); + await page.getByRole("button", { name: "Create route" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\?.*route=\d+/); - // Edit the route (no-op save exercises the round trip of a null match expression). - await page.getByRole("button", { name: /Edit route \d+/ }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/routes\/\d+$/); - await page.getByRole("button", { name: "Save" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); + // Reload to prove the route round-tripped, null match expression and all. + await page.reload(); + await expect(page.getByText(subscriptionName).first()).toBeVisible(); // Remove the route. - await page.getByRole("button", { name: /Remove route \d+/ }).click(); + await page.getByRole("button", { name: "Remove route" }).click(); await page .getByRole("dialog", { name: "Remove this route?" }) .getByRole("button", { name: "Remove route" }) .click(); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByText("No routes")).toBeVisible(); + // The route list and the main panel both say this, so name the panel's heading: it shows only + // when no route is selected, which is the thing actually worth proving here — that removing + // the last route doesn't leave the page still pointing at it. + await expect(page.getByRole("heading", { name: /^No routes —/ })).toBeVisible(); await page.getByRole("button", { name: "Delete" }).click(); await page .getByRole("dialog", { name: "Delete this bus gateway?" }) .getByRole("button", { name: "Delete gateway" }) .click(); - await expect(page).toHaveURL(/\/subscriptions\?types=bus-gateways$/); + // Bus gateways have their own list page now, which is where deleting one lands. + await expect(page).toHaveURL(/\/bus-gateways$/); }); diff --git a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts index d89b59b..6e83499 100644 --- a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts +++ b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts @@ -18,6 +18,8 @@ const ADMINISTRATOR_ROLE_ID = 1; const TEST_EMAIL = /^pw-.*@example\.test$/; const TEST_ROLE = /^PW /; +/** Everything the suite creates is named this way, so it can be found again and removed. */ +const TEST_NAME = /^Playwright /; /** The only settings the suite writes to — see the reset below for why this is a list, not "all". */ const TEST_SETTINGS = ["Theme.PrimaryColor", "Theme.TabTitle", "Theme.CompanyName"]; @@ -81,5 +83,33 @@ export default async function purgeTestData() { for (const key of TEST_SETTINGS) await api.delete(`${API}/settings/${encodeURIComponent(key)}`, { headers: auth() }); + // The domain objects the suite creates outlive a failed run too, and they are not harmless + // either: enough of them push a newly created row off the first page of a list, and the tests + // that look for their own row then fail for a reason that has nothing to do with them. + // + // Order matters. A gateway holds attachments and routes that reference subscriptions, so the + // gateways go first or the subscriptions underneath them refuse to delete. + const purge = async (list: string, remove: (id: number) => Promise) => { + const res = await api.get(`${API}/${list}?size=500&limit=500`, { headers: auth() }); + if (!res.ok()) return; + const rows = ((await res.json()).result ?? []) as { id: number; name: string }[]; + for (const row of rows.filter((r) => TEST_NAME.test(r.name ?? ""))) { + // Best effort: something still referencing a row is not a reason to abandon the rest. + try { + await remove(row.id); + } catch { + /* leave it for the next run */ + } + } + }; + + await purge("apigateways", (id) => api.delete(`${API}/apigateways/${id}`, { headers: auth() })); + await purge("busgateways", (id) => api.delete(`${API}/busgateways/${id}`, { headers: auth() })); + await purge("subscriptions", (id) => api.delete(`${API}/subscriptions/${id}`, { headers: auth() })); + await purge("workgroups", (id) => + api.post(`${API}/workgroups/${id}/delete`, { headers: auth(), data: {} }), + ); + await purge("documents", (id) => api.delete(`${API}/documents/${id}`, { headers: auth() })); + await api.dispose(); } diff --git a/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts index 25814e1..4481463 100644 --- a/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts @@ -14,16 +14,16 @@ test.beforeEach(async ({ page }) => { test("information type Code is optional end to end", async ({ page }) => { const name = `Playwright No Code ${Date.now()}`; - await page.goto("information-types/new"); - await page.fill("#nit-name", name); + // Creating happens in a dialog on the list page, not on a page of its own. + await page.goto("information-types"); + await page.getByRole("button", { name: "New information type" }).click(); - // Expand the collapsed code/format section and clear the auto-suggested code. - await page.getByRole("button", { name: /^Code/ }).click(); - const codeInput = page.locator("#nit-code"); - await expect(codeInput).not.toHaveAttribute("required", ""); - await codeInput.fill(""); + const dialog = page.getByRole("dialog", { name: "New information type" }); + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + // Code starts empty and stays optional — nothing to clear, and it doesn't block creating. + await expect(dialog.getByRole("textbox", { name: "Code" })).toHaveValue(""); - await page.getByRole("button", { name: "Create information type" }).click(); + await dialog.getByRole("button", { name: "Create information type" }).click(); // Should navigate straight to the detail page — no validation block on empty code. await expect(page).toHaveURL(/\/information-types\/\d+$/); diff --git a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts index 675cb5e..d44ca12 100644 --- a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts @@ -6,8 +6,10 @@ import { deleteRole, removeMember, signIn, + openMember, signInAsAdmin, signOut, + startsWith, } from "./helpers"; /** @@ -168,7 +170,14 @@ test("editing a role changes what its members can do, without them signing in ag test("a member with no roles at all sees nothing and can do nothing", async ({ page }) => { await signInAsAdmin(page); - const email = await addMember(page, { name: "No Roles", roles: [] }); + // A member cannot be created without a role — the server refuses an empty one — so the + // roleless state is reached the only way it can be: by taking their one role away after. + const email = await addMember(page, { name: "No Roles", roles: ["Viewer"] }); + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByRole("checkbox", { name: startsWith("Viewer") }).uncheck(); + await drawer.getByRole("button", { name: "Save roles" }).click(); + await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); await signOut(page); await signIn(page, email, FIRST_PASSWORD); diff --git a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts index 73b8461..9179cd4 100644 --- a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts @@ -10,12 +10,15 @@ const DEFAULT_CRON = "0 * * * * ?"; const brandColorVar = (page: Page) => page.evaluate(() => document.documentElement.style.getPropertyValue("--color-crimson-600").trim()); +/** Sections are real links — a section is a URL you can paste into a ticket — not buttons. */ +const sectionLink = (page: Page, name: string) => page.getByRole("link", { name }); + /** The hex box beside the colour swatch; `exact` keeps it apart from the picker itself. */ const hexInput = (page: Page) => page.getByRole("textbox", { name: "Primary color", exact: true }); async function openBrandSection(page: Page) { await page.goto("settings"); - await page.getByRole("button", { name: "Brand & theme" }).click(); + await sectionLink(page, "Brand & theme").click(); } test.beforeEach(async ({ page }) => { @@ -36,7 +39,7 @@ test("sections come from the backend catalog, with no restart-required rows", as "Security", "Brand & theme", ]) - await expect(page.getByRole("button", { name: section })).toBeVisible(); + await expect(sectionLink(page, section)).toBeVisible(); // Nothing carries a restart badge: a setting that couldn't take effect immediately is shown // as an environment value instead of being offered as an edit that needs a restart to land. @@ -45,7 +48,7 @@ test("sections come from the backend catalog, with no restart-required rows", as test("environment settings are shown but not offered as edits", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Database" }).click(); + await sectionLink(page, "Database").click(); // A read-only row renders its value as text — there's no control carrying its label… await expect(page.getByText("Use Azure managed identity")).toBeVisible(); @@ -68,7 +71,7 @@ test("environment settings are shown but not offered as edits", async ({ page }) test("Microsoft-only sign-in is an editable setting, not an environment value", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Single sign-on (Microsoft)" }).click(); + await sectionLink(page, "Single sign-on (Microsoft)").click(); // It applies per request — the Login handler and the config endpoint both read it live — so it // belongs in the catalog as an edit rather than a read-only environment row. @@ -80,7 +83,7 @@ test("Microsoft-only sign-in is an editable setting, not an environment value", test("the retry schedule is editable and rejects an invalid cron", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Reliability & jobs" }).click(); + await sectionLink(page, "Reliability & jobs").click(); const cron = page.getByRole("textbox", { name: "Retry poll schedule", exact: true }); await expect(cron).toHaveValue(DEFAULT_CRON); @@ -137,7 +140,7 @@ test("a secret's value never reaches the browser", async ({ page }) => { }); await page.goto("settings"); - await page.getByRole("button", { name: "Adapters" }).click(); + await sectionLink(page, "Adapters").click(); // The local backend configures a Rebex key, so the row shows as set — masked, with the // adapter-config "Replace" affordance rather than the value itself. diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index 26c1818..443e15b 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -15,28 +15,38 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", const name = `Playwright Job ${Date.now()}`; await page.goto("scheduled-jobs/new"); - await page.fill("#sjw-name", name); - await page.getByRole("button", { name: "order" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); + await page.fill("#nj-name", name); - // Source & schedule step — receiver adapter + its one required prop. - await page.getByLabel("receiver adapter").click(); + // The information type is a searchable picker, and the stages below it are cards that open + // in place — there is no wizard to "Continue" through any more. + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Shipment order/ }).click(); + // Wait for the pick to land: without this the next click can run first and the form is still + // missing its information type, leaving "Create job" disabled. + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); + + // Source — open by default. Receiver adapter plus its one required prop. + await page.getByRole("combobox", { name: "receiver adapter" }).click(); await page.getByRole("option", { name: "NativeHttpReceiver" }).click(); - await page.keyboard.press("Escape"); // close the combobox popover, it doesn't auto-dismiss - await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); + await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("NativeHttpReceiver"); await page.locator("#prop-Url").fill("https://example.com/feed"); - await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed"); + + // Collapse Source before opening Delivery: its property form renders asynchronously and the + // cards below it keep moving while it does, so clicking straight through hits a moving target. + await page.getByRole("button", { name: "Close this step" }).click(); - // Pipeline step — handler adapter + its required prop (mapper stays "None"). - await page.getByLabel("handler adapter").click(); + // Delivery — handler adapter and its required prop (transformation stays "Passes through"). + // Only one stage is open at a time, so #prop-Url is unambiguous here. + await page.getByRole("button", { name: /^Delivery/ }).click(); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); + await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("NativeHttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); - await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); - // Review step — "Enable immediately" is checked by default. - await page.getByRole("button", { name: "Create scheduled job" }).click(); + // "Enable immediately" is checked by default. + await page.getByRole("button", { name: "Create job" }).click(); await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); @@ -59,18 +69,26 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Next run")).toBeVisible(); - // Reload to prove the adapter config truly persisted server-side. + // Reload to prove the adapter config truly persisted server-side. Each stage card + // summarises what it saved, so both the adapter and its property show without opening it. await page.reload(); - await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); - await expect(page.locator("#prop-Url").first()).toHaveValue("https://example.com/feed"); - await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); + const source = page.getByRole("button", { name: /^Source/ }); + await expect(source).toContainText("NativeHttpReceiver"); + await expect(source).toContainText("https://example.com/feed"); + const delivery = page.getByRole("button", { name: /^Delivery/ }); + await expect(delivery).toContainText("NativeHttpHandler"); + await expect(delivery).toContainText("https://example.com/sink"); - await page.goto("subscriptions?types=scheduled-jobs"); + // Narrow by type — the supported filter — so the row can't be paged out of sight. Deliberately + // not the search box: a multi-word term is encoded with "+" and the backend never decodes it, + // so searching any name containing a space returns nothing (pre-existing, see notes). + await page.goto("subscriptions?type=Receiving"); const row = page.getByRole("row", { name: new RegExp(name) }); await expect(row).toBeVisible({ timeout: 15000 }); await expect(row.getByText("undefined")).toHaveCount(0); - await row.getByRole("button", { name: `Open ${name}` }).click(); + // The whole row is the link on this table — there is no separate open button. + await row.click(); await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await page.getByRole("button", { name: "Delete" }).click(); await page.getByRole("button", { name: "Delete subscription" }).click(); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts index c3d9343..7206c6c 100644 --- a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts @@ -61,7 +61,9 @@ test("an administrator resets a member's password", async ({ page }) => { const drawer = page.getByRole("dialog", { name: "Member details" }); await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); await drawer.getByRole("button", { name: "Set password" }).click(); - await expect(drawer.getByLabel("New password")).toHaveValue(""); + // The form is replaced by a confirmation carrying the new password to copy: Bitween sends + // no email, so this is the only place it is ever shown. + await expect(drawer.getByText(/Password set for/)).toBeVisible(); await signOut(page); await signIn(page, email, ROTATED_PASSWORD); @@ -79,7 +81,9 @@ test("the old password stops working after a reset", async ({ page }) => { const drawer = page.getByRole("dialog", { name: "Member details" }); await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); await drawer.getByRole("button", { name: "Set password" }).click(); - await expect(drawer.getByLabel("New password")).toHaveValue(""); + // The form is replaced by a confirmation carrying the new password to copy: Bitween sends + // no email, so this is the only place it is ever shown. + await expect(drawer.getByText(/Password set for/)).toBeVisible(); await signOut(page); await page.fill("#login-email", email); diff --git a/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts index 793d714..1bab858 100644 --- a/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts @@ -14,13 +14,18 @@ test.beforeEach(async ({ page }) => { test("work group create, edit queue settings, list, delete", async ({ page }) => { const name = `Playwright Workgroup ${Date.now()}`; - await page.goto("work-groups/new"); - await page.fill("#nwg-name", name); - await page.getByRole("button", { name: "Create work group" }).click(); + // Creating happens in a dialog on the list page, not on a page of its own. + await page.goto("work-groups"); + await page.getByRole("button", { name: "New work group" }).click(); + + const dialog = page.getByRole("dialog", { name: "New work group" }); + // `exact` keeps this off "Bus message name", which also contains "Name". + await dialog.getByRole("textbox", { name: "Name", exact: true }).fill(name); + await dialog.getByRole("button", { name: "Create work group" }).click(); await expect(page).toHaveURL(/\/work-groups\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - // Defaults from the new-page form. + // Defaults carried over from the create dialog. await expect(page.locator("#wg-prefetch")).toHaveValue("10"); await expect(page.locator("#wg-priority")).toHaveValue("5"); @@ -36,9 +41,10 @@ test("work group create, edit queue settings, list, delete", async ({ page }) => await page.goto("work-groups"); const row = page.getByRole("row", { name: new RegExp(name) }); await expect(row).toBeVisible(); - // A brand-new group has no consumers and nothing assigned to it, so both of - // those render as an em dash. - await expect(row.getByText("—")).toHaveCount(2); + // Nothing is assigned to a brand-new group, so "Used by" is an em dash. Its consumer + // count is deliberately not asserted: declaring the group's queue makes the running + // instance a consumer of it straight away, so "Nodes" is legitimately 1, not blank. + await expect(row.getByRole("cell").nth(2)).toHaveText("—"); await row.getByRole("button", { name: `Open ${name}` }).click(); await expect(page).toHaveURL(/\/work-groups\/\d+$/); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts new file mode 100644 index 0000000..71ac814 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { SEARCHY_RULE, buildListQuery, searchyQueryString } from "../searchQuery"; + +/** + * Guards a bug that was invisible from the UI: every multi-word search silently returned + * nothing, because the space went out form-encoded as "+" and the backend's query-string + * parser (Uri.UnescapeDataString) leaves "+" as a literal plus. + */ +describe("searchy query strings", () => { + it("percent-encodes a space rather than form-encoding it", () => { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, "Order intake"]], + offset: 0, + limit: 25, + }); + + expect(qs).toContain("Order%20intake"); + expect(qs).not.toContain("+"); + }); + + it("leaves a literal plus escaped, so the replace can't corrupt a term", () => { + const params = new URLSearchParams(); + params.append("filter", "Name:4:a + b"); + + // "+" survives as %2B; only the space becomes %20. + expect(searchyQueryString(params)).toBe("filter=Name%3A4%3Aa%20%2B%20b"); + }); + + it("still pages the way every list endpoint expects", () => { + const qs = buildListQuery({ offset: 50, limit: 25 }); + expect(qs).toBe("page=2&size=25"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts index 8278b0d..058563a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts @@ -10,7 +10,7 @@ import type { } from "../types"; import { exchangeMethods } from "./exchanges"; import { gatewayMethods } from "./gateways"; -import { get, getEnrichment, post, request } from "./request"; +import { get, post, request } from "./request"; import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { @@ -31,6 +31,8 @@ interface RawDocument { duplicateInterval: number; disregardsUnfilteredMessages: boolean; promotedProperties: RawKeyAndValue[] | null; + /** Counted by the backend — see DocumentRow.UsedByCount. */ + usedByCount: number; } interface RawSubscriptionRef { id: number; @@ -138,14 +140,8 @@ const documentBody = (t: Omit) => ({ export const documentMethods = { async listInformationTypes(): Promise { - const [res, subs] = await Promise.all([ - get>("/documents"), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByDocument = new Map(); - for (const s of subs.result ?? []) - countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); - return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })); + const res = await get>("/documents"); + return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: d.usedByCount })); }, async searchInformationTypes(query: { @@ -164,16 +160,10 @@ export const documentMethods = { offset: query.offset, limit: query.limit, }); - const [res, subs] = await Promise.all([ - get>(`/documents?${qs}`), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByDocument = new Map(); - for (const s of subs.result ?? []) - countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); + const res = await get>(`/documents?${qs}`); return { total: res.totalCount, - result: (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })), + result: (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: d.usedByCount })), }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 7cf934b..d8e335a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -9,6 +9,7 @@ import type { } from "../types"; import { partnerMethods } from "./partners"; import { get, post } from "./request"; +import { searchyQueryString } from "./searchQuery"; // ——— backend shapes (camelCase over the wire) ——— interface SearchyResponse { @@ -145,7 +146,7 @@ function buildExchangeQuery(query: ExchangeQuery): string { if (query.to) params.append("filter", `StartedOn:8:${query.to}`); params.set("page", String(Math.floor(query.offset / query.limit))); params.set("size", String(query.limit)); - return params.toString(); + return searchyQueryString(params); } function buildScheduledRetryQuery(query: ScheduledRetryQuery): string { @@ -157,7 +158,7 @@ function buildScheduledRetryQuery(query: ScheduledRetryQuery): string { if (query.to) params.append("filter", `On:8:${query.to}`); params.set("page", String(Math.floor(query.offset / query.limit))); params.set("size", String(query.limit)); - return params.toString(); + return searchyQueryString(params); } async function partnerNameMap(): Promise> { diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index 9d48872..0410489 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -15,7 +15,7 @@ import { type RetryUsageRow, type Paged, } from "../types"; -import { get, getEnrichment, post, request } from "./request"; +import { get, post, request } from "./request"; import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { @@ -26,6 +26,8 @@ interface RawRetryPolicyRow { id: number; name: string; groupCount: number; + /** Counted by the backend — see RetryPolicyRow.UsedByCount. */ + usedByCount: number; } interface RawRetryPolicy { name: string; @@ -228,21 +230,13 @@ interface RawAttempt { export const retryPolicyMethods = { async listRetryPolicies(): Promise { - const [res, subs] = await Promise.all([ - get>("/retrypolicies"), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByRetryPolicy = new Map(); - for (const s of subs.result ?? []) { - if (s.retryPolicyId == null) continue; - countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); - } + const res = await get>("/retrypolicies"); return (res.result ?? []).map((p) => ({ id: p.id, name: p.name, groupCount: p.groupCount, createdOn: "", - usedByCount: countByRetryPolicy.get(p.id) ?? 0, + usedByCount: p.usedByCount, })); }, @@ -256,15 +250,7 @@ export const retryPolicyMethods = { offset: query.offset, limit: query.limit, }); - const [res, subs] = await Promise.all([ - get>(`/retrypolicies?${qs}`), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByRetryPolicy = new Map(); - for (const s of subs.result ?? []) { - if (s.retryPolicyId == null) continue; - countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); - } + const res = await get>(`/retrypolicies?${qs}`); return { total: res.totalCount, result: (res.result ?? []).map((p) => ({ @@ -272,7 +258,7 @@ export const retryPolicyMethods = { name: p.name, groupCount: p.groupCount, createdOn: "", - usedByCount: countByRetryPolicy.get(p.id) ?? 0, + usedByCount: p.usedByCount, })), }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts index ffeba36..30887a8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts @@ -9,6 +9,22 @@ * unbounded" guard, so an omitted size silently returns zero rows despite a correct * total count. Always supplying both sidesteps that regardless of which handler runs. */ +/** + * Serializes a Searchy query string, percent-encoding spaces. + * + * `URLSearchParams.toString()` form-encodes a space as "+", but the backend parses this query + * string with `Uri.UnescapeDataString` (SW.PrimitiveTypes' QueryStringParser), which decodes + * %XX escapes and leaves "+" as a literal plus. So every multi-word term silently matched + * nothing: searching "Order intake" looked for "Order+intake". Only `filter=` is affected — + * endpoints whose parameters are model-bound go through ASP.NET's own parser, which reads "+" + * as a space correctly. + * + * Safe as a blanket replace: URLSearchParams already encodes a literal "+" as %2B, so any bare + * "+" left in the output is a space. + */ +export const searchyQueryString = (params: URLSearchParams): string => + params.toString().replace(/\+/g, "%20"); + export function buildListQuery(opts: { /** [Field, Rule, Value] triples — Rule 1 = EqualsTo, 4 = Contains. Skipped when Value is "". */ filters?: [string, number, string | number][]; @@ -24,7 +40,7 @@ export function buildListQuery(opts: { if (opts.sort) params.append("sort", `${opts.sort[0]}:${opts.sort[1]}`); params.set("page", String(Math.floor(opts.offset / opts.limit))); params.set("size", String(opts.limit)); - return params.toString(); + return searchyQueryString(params); } export const SEARCHY_RULE = { diff --git a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts index 0e39494..5c2e080 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts @@ -7,7 +7,7 @@ import { type WorkGroupRow, type Paged, } from "../types"; -import { get, getEnrichment, post } from "./request"; +import { get, post } from "./request"; interface SearchyResponse { result: T[]; @@ -19,6 +19,8 @@ interface RawWorkGroup { busMessageName: string; options: { rabbitMqOptions: { prefetch: number | null; priority: number | null } | null } | null; processorNodeCount: number | null; + /** Counted by the backend — see WorkGroupModel.UsedByCount. */ + usedByCount: number; } interface RawSubscriptionRef { id: number; @@ -95,18 +97,10 @@ async function fetchPagedRows(query: { export const workGroupMethods = { async listWorkGroups(): Promise { - const [rows, subs] = await Promise.all([ - fetchRows(), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByWorkGroup = new Map(); - for (const s of subs.result ?? []) { - if (s.workGroupId == null) continue; - countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); - } + const rows = await fetchRows(); return rows.map((w) => ({ ...toWorkGroup(w), - usedByCount: countByWorkGroup.get(w.id) ?? 0, + usedByCount: w.usedByCount, consumerCount: w.processorNodeCount ?? 0, })); }, @@ -116,20 +110,12 @@ export const workGroupMethods = { offset: number; limit: number; }): Promise> { - const [{ rows, total }, subs] = await Promise.all([ - fetchPagedRows(query), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByWorkGroup = new Map(); - for (const s of subs.result ?? []) { - if (s.workGroupId == null) continue; - countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); - } + const { rows, total } = await fetchPagedRows(query); return { total, result: rows.map((w) => ({ ...toWorkGroup(w), - usedByCount: countByWorkGroup.get(w.id) ?? 0, + usedByCount: w.usedByCount, consumerCount: w.processorNodeCount ?? 0, })), }; diff --git a/SW.Bitween.Web/ClientApp/src/api/permissions.ts b/SW.Bitween.Web/ClientApp/src/api/permissions.ts index dec228a..cf09e20 100644 --- a/SW.Bitween.Web/ClientApp/src/api/permissions.ts +++ b/SW.Bitween.Web/ClientApp/src/api/permissions.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "."; import type { ActionId, PermissionArea, PermissionKey } from "./types"; +import { keys } from "./queryKeys"; /** * The permission catalog is defined and enforced in the backend (SW.Bitween.Sdk/Model/Permissions.cs) @@ -26,9 +27,8 @@ export const permissionKey = (areaId: string, actionId: ActionId): PermissionKey /** Static per deployment, so it's fetched once and kept. */ export function usePermissionCatalog() { return useQuery({ - queryKey: ["permission-catalog"], + queryKey: keys.permissionCatalog, queryFn: () => api.getPermissionCatalog(), - staleTime: Infinity, }); } diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts new file mode 100644 index 0000000..7587270 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -0,0 +1,208 @@ +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Every cache label in the app, in one place. + * + * Keys are hierarchical and grouped by *entity*, not by endpoint: a subscription's detail, its + * paged rows and the whole-list cache all start with `["subscriptions"]`. React Query matches + * invalidation by prefix, so `invalidateQueries({ queryKey: keys.subscriptions.all })` clears the + * lot — one line where the old hand-written string keys needed one line per variant, and where + * missing one was silent. + * + * It was not hypothetical: nothing in the app ever invalidated `partners-search`, + * `information-types-search`, `retry-policies-search` or `api-gateways-search` — the four paged + * tables people actually look at. They only appeared correct because the global 10s staleTime + * expired almost immediately and a remount refetched. Two more pairs (`information-types` / + * `information-types-all`, `partners` / `partners-all`) were the same fetch cached twice under + * different names, and `appConfig` / `app-config` were one endpoint under two labels, only one of + * which the settings page invalidated. + * + * Adding a query? Add its key here. A new key under an existing entity is covered by that + * entity's existing invalidations automatically, which is the whole point. + */ +export const keys = { + subscriptions: { + all: ["subscriptions"] as const, + /** The whole list, held once and shared by every "who uses this?" panel. */ + cache: ["subscriptions", "cache"] as const, + /** Live status for every subscription, keyed by id — shared by the gateway pages. */ + rows: ["subscriptions", "rows"] as const, + rowsSearch: (params: Record) => ["subscriptions", "rows", params] as const, + detail: (id: number | string | null | undefined) => ["subscriptions", "detail", id] as const, + runs: (id: number) => ["subscriptions", "runs", id] as const, + receiveAttempts: (id: number, params: Record) => + ["subscriptions", "receive-attempts", id, params] as const, + lastRuns: ["subscriptions", "last-runs"] as const, + scheduleHealth: ["subscriptions", "schedule-health"] as const, + }, + + informationTypes: { + all: ["information-types"] as const, + list: ["information-types", "list"] as const, + search: (params: Record) => ["information-types", "search", params] as const, + detail: (id: number | null | undefined) => ["information-types", "detail", id] as const, + }, + + partners: { + all: ["partners"] as const, + list: ["partners", "list"] as const, + search: (params: Record) => ["partners", "search", params] as const, + detail: (id: number | null | undefined) => ["partners", "detail", id] as const, + /** Adapter properties a partner overrides, per partner. */ + adapterProperties: (id: number | null | undefined) => + ["partners", "adapter-properties", id] as const, + }, + + apiGateways: { + all: ["api-gateways"] as const, + list: ["api-gateways", "list"] as const, + search: (params: Record) => ["api-gateways", "search", params] as const, + detail: (id: number | string) => ["api-gateways", "detail", id] as const, + attachments: (id: number | string, params: Record) => + ["api-gateways", "attachments", id, params] as const, + }, + + busGateways: { + all: ["bus-gateways"] as const, + list: ["bus-gateways", "list"] as const, + search: (params: Record) => ["bus-gateways", "search", params] as const, + detail: (id: number | string) => ["bus-gateways", "detail", id] as const, + }, + + workGroups: { + all: ["work-groups"] as const, + list: ["work-groups", "list"] as const, + search: (params: Record) => ["work-groups", "search", params] as const, + detail: (id: number | null | undefined) => ["work-groups", "detail", id] as const, + }, + + retryPolicies: { + all: ["retry-policies"] as const, + list: ["retry-policies", "list"] as const, + search: (params: Record) => ["retry-policies", "search", params] as const, + detail: (id: number | string) => ["retry-policies", "detail", id] as const, + }, + + /** + * Budget consumption, kept apart from the policies themselves: it moves on its own as exchanges + * fail and gets reset by operators, so it is never worth refetching a policy list for. + */ + retryUsage: { + all: ["retry-usage"] as const, + forPolicy: (policyId: number | string) => ["retry-usage", "policy", policyId] as const, + forSubscription: (id: number) => ["retry-usage", "subscription", id] as const, + attempts: (policyId: number | string, subscriptionId: number, groupId: number | string) => + ["retry-usage", "attempts", policyId, subscriptionId, groupId] as const, + }, + + valueSets: { + all: ["value-sets"] as const, + list: ["value-sets", "list"] as const, + detail: (id: string) => ["value-sets", "detail", id] as const, + }, + + notifiers: { + all: ["notifiers"] as const, + list: ["notifiers", "list"] as const, + search: (params: Record) => ["notifiers", "search", params] as const, + detail: (id: number | string) => ["notifiers", "detail", id] as const, + }, + + roles: { + all: ["roles"] as const, + list: ["roles", "list"] as const, + detail: (id: number | string | null | undefined) => ["roles", "detail", id] as const, + }, + + users: { + all: ["users"] as const, + list: ["users", "list"] as const, + detail: (id: number | string) => ["users", "detail", id] as const, + }, + + exchanges: { + all: ["exchanges"] as const, + search: (params: string) => ["exchanges", "search", params] as const, + document: (key: string | null) => ["exchanges", "document", key] as const, + }, + + scheduledRetries: { + all: ["scheduled-retries"] as const, + search: (params: string) => ["scheduled-retries", "search", params] as const, + }, + + queueHealth: ["queue-health"] as const, + dashboard: ["dashboard"] as const, + + settings: { + all: ["settings"] as const, + list: ["settings", "list"] as const, + }, + + /** The anonymous branding/config endpoint, read by the sign-in page and the app shell alike. */ + appConfig: ["app-config"] as const, + + /** Fixed for the lifetime of a deployment. */ + permissionCatalog: ["permission-catalog"] as const, + adapters: (kind: string) => ["adapters", kind] as const, +} as const; + +const MINUTE = 60_000; + +/** + * How long each kind of data is trusted before a remount refetches it. + * + * Declared centrally rather than at 97 call sites, so the policy for an entity is one number in + * one place. Mutations invalidate explicitly, so these windows only govern *background* + * refetching — an edit still shows up immediately. + * + * Three tiers: + * - **Fixed per deployment** — never refetched. + * - **Reference/config data** — changes only when someone edits it here, and that path + * invalidates. Minutes are safe. + * - **Operational data** — moves on its own as messages flow. Always refetched on mount; most of + * these screens also declare their own `refetchInterval`. + */ +export function applyQueryDefaults(queryClient: QueryClient): void { + const fixed = [keys.permissionCatalog, ["adapters"]]; + const reference = [ + keys.informationTypes.all, + keys.partners.all, + keys.workGroups.all, + keys.retryPolicies.all, + keys.valueSets.all, + keys.notifiers.all, + keys.roles.all, + keys.users.all, + keys.apiGateways.all, + keys.busGateways.all, + keys.subscriptions.all, + keys.settings.all, + keys.appConfig, + ]; + const operational = [ + keys.exchanges.all, + keys.scheduledRetries.all, + keys.queueHealth, + keys.dashboard, + keys.retryUsage.all, + keys.subscriptions.lastRuns, + keys.subscriptions.scheduleHealth, + ["subscriptions", "receive-attempts"], + ["subscriptions", "runs"], + // The row shape carries live state — is it running, how many consecutive failures, when it + // fires next — so the tables built on it have to refetch on mount like any other live view. + // (The whole-list `cache` below is a different query and stays held for the session.) + keys.subscriptions.rows, + ]; + + for (const key of fixed) queryClient.setQueryDefaults(key, { staleTime: Infinity }); + for (const key of reference) queryClient.setQueryDefaults(key, { staleTime: 5 * MINUTE }); + // Registered after `reference` on purpose: these are nested under `subscriptions`, and defaults + // merge in registration order, so the more specific prefix has to come second to win. + for (const key of operational) queryClient.setQueryDefaults(key, { staleTime: 0 }); + + // The heaviest response in the app, and the one every "who uses this?" panel reads. Held for the + // session rather than re-fetched every five minutes; edits still invalidate it explicitly. + queryClient.setQueryDefaults(keys.subscriptions.cache, { staleTime: Infinity }); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx index bb35f0c..26273d6 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -6,6 +6,7 @@ import { api, type AdapterInfo, type AdapterKind, type PartnerRow } from "../../ import { Button } from "../ui/basics"; import { Field } from "../ui/forms"; import { SearchSelect } from "../ui/SearchSelect"; +import { keys } from "../../api/queryKeys"; /** What the picker is choosing, named for the band above the fields. */ const KIND_LABELS: Record = { @@ -17,9 +18,8 @@ const KIND_LABELS: Record = { export function useAdapterCatalog(kind: AdapterKind) { return useQuery({ - queryKey: ["adapters", kind], + queryKey: keys.adapters(kind), queryFn: () => api.listAdapters(kind), - staleTime: Infinity, }); } @@ -32,8 +32,8 @@ interface ReferenceToken { /** All insertable reference tokens: every global key + every known partner property. */ function useReferenceTokens() { - const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets(), staleTime: 60_000 }); - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners(), staleTime: 60_000 }); + const sets = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const globals: ReferenceToken[] = (sets.data ?? []).flatMap((s) => Object.entries(s.values).map(([key, value]) => ({ label: `${s.id}.${key}`, @@ -60,9 +60,8 @@ function useReferenceTokens() { */ function PartnerPropValue({ partnerId, propKey }: { partnerId: number; propKey: string }) { const props = useQuery({ - queryKey: ["partner-adapter-properties", partnerId], + queryKey: keys.partners.adapterProperties(partnerId), queryFn: () => api.getPartnerAdapterProperties(partnerId), - staleTime: 60_000, }); if (props.isPending) return loading…; const value = props.data?.[propKey]; diff --git a/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx index f1aaac6..70c45af 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx @@ -4,6 +4,7 @@ import { api } from "../../api"; import { useSessionCan } from "../../auth/guards"; import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Dialog } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; import { EMPTY_INFORMATION_TYPE, InformationTypeFields, @@ -41,7 +42,7 @@ export function InformationTypeDialog({ const canEdit = useSessionCan("documents.edit"); const existing = useQuery({ - queryKey: ["information-type", typeId], + queryKey: keys.informationTypes.detail(typeId), queryFn: () => api.getInformationType(typeId!), enabled: typeId !== null, }); @@ -78,8 +79,7 @@ export function InformationTypeDialog({ return (await api.createInformationType(body)).id; }, onSuccess: async (savedId) => { - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); - await queryClient.invalidateQueries({ queryKey: ["information-type", savedId] }); + await queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); onSaved?.({ id: savedId, busMessageTypeName: informationTypeChanges(draft!).busMessageTypeName ?? "" }); if (typeId === null) { onClose(); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx index 0d7e88f..7940447 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx @@ -4,6 +4,7 @@ import { api } from "../../api"; import { useSessionCan } from "../../auth/guards"; import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Dialog } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; import { PartnerFields, partnerChanges, @@ -41,7 +42,7 @@ export function PartnerDialog({ const [justCreated, setJustCreated] = useState(false); const existing = useQuery({ - queryKey: ["partner", id], + queryKey: keys.partners.detail(id), queryFn: () => api.getPartner(id!), enabled: id !== null, }); @@ -75,8 +76,7 @@ export function PartnerDialog({ const creating = id === null; setId(savedId); onSaved?.(savedId); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); - await queryClient.invalidateQueries({ queryKey: ["partner", savedId] }); + await queryClient.invalidateQueries({ queryKey: keys.partners.all }); // Re-seed from the server rather than from the draft, so what the dialog // compares against is what was actually stored. setDraft(null); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx index add1faa..a88fbb3 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx @@ -11,6 +11,7 @@ import { ConfirmDialog, Dialog } from "../ui/overlays"; import { Panel } from "../ui/Panel"; import { MiniTable } from "../ui/Table"; import { SubscriptionMiniList, usePartnerSubscriptions } from "./shared"; +import { keys } from "../../api/queryKeys"; /** * Everything about a partner that can be *edited*, as one component. @@ -73,8 +74,7 @@ export function PartnerFields({ const revoke = useMutation({ mutationFn: (keyName: string) => api.revokePartnerCredential(partnerId!, keyName), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); }, }); @@ -209,8 +209,7 @@ function AddKeyDialog({ partnerId, onClose }: { partnerId: number; onClose: () = mutationFn: () => api.addPartnerCredential(partnerId, name), onSuccess: ({ key }) => { setIssuedKey(key); - void queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx index c1ddfd1..79feaee 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx @@ -9,6 +9,7 @@ import { CodeBadge } from "../ui/Panel"; import { AdapterConfig, useAdapterCatalog } from "./AdapterConfig"; import { InfoTypePicker } from "./pickers"; import { adapterIncomplete } from "../../pages/subscriptions/studio/faces"; +import { keys } from "../../api/queryKeys"; /** * A new gateway-backed subscription, asked down to what it cannot run without: a @@ -35,9 +36,8 @@ export function SubscriptionDialog({ const queryClient = useQueryClient(); const handlers = useAdapterCatalog("handler"); const infoTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), - staleTime: Infinity, }); const [name, setName] = useState(""); @@ -61,9 +61,7 @@ export function SubscriptionDialog({ enabled: true, }), onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); onCreated(created.id); onClose(); }, diff --git a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx index 1c4a765..489f39f 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx @@ -6,6 +6,7 @@ import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Field, TextInput } from "../ui/forms"; import { Dialog } from "../ui/overlays"; import { suggestSlug } from "../../lib/identifiers"; +import { keys } from "../../api/queryKeys"; /** * A work group's editable settings, as one component. @@ -131,7 +132,7 @@ export function WorkGroupDialog({ const [draft, setDraft] = useState(groupId === null ? EMPTY : null); const existing = useQuery({ - queryKey: ["work-group", groupId], + queryKey: keys.workGroups.detail(groupId), queryFn: () => api.getWorkGroup(groupId!), enabled: groupId !== null, }); @@ -150,9 +151,7 @@ export function WorkGroupDialog({ return created.id; }, onSuccess: (id) => { - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); - void queryClient.invalidateQueries({ queryKey: ["work-group", id] }); + void queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); onSaved?.(id); onClose(); }, diff --git a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx index 906e383..5fc630f 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx @@ -9,6 +9,7 @@ import { InformationTypeDialog } from "./InformationTypeDialog"; import { SubscriptionDialog } from "./SubscriptionDialog"; import { PartnerDialog } from "./PartnerDialog"; import { useSubscriptionsCache } from "./shared"; +import { keys } from "../../api/queryKeys"; /* * Pick-one controls used inside flows. Creating or amending the thing you are @@ -77,7 +78,7 @@ export function InfoTypePicker({ busRequired?: boolean; id?: string; }) { - const types = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); + const types = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }); const canCreate = useSessionCan("documents.create"); const canEdit = useSessionCan("documents.edit"); /** undefined = closed, null = creating, number = editing that type. */ @@ -147,7 +148,7 @@ export function SubscriptionPicker({ onDefineHere?: () => void; }) { const subscriptions = useSubscriptionsCache(); - const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); + const infoTypes = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }); const canCreate = useSessionCan("subscriptions.create"); const [creating, setCreating] = useState(false); @@ -222,7 +223,7 @@ export function PartnerPicker({ excludeIds?: number[]; id?: string; }) { - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const canCreate = useSessionCan("partners.create"); const canEdit = useSessionCan("partners.edit"); /** undefined = closed, null = creating, number = editing that partner. */ diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index 66c49e9..ed4ef5d 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -17,6 +17,7 @@ import { Badge } from "../ui/basics"; import { Popover } from "../ui/Popover"; import { MiniTable, type Column } from "../ui/Table"; import { formatDate, timeAgo } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; /** * Display names for subscription types; Internal and ApiCall are legacy. @@ -386,9 +387,8 @@ export function SetupList({ items }: { items: SubscriptionSetupRef[] }) { */ export function useSubscriptionsCache() { return useQuery({ - queryKey: ["subscriptions"], + queryKey: keys.subscriptions.cache, queryFn: () => api.listSubscriptions(), - staleTime: Infinity, }); } @@ -407,13 +407,13 @@ export function usePartnerSubscriptions(): Map { const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }).data ?? []; const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }).data ?? []; @@ -457,13 +457,13 @@ export function useGatewayPartners(): Map< const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }).data ?? []; const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }).data ?? []; @@ -496,7 +496,7 @@ export function useGatewayPartners(): Map< export function useSubscriptionRowsById(): Map { const rows = useQuery({ - queryKey: ["subscription-rows"], + queryKey: keys.subscriptions.rows, queryFn: () => api.listSubscriptionRows(), }).data ?? []; return useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]); @@ -507,7 +507,7 @@ export function useWorkGroupNames(): Map { const canSee = useSessionCan("workgroups.view"); const groups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), enabled: canSee, }).data ?? []; @@ -519,7 +519,7 @@ export function useRetryPolicyNames(): Map { const canSee = useSessionCan("retry-policies.view"); const policies = useQuery({ - queryKey: ["retry-policies"], + queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies(), enabled: canSee, }).data ?? []; diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx index 9909149..8ea3818 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx @@ -20,6 +20,7 @@ import { visibleGroups } from "../../nav"; import { Avatar } from "../ui/Avatar"; import { Button } from "../ui/basics"; import { Menu, MenuItem } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; /** * Shown on every page while unsaved setting changes exist — the whole app @@ -35,7 +36,7 @@ function SettingsPreviewBanner() { if (count === 0 || pathname.startsWith("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/settings")) return null; // Land back on the section holding the first pending change. - const rows = queryClient.getQueryData(["settings"]); + const rows = queryClient.getQueryData(keys.settings.list); const section = rows?.find((r) => r.key in draft)?.section; const editUrl = section ? `/settings?section=${encodeURIComponent(section)}` : "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/settings"; diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx b/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx index 983d4d4..4613ea6 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx @@ -12,6 +12,7 @@ import { setSelectedPartner, } from "../../lib/mapping/MappingEditorContext"; import { api } from "../../api"; +import { keys } from "../../api/queryKeys"; // ─── Mode toggle button ─────────────────────────────────────────────────────── @@ -59,11 +60,11 @@ const MappingEditorToolbar: React.FC = ({ const { mode, fieldMappings, arrayMappings, past, future, selectedPartnerId } = useMappingEditorState(); // Sync local dropdown state when selectedPartnerId changes (including reset to null) - const { data: partners } = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const { data: partners } = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); // listPartners() is a light row (no adapterProperties) — fetch the selected partner's // properties separately so the "Partner" mode datalist actually has options to suggest. const { data: adapterProperties, isFetching: isPartnerFetching } = useQuery({ - queryKey: ["partner-adapter-properties", selectedPartnerId], + queryKey: keys.partners.adapterProperties(selectedPartnerId), queryFn: () => api.getPartnerAdapterProperties(selectedPartnerId!), enabled: selectedPartnerId != null, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts index 83d98e8..bad5e10 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { api, type GlobalValuesSetRow } from "../../api"; import type { KeyValuePair } from "../../lib/mapping/types"; import type { ValuesSetMap } from "../../lib/mapping/scribanGenerator"; +import { keys } from "../../api/queryKeys"; /** Boundary adapters: the prototype persists mapperProperties as Record, * while the verbatim mapping reducer/generator speak the legacy KeyValuePair[] shape. */ @@ -20,7 +21,7 @@ export const kvpsToRecord = (kvps: KeyValuePair[]): Record => * the source of the data. */ export function useGlobalSets(): GlobalValuesSetRow[] { - const { data } = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets() }); + const { data } = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); return data ?? []; } diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts index 99a2f1a..3da6a56 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts @@ -6,13 +6,14 @@ import { loadEditorContext, } from "../../lib/mapping/MappingEditorContext"; import { recordToKvps } from "./data"; +import { keys } from "../../api/queryKeys"; // Handles the two data-loading effects: clear-on-id-change and populate-on-data-arrive. // Fully self-contained — callers get no return value. export function useMappingEditorLoader(subscriptionId: number): void { const dispatch = useMappingEditorDispatch(); const { data: subscriptionData } = useQuery({ - queryKey: ["subscription", subscriptionId], + queryKey: keys.subscriptions.detail(subscriptionId), queryFn: () => api.getSubscription(subscriptionId), enabled: !!subscriptionId, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts index c94839e..e276dad 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts @@ -13,6 +13,7 @@ import { import { NATIVE_JSON_MAPPER_ID, type ValidationError, type KeyValuePair } from "../../lib/mapping/types"; import { generateScriban, parseScriban, resolveParentArrayIds } from "../../lib/mapping/scribanGenerator"; import { kvpsToRecord, useValuesSetMap } from "./data"; +import { keys } from "../../api/queryKeys"; // ─── Return type ────────────────────────────────────────────────────────────── @@ -45,7 +46,7 @@ export function useSave(subscriptionId: number): UseSaveResult { mapperId: NATIVE_JSON_MAPPER_ID, mapperProperties: kvpsToRecord(props), }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["subscription", subscriptionId] }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.subscriptions.detail(subscriptionId) }), }); const isSaving = saveMapper.isPending; const [saveSuccess, setSaveSuccess] = useState(false); diff --git a/SW.Bitween.Web/ClientApp/src/lib/branding.ts b/SW.Bitween.Web/ClientApp/src/lib/branding.ts index ff4a61f..574ba6f 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/branding.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/branding.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { getAppConfig } from "../api"; import { applyColorScale } from "./colorScale"; import { useSettingsDraft } from "./settingsDraft"; +import { keys } from "../api/queryKeys"; /** * The Brand & theme settings, resolved through the unsaved draft so edits @@ -40,7 +41,7 @@ const catalogKey = (prop: string) => `Theme.${prop[0].toUpperCase()}${prop.slice export function useBranding(): Branding { // Read through the anonymous config endpoint rather than the settings list: the sign-in page // has to brand itself with no session, and this way both sides of the door share one path. - const { data } = useQuery({ queryKey: ["appConfig"], queryFn: getAppConfig }); + const { data } = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); const draft = useSettingsDraft(); return useMemo(() => { diff --git a/SW.Bitween.Web/ClientApp/src/main.tsx b/SW.Bitween.Web/ClientApp/src/main.tsx index 1d62ad1..6d35125 100644 --- a/SW.Bitween.Web/ClientApp/src/main.tsx +++ b/SW.Bitween.Web/ClientApp/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "react-router"; import { NotWiredError } from "./api/types"; +import { applyQueryDefaults } from "./api/queryKeys"; import { SessionProvider } from "./auth/SessionContext"; import { router } from "./router"; import "./index.css"; @@ -18,7 +19,13 @@ if (base !== "/" && !window.location.pathname.startsWith(base.replace(/\/$/, "") const queryClient = new QueryClient({ defaultOptions: { queries: { + // A floor, not the policy: per-entity windows are registered by applyQueryDefaults below and + // override this. It only governs a query whose key isn't in the catalog, where the safe + // answer is "refetch on mount, but don't do it twice in ten seconds". staleTime: 10_000, + // Kept long enough that going back to a page you were just on renders from cache instead of + // re-fetching. Was the 5-minute default, which is shorter than a train of thought. + gcTime: 30 * 60_000, refetchOnWindowFocus: false, // NotWiredError is permanent (the method will reject every time, with no // network round-trip) — retrying it just stalls "Loading…" states for the @@ -28,6 +35,8 @@ const queryClient = new QueryClient({ }, }); +applyQueryDefaults(queryClient); + createRoot(document.getElementById("root")!).render( diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx index 80c5ad8..ad062c7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -21,6 +21,7 @@ import { useWorkGroupNames, } from "../../components/config/shared"; import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; function AggregateNowButton({ job }: { job: SubscriptionRow }) { const queryClient = useQueryClient(); @@ -28,9 +29,7 @@ function AggregateNowButton({ job }: { job: SubscriptionRow }) { const aggregate = useMutation({ mutationFn: () => api.aggregateNow(job.id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }, }); @@ -103,7 +102,7 @@ export function AggregationsPage() { const canOperate = useSessionCan("subscriptions.operate"); const rows = useQuery({ - queryKey: ["subscription-rows-search", "Aggregation", q, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ type: "Aggregation", q, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, type: "Aggregation", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, @@ -115,10 +114,10 @@ export function AggregationsPage() { const nameById = useMemo(() => new Map(setups.map((s) => [s.id, s.name])), [setups]); const workGroupNames = useWorkGroupNames(); const retryPolicyNames = useRetryPolicyNames(); - const lastRuns = useQuery({ queryKey: ["last-runs"], queryFn: () => api.listLastRuns() }).data ?? []; + const lastRuns = useQuery({ queryKey: keys.subscriptions.lastRuns, queryFn: () => api.listLastRuns() }).data ?? []; const lastRunById = useMemo(() => new Map(lastRuns.map((r) => [r.subscriptionId, r])), [lastRuns]); const health = - useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + useQuery({ queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth() }).data ?? []; const healthById = useMemo(() => new Map(health.map((h) => [h.subscriptionId, h])), [health]); const setParam = (key: string, value: string | null, resetOffset = true) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx index 0efcfba..0e8c457 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx @@ -6,6 +6,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { finishUrlName, suggestSlug, toUrlName } from "../../lib/identifiers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; export function ApiGatewayNewPage() { const navigate = useNavigate(); @@ -17,7 +18,7 @@ export function ApiGatewayNewPage() { const create = useMutation({ mutationFn: () => api.createApiGateway({ name, urlName: finishUrlName(urlName) }), onSuccess: (gateway) => { - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); const base = `/api-gateways/${gateway.id}`; navigate(base); }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 0f04687..62b4bdd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -14,6 +14,7 @@ import { MiniTable } from "../../components/ui/Table"; import { Pagination } from "../../components/ui/Pagination"; import { useWiredSubscriptionColumns } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; const ATTACHMENTS_PAGE_SIZE = 10; @@ -27,7 +28,7 @@ export function ApiGatewayPage() { const wiredColumns = useWiredSubscriptionColumns((a) => a.subscriptionId); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -35,7 +36,7 @@ export function ApiGatewayPage() { const attachmentsQuery = searchParams.get("aq") ?? ""; const attachmentsOffset = searchParams.get("aoffset") ? Number(searchParams.get("aoffset")) : 0; const attachments = useQuery({ - queryKey: ["api-gateway-attachments-search", gatewayId, attachmentsQuery, attachmentsOffset], + queryKey: keys.apiGateways.attachments(gatewayId, { q: attachmentsQuery, offset: attachmentsOffset }), queryFn: () => api.searchGatewayAttachments(gatewayId, { search: attachmentsQuery, @@ -87,9 +88,8 @@ export function ApiGatewayPage() { inactive: gateway.data?.inactive ?? false, }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); setLoaded(false); }, }); @@ -274,9 +274,8 @@ export function ApiGatewayPage() { confirmLabel="Detach partner" onConfirm={async () => { await api.removeGatewayAttachment(gatewayId, removing.partnerId); - void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }} onClose={() => setRemoving(null)} /> @@ -297,8 +296,7 @@ export function ApiGatewayPage() { urlName: g.urlName, inactive: !g.inactive, }); - await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + await queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); }} onClose={() => setConfirmingActive(false)} /> @@ -317,8 +315,8 @@ export function ApiGatewayPage() { confirmLabel="Delete gateway" onConfirm={async () => { await api.deleteApiGateway(gatewayId); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/api-gateways"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index 804d0be..ded2212 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -8,6 +8,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { Pagination } from "../../components/ui/Pagination"; import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; import { LinkListCell, WiredHealthBadge, @@ -36,7 +37,7 @@ export function ApiGatewaysPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const gateways = useQuery({ - queryKey: ["api-gateways-search", q, inactive, offset], + queryKey: keys.apiGateways.search({ q, inactive, offset }), queryFn: () => api.searchApiGateways({ search: q, inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx index d4dc912..e46db78 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx @@ -6,6 +6,7 @@ import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui import { Field } from "../../components/ui/forms"; import { SubscriptionPicker, PartnerPicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -37,7 +38,7 @@ export function AttachPartnerPage() { const [searchParams, setSearchParams] = useSearchParams(); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx index d67ed94..ede40e8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx @@ -5,6 +5,7 @@ import { api } from "../../api"; import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { SubscriptionPicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -28,7 +29,7 @@ export function EditAttachmentPage() { const queryClient = useQueryClient(); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -50,9 +51,8 @@ export function EditAttachmentPage() { mutationFn: () => api.updateGatewayAttachment(gatewayId, { partnerId: pid, subscriptionId: draft.subscriptionId! }), onSuccess: () => { clear(); - void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate(`/api-gateways/${gatewayId}`); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx index fdaea86..37e94c8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx @@ -16,6 +16,7 @@ import { adapterIncomplete, faceOf } from "../subscriptions/studio/faces"; import { ResponseFields } from "../subscriptions/studio/ResponseFields"; import type { Draft as StudioDraft } from "../subscriptions/studio/model"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -73,7 +74,7 @@ export function NewGatewaySubscriptionPage() { const [stage, setStage] = useState("delivery"); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -111,9 +112,7 @@ export function NewGatewaySubscriptionPage() { enabled: true, }), onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); backToAttach({ picked: String(created.id) }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx index d36258c..e3dea70 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx @@ -7,6 +7,7 @@ import { homePath } from "../../nav"; import { Button, FormError } from "../../components/ui/basics"; import { Field, PasswordInput, TextInput } from "../../components/ui/forms"; import { AuthLayout } from "./AuthLayout"; +import { keys } from "../../api/queryKeys"; function MicrosoftMark() { return ( @@ -31,7 +32,7 @@ export function LoginPage() { const [busy, setBusy] = useState(false); // Microsoft sign-in only shows when the backend has MSAL configured. - const appConfig = useQuery({ queryKey: ["app-config"], queryFn: getAppConfig }); + const appConfig = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); const microsoftEnabled = Boolean(appConfig.data?.msalClientId); // The Login handler rejects email/password outright when this instance is Microsoft-only, so // offering the form would only ever produce a failed sign-in. diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx index c18dcc2..0a0be2e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx @@ -6,6 +6,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { InfoTypePicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form body uses. */ function useDraft(initial: T) { @@ -35,7 +36,7 @@ export function BusGatewayNewPage() { mutationFn: () => api.createBusGateway({ name: draft.name, informationTypeId: draft.informationTypeId! }), onSuccess: (gateway) => { clear(); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); const base = `/bus-gateways/${gateway.id}`; navigate(base); }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index 292c28d..acc5dac 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -24,6 +24,7 @@ import { import { PartnerDialog } from "../../components/config/PartnerDialog"; import { RouteList, type Selection } from "./studio/RouteList"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { BUS_NODES, NEW_ROUTE, @@ -74,19 +75,19 @@ export function BusGatewayPage() { const [params, setParams] = useSearchParams(); const gateway = useQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), retry: false, }); // One list serves three needs: the gateway's own promoted properties, its bus // message name, and resolving which type carries a published response. const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), }); // Every gateway, because a response on the bus wakes routes on all of them. - const allGateways = useQuery({ queryKey: ["bus-gateways"], queryFn: () => api.listBusGateways() }); - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const allGateways = useQuery({ queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const rowsById = useSubscriptionRowsById(); const allSubscriptions = useSubscriptionsCache(); const catalogs = { @@ -160,7 +161,7 @@ export function BusGatewayPage() { const id0 = routeEdit?.draft.subscriptionId ?? null; const q0 = useQuery({ - queryKey: ["subscription", id0], + queryKey: keys.subscriptions.detail(id0), queryFn: () => api.getSubscription(id0!), // The subscription being defined here has no server side to fetch yet. enabled: id0 !== null && id0 !== NEW_SUBSCRIPTION_ID, @@ -168,14 +169,14 @@ export function BusGatewayPage() { const d0 = useHopDraft(edit, id0, q0.data); const id1 = d0?.responseSubscriptionId ?? null; const q1 = useQuery({ - queryKey: ["subscription", id1], + queryKey: keys.subscriptions.detail(id1), queryFn: () => api.getSubscription(id1!), enabled: id1 !== null, }); const d1 = useHopDraft(edit, id1, q1.data); const id2 = d1?.responseSubscriptionId ?? null; const q2 = useQuery({ - queryKey: ["subscription", id2], + queryKey: keys.subscriptions.detail(id2), queryFn: () => api.getSubscription(id2!), enabled: id2 !== null, }); @@ -330,15 +331,18 @@ export function BusGatewayPage() { // Both awaited before the drafts are dropped: re-seeding from stale data // would leave the save bar up over changes that are already saved. const fresh = await queryClient.fetchQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), + // This has to be the saved gateway, not the one we already had: fetchQuery honours + // staleTime, and this key inherits the five minutes registered for bus gateways, so + // without this it returns the copy from before the save — and the new route would be + // missing from fresh.routes below. + staleTime: 0, }); - if (edit && edit.subscriptionId !== NEW_SUBSCRIPTION_ID) - await queryClient.invalidateQueries({ queryKey: ["subscription", edit.subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + // Awaited before the drafts are dropped: re-seeding from stale data would leave the save bar + // up over changes that are already saved. Covers the edited route's own subscription too. + await queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); setRouteEdit(null); setEdit(null); setName(fresh.name); @@ -723,11 +727,14 @@ export function BusGatewayPage() { onConfirm={async () => { await api.removeBusRoute(gatewayId, removingRoute); const fresh = await queryClient.fetchQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), + // As above: the cached copy still lists the route that was just removed, and + // fresh.routes[0] below would select it. + staleTime: 0, }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); setRouteEdit(null); setEdit(null); setQuery({ route: fresh.routes[0] ? String(fresh.routes[0].id) : null, hop: null }); @@ -747,8 +754,7 @@ export function BusGatewayPage() { confirmLabel={g.inactive ? "Activate" : "Deactivate"} onConfirm={async () => { await api.updateBusGateway(gatewayId, { name: g.name, inactive: !g.inactive }); - await queryClient.invalidateQueries({ queryKey: ["bus-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); + await queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); }} onClose={() => setConfirmingActive(false)} /> @@ -766,8 +772,8 @@ export function BusGatewayPage() { confirmLabel="Delete gateway" onConfirm={async () => { await api.deleteBusGateway(gatewayId); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/bus-gateways"); }} onClose={() => setDeletingGateway(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx index 4dc442d..ef3987d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx @@ -16,6 +16,7 @@ import { useSubscriptionRowsById, } from "../../components/config/shared"; import { matchSummary } from "../../lib/match"; +import { keys } from "../../api/queryKeys"; /** * Bus gateways — messages picked off the bus. A gateway listens for one @@ -42,14 +43,14 @@ export function BusGatewaysPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const gateways = useQuery({ - queryKey: ["bus-gateways-search", q, informationTypeId, inactive, offset], + queryKey: keys.busGateways.search({ q, informationTypeId, inactive, offset }), queryFn: () => api.searchBusGateways({ search: q, informationTypeId, inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); const subscriptionsById = useSubscriptionRowsById(); const infoTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), enabled: canSeeInfoTypes, }).data ?? []; diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index 67004a0..a6d02d0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -10,6 +10,7 @@ import { AdapterConfig } from "../../../components/config/AdapterConfig"; import { MatchExpressionEditor } from "../../../components/config/MatchExpressionEditor"; import { HealthBadge } from "../../../components/config/shared"; import { ResponseFields } from "../../subscriptions/studio/ResponseFields"; +import { keys } from "../../../api/queryKeys"; import { BUS_NODES, type BusNodeId, @@ -121,11 +122,10 @@ export function RouteBody({ onEditPartner: (partnerId: number) => void; onNewSubscription: () => void; }) { - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const subscriptions = useQuery({ - queryKey: ["subscriptions"], + queryKey: keys.subscriptions.cache, queryFn: () => api.listSubscriptions(), - staleTime: Infinity, }); const canCreatePartner = useSessionCan("partners.create"); const canCreateSubscription = useSessionCan("subscriptions.create"); @@ -224,11 +224,10 @@ export function SubscriptionBody({ autoFocusName?: boolean; }) { const workGroups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), - staleTime: Infinity, }); - const retryPolicies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + const retryPolicies = useQuery({ queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies() }); return (
diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index 1f5f20b..34bdc74 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -8,6 +8,7 @@ import { Panel } from "../../components/ui/Panel"; import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; import { timeAgo } from "../../lib/dates"; import { StatusBadge, XchangeId } from "../exchanges/shared"; +import { keys } from "../../api/queryKeys"; const CHART_HEIGHT = 140; @@ -53,7 +54,7 @@ function StatTile({ export function DashboardPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const { data, isLoading, isError } = useQuery({ - queryKey: ["dashboard"], + queryKey: keys.dashboard, queryFn: () => api.getDashboard(), refetchInterval: 60_000, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index 5cb3e85..bf39ec6 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDateTime, duration, timeUntil } from "../../lib/dates"; import { useSubscriptionsCache } from "../../components/config/shared"; import { RetryDialog, journeyStages, type JourneyStage } from "./shared"; +import { keys } from "../../api/queryKeys"; const STAGE_TONES: Record = { done: { ring: "border-ok-100", badge: Done }, @@ -78,7 +79,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { isLoading: activeLoading, isError: activeErrored, } = useQuery({ - queryKey: ["exchange-document", activeKey], + queryKey: keys.exchanges.document(activeKey), queryFn: () => api.getExchangeDocument(activeKey!), enabled: activeKey !== null, }); @@ -87,7 +88,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { const [actionError, setActionError] = useState(null); const [startedId, setStartedId] = useState(null); - const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + const invalidate = () => void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); const retry = useMutation({ mutationFn: (reset: boolean) => api.retryExchange(x.id, { reset }), @@ -108,7 +109,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { onSuccess: () => { setStartedId("scheduled"); invalidate(); - void queryClient.invalidateQueries({ queryKey: ["scheduled-retries"] }); + void queryClient.invalidateQueries({ queryKey: keys.scheduledRetries.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx index 45d7278..d873121 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx @@ -7,6 +7,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field } from "../../components/ui/forms"; import { SearchSelect } from "../../components/ui/SearchSelect"; import { useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; /** * Manually inject a payload — useful for testing a pipeline without waiting @@ -29,7 +30,7 @@ export function ExchangeNewPage() { const subscriptions = useSubscriptionsCache().data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; const create = useMutation({ mutationFn: () => diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index bdadce4..7d6f76f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -13,6 +13,7 @@ import { timeAgo, timeUntil, duration } from "../../lib/dates"; import { ExchangeDrawer } from "./ExchangeDrawer"; import { JourneyStrip, RetryDialog, STATUS_LABELS, StatusBadge } from "./shared"; import { PromotedProps } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; const STATUSES: ExchangeStatus[] = ["processing", "success", "badResponse", "failed"]; @@ -53,16 +54,16 @@ export function ExchangesPage() { const queryClient = useQueryClient(); const { data, isLoading, dataUpdatedAt } = useQuery({ - queryKey: ["exchanges", searchParams.toString()], + queryKey: keys.exchanges.search(searchParams.toString()), queryFn: () => api.searchExchanges(query), refetchInterval: refreshMs || false, placeholderData: keepPreviousData, }); const subscriptions = useSubscriptionsCache().data ?? []; - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }).data ?? []; + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }).data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; /** * Every promoted key any information type declares, with the types that declare it. @@ -122,7 +123,7 @@ export function ExchangesPage() { setBulkResult( `${retried} retr${retried === 1 ? "y" : "ies"} started${skipped > 0 ? `, ${skipped} skipped (auto-retry already scheduled)` : ""}.`, ); - void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx index 58b9bef..f659348 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx @@ -9,6 +9,7 @@ import { EmptyState, LoadingBlock } from "../../components/ui/basics"; import { FlowLegend, FlowMap } from "./FlowMap"; import { buildFlowGraph } from "./model"; import { layoutFlow } from "./layout"; +import { keys } from "../../api/queryKeys"; /** * How data moves between gateways, on one surface. @@ -27,16 +28,16 @@ export function FlowPage() { const subscriptions = useSubscriptionsCache(); const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), }); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }); const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx index 1908b41..ec56524 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx @@ -10,6 +10,7 @@ import { KeyValueEditor, toRecord, toRows, type KvRow } from "../../components/u import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { SUBSCRIPTION_TYPE_LABELS, SubscriptionMiniList, @@ -23,7 +24,7 @@ export function GlobalValueSetPage() { const canEdit = useSessionCan("global-values.edit"); const set = useQuery({ - queryKey: ["value-set", id], + queryKey: keys.valueSets.detail(id), queryFn: () => api.getValueSet(id), retry: false, }); @@ -50,9 +51,8 @@ export function GlobalValueSetPage() { const save = useMutation({ mutationFn: () => api.updateValueSet(id, { name, values: toRecord(rows ?? []) }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["value-set", id] }); - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); setLoaded(false); }, }); @@ -186,7 +186,7 @@ export function GlobalValueSetPage() { confirmLabel="Delete value set" onConfirm={async () => { await api.deleteValueSet(id); - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + void queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); navigate("/global-values"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx index d6f0fa7..b273b5f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx @@ -12,6 +12,7 @@ import { Dialog } from "../../components/ui/overlays"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; import { suggestSlug } from "../../lib/identifiers"; +import { keys } from "../../api/queryKeys"; function CreateValueSetDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -23,7 +24,7 @@ function CreateValueSetDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createValueSet({ id: slug, name, values: {} }), onSuccess: (set) => { - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + void queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); navigate(`/global-values/${set.id}`); }, }); @@ -94,7 +95,7 @@ export function GlobalValueSetsPage() { const subscriptionIds = parseIds(searchParams.get("subscriptions")); const creating = searchParams.get("new") === "1"; - const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets() }); + const sets = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); const subscriptions = useSubscriptionsCache().data ?? []; const setParam = (key: string, value: string | null) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx index 50f3aa9..8293446 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx @@ -10,6 +10,7 @@ import { CodeBadge, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { ExchangesList, SetupList, TrailTable } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { InformationTypeFields, informationTypeChanges, @@ -26,7 +27,7 @@ export function InformationTypePage() { const canEdit = useSessionCan("documents.edit"); const type = useQuery({ - queryKey: ["information-type", typeId], + queryKey: keys.informationTypes.detail(typeId), queryFn: () => api.getInformationType(typeId), retry: false, }); @@ -50,9 +51,8 @@ export function InformationTypePage() { const save = useMutation({ mutationFn: () => api.updateInformationType(typeId, informationTypeChanges(draft!)), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["information-type", typeId] }); - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); setLoaded(false); }, }); @@ -157,7 +157,7 @@ export function InformationTypePage() { confirmLabel="Delete information type" onConfirm={async () => { await api.deleteInformationType(typeId); - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); + void queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); navigate("/information-types"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx index 2958e84..093c05f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx @@ -13,6 +13,7 @@ import { CodeBadge } from "../../components/ui/Panel"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -57,13 +58,13 @@ export function InformationTypesPage() { const filtering = subscriptionIds.length > 0; const serverSearch = useQuery({ - queryKey: ["information-types-search", q, format, busEnabled, offset], + queryKey: keys.informationTypes.search({ q, format, busEnabled, offset }), queryFn: () => api.searchInformationTypes({ search: q, format, busEnabled, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, enabled: !filtering, }); const allTypes = useQuery({ - queryKey: ["information-types-all"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), enabled: filtering, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx index 754e316..394a2b8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx @@ -14,6 +14,7 @@ import { useAdapterCatalog } from "../../components/config/AdapterConfig"; import { useSubscriptionsCache } from "../../components/config/shared"; import { timeAgo } from "../../lib/dates"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; type Draft = Omit; @@ -72,7 +73,7 @@ export function NotifierPage() { const canEdit = useSessionCan("notifiers.edit"); const notifier = useQuery({ - queryKey: ["notifier", notifierId], + queryKey: keys.notifiers.detail(notifierId), queryFn: () => api.getNotifier(notifierId), retry: false, }); @@ -101,10 +102,8 @@ export function NotifierPage() { const save = useMutation({ mutationFn: () => api.updateNotifier(notifierId, draft!), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["notifier", notifierId] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); setLoaded(false); }, }); @@ -354,8 +353,7 @@ export function NotifierPage() { confirmLabel="Delete notifier" onConfirm={async () => { await api.deleteNotifier(notifierId); - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); navigate("/notifiers"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx index d1da4fc..0b2c9e9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx @@ -11,6 +11,7 @@ import { Field, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; function CreateNotifierDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -20,8 +21,7 @@ function CreateNotifierDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createNotifier({ name }), onSuccess: (notifier) => { - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); navigate(`/notifiers/${notifier.id}`); }, }); @@ -70,7 +70,7 @@ export function NotifiersPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const notifiers = useQuery({ - queryKey: ["notifiers-search", q, offset], + queryKey: keys.notifiers.search({ q, offset }), queryFn: () => api.searchNotifiers({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx index 70f4f7a..7ef6525 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx @@ -10,6 +10,7 @@ import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { ExchangesList, SetupList, usePartnerSubscriptions } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { PartnerFields, partnerChanges, @@ -26,7 +27,7 @@ export function PartnerPage() { const canEdit = useSessionCan("partners.edit"); const partner = useQuery({ - queryKey: ["partner", partnerId], + queryKey: keys.partners.detail(partnerId), queryFn: () => api.getPartner(partnerId), retry: false, }); @@ -53,10 +54,9 @@ export function PartnerPage() { const save = useMutation({ mutationFn: () => api.updatePartner(partnerId, partnerChanges(draft!)), onSuccess: async () => { - // Await the detail refetch BEFORE re-syncing the draft, so the re-sync - // effect reads the freshly-saved server data (not the stale cache). - await queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + // Awaited BEFORE the draft is re-synced, so the re-sync effect reads the + // freshly-saved server data (not the stale cache). + await queryClient.invalidateQueries({ queryKey: keys.partners.all }); setLoaded(false); }, }); @@ -206,7 +206,7 @@ export function PartnerPage() { confirmLabel="Delete partner" onConfirm={async () => { await api.deletePartner(partnerId); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); navigate("/partners"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx index 1e053ff..9ea7710 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx @@ -11,6 +11,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache, usePartnerSubscriptions } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -55,13 +56,13 @@ export function PartnersPage() { const filtering = subscriptionIds.length > 0; const serverSearch = useQuery({ - queryKey: ["partners-search", q, offset], + queryKey: keys.partners.search({ q, offset }), queryFn: () => api.searchPartners({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, enabled: !filtering, }); const allPartners = useQuery({ - queryKey: ["partners-all"], + queryKey: keys.partners.list, queryFn: () => api.listPartners(), enabled: filtering, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx index c4bf38e..467e196 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx @@ -8,6 +8,7 @@ import { Badge, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { queueHealthTitle } from "../../components/config/shared"; import { Panel } from "../../components/ui/Panel"; import { timeAgo } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; const POLL_MS = 5_000; @@ -92,7 +93,7 @@ function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub? export function QueueHealthPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const { data, isLoading, isError, dataUpdatedAt } = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: POLL_MS, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx index 956811a..7d8eb03 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx @@ -11,6 +11,7 @@ import { Dialog } from "../../components/ui/overlays"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; function CreateRetryPolicyDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -20,7 +21,7 @@ function CreateRetryPolicyDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createRetryPolicy({ name }), onSuccess: (policy) => { - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); navigate(`/retry-policies/${policy.id}`); }, }); @@ -65,7 +66,7 @@ export function RetryPoliciesPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const policies = useQuery({ - queryKey: ["retry-policies-search", q, offset], + queryKey: keys.retryPolicies.search({ q, offset }), queryFn: () => api.searchRetryPolicies({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index 202ed57..34b9940 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -13,6 +13,7 @@ import { AdapterConfig } from "../../components/config/AdapterConfig"; import { GroupDialog } from "./GroupDialog"; import { UsagePanel } from "./UsagePanel"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; const matcherSummary = (m: RetryMatcher): string => { switch (m.type) { @@ -226,7 +227,7 @@ export function RetryPolicyPage() { const canEdit = useSessionCan("retry-policies.edit"); const policy = useQuery({ - queryKey: ["retry-policy", policyId], + queryKey: keys.retryPolicies.detail(policyId), queryFn: () => api.getRetryPolicy(policyId), retry: false, }); @@ -268,11 +269,10 @@ export function RetryPolicyPage() { alertHandlerProperties: alertProps, }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["retry-policy", policyId] }); - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); // Editing a group can change which budgets exist, so the usage report is stale too. - void queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); setLoaded(false); }, }); @@ -497,7 +497,7 @@ export function RetryPolicyPage() { confirmLabel="Delete policy" onConfirm={async () => { await api.deleteRetryPolicy(policyId); - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); navigate("/retry-policies"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx index ae8708d..aac1c0e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; import { Panel } from "../../components/ui/Panel"; import { formatDateTime, timeAgo } from "../../lib/dates"; import { AlertRouting } from "./AlertRouting"; +import { keys } from "../../api/queryKeys"; /** * What each retry budget of this policy has actually spent, and whether anyone was told when @@ -120,7 +121,7 @@ function AlertedCell({ row }: { row: RetryUsageRow }) { /** The failures a pair spent its budget on, fetched only once the row is opened. */ function Attempts({ policyId, row }: { policyId: number; row: RetryUsageRow }) { const q = useQuery({ - queryKey: ["retry-attempts", policyId, row.subscriptionId, row.groupId], + queryKey: keys.retryUsage.attempts(policyId, row.subscriptionId, row.groupId), queryFn: () => api.getRetryAttempts(policyId, { subscriptionId: row.subscriptionId, groupId: row.groupId }), }); @@ -209,7 +210,7 @@ function OverrideDialog({ const save = useMutation({ mutationFn: () => api.saveRetryAlertOverride(policyId, { ...value, subscriptionId: row.subscriptionId, groupId: row.groupId }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + await queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); onClose(); }, }); @@ -270,9 +271,9 @@ export function UsagePanel({ const [overriding, setOverriding] = useState(null); const [resetting, setResetting] = useState(null); - const usage = useQuery({ queryKey: ["retry-usage", policyId], queryFn: () => api.getRetryUsage(policyId) }); + const usage = useQuery({ queryKey: keys.retryUsage.forPolicy(policyId), queryFn: () => api.getRetryUsage(policyId) }); - const invalidate = () => queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); if (usage.isPending) return ; if (usage.isError) diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 76d24af..0d2cba8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -20,6 +20,7 @@ import { useWorkGroupNames, } from "../../components/config/shared"; import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; function ReceiveNowButton({ job }: { job: SubscriptionRow }) { const queryClient = useQueryClient(); @@ -27,9 +28,7 @@ function ReceiveNowButton({ job }: { job: SubscriptionRow }) { const receive = useMutation({ mutationFn: () => api.receiveNow(job.id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }, }); @@ -103,7 +102,7 @@ export function ScheduledJobsPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const rows = useQuery({ - queryKey: ["subscription-rows-search", "Receiving", q, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ type: "Receiving", q, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, type: "Receiving", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, @@ -115,10 +114,10 @@ export function ScheduledJobsPage() { const workGroupNames = useWorkGroupNames(); const retryPolicyNames = useRetryPolicyNames(); // One request for the whole list rather than one per row. - const lastRuns = useQuery({ queryKey: ["last-runs"], queryFn: () => api.listLastRuns() }).data ?? []; + const lastRuns = useQuery({ queryKey: keys.subscriptions.lastRuns, queryFn: () => api.listLastRuns() }).data ?? []; const lastRunById = useMemo(() => new Map(lastRuns.map((r) => [r.subscriptionId, r])), [lastRuns]); const health = - useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + useQuery({ queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth() }).data ?? []; const healthById = useMemo(() => new Map(health.map((h) => [h.subscriptionId, h])), [health]); const setParam = (key: string, value: string | null, resetOffset = true) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx index 1591d33..0442de5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx @@ -14,6 +14,7 @@ import { Table } from "../../components/ui/Table"; import { useSubscriptionsCache } from "../../components/config/shared"; import { formatDateTime, timeAgo, timeUntil } from "../../lib/dates"; import { PromotedProps } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -37,7 +38,7 @@ export function ScheduledRetriesPage() { const { can } = useSession(); const { data, isLoading } = useQuery({ - queryKey: ["scheduled-retries", searchParams.toString()], + queryKey: keys.scheduledRetries.search(searchParams.toString()), queryFn: () => api.searchScheduledRetries(query), refetchInterval: 30_000, placeholderData: keepPreviousData, @@ -45,7 +46,7 @@ export function ScheduledRetriesPage() { const subscriptions = useSubscriptionsCache().data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; const setParam = (key: string, value: string | null, resetOffset = true) => { const next = new URLSearchParams(searchParams); @@ -62,8 +63,8 @@ export function ScheduledRetriesPage() { const runNow = useMutation({ mutationFn: (id: string) => api.runScheduledRetryNow(id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["scheduled-retries"] }); - void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + void queryClient.invalidateQueries({ queryKey: keys.scheduledRetries.all }); + void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx index 719bfbc..1b986ea 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Badge, Button, LoadingBlock } from "../../components/ui/basics"; import { Checkbox, TextInput } from "../../components/ui/forms"; import { UnsavedBar } from "../../components/ui/Panel"; import { settingsDraft, useSettingsDraft } from "../../lib/settingsDraft"; +import { keys } from "../../api/queryKeys"; /** Sections, in the order the backend catalog lists them. */ const sectionsOf = (rows: SettingRow[]): string[] => [...new Set(rows.map((r) => r.section))]; @@ -226,7 +227,7 @@ function SettingRowEditor({ export function SettingsPage() { const canEdit = useSessionCan("settings.edit"); const queryClient = useQueryClient(); - const { data: rows, isLoading } = useQuery({ queryKey: ["settings"], queryFn: () => api.listSettings() }); + const { data: rows, isLoading } = useQuery({ queryKey: keys.settings.list, queryFn: () => api.listSettings() }); const draft = useSettingsDraft(); const [searchParams] = useSearchParams(); @@ -249,11 +250,11 @@ export function SettingsPage() { }, onSuccess: () => { settingsDraft.discardAll(); - void queryClient.invalidateQueries({ queryKey: ["settings"] }); + void queryClient.invalidateQueries({ queryKey: keys.settings.all }); // Branding everywhere reads the memoised config payload, so it has to be re-fetched // for a saved brand change to stick once the draft preview is dropped. resetAppConfig(); - void queryClient.invalidateQueries({ queryKey: ["appConfig"] }); + void queryClient.invalidateQueries({ queryKey: keys.appConfig }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx index 81b21ac..5b32ddd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx @@ -19,6 +19,7 @@ import { EntryPointsTable, Overview } from "./studio/Overview"; import { ResponseFields } from "./studio/ResponseFields"; import { draftOf, entryPointsOf, stageDirty, type Draft } from "./studio/model"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; export function SubscriptionPage() { const { id = "" } = useParams(); @@ -31,14 +32,14 @@ export function SubscriptionPage() { const [params, setParams] = useSearchParams(); const subscription = useQuery({ - queryKey: ["subscription", subscriptionId], + queryKey: keys.subscriptions.detail(subscriptionId), queryFn: () => api.getSubscription(subscriptionId), retry: false, }); const allSubscriptions = useSubscriptionsCache(); // promoted properties power the legacy message filter const infoType = useQuery({ - queryKey: ["information-type", subscription.data?.informationTypeId], + queryKey: keys.informationTypes.detail(subscription.data?.informationTypeId), queryFn: () => api.getInformationType(subscription.data!.informationTypeId), enabled: subscription.data?.type === "Internal", }); @@ -53,7 +54,7 @@ export function SubscriptionPage() { // Shares the scheduled-jobs page's cache entry. The only per-stage fault we // can honestly attribute — it comes from the scheduler's own trigger state. const scheduleHealth = useQuery({ - queryKey: ["schedule-health"], + queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth(), enabled: subscription.data?.type === "Receiving" || subscription.data?.type === "Aggregation", }); @@ -106,13 +107,11 @@ export function SubscriptionPage() { return () => window.removeEventListener("keydown", onKey); }, [params, draft, subscription.data, setParams]); - const invalidate = () => { - const detail = queryClient.invalidateQueries({ queryKey: ["subscription", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - return detail; - }; + // One call, because `all` is a prefix of `detail` and already covers it. Invalidating both + // cancelled the detail refetch the first call had just started (invalidateQueries cancels by + // default), so the promise awaited below belonged to the cancelled one and could resolve before + // the fresh data landed — the very race the await exists to prevent. + const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); const save = useMutation({ mutationFn: () => api.updateSubscription(subscriptionId, draft!), @@ -132,8 +131,8 @@ export function SubscriptionPage() { mutationFn: () => api.aggregateNow(subscriptionId), onSuccess: async () => { await invalidate(); - void queryClient.invalidateQueries({ queryKey: ["subscription-runs", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.runs(subscriptionId) }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.lastRuns }); }, }); @@ -141,8 +140,8 @@ export function SubscriptionPage() { mutationFn: () => api.receiveNow(subscriptionId), onSuccess: async () => { await invalidate(); - void queryClient.invalidateQueries({ queryKey: ["subscription-runs", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.runs(subscriptionId) }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.lastRuns }); }, }); @@ -524,9 +523,7 @@ export function SubscriptionPage() { confirmLabel="Delete subscription" onConfirm={async () => { await api.deleteSubscription(subscriptionId); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/subscriptions"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx index 1209bb3..6dc2e12 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx @@ -9,6 +9,7 @@ import { Pagination } from "../../components/ui/Pagination"; import { SearchSelect } from "../../components/ui/SearchSelect"; import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; import { HealthBadge, SUBSCRIPTION_TYPE_LABELS, @@ -61,7 +62,7 @@ export function SubscriptionsPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const rows = useQuery({ - queryKey: ["subscription-rows-search", q, type, informationTypeId, partnerId, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ q, type, informationTypeId, partnerId, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, @@ -75,8 +76,8 @@ export function SubscriptionsPage() { placeholderData: keepPreviousData, }); const gatewayPartners = useGatewayPartners(); - const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }).data ?? []; + const infoTypes = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }).data ?? []; /** Its own partner (legacy types) plus any reached through a gateway. */ const partnersFor = (r: SubscriptionRow) => { diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx index db43d7e..878f28e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx @@ -13,6 +13,7 @@ import { formatDate, formatDateTime, formatDurationMs, timeAgo, timeUntil } from import { ReceiveAttemptsPanel, type AttemptKind } from "./ReceiveAttemptsPanel"; import { RetryBudget } from "./RetryBudget"; import type { Draft, EntryPoint } from "./model"; +import { keys } from "../../../api/queryKeys"; /** Who can feed this subscription. Shared with the Trigger stage, which is the same question. */ export function EntryPointsTable({ rows, empty }: { rows: EntryPoint[]; empty: string }) { @@ -170,11 +171,10 @@ export function Overview({ scheduled: boolean; }) { const workGroups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), - staleTime: Infinity, }); - const retryPolicies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + const retryPolicies = useQuery({ queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies() }); // Both scheduled types keep their own attempt history and show it in one table // (ReceiveAttemptsPanel) instead of the scheduler's run history beside a separate exchange // list. The scheduler's history is Quartz vocabulary an operator has no reason to know, it @@ -184,13 +184,13 @@ export function Overview({ const aggregation = s.type === "Aggregation"; const attemptKind: AttemptKind | null = receiving ? "receiving" : aggregation ? "aggregation" : null; const runs = useQuery({ - queryKey: ["subscription-runs", s.id], + queryKey: keys.subscriptions.runs(s.id), queryFn: () => api.listSubscriptionRuns(s.id, 20), enabled: scheduled && attemptKind === null, }); // Just for the "Last run" fact above — ReceiveAttemptsPanel fetches its own page. const latestAttempt = useQuery({ - queryKey: ["receive-attempts", s.id, null, 0, 1], + queryKey: keys.subscriptions.receiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), queryFn: () => api.searchReceiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), enabled: attemptKind !== null, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx index 2c0ddd3..a61f08c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx @@ -10,6 +10,7 @@ import { Table } from "../../../components/ui/Table"; import { PromotedProps } from "../../../components/config/shared"; import { StatusBadge } from "../../exchanges/shared"; import { formatDateTime, timeAgo } from "../../../lib/dates"; +import { keys } from "../../../api/queryKeys"; const PAGE_SIZE = 25; @@ -191,7 +192,7 @@ export function ReceiveAttemptsPanel({ const [offset, setOffset] = useState(0); const attempts = useQuery({ - queryKey: ["receive-attempts", subscriptionId, outcome, offset], + queryKey: keys.subscriptions.receiveAttempts(subscriptionId, { outcome, offset }), queryFn: () => api.searchReceiveAttempts(subscriptionId, { outcome, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx index 6335da8..2e808e5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx @@ -8,6 +8,7 @@ import { Field } from "../../../components/ui/forms"; import { SearchSelect } from "../../../components/ui/SearchSelect"; import { InformationTypeDialog } from "../../../components/config/InformationTypeDialog"; import { busMessageNameProblem } from "../../../lib/busMessageName"; +import { keys } from "../../../api/queryKeys"; /** * The Response stage's body: what happens to whatever the delivery hands back. @@ -148,9 +149,8 @@ function BusMessageField({ onChange: (value: string | null) => void; }) { const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), - staleTime: Infinity, }); const canCreate = useSessionCan("documents.create"); const [creating, setCreating] = useState(false); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx index dd166c4..d8102fc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx @@ -6,6 +6,7 @@ import { api } from "../../../api"; import { Button } from "../../../components/ui/basics"; import { ConfirmDialog } from "../../../components/ui/overlays"; import { timeAgo } from "../../../lib/dates"; +import { keys } from "../../../api/queryKeys"; /** * This subscription's own retry budgets, asked for from its side rather than its policy's. @@ -24,7 +25,7 @@ export function RetryBudget({ subscriptionId, canEdit }: { subscriptionId: numbe const [resetting, setResetting] = useState(false); const usage = useQuery({ - queryKey: ["retry-usage", "subscription", subscriptionId], + queryKey: keys.retryUsage.forSubscription(subscriptionId), queryFn: () => api.getSubscriptionRetryUsage(subscriptionId), }); @@ -92,7 +93,7 @@ export function RetryBudget({ subscriptionId, canEdit }: { subscriptionId: numbe onConfirm={async () => { // No group id: every group of this subscription, which is what the banner reports on. await api.resetSubscriptionRetryUsage(subscriptionId); - await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + await queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); }} onClose={() => setResetting(false)} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx index 3d464e0..0365a59 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx @@ -4,10 +4,11 @@ import { api, ApiRequestError } from "../../api"; import { Button, FormError } from "../../components/ui/basics"; import { Checkbox, Field, PasswordInput, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; +import { keys } from "../../api/queryKeys"; export function AddMemberDialog({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); @@ -17,8 +18,8 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createUser({ displayName, email, password, roleIds }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["users"] }); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.users.all }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); onClose(); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index 68a66e4..1a1b98a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -11,6 +11,7 @@ import { Checkbox, PasswordInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDate, timeAgo, timeUntil } from "../../lib/dates"; import { statusBadge } from "./MembersTab"; +import { keys } from "../../api/queryKeys"; function Section({ title, children }: { title: string; children: ReactNode }) { return ( @@ -25,8 +26,8 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () const { session, can } = useSession(); const queryClient = useQueryClient(); - const user = useQuery({ queryKey: ["user", userId], queryFn: () => api.getUser(userId), retry: false }); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const user = useQuery({ queryKey: keys.users.detail(userId), queryFn: () => api.getUser(userId), retry: false }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const [draftRoleIds, setDraftRoleIds] = useState(null); const [confirming, setConfirming] = useState<"remove" | null>(null); const [newPassword, setNewPassword] = useState(""); @@ -42,9 +43,8 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () }, [onClose]); const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: ["users"] }); - void queryClient.invalidateQueries({ queryKey: ["user", userId] }); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.users.all }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); }; const saveRoles = useMutation({ diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx index d6e2083..60e5987 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx @@ -9,6 +9,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { timeAgo } from "../../lib/dates"; import { AddMemberDialog } from "./AddMemberDialog"; import { MemberDrawer } from "./MemberDrawer"; +import { keys } from "../../api/queryKeys"; const STATUS_FILTERS: { value: string; label: string }[] = [ { value: "all", label: "All" }, @@ -33,8 +34,8 @@ export function MembersTab() { const status = searchParams.get("status") ?? "all"; const addOpen = searchParams.get("add") === "1"; - const users = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() }); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const users = useQuery({ queryKey: keys.users.list, queryFn: () => api.listUsers() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const setParam = (key: string, value: string | null) => { setSearchParams( diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx index 5580180..d9b1ee9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx @@ -17,6 +17,7 @@ import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basi import { Field, TextInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Live answer to "what would someone with this role actually see?" */ function AccessPreview({ permissions, total }: { permissions: Set; total: number }) { @@ -73,7 +74,7 @@ export function RoleEditor() { const areas = catalog.data ?? []; const source = useQuery({ - queryKey: ["role", sourceId], + queryKey: keys.roles.detail(sourceId), queryFn: () => api.getRole(sourceId!), enabled: sourceId !== null, retry: false, @@ -127,8 +128,7 @@ export function RoleEditor() { return isNew ? api.createRole(input) : api.updateRole(id!, input); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["roles"] }); - void queryClient.invalidateQueries({ queryKey: ["role", id] }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); navigate("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/team/roles"); }, }); @@ -355,7 +355,7 @@ export function RoleEditor() { confirmLabel="Delete role" onConfirm={async () => { await api.deleteRole(id!); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); navigate("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/team/roles"); }} onClose={() => setConfirmingDelete(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx index a5ca155..447b226 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx @@ -5,10 +5,11 @@ import { api } from "../../api"; import { allKeysIn, usePermissionCatalog } from "../../api/permissions"; import { Can } from "../../auth/guards"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { keys } from "../../api/queryKeys"; export function RolesTab() { const navigate = useNavigate(); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const totalPermissions = allKeysIn(usePermissionCatalog().data ?? []).length; if (roles.isPending) return ; diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx index 93001d3..bcdef6f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx @@ -3,6 +3,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { api } from "../../api"; import { Badge } from "../../components/ui/basics"; import { queueHealthTitle } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; function LiveStat({ label, value, tone }: { label: string; value: ReactNode; tone?: "warn" | "danger" }) { @@ -28,7 +29,7 @@ function LiveStat({ label, value, tone }: { label: string; value: ReactNode; ton export function LiveQueueStats({ groupId }: { groupId: number }) { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const { data, isError } = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx index 1477fd5..20c2347 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx @@ -15,6 +15,7 @@ import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { SetupList } from "../../components/config/shared"; import { LiveQueueStats } from "./LiveQueueStats"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** * This group's slice of the live RabbitMQ picture — the same numbers the @@ -47,7 +48,7 @@ export function WorkGroupPage() { const canEdit = useSessionCan("workgroups.edit"); const group = useQuery({ - queryKey: ["work-group", groupId], + queryKey: keys.workGroups.detail(groupId), queryFn: () => api.getWorkGroup(groupId), retry: false, }); @@ -71,10 +72,8 @@ export function WorkGroupPage() { const save = useMutation({ mutationFn: () => api.updateWorkGroup(groupId, draft!), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["work-group", groupId] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); setLoaded(false); }, }); @@ -146,8 +145,7 @@ export function WorkGroupPage() { confirmLabel="Delete work group" onConfirm={async () => { await api.deleteWorkGroup(groupId); - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); navigate("/work-groups"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx index 54ab37d..7c31908 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx @@ -10,6 +10,7 @@ import { Badge, Button, EmptyState, InlineNotice, LoadingBlock } from "../../com import { Pagination } from "../../components/ui/Pagination"; import { Table, type Column } from "../../components/ui/Table"; import { UsedByCell, queueHealthTitle, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; /** @@ -60,13 +61,13 @@ export function WorkGroupsPage() { const rabbitMqConfigured = useRabbitMqManagementConfigured(); const groups = useQuery({ - queryKey: ["work-groups-search", q, offset], + queryKey: keys.workGroups.search({ q, offset }), queryFn: () => api.searchWorkGroups({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); const subscriptions = useSubscriptionsCache().data ?? []; const live = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/vitest.config.ts b/SW.Bitween.Web/ClientApp/vitest.config.ts index 847ad8a..fd17082 100644 --- a/SW.Bitween.Web/ClientApp/vitest.config.ts +++ b/SW.Bitween.Web/ClientApp/vitest.config.ts @@ -7,6 +7,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: ["src/lib/mapping/__tests__/**/*.test.ts"], + include: ["src/**/__tests__/**/*.test.ts"], }, }); diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 3edb1fb..45878e1 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -1,8 +1,11 @@ using System; +using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -329,6 +332,39 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddControllers(); + // Nothing was compressed before this: the SPA bundle went out at its full ~1 MB, and + // JSON list responses grow with the customer's data. Measured on the current bundle, + // gzip at Optimal takes it from 1,054 KB to 288 KB for ~15 ms of CPU. + // + // Gzip only, deliberately. .NET exposes just two useful Brotli levels and neither wins + // here: Fastest produces 329 KB (worse than gzip at Optimal) and Optimal produces + // 233 KB but costs ~0.9 s of CPU per megabyte, paid again by every cold visitor because + // nothing caches the compressed bytes server-side. Browsers prefer Brotli when it's + // offered, so registering it at Fastest would actively make the common case worse. The + // remaining 54 KB is only worth chasing by pre-compressing at build time. + // + // The explicit "text/javascript" matters — the static file middleware labels .js files + // that way, while the framework's default list only names "application/javascript", so + // relying on the defaults would silently skip the single largest response we serve. + // + // EnableForHttps is deliberate. TLS terminates here in local dev (and may in a + // deployment that doesn't front the pod with a proxy), so leaving it off would mean no + // compression at all in exactly the place we test it. The BREACH risk it guards against + // needs a secret and attacker-controlled text in the same response body; the API returns + // neither — auth tokens travel in headers and the Set-Cookie, never in a GET body. + services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] + { + "text/javascript", + "image/svg+xml", + }); + }); + services.Configure(options => + options.Level = CompressionLevel.Optimal); + services.AddAuthentication() .AddJwtBearer(configureOptions => @@ -395,6 +431,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseSWConsoleLogger(); app.UseForwardedHeaders(); + // Early, so everything downstream — static files, the SPA fallback, every API + // response — is compressed on the way out. + app.UseResponseCompression(); app.Use(async (context, next) => { @@ -418,17 +457,37 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) if (!context.Request.Path.StartsWithSegments("/swagger")) headers[ContentSecurityPolicyHeader] = ContentSecurityPolicy; - // Sensitive API responses (JSON) must not be cached by the browser or - // intermediaries. Scoped by content type so static assets stay cacheable. + // Every Cache-Control decision lives here, in one ordered set of rules, so they + // cannot contradict each other. Deferred to OnStarting because the content type + // is only known once whatever handled the request has decided what it's sending. context.Response.OnStarting(() => { - var contentType = context.Response.ContentType; - if (!string.IsNullOrEmpty(contentType) && - (contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("+json", StringComparison.OrdinalIgnoreCase))) + var contentType = context.Response.ContentType ?? ""; + var isJson = contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("+json", StringComparison.OrdinalIgnoreCase); + + if (isJson) { + // Sensitive API responses must not be cached by the browser or intermediaries. context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; } + else if (context.Request.Path.StartsWithSegments("/assets")) + { + // Vite content-hashes every filename under /assets, so the bytes behind a + // given URL never change — a new build produces new URLs. Saying so lets a + // returning browser skip the request entirely. Without this header it isn't + // told anything and falls back to guessing a freshness window from the + // file's age, which differs between browsers and shrinks after each deploy. + context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } + else if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + { + // index.html is the one file whose URL survives a deploy, and it carries the + // hashed asset names. It has to be revalidated every time: a heuristically + // cached copy would keep pointing at assets the new build has replaced. + context.Response.Headers["Cache-Control"] = "no-cache"; + } + return Task.CompletedTask; });