- Move fleet polling logic to control orchestrator - Split client state management into separate controller/repository - Add client bootstrap and home page components - Update HTTP fleet handlers and views - Add unit tests for client bootstrap and status controller - Archive completed subtask documents
175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"iop/apps/control-plane/internal/wire"
|
|
)
|
|
|
|
// fleetOptions configures the fleet orchestration service: per-call timeouts,
|
|
// bounded concurrency for status/command fan-out, and a short freshness cache
|
|
// for connected-edge status views. Zero values fall back to package defaults so
|
|
// callers and tests can override only the fields they care about.
|
|
type fleetOptions struct {
|
|
StatusTimeout time.Duration
|
|
CommandTimeout time.Duration
|
|
MaxConcurrentStatus int
|
|
MaxConcurrentCommands int
|
|
StatusCacheTTL time.Duration
|
|
Now func() time.Time
|
|
}
|
|
|
|
const (
|
|
defaultFleetStatusTimeout = 5 * time.Second
|
|
defaultFleetCommandTimeout = 10 * time.Second
|
|
defaultFleetMaxConcurrentStatus = 4
|
|
defaultFleetMaxConcurrentCommands = 4
|
|
defaultFleetStatusCacheTTL = 2 * time.Second
|
|
)
|
|
|
|
func (o fleetOptions) withDefaults() fleetOptions {
|
|
if o.StatusTimeout <= 0 {
|
|
o.StatusTimeout = defaultFleetStatusTimeout
|
|
}
|
|
if o.CommandTimeout <= 0 {
|
|
o.CommandTimeout = defaultFleetCommandTimeout
|
|
}
|
|
if o.MaxConcurrentStatus <= 0 {
|
|
o.MaxConcurrentStatus = defaultFleetMaxConcurrentStatus
|
|
}
|
|
if o.MaxConcurrentCommands <= 0 {
|
|
o.MaxConcurrentCommands = defaultFleetMaxConcurrentCommands
|
|
}
|
|
if o.StatusCacheTTL <= 0 {
|
|
o.StatusCacheTTL = defaultFleetStatusCacheTTL
|
|
}
|
|
if o.Now == nil {
|
|
o.Now = time.Now
|
|
}
|
|
return o
|
|
}
|
|
|
|
// fleetService orchestrates fleet-wide status and command fan-out over the Edge
|
|
// registry with bounded concurrency, per-call timeouts, and a short freshness
|
|
// cache for connected-edge status. It keeps the existing JSON contracts; it only
|
|
// changes how the views are gathered.
|
|
type fleetService struct {
|
|
registry *wire.EdgeRegistry
|
|
requestStatus edgeStatusRequester
|
|
sendCommand edgeCommandSender
|
|
opts fleetOptions
|
|
|
|
cacheMu sync.Mutex
|
|
cache map[string]fleetStatusCacheEntry
|
|
}
|
|
|
|
type fleetStatusCacheEntry struct {
|
|
view fleetEdgeView
|
|
expiry time.Time
|
|
}
|
|
|
|
func newFleetService(registry *wire.EdgeRegistry, requestStatus edgeStatusRequester, sendCommand edgeCommandSender, opts fleetOptions) *fleetService {
|
|
return &fleetService{
|
|
registry: registry,
|
|
requestStatus: requestStatus,
|
|
sendCommand: sendCommand,
|
|
opts: opts.withDefaults(),
|
|
cache: make(map[string]fleetStatusCacheEntry),
|
|
}
|
|
}
|
|
|
|
// Status returns one view per registered Edge in registry snapshot order.
|
|
// Connected-edge status is fetched with bounded concurrency and served from a
|
|
// short freshness cache; disconnected edges reflect registry state immediately
|
|
// and are never cached.
|
|
func (s *fleetService) Status() fleetStatusResponse {
|
|
states := s.registry.Snapshots()
|
|
views := make([]fleetEdgeView, len(states))
|
|
|
|
sem := make(chan struct{}, s.opts.MaxConcurrentStatus)
|
|
var wg sync.WaitGroup
|
|
for i, state := range states {
|
|
// Disconnected edges (and the no-requester case) need no wire call and
|
|
// must reflect registry state immediately, so they bypass the cache and
|
|
// the concurrency-bounded worker pool.
|
|
if !state.Connected || s.requestStatus == nil {
|
|
views[i] = fleetEdgeViewFromState(state, s.requestStatus, s.opts.StatusTimeout)
|
|
continue
|
|
}
|
|
if cached, ok := s.cachedStatus(state.EdgeID); ok {
|
|
views[i] = cached
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, state wire.EdgeConnectionState) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
view := fleetEdgeViewFromState(state, s.requestStatus, s.opts.StatusTimeout)
|
|
s.storeStatus(state.EdgeID, view)
|
|
views[i] = view
|
|
}(i, state)
|
|
}
|
|
wg.Wait()
|
|
|
|
return fleetStatusResponse{GeneratedAt: s.opts.Now().UTC(), Edges: views}
|
|
}
|
|
|
|
func (s *fleetService) cachedStatus(edgeID string) (fleetEdgeView, bool) {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
entry, ok := s.cache[edgeID]
|
|
if !ok || !entry.expiry.After(s.opts.Now()) {
|
|
return fleetEdgeView{}, false
|
|
}
|
|
return entry.view, true
|
|
}
|
|
|
|
func (s *fleetService) storeStatus(edgeID string, view fleetEdgeView) {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
s.cache[edgeID] = fleetStatusCacheEntry{
|
|
view: view,
|
|
expiry: s.opts.Now().Add(s.opts.StatusCacheTTL),
|
|
}
|
|
}
|
|
|
|
// Command fans out a surface-neutral command to every connected Edge with
|
|
// bounded concurrency, preserving connected snapshot order in the results.
|
|
// Disconnected edges are skipped and per-edge errors are surfaced in the result
|
|
// view. The caller is responsible for body validation and the nil-dispatcher
|
|
// case before invoking Command.
|
|
func (s *fleetService) Command(operation, targetSelector string, parameters map[string]string) fleetCommandResponse {
|
|
connected := make([]wire.EdgeConnectionState, 0)
|
|
for _, state := range s.registry.Snapshots() {
|
|
if state.Connected {
|
|
connected = append(connected, state)
|
|
}
|
|
}
|
|
|
|
results := make([]edgeCommandResponseView, len(connected))
|
|
sem := make(chan struct{}, s.opts.MaxConcurrentCommands)
|
|
var wg sync.WaitGroup
|
|
for i, state := range connected {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, state wire.EdgeConnectionState) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
resp, err := s.sendCommand(state.EdgeID, operation, targetSelector, parameters, s.opts.CommandTimeout)
|
|
if err != nil {
|
|
results[i] = edgeCommandResponseView{
|
|
EdgeID: state.EdgeID,
|
|
Status: "error",
|
|
Error: err.Error(),
|
|
}
|
|
return
|
|
}
|
|
results[i] = edgeCommandResponseViewFromProto(resp)
|
|
}(i, state)
|
|
}
|
|
wg.Wait()
|
|
|
|
return fleetCommandResponse{Operation: operation, Results: results}
|
|
}
|