feat(picker): problem-only status dots and typed client notices (#293)
* refactor(client): route every client toast through domain.Notification Client toasts now carry a typed code and severity and are keyed by Notification.Subject, so a newer notice about the same subject replaces the visible one. Remote host transitions are judged by the new domain.RemoteHealthNotice, which also notifies recoveries. * feat(picker): show status dots only for problems Healthy and stopped rows no longer carry a dot. The grouped section header now carries its host's problem dot (down, stale, version, no daemon), and a session carries a red dot only when broken. * fix(picker): avoid duplicate and unexplained problem dots The section dot is dropped when a host row already shows it, and the local section never shows a transient stale dot. Recovery is still reported across an unknown in-between state, and a daemon starting on a no-daemon host is not reported as a reconnection. * feat(client): color client toast borders by severity Error toasts get a red border and warnings a yellow one, using the same fixed xterm-256 colors as the picker problem dots. Info keeps the default. * refactor(client): keep remote notice policy in domain and text in client domain.RemoteHealthTransition now owns the whole transition rule, including keeping the last settled state across unknown observations and noticing a failure-class change. User-facing wording moves back to the client, which also gets one RemoteAvailability.Failing list, shared problem colors, a renamed client toast renderer, a wide-rune width fix, and a separate handoff subject. * test(client): cover remote host notice text, severity and scope
- commit
- 60d0c9ff2d4199dfdff04b87997db2e6050e8a27
- author
- bnema · · 2026-09-26 06:20 UTC
- committer
- GitHub · · 2026-09-26 06:20 UTC
- parent
- 5334f33
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
22 files · +608 -203
M internal/app/broker_client.go +2 -2
| @@ -310,10 +310,10 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 310 | 310 | cfg.OnLifecycle(notice) | |
| 311 | 311 | } | |
| 312 | 312 | if notice.Kind == client.LifecycleNoticeBrokerLost { | |
| 313 | - | picker.OfferNotice("broker-lost", "Connection lost; retrying…") | |
| 313 | + | picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"}) | |
| 314 | 314 | } | |
| 315 | 315 | if notice.Kind == client.LifecycleNoticeBrokerReconnected { | |
| 316 | - | picker.OfferNotice("broker-reconnected", "Connection restored") | |
| 316 | + | picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"}) | |
| 317 | 317 | } | |
| 318 | 318 | }, | |
| 319 | 319 | Spinner: presentation, | |
| @@ -310,10 +310,10 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 310 | cfg.OnLifecycle(notice) | 310 | cfg.OnLifecycle(notice) | ||
| 311 | } | 311 | } | ||
| 312 | if notice.Kind == client.LifecycleNoticeBrokerLost { | 312 | if notice.Kind == client.LifecycleNoticeBrokerLost { | ||
| 313 | - | picker.OfferNotice("broker-lost", "Connection lost; retrying…") | 313 | + | picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"}) |
| 314 | } | 314 | } | ||
| 315 | if notice.Kind == client.LifecycleNoticeBrokerReconnected { | 315 | if notice.Kind == client.LifecycleNoticeBrokerReconnected { | ||
| 316 | - | picker.OfferNotice("broker-reconnected", "Connection restored") | 316 | + | picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"}) |
| 317 | } | 317 | } | ||
| 318 | }, | 318 | }, | ||
| 319 | Spinner: presentation, | 319 | Spinner: presentation, | ||
M internal/domain/notice.go +12 -0
| @@ -44,6 +44,7 @@ const ( | |||
| 44 | 44 | NoticeUser | |
| 45 | 45 | NoticeNavigationInventory | |
| 46 | 46 | NoticeRemoteObservation | |
| 47 | + | NoticeSessionKill | |
| 47 | 48 | ||
| 48 | 49 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | |
| 49 | 50 | // valid notice code. Keep it last when adding a NoticeCode. | |
| @@ -74,6 +75,7 @@ var noticeSlugs = map[NoticeCode]string{ | |||
| 74 | 75 | NoticeUser: "user", | |
| 75 | 76 | NoticeNavigationInventory: "navigation-inventory", | |
| 76 | 77 | NoticeRemoteObservation: "remote-observation", | |
| 78 | + | NoticeSessionKill: "session-kill", | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 79 | 81 | func (c NoticeCode) String() string { | |
| @@ -120,4 +122,14 @@ type Notification struct { | |||
| 120 | 122 | Time time.Time | |
| 121 | 123 | Count int // >1 when coalesced | |
| 122 | 124 | SessionID SessionID // "" = daemon-global | |
| 125 | + | // Scope narrows the subject below the session, such as one remote host. | |
| 126 | + | // Notices with the same code, session, and scope replace each other. | |
| 127 | + | Scope string `json:",omitempty"` | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Subject is the stable identity of what a notice is about. Client toasts | |
| 131 | + | // use it so a newer notice for the same subject replaces the visible one; | |
| 132 | + | // the daemon's notice coalescing keeps its own rule (sameToastNotice). | |
| 133 | + | func (n Notification) Subject() string { | |
| 134 | + | return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope | |
| 123 | 135 | } | |
| @@ -44,6 +44,7 @@ const ( | |||||
| 44 | NoticeUser | 44 | NoticeUser | ||
| 45 | NoticeNavigationInventory | 45 | NoticeNavigationInventory | ||
| 46 | NoticeRemoteObservation | 46 | NoticeRemoteObservation | ||
| 47 | + | NoticeSessionKill | |||
| 47 | 48 | ||||
| 48 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | 49 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | ||
| 49 | // valid notice code. Keep it last when adding a NoticeCode. | 50 | // valid notice code. Keep it last when adding a NoticeCode. | ||
| @@ -74,6 +75,7 @@ var noticeSlugs = map[NoticeCode]string{ | |||||
| 74 | NoticeUser: "user", | 75 | NoticeUser: "user", | ||
| 75 | NoticeNavigationInventory: "navigation-inventory", | 76 | NoticeNavigationInventory: "navigation-inventory", | ||
| 76 | NoticeRemoteObservation: "remote-observation", | 77 | NoticeRemoteObservation: "remote-observation", | ||
| 78 | + | NoticeSessionKill: "session-kill", | |||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
| 79 | func (c NoticeCode) String() string { | 81 | func (c NoticeCode) String() string { | ||
| @@ -120,4 +122,14 @@ type Notification struct { | |||||
| 120 | Time time.Time | 122 | Time time.Time | ||
| 121 | Count int // >1 when coalesced | 123 | Count int // >1 when coalesced | ||
| 122 | SessionID SessionID // "" = daemon-global | 124 | SessionID SessionID // "" = daemon-global | ||
| 125 | + | // Scope narrows the subject below the session, such as one remote host. | |||
| 126 | + | // Notices with the same code, session, and scope replace each other. | |||
| 127 | + | Scope string `json:",omitempty"` | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Subject is the stable identity of what a notice is about. Client toasts | |||
| 131 | + | // use it so a newer notice for the same subject replaces the visible one; | |||
| 132 | + | // the daemon's notice coalescing keeps its own rule (sameToastNotice). | |||
| 133 | + | func (n Notification) Subject() string { | |||
| 134 | + | return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope | |||
| 123 | } | 135 | } | ||
A internal/domain/remote_notice.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // Failing reports an availability that cannot serve sessions right now. | |
| 4 | + | func (a RemoteAvailability) Failing() bool { | |
| 5 | + | switch a { | |
| 6 | + | case RemoteAvailabilityUnreachable, RemoteAvailabilityAuthFailed, | |
| 7 | + | RemoteAvailabilityInvalidResponse, RemoteAvailabilityNoDaemon, | |
| 8 | + | RemoteAvailabilityIncompatible: | |
| 9 | + | return true | |
| 10 | + | default: | |
| 11 | + | return false | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // RemoteHealth is the notice-relevant state of one observed remote host. | |
| 16 | + | type RemoteHealth struct { | |
| 17 | + | Availability RemoteAvailability | |
| 18 | + | VersionMismatch bool | |
| 19 | + | // Episode increments on each new failure episode. | |
| 20 | + | Episode uint64 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Failing reports a host that cannot serve sessions right now. | |
| 24 | + | func (h RemoteHealth) Failing() bool { | |
| 25 | + | return h.VersionMismatch || h.Availability.Failing() | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (h RemoteHealth) healthy() bool { | |
| 29 | + | return h.Availability == RemoteAvailabilityReachable && !h.VersionMismatch | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // settled reports a state worth remembering: in-between states (unknown, | |
| 33 | + | // checking) keep the last settled one so a later recovery is still seen. | |
| 34 | + | func (h RemoteHealth) settled() bool { | |
| 35 | + | return h.Failing() || h.Availability == RemoteAvailabilityReachable | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // RemoteHealthEvent is what one host transition is worth telling the user. | |
| 39 | + | type RemoteHealthEvent uint8 | |
| 40 | + | ||
| 41 | + | const ( | |
| 42 | + | RemoteHealthSilent RemoteHealthEvent = iota | |
| 43 | + | // RemoteHealthFailed is a new failure episode or a new failure class. | |
| 44 | + | RemoteHealthFailed | |
| 45 | + | // RemoteHealthRecovered is a host serving again after a real failure. | |
| 46 | + | RemoteHealthRecovered | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // RemoteHealthTransition judges one host observation against the last | |
| 50 | + | // remembered state (seen is false when there is none). It returns the state | |
| 51 | + | // to remember and the event worth one notice. Ordinary progress (checking, | |
| 52 | + | // first sighting of a healthy host) is silent. A host whose daemon just | |
| 53 | + | // started after "no daemon" was never disconnected, so it is silent too. | |
| 54 | + | func RemoteHealthTransition(prev RemoteHealth, seen bool, cur RemoteHealth) (RemoteHealth, RemoteHealthEvent) { | |
| 55 | + | next := cur | |
| 56 | + | if seen && !cur.settled() { | |
| 57 | + | next = prev | |
| 58 | + | } | |
| 59 | + | switch { | |
| 60 | + | case cur.Failing(): | |
| 61 | + | if seen && prev.Failing() && prev.Episode == cur.Episode && prev.Availability == cur.Availability { | |
| 62 | + | return next, RemoteHealthSilent | |
| 63 | + | } | |
| 64 | + | return next, RemoteHealthFailed | |
| 65 | + | case seen && prev.Failing() && prev.Availability != RemoteAvailabilityNoDaemon && cur.healthy(): | |
| 66 | + | return next, RemoteHealthRecovered | |
| 67 | + | default: | |
| 68 | + | return next, RemoteHealthSilent | |
| 69 | + | } | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | // Failing reports an availability that cannot serve sessions right now. | |||
| 4 | + | func (a RemoteAvailability) Failing() bool { | |||
| 5 | + | switch a { | |||
| 6 | + | case RemoteAvailabilityUnreachable, RemoteAvailabilityAuthFailed, | |||
| 7 | + | RemoteAvailabilityInvalidResponse, RemoteAvailabilityNoDaemon, | |||
| 8 | + | RemoteAvailabilityIncompatible: | |||
| 9 | + | return true | |||
| 10 | + | default: | |||
| 11 | + | return false | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // RemoteHealth is the notice-relevant state of one observed remote host. | |||
| 16 | + | type RemoteHealth struct { | |||
| 17 | + | Availability RemoteAvailability | |||
| 18 | + | VersionMismatch bool | |||
| 19 | + | // Episode increments on each new failure episode. | |||
| 20 | + | Episode uint64 | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Failing reports a host that cannot serve sessions right now. | |||
| 24 | + | func (h RemoteHealth) Failing() bool { | |||
| 25 | + | return h.VersionMismatch || h.Availability.Failing() | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (h RemoteHealth) healthy() bool { | |||
| 29 | + | return h.Availability == RemoteAvailabilityReachable && !h.VersionMismatch | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // settled reports a state worth remembering: in-between states (unknown, | |||
| 33 | + | // checking) keep the last settled one so a later recovery is still seen. | |||
| 34 | + | func (h RemoteHealth) settled() bool { | |||
| 35 | + | return h.Failing() || h.Availability == RemoteAvailabilityReachable | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // RemoteHealthEvent is what one host transition is worth telling the user. | |||
| 39 | + | type RemoteHealthEvent uint8 | |||
| 40 | + | ||||
| 41 | + | const ( | |||
| 42 | + | RemoteHealthSilent RemoteHealthEvent = iota | |||
| 43 | + | // RemoteHealthFailed is a new failure episode or a new failure class. | |||
| 44 | + | RemoteHealthFailed | |||
| 45 | + | // RemoteHealthRecovered is a host serving again after a real failure. | |||
| 46 | + | RemoteHealthRecovered | |||
| 47 | + | ) | |||
| 48 | + | ||||
| 49 | + | // RemoteHealthTransition judges one host observation against the last | |||
| 50 | + | // remembered state (seen is false when there is none). It returns the state | |||
| 51 | + | // to remember and the event worth one notice. Ordinary progress (checking, | |||
| 52 | + | // first sighting of a healthy host) is silent. A host whose daemon just | |||
| 53 | + | // started after "no daemon" was never disconnected, so it is silent too. | |||
| 54 | + | func RemoteHealthTransition(prev RemoteHealth, seen bool, cur RemoteHealth) (RemoteHealth, RemoteHealthEvent) { | |||
| 55 | + | next := cur | |||
| 56 | + | if seen && !cur.settled() { | |||
| 57 | + | next = prev | |||
| 58 | + | } | |||
| 59 | + | switch { | |||
| 60 | + | case cur.Failing(): | |||
| 61 | + | if seen && prev.Failing() && prev.Episode == cur.Episode && prev.Availability == cur.Availability { | |||
| 62 | + | return next, RemoteHealthSilent | |||
| 63 | + | } | |||
| 64 | + | return next, RemoteHealthFailed | |||
| 65 | + | case seen && prev.Failing() && prev.Availability != RemoteAvailabilityNoDaemon && cur.healthy(): | |||
| 66 | + | return next, RemoteHealthRecovered | |||
| 67 | + | default: | |||
| 68 | + | return next, RemoteHealthSilent | |||
| 69 | + | } | |||
| 70 | + | } | |||
A internal/domain/remote_notice_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRemoteHealthTransition(t *testing.T) { | |
| 6 | + | healthy := RemoteHealth{Availability: RemoteAvailabilityReachable} | |
| 7 | + | unknown := RemoteHealth{Availability: RemoteAvailabilityUnknown} | |
| 8 | + | noDaemon := RemoteHealth{Availability: RemoteAvailabilityNoDaemon, Episode: 1} | |
| 9 | + | down := func(episode uint64) RemoteHealth { | |
| 10 | + | return RemoteHealth{Availability: RemoteAvailabilityUnreachable, Episode: episode} | |
| 11 | + | } | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | prev RemoteHealth | |
| 15 | + | seen bool | |
| 16 | + | cur RemoteHealth | |
| 17 | + | want RemoteHealthEvent | |
| 18 | + | wantNext RemoteHealth | |
| 19 | + | }{ | |
| 20 | + | {name: "first healthy sighting is silent", cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |
| 21 | + | {name: "first unknown sighting is silent", cur: unknown, want: RemoteHealthSilent, wantNext: unknown}, | |
| 22 | + | {name: "healthy to healthy is silent", prev: healthy, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |
| 23 | + | {name: "first failing sighting fails", cur: down(1), want: RemoteHealthFailed, wantNext: down(1)}, | |
| 24 | + | {name: "healthy to failing fails", prev: healthy, seen: true, cur: down(1), want: RemoteHealthFailed, wantNext: down(1)}, | |
| 25 | + | {name: "same episode is silent", prev: down(1), seen: true, cur: down(1), want: RemoteHealthSilent, wantNext: down(1)}, | |
| 26 | + | {name: "new episode fails", prev: down(1), seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)}, | |
| 27 | + | {name: "new failure class in same episode fails", prev: down(1), seen: true, cur: noDaemon, want: RemoteHealthFailed, wantNext: noDaemon}, | |
| 28 | + | {name: "no daemon to unreachable fails", prev: noDaemon, seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)}, | |
| 29 | + | {name: "recovery", prev: down(1), seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy}, | |
| 30 | + | {name: "unknown keeps the failing state", prev: down(1), seen: true, cur: unknown, want: RemoteHealthSilent, wantNext: down(1)}, | |
| 31 | + | {name: "daemon started after no daemon is silent", prev: noDaemon, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |
| 32 | + | {name: "version fixed recovers", prev: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy}, | |
| 33 | + | {name: "version mismatch fails", cur: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}}, | |
| 34 | + | {name: "incompatible fails", cur: RemoteHealth{Availability: RemoteAvailabilityIncompatible}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityIncompatible}}, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | next, event := RemoteHealthTransition(tt.prev, tt.seen, tt.cur) | |
| 39 | + | if event != tt.want || next != tt.wantNext { | |
| 40 | + | t.Fatalf("got (%+v, %d), want (%+v, %d)", next, event, tt.wantNext, tt.want) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestRemoteHealthRecoveryAcrossUnknown(t *testing.T) { | |
| 47 | + | steps := []RemoteHealth{ | |
| 48 | + | {Availability: RemoteAvailabilityUnreachable, Episode: 1}, | |
| 49 | + | {Availability: RemoteAvailabilityUnknown, Episode: 1}, | |
| 50 | + | {Availability: RemoteAvailabilityReachable, Episode: 1}, | |
| 51 | + | } | |
| 52 | + | want := []RemoteHealthEvent{RemoteHealthFailed, RemoteHealthSilent, RemoteHealthRecovered} | |
| 53 | + | var prev RemoteHealth | |
| 54 | + | seen := false | |
| 55 | + | for i, cur := range steps { | |
| 56 | + | var event RemoteHealthEvent | |
| 57 | + | prev, event = RemoteHealthTransition(prev, seen, cur) | |
| 58 | + | seen = true | |
| 59 | + | if event != want[i] { | |
| 60 | + | t.Fatalf("step %d: event %d, want %d", i, event, want[i]) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestNotificationSubject(t *testing.T) { | |
| 66 | + | base := Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "down"} | |
| 67 | + | tests := []struct { | |
| 68 | + | name string | |
| 69 | + | other Notification | |
| 70 | + | same bool | |
| 71 | + | }{ | |
| 72 | + | {name: "same subject, other message", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "reconnected"}, same: true}, | |
| 73 | + | {name: "other scope", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "other"}}, | |
| 74 | + | {name: "other session", other: Notification{Code: NoticeRemoteObservation, SessionID: "t", Scope: "h"}}, | |
| 75 | + | {name: "other code", other: Notification{Code: NoticeSessionKill, SessionID: "s", Scope: "h"}}, | |
| 76 | + | } | |
| 77 | + | for _, tt := range tests { | |
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |
| 79 | + | if got := base.Subject() == tt.other.Subject(); got != tt.same { | |
| 80 | + | t.Fatalf("same subject = %v, want %v", got, tt.same) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestRemoteHealthTransition(t *testing.T) { | |||
| 6 | + | healthy := RemoteHealth{Availability: RemoteAvailabilityReachable} | |||
| 7 | + | unknown := RemoteHealth{Availability: RemoteAvailabilityUnknown} | |||
| 8 | + | noDaemon := RemoteHealth{Availability: RemoteAvailabilityNoDaemon, Episode: 1} | |||
| 9 | + | down := func(episode uint64) RemoteHealth { | |||
| 10 | + | return RemoteHealth{Availability: RemoteAvailabilityUnreachable, Episode: episode} | |||
| 11 | + | } | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | prev RemoteHealth | |||
| 15 | + | seen bool | |||
| 16 | + | cur RemoteHealth | |||
| 17 | + | want RemoteHealthEvent | |||
| 18 | + | wantNext RemoteHealth | |||
| 19 | + | }{ | |||
| 20 | + | {name: "first healthy sighting is silent", cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |||
| 21 | + | {name: "first unknown sighting is silent", cur: unknown, want: RemoteHealthSilent, wantNext: unknown}, | |||
| 22 | + | {name: "healthy to healthy is silent", prev: healthy, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |||
| 23 | + | {name: "first failing sighting fails", cur: down(1), want: RemoteHealthFailed, wantNext: down(1)}, | |||
| 24 | + | {name: "healthy to failing fails", prev: healthy, seen: true, cur: down(1), want: RemoteHealthFailed, wantNext: down(1)}, | |||
| 25 | + | {name: "same episode is silent", prev: down(1), seen: true, cur: down(1), want: RemoteHealthSilent, wantNext: down(1)}, | |||
| 26 | + | {name: "new episode fails", prev: down(1), seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)}, | |||
| 27 | + | {name: "new failure class in same episode fails", prev: down(1), seen: true, cur: noDaemon, want: RemoteHealthFailed, wantNext: noDaemon}, | |||
| 28 | + | {name: "no daemon to unreachable fails", prev: noDaemon, seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)}, | |||
| 29 | + | {name: "recovery", prev: down(1), seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy}, | |||
| 30 | + | {name: "unknown keeps the failing state", prev: down(1), seen: true, cur: unknown, want: RemoteHealthSilent, wantNext: down(1)}, | |||
| 31 | + | {name: "daemon started after no daemon is silent", prev: noDaemon, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy}, | |||
| 32 | + | {name: "version fixed recovers", prev: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy}, | |||
| 33 | + | {name: "version mismatch fails", cur: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}}, | |||
| 34 | + | {name: "incompatible fails", cur: RemoteHealth{Availability: RemoteAvailabilityIncompatible}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityIncompatible}}, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | next, event := RemoteHealthTransition(tt.prev, tt.seen, tt.cur) | |||
| 39 | + | if event != tt.want || next != tt.wantNext { | |||
| 40 | + | t.Fatalf("got (%+v, %d), want (%+v, %d)", next, event, tt.wantNext, tt.want) | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRemoteHealthRecoveryAcrossUnknown(t *testing.T) { | |||
| 47 | + | steps := []RemoteHealth{ | |||
| 48 | + | {Availability: RemoteAvailabilityUnreachable, Episode: 1}, | |||
| 49 | + | {Availability: RemoteAvailabilityUnknown, Episode: 1}, | |||
| 50 | + | {Availability: RemoteAvailabilityReachable, Episode: 1}, | |||
| 51 | + | } | |||
| 52 | + | want := []RemoteHealthEvent{RemoteHealthFailed, RemoteHealthSilent, RemoteHealthRecovered} | |||
| 53 | + | var prev RemoteHealth | |||
| 54 | + | seen := false | |||
| 55 | + | for i, cur := range steps { | |||
| 56 | + | var event RemoteHealthEvent | |||
| 57 | + | prev, event = RemoteHealthTransition(prev, seen, cur) | |||
| 58 | + | seen = true | |||
| 59 | + | if event != want[i] { | |||
| 60 | + | t.Fatalf("step %d: event %d, want %d", i, event, want[i]) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestNotificationSubject(t *testing.T) { | |||
| 66 | + | base := Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "down"} | |||
| 67 | + | tests := []struct { | |||
| 68 | + | name string | |||
| 69 | + | other Notification | |||
| 70 | + | same bool | |||
| 71 | + | }{ | |||
| 72 | + | {name: "same subject, other message", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "reconnected"}, same: true}, | |||
| 73 | + | {name: "other scope", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "other"}}, | |||
| 74 | + | {name: "other session", other: Notification{Code: NoticeRemoteObservation, SessionID: "t", Scope: "h"}}, | |||
| 75 | + | {name: "other code", other: Notification{Code: NoticeSessionKill, SessionID: "s", Scope: "h"}}, | |||
| 76 | + | } | |||
| 77 | + | for _, tt := range tests { | |||
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 79 | + | if got := base.Subject() == tt.other.Subject(); got != tt.same { | |||
| 80 | + | t.Fatalf("same subject = %v, want %v", got, tt.same) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
R internal/usecase/client/reconnect_toast.go → internal/usecase/client/client_toast.go +40 -6 renamed, 52% similar
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -14,16 +15,32 @@ import ( | |||
| 14 | 15 | // The attach main loop owns both this write and the later daemon-frame | |
| 15 | 16 | // reconciliation; input pumps must only publish a request for it. | |
| 16 | 17 | // The anchor places it: transitions stay centered, notices sit top-right so | |
| 17 | - | // they never cover the picker list. | |
| 18 | - | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) { | |
| 18 | + | // they never cover the picker list. The border is colored by severity. | |
| 19 | + | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) { | |
| 19 | 20 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | |
| 20 | 21 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 21 | 22 | return domain.Rect{}, nil | |
| 22 | 23 | } | |
| 23 | - | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |
| 24 | + | return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity))) | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | - | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |
| 27 | + | // toastBorderSGR is the border color per severity, in the same fixed | |
| 28 | + | // xterm-256 colors as the picker's problem dots. Info keeps the terminal's | |
| 29 | + | // default color. | |
| 30 | + | func toastBorderSGR(severity domain.NoticeSeverity) string { | |
| 31 | + | switch severity { | |
| 32 | + | case domain.NoticeError: | |
| 33 | + | return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError) | |
| 34 | + | case domain.NoticeWarn: | |
| 35 | + | return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn) | |
| 36 | + | default: | |
| 37 | + | return "" | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // clientToastLines renders the toast box as one string per row, exactly | |
| 42 | + | // bounds.Width columns wide. | |
| 43 | + | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |
| 27 | 44 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 28 | 45 | return nil | |
| 29 | 46 | } | |
| @@ -36,20 +53,37 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |||
| 36 | 53 | for y := range bounds.Height { | |
| 37 | 54 | var b strings.Builder | |
| 38 | 55 | b.Grow(bounds.Width) | |
| 56 | + | colored := false | |
| 39 | 57 | for x := range bounds.Width { | |
| 58 | + | border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1 | |
| 59 | + | if borderSGR != "" && border != colored { | |
| 60 | + | if border { | |
| 61 | + | b.WriteString(borderSGR) | |
| 62 | + | } else { | |
| 63 | + | b.WriteString("\x1b[39m") | |
| 64 | + | } | |
| 65 | + | colored = border | |
| 66 | + | } | |
| 40 | 67 | cell := frame.At(x, y) | |
| 41 | - | if cell.Continuation || cell.Rune == 0 { | |
| 68 | + | if cell.Continuation { | |
| 69 | + | // The wide rune before it already covers this column. | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | if cell.Rune == 0 { | |
| 42 | 73 | b.WriteRune(' ') | |
| 43 | 74 | continue | |
| 44 | 75 | } | |
| 45 | 76 | b.WriteRune(cell.Rune) | |
| 46 | 77 | } | |
| 78 | + | if colored { | |
| 79 | + | b.WriteString("\x1b[39m") | |
| 80 | + | } | |
| 47 | 81 | lines[y] = b.String() | |
| 48 | 82 | } | |
| 49 | 83 | return lines | |
| 50 | 84 | } | |
| 51 | 85 | ||
| 52 | - | func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error { | |
| 86 | + | func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error { | |
| 53 | 87 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | |
| 54 | 88 | return err | |
| 55 | 89 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 10 | "github.com/bnema/vev/internal/usecase/ui" | 11 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -14,16 +15,32 @@ import ( | |||||
| 14 | // The attach main loop owns both this write and the later daemon-frame | 15 | // The attach main loop owns both this write and the later daemon-frame | ||
| 15 | // reconciliation; input pumps must only publish a request for it. | 16 | // reconciliation; input pumps must only publish a request for it. | ||
| 16 | // The anchor places it: transitions stay centered, notices sit top-right so | 17 | // The anchor places it: transitions stay centered, notices sit top-right so | ||
| 17 | - | // they never cover the picker list. | 18 | + | // they never cover the picker list. The border is colored by severity. |
| 18 | - | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) { | 19 | + | func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) { |
| 19 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | 20 | bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor}) | ||
| 20 | if bounds.Width <= 0 || bounds.Height <= 0 { | 21 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 21 | return domain.Rect{}, nil | 22 | return domain.Rect{}, nil | ||
| 22 | } | 23 | } | ||
| 23 | - | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | 24 | + | return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity))) |
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | - | func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | 27 | + | // toastBorderSGR is the border color per severity, in the same fixed |
| 28 | + | // xterm-256 colors as the picker's problem dots. Info keeps the terminal's | |||
| 29 | + | // default color. | |||
| 30 | + | func toastBorderSGR(severity domain.NoticeSeverity) string { | |||
| 31 | + | switch severity { | |||
| 32 | + | case domain.NoticeError: | |||
| 33 | + | return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError) | |||
| 34 | + | case domain.NoticeWarn: | |||
| 35 | + | return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn) | |||
| 36 | + | default: | |||
| 37 | + | return "" | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // clientToastLines renders the toast box as one string per row, exactly | |||
| 42 | + | // bounds.Width columns wide. | |||
| 43 | + | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 27 | if bounds.Width <= 0 || bounds.Height <= 0 { | 44 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 28 | return nil | 45 | return nil | ||
| 29 | } | 46 | } | ||
| @@ -36,20 +53,37 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string { | |||||
| 36 | for y := range bounds.Height { | 53 | for y := range bounds.Height { | ||
| 37 | var b strings.Builder | 54 | var b strings.Builder | ||
| 38 | b.Grow(bounds.Width) | 55 | b.Grow(bounds.Width) | ||
| 56 | + | colored := false | |||
| 39 | for x := range bounds.Width { | 57 | for x := range bounds.Width { | ||
| 58 | + | border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1 | |||
| 59 | + | if borderSGR != "" && border != colored { | |||
| 60 | + | if border { | |||
| 61 | + | b.WriteString(borderSGR) | |||
| 62 | + | } else { | |||
| 63 | + | b.WriteString("\x1b[39m") | |||
| 64 | + | } | |||
| 65 | + | colored = border | |||
| 66 | + | } | |||
| 40 | cell := frame.At(x, y) | 67 | cell := frame.At(x, y) | ||
| 41 | - | if cell.Continuation || cell.Rune == 0 { | 68 | + | if cell.Continuation { |
| 69 | + | // The wide rune before it already covers this column. | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | if cell.Rune == 0 { | |||
| 42 | b.WriteRune(' ') | 73 | b.WriteRune(' ') | ||
| 43 | continue | 74 | continue | ||
| 44 | } | 75 | } | ||
| 45 | b.WriteRune(cell.Rune) | 76 | b.WriteRune(cell.Rune) | ||
| 46 | } | 77 | } | ||
| 78 | + | if colored { | |||
| 79 | + | b.WriteString("\x1b[39m") | |||
| 80 | + | } | |||
| 47 | lines[y] = b.String() | 81 | lines[y] = b.String() | ||
| 48 | } | 82 | } | ||
| 49 | return lines | 83 | return lines | ||
| 50 | } | 84 | } | ||
| 51 | 85 | ||||
| 52 | - | func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error { | 86 | + | func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error { |
| 53 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | 87 | if _, err := io.WriteString(out, "\x1b[s"); err != nil { | ||
| 54 | return err | 88 | return err | ||
| 55 | } | 89 | } | ||
A internal/usecase/client/client_toast_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "regexp" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | renderer "github.com/bnema/vev-vt" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestDrawClientToastPlacement(t *testing.T) { | |
| 16 | + | size := domain.Size{Cols: 100, Rows: 30} | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | anchor domain.Anchor | |
| 20 | + | check func(t *testing.T, bounds domain.Rect) | |
| 21 | + | }{ | |
| 22 | + | {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) { | |
| 23 | + | require.Less(t, bounds.Y, 3) | |
| 24 | + | require.Greater(t, bounds.X+bounds.Width, size.Cols-3) | |
| 25 | + | }}, | |
| 26 | + | {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) { | |
| 27 | + | require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y) | |
| 28 | + | require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X) | |
| 29 | + | }}, | |
| 30 | + | } | |
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | var out bytes.Buffer | |
| 34 | + | bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor, domain.NoticeInfo) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.NotZero(t, out.Len()) | |
| 37 | + | tt.check(t, bounds) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestClientToastLinesKeepExactWidth(t *testing.T) { | |
| 43 | + | sgr := regexp.MustCompile("\x1b\\[[0-9;]*m") | |
| 44 | + | tests := []struct { | |
| 45 | + | name string | |
| 46 | + | message string | |
| 47 | + | border string | |
| 48 | + | }{ | |
| 49 | + | {name: "ascii", message: "host down"}, | |
| 50 | + | {name: "wide rune", message: "hôte 東京 down"}, | |
| 51 | + | {name: "colored border", message: "hôte 東京 down", border: toastBorderSGR(domain.NoticeError)}, | |
| 52 | + | } | |
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | bounds := ui.ToastBounds(domain.Size{Cols: 100, Rows: 30}, ui.Toast{Message: tt.message, Anchor: domain.AnchorTopRight}) | |
| 56 | + | for i, line := range clientToastLines(bounds, tt.message, tt.border) { | |
| 57 | + | width := 0 | |
| 58 | + | for _, r := range sgr.ReplaceAllString(line, "") { | |
| 59 | + | width += renderer.RuneWidth(r) | |
| 60 | + | } | |
| 61 | + | require.Equal(t, bounds.Width, width, "row %d width", i) | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestDrawClientToastSeverityBorder(t *testing.T) { | |
| 68 | + | size := domain.Size{Cols: 100, Rows: 30} | |
| 69 | + | tests := []struct { | |
| 70 | + | name string | |
| 71 | + | severity domain.NoticeSeverity | |
| 72 | + | want string | |
| 73 | + | }{ | |
| 74 | + | {name: "error border is red", severity: domain.NoticeError, want: "\x1b[38;5;167m"}, | |
| 75 | + | {name: "warn border is yellow", severity: domain.NoticeWarn, want: "\x1b[38;5;179m"}, | |
| 76 | + | {name: "info keeps the default color", severity: domain.NoticeInfo}, | |
| 77 | + | } | |
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | var out bytes.Buffer | |
| 81 | + | _, err := drawClientToast(&out, size, "host down", domain.AnchorTopRight, tt.severity) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | if tt.want == "" { | |
| 84 | + | require.NotContains(t, out.String(), "\x1b[38;5;") | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | require.Contains(t, out.String(), tt.want) | |
| 88 | + | require.Contains(t, out.String(), "host down") | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "regexp" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | renderer "github.com/bnema/vev-vt" | |||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestDrawClientToastPlacement(t *testing.T) { | |||
| 16 | + | size := domain.Size{Cols: 100, Rows: 30} | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | anchor domain.Anchor | |||
| 20 | + | check func(t *testing.T, bounds domain.Rect) | |||
| 21 | + | }{ | |||
| 22 | + | {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) { | |||
| 23 | + | require.Less(t, bounds.Y, 3) | |||
| 24 | + | require.Greater(t, bounds.X+bounds.Width, size.Cols-3) | |||
| 25 | + | }}, | |||
| 26 | + | {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) { | |||
| 27 | + | require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y) | |||
| 28 | + | require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X) | |||
| 29 | + | }}, | |||
| 30 | + | } | |||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | + | var out bytes.Buffer | |||
| 34 | + | bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor, domain.NoticeInfo) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.NotZero(t, out.Len()) | |||
| 37 | + | tt.check(t, bounds) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestClientToastLinesKeepExactWidth(t *testing.T) { | |||
| 43 | + | sgr := regexp.MustCompile("\x1b\\[[0-9;]*m") | |||
| 44 | + | tests := []struct { | |||
| 45 | + | name string | |||
| 46 | + | message string | |||
| 47 | + | border string | |||
| 48 | + | }{ | |||
| 49 | + | {name: "ascii", message: "host down"}, | |||
| 50 | + | {name: "wide rune", message: "hôte 東京 down"}, | |||
| 51 | + | {name: "colored border", message: "hôte 東京 down", border: toastBorderSGR(domain.NoticeError)}, | |||
| 52 | + | } | |||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | bounds := ui.ToastBounds(domain.Size{Cols: 100, Rows: 30}, ui.Toast{Message: tt.message, Anchor: domain.AnchorTopRight}) | |||
| 56 | + | for i, line := range clientToastLines(bounds, tt.message, tt.border) { | |||
| 57 | + | width := 0 | |||
| 58 | + | for _, r := range sgr.ReplaceAllString(line, "") { | |||
| 59 | + | width += renderer.RuneWidth(r) | |||
| 60 | + | } | |||
| 61 | + | require.Equal(t, bounds.Width, width, "row %d width", i) | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestDrawClientToastSeverityBorder(t *testing.T) { | |||
| 68 | + | size := domain.Size{Cols: 100, Rows: 30} | |||
| 69 | + | tests := []struct { | |||
| 70 | + | name string | |||
| 71 | + | severity domain.NoticeSeverity | |||
| 72 | + | want string | |||
| 73 | + | }{ | |||
| 74 | + | {name: "error border is red", severity: domain.NoticeError, want: "\x1b[38;5;167m"}, | |||
| 75 | + | {name: "warn border is yellow", severity: domain.NoticeWarn, want: "\x1b[38;5;179m"}, | |||
| 76 | + | {name: "info keeps the default color", severity: domain.NoticeInfo}, | |||
| 77 | + | } | |||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | var out bytes.Buffer | |||
| 81 | + | _, err := drawClientToast(&out, size, "host down", domain.AnchorTopRight, tt.severity) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | if tt.want == "" { | |||
| 84 | + | require.NotContains(t, out.String(), "\x1b[38;5;") | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | require.Contains(t, out.String(), tt.want) | |||
| 88 | + | require.Contains(t, out.String(), "host down") | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
M internal/usecase/client/picker_catalogue.go +8 -12
| @@ -869,7 +869,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||
| 869 | 869 | if observation.ProtocolVersion == 0 || !fresh { | |
| 870 | 870 | return protocol.PickerLineStatusStale | |
| 871 | 871 | } | |
| 872 | - | return protocol.PickerLineStatusUp | |
| 872 | + | // A healthy host carries no dot. | |
| 873 | + | return protocol.PickerLineStatusNone | |
| 873 | 874 | default: | |
| 874 | 875 | // Availability Unknown (unobserved) and any out-of-range value are | |
| 875 | 876 | // never treated as compatible. | |
| @@ -919,22 +920,17 @@ func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | |||
| 919 | 920 | if !observation.InventoryKnown { | |
| 920 | 921 | return "…" | |
| 921 | 922 | } | |
| 922 | - | // A host row is only published when it has no sessions: the status dot | |
| 923 | - | // already says why, so no count is shown. | |
| 923 | + | // The problem dot and its toast already say why no session is listed. | |
| 924 | 924 | return "" | |
| 925 | 925 | } | |
| 926 | 926 | ||
| 927 | + | // pickerSessionStatus marks only a broken session. A live session is the | |
| 928 | + | // normal case and a stopped one is already muted, so neither carries a dot. | |
| 927 | 929 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | |
| 928 | - | switch session.State { | |
| 929 | - | case catalogue.RemoteCatalogSessionUp: | |
| 930 | - | return protocol.PickerLineStatusUp | |
| 931 | - | case catalogue.RemoteCatalogSessionDown: | |
| 932 | - | return protocol.PickerLineStatusStopped | |
| 933 | - | case catalogue.RemoteCatalogSessionBroken: | |
| 930 | + | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 934 | 931 | return protocol.PickerLineStatusError | |
| 935 | - | default: | |
| 936 | - | return protocol.PickerLineStatusNone | |
| 937 | 932 | } | |
| 933 | + | return protocol.PickerLineStatusNone | |
| 938 | 934 | } | |
| 939 | 935 | ||
| 940 | 936 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |
| @@ -943,7 +939,7 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |||
| 943 | 939 | count = strconv.Itoa(n) | |
| 944 | 940 | } | |
| 945 | 941 | if session.Attached { | |
| 946 | - | // "*" marks the attached session; ● is reserved for the status dot. | |
| 942 | + | // "*" marks the attached session; ● is reserved for the problem dot. | |
| 947 | 943 | return strings.TrimSpace("* " + count) | |
| 948 | 944 | } | |
| 949 | 945 | return count | |
| @@ -869,7 +869,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||||
| 869 | if observation.ProtocolVersion == 0 || !fresh { | 869 | if observation.ProtocolVersion == 0 || !fresh { | ||
| 870 | return protocol.PickerLineStatusStale | 870 | return protocol.PickerLineStatusStale | ||
| 871 | } | 871 | } | ||
| 872 | - | return protocol.PickerLineStatusUp | 872 | + | // A healthy host carries no dot. |
| 873 | + | return protocol.PickerLineStatusNone | |||
| 873 | default: | 874 | default: | ||
| 874 | // Availability Unknown (unobserved) and any out-of-range value are | 875 | // Availability Unknown (unobserved) and any out-of-range value are | ||
| 875 | // never treated as compatible. | 876 | // never treated as compatible. | ||
| @@ -919,22 +920,17 @@ func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | |||||
| 919 | if !observation.InventoryKnown { | 920 | if !observation.InventoryKnown { | ||
| 920 | return "…" | 921 | return "…" | ||
| 921 | } | 922 | } | ||
| 922 | - | // A host row is only published when it has no sessions: the status dot | 923 | + | // The problem dot and its toast already say why no session is listed. |
| 923 | - | // already says why, so no count is shown. | |||
| 924 | return "" | 924 | return "" | ||
| 925 | } | 925 | } | ||
| 926 | 926 | ||||
| 927 | + | // pickerSessionStatus marks only a broken session. A live session is the | |||
| 928 | + | // normal case and a stopped one is already muted, so neither carries a dot. | |||
| 927 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | 929 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | ||
| 928 | - | switch session.State { | 930 | + | if session.State == catalogue.RemoteCatalogSessionBroken { |
| 929 | - | case catalogue.RemoteCatalogSessionUp: | |||
| 930 | - | return protocol.PickerLineStatusUp | |||
| 931 | - | case catalogue.RemoteCatalogSessionDown: | |||
| 932 | - | return protocol.PickerLineStatusStopped | |||
| 933 | - | case catalogue.RemoteCatalogSessionBroken: | |||
| 934 | return protocol.PickerLineStatusError | 931 | return protocol.PickerLineStatusError | ||
| 935 | - | default: | |||
| 936 | - | return protocol.PickerLineStatusNone | |||
| 937 | } | 932 | } | ||
| 933 | + | return protocol.PickerLineStatusNone | |||
| 938 | } | 934 | } | ||
| 939 | 935 | ||||
| 940 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | 936 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | ||
| @@ -943,7 +939,7 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |||||
| 943 | count = strconv.Itoa(n) | 939 | count = strconv.Itoa(n) | ||
| 944 | } | 940 | } | ||
| 945 | if session.Attached { | 941 | if session.Attached { | ||
| 946 | - | // "*" marks the attached session; ● is reserved for the status dot. | 942 | + | // "*" marks the attached session; ● is reserved for the problem dot. |
| 947 | return strings.TrimSpace("* " + count) | 943 | return strings.TrimSpace("* " + count) | ||
| 948 | } | 944 | } | ||
| 949 | return count | 945 | return count | ||
M internal/usecase/client/picker_catalogue_projection.go +64 -44
| @@ -67,9 +67,11 @@ func (c *pickerCatalogue) projectLocked() { | |||
| 67 | 67 | refs := make(map[string]pickerSelectionRef) | |
| 68 | 68 | var localLive, localStopped []pickerSessionBlock | |
| 69 | 69 | localOrigin := "" | |
| 70 | + | localStatus := protocol.PickerLineStatusNone | |
| 70 | 71 | hasLocal := false | |
| 71 | 72 | type remoteGroup struct { | |
| 72 | 73 | origin string | |
| 74 | + | status protocol.PickerLineStatus | |
| 73 | 75 | rows []pickerRow | |
| 74 | 76 | } | |
| 75 | 77 | var remotes []remoteGroup | |
| @@ -100,7 +102,8 @@ func (c *pickerCatalogue) projectLocked() { | |||
| 100 | 102 | localStopped = append(localStopped, block) | |
| 101 | 103 | } | |
| 102 | 104 | } | |
| 103 | - | if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable { | |
| 105 | + | hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable | |
| 106 | + | if hasHostRow { | |
| 104 | 107 | hostRef := pickerSelectionRef{ | |
| 105 | 108 | kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch, | |
| 106 | 109 | local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration, | |
| @@ -124,14 +127,22 @@ func (c *pickerCatalogue) projectLocked() { | |||
| 124 | 127 | }(), | |
| 125 | 128 | }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon}) | |
| 126 | 129 | } | |
| 130 | + | // The section carries the host's problem dot unless a host row below | |
| 131 | + | // it already does. The local host never shows a transient stale dot: | |
| 132 | + | // no toast would explain it. | |
| 133 | + | sectionStatus := pickerObservationStatus(observation, fresh) | |
| 134 | + | if hasHostRow || observation.Local && sectionStatus == protocol.PickerLineStatusStale { | |
| 135 | + | sectionStatus = protocol.PickerLineStatusNone | |
| 136 | + | } | |
| 127 | 137 | if observation.Local { | |
| 128 | 138 | hasLocal = true | |
| 129 | 139 | localOrigin = origin | |
| 140 | + | localStatus = sectionStatus | |
| 130 | 141 | localHost = hostRows | |
| 131 | 142 | continue | |
| 132 | 143 | } | |
| 133 | 144 | if len(hostRows) != 0 { | |
| 134 | - | remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows}) | |
| 145 | + | remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows}) | |
| 135 | 146 | } | |
| 136 | 147 | } | |
| 137 | 148 | sortPickerBlocks(localLive) | |
| @@ -170,11 +181,11 @@ func (c *pickerCatalogue) projectLocked() { | |||
| 170 | 181 | ||
| 171 | 182 | localRows = append(localRows, stoppedRows...) | |
| 172 | 183 | if hasLocal && len(localRows) != 0 { | |
| 173 | - | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}}) | |
| 184 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}}) | |
| 174 | 185 | grouped = append(grouped, localRows...) | |
| 175 | 186 | } | |
| 176 | 187 | for _, remote := range remotes { | |
| 177 | - | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}}) | |
| 188 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}}) | |
| 178 | 189 | grouped = append(grouped, remote.rows...) | |
| 179 | 190 | } | |
| 180 | 191 | ||
| @@ -447,14 +458,10 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes | |||
| 447 | 458 | } | |
| 448 | 459 | ||
| 449 | 460 | // pickerObservationFailing reports a host the broker observed failing: its | |
| 450 | - | // last probe could not reach it, authenticate, or read its catalogue. | |
| 461 | + | // last probe could not reach it, authenticate, or read its catalogue. An | |
| 462 | + | // incompatible host is reachable and handled apart. | |
| 451 | 463 | func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool { | |
| 452 | - | switch observation.Availability { | |
| 453 | - | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon: | |
| 454 | - | return true | |
| 455 | - | default: | |
| 456 | - | return false | |
| 457 | - | } | |
| 464 | + | return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible | |
| 458 | 465 | } | |
| 459 | 466 | ||
| 460 | 467 | // pickerUnavailableNotice is main's remote-unavailable notice: the session | |
| @@ -558,66 +565,79 @@ func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) { | |||
| 558 | 565 | }, nil | |
| 559 | 566 | } | |
| 560 | 567 | ||
| 561 | - | // pickerHostFailure is one remote host failure episode worth one toast. | |
| 562 | - | type pickerHostFailure struct { | |
| 563 | - | key string | |
| 564 | - | episode uint64 | |
| 565 | - | message string | |
| 568 | + | // pickerHostHealth is one remote host's notice-relevant state. | |
| 569 | + | type pickerHostHealth struct { | |
| 570 | + | key string | |
| 571 | + | observation ports.BrokerDaemonObservation | |
| 572 | + | health domain.RemoteHealth | |
| 566 | 573 | } | |
| 567 | 574 | ||
| 568 | - | // failures reports every remote host currently in a failure episode. The | |
| 569 | - | // controller toasts each (host, episode) once, as main's | |
| 570 | - | // notifyNewRemoteFailures did. | |
| 571 | - | func (c *pickerCatalogue) failures() []pickerHostFailure { | |
| 575 | + | // remoteHealth reports every remote host's state, in catalogue order. | |
| 576 | + | // Transition policy lives in domain.RemoteHealthTransition. | |
| 577 | + | func (c *pickerCatalogue) remoteHealth() []pickerHostHealth { | |
| 572 | 578 | if c == nil { | |
| 573 | 579 | return nil | |
| 574 | 580 | } | |
| 575 | 581 | c.mu.Lock() | |
| 576 | 582 | defer c.mu.Unlock() | |
| 577 | - | failures := make([]pickerHostFailure, 0) | |
| 583 | + | hosts := make([]pickerHostHealth, 0, len(c.order)) | |
| 578 | 584 | for _, key := range c.order { | |
| 579 | 585 | host, ok := c.hosts[key] | |
| 580 | 586 | if !ok || host.observation.Local { | |
| 581 | 587 | continue | |
| 582 | 588 | } | |
| 583 | 589 | observation := host.observation | |
| 584 | - | if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) { | |
| 585 | - | continue | |
| 586 | - | } | |
| 587 | - | failures = append(failures, pickerHostFailure{ | |
| 588 | - | key: key, | |
| 589 | - | episode: observation.FailureEpisode, | |
| 590 | - | message: pickerHostFailureMessage(observation), | |
| 591 | - | }) | |
| 590 | + | hosts = append(hosts, pickerHostHealth{key: key, observation: observation, health: domain.RemoteHealth{ | |
| 591 | + | Availability: observation.Availability, | |
| 592 | + | VersionMismatch: pickerObservationVersionMismatch(observation), | |
| 593 | + | Episode: observation.FailureEpisode, | |
| 594 | + | }}) | |
| 592 | 595 | } | |
| 593 | - | return failures | |
| 596 | + | return hosts | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // pickerHostHealthNotice renders one host event as a toast scoped to the host, | |
| 600 | + | // so a recovery replaces the visible failure. | |
| 601 | + | func pickerHostHealthNotice(host pickerHostHealth, event domain.RemoteHealthEvent) domain.Notification { | |
| 602 | + | n := domain.Notification{Code: domain.NoticeRemoteObservation, Scope: host.key} | |
| 603 | + | origin := pickerOriginLabel(host.observation) | |
| 604 | + | if event == domain.RemoteHealthRecovered { | |
| 605 | + | n.Severity = domain.NoticeInfo | |
| 606 | + | n.Message = "Remote host reconnected: " + origin | |
| 607 | + | return n | |
| 608 | + | } | |
| 609 | + | n.Severity = domain.NoticeError | |
| 610 | + | if host.observation.Availability == domain.RemoteAvailabilityNoDaemon { | |
| 611 | + | n.Severity = domain.NoticeWarn | |
| 612 | + | } | |
| 613 | + | n.Message = "Remote check failed: " + origin + " — " + pickerHostFailureText(host.observation, host.health.VersionMismatch) | |
| 614 | + | return n | |
| 594 | 615 | } | |
| 595 | 616 | ||
| 596 | - | // pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the | |
| 597 | - | // host's display origin rather than a raw endpoint. | |
| 598 | - | func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string { | |
| 599 | - | prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — " | |
| 600 | - | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |
| 601 | - | return prefix + "remote vev version is incompatible" | |
| 617 | + | func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string { | |
| 618 | + | if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch { | |
| 619 | + | return "remote vev version is incompatible" | |
| 602 | 620 | } | |
| 603 | 621 | switch observation.LastFailure.Kind { | |
| 604 | 622 | case domain.RemoteFailureAuthentication: | |
| 605 | - | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |
| 623 | + | return "SSH authentication failed; verify non-interactive SSH access" | |
| 606 | 624 | case domain.RemoteFailureTrust: | |
| 607 | - | return prefix + "SSH host verification failed; verify the host key policy" | |
| 625 | + | return "SSH host verification failed; verify the host key policy" | |
| 608 | 626 | case domain.RemoteFailureIncompatible: | |
| 609 | - | return prefix + "remote vev version is incompatible" | |
| 627 | + | return "remote vev version is incompatible" | |
| 610 | 628 | case domain.RemoteFailureInvalidResponse: | |
| 611 | - | return prefix + "remote catalog response is invalid" | |
| 629 | + | return "remote catalog response is invalid" | |
| 612 | 630 | case domain.RemoteFailureTimeout: | |
| 613 | - | return prefix + "SSH timed out" | |
| 631 | + | return "SSH timed out" | |
| 614 | 632 | } | |
| 615 | 633 | switch observation.Availability { | |
| 616 | 634 | case domain.RemoteAvailabilityAuthFailed: | |
| 617 | - | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |
| 635 | + | return "SSH authentication failed; verify non-interactive SSH access" | |
| 618 | 636 | case domain.RemoteAvailabilityInvalidResponse: | |
| 619 | - | return prefix + "remote catalog response is invalid" | |
| 637 | + | return "remote catalog response is invalid" | |
| 638 | + | case domain.RemoteAvailabilityNoDaemon: | |
| 639 | + | return "no vev daemon is running" | |
| 620 | 640 | default: | |
| 621 | - | return prefix + "SSH connection failed; verify SSH access" | |
| 641 | + | return "SSH connection failed; verify SSH access" | |
| 622 | 642 | } | |
| 623 | 643 | } | |
| @@ -67,9 +67,11 @@ func (c *pickerCatalogue) projectLocked() { | |||||
| 67 | refs := make(map[string]pickerSelectionRef) | 67 | refs := make(map[string]pickerSelectionRef) | ||
| 68 | var localLive, localStopped []pickerSessionBlock | 68 | var localLive, localStopped []pickerSessionBlock | ||
| 69 | localOrigin := "" | 69 | localOrigin := "" | ||
| 70 | + | localStatus := protocol.PickerLineStatusNone | |||
| 70 | hasLocal := false | 71 | hasLocal := false | ||
| 71 | type remoteGroup struct { | 72 | type remoteGroup struct { | ||
| 72 | origin string | 73 | origin string | ||
| 74 | + | status protocol.PickerLineStatus | |||
| 73 | rows []pickerRow | 75 | rows []pickerRow | ||
| 74 | } | 76 | } | ||
| 75 | var remotes []remoteGroup | 77 | var remotes []remoteGroup | ||
| @@ -100,7 +102,8 @@ func (c *pickerCatalogue) projectLocked() { | |||||
| 100 | localStopped = append(localStopped, block) | 102 | localStopped = append(localStopped, block) | ||
| 101 | } | 103 | } | ||
| 102 | } | 104 | } | ||
| 103 | - | if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable { | 105 | + | hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable |
| 106 | + | if hasHostRow { | |||
| 104 | hostRef := pickerSelectionRef{ | 107 | hostRef := pickerSelectionRef{ | ||
| 105 | kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch, | 108 | kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch, | ||
| 106 | local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration, | 109 | local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration, | ||
| @@ -124,14 +127,22 @@ func (c *pickerCatalogue) projectLocked() { | |||||
| 124 | }(), | 127 | }(), | ||
| 125 | }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon}) | 128 | }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon}) | ||
| 126 | } | 129 | } | ||
| 130 | + | // The section carries the host's problem dot unless a host row below | |||
| 131 | + | // it already does. The local host never shows a transient stale dot: | |||
| 132 | + | // no toast would explain it. | |||
| 133 | + | sectionStatus := pickerObservationStatus(observation, fresh) | |||
| 134 | + | if hasHostRow || observation.Local && sectionStatus == protocol.PickerLineStatusStale { | |||
| 135 | + | sectionStatus = protocol.PickerLineStatusNone | |||
| 136 | + | } | |||
| 127 | if observation.Local { | 137 | if observation.Local { | ||
| 128 | hasLocal = true | 138 | hasLocal = true | ||
| 129 | localOrigin = origin | 139 | localOrigin = origin | ||
| 140 | + | localStatus = sectionStatus | |||
| 130 | localHost = hostRows | 141 | localHost = hostRows | ||
| 131 | continue | 142 | continue | ||
| 132 | } | 143 | } | ||
| 133 | if len(hostRows) != 0 { | 144 | if len(hostRows) != 0 { | ||
| 134 | - | remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows}) | 145 | + | remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows}) |
| 135 | } | 146 | } | ||
| 136 | } | 147 | } | ||
| 137 | sortPickerBlocks(localLive) | 148 | sortPickerBlocks(localLive) | ||
| @@ -170,11 +181,11 @@ func (c *pickerCatalogue) projectLocked() { | |||||
| 170 | 181 | ||||
| 171 | localRows = append(localRows, stoppedRows...) | 182 | localRows = append(localRows, stoppedRows...) | ||
| 172 | if hasLocal && len(localRows) != 0 { | 183 | if hasLocal && len(localRows) != 0 { | ||
| 173 | - | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}}) | 184 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}}) |
| 174 | grouped = append(grouped, localRows...) | 185 | grouped = append(grouped, localRows...) | ||
| 175 | } | 186 | } | ||
| 176 | for _, remote := range remotes { | 187 | for _, remote := range remotes { | ||
| 177 | - | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}}) | 188 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}}) |
| 178 | grouped = append(grouped, remote.rows...) | 189 | grouped = append(grouped, remote.rows...) | ||
| 179 | } | 190 | } | ||
| 180 | 191 | ||||
| @@ -447,14 +458,10 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes | |||||
| 447 | } | 458 | } | ||
| 448 | 459 | ||||
| 449 | // pickerObservationFailing reports a host the broker observed failing: its | 460 | // pickerObservationFailing reports a host the broker observed failing: its | ||
| 450 | - | // last probe could not reach it, authenticate, or read its catalogue. | 461 | + | // last probe could not reach it, authenticate, or read its catalogue. An |
| 462 | + | // incompatible host is reachable and handled apart. | |||
| 451 | func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool { | 463 | func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool { | ||
| 452 | - | switch observation.Availability { | 464 | + | return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible |
| 453 | - | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon: | |||
| 454 | - | return true | |||
| 455 | - | default: | |||
| 456 | - | return false | |||
| 457 | - | } | |||
| 458 | } | 465 | } | ||
| 459 | 466 | ||||
| 460 | // pickerUnavailableNotice is main's remote-unavailable notice: the session | 467 | // pickerUnavailableNotice is main's remote-unavailable notice: the session | ||
| @@ -558,66 +565,79 @@ func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) { | |||||
| 558 | }, nil | 565 | }, nil | ||
| 559 | } | 566 | } | ||
| 560 | 567 | ||||
| 561 | - | // pickerHostFailure is one remote host failure episode worth one toast. | 568 | + | // pickerHostHealth is one remote host's notice-relevant state. |
| 562 | - | type pickerHostFailure struct { | 569 | + | type pickerHostHealth struct { |
| 563 | - | key string | 570 | + | key string |
| 564 | - | episode uint64 | 571 | + | observation ports.BrokerDaemonObservation |
| 565 | - | message string | 572 | + | health domain.RemoteHealth |
| 566 | } | 573 | } | ||
| 567 | 574 | ||||
| 568 | - | // failures reports every remote host currently in a failure episode. The | 575 | + | // remoteHealth reports every remote host's state, in catalogue order. |
| 569 | - | // controller toasts each (host, episode) once, as main's | 576 | + | // Transition policy lives in domain.RemoteHealthTransition. |
| 570 | - | // notifyNewRemoteFailures did. | 577 | + | func (c *pickerCatalogue) remoteHealth() []pickerHostHealth { |
| 571 | - | func (c *pickerCatalogue) failures() []pickerHostFailure { | |||
| 572 | if c == nil { | 578 | if c == nil { | ||
| 573 | return nil | 579 | return nil | ||
| 574 | } | 580 | } | ||
| 575 | c.mu.Lock() | 581 | c.mu.Lock() | ||
| 576 | defer c.mu.Unlock() | 582 | defer c.mu.Unlock() | ||
| 577 | - | failures := make([]pickerHostFailure, 0) | 583 | + | hosts := make([]pickerHostHealth, 0, len(c.order)) |
| 578 | for _, key := range c.order { | 584 | for _, key := range c.order { | ||
| 579 | host, ok := c.hosts[key] | 585 | host, ok := c.hosts[key] | ||
| 580 | if !ok || host.observation.Local { | 586 | if !ok || host.observation.Local { | ||
| 581 | continue | 587 | continue | ||
| 582 | } | 588 | } | ||
| 583 | observation := host.observation | 589 | observation := host.observation | ||
| 584 | - | if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) { | 590 | + | hosts = append(hosts, pickerHostHealth{key: key, observation: observation, health: domain.RemoteHealth{ |
| 585 | - | continue | 591 | + | Availability: observation.Availability, |
| 586 | - | } | 592 | + | VersionMismatch: pickerObservationVersionMismatch(observation), |
| 587 | - | failures = append(failures, pickerHostFailure{ | 593 | + | Episode: observation.FailureEpisode, |
| 588 | - | key: key, | 594 | + | }}) |
| 589 | - | episode: observation.FailureEpisode, | |||
| 590 | - | message: pickerHostFailureMessage(observation), | |||
| 591 | - | }) | |||
| 592 | } | 595 | } | ||
| 593 | - | return failures | 596 | + | return hosts |
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // pickerHostHealthNotice renders one host event as a toast scoped to the host, | |||
| 600 | + | // so a recovery replaces the visible failure. | |||
| 601 | + | func pickerHostHealthNotice(host pickerHostHealth, event domain.RemoteHealthEvent) domain.Notification { | |||
| 602 | + | n := domain.Notification{Code: domain.NoticeRemoteObservation, Scope: host.key} | |||
| 603 | + | origin := pickerOriginLabel(host.observation) | |||
| 604 | + | if event == domain.RemoteHealthRecovered { | |||
| 605 | + | n.Severity = domain.NoticeInfo | |||
| 606 | + | n.Message = "Remote host reconnected: " + origin | |||
| 607 | + | return n | |||
| 608 | + | } | |||
| 609 | + | n.Severity = domain.NoticeError | |||
| 610 | + | if host.observation.Availability == domain.RemoteAvailabilityNoDaemon { | |||
| 611 | + | n.Severity = domain.NoticeWarn | |||
| 612 | + | } | |||
| 613 | + | n.Message = "Remote check failed: " + origin + " — " + pickerHostFailureText(host.observation, host.health.VersionMismatch) | |||
| 614 | + | return n | |||
| 594 | } | 615 | } | ||
| 595 | 616 | ||||
| 596 | - | // pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the | 617 | + | func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string { |
| 597 | - | // host's display origin rather than a raw endpoint. | 618 | + | if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch { |
| 598 | - | func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string { | 619 | + | return "remote vev version is incompatible" |
| 599 | - | prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — " | |||
| 600 | - | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |||
| 601 | - | return prefix + "remote vev version is incompatible" | |||
| 602 | } | 620 | } | ||
| 603 | switch observation.LastFailure.Kind { | 621 | switch observation.LastFailure.Kind { | ||
| 604 | case domain.RemoteFailureAuthentication: | 622 | case domain.RemoteFailureAuthentication: | ||
| 605 | - | return prefix + "SSH authentication failed; verify non-interactive SSH access" | 623 | + | return "SSH authentication failed; verify non-interactive SSH access" |
| 606 | case domain.RemoteFailureTrust: | 624 | case domain.RemoteFailureTrust: | ||
| 607 | - | return prefix + "SSH host verification failed; verify the host key policy" | 625 | + | return "SSH host verification failed; verify the host key policy" |
| 608 | case domain.RemoteFailureIncompatible: | 626 | case domain.RemoteFailureIncompatible: | ||
| 609 | - | return prefix + "remote vev version is incompatible" | 627 | + | return "remote vev version is incompatible" |
| 610 | case domain.RemoteFailureInvalidResponse: | 628 | case domain.RemoteFailureInvalidResponse: | ||
| 611 | - | return prefix + "remote catalog response is invalid" | 629 | + | return "remote catalog response is invalid" |
| 612 | case domain.RemoteFailureTimeout: | 630 | case domain.RemoteFailureTimeout: | ||
| 613 | - | return prefix + "SSH timed out" | 631 | + | return "SSH timed out" |
| 614 | } | 632 | } | ||
| 615 | switch observation.Availability { | 633 | switch observation.Availability { | ||
| 616 | case domain.RemoteAvailabilityAuthFailed: | 634 | case domain.RemoteAvailabilityAuthFailed: | ||
| 617 | - | return prefix + "SSH authentication failed; verify non-interactive SSH access" | 635 | + | return "SSH authentication failed; verify non-interactive SSH access" |
| 618 | case domain.RemoteAvailabilityInvalidResponse: | 636 | case domain.RemoteAvailabilityInvalidResponse: | ||
| 619 | - | return prefix + "remote catalog response is invalid" | 637 | + | return "remote catalog response is invalid" |
| 638 | + | case domain.RemoteAvailabilityNoDaemon: | |||
| 639 | + | return "no vev daemon is running" | |||
| 620 | default: | 640 | default: | ||
| 621 | - | return prefix + "SSH connection failed; verify SSH access" | 641 | + | return "SSH connection failed; verify SSH access" |
| 622 | } | 642 | } | ||
| 623 | } | 643 | } | ||
M internal/usecase/client/picker_catalogue_test.go +63 -5
| @@ -124,6 +124,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick | |||
| 124 | 124 | return protocol.PickerLine{}, false | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | func pickerSectionByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) { | |
| 128 | + | for _, line := range lines { | |
| 129 | + | if line.Kind == protocol.PickerLineSection && line.Label == label { | |
| 130 | + | return line, true | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return protocol.PickerLine{}, false | |
| 134 | + | } | |
| 135 | + | ||
| 127 | 136 | func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) { | |
| 128 | 137 | for _, line := range lines { | |
| 129 | 138 | if line.Kind == protocol.PickerLineHost { | |
| @@ -187,6 +196,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 187 | 196 | wantSessions []string | |
| 188 | 197 | wantActions map[string]protocol.PickerLineActions | |
| 189 | 198 | wantStatus map[string]protocol.PickerLineStatus | |
| 199 | + | wantSection map[string]protocol.PickerLineStatus | |
| 190 | 200 | wantDim map[string]bool | |
| 191 | 201 | wantHostRow bool | |
| 192 | 202 | wantNoDaemon bool | |
| @@ -244,7 +254,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 244 | 254 | wantSections: []string{"local"}, | |
| 245 | 255 | wantSessions: []string{"alpha"}, | |
| 246 | 256 | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill}, | |
| 247 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp}, | |
| 257 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusNone}, | |
| 258 | + | wantSection: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusNone}, | |
| 248 | 259 | wantDim: map[string]bool{"alpha": true}, | |
| 249 | 260 | }, | |
| 250 | 261 | { | |
| @@ -259,7 +270,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 259 | 270 | wantSections: []string{"user@arch"}, | |
| 260 | 271 | wantSessions: []string{"remote-a"}, | |
| 261 | 272 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | |
| 262 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | |
| 273 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone}, | |
| 274 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | |
| 263 | 275 | wantDim: map[string]bool{"remote-a": true}, | |
| 264 | 276 | }, | |
| 265 | 277 | { | |
| @@ -274,6 +286,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 274 | 286 | wantSections: []string{"user@arch"}, | |
| 275 | 287 | wantSessions: []string{}, | |
| 276 | 288 | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | |
| 289 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNone}, | |
| 277 | 290 | wantHostRow: true, | |
| 278 | 291 | }, | |
| 279 | 292 | { | |
| @@ -288,7 +301,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 288 | 301 | wantSections: []string{"user@arch"}, | |
| 289 | 302 | wantSessions: []string{"remote-a"}, | |
| 290 | 303 | wantActions: map[string]protocol.PickerLineActions{"remote-a": 0}, | |
| 291 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | |
| 304 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone}, | |
| 305 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusVersion}, | |
| 292 | 306 | wantDim: map[string]bool{"remote-a": true}, | |
| 293 | 307 | }, | |
| 294 | 308 | { | |
| @@ -328,6 +342,11 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 328 | 342 | require.True(t, ok, "row %q must be projected", label) | |
| 329 | 343 | require.Equal(t, status, line.Status, "row %q badge", label) | |
| 330 | 344 | } | |
| 345 | + | for label, status := range tt.wantSection { | |
| 346 | + | line, ok := pickerSectionByLabel(lines, label) | |
| 347 | + | require.True(t, ok, "section %q must be projected", label) | |
| 348 | + | require.Equal(t, status, line.Status, "section %q problem dot", label) | |
| 349 | + | } | |
| 331 | 350 | for label, dim := range tt.wantDim { | |
| 332 | 351 | line, ok := pickerLineByLabel(lines, label) | |
| 333 | 352 | require.True(t, ok) | |
| @@ -820,11 +839,11 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) | |||
| 820 | 839 | o.ProtocolVersion = protocol.Version + 1 | |
| 821 | 840 | }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |
| 822 | 841 | {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |
| 823 | - | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""}, | |
| 842 | + | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: ""}, | |
| 824 | 843 | {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale}, | |
| 825 | 844 | {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |
| 826 | 845 | {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |
| 827 | - | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing}, | |
| 846 | + | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: domain.RemoteReasonRefreshing}, | |
| 828 | 847 | } | |
| 829 | 848 | for _, tt := range tests { | |
| 830 | 849 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -1049,3 +1068,42 @@ func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) { | |||
| 1049 | 1068 | require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0)))) | |
| 1050 | 1069 | require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)))) | |
| 1051 | 1070 | } | |
| 1071 | + | ||
| 1072 | + | func TestPickerHostHealthNotice(t *testing.T) { | |
| 1073 | + | now := time.Unix(1000, 0) | |
| 1074 | + | host := func(availability domain.RemoteAvailability, kind domain.RemoteFailureKind, mismatch bool) pickerHostHealth { | |
| 1075 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 1076 | + | o.Availability = availability | |
| 1077 | + | o.LastFailure = domain.RemoteFailure{Kind: kind} | |
| 1078 | + | return pickerHostHealth{key: "host-key", observation: o, health: domain.RemoteHealth{Availability: availability, VersionMismatch: mismatch}} | |
| 1079 | + | } | |
| 1080 | + | const failed = "Remote check failed: user@arch — " | |
| 1081 | + | tests := []struct { | |
| 1082 | + | name string | |
| 1083 | + | host pickerHostHealth | |
| 1084 | + | event domain.RemoteHealthEvent | |
| 1085 | + | severity domain.NoticeSeverity | |
| 1086 | + | message string | |
| 1087 | + | }{ | |
| 1088 | + | {name: "recovered", host: host(domain.RemoteAvailabilityReachable, 0, false), event: domain.RemoteHealthRecovered, severity: domain.NoticeInfo, message: "Remote host reconnected: user@arch"}, | |
| 1089 | + | {name: "timeout", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTimeout, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH timed out"}, | |
| 1090 | + | {name: "authentication", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureAuthentication, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"}, | |
| 1091 | + | {name: "trust", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTrust, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH host verification failed; verify the host key policy"}, | |
| 1092 | + | {name: "invalid response kind", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureInvalidResponse, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"}, | |
| 1093 | + | {name: "auth failed availability fallback", host: host(domain.RemoteAvailabilityAuthFailed, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"}, | |
| 1094 | + | {name: "invalid response availability fallback", host: host(domain.RemoteAvailabilityInvalidResponse, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"}, | |
| 1095 | + | {name: "unreachable default", host: host(domain.RemoteAvailabilityUnreachable, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH connection failed; verify SSH access"}, | |
| 1096 | + | {name: "incompatible", host: host(domain.RemoteAvailabilityIncompatible, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"}, | |
| 1097 | + | {name: "version mismatch", host: host(domain.RemoteAvailabilityReachable, 0, true), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"}, | |
| 1098 | + | {name: "no daemon warns", host: host(domain.RemoteAvailabilityNoDaemon, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeWarn, message: failed + "no vev daemon is running"}, | |
| 1099 | + | } | |
| 1100 | + | for _, tt := range tests { | |
| 1101 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1102 | + | n := pickerHostHealthNotice(tt.host, tt.event) | |
| 1103 | + | require.Equal(t, domain.NoticeRemoteObservation, n.Code) | |
| 1104 | + | require.Equal(t, "host-key", n.Scope, "failure and recovery share the host subject") | |
| 1105 | + | require.Equal(t, tt.severity, n.Severity) | |
| 1106 | + | require.Equal(t, tt.message, n.Message) | |
| 1107 | + | }) | |
| 1108 | + | } | |
| 1109 | + | } | |
| @@ -124,6 +124,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick | |||||
| 124 | return protocol.PickerLine{}, false | 124 | return protocol.PickerLine{}, false | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | + | func pickerSectionByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) { | |||
| 128 | + | for _, line := range lines { | |||
| 129 | + | if line.Kind == protocol.PickerLineSection && line.Label == label { | |||
| 130 | + | return line, true | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | return protocol.PickerLine{}, false | |||
| 134 | + | } | |||
| 135 | + | ||||
| 127 | func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) { | 136 | func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) { | ||
| 128 | for _, line := range lines { | 137 | for _, line := range lines { | ||
| 129 | if line.Kind == protocol.PickerLineHost { | 138 | if line.Kind == protocol.PickerLineHost { | ||
| @@ -187,6 +196,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 187 | wantSessions []string | 196 | wantSessions []string | ||
| 188 | wantActions map[string]protocol.PickerLineActions | 197 | wantActions map[string]protocol.PickerLineActions | ||
| 189 | wantStatus map[string]protocol.PickerLineStatus | 198 | wantStatus map[string]protocol.PickerLineStatus | ||
| 199 | + | wantSection map[string]protocol.PickerLineStatus | |||
| 190 | wantDim map[string]bool | 200 | wantDim map[string]bool | ||
| 191 | wantHostRow bool | 201 | wantHostRow bool | ||
| 192 | wantNoDaemon bool | 202 | wantNoDaemon bool | ||
| @@ -244,7 +254,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 244 | wantSections: []string{"local"}, | 254 | wantSections: []string{"local"}, | ||
| 245 | wantSessions: []string{"alpha"}, | 255 | wantSessions: []string{"alpha"}, | ||
| 246 | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill}, | 256 | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill}, | ||
| 247 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp}, | 257 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusNone}, |
| 258 | + | wantSection: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusNone}, | |||
| 248 | wantDim: map[string]bool{"alpha": true}, | 259 | wantDim: map[string]bool{"alpha": true}, | ||
| 249 | }, | 260 | }, | ||
| 250 | { | 261 | { | ||
| @@ -259,7 +270,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 259 | wantSections: []string{"user@arch"}, | 270 | wantSections: []string{"user@arch"}, | ||
| 260 | wantSessions: []string{"remote-a"}, | 271 | wantSessions: []string{"remote-a"}, | ||
| 261 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | 272 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | ||
| 262 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | 273 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone}, |
| 274 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | |||
| 263 | wantDim: map[string]bool{"remote-a": true}, | 275 | wantDim: map[string]bool{"remote-a": true}, | ||
| 264 | }, | 276 | }, | ||
| 265 | { | 277 | { | ||
| @@ -274,6 +286,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 274 | wantSections: []string{"user@arch"}, | 286 | wantSections: []string{"user@arch"}, | ||
| 275 | wantSessions: []string{}, | 287 | wantSessions: []string{}, | ||
| 276 | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | 288 | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | ||
| 289 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNone}, | |||
| 277 | wantHostRow: true, | 290 | wantHostRow: true, | ||
| 278 | }, | 291 | }, | ||
| 279 | { | 292 | { | ||
| @@ -288,7 +301,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 288 | wantSections: []string{"user@arch"}, | 301 | wantSections: []string{"user@arch"}, | ||
| 289 | wantSessions: []string{"remote-a"}, | 302 | wantSessions: []string{"remote-a"}, | ||
| 290 | wantActions: map[string]protocol.PickerLineActions{"remote-a": 0}, | 303 | wantActions: map[string]protocol.PickerLineActions{"remote-a": 0}, | ||
| 291 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | 304 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone}, |
| 305 | + | wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusVersion}, | |||
| 292 | wantDim: map[string]bool{"remote-a": true}, | 306 | wantDim: map[string]bool{"remote-a": true}, | ||
| 293 | }, | 307 | }, | ||
| 294 | { | 308 | { | ||
| @@ -328,6 +342,11 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 328 | require.True(t, ok, "row %q must be projected", label) | 342 | require.True(t, ok, "row %q must be projected", label) | ||
| 329 | require.Equal(t, status, line.Status, "row %q badge", label) | 343 | require.Equal(t, status, line.Status, "row %q badge", label) | ||
| 330 | } | 344 | } | ||
| 345 | + | for label, status := range tt.wantSection { | |||
| 346 | + | line, ok := pickerSectionByLabel(lines, label) | |||
| 347 | + | require.True(t, ok, "section %q must be projected", label) | |||
| 348 | + | require.Equal(t, status, line.Status, "section %q problem dot", label) | |||
| 349 | + | } | |||
| 331 | for label, dim := range tt.wantDim { | 350 | for label, dim := range tt.wantDim { | ||
| 332 | line, ok := pickerLineByLabel(lines, label) | 351 | line, ok := pickerLineByLabel(lines, label) | ||
| 333 | require.True(t, ok) | 352 | require.True(t, ok) | ||
| @@ -820,11 +839,11 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) | |||||
| 820 | o.ProtocolVersion = protocol.Version + 1 | 839 | o.ProtocolVersion = protocol.Version + 1 | ||
| 821 | }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | 840 | }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | ||
| 822 | {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | 841 | {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | ||
| 823 | - | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""}, | 842 | + | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: ""}, |
| 824 | {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale}, | 843 | {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale}, | ||
| 825 | {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | 844 | {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | ||
| 826 | {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | 845 | {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | ||
| 827 | - | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing}, | 846 | + | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: domain.RemoteReasonRefreshing}, |
| 828 | } | 847 | } | ||
| 829 | for _, tt := range tests { | 848 | for _, tt := range tests { | ||
| 830 | t.Run(tt.name, func(t *testing.T) { | 849 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -1049,3 +1068,42 @@ func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) { | |||||
| 1049 | require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0)))) | 1068 | require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0)))) | ||
| 1050 | require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)))) | 1069 | require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)))) | ||
| 1051 | } | 1070 | } | ||
| 1071 | + | ||||
| 1072 | + | func TestPickerHostHealthNotice(t *testing.T) { | |||
| 1073 | + | now := time.Unix(1000, 0) | |||
| 1074 | + | host := func(availability domain.RemoteAvailability, kind domain.RemoteFailureKind, mismatch bool) pickerHostHealth { | |||
| 1075 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 1076 | + | o.Availability = availability | |||
| 1077 | + | o.LastFailure = domain.RemoteFailure{Kind: kind} | |||
| 1078 | + | return pickerHostHealth{key: "host-key", observation: o, health: domain.RemoteHealth{Availability: availability, VersionMismatch: mismatch}} | |||
| 1079 | + | } | |||
| 1080 | + | const failed = "Remote check failed: user@arch — " | |||
| 1081 | + | tests := []struct { | |||
| 1082 | + | name string | |||
| 1083 | + | host pickerHostHealth | |||
| 1084 | + | event domain.RemoteHealthEvent | |||
| 1085 | + | severity domain.NoticeSeverity | |||
| 1086 | + | message string | |||
| 1087 | + | }{ | |||
| 1088 | + | {name: "recovered", host: host(domain.RemoteAvailabilityReachable, 0, false), event: domain.RemoteHealthRecovered, severity: domain.NoticeInfo, message: "Remote host reconnected: user@arch"}, | |||
| 1089 | + | {name: "timeout", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTimeout, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH timed out"}, | |||
| 1090 | + | {name: "authentication", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureAuthentication, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"}, | |||
| 1091 | + | {name: "trust", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTrust, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH host verification failed; verify the host key policy"}, | |||
| 1092 | + | {name: "invalid response kind", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureInvalidResponse, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"}, | |||
| 1093 | + | {name: "auth failed availability fallback", host: host(domain.RemoteAvailabilityAuthFailed, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"}, | |||
| 1094 | + | {name: "invalid response availability fallback", host: host(domain.RemoteAvailabilityInvalidResponse, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"}, | |||
| 1095 | + | {name: "unreachable default", host: host(domain.RemoteAvailabilityUnreachable, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH connection failed; verify SSH access"}, | |||
| 1096 | + | {name: "incompatible", host: host(domain.RemoteAvailabilityIncompatible, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"}, | |||
| 1097 | + | {name: "version mismatch", host: host(domain.RemoteAvailabilityReachable, 0, true), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"}, | |||
| 1098 | + | {name: "no daemon warns", host: host(domain.RemoteAvailabilityNoDaemon, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeWarn, message: failed + "no vev daemon is running"}, | |||
| 1099 | + | } | |||
| 1100 | + | for _, tt := range tests { | |||
| 1101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1102 | + | n := pickerHostHealthNotice(tt.host, tt.event) | |||
| 1103 | + | require.Equal(t, domain.NoticeRemoteObservation, n.Code) | |||
| 1104 | + | require.Equal(t, "host-key", n.Scope, "failure and recovery share the host subject") | |||
| 1105 | + | require.Equal(t, tt.severity, n.Severity) | |||
| 1106 | + | require.Equal(t, tt.message, n.Message) | |||
| 1107 | + | }) | |||
| 1108 | + | } | |||
| 1109 | + | } | |||
M internal/usecase/client/picker_composition.go +3 -5
| @@ -130,15 +130,13 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||
| 130 | 130 | p.controller.SetOwnsInput(owns) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | - | // OfferNotice shows one bounded client-local notice on the picker. | |
| 134 | - | func (p *Picker) OfferNotice(id, message string) { | |
| 133 | + | // Notify shows one bounded client-local notice on the picker. | |
| 134 | + | func (p *Picker) Notify(n domain.Notification) { | |
| 135 | 135 | if p != nil && p.controller != nil { | |
| 136 | - | p.controller.offerNotice(id, message) | |
| 136 | + | p.controller.Notify(n) | |
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | - | func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) } | |
| 141 | - | ||
| 142 | 140 | // invalidatePresentation forces the next frame to redraw the whole box. | |
| 143 | 141 | func (p *Picker) invalidatePresentation() { | |
| 144 | 142 | if p != nil && p.controller != nil { | |
| @@ -130,15 +130,13 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||||
| 130 | p.controller.SetOwnsInput(owns) | 130 | p.controller.SetOwnsInput(owns) | ||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | - | // OfferNotice shows one bounded client-local notice on the picker. | 133 | + | // Notify shows one bounded client-local notice on the picker. |
| 134 | - | func (p *Picker) OfferNotice(id, message string) { | 134 | + | func (p *Picker) Notify(n domain.Notification) { |
| 135 | if p != nil && p.controller != nil { | 135 | if p != nil && p.controller != nil { | ||
| 136 | - | p.controller.offerNotice(id, message) | 136 | + | p.controller.Notify(n) |
| 137 | } | 137 | } | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | - | func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) } | |||
| 141 | - | ||||
| 142 | // invalidatePresentation forces the next frame to redraw the whole box. | 140 | // invalidatePresentation forces the next frame to redraw the whole box. | ||
| 143 | func (p *Picker) invalidatePresentation() { | 141 | func (p *Picker) invalidatePresentation() { | ||
| 144 | if p != nil && p.controller != nil { | 142 | if p != nil && p.controller != nil { | ||
M internal/usecase/client/picker_controller.go +40 -43
| @@ -109,10 +109,10 @@ type pickerController struct { | |||
| 109 | 109 | lastCommitKey string | |
| 110 | 110 | // lastKillKey is the catalogue key captured with a pending kill decision. | |
| 111 | 111 | lastKillKey string | |
| 112 | - | // noticedFailures records the failure episode already toasted per remote | |
| 113 | - | // host, so one outage toasts once (main's notifyNewRemoteFailures). | |
| 114 | - | noticedFailures map[string]uint64 | |
| 115 | - | preview pickerusecase.Preview | |
| 112 | + | // hostHealth is the last remote host state seen, so each host transition | |
| 113 | + | // is judged once by domain.RemoteHealthNotice. | |
| 114 | + | hostHealth map[string]domain.RemoteHealth | |
| 115 | + | preview pickerusecase.Preview | |
| 116 | 116 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | |
| 117 | 117 | // while one flush is armed for the currently withheld prefix. | |
| 118 | 118 | flushStop chan struct{} | |
| @@ -177,7 +177,7 @@ func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) { | |||
| 177 | 177 | // notice instead of silently vanishing. A merely stale publication | |
| 178 | 178 | // validates and is ignored without a notice. | |
| 179 | 179 | if snapshot.Validate() != nil { | |
| 180 | - | p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected") | |
| 180 | + | p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"}) | |
| 181 | 181 | } | |
| 182 | 182 | return | |
| 183 | 183 | } | |
| @@ -495,7 +495,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||
| 495 | 495 | return nil | |
| 496 | 496 | } | |
| 497 | 497 | var buffer bytes.Buffer | |
| 498 | - | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor); err != nil { | |
| 498 | + | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil { | |
| 499 | 499 | return nil | |
| 500 | 500 | } | |
| 501 | 501 | return buffer.Bytes() | |
| @@ -534,7 +534,7 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre | |||
| 534 | 534 | key, ok := p.displayedKey() | |
| 535 | 535 | if !ok { | |
| 536 | 536 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"} | |
| 537 | - | p.offerNotice("picker-refusal", err.Error()) | |
| 537 | + | p.Notify(pickerRefusalNotice(err)) | |
| 538 | 538 | return ports.BrokerOpenStreamRequest{}, err | |
| 539 | 539 | } | |
| 540 | 540 | return p.ResolveKey(key, base) | |
| @@ -549,7 +549,7 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports | |||
| 549 | 549 | if p == nil || p.catalogue == nil { | |
| 550 | 550 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 551 | 551 | if p != nil { | |
| 552 | - | p.offerNotice("picker-refusal", err.Error()) | |
| 552 | + | p.Notify(pickerRefusalNotice(err)) | |
| 553 | 553 | } | |
| 554 | 554 | return ports.BrokerOpenStreamRequest{}, err | |
| 555 | 555 | } | |
| @@ -562,14 +562,14 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) | |||
| 562 | 562 | if p == nil || p.catalogue == nil { | |
| 563 | 563 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 564 | 564 | if p != nil { | |
| 565 | - | p.offerNotice("picker-refusal", err.Error()) | |
| 565 | + | p.Notify(pickerRefusalNotice(err)) | |
| 566 | 566 | } | |
| 567 | 567 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 568 | 568 | } | |
| 569 | 569 | ref, ok := p.catalogue.Ref(key) | |
| 570 | 570 | if !ok { | |
| 571 | 571 | err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |
| 572 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |
| 572 | + | p.Notify(pickerRefusalNotice(err)) | |
| 573 | 573 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 574 | 574 | } | |
| 575 | 575 | var request ports.BrokerOpenStreamRequest | |
| @@ -583,7 +583,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) | |||
| 583 | 583 | err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"} | |
| 584 | 584 | } | |
| 585 | 585 | if err != nil { | |
| 586 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |
| 586 | + | p.Notify(pickerRefusalNotice(err)) | |
| 587 | 587 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 588 | 588 | } | |
| 589 | 589 | return request, tab, nil | |
| @@ -596,7 +596,7 @@ func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) { | |||
| 596 | 596 | } | |
| 597 | 597 | target, err := p.catalogue.ResolveKill(key) | |
| 598 | 598 | if err != nil { | |
| 599 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |
| 599 | + | p.Notify(pickerRefusalNotice(err)) | |
| 600 | 600 | return pickerKillTarget{}, err | |
| 601 | 601 | } | |
| 602 | 602 | return target, nil | |
| @@ -630,13 +630,14 @@ func (p *pickerController) rememberSortLocked() { | |||
| 630 | 630 | } | |
| 631 | 631 | } | |
| 632 | 632 | ||
| 633 | - | // pickerRefusalNotice is the toast text for one resolution refusal. | |
| 634 | - | func pickerRefusalNotice(err error) string { | |
| 633 | + | // pickerRefusalNotice is the notice for one resolution refusal. | |
| 634 | + | func pickerRefusalNotice(err error) domain.Notification { | |
| 635 | + | text := err.Error() | |
| 635 | 636 | var typed pickerCatalogueError | |
| 636 | 637 | if errors.As(err, &typed) { | |
| 637 | - | return typed.noticeText() | |
| 638 | + | text = typed.noticeText() | |
| 638 | 639 | } | |
| 639 | - | return err.Error() | |
| 640 | + | return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text} | |
| 640 | 641 | } | |
| 641 | 642 | ||
| 642 | 643 | // displayedKey names the row under the cursor, whether or not it admits an | |
| @@ -719,44 +720,40 @@ func (p *pickerController) rebuildLocked() { | |||
| 719 | 720 | p.loop.replaceLines(snapshot()) | |
| 720 | 721 | } | |
| 721 | 722 | ||
| 722 | - | // offerDiagnosticsLocked toasts each remote host failure once per failure | |
| 723 | - | // episode (main's notifyNewRemoteFailures). Ordinary observation progress and | |
| 724 | - | // catalogue aging stay on the rows; a recovered host forgets its episode so a | |
| 725 | - | // later outage toasts again. | |
| 723 | + | // offerDiagnosticsLocked notices each remote host transition that | |
| 724 | + | // domain.RemoteHealthTransition deems worth one toast: a new failure or a | |
| 725 | + | // recovery. Ordinary observation progress stays on the rows. | |
| 726 | 726 | func (p *pickerController) offerDiagnosticsLocked() { | |
| 727 | - | failing := make(map[string]struct{}) | |
| 728 | - | for _, failure := range p.catalogue.failures() { | |
| 729 | - | failing[failure.key] = struct{}{} | |
| 730 | - | if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode { | |
| 731 | - | continue | |
| 732 | - | } | |
| 733 | - | if p.noticedFailures == nil { | |
| 734 | - | p.noticedFailures = make(map[string]uint64) | |
| 735 | - | } | |
| 736 | - | p.noticedFailures[failure.key] = failure.episode | |
| 737 | - | p.notices.Show(p.clock.Now(), ui.Toast{ | |
| 738 | - | ID: "picker-host:" + failure.key, | |
| 739 | - | Message: failure.message, | |
| 740 | - | Anchor: domain.AnchorTopRight, | |
| 741 | - | Duration: pickerNoticeLifetime, | |
| 742 | - | }) | |
| 743 | - | } | |
| 744 | - | for key := range p.noticedFailures { | |
| 745 | - | if _, ok := failing[key]; !ok { | |
| 746 | - | delete(p.noticedFailures, key) | |
| 727 | + | current := make(map[string]domain.RemoteHealth) | |
| 728 | + | for _, host := range p.catalogue.remoteHealth() { | |
| 729 | + | prev, seen := p.hostHealth[host.key] | |
| 730 | + | next, event := domain.RemoteHealthTransition(prev, seen, host.health) | |
| 731 | + | current[host.key] = next | |
| 732 | + | if event != domain.RemoteHealthSilent { | |
| 733 | + | p.notifyLocked(pickerHostHealthNotice(host, event)) | |
| 747 | 734 | } | |
| 748 | 735 | } | |
| 736 | + | p.hostHealth = current | |
| 749 | 737 | } | |
| 750 | 738 | ||
| 751 | - | func (p *pickerController) offerNotice(id, message string) { | |
| 752 | - | if message == "" { | |
| 739 | + | // Notify shows one client-local notice. Notices about the same subject | |
| 740 | + | // (domain.Notification.Subject) replace each other. | |
| 741 | + | func (p *pickerController) Notify(n domain.Notification) { | |
| 742 | + | if p == nil { | |
| 753 | 743 | return | |
| 754 | 744 | } | |
| 755 | 745 | p.mu.Lock() | |
| 756 | - | p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | |
| 746 | + | p.notifyLocked(n) | |
| 757 | 747 | p.mu.Unlock() | |
| 758 | 748 | } | |
| 759 | 749 | ||
| 750 | + | func (p *pickerController) notifyLocked(n domain.Notification) { | |
| 751 | + | if n.Message == "" { | |
| 752 | + | return | |
| 753 | + | } | |
| 754 | + | p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | |
| 755 | + | } | |
| 756 | + | ||
| 760 | 757 | // mergePickerOps accumulates two presentation decisions from one read. Close | |
| 761 | 758 | // wins because it retires the interaction; commit and kill both remain. | |
| 762 | 759 | func mergePickerOps(a, b pickerOp) pickerOp { | |
| @@ -109,10 +109,10 @@ type pickerController struct { | |||||
| 109 | lastCommitKey string | 109 | lastCommitKey string | ||
| 110 | // lastKillKey is the catalogue key captured with a pending kill decision. | 110 | // lastKillKey is the catalogue key captured with a pending kill decision. | ||
| 111 | lastKillKey string | 111 | lastKillKey string | ||
| 112 | - | // noticedFailures records the failure episode already toasted per remote | 112 | + | // hostHealth is the last remote host state seen, so each host transition |
| 113 | - | // host, so one outage toasts once (main's notifyNewRemoteFailures). | 113 | + | // is judged once by domain.RemoteHealthNotice. |
| 114 | - | noticedFailures map[string]uint64 | 114 | + | hostHealth map[string]domain.RemoteHealth |
| 115 | - | preview pickerusecase.Preview | 115 | + | preview pickerusecase.Preview |
| 116 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | 116 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | ||
| 117 | // while one flush is armed for the currently withheld prefix. | 117 | // while one flush is armed for the currently withheld prefix. | ||
| 118 | flushStop chan struct{} | 118 | flushStop chan struct{} | ||
| @@ -177,7 +177,7 @@ func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) { | |||||
| 177 | // notice instead of silently vanishing. A merely stale publication | 177 | // notice instead of silently vanishing. A merely stale publication | ||
| 178 | // validates and is ignored without a notice. | 178 | // validates and is ignored without a notice. | ||
| 179 | if snapshot.Validate() != nil { | 179 | if snapshot.Validate() != nil { | ||
| 180 | - | p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected") | 180 | + | p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"}) |
| 181 | } | 181 | } | ||
| 182 | return | 182 | return | ||
| 183 | } | 183 | } | ||
| @@ -495,7 +495,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||||
| 495 | return nil | 495 | return nil | ||
| 496 | } | 496 | } | ||
| 497 | var buffer bytes.Buffer | 497 | var buffer bytes.Buffer | ||
| 498 | - | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor); err != nil { | 498 | + | if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil { |
| 499 | return nil | 499 | return nil | ||
| 500 | } | 500 | } | ||
| 501 | return buffer.Bytes() | 501 | return buffer.Bytes() | ||
| @@ -534,7 +534,7 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre | |||||
| 534 | key, ok := p.displayedKey() | 534 | key, ok := p.displayedKey() | ||
| 535 | if !ok { | 535 | if !ok { | ||
| 536 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"} | 536 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"} | ||
| 537 | - | p.offerNotice("picker-refusal", err.Error()) | 537 | + | p.Notify(pickerRefusalNotice(err)) |
| 538 | return ports.BrokerOpenStreamRequest{}, err | 538 | return ports.BrokerOpenStreamRequest{}, err | ||
| 539 | } | 539 | } | ||
| 540 | return p.ResolveKey(key, base) | 540 | return p.ResolveKey(key, base) | ||
| @@ -549,7 +549,7 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports | |||||
| 549 | if p == nil || p.catalogue == nil { | 549 | if p == nil || p.catalogue == nil { | ||
| 550 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | 550 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | ||
| 551 | if p != nil { | 551 | if p != nil { | ||
| 552 | - | p.offerNotice("picker-refusal", err.Error()) | 552 | + | p.Notify(pickerRefusalNotice(err)) |
| 553 | } | 553 | } | ||
| 554 | return ports.BrokerOpenStreamRequest{}, err | 554 | return ports.BrokerOpenStreamRequest{}, err | ||
| 555 | } | 555 | } | ||
| @@ -562,14 +562,14 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) | |||||
| 562 | if p == nil || p.catalogue == nil { | 562 | if p == nil || p.catalogue == nil { | ||
| 563 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | 563 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | ||
| 564 | if p != nil { | 564 | if p != nil { | ||
| 565 | - | p.offerNotice("picker-refusal", err.Error()) | 565 | + | p.Notify(pickerRefusalNotice(err)) |
| 566 | } | 566 | } | ||
| 567 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | 567 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | ||
| 568 | } | 568 | } | ||
| 569 | ref, ok := p.catalogue.Ref(key) | 569 | ref, ok := p.catalogue.Ref(key) | ||
| 570 | if !ok { | 570 | if !ok { | ||
| 571 | err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | 571 | err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | ||
| 572 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | 572 | + | p.Notify(pickerRefusalNotice(err)) |
| 573 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | 573 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | ||
| 574 | } | 574 | } | ||
| 575 | var request ports.BrokerOpenStreamRequest | 575 | var request ports.BrokerOpenStreamRequest | ||
| @@ -583,7 +583,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) | |||||
| 583 | err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"} | 583 | err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"} | ||
| 584 | } | 584 | } | ||
| 585 | if err != nil { | 585 | if err != nil { | ||
| 586 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | 586 | + | p.Notify(pickerRefusalNotice(err)) |
| 587 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | 587 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | ||
| 588 | } | 588 | } | ||
| 589 | return request, tab, nil | 589 | return request, tab, nil | ||
| @@ -596,7 +596,7 @@ func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) { | |||||
| 596 | } | 596 | } | ||
| 597 | target, err := p.catalogue.ResolveKill(key) | 597 | target, err := p.catalogue.ResolveKill(key) | ||
| 598 | if err != nil { | 598 | if err != nil { | ||
| 599 | - | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | 599 | + | p.Notify(pickerRefusalNotice(err)) |
| 600 | return pickerKillTarget{}, err | 600 | return pickerKillTarget{}, err | ||
| 601 | } | 601 | } | ||
| 602 | return target, nil | 602 | return target, nil | ||
| @@ -630,13 +630,14 @@ func (p *pickerController) rememberSortLocked() { | |||||
| 630 | } | 630 | } | ||
| 631 | } | 631 | } | ||
| 632 | 632 | ||||
| 633 | - | // pickerRefusalNotice is the toast text for one resolution refusal. | 633 | + | // pickerRefusalNotice is the notice for one resolution refusal. |
| 634 | - | func pickerRefusalNotice(err error) string { | 634 | + | func pickerRefusalNotice(err error) domain.Notification { |
| 635 | + | text := err.Error() | |||
| 635 | var typed pickerCatalogueError | 636 | var typed pickerCatalogueError | ||
| 636 | if errors.As(err, &typed) { | 637 | if errors.As(err, &typed) { | ||
| 637 | - | return typed.noticeText() | 638 | + | text = typed.noticeText() |
| 638 | } | 639 | } | ||
| 639 | - | return err.Error() | 640 | + | return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text} |
| 640 | } | 641 | } | ||
| 641 | 642 | ||||
| 642 | // displayedKey names the row under the cursor, whether or not it admits an | 643 | // displayedKey names the row under the cursor, whether or not it admits an | ||
| @@ -719,44 +720,40 @@ func (p *pickerController) rebuildLocked() { | |||||
| 719 | p.loop.replaceLines(snapshot()) | 720 | p.loop.replaceLines(snapshot()) | ||
| 720 | } | 721 | } | ||
| 721 | 722 | ||||
| 722 | - | // offerDiagnosticsLocked toasts each remote host failure once per failure | 723 | + | // offerDiagnosticsLocked notices each remote host transition that |
| 723 | - | // episode (main's notifyNewRemoteFailures). Ordinary observation progress and | 724 | + | // domain.RemoteHealthTransition deems worth one toast: a new failure or a |
| 724 | - | // catalogue aging stay on the rows; a recovered host forgets its episode so a | 725 | + | // recovery. Ordinary observation progress stays on the rows. |
| 725 | - | // later outage toasts again. | |||
| 726 | func (p *pickerController) offerDiagnosticsLocked() { | 726 | func (p *pickerController) offerDiagnosticsLocked() { | ||
| 727 | - | failing := make(map[string]struct{}) | 727 | + | current := make(map[string]domain.RemoteHealth) |
| 728 | - | for _, failure := range p.catalogue.failures() { | 728 | + | for _, host := range p.catalogue.remoteHealth() { |
| 729 | - | failing[failure.key] = struct{}{} | 729 | + | prev, seen := p.hostHealth[host.key] |
| 730 | - | if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode { | 730 | + | next, event := domain.RemoteHealthTransition(prev, seen, host.health) |
| 731 | - | continue | 731 | + | current[host.key] = next |
| 732 | - | } | 732 | + | if event != domain.RemoteHealthSilent { |
| 733 | - | if p.noticedFailures == nil { | 733 | + | p.notifyLocked(pickerHostHealthNotice(host, event)) |
| 734 | - | p.noticedFailures = make(map[string]uint64) | |||
| 735 | - | } | |||
| 736 | - | p.noticedFailures[failure.key] = failure.episode | |||
| 737 | - | p.notices.Show(p.clock.Now(), ui.Toast{ | |||
| 738 | - | ID: "picker-host:" + failure.key, | |||
| 739 | - | Message: failure.message, | |||
| 740 | - | Anchor: domain.AnchorTopRight, | |||
| 741 | - | Duration: pickerNoticeLifetime, | |||
| 742 | - | }) | |||
| 743 | - | } | |||
| 744 | - | for key := range p.noticedFailures { | |||
| 745 | - | if _, ok := failing[key]; !ok { | |||
| 746 | - | delete(p.noticedFailures, key) | |||
| 747 | } | 734 | } | ||
| 748 | } | 735 | } | ||
| 736 | + | p.hostHealth = current | |||
| 749 | } | 737 | } | ||
| 750 | 738 | ||||
| 751 | - | func (p *pickerController) offerNotice(id, message string) { | 739 | + | // Notify shows one client-local notice. Notices about the same subject |
| 752 | - | if message == "" { | 740 | + | // (domain.Notification.Subject) replace each other. |
| 741 | + | func (p *pickerController) Notify(n domain.Notification) { | |||
| 742 | + | if p == nil { | |||
| 753 | return | 743 | return | ||
| 754 | } | 744 | } | ||
| 755 | p.mu.Lock() | 745 | p.mu.Lock() | ||
| 756 | - | p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | 746 | + | p.notifyLocked(n) |
| 757 | p.mu.Unlock() | 747 | p.mu.Unlock() | ||
| 758 | } | 748 | } | ||
| 759 | 749 | ||||
| 750 | + | func (p *pickerController) notifyLocked(n domain.Notification) { | |||
| 751 | + | if n.Message == "" { | |||
| 752 | + | return | |||
| 753 | + | } | |||
| 754 | + | p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime}) | |||
| 755 | + | } | |||
| 756 | + | ||||
| 760 | // mergePickerOps accumulates two presentation decisions from one read. Close | 757 | // mergePickerOps accumulates two presentation decisions from one read. Close | ||
| 761 | // wins because it retires the interaction; commit and kill both remain. | 758 | // wins because it retires the interaction; commit and kill both remain. | ||
| 762 | func mergePickerOps(a, b pickerOp) pickerOp { | 759 | func mergePickerOps(a, b pickerOp) pickerOp { | ||
M internal/usecase/client/picker_parity_test.go +44 -3
| @@ -506,6 +506,35 @@ func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) { | |||
| 506 | 506 | } | |
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | + | // TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's | |
| 510 | + | // recovery toast replaces its failure toast and leaves other hosts' toasts. | |
| 511 | + | func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) { | |
| 512 | + | controller, clock := pickerTestController(t) | |
| 513 | + | down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) | |
| 514 | + | down.Availability = domain.RemoteAvailabilityUnreachable | |
| 515 | + | down.FailureEpisode = 1 | |
| 516 | + | other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now()) | |
| 517 | + | other.Availability = domain.RemoteAvailabilityUnreachable | |
| 518 | + | other.FailureEpisode = 1 | |
| 519 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}}) | |
| 520 | + | ||
| 521 | + | up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) | |
| 522 | + | up.FailureEpisode = 1 | |
| 523 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}}) | |
| 524 | + | ||
| 525 | + | controller.mu.Lock() | |
| 526 | + | defer controller.mu.Unlock() | |
| 527 | + | messages := make([]string, 0) | |
| 528 | + | for _, toast := range controller.notices.Active(clock.Now()) { | |
| 529 | + | messages = append(messages, toast.Message) | |
| 530 | + | } | |
| 531 | + | require.Len(t, messages, 2, "each host keeps one toast") | |
| 532 | + | require.Contains(t, messages, "Remote host reconnected: user@arch") | |
| 533 | + | for _, message := range messages { | |
| 534 | + | require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure") | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 509 | 538 | // TestPickerControllerHostFailureToastOncePerEpisode ports main's | |
| 510 | 539 | // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and | |
| 511 | 540 | // TestRemoteFailureNoticesKeepEndpointsDistinct. | |
| @@ -540,10 +569,22 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||
| 540 | 569 | ||
| 541 | 570 | recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second)) | |
| 542 | 571 | recovered.FailureEpisode = 2 | |
| 543 | - | require.Empty(t, apply(recovered)) | |
| 544 | - | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | |
| 572 | + | toasts = apply(recovered) | |
| 573 | + | require.Len(t, toasts, 1, "a recovery toasts once") | |
| 574 | + | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message) | |
| 575 | + | require.Empty(t, apply(recovered), "a steady healthy host is silent") | |
| 576 | + | ||
| 577 | + | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1) | |
| 578 | + | unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second)) | |
| 579 | + | unknown.Availability = domain.RemoteAvailabilityUnknown | |
| 580 | + | unknown.FailureEpisode = 3 | |
| 581 | + | require.Empty(t, apply(unknown), "an in-between state is silent") | |
| 582 | + | recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second)) | |
| 583 | + | recovered.FailureEpisode = 3 | |
| 584 | + | require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery") | |
| 585 | + | require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | |
| 545 | 586 | ||
| 546 | - | toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | |
| 587 | + | toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | |
| 547 | 588 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | |
| 548 | 589 | require.Contains(t, toasts[0].Message, "user@mule") | |
| 549 | 590 | } | |
| @@ -506,6 +506,35 @@ func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) { | |||||
| 506 | } | 506 | } | ||
| 507 | } | 507 | } | ||
| 508 | 508 | ||||
| 509 | + | // TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's | |||
| 510 | + | // recovery toast replaces its failure toast and leaves other hosts' toasts. | |||
| 511 | + | func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) { | |||
| 512 | + | controller, clock := pickerTestController(t) | |||
| 513 | + | down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) | |||
| 514 | + | down.Availability = domain.RemoteAvailabilityUnreachable | |||
| 515 | + | down.FailureEpisode = 1 | |||
| 516 | + | other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now()) | |||
| 517 | + | other.Availability = domain.RemoteAvailabilityUnreachable | |||
| 518 | + | other.FailureEpisode = 1 | |||
| 519 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}}) | |||
| 520 | + | ||||
| 521 | + | up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) | |||
| 522 | + | up.FailureEpisode = 1 | |||
| 523 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}}) | |||
| 524 | + | ||||
| 525 | + | controller.mu.Lock() | |||
| 526 | + | defer controller.mu.Unlock() | |||
| 527 | + | messages := make([]string, 0) | |||
| 528 | + | for _, toast := range controller.notices.Active(clock.Now()) { | |||
| 529 | + | messages = append(messages, toast.Message) | |||
| 530 | + | } | |||
| 531 | + | require.Len(t, messages, 2, "each host keeps one toast") | |||
| 532 | + | require.Contains(t, messages, "Remote host reconnected: user@arch") | |||
| 533 | + | for _, message := range messages { | |||
| 534 | + | require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure") | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | ||||
| 509 | // TestPickerControllerHostFailureToastOncePerEpisode ports main's | 538 | // TestPickerControllerHostFailureToastOncePerEpisode ports main's | ||
| 510 | // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and | 539 | // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and | ||
| 511 | // TestRemoteFailureNoticesKeepEndpointsDistinct. | 540 | // TestRemoteFailureNoticesKeepEndpointsDistinct. | ||
| @@ -540,10 +569,22 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||||
| 540 | 569 | ||||
| 541 | recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second)) | 570 | recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second)) | ||
| 542 | recovered.FailureEpisode = 2 | 571 | recovered.FailureEpisode = 2 | ||
| 543 | - | require.Empty(t, apply(recovered)) | 572 | + | toasts = apply(recovered) |
| 544 | - | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | 573 | + | require.Len(t, toasts, 1, "a recovery toasts once") |
| 574 | + | require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message) | |||
| 575 | + | require.Empty(t, apply(recovered), "a steady healthy host is silent") | |||
| 576 | + | ||||
| 577 | + | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1) | |||
| 578 | + | unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second)) | |||
| 579 | + | unknown.Availability = domain.RemoteAvailabilityUnknown | |||
| 580 | + | unknown.FailureEpisode = 3 | |||
| 581 | + | require.Empty(t, apply(unknown), "an in-between state is silent") | |||
| 582 | + | recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second)) | |||
| 583 | + | recovered.FailureEpisode = 3 | |||
| 584 | + | require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery") | |||
| 585 | + | require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | |||
| 545 | 586 | ||||
| 546 | - | toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | 587 | + | toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) |
| 547 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | 588 | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | ||
| 548 | require.Contains(t, toasts[0].Message, "user@mule") | 589 | require.Contains(t, toasts[0].Message, "user@mule") | ||
| 549 | } | 590 | } | ||
D internal/usecase/client/reconnect_toast_test.go +0 -37
| @@ -1,37 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "testing" | |
| 6 | - | ||
| 7 | - | "github.com/stretchr/testify/require" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/vev/internal/domain" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func TestDrawClientToastPlacement(t *testing.T) { | |
| 13 | - | size := domain.Size{Cols: 100, Rows: 30} | |
| 14 | - | tests := []struct { | |
| 15 | - | name string | |
| 16 | - | anchor domain.Anchor | |
| 17 | - | check func(t *testing.T, bounds domain.Rect) | |
| 18 | - | }{ | |
| 19 | - | {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) { | |
| 20 | - | require.Less(t, bounds.Y, 3) | |
| 21 | - | require.Greater(t, bounds.X+bounds.Width, size.Cols-3) | |
| 22 | - | }}, | |
| 23 | - | {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) { | |
| 24 | - | require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y) | |
| 25 | - | require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X) | |
| 26 | - | }}, | |
| 27 | - | } | |
| 28 | - | for _, tt := range tests { | |
| 29 | - | t.Run(tt.name, func(t *testing.T) { | |
| 30 | - | var out bytes.Buffer | |
| 31 | - | bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor) | |
| 32 | - | require.NoError(t, err) | |
| 33 | - | require.NotZero(t, out.Len()) | |
| 34 | - | tt.check(t, bounds) | |
| 35 | - | }) | |
| 36 | - | } | |
| 37 | - | } | |
| @@ -1,37 +0,0 @@ | |||||
| 1 | - | package client | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/vev/internal/domain" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | func TestDrawClientToastPlacement(t *testing.T) { | |||
| 13 | - | size := domain.Size{Cols: 100, Rows: 30} | |||
| 14 | - | tests := []struct { | |||
| 15 | - | name string | |||
| 16 | - | anchor domain.Anchor | |||
| 17 | - | check func(t *testing.T, bounds domain.Rect) | |||
| 18 | - | }{ | |||
| 19 | - | {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) { | |||
| 20 | - | require.Less(t, bounds.Y, 3) | |||
| 21 | - | require.Greater(t, bounds.X+bounds.Width, size.Cols-3) | |||
| 22 | - | }}, | |||
| 23 | - | {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) { | |||
| 24 | - | require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y) | |||
| 25 | - | require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X) | |||
| 26 | - | }}, | |||
| 27 | - | } | |||
| 28 | - | for _, tt := range tests { | |||
| 29 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | - | var out bytes.Buffer | |||
| 31 | - | bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor) | |||
| 32 | - | require.NoError(t, err) | |||
| 33 | - | require.NotZero(t, out.Len()) | |||
| 34 | - | tt.check(t, bounds) | |||
| 35 | - | }) | |||
| 36 | - | } | |||
| 37 | - | } | |||
M internal/usecase/client/route_ledger.go +1 -1
| @@ -354,7 +354,7 @@ func routeReachability(daemon ports.BrokerDaemonObservation) protocol.RouteReach | |||
| 354 | 354 | switch { | |
| 355 | 355 | case daemon.Availability == domain.RemoteAvailabilityReachable: | |
| 356 | 356 | return protocol.RouteReachabilityReachable | |
| 357 | - | case pickerObservationFailing(daemon) || daemon.Availability == domain.RemoteAvailabilityIncompatible: | |
| 357 | + | case daemon.Availability.Failing(): | |
| 358 | 358 | return protocol.RouteReachabilityUnavailable | |
| 359 | 359 | default: | |
| 360 | 360 | return protocol.RouteReachabilityUnknown | |
| @@ -354,7 +354,7 @@ func routeReachability(daemon ports.BrokerDaemonObservation) protocol.RouteReach | |||||
| 354 | switch { | 354 | switch { | ||
| 355 | case daemon.Availability == domain.RemoteAvailabilityReachable: | 355 | case daemon.Availability == domain.RemoteAvailabilityReachable: | ||
| 356 | return protocol.RouteReachabilityReachable | 356 | return protocol.RouteReachabilityReachable | ||
| 357 | - | case pickerObservationFailing(daemon) || daemon.Availability == domain.RemoteAvailabilityIncompatible: | 357 | + | case daemon.Availability.Failing(): |
| 358 | return protocol.RouteReachabilityUnavailable | 358 | return protocol.RouteReachabilityUnavailable | ||
| 359 | default: | 359 | default: | ||
| 360 | return protocol.RouteReachabilityUnknown | 360 | return protocol.RouteReachabilityUnknown | ||
M internal/usecase/client/supervisor.go +11 -7
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "sync/atomic" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -930,7 +931,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 930 | 931 | } | |
| 931 | 932 | // Escape and q cancel; with no attachment there is nothing to | |
| 932 | 933 | // cancel back to, so the picker stays and says how to leave. | |
| 933 | - | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |
| 934 | + | s.notifyPicker(pickerExitHint) | |
| 934 | 935 | s.renderCurrent() | |
| 935 | 936 | continue | |
| 936 | 937 | } | |
| @@ -978,12 +979,12 @@ func (s *Supervisor) takePickerClose() bool { | |||
| 978 | 979 | s.pendingPickerKey = key | |
| 979 | 980 | } | |
| 980 | 981 | if op.close && !op.exit { | |
| 981 | - | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |
| 982 | + | s.notifyPicker(pickerExitHint) | |
| 982 | 983 | s.renderCurrent() | |
| 983 | 984 | } | |
| 984 | 985 | if op.kill && !op.commit && !op.close && key != "" { | |
| 985 | 986 | // A kill needs the broker's control stream; it is refused, not queued. | |
| 986 | - | s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable") | |
| 987 | + | s.notifyPicker(pickerKillRefused("couldn't kill: broker unavailable")) | |
| 987 | 988 | s.renderCurrent() | |
| 988 | 989 | } | |
| 989 | 990 | return op.close && op.exit | |
| @@ -992,18 +993,21 @@ func (s *Supervisor) takePickerClose() bool { | |||
| 992 | 993 | // pickerPresentationHost is the optional presentation surface of the real | |
| 993 | 994 | // picker. Scripted pickers may omit it. | |
| 994 | 995 | type pickerPresentationHost interface { | |
| 995 | - | offerNotice(id, message string) | |
| 996 | + | Notify(n domain.Notification) | |
| 996 | 997 | invalidatePresentation() | |
| 997 | 998 | } | |
| 998 | 999 | ||
| 999 | - | // offerPickerNotice shows one bounded client-local notice when the picker | |
| 1000 | + | // notifyPicker shows one bounded client-local notice when the picker | |
| 1000 | 1001 | // supports it. | |
| 1001 | - | func (s *Supervisor) offerPickerNotice(id, message string) { | |
| 1002 | + | func (s *Supervisor) notifyPicker(n domain.Notification) { | |
| 1002 | 1003 | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | |
| 1003 | - | host.offerNotice(id, message) | |
| 1004 | + | host.Notify(n) | |
| 1004 | 1005 | } | |
| 1005 | 1006 | } | |
| 1006 | 1007 | ||
| 1008 | + | // pickerExitHint tells an unattached user how to leave the picker. | |
| 1009 | + | var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"} | |
| 1010 | + | ||
| 1007 | 1011 | // invalidatePickerPresentation forces the next picker frame to redraw the | |
| 1008 | 1012 | // whole box after another owner wrote the terminal. | |
| 1009 | 1013 | func (s *Supervisor) invalidatePickerPresentation() { | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "sync/atomic" | 12 | "sync/atomic" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -930,7 +931,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 930 | } | 931 | } | ||
| 931 | // Escape and q cancel; with no attachment there is nothing to | 932 | // Escape and q cancel; with no attachment there is nothing to | ||
| 932 | // cancel back to, so the picker stays and says how to leave. | 933 | // cancel back to, so the picker stays and says how to leave. | ||
| 933 | - | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | 934 | + | s.notifyPicker(pickerExitHint) |
| 934 | s.renderCurrent() | 935 | s.renderCurrent() | ||
| 935 | continue | 936 | continue | ||
| 936 | } | 937 | } | ||
| @@ -978,12 +979,12 @@ func (s *Supervisor) takePickerClose() bool { | |||||
| 978 | s.pendingPickerKey = key | 979 | s.pendingPickerKey = key | ||
| 979 | } | 980 | } | ||
| 980 | if op.close && !op.exit { | 981 | if op.close && !op.exit { | ||
| 981 | - | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | 982 | + | s.notifyPicker(pickerExitHint) |
| 982 | s.renderCurrent() | 983 | s.renderCurrent() | ||
| 983 | } | 984 | } | ||
| 984 | if op.kill && !op.commit && !op.close && key != "" { | 985 | if op.kill && !op.commit && !op.close && key != "" { | ||
| 985 | // A kill needs the broker's control stream; it is refused, not queued. | 986 | // A kill needs the broker's control stream; it is refused, not queued. | ||
| 986 | - | s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable") | 987 | + | s.notifyPicker(pickerKillRefused("couldn't kill: broker unavailable")) |
| 987 | s.renderCurrent() | 988 | s.renderCurrent() | ||
| 988 | } | 989 | } | ||
| 989 | return op.close && op.exit | 990 | return op.close && op.exit | ||
| @@ -992,18 +993,21 @@ func (s *Supervisor) takePickerClose() bool { | |||||
| 992 | // pickerPresentationHost is the optional presentation surface of the real | 993 | // pickerPresentationHost is the optional presentation surface of the real | ||
| 993 | // picker. Scripted pickers may omit it. | 994 | // picker. Scripted pickers may omit it. | ||
| 994 | type pickerPresentationHost interface { | 995 | type pickerPresentationHost interface { | ||
| 995 | - | offerNotice(id, message string) | 996 | + | Notify(n domain.Notification) |
| 996 | invalidatePresentation() | 997 | invalidatePresentation() | ||
| 997 | } | 998 | } | ||
| 998 | 999 | ||||
| 999 | - | // offerPickerNotice shows one bounded client-local notice when the picker | 1000 | + | // notifyPicker shows one bounded client-local notice when the picker |
| 1000 | // supports it. | 1001 | // supports it. | ||
| 1001 | - | func (s *Supervisor) offerPickerNotice(id, message string) { | 1002 | + | func (s *Supervisor) notifyPicker(n domain.Notification) { |
| 1002 | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | 1003 | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | ||
| 1003 | - | host.offerNotice(id, message) | 1004 | + | host.Notify(n) |
| 1004 | } | 1005 | } | ||
| 1005 | } | 1006 | } | ||
| 1006 | 1007 | ||||
| 1008 | + | // pickerExitHint tells an unattached user how to leave the picker. | |||
| 1009 | + | var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"} | |||
| 1010 | + | ||||
| 1007 | // invalidatePickerPresentation forces the next picker frame to redraw the | 1011 | // invalidatePickerPresentation forces the next picker frame to redraw the | ||
| 1008 | // whole box after another owner wrote the terminal. | 1012 | // whole box after another owner wrote the terminal. | ||
| 1009 | func (s *Supervisor) invalidatePickerPresentation() { | 1013 | func (s *Supervisor) invalidatePickerPresentation() { | ||
M internal/usecase/client/supervisor_picker_kill.go +19 -5
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 10 | 11 | ) | |
| @@ -69,7 +70,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||
| 69 | 70 | return | |
| 70 | 71 | } | |
| 71 | 72 | if s.kills.running { | |
| 72 | - | s.offerPickerNotice("picker-kill", "a kill is already in progress") | |
| 73 | + | s.notifyPicker(pickerKillRefused("a kill is already in progress")) | |
| 73 | 74 | return | |
| 74 | 75 | } | |
| 75 | 76 | target, err := resolver.ResolveKill(key) | |
| @@ -78,7 +79,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||
| 78 | 79 | } | |
| 79 | 80 | operations, err := NewBrokerOperations(service, s.cfg.Clock) | |
| 80 | 81 | if err != nil { | |
| 81 | - | s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable") | |
| 82 | + | s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable")) | |
| 82 | 83 | return | |
| 83 | 84 | } | |
| 84 | 85 | if s.kills.done == nil { | |
| @@ -99,7 +100,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||
| 99 | 100 | // the local daemon, so the killed row leaves the catalogue promptly. | |
| 100 | 101 | func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) { | |
| 101 | 102 | s.settlePickerKill() | |
| 102 | - | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |
| 103 | + | s.notifyPicker(pickerKillNotice(outcome)) | |
| 103 | 104 | if !supervisorNil(service) { | |
| 104 | 105 | service.RequestReconcile("") | |
| 105 | 106 | } | |
| @@ -116,7 +117,7 @@ func (s *Supervisor) retirePickerKill() { | |||
| 116 | 117 | s.kills.wg.Wait() | |
| 117 | 118 | outcome := <-s.kills.done | |
| 118 | 119 | s.settlePickerKill() | |
| 119 | - | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |
| 120 | + | s.notifyPicker(pickerKillNotice(outcome)) | |
| 120 | 121 | } | |
| 121 | 122 | ||
| 122 | 123 | func (s *Supervisor) settlePickerKill() { | |
| @@ -127,8 +128,21 @@ func (s *Supervisor) settlePickerKill() { | |||
| 127 | 128 | s.kills.cancel = nil | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 131 | + | // pickerKillRefused is a kill that was never sent. | |
| 132 | + | func pickerKillRefused(message string) domain.Notification { | |
| 133 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message} | |
| 134 | + | } | |
| 135 | + | ||
| 130 | 136 | // pickerKillNotice renders one typed kill outcome. | |
| 131 | - | func pickerKillNotice(outcome pickerKillOutcome) string { | |
| 137 | + | func pickerKillNotice(outcome pickerKillOutcome) domain.Notification { | |
| 138 | + | sev := domain.NoticeError | |
| 139 | + | if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { | |
| 140 | + | sev = domain.NoticeInfo | |
| 141 | + | } | |
| 142 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)} | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func pickerKillText(outcome pickerKillOutcome) string { | |
| 132 | 146 | name := outcome.target.name | |
| 133 | 147 | verb, done := "kill", "killed session " | |
| 134 | 148 | if outcome.target.stopped { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/internal/protocol" | 10 | "github.com/bnema/vev/internal/protocol" | ||
| 10 | ) | 11 | ) | ||
| @@ -69,7 +70,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||||
| 69 | return | 70 | return | ||
| 70 | } | 71 | } | ||
| 71 | if s.kills.running { | 72 | if s.kills.running { | ||
| 72 | - | s.offerPickerNotice("picker-kill", "a kill is already in progress") | 73 | + | s.notifyPicker(pickerKillRefused("a kill is already in progress")) |
| 73 | return | 74 | return | ||
| 74 | } | 75 | } | ||
| 75 | target, err := resolver.ResolveKill(key) | 76 | target, err := resolver.ResolveKill(key) | ||
| @@ -78,7 +79,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||||
| 78 | } | 79 | } | ||
| 79 | operations, err := NewBrokerOperations(service, s.cfg.Clock) | 80 | operations, err := NewBrokerOperations(service, s.cfg.Clock) | ||
| 80 | if err != nil { | 81 | if err != nil { | ||
| 81 | - | s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable") | 82 | + | s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable")) |
| 82 | return | 83 | return | ||
| 83 | } | 84 | } | ||
| 84 | if s.kills.done == nil { | 85 | if s.kills.done == nil { | ||
| @@ -99,7 +100,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||||
| 99 | // the local daemon, so the killed row leaves the catalogue promptly. | 100 | // the local daemon, so the killed row leaves the catalogue promptly. | ||
| 100 | func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) { | 101 | func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) { | ||
| 101 | s.settlePickerKill() | 102 | s.settlePickerKill() | ||
| 102 | - | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | 103 | + | s.notifyPicker(pickerKillNotice(outcome)) |
| 103 | if !supervisorNil(service) { | 104 | if !supervisorNil(service) { | ||
| 104 | service.RequestReconcile("") | 105 | service.RequestReconcile("") | ||
| 105 | } | 106 | } | ||
| @@ -116,7 +117,7 @@ func (s *Supervisor) retirePickerKill() { | |||||
| 116 | s.kills.wg.Wait() | 117 | s.kills.wg.Wait() | ||
| 117 | outcome := <-s.kills.done | 118 | outcome := <-s.kills.done | ||
| 118 | s.settlePickerKill() | 119 | s.settlePickerKill() | ||
| 119 | - | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | 120 | + | s.notifyPicker(pickerKillNotice(outcome)) |
| 120 | } | 121 | } | ||
| 121 | 122 | ||||
| 122 | func (s *Supervisor) settlePickerKill() { | 123 | func (s *Supervisor) settlePickerKill() { | ||
| @@ -127,8 +128,21 @@ func (s *Supervisor) settlePickerKill() { | |||||
| 127 | s.kills.cancel = nil | 128 | s.kills.cancel = nil | ||
| 128 | } | 129 | } | ||
| 129 | 130 | ||||
| 131 | + | // pickerKillRefused is a kill that was never sent. | |||
| 132 | + | func pickerKillRefused(message string) domain.Notification { | |||
| 133 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message} | |||
| 134 | + | } | |||
| 135 | + | ||||
| 130 | // pickerKillNotice renders one typed kill outcome. | 136 | // pickerKillNotice renders one typed kill outcome. | ||
| 131 | - | func pickerKillNotice(outcome pickerKillOutcome) string { | 137 | + | func pickerKillNotice(outcome pickerKillOutcome) domain.Notification { |
| 138 | + | sev := domain.NoticeError | |||
| 139 | + | if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded { | |||
| 140 | + | sev = domain.NoticeInfo | |||
| 141 | + | } | |||
| 142 | + | return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)} | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func pickerKillText(outcome pickerKillOutcome) string { | |||
| 132 | name := outcome.target.name | 146 | name := outcome.target.name | ||
| 133 | verb, done := "kill", "killed session " | 147 | verb, done := "kill", "killed session " | ||
| 134 | if outcome.target.stopped { | 148 | if outcome.target.stopped { | ||
M internal/usecase/client/supervisor_picker_parity_test.go +3 -2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 15 | 16 | ) | |
| @@ -45,9 +46,9 @@ func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) { | |||
| 45 | 46 | return p.kill, nil | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | - | func (p *parityTestPicker) offerNotice(_ string, message string) { | |
| 49 | + | func (p *parityTestPicker) Notify(n domain.Notification) { | |
| 49 | 50 | p.mu.Lock() | |
| 50 | - | p.notices = append(p.notices, message) | |
| 51 | + | p.notices = append(p.notices, n.Message) | |
| 51 | 52 | p.mu.Unlock() | |
| 52 | 53 | } | |
| 53 | 54 | ||
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/protocol" | 15 | "github.com/bnema/vev/internal/protocol" | ||
| 15 | ) | 16 | ) | ||
| @@ -45,9 +46,9 @@ func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) { | |||||
| 45 | return p.kill, nil | 46 | return p.kill, nil | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 48 | - | func (p *parityTestPicker) offerNotice(_ string, message string) { | 49 | + | func (p *parityTestPicker) Notify(n domain.Notification) { |
| 49 | p.mu.Lock() | 50 | p.mu.Lock() | ||
| 50 | - | p.notices = append(p.notices, message) | 51 | + | p.notices = append(p.notices, n.Message) |
| 51 | p.mu.Unlock() | 52 | p.mu.Unlock() | ||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
M internal/usecase/client/supervisor_routes.go +2 -1
| @@ -3,6 +3,7 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 9 | ) | |
| @@ -169,7 +170,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token | |||
| 169 | 170 | // revalidates the exact identity on Hello. | |
| 170 | 171 | func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) { | |
| 171 | 172 | if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach { | |
| 172 | - | s.offerPickerNotice("daemon-handoff", "the daemon asked for a destination this client cannot open") | |
| 173 | + | s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"}) | |
| 173 | 174 | return pickerAttachmentTarget{}, false | |
| 174 | 175 | } | |
| 175 | 176 | stream, err := service.NextStreamID() | |
| @@ -3,6 +3,7 @@ package client | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 7 | "github.com/bnema/vev/internal/protocol" | 8 | "github.com/bnema/vev/internal/protocol" | ||
| 8 | ) | 9 | ) | ||
| @@ -169,7 +170,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token | |||||
| 169 | // revalidates the exact identity on Hello. | 170 | // revalidates the exact identity on Hello. | ||
| 170 | func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) { | 171 | func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) { | ||
| 171 | if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach { | 172 | if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach { | ||
| 172 | - | s.offerPickerNotice("daemon-handoff", "the daemon asked for a destination this client cannot open") | 173 | + | s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"}) |
| 173 | return pickerAttachmentTarget{}, false | 174 | return pickerAttachmentTarget{}, false | ||
| 174 | } | 175 | } | ||
| 175 | stream, err := service.NextStreamID() | 176 | stream, err := service.NextStreamID() | ||
M internal/usecase/client/transition_ui.go +1 -1
| @@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴' | |||
| 12 | 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | |
| 13 | 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | |
| 14 | 14 | var out bytes.Buffer | |
| 15 | - | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil { | |
| 15 | + | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter, domain.NoticeInfo); err != nil { | |
| 16 | 16 | return nil | |
| 17 | 17 | } | |
| 18 | 18 | return out.Bytes() | |
| @@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴' | |||||
| 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | 12 | // RenderTransitionNotice draws one connecting frame for the terminal owner. | ||
| 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | 13 | func RenderTransitionNotice(size domain.Size, frame int, message string) []byte { | ||
| 14 | var out bytes.Buffer | 14 | var out bytes.Buffer | ||
| 15 | - | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil { | 15 | + | if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter, domain.NoticeInfo); err != nil { |
| 16 | return nil | 16 | return nil | ||
| 17 | } | 17 | } | ||
| 18 | return out.Bytes() | 18 | return out.Bytes() | ||
M internal/usecase/picker/model.go +33 -23
| @@ -553,23 +553,27 @@ func (s SortMode) Title() string { | |||
| 553 | 553 | return " Sessions · recent " | |
| 554 | 554 | } | |
| 555 | 555 | ||
| 556 | - | // statusDot is the one-cell state marker. Every state uses the same dot and | |
| 557 | - | // a fixed xterm-256 color, so it reads the same in any terminal and theme: | |
| 558 | - | // green up, gray stopped, red down or error, yellow stale, magenta version | |
| 559 | - | // mismatch, blue no daemon. | |
| 556 | + | // statusDot is the one-cell problem marker. It is drawn only when something | |
| 557 | + | // is wrong, in a fixed xterm-256 color so it reads the same in any terminal | |
| 558 | + | // and theme: red down or error, yellow stale, magenta version mismatch, blue | |
| 559 | + | // no daemon. Healthy and stopped rows carry no dot: a stopped row is already | |
| 560 | + | // muted, and the toast explains each failure in words. | |
| 560 | 561 | const statusDot = "●" | |
| 561 | 562 | ||
| 562 | - | // lineStatusColor is the xterm-256 index for a status, or -1 for none. | |
| 563 | + | // Fixed xterm-256 problem colors, shared with client toast borders so one | |
| 564 | + | // color always means one thing. | |
| 565 | + | const ( | |
| 566 | + | ColorProblemError = 167 // red | |
| 567 | + | ColorProblemWarn = 179 // yellow | |
| 568 | + | ) | |
| 569 | + | ||
| 570 | + | // lineStatusColor is the xterm-256 index for a problem status, or -1 for none. | |
| 563 | 571 | func lineStatusColor(status protocol.PickerLineStatus) int { | |
| 564 | 572 | switch status { | |
| 565 | - | case protocol.PickerLineStatusUp: | |
| 566 | - | return 71 // green | |
| 567 | - | case protocol.PickerLineStatusStopped: | |
| 568 | - | return 244 // gray | |
| 569 | 573 | case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: | |
| 570 | - | return 167 // red | |
| 574 | + | return ColorProblemError | |
| 571 | 575 | case protocol.PickerLineStatusStale: | |
| 572 | - | return 179 // yellow | |
| 576 | + | return ColorProblemWarn | |
| 573 | 577 | case protocol.PickerLineStatusVersion: | |
| 574 | 578 | return 170 // magenta | |
| 575 | 579 | case protocol.PickerLineStatusNoDaemon: | |
| @@ -579,6 +583,19 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||
| 579 | 583 | } | |
| 580 | 584 | } | |
| 581 | 585 | ||
| 586 | + | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | |
| 587 | + | // its own color even on the inverse selected row. | |
| 588 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | |
| 589 | + | if badge == "" { | |
| 590 | + | return | |
| 591 | + | } | |
| 592 | + | style := base | |
| 593 | + | style.Inverse = false | |
| 594 | + | style.Foreground = lineStatusColor(status) | |
| 595 | + | style.HasForegroundRGB = false | |
| 596 | + | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | |
| 597 | + | } | |
| 598 | + | ||
| 582 | 599 | func lineStatusBadge(status protocol.PickerLineStatus) string { | |
| 583 | 600 | if lineStatusColor(status) < 0 { | |
| 584 | 601 | return "" | |
| @@ -589,8 +606,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string { | |||
| 589 | 606 | // renderList draws each visible row: a dimmed tree prefix, the name (bold on | |
| 590 | 607 | // unselected live session and host headers), the attention marker, then for | |
| 591 | 608 | // headers a right-aligned muted detail (tab count, "*" when attached) and a | |
| 592 | - | // status dot in a fixed xterm-256 color. A tight width truncates the detail | |
| 593 | - | // before the name. | |
| 609 | + | // problem dot in a fixed xterm-256 color. Section headers carry their host's | |
| 610 | + | // problem dot. A tight width truncates the detail before the name. | |
| 594 | 611 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | |
| 595 | 612 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 596 | 613 | return | |
| @@ -644,7 +661,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 644 | 661 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle) | |
| 645 | 662 | } | |
| 646 | 663 | badge := "" | |
| 647 | - | if r.rendersAsHeader() { | |
| 664 | + | if r.rendersAsHeader() || r.section() { | |
| 648 | 665 | badge = lineStatusBadge(r.line.Status) | |
| 649 | 666 | } | |
| 650 | 667 | contentClipX := clipX | |
| @@ -664,6 +681,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 664 | 681 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | |
| 665 | 682 | ||
| 666 | 683 | if r.section() { | |
| 684 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 667 | 685 | continue | |
| 668 | 686 | } | |
| 669 | 687 | if r.rendersAsHeader() { | |
| @@ -679,15 +697,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 679 | 697 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 680 | 698 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 681 | 699 | } | |
| 682 | - | if badge != "" { | |
| 683 | - | badgeX := max(rect.X, clipX-badgeWidth) | |
| 684 | - | badgeStyle := base | |
| 685 | - | // The dot keeps its own color even on the inverse selected row. | |
| 686 | - | badgeStyle.Inverse = false | |
| 687 | - | badgeStyle.Foreground = lineStatusColor(r.line.Status) | |
| 688 | - | badgeStyle.HasForegroundRGB = false | |
| 689 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle) | |
| 690 | - | } | |
| 700 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 691 | 701 | continue | |
| 692 | 702 | } | |
| 693 | 703 | ||
| @@ -553,23 +553,27 @@ func (s SortMode) Title() string { | |||||
| 553 | return " Sessions · recent " | 553 | return " Sessions · recent " | ||
| 554 | } | 554 | } | ||
| 555 | 555 | ||||
| 556 | - | // statusDot is the one-cell state marker. Every state uses the same dot and | 556 | + | // statusDot is the one-cell problem marker. It is drawn only when something |
| 557 | - | // a fixed xterm-256 color, so it reads the same in any terminal and theme: | 557 | + | // is wrong, in a fixed xterm-256 color so it reads the same in any terminal |
| 558 | - | // green up, gray stopped, red down or error, yellow stale, magenta version | 558 | + | // and theme: red down or error, yellow stale, magenta version mismatch, blue |
| 559 | - | // mismatch, blue no daemon. | 559 | + | // no daemon. Healthy and stopped rows carry no dot: a stopped row is already |
| 560 | + | // muted, and the toast explains each failure in words. | |||
| 560 | const statusDot = "●" | 561 | const statusDot = "●" | ||
| 561 | 562 | ||||
| 562 | - | // lineStatusColor is the xterm-256 index for a status, or -1 for none. | 563 | + | // Fixed xterm-256 problem colors, shared with client toast borders so one |
| 564 | + | // color always means one thing. | |||
| 565 | + | const ( | |||
| 566 | + | ColorProblemError = 167 // red | |||
| 567 | + | ColorProblemWarn = 179 // yellow | |||
| 568 | + | ) | |||
| 569 | + | ||||
| 570 | + | // lineStatusColor is the xterm-256 index for a problem status, or -1 for none. | |||
| 563 | func lineStatusColor(status protocol.PickerLineStatus) int { | 571 | func lineStatusColor(status protocol.PickerLineStatus) int { | ||
| 564 | switch status { | 572 | switch status { | ||
| 565 | - | case protocol.PickerLineStatusUp: | |||
| 566 | - | return 71 // green | |||
| 567 | - | case protocol.PickerLineStatusStopped: | |||
| 568 | - | return 244 // gray | |||
| 569 | case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: | 573 | case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: | ||
| 570 | - | return 167 // red | 574 | + | return ColorProblemError |
| 571 | case protocol.PickerLineStatusStale: | 575 | case protocol.PickerLineStatusStale: | ||
| 572 | - | return 179 // yellow | 576 | + | return ColorProblemWarn |
| 573 | case protocol.PickerLineStatusVersion: | 577 | case protocol.PickerLineStatusVersion: | ||
| 574 | return 170 // magenta | 578 | return 170 // magenta | ||
| 575 | case protocol.PickerLineStatusNoDaemon: | 579 | case protocol.PickerLineStatusNoDaemon: | ||
| @@ -579,6 +583,19 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||||
| 579 | } | 583 | } | ||
| 580 | } | 584 | } | ||
| 581 | 585 | ||||
| 586 | + | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | |||
| 587 | + | // its own color even on the inverse selected row. | |||
| 588 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | |||
| 589 | + | if badge == "" { | |||
| 590 | + | return | |||
| 591 | + | } | |||
| 592 | + | style := base | |||
| 593 | + | style.Inverse = false | |||
| 594 | + | style.Foreground = lineStatusColor(status) | |||
| 595 | + | style.HasForegroundRGB = false | |||
| 596 | + | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | |||
| 597 | + | } | |||
| 598 | + | ||||
| 582 | func lineStatusBadge(status protocol.PickerLineStatus) string { | 599 | func lineStatusBadge(status protocol.PickerLineStatus) string { | ||
| 583 | if lineStatusColor(status) < 0 { | 600 | if lineStatusColor(status) < 0 { | ||
| 584 | return "" | 601 | return "" | ||
| @@ -589,8 +606,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string { | |||||
| 589 | // renderList draws each visible row: a dimmed tree prefix, the name (bold on | 606 | // renderList draws each visible row: a dimmed tree prefix, the name (bold on | ||
| 590 | // unselected live session and host headers), the attention marker, then for | 607 | // unselected live session and host headers), the attention marker, then for | ||
| 591 | // headers a right-aligned muted detail (tab count, "*" when attached) and a | 608 | // headers a right-aligned muted detail (tab count, "*" when attached) and a | ||
| 592 | - | // status dot in a fixed xterm-256 color. A tight width truncates the detail | 609 | + | // problem dot in a fixed xterm-256 color. Section headers carry their host's |
| 593 | - | // before the name. | 610 | + | // problem dot. A tight width truncates the detail before the name. |
| 594 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | 611 | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) { | ||
| 595 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 612 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 596 | return | 613 | return | ||
| @@ -644,7 +661,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 644 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle) | 661 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle) | ||
| 645 | } | 662 | } | ||
| 646 | badge := "" | 663 | badge := "" | ||
| 647 | - | if r.rendersAsHeader() { | 664 | + | if r.rendersAsHeader() || r.section() { |
| 648 | badge = lineStatusBadge(r.line.Status) | 665 | badge = lineStatusBadge(r.line.Status) | ||
| 649 | } | 666 | } | ||
| 650 | contentClipX := clipX | 667 | contentClipX := clipX | ||
| @@ -664,6 +681,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 664 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | 681 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | ||
| 665 | 682 | ||||
| 666 | if r.section() { | 683 | if r.section() { | ||
| 684 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |||
| 667 | continue | 685 | continue | ||
| 668 | } | 686 | } | ||
| 669 | if r.rendersAsHeader() { | 687 | if r.rendersAsHeader() { | ||
| @@ -679,15 +697,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 679 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | 697 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | ||
| 680 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | 698 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | ||
| 681 | } | 699 | } | ||
| 682 | - | if badge != "" { | 700 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) |
| 683 | - | badgeX := max(rect.X, clipX-badgeWidth) | |||
| 684 | - | badgeStyle := base | |||
| 685 | - | // The dot keeps its own color even on the inverse selected row. | |||
| 686 | - | badgeStyle.Inverse = false | |||
| 687 | - | badgeStyle.Foreground = lineStatusColor(r.line.Status) | |||
| 688 | - | badgeStyle.HasForegroundRGB = false | |||
| 689 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle) | |||
| 690 | - | } | |||
| 691 | continue | 701 | continue | ||
| 692 | } | 702 | } | ||
| 693 | 703 | ||||
M internal/usecase/picker/model_test.go +12 -4
| @@ -284,8 +284,13 @@ func TestChooseGeometryReservesTheStatusRow(t *testing.T) { | |||
| 284 | 284 | require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status) | |
| 285 | 285 | } | |
| 286 | 286 | ||
| 287 | - | func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |
| 287 | + | func TestRenderDrawsProblemDotsOnly(t *testing.T) { | |
| 288 | 288 | m := New([]protocol.PickerLine{ | |
| 289 | + | {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown}, | |
| 290 | + | { | |
| 291 | + | Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 292 | + | Status: protocol.PickerLineStatusError, Detail: "2", | |
| 293 | + | }, | |
| 289 | 294 | { | |
| 290 | 295 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 291 | 296 | Status: protocol.PickerLineStatusUp, Detail: "2", | |
| @@ -299,14 +304,17 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||
| 299 | 304 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | |
| 300 | 305 | require.Equal(t, 60, frame.Width) | |
| 301 | 306 | require.Equal(t, 8, frame.Height) | |
| 302 | - | for row, want := range map[int]int{0: 71, 1: 244} { | |
| 307 | + | for row, want := range map[int]int{0: 167, 1: 167} { | |
| 303 | 308 | text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) | |
| 304 | - | require.Equal(t, '●', text[len(text)-1]) | |
| 309 | + | require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row) | |
| 305 | 310 | dot := frame.At(len(text)-1, row).Style | |
| 306 | 311 | require.Equal(t, want, dot.Foreground, "row %d dot color", row) | |
| 307 | 312 | require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row) | |
| 308 | 313 | } | |
| 309 | - | require.True(t, frame.At(0, 0).Style.Inverse, "row 0 is the selected row") | |
| 314 | + | require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row") | |
| 315 | + | for _, row := range []int{2, 3} { | |
| 316 | + | require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot") | |
| 317 | + | } | |
| 310 | 318 | } | |
| 311 | 319 | ||
| 312 | 320 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |
| @@ -284,8 +284,13 @@ func TestChooseGeometryReservesTheStatusRow(t *testing.T) { | |||||
| 284 | require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status) | 284 | require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status) | ||
| 285 | } | 285 | } | ||
| 286 | 286 | ||||
| 287 | - | func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | 287 | + | func TestRenderDrawsProblemDotsOnly(t *testing.T) { |
| 288 | m := New([]protocol.PickerLine{ | 288 | m := New([]protocol.PickerLine{ | ||
| 289 | + | {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown}, | |||
| 290 | + | { | |||
| 291 | + | Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate, | |||
| 292 | + | Status: protocol.PickerLineStatusError, Detail: "2", | |||
| 293 | + | }, | |||
| 289 | { | 294 | { | ||
| 290 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | 295 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | ||
| 291 | Status: protocol.PickerLineStatusUp, Detail: "2", | 296 | Status: protocol.PickerLineStatusUp, Detail: "2", | ||
| @@ -299,14 +304,17 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||||
| 299 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | 304 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | ||
| 300 | require.Equal(t, 60, frame.Width) | 305 | require.Equal(t, 60, frame.Width) | ||
| 301 | require.Equal(t, 8, frame.Height) | 306 | require.Equal(t, 8, frame.Height) | ||
| 302 | - | for row, want := range map[int]int{0: 71, 1: 244} { | 307 | + | for row, want := range map[int]int{0: 167, 1: 167} { |
| 303 | text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) | 308 | text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) | ||
| 304 | - | require.Equal(t, '●', text[len(text)-1]) | 309 | + | require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row) |
| 305 | dot := frame.At(len(text)-1, row).Style | 310 | dot := frame.At(len(text)-1, row).Style | ||
| 306 | require.Equal(t, want, dot.Foreground, "row %d dot color", row) | 311 | require.Equal(t, want, dot.Foreground, "row %d dot color", row) | ||
| 307 | require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row) | 312 | require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row) | ||
| 308 | } | 313 | } | ||
| 309 | - | require.True(t, frame.At(0, 0).Style.Inverse, "row 0 is the selected row") | 314 | + | require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row") |
| 315 | + | for _, row := range []int{2, 3} { | |||
| 316 | + | require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot") | |||
| 317 | + | } | |||
| 310 | } | 318 | } | ||
| 311 | 319 | ||||
| 312 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | 320 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | ||
M internal/usecase/ui/toast.go +5 -2
| @@ -15,8 +15,11 @@ const ( | |||
| 15 | 15 | ||
| 16 | 16 | // Toast describes a transient message rendered over a frame. | |
| 17 | 17 | type Toast struct { | |
| 18 | - | ID string | |
| 19 | - | Message string | |
| 18 | + | ID string | |
| 19 | + | Message string | |
| 20 | + | // Severity is carried for callers that style by it (client toast | |
| 21 | + | // borders); CompositeToasts does not read it. | |
| 22 | + | Severity domain.NoticeSeverity | |
| 20 | 23 | Anchor domain.Anchor | |
| 21 | 24 | Duration time.Duration | |
| 22 | 25 | DimBackground bool | |
| @@ -15,8 +15,11 @@ const ( | |||||
| 15 | 15 | ||||
| 16 | // Toast describes a transient message rendered over a frame. | 16 | // Toast describes a transient message rendered over a frame. | ||
| 17 | type Toast struct { | 17 | type Toast struct { | ||
| 18 | - | ID string | 18 | + | ID string |
| 19 | - | Message string | 19 | + | Message string |
| 20 | + | // Severity is carried for callers that style by it (client toast | |||
| 21 | + | // borders); CompositeToasts does not read it. | |||
| 22 | + | Severity domain.NoticeSeverity | |||
| 20 | Anchor domain.Anchor | 23 | Anchor domain.Anchor | ||
| 21 | Duration time.Duration | 24 | Duration time.Duration | ||
| 22 | DimBackground bool | 25 | DimBackground bool | ||