diff --git a/SW.Bitween.Api/Resources/Ops/UnattendedQueues.cs b/SW.Bitween.Api/Resources/Ops/UnattendedQueues.cs index d231c869..0c9af0c8 100644 --- a/SW.Bitween.Api/Resources/Ops/UnattendedQueues.cs +++ b/SW.Bitween.Api/Resources/Ops/UnattendedQueues.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using EasyNetQ.Management.Client; +using EasyNetQ.Management.Client.Model; using Microsoft.Extensions.Caching.Memory; using SW.Bitween.Domain; using SW.Bus; @@ -59,13 +60,23 @@ public async Task Handle() } // Cached on the same clock as the bus's own management call, because this page polls. - var queues = await memoryCache.GetOrCreateAsync("bitween-all-queues", async entry => + // A failed fetch is deliberately not cached: caching it would report "no queues" for the + // rest of the cache window even after the management API recovers. + if (!memoryCache.TryGetValue("bitween-all-queues", out IReadOnlyList queues)) { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(busOptions.MonitoringCacheSeconds); - var client = new ManagementClient(new Uri(busOptions.ManagementUrl), - busOptions.ManagementUsername, busOptions.ManagementPassword); - return await client.GetQueuesAsync(busOptions.VirtualHost); - }); + try + { + var client = new ManagementClient(new Uri(busOptions.ManagementUrl), + busOptions.ManagementUsername, busOptions.ManagementPassword); + queues = await client.GetQueuesAsync(busOptions.VirtualHost); + memoryCache.Set("bitween-all-queues", queues, TimeSpan.FromSeconds(busOptions.MonitoringCacheSeconds)); + } + catch + { + // Management API unreachable or misconfigured - degrade to "no data" instead of 500ing. + queues = Array.Empty(); + } + } var orphans = queues .Where(q => q.Name.StartsWith($"{prefix}.", StringComparison.OrdinalIgnoreCase)) diff --git a/SW.Bitween.Api/Resources/WorkGroups/Search.cs b/SW.Bitween.Api/Resources/WorkGroups/Search.cs index d5c85e7d..43a5a5f1 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Search.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Search.cs @@ -43,6 +43,10 @@ public async Task Handle(SearchWorkGroupModel request) } catch (Exception ex) when (ex is TaskCanceledException or TimeoutException or System.Net.Http.HttpRequestException) { + // The RabbitMQ management API call itself already degrades to empty data on failure + // (ConsumerReader.GetConsumerCounts) - this only guards the network-facing edge of + // that call. Anything else (e.g. ConsumerDiscovery.Load() throwing) is a real bug and + // should surface as a 500, not silently render as "no metrics". logger.LogWarning(ex, "Unable to load RabbitMQ consumer metrics for work groups."); } diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 6ec83588..da390edb 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -32,8 +32,8 @@ them to ask the broker what exists; without this it would have to rebuild each of them from configuration and get them subtly wrong. Already in the app through SW.Bitween.Web — keep the version in step with it. --> - - + + diff --git a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj index 53c76358..7415050f 100644 --- a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj +++ b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj @@ -15,7 +15,7 @@ - + all @@ -27,10 +27,10 @@ - + - + diff --git a/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs b/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs index 05148909..102f24d6 100644 --- a/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs +++ b/SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs @@ -13,7 +13,7 @@ public class NativeS3Receiver : INativeInfolinkReceiver, IDisposable public Task Initialize() { - var options = new CloudFilesOptions + var options = new S3CloudFilesOptions { AccessKeyId = _options.AccessKeyId, SecretAccessKey = _options.SecretAccessKey, diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index 007c67b1..b12b1a8e 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -23,7 +23,7 @@ - + diff --git a/SW.Bitween.SampleConfigurableAdapter/SW.Bitween.SampleConfigurableAdapter.csproj b/SW.Bitween.SampleConfigurableAdapter/SW.Bitween.SampleConfigurableAdapter.csproj index 574f6962..ca017b8a 100644 --- a/SW.Bitween.SampleConfigurableAdapter/SW.Bitween.SampleConfigurableAdapter.csproj +++ b/SW.Bitween.SampleConfigurableAdapter/SW.Bitween.SampleConfigurableAdapter.csproj @@ -5,6 +5,6 @@ SW.Bitween.SampleConfigurableAdapter - + diff --git a/SW.Bitween.SampleHandler/SW.Bitween.SampleHandler.csproj b/SW.Bitween.SampleHandler/SW.Bitween.SampleHandler.csproj index 0a1f988f..cb83ae75 100644 --- a/SW.Bitween.SampleHandler/SW.Bitween.SampleHandler.csproj +++ b/SW.Bitween.SampleHandler/SW.Bitween.SampleHandler.csproj @@ -7,7 +7,7 @@ - + diff --git a/SW.Bitween.SampleMapper/SW.Bitween.SampleMapper.csproj b/SW.Bitween.SampleMapper/SW.Bitween.SampleMapper.csproj index b98f76c9..bc31167c 100644 --- a/SW.Bitween.SampleMapper/SW.Bitween.SampleMapper.csproj +++ b/SW.Bitween.SampleMapper/SW.Bitween.SampleMapper.csproj @@ -7,7 +7,7 @@ - + diff --git a/SW.Bitween.SampleValidator/SW.Bitween.SampleValidator.csproj b/SW.Bitween.SampleValidator/SW.Bitween.SampleValidator.csproj index b9dc71af..a6ba4657 100644 --- a/SW.Bitween.SampleValidator/SW.Bitween.SampleValidator.csproj +++ b/SW.Bitween.SampleValidator/SW.Bitween.SampleValidator.csproj @@ -8,7 +8,7 @@ - + diff --git a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj index 188a86de..eec835e0 100644 --- a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj +++ b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj @@ -19,7 +19,7 @@ - + diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts index 426e2305..ffd01584 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts @@ -45,7 +45,9 @@ export const dashboardMethods = { `/xchanges?filter=${encodeURIComponent(`StartedOn:6:${new Date(windowStart).toISOString()}`)}&size=1000&sort=StartedOn:1`, ), get>("/delayedretries?size=1"), - get("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/ops/alerts"), + // Depends on RabbitMQ management being configured on the backend — don't let it take the + // rest of the dashboard down when it isn't; the "Queue alerts" tile flags it instead. + get("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/ops/alerts").catch(() => null), subscriptionMethods.listSubscriptionRows(), ]); @@ -117,7 +119,7 @@ export const dashboardMethods = { yesterdayTotal: yesterdayRows.length, successRate7d, pendingRetries: delayedRes.totalCount, - queueAlerts: alertsRaw.filter((a) => a.severity !== "Info").length, + queueAlerts: alertsRaw === null ? null : alertsRaw.filter((a) => a.severity !== "Info").length, trafficByDay, busiest, latestFailures, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 3ee67435..3337a7fe 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -1002,7 +1002,8 @@ export interface DashboardData { /** Percentage 0–100 across the last 7 days of finished exchanges. */ successRate7d: number; pendingRetries: number; - queueAlerts: number; + /** Null when the RabbitMQ management API is unavailable — never counts as zero. */ + queueAlerts: number | null; /** Last 14 days, oldest first; today is the final entry. */ trafficByDay: { date: string; success: number; failed: number }[]; /** Top subscriptions by 7-day traffic, busiest first. */ diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx index 3a5e8517..ed84d77c 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx @@ -1,5 +1,5 @@ import type { ButtonHTMLAttributes, ReactNode } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, TriangleAlert } from "lucide-react"; type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; @@ -108,6 +108,16 @@ export function EmptyState({ ); } +/** Slim inline banner for a known, expected degraded state (e.g. an optional integration not configured). */ +export function InlineNotice({ children }: { children: ReactNode }) { + return ( +

+ + {children} +

+ ); +} + /** Inline error strip for failed form submissions. */ export function FormError({ children }: { children: ReactNode }) { if (!children) return null; diff --git a/SW.Bitween.Web/ClientApp/src/lib/appConfig.ts b/SW.Bitween.Web/ClientApp/src/lib/appConfig.ts new file mode 100644 index 00000000..ceb6d9f2 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/appConfig.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAppConfig } from "../api"; + +/** Same query key as `useBranding`, so this shares its cache rather than firing a second fetch. */ +export function useAppConfig() { + return useQuery({ queryKey: ["appConfig"], queryFn: getAppConfig }); +} + +/** Defaults to true while the config is still loading, so pages don't flash a "not configured" state. */ +export function useRabbitMqManagementConfigured(): boolean { + const { data } = useAppConfig(); + return data?.isRabbitMqManagementConfigured ?? true; +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index e15c125f..1f5f20ba 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -5,6 +5,7 @@ import { api } from "../../api"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { Panel } from "../../components/ui/Panel"; +import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; import { timeAgo } from "../../lib/dates"; import { StatusBadge, XchangeId } from "../exchanges/shared"; @@ -50,15 +51,29 @@ function StatTile({ * what needs a human — everything links into the page that can act on it. */ export function DashboardPage() { - const { data, isLoading } = useQuery({ + const rabbitMqConfigured = useRabbitMqManagementConfigured(); + const { data, isLoading, isError } = useQuery({ queryKey: ["dashboard"], queryFn: () => api.getDashboard(), refetchInterval: 60_000, placeholderData: keepPreviousData, }); + if (isError && !data) + return ( + + Something went wrong fetching dashboard data. It'll keep retrying in the background — try + refreshing the page. + + ); if (isLoading || !data) return ; + const queueAlerts = rabbitMqConfigured ? data.queueAlerts : null; + const queueAlertsSub = !rabbitMqConfigured + ? "needs RabbitMQ management configured" + : queueAlerts === null + ? "unavailable right now" + : "live consumer health"; const delta = data.today.total - data.yesterdayTotal; const maxDay = Math.max(1, ...data.trafficByDay.map((d) => d.success + d.failed)); const needsAttention = @@ -107,10 +122,10 @@ export function DashboardPage() { /> 0 ? "warn" : undefined} + accent={queueAlerts !== null && queueAlerts > 0 ? "warn" : undefined} /> 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 873d1e0a..c4bf38ea 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx @@ -1,13 +1,14 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { AlertTriangle, OctagonAlert } from "lucide-react"; +import { AlertTriangle, OctagonAlert, Unplug } from "lucide-react"; import { api, type ConsumerHealth, type QueueLane, type QueueSeverity } from "../../api"; import { PageHeader } from "../../components/layout/PageHeader"; 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 { useRabbitMqManagementConfigured } from "../../lib/appConfig"; const POLL_MS = 5_000; @@ -89,13 +90,38 @@ function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub? * stays on screen while the next one loads, so nothing flashes. */ export function QueueHealthPage() { - const { data, isLoading, dataUpdatedAt } = useQuery({ + const rabbitMqConfigured = useRabbitMqManagementConfigured(); + const { data, isLoading, isError, dataUpdatedAt } = useQuery({ queryKey: ["queue-health"], queryFn: () => api.getQueueHealth(), refetchInterval: POLL_MS, placeholderData: keepPreviousData, + enabled: rabbitMqConfigured, }); + if (!rabbitMqConfigured) + return ( +
+ + } title="RabbitMQ management isn't configured"> + This page reads live queue stats from the RabbitMQ management API, which the backend + isn't configured to reach. Ask an admin to set the management URL, username and + password, then reload. + +
+ ); + + if (isError && !data) + return ( + + Something went wrong reaching the RabbitMQ management API. It'll keep retrying in the + background — try refreshing the page. + + ); + if (isLoading || !data) return ; const { summary, consumers, retryBacklog, deadLetters, unattended, alerts } = data; 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 edd0f658..93001d37 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 { useRabbitMqManagementConfigured } from "../../lib/appConfig"; function LiveStat({ label, value, tone }: { label: string; value: ReactNode; tone?: "warn" | "danger" }) { return ( @@ -25,12 +26,25 @@ function LiveStat({ label, value, tone }: { label: string; value: ReactNode; ton * these as columns instead, off the same shared query. */ export function LiveQueueStats({ groupId }: { groupId: number }) { - const { data } = useQuery({ + const rabbitMqConfigured = useRabbitMqManagementConfigured(); + const { data, isError } = useQuery({ queryKey: ["queue-health"], queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, + enabled: rabbitMqConfigured, }); + + if (!rabbitMqConfigured) + return ( +

+ Live queue stats need RabbitMQ management configured on the backend. +

+ ); + + if (isError && !data) + return

Couldn't reach RabbitMQ management for live queue stats.

; + const consumer = data?.consumers.find((c) => c.workGroupId === groupId); if (!consumer) 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 2f4814de..54ab37db 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx @@ -6,10 +6,11 @@ import { api, type QueueHealthSnapshot, type WorkGroupRow } from "../../api"; import { Can, useSessionCan } from "../../auth/guards"; import { WorkGroupDialog } from "../../components/config/WorkGroupDialog"; import { PageHeader } from "../../components/layout/PageHeader"; -import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Badge, Button, EmptyState, InlineNotice, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; import { Table, type Column } from "../../components/ui/Table"; import { UsedByCell, queueHealthTitle, useSubscriptionsCache } from "../../components/config/shared"; +import { useRabbitMqManagementConfigured } from "../../lib/appConfig"; /** * The live RabbitMQ numbers, as columns rather than a per-row drill-down. @@ -56,6 +57,7 @@ export function WorkGroupsPage() { const q = searchParams.get("q") ?? ""; const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canMonitor = useSessionCan("monitoring.view"); + const rabbitMqConfigured = useRabbitMqManagementConfigured(); const groups = useQuery({ queryKey: ["work-groups-search", q, offset], @@ -68,7 +70,7 @@ export function WorkGroupsPage() { queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, - enabled: canMonitor, + enabled: canMonitor && rabbitMqConfigured, }); const setParam = (key: string, value: string | null, resetOffset = true) => @@ -126,6 +128,13 @@ export function WorkGroupsPage() { /> + {canMonitor && !rabbitMqConfigured && ( + + Live queue stats (Health, Nodes, Queued, …) need RabbitMQ management configured on the + backend — those columns will stay blank until then. + + )} + {groups.isPending ? ( ) : filtered.length === 0 ? ( @@ -157,7 +166,10 @@ export function WorkGroupsPage() { truncate: true, cell: (g) => s.workGroupId === g.id)} />, }, - ...(canMonitor ? liveColumns(live.data) : []), + // `enabled: false` on the `live` query only stops it refetching — it doesn't clear a + // result already cached from before RabbitMQ management was disabled. Pass undefined + // explicitly so the columns actually go blank rather than showing stale numbers. + ...(canMonitor ? liveColumns(rabbitMqConfigured ? live.data : undefined) : []), { header: "", align: "right", diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index dede7597..9efbfd9d 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -36,19 +36,19 @@ runtime; build; native; contentfiles; analyzers; buildtransitive
- + - - - - - + + + + + - +