229 lines
7.1 KiB
Go
229 lines
7.1 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// OperationPhase describes coordination state independently of any terminal UI.
|
|
type OperationPhase uint8
|
|
|
|
const (
|
|
OperationRunning OperationPhase = iota + 1
|
|
OperationAwaitingConfirmation
|
|
OperationSucceeded
|
|
OperationFailed
|
|
OperationBlocked
|
|
)
|
|
|
|
// StatusKind gives presentation layers semantics without requiring them to
|
|
// inspect user-facing status text.
|
|
type StatusKind uint8
|
|
|
|
const (
|
|
StatusProgress StatusKind = iota + 1
|
|
StatusSuccess
|
|
StatusError
|
|
StatusBlocked
|
|
StatusConfirmation
|
|
)
|
|
|
|
// OperationStatus is the current concise status plus optional expandable
|
|
// detail. TargetID relates a result to the Target it can explicitly resolve.
|
|
type OperationStatus struct {
|
|
Kind StatusKind
|
|
Summary string
|
|
Detail string
|
|
TargetID string
|
|
}
|
|
|
|
// Confirmation is a structured, safe-default decision. Presentation layers
|
|
// render these fields but never infer confirmation state from their wording.
|
|
type Confirmation struct {
|
|
Title string
|
|
Summary string
|
|
Details []string
|
|
ConfirmLabel string
|
|
CancelLabel string
|
|
HighImpact bool
|
|
}
|
|
|
|
// TargetProgressPhase describes one Target's part in an operation stream.
|
|
type TargetProgressPhase uint8
|
|
|
|
const (
|
|
TargetProgressRunning TargetProgressPhase = iota + 1
|
|
TargetProgressSucceeded
|
|
TargetProgressFailed
|
|
)
|
|
|
|
// OperationUpdate is the common progressive contract used by interactive
|
|
// detection, Target mutation, and Active Profile transition operations.
|
|
type OperationUpdate struct {
|
|
Snapshot Snapshot
|
|
Phase OperationPhase
|
|
Status OperationStatus
|
|
Progress []TargetProgress
|
|
Confirmation *Confirmation
|
|
FirstFailedTargetID string
|
|
Err error
|
|
}
|
|
|
|
// OperationStream exposes the useful initial frame synchronously and all
|
|
// subsequent structured changes through Updates. Updates closes after a
|
|
// terminal phase.
|
|
type OperationStream struct {
|
|
Snapshot Snapshot
|
|
Updates <-chan OperationUpdate
|
|
}
|
|
|
|
// InteractiveModule is the presentation-independent Application seam used by
|
|
// the TUI. Module.Dispatch remains available for synchronous callers.
|
|
type InteractiveModule interface {
|
|
Module
|
|
StartOperation(Intent) OperationStream
|
|
}
|
|
|
|
// StartOperation rejects overlap instead of queuing hidden interactive work.
|
|
// Synchronous Dispatch callers retain the established serialized behavior.
|
|
func (a *Application) StartOperation(intent Intent) OperationStream {
|
|
a.interactiveMu.Lock()
|
|
if a.interactiveOperation {
|
|
a.interactiveMu.Unlock()
|
|
updates := make(chan OperationUpdate, 1)
|
|
updates <- OperationUpdate{
|
|
Snapshot: a.Snapshot(),
|
|
Phase: OperationBlocked,
|
|
Status: OperationStatus{Kind: StatusBlocked,
|
|
Summary: "Another system operation is already in progress",
|
|
Detail: "Wait for the current operation to finish before starting another modification."},
|
|
}
|
|
close(updates)
|
|
return OperationStream{Snapshot: a.Snapshot(), Updates: updates}
|
|
}
|
|
a.interactiveOperation = true
|
|
a.interactiveMu.Unlock()
|
|
|
|
if intent.Kind == RefreshTargets {
|
|
return a.startDetectionOperation()
|
|
}
|
|
|
|
initial := a.Snapshot()
|
|
updates := make(chan OperationUpdate, 2*len(a.targets)+2)
|
|
go func() {
|
|
defer close(updates)
|
|
defer a.finishInteractiveOperation()
|
|
final := a.dispatch(intent, func(result Result) {
|
|
updates <- operationUpdate(result, false)
|
|
})
|
|
updates <- operationUpdate(final, true)
|
|
}()
|
|
return OperationStream{Snapshot: initial, Updates: updates}
|
|
}
|
|
|
|
func (a *Application) startDetectionOperation() OperationStream {
|
|
initial, detection := a.BeginTargetDetection()
|
|
updates := make(chan OperationUpdate, len(a.targets)+1)
|
|
go func() {
|
|
defer close(updates)
|
|
defer a.finishInteractiveOperation()
|
|
var failures []error
|
|
firstFailed := ""
|
|
for item := range detection {
|
|
if item.Complete {
|
|
update := OperationUpdate{
|
|
Snapshot: item.Snapshot,
|
|
Phase: OperationSucceeded,
|
|
Status: OperationStatus{Kind: StatusSuccess, Summary: "Target availability and state checks completed"},
|
|
}
|
|
if len(failures) > 0 {
|
|
update.Phase = OperationFailed
|
|
update.Err = errors.Join(failures...)
|
|
update.FirstFailedTargetID = firstFailed
|
|
update.Status = OperationStatus{Kind: StatusError, Summary: "Some Target checks failed", Detail: update.Err.Error(), TargetID: firstFailed}
|
|
}
|
|
updates <- update
|
|
continue
|
|
}
|
|
targetSnapshot, name := targetByID(item.Snapshot, item.TargetID)
|
|
progress := TargetProgress{TargetID: item.TargetID, State: targetSnapshot.State, Evidence: targetSnapshot.Evidence, Phase: TargetProgressSucceeded}
|
|
status := OperationStatus{Kind: StatusProgress, Summary: "Checked " + name + " Target", TargetID: item.TargetID}
|
|
if targetSnapshot.DetectionPhase == TargetDetectionFailed {
|
|
progress.Phase = TargetProgressFailed
|
|
progress.Err = targetSnapshot.DetectionErr
|
|
status = OperationStatus{Kind: StatusError, Summary: "Could not check " + name + " Target", TargetID: item.TargetID}
|
|
if targetSnapshot.DetectionErr != nil {
|
|
status.Detail = targetSnapshot.DetectionErr.Error()
|
|
failures = append(failures, fmt.Errorf("%s Target: %w", name, targetSnapshot.DetectionErr))
|
|
}
|
|
if firstFailed == "" {
|
|
firstFailed = item.TargetID
|
|
}
|
|
}
|
|
updates <- OperationUpdate{Snapshot: item.Snapshot, Phase: OperationRunning, Status: status, Progress: []TargetProgress{progress}, Err: progress.Err}
|
|
}
|
|
}()
|
|
return OperationStream{Snapshot: initial, Updates: updates}
|
|
}
|
|
|
|
func (a *Application) finishInteractiveOperation() {
|
|
a.interactiveMu.Lock()
|
|
a.interactiveOperation = false
|
|
a.interactiveMu.Unlock()
|
|
}
|
|
|
|
func targetByID(snapshot Snapshot, id string) (TargetSnapshot, string) {
|
|
for _, item := range snapshot.Targets {
|
|
if item.ID == id {
|
|
name := item.Description.Name
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
return item, name
|
|
}
|
|
}
|
|
return TargetSnapshot{ID: id}, id
|
|
}
|
|
|
|
func operationUpdate(result Result, terminal bool) OperationUpdate {
|
|
update := OperationUpdate{
|
|
Snapshot: result.Snapshot,
|
|
Phase: OperationRunning,
|
|
Status: result.Status,
|
|
Progress: result.Progress,
|
|
Confirmation: result.ConfirmationRequest,
|
|
FirstFailedTargetID: result.FirstFailedTargetID,
|
|
Err: result.Err,
|
|
}
|
|
if result.ConfirmationRequest != nil {
|
|
update.Phase = OperationAwaitingConfirmation
|
|
if update.Status.Kind == 0 {
|
|
update.Status = OperationStatus{Kind: StatusConfirmation, Summary: result.ConfirmationRequest.Title, Detail: result.ConfirmationRequest.Summary}
|
|
}
|
|
return update
|
|
}
|
|
if update.Status.Kind == 0 {
|
|
switch {
|
|
case result.Err != nil:
|
|
update.Status = OperationStatus{Kind: StatusError, Summary: "Operation failed", Detail: result.Err.Error()}
|
|
case result.Notice != "":
|
|
update.Status = OperationStatus{Kind: StatusSuccess, Summary: result.Notice}
|
|
}
|
|
}
|
|
if !terminal {
|
|
return update
|
|
}
|
|
if result.Err != nil {
|
|
update.Phase = OperationFailed
|
|
if update.Status.Kind != StatusError {
|
|
update.Status = OperationStatus{Kind: StatusError, Summary: "Operation failed", Detail: result.Err.Error(), TargetID: update.Status.TargetID}
|
|
}
|
|
} else {
|
|
update.Phase = OperationSucceeded
|
|
if update.Status.Kind == StatusProgress || update.Status.Kind == 0 {
|
|
update.Status.Kind = StatusSuccess
|
|
}
|
|
}
|
|
return update
|
|
}
|