You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

441 lines
15 KiB

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,
SessionIPs: append([]string(nil), service.SessionIPs...), Interface: service.Interface,
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 runtime.session.Broker.SharedPortSessions {
port.ConnectedUsers = channel.broker.attachments
}
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.LinkAvailable {
if runtime.LinkState == "up" {
return "up"
}
if runtime.LinkEverUp && runtime.LinkState == "down" {
return "down"
}
}
if runtime.LastRXAt != nil && runtime.RXAgeMillis >= 0 && runtime.RXAgeMillis <= 10000 {
return "up"
}
return serial.CRConnectionStatus(serial.CRProbeState{
Enabled: runtime.CRProbeEnabled, Up: runtime.CRProbeUp,
EverUp: runtime.CRProbeEverUp, LastResponseAt: runtime.CRProbeResponseAt,
}, time.Now())
}
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
}