Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions SW.Bitween.Api/Resources/Ops/UnattendedQueues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,13 +60,23 @@ public async Task<object> 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<Queue> 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<Queue>();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

var orphans = queues
.Where(q => q.Name.StartsWith($"{prefix}.", StringComparison.OrdinalIgnoreCase))
Expand Down
4 changes: 4 additions & 0 deletions SW.Bitween.Api/Resources/WorkGroups/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public async Task<object> 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.");
}

Expand Down
4 changes: 2 additions & 2 deletions SW.Bitween.Api/SW.Bitween.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<PackageReference Include="SimplyWorks.Bus" Version="8.1.11" />
<PackageReference Include="SimplyWorks.Bus.RabbitMqExtensions" Version="8.1.11" />
<PackageReference Include="SimplyWorks.Bus" Version="8.1.18" />
<PackageReference Include="SimplyWorks.Bus.RabbitMqExtensions" Version="8.1.18" />
<PackageReference Include="SimplyWorks.EfCoreExtensions" Version="8.1.2" />
<PackageReference Include="SimplyWorks.Scheduler.Sdk" Version="8.1.7" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="SimplyWorks.CloudFiles.LocalTests.Extensions" Version="8.1.6" />
<PackageReference Include="SimplyWorks.CloudFiles.LocalTests.Extensions" Version="8.1.12" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
Expand All @@ -27,10 +27,10 @@
<PackageReference Include="Testcontainers.RabbitMq" Version="3.10.0" />

<!-- Serverless runtime -->
<PackageReference Include="SimplyWorks.Serverless" Version="8.1.5" />
<PackageReference Include="SimplyWorks.Serverless" Version="8.1.11" />

<!-- Bus (for IPublish + AddBus registration) -->
<PackageReference Include="SimplyWorks.Bus" Version="8.1.11" />
<PackageReference Include="SimplyWorks.Bus" Version="8.1.18" />

<!-- EF Core naming conventions (used by PgSql context) -->
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
Expand Down
2 changes: 1 addition & 1 deletion SW.Bitween.NativeAdapters/S3Receiver/NativeS3Receiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<PackageReference Include="Rebex.Pop3" Version="8.0.9673" />
<PackageReference Include="Rebex.Sftp" Version="8.0.9673" />
<PackageReference Include="Scriban" Version="7.0.6" />
<PackageReference Include="SimplyWorks.CloudFiles.S3" Version="8.1.1" />
<PackageReference Include="SimplyWorks.CloudFiles.S3" Version="8.1.12" />
<PackageReference Include="SimplyWorks.PrimitiveTypes" Version="8.1.5" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<RootNamespace>SW.Bitween.SampleConfigurableAdapter</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.1" />
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.11" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion SW.Bitween.SampleHandler/SW.Bitween.SampleHandler.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.1" />
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.11" />
</ItemGroup>


Expand Down
2 changes: 1 addition & 1 deletion SW.Bitween.SampleMapper/SW.Bitween.SampleMapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.1" />
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.11" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.1" />
<PackageReference Include="SimplyWorks.Serverless.Sdk" Version="8.1.11" />
</ItemGroup>


Expand Down
2 changes: 1 addition & 1 deletion SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<PackageReference Include="MSTest.TestAdapter" Version="3.7.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.7.1" />
<PackageReference Include="SimplyWorks.CqApi" Version="8.2.6" />
<PackageReference Include="SimplyWorks.Bus" Version="8.1.11" />
<PackageReference Include="SimplyWorks.Bus" Version="8.1.18" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 4 additions & 2 deletions SW.Bitween.Web/ClientApp/src/api/http/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export const dashboardMethods = {
`/xchanges?filter=${encodeURIComponent(`StartedOn:6:${new Date(windowStart).toISOString()}`)}&size=1000&sort=StartedOn:1`,
),
get<SearchyResponse<unknown>>("/delayedretries?size=1"),
get<RawAlert[]>("/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<RawAlert[]>("/ops/alerts").catch(() => null),
subscriptionMethods.listSubscriptionRows(),
]);

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion SW.Bitween.Web/ClientApp/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
12 changes: 11 additions & 1 deletion SW.Bitween.Web/ClientApp/src/components/ui/basics.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 (
<p className="mb-3 flex items-start gap-1.5 rounded-lg border border-warn-100 bg-warn-100/40 px-3 py-2 text-[13px] text-warn-800">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span>{children}</span>
</p>
);
}

/** Inline error strip for failed form submissions. */
export function FormError({ children }: { children: ReactNode }) {
if (!children) return null;
Expand Down
13 changes: 13 additions & 0 deletions SW.Bitween.Web/ClientApp/src/lib/appConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 19 additions & 4 deletions SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<EmptyState title="Couldn't load the dashboard">
Something went wrong fetching dashboard data. It'll keep retrying in the background — try
refreshing the page.
</EmptyState>
);
if (isLoading || !data) return <LoadingBlock label="Putting the picture together…" />;

const queueAlerts = rabbitMqConfigured ? data.queueAlerts : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 =
Expand Down Expand Up @@ -107,10 +122,10 @@ export function DashboardPage() {
/>
<StatTile
label="Queue alerts"
value={data.queueAlerts}
sub="live consumer health"
value={queueAlerts ?? "—"}
sub={queueAlertsSub}
to="/queue-health"
accent={data.queueAlerts > 0 ? "warn" : undefined}
accent={queueAlerts !== null && queueAlerts > 0 ? "warn" : undefined}
/>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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 (
<div>
<PageHeader
title="Queue health"
description="Live throughput and backlog for every queue this instance consumes."
/>
<EmptyState icon={<Unplug />} 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.
</EmptyState>
</div>
);

if (isError && !data)
return (
<EmptyState title="Couldn't load queue statistics">
Something went wrong reaching the RabbitMQ management API. It'll keep retrying in the
background — try refreshing the page.
</EmptyState>
);

if (isLoading || !data) return <LoadingBlock label="Reading queue statistics…" />;

const { summary, consumers, retryBacklog, deadLetters, unattended, alerts } = data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!rabbitMqConfigured)
return (
<p className="text-sm text-ink-500">
Live queue stats need RabbitMQ management configured on the backend.
</p>
);

if (isError && !data)
return <p className="text-sm text-ink-500">Couldn't reach RabbitMQ management for live queue stats.</p>;

const consumer = data?.consumers.find((c) => c.workGroupId === groupId);

if (!consumer)
Expand Down
Loading
Loading