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); } }