b222362ce0
Fixes every finding from the codereviews/2026-05-16 multi-agent review (2 Critical, 20 Major, 38 Minor) and adds that review to the repo. Highlights: dashboard XSS escape; response cache invalidated on the write request (not just the response); ReloadValidator now runs at startup so port collisions / duplicate names / malformed Resilience profiles fail fast; AdminPort 0 genuinely disables the admin endpoint; PlcListener accept-loop faults propagate to the supervisor's faulted path; reconciler Restart builds before removing; Resilience pipelines are restart-only from a frozen snapshot; multiplexer connect-race leak, watchdog party-list snapshot, backend-response and FC16 framing validation; frontend reconnect retry and util.js load guard; plus the log-event/doc drift sweep and test-port hygiene. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
11 KiB
C#
234 lines
11 KiB
C#
using Mbproxy.Options;
|
|
using Mbproxy.Proxy;
|
|
using Mbproxy.Proxy.Multiplexing;
|
|
using Mbproxy.Proxy.Supervision;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Mbproxy.Admin;
|
|
|
|
/// <summary>
|
|
/// Pure orchestration: reads live state from injected singletons and builds a
|
|
/// <see cref="StatusResponse"/> for <c>GET /</c> and <c>GET /status.json</c>.
|
|
///
|
|
/// <para>No I/O; no side effects. Constructed once via DI; <see cref="Build"/> is the
|
|
/// only operation and may be called on any thread at any time.</para>
|
|
/// </summary>
|
|
internal sealed class StatusSnapshotBuilder
|
|
{
|
|
private readonly IOptionsMonitor<MbproxyOptions> _options;
|
|
private readonly ServiceCounters _serviceCounters;
|
|
private readonly AssemblyVersionAccessor _version;
|
|
private readonly ProxyWorker _proxyWorker;
|
|
private readonly TagCaptureRegistry _captureRegistry;
|
|
|
|
public StatusSnapshotBuilder(
|
|
IOptionsMonitor<MbproxyOptions> options,
|
|
ServiceCounters serviceCounters,
|
|
AssemblyVersionAccessor version,
|
|
ProxyWorker proxyWorker,
|
|
TagCaptureRegistry captureRegistry)
|
|
{
|
|
_options = options;
|
|
_serviceCounters = serviceCounters;
|
|
_version = version;
|
|
_proxyWorker = proxyWorker;
|
|
_captureRegistry = captureRegistry;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the connection-detail debug snapshot for one PLC: the last value observed
|
|
/// for every configured BCD tag. Returns an empty, disarmed snapshot when
|
|
/// <paramref name="plcName"/> is unknown (e.g. a detail page open for a PLC removed
|
|
/// by hot-reload).
|
|
///
|
|
/// <para><paramref name="armedOverride"/> lets the caller supply the armed flag rather
|
|
/// than have this method independently re-read <c>capture.IsArmed</c>. The broadcaster
|
|
/// passes <c>true</c> because it only builds a debug snapshot for PLCs it just
|
|
/// reconciled armed in the same push cycle — so the pushed payload's <c>CaptureArmed</c>
|
|
/// flag is consistent with that decision by construction, instead of racing a
|
|
/// disarm between the reconcile and this read (review AdminSignalR M1). When omitted,
|
|
/// the live <c>capture.IsArmed</c> is used.</para>
|
|
/// </summary>
|
|
public PlcDebugSnapshot BuildDebug(string plcName, bool? armedOverride = null)
|
|
{
|
|
if (!_captureRegistry.TryGet(plcName, out var capture))
|
|
return new PlcDebugSnapshot(CaptureArmed: false, Tags: Array.Empty<TagValueDto>());
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var tags = capture.Snapshot()
|
|
.Select(o => ToTagDto(o, now))
|
|
.ToList();
|
|
|
|
return new PlcDebugSnapshot(armedOverride ?? capture.IsArmed, tags);
|
|
}
|
|
|
|
private static TagValueDto ToTagDto(TagValueObservation o, DateTimeOffset now)
|
|
{
|
|
bool hasValue = o.UpdatedAtUtc.HasValue;
|
|
|
|
string rawHex = !hasValue
|
|
? "—"
|
|
: o.Width == 32
|
|
? $"0x{o.RawHigh:X4}{o.RawLow:X4}"
|
|
: $"0x{o.RawLow:X4}";
|
|
|
|
return new TagValueDto(
|
|
Address: o.Address,
|
|
Width: o.Width,
|
|
Name: o.Name,
|
|
HasValue: hasValue,
|
|
Direction: o.Direction == CaptureDirection.Write ? "write" : "read",
|
|
RawHex: rawHex,
|
|
DecodedValue: o.DecodedValue,
|
|
UpdatedAtUtc: o.UpdatedAtUtc?.ToString("o"),
|
|
AgeSeconds: o.UpdatedAtUtc is { } at ? (now - at).TotalSeconds : null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a point-in-time <see cref="StatusResponse"/>.
|
|
/// Each counter is read atomically; no locks are held across the build.
|
|
/// </summary>
|
|
public StatusResponse Build()
|
|
{
|
|
var opts = _options.CurrentValue;
|
|
var now = DateTimeOffset.UtcNow;
|
|
var started = _serviceCounters.StartedAtUtc;
|
|
var uptime = (long)(now - started).TotalSeconds;
|
|
var supervisors = _proxyWorker.Supervisors;
|
|
|
|
// ── Build per-PLC status rows ─────────────────────────────────────────
|
|
var plcStatuses = new List<PlcStatus>(opts.Plcs.Count);
|
|
int boundCount = 0;
|
|
|
|
foreach (var plc in opts.Plcs)
|
|
{
|
|
supervisors.TryGetValue(plc.Name, out var supervisor);
|
|
|
|
// Supervisor state
|
|
SupervisorSnapshot? snap = supervisor?.Snapshot();
|
|
string stateStr = snap?.State switch
|
|
{
|
|
SupervisorState.Bound => "bound",
|
|
SupervisorState.Recovering => "recovering",
|
|
_ => "stopped",
|
|
};
|
|
if (snap?.State == SupervisorState.Bound) boundCount++;
|
|
|
|
// Per-client snapshots
|
|
var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
|
|
var clientSnapshots = activeUpstreams
|
|
.Select(p => new ClientSnapshot(
|
|
Remote: p.RemoteEp?.ToString() ?? "?",
|
|
ConnectedAtUtc: p.ConnectedAtUtc,
|
|
PdusForwarded: p.PdusForwardedCount))
|
|
.ToList();
|
|
|
|
// Counter snapshot
|
|
var counters = supervisor?.CurrentCounters.Snapshot()
|
|
?? new CounterSnapshot(
|
|
PdusForwarded: 0,
|
|
Fc03: 0,
|
|
Fc04: 0,
|
|
Fc06: 0,
|
|
Fc16: 0,
|
|
FcOther: 0,
|
|
RewrittenSlots: 0,
|
|
PartialBcdWarnings: 0,
|
|
InvalidBcdWarnings: 0,
|
|
BackendException01: 0,
|
|
BackendException02: 0,
|
|
BackendException03: 0,
|
|
BackendException04: 0,
|
|
BackendExceptionOther: 0,
|
|
BytesUpstreamIn: 0,
|
|
BytesUpstreamOut: 0,
|
|
RecoveryAttempts: 0,
|
|
LastBindError: null,
|
|
LastRoundTripMs: 0.0,
|
|
ConnectsSuccess: 0,
|
|
ConnectsFailed: 0,
|
|
InFlightCount: 0,
|
|
MaxInFlight: 0,
|
|
TxIdWraps: 0,
|
|
BackendDisconnectCascades: 0,
|
|
BackendQueueDepth: 0,
|
|
CoalescedHitCount: 0,
|
|
CoalescedMissCount: 0,
|
|
CoalescedResponseToDeadUpstream: 0,
|
|
CacheHitCount: 0,
|
|
CacheMissCount: 0,
|
|
CacheInvalidations: 0,
|
|
CacheEntryCount: 0,
|
|
CacheBytes: 0,
|
|
ResponseDropForFullUpstream: 0,
|
|
BackendHeartbeatsSent: 0,
|
|
BackendHeartbeatsFailed: 0,
|
|
BackendIdleDisconnects: 0);
|
|
|
|
long connectsSuccess = counters.ConnectsSuccess;
|
|
long connectsFailed = counters.ConnectsFailed;
|
|
|
|
plcStatuses.Add(new PlcStatus(
|
|
Name: plc.Name,
|
|
Host: plc.Host,
|
|
ListenPort: plc.ListenPort,
|
|
Listener: new PlcListenerStatus(
|
|
State: stateStr,
|
|
LastBindError: snap?.LastBindError,
|
|
RecoveryAttempts: snap?.RecoveryAttempts ?? 0),
|
|
Clients: new PlcClientsStatus(
|
|
Connected: clientSnapshots.Count,
|
|
RemoteEndpoints: clientSnapshots),
|
|
Pdus: new PlcPdusStatus(
|
|
Forwarded: counters.PdusForwarded,
|
|
ByFc: new FcCounts(counters.Fc03, counters.Fc04, counters.Fc06, counters.Fc16, counters.FcOther),
|
|
RewrittenSlots: counters.RewrittenSlots,
|
|
PartialBcdWarnings: counters.PartialBcdWarnings,
|
|
InvalidBcdWarnings: counters.InvalidBcdWarnings),
|
|
Backend: new PlcBackendStatus(
|
|
ConnectsSuccess: connectsSuccess,
|
|
ConnectsFailed: connectsFailed,
|
|
ExceptionsByCode: new ExceptionCounts(
|
|
counters.BackendException01,
|
|
counters.BackendException02,
|
|
counters.BackendException03,
|
|
counters.BackendException04,
|
|
counters.BackendExceptionOther),
|
|
LastRoundTripMs: counters.LastRoundTripMs,
|
|
InFlight: counters.InFlightCount,
|
|
MaxInFlight: counters.MaxInFlight,
|
|
TxIdWraps: counters.TxIdWraps,
|
|
DisconnectCascades: counters.BackendDisconnectCascades,
|
|
QueueDepth: counters.BackendQueueDepth,
|
|
CoalescedHitCount: counters.CoalescedHitCount,
|
|
CoalescedMissCount: counters.CoalescedMissCount,
|
|
CoalescedResponseToDeadUpstream: counters.CoalescedResponseToDeadUpstream,
|
|
CacheHitCount: counters.CacheHitCount,
|
|
CacheMissCount: counters.CacheMissCount,
|
|
CacheInvalidations: counters.CacheInvalidations,
|
|
CacheEntryCount: counters.CacheEntryCount,
|
|
CacheBytes: counters.CacheBytes,
|
|
BackendHeartbeatsSent: counters.BackendHeartbeatsSent,
|
|
BackendHeartbeatsFailed: counters.BackendHeartbeatsFailed,
|
|
BackendIdleDisconnects: counters.BackendIdleDisconnects),
|
|
Bytes: new PlcBytesStatus(
|
|
UpstreamIn: counters.BytesUpstreamIn,
|
|
UpstreamOut: counters.BytesUpstreamOut)));
|
|
}
|
|
|
|
// ── Service-wide fields ───────────────────────────────────────────────
|
|
var service = new ServiceFields(
|
|
UptimeSeconds: uptime,
|
|
Version: _version.Version,
|
|
ConfigLastReloadUtc: _serviceCounters.LastReloadUtc,
|
|
ConfigReloadCount: _serviceCounters.ReloadAppliedCount,
|
|
ConfigReloadRejectedCount: _serviceCounters.ReloadRejectedCount);
|
|
|
|
var listeners = new ListenersAggregate(
|
|
Bound: boundCount,
|
|
Configured: opts.Plcs.Count);
|
|
|
|
return new StatusResponse(service, listeners, plcStatuses);
|
|
}
|
|
}
|