using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Options;
namespace Mbproxy.Admin;
///
/// Pure orchestration: reads live state from injected singletons and builds a
/// for GET / and GET /status.json.
///
/// No I/O; no side effects. Constructed once via DI; is the
/// only operation and may be called on any thread at any time.
///
internal sealed class StatusSnapshotBuilder
{
private readonly IOptionsMonitor _options;
private readonly ServiceCounters _serviceCounters;
private readonly AssemblyVersionAccessor _version;
private readonly ProxyWorker _proxyWorker;
private readonly TagCaptureRegistry _captureRegistry;
public StatusSnapshotBuilder(
IOptionsMonitor options,
ServiceCounters serviceCounters,
AssemblyVersionAccessor version,
ProxyWorker proxyWorker,
TagCaptureRegistry captureRegistry)
{
_options = options;
_serviceCounters = serviceCounters;
_version = version;
_proxyWorker = proxyWorker;
_captureRegistry = captureRegistry;
}
///
/// Builds the connection-detail debug snapshot for one PLC: the last value observed
/// for every configured BCD tag. Returns an empty, disarmed snapshot when
/// is unknown (e.g. a detail page open for a PLC removed
/// by hot-reload).
///
/// lets the caller supply the armed flag rather
/// than have this method independently re-read capture.IsArmed. The broadcaster
/// passes true because it only builds a debug snapshot for PLCs it just
/// reconciled armed in the same push cycle — so the pushed payload's CaptureArmed
/// 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 capture.IsArmed is used.
///
public PlcDebugSnapshot BuildDebug(string plcName, bool? armedOverride = null)
{
if (!_captureRegistry.TryGet(plcName, out var capture))
return new PlcDebugSnapshot(CaptureArmed: false, Tags: Array.Empty());
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);
}
///
/// Builds a point-in-time .
/// Each counter is read atomically; no locks are held across the build.
///
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(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();
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);
}
}