package api import ( "context" "fmt" "sync" "time" "tera-clone/services/tera-api/internal/domain" "tera-clone/services/tera-api/internal/portservice" "tera-clone/services/tera-api/internal/serial" "tera-clone/services/tera-api/internal/sessionhealth" ) const ( runtimeStatusTimeout = 2 * time.Second runtimeStatusCacheTTL = 300 * time.Millisecond ) type serialRuntimeSnapshot struct { states map[string]serial.State links map[string]serial.LinkState crProbes map[string]serial.CRProbeState session sessionhealth.Status stateErr error linkErr error crProbeErr error sessionErr error } type compactPortRuntimeStatus struct { Channel string `json:"channel"` Alias string `json:"alias,omitempty"` Status string `json:"status"` Mode string `json:"mode"` ConnectedUsers int `json:"connected_users"` Runtime compactPortRuntimeDetails `json:"runtime"` } type compactPortRuntimeDetails struct { SessionActive bool `json:"session_active"` RXBytes uint64 `json:"rx_bytes"` TXBytes uint64 `json:"tx_bytes"` RXAgeMillis int64 `json:"rx_age_ms,omitempty"` CRProbeState string `json:"cr_probe_state,omitempty"` CRProbeMisses int `json:"cr_probe_consecutive_misses,omitempty"` CRProbeUp bool `json:"cr_probe_up"` CRProbeEverUp bool `json:"cr_probe_ever_up"` CRProbeMessage string `json:"cr_probe_message,omitempty"` } func compactPortRuntimeStatuses(ports []domain.Port) []compactPortRuntimeStatus { result := make([]compactPortRuntimeStatus, 0, len(ports)) for _, port := range ports { status := compactPortRuntimeStatus{ Channel: port.Channel, Alias: port.Alias, Status: port.Status, Mode: port.Mode, ConnectedUsers: port.ConnectedUsers, } if runtime := port.Runtime; runtime != nil { status.Runtime = compactPortRuntimeDetails{ SessionActive: runtime.SessionActive, RXBytes: runtime.RXBytes, TXBytes: runtime.TXBytes, RXAgeMillis: runtime.RXAgeMillis, CRProbeState: runtime.CRProbeState, CRProbeMisses: runtime.CRProbeMisses, CRProbeUp: runtime.CRProbeUp, CRProbeEverUp: runtime.CRProbeEverUp, CRProbeMessage: runtime.CRProbeMessage, } } result = append(result, status) } return result } // serialRuntimeCache collapses concurrent port/status requests into one // hardware snapshot. In particular, a browser reload must not start another // tera-seriald/sessiond sweep while the previous request is still finishing. // The snapshot is hardware-wide and contains no actor-specific data. type serialRuntimeCache struct { mu sync.Mutex snapshot serialRuntimeSnapshot cachedAt time.Time ready bool inFlight chan struct{} } func (r *Router) readSerialRuntime(parent context.Context) serialRuntimeSnapshot { for { now := time.Now() r.serialRuntime.mu.Lock() if r.serialRuntime.ready && now.Sub(r.serialRuntime.cachedAt) < runtimeStatusCacheTTL { result := r.serialRuntime.snapshot r.serialRuntime.mu.Unlock() return result } inFlight := r.serialRuntime.inFlight if inFlight == nil { inFlight = make(chan struct{}) r.serialRuntime.inFlight = inFlight go r.refreshSerialRuntime(inFlight) } r.serialRuntime.mu.Unlock() select { case <-inFlight: // Re-check the cache under its lock. A completed refresh always // publishes its immutable snapshot before closing this channel. continue case <-parent.Done(): return serialRuntimeErrorSnapshot(parent.Err()) } } } func (r *Router) refreshSerialRuntime(completed chan struct{}) { // Keep the single shared hardware read alive across browser reloads. Each // caller still observes its own cancellation while waiting above, and the // shared read remains bounded by runtimeStatusTimeout. ctx, cancel := context.WithTimeout(context.Background(), runtimeStatusTimeout) defer cancel() result := r.fetchSerialRuntime(ctx) r.serialRuntime.mu.Lock() r.serialRuntime.snapshot = result r.serialRuntime.cachedAt = time.Now() r.serialRuntime.ready = true r.serialRuntime.inFlight = nil close(completed) r.serialRuntime.mu.Unlock() } func (r *Router) fetchSerialRuntime(ctx context.Context) serialRuntimeSnapshot { result := serialRuntimeSnapshot{ states: make(map[string]serial.State, serial.ChannelCount), links: make(map[string]serial.LinkState, serial.ChannelCount), crProbes: make(map[string]serial.CRProbeState, serial.ChannelCount), } var wait sync.WaitGroup wait.Add(2) go func() { defer wait.Done() states, err := r.serialPorts.States(ctx) result.stateErr = err for _, state := range states { result.states[state.Channel] = state } if links, ok := r.serialPorts.(serial.LinkStateController); ok { values, linkErr := links.LinkStates(ctx) result.linkErr = linkErr for _, state := range values { current, exists := result.links[state.Channel] everUp := state.EverUp || current.EverUp if !exists || (current.State != "up" && state.State == "up") || (!current.Available && state.Available) { state.EverUp = everUp result.links[state.Channel] = state } else { current.EverUp = everUp result.links[state.Channel] = current } } } else { result.linkErr = fmt.Errorf("serial link-state adapter is unavailable") } if probes, ok := r.serialPorts.(serial.CRProbeStateController); ok { values, probeErr := probes.CRProbeStates(ctx) result.crProbeErr = probeErr for _, state := range values { result.crProbes[state.Channel] = state } } else { result.crProbeErr = fmt.Errorf("serial CR response probe adapter is unavailable") } }() go func() { defer wait.Done() result.session, result.sessionErr = r.sessions.Status(ctx) }() wait.Wait() return result } func serialRuntimeErrorSnapshot(err error) serialRuntimeSnapshot { return serialRuntimeSnapshot{ states: make(map[string]serial.State), links: make(map[string]serial.LinkState), crProbes: make(map[string]serial.CRProbeState), stateErr: err, linkErr: err, crProbeErr: err, sessionErr: err, } } func (s serialRuntimeSnapshot) availablePorts() int { count := 0 for _, state := range s.states { if state.Available { count++ } } return count } func (s serialRuntimeSnapshot) appliedProfiles() int { count := 0 for _, state := range s.states { if state.Available && state.ProfileApplied { count++ } } return count } func (r *Router) overlayPortRuntime(ports []domain.Port, runtime serialRuntimeSnapshot) []domain.Port { channels := make(map[string]struct { broker serialChannelRuntime stream serial.StreamChannelSnapshot }, serial.ChannelCount) for _, channel := range runtime.session.Broker.Channels { value := channels[channel.Channel] value.broker = serialChannelRuntime{ attachments: channel.AttachmentCount, maxAttachments: channel.MaxAttachments, writer: channel.WriterSessionID, queuedBytes: channel.QueuedBytes, } channels[channel.Channel] = value } for _, stream := range runtime.session.Serial.Channels { value := channels[stream.Channel] value.stream = stream channels[stream.Channel] = value } operationRuntime := make(map[string][]domain.PortOperationRuntime) if provider, ok := r.portServices.(portservice.SnapshotProvider); ok { for _, service := range provider.Snapshot().Services { value := domain.PortOperationRuntime{ Operation: service.Operation, Mode: service.Mode, Host: service.Host, Port: service.Port, State: service.State, ActiveClients: service.ActiveClients, Sources: append([]string(nil), service.Sources...), Targets: append([]string(nil), service.Targets...), } if service.Mode == "redirect" { for _, source := range service.Sources { operationRuntime[source] = append(operationRuntime[source], value) } } else { operationRuntime[service.Channel] = append(operationRuntime[service.Channel], value) } } } for index := range ports { port := &ports[index] state, stateFound := runtime.states[port.Channel] link, linkFound := runtime.links[port.Channel] probe, probeFound := runtime.crProbes[port.Channel] channel := channels[port.Channel] operationClients := 0 for _, operation := range operationRuntime[port.Channel] { if operation.ActiveClients > 0 { operationClients += operation.ActiveClients } } port.ConnectedUsers = channel.broker.attachments + operationClients if channel.broker.maxAttachments > 0 { port.MaxViewers = channel.broker.maxAttachments } port.Writer = channel.broker.writer value := &domain.PortRuntime{ HardwareAvailable: stateFound && state.Available, HardwareMessage: state.Message, HardwareProfile: state.HardwareProfile, MappingVersion: state.MappingVersion, Device: state.Device, ProfileApplied: state.ProfileApplied, LinkState: "unknown", LinkSignal: "unknown", SessionState: "unavailable", SessionActive: channel.broker.attachments > 0 || channel.stream.Active || operationClients > 0, RXBytes: channel.stream.RXBytes, TXBytes: channel.stream.TXBytes, LastRXAt: channel.stream.LastRXAt, QueuedBytes: channel.broker.queuedBytes, Operations: append([]domain.PortOperationRuntime(nil), operationRuntime[port.Channel]...), } if channel.stream.LastRXAt != nil { age := time.Since(*channel.stream.LastRXAt).Milliseconds() if age < 0 { age = 0 } value.RXAgeMillis = age } if state.LineSettings != nil { value.AppliedLine = &domain.PortLineSettings{ BaudRate: state.LineSettings.BaudRate, DataBits: state.LineSettings.DataBits, Parity: state.LineSettings.Parity, StopBits: state.LineSettings.StopBits, StopBitsMode: state.LineSettings.StopBitsMode, FlowControl: state.LineSettings.FlowControl, RTSControl: state.LineSettings.RTSControl, DTRControl: state.LineSettings.DTRControl, IgnoreJammed: state.LineSettings.IgnoreJammed, } value.ProfileApplied = state.ProfileApplied && appliedLineMatchesPort(*state.LineSettings, *port) if state.ProfileApplied && !value.ProfileApplied && value.HardwareMessage == "" { value.HardwareMessage = "applied TTY line settings do not match the running configuration" } } if linkFound { value.LinkState = link.State value.LinkSignal = link.Signal value.LinkAvailable = link.Available value.LinkEverUp = link.EverUp value.LinkMessage = link.Message } else if runtime.linkErr != nil { value.LinkMessage = runtime.linkErr.Error() } if probeFound { value.CRProbeEnabled = probe.Enabled value.CRProbeState = probe.State value.CRProbeMethod = probe.Method value.CRProbeLastAt = probe.LastProbeAt value.CRProbeResponseAt = probe.LastResponseAt value.CRProbeConfirmedAt = probe.LastConfirmedAt value.CRProbeBytes = probe.ResponseBytes value.CRProbeResponses = probe.ConsecutiveResponses value.CRProbeMisses = probe.ConsecutiveMisses value.CRProbeUp = probe.Up value.CRProbeEverUp = probe.EverUp value.CRProbeMessage = probe.Message } else if runtime.crProbeErr != nil { value.CRProbeState = serial.CRProbeStateError value.CRProbeMessage = runtime.crProbeErr.Error() } if runtime.stateErr != nil && value.HardwareMessage == "" { value.HardwareMessage = runtime.stateErr.Error() } if runtime.sessionErr == nil { value.SessionState = runtime.session.Status } port.Status = runtimePortStatus(value) port.Runtime = value } return ports } func appliedLineMatchesPort(line serial.LineSettings, port domain.Port) bool { lineStopBits := line.StopBitsMode if lineStopBits == "" { lineStopBits = fmt.Sprintf("%d", line.StopBits) } portStopBits := port.StopBitsMode if portStopBits == "" { portStopBits = fmt.Sprintf("%d", port.StopBits) } return line.BaudRate == port.BaudRate && line.DataBits == port.DataBits && line.Parity == port.Parity && lineStopBits == portStopBits && line.FlowControl == port.FlowControl } type serialChannelRuntime struct { attachments int maxAttachments int writer string queuedBytes int } func runtimePortStatus(runtime *domain.PortRuntime) string { if runtime == nil { return "unknown" } if runtime.SessionActive && (runtime.CRProbeEverUp || runtime.LinkEverUp) { return "session" } if runtime.CRProbeUp { return "up" } if runtime.CRProbeEverUp && runtime.CRProbeState == serial.CRProbeStateNoResponse { return "down" } if runtime.LinkAvailable { if runtime.LinkState == "up" { return "up" } if runtime.LinkEverUp && runtime.LinkState == "down" { return "down" } } return "unknown" } func (r *Router) withRuntimeHealth(ctx context.Context, health []domain.ServiceHealth) []domain.ServiceHealth { runtime := r.readSerialRuntime(ctx) checkedAt := time.Now().UTC() hardwareContext, cancel := context.WithTimeout(ctx, runtimeStatusTimeout) greenLEDs, hardwareErr := r.greenLEDs.States(hardwareContext) cancel() for index := range health { health[index].LastCheckedAt = checkedAt switch health[index].Service { case "tera-seriald": available, applied := runtime.availablePorts(), runtime.appliedProfiles() if runtime.stateErr != nil { health[index].Status = "degraded" health[index].Detail = "serial hardware inventory unavailable: " + runtime.stateErr.Error() } else if available != serial.ChannelCount || applied != serial.ChannelCount { health[index].Status = "degraded" health[index].Detail = fmt.Sprintf("serial runtime incomplete; available=%d/%d; profiles_applied=%d/%d", available, serial.ChannelCount, applied, serial.ChannelCount) } else if runtime.sessionErr != nil || runtime.session.SerialError != "" { health[index].Status = "degraded" health[index].Detail = "serial stream status unavailable through tera-sessiond" } else { health[index].Status = "ok" health[index].Detail = fmt.Sprintf("serial runtime healthy; available=%d/%d; profiles_applied=%d/%d; active_leases=%d", available, serial.ChannelCount, applied, serial.ChannelCount, runtime.session.Serial.ActiveLeases) } case "tera-sessiond": if runtime.sessionErr != nil { health[index].Status = "degraded" health[index].Detail = "session broker unavailable: " + runtime.sessionErr.Error() } else { health[index].Status = runtime.session.Status health[index].Detail = fmt.Sprintf("session broker status=%s; attachments=%d; active_channels=%d; serial_leases=%d", runtime.session.Status, runtime.session.Broker.TotalAttachments, runtime.session.Broker.ActiveChannels, runtime.session.Serial.ActiveLeases) } case "tera-hwd": available := 0 for _, state := range greenLEDs { if state.Available { available++ } } if hardwareErr != nil || available != serial.ChannelCount { health[index].Status = "degraded" if hardwareErr != nil { health[index].Detail = "hardware control unavailable: " + hardwareErr.Error() } else { health[index].Detail = fmt.Sprintf("green LED hardware incomplete; available=%d/%d", available, serial.ChannelCount) } } else { health[index].Status = "ok" health[index].Detail = fmt.Sprintf("green LED hardware available=%d/%d", available, serial.ChannelCount) } } } return health }