1package client
2
3import (
4 "fmt"
5 "sort"
6 "strconv"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/protocol/catalogue"
12)
13
14// Picker projections, ported from the daemon-built picker
15// on main (picker.go, picker_lines.go).
16//
17// The catalogue publishes two projections of the same rows, keys, and
18// actions:
19//
20// - Recent is flat: live local sessions by recency (LastUsedSeq), then each
21// remote host's sessions in catalogue order, then stopped local sessions
22// by recency. Remote rows carry their origin in the detail because no
23// section names it.
24// - Grouped is sectioned by daemon: the local section (live by recency,
25// then stopped), then one section per remote host.
26//
27// A session with tabs is a non-focusable header; its tab rows are the
28// destinations, each resolving to its exact tab. A session without tab
29// metadata is its own destination. A host status row is published only when
30// it adds information: the host lists no session and is either not reachable
31// or known to have no daemon.
32//
33// The cursor starts on the attachment the picker is presented over (its tab,
34// else its session), otherwise on the most recent session's active tab.
35
36// pickerCurrent names the attachment the picker is presented over. Only its
37// presence changes the cursor hint; it is never a selection authority.
38type pickerCurrent struct {
39 known bool
40 local bool
41 endpoint string
42 lifecycle domain.SessionLifecycleID
43 tab domain.TabStableID
44}
45
46// pickerRow is one projected line plus the facts its cursor hint needs.
47type pickerRow struct {
48 line protocol.PickerLine
49 ref pickerSelectionRef
50 hasRef bool
51 defaultCursor bool
52}
53
54// pickerSessionBlock is one session header and its tab rows.
55type pickerSessionBlock struct {
56 rows []pickerRow
57 lastUsedSeq uint64
58 name string
59 lifecycle domain.SessionLifecycleID
60}
61
62// projectLocked rebuilds both projections and their opaque keys from the
63// current host state. It is deterministic: the same inputs always produce the
64// same keys, order, and actions.
65func (c *pickerCatalogue) projectLocked() {
66 now := c.clock.Now()
67 refs := make(map[string]pickerSelectionRef)
68 var localLive, localStopped []pickerSessionBlock
69 localOrigin := ""
70 localStatus := protocol.PickerLineStatusNone
71 hasLocal := false
72 type remoteGroup struct {
73 origin string
74 status protocol.PickerLineStatus
75 rows []pickerRow
76 }
77 var remotes []remoteGroup
78 var localHost []pickerRow
79 for _, key := range c.order {
80 host, ok := c.hosts[key]
81 if !ok {
82 continue
83 }
84 observation := host.observation
85 fresh := c.freshLocked(observation, now)
86 origin := pickerOriginLabel(observation)
87 var hostRows []pickerRow
88 for i := range observation.Sessions {
89 session := observation.Sessions[i]
90 if session.LifecycleID == (domain.SessionLifecycleID{}) {
91 // A session without a lifecycle cannot be addressed exactly and
92 // is never invented into one.
93 continue
94 }
95 block := c.sessionBlockLocked(observation, session, fresh, refs)
96 switch {
97 case !observation.Local:
98 hostRows = append(hostRows, block.rows...)
99 case session.State == catalogue.RemoteCatalogSessionUp:
100 localLive = append(localLive, block)
101 default:
102 localStopped = append(localStopped, block)
103 }
104 }
105 hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable
106 if hasHostRow {
107 hostRef := pickerSelectionRef{
108 kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
109 local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
110 }
111 hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
112 Key: pickerHostRowKey(observation),
113 Kind: protocol.PickerLineHost,
114 Label: origin,
115 Detail: pickerHostDetail(observation),
116 Status: pickerObservationStatus(observation, fresh),
117 StatusDetail: pickerObservationReason(observation, fresh),
118 Dim: true,
119 // A host row is focusable and offers creation only when no daemon
120 // exists. The endpoint registration is revalidated at commit.
121 Focusable: true,
122 Actions: func() protocol.PickerLineActions {
123 if observation.Availability == domain.RemoteAvailabilityNoDaemon {
124 return protocol.PickerCanNavigate
125 }
126 return 0
127 }(),
128 }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
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 }
137 if observation.Local {
138 hasLocal = true
139 localOrigin = origin
140 localStatus = sectionStatus
141 localHost = hostRows
142 continue
143 }
144 if len(hostRows) != 0 {
145 remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows})
146 }
147 }
148 sortPickerBlocks(localLive)
149 sortPickerBlocks(localStopped)
150
151 var recent, grouped []pickerRow
152 localRows := make([]pickerRow, 0)
153 for _, block := range localLive {
154 localRows = append(localRows, block.rows...)
155 }
156 localRows = append(localRows, localHost...)
157 for _, row := range localHost {
158 if row.hasRef {
159 refs[row.line.Key] = row.ref
160 }
161 }
162 recent = append(recent, localRows...)
163 for _, remote := range remotes {
164 for _, row := range remote.rows {
165 if row.line.Kind == protocol.PickerLineHost && row.hasRef {
166 refs[row.line.Key] = row.ref
167 }
168 if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
169 row.line.Detail = "@" + remote.origin
170 } else if row.line.Kind == protocol.PickerLineSession {
171 row.line.Detail = "@" + remote.origin + " · " + row.line.Detail
172 }
173 recent = append(recent, row)
174 }
175 }
176 stoppedRows := make([]pickerRow, 0)
177 for _, block := range localStopped {
178 stoppedRows = append(stoppedRows, block.rows...)
179 }
180 recent = append(recent, stoppedRows...)
181
182 localRows = append(localRows, stoppedRows...)
183 if hasLocal && len(localRows) != 0 {
184 grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}})
185 grouped = append(grouped, localRows...)
186 }
187 for _, remote := range remotes {
188 grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}})
189 grouped = append(grouped, remote.rows...)
190 }
191
192 c.moveRetiredLocked(refs)
193 c.refs = refs
194 c.lines, c.cursor = c.pickerProjectionLocked(grouped)
195 c.recent, c.recentCursor = c.pickerProjectionLocked(recent)
196}
197
198// sessionBlockLocked projects one session into its header and tab rows.
199func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock {
200 compatible := pickerObservationCompatible(observation)
201 available := observation.Availability == domain.RemoteAvailabilityReachable
202 eligible := compatible && available && fresh
203 // Admission is deliberately more permissive than eligibility: a stale or
204 // not-yet-observed daemon is still selectable, because only the
205 // destination revalidates the exact identity and only it may start a
206 // stopped target under the resolved policy. A known incompatibility and a
207 // broken session refuse immediately; a remote host known to be failing
208 // refuses at resolution with a notice instead of a slow attempt.
209 admissible := !pickerObservationKnownIncompatible(observation)
210 selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
211 stopped := session.State == catalogue.RemoteCatalogSessionDown
212 reason := pickerObservationReason(observation, fresh)
213 if reason == "" {
214 reason = session.Reason
215 }
216 ref := pickerSelectionRef{
217 kind: pickerSelectionExact,
218 epoch: c.snapshot.Epoch,
219 local: observation.Local,
220 endpoint: observation.Endpoint,
221 registration: observation.Registration,
222 lifecycle: session.LifecycleID,
223 name: session.Name,
224 }
225 actions := pickerRowActions(selectable, observation.Local)
226 attention := false
227 for _, tab := range session.Tabs {
228 attention = attention || tab.Attention
229 }
230 headerKey := pickerSessionRowKey(ref)
231 refs[headerKey] = ref
232 header := pickerRow{
233 line: protocol.PickerLine{
234 Key: headerKey,
235 Kind: protocol.PickerLineSession,
236 Label: session.Name,
237 Detail: pickerSessionDetail(session),
238 Status: pickerSessionStatus(session),
239 StatusDetail: reason,
240 Attention: attention,
241 Stopped: stopped,
242 // Dim keeps the informational eligibility signal (fresh,
243 // compatible, reachable) and the hard non-committable state.
244 Dim: !eligible || !selectable,
245 Ephemeral: session.Ephemeral,
246 },
247 ref: ref,
248 hasRef: true,
249 }
250 block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID}
251 if len(session.Tabs) == 0 {
252 // Without tab metadata the session is its own destination.
253 header.line.Focusable = true
254 header.line.Actions = actions
255 header.defaultCursor = true
256 block.rows = append(block.rows, header)
257 return block
258 }
259 // A session with tabs names a group: its tab rows are the destinations.
260 block.rows = append(block.rows, header)
261 active := pickerActiveTabIndex(session)
262 for i, tab := range session.Tabs {
263 tabRef := ref
264 tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)}
265 key := pickerTabRowKey(tabRef)
266 refs[key] = tabRef
267 tabActions := actions
268 if !stopped && tab.ID == "" {
269 // A live tab without a stable identity cannot be targeted exactly.
270 tabActions &^= protocol.PickerCanNavigate
271 }
272 label := tab.Name
273 if label == "" {
274 label = strconv.Itoa(int(tab.Index) + 1)
275 }
276 block.rows = append(block.rows, pickerRow{
277 line: protocol.PickerLine{
278 Key: key,
279 Kind: protocol.PickerLineTab,
280 Label: label,
281 Detail: tab.Detail,
282 StatusDetail: reason,
283 Attention: tab.Attention,
284 Stopped: stopped,
285 Dim: header.line.Dim,
286 Focusable: true,
287 Actions: tabActions,
288 Ephemeral: session.Ephemeral,
289 },
290 ref: tabRef,
291 hasRef: true,
292 defaultCursor: i == active,
293 })
294 }
295 return block
296}
297
298// pickerRowActions maps eligibility to the actions a row authorises. Kill is
299// offered on local rows only, exactly as on main; the daemon revalidates the
300// name, and a stopped row deletes its history record.
301func pickerRowActions(selectable, local bool) protocol.PickerLineActions {
302 var actions protocol.PickerLineActions
303 if selectable {
304 actions |= protocol.PickerCanNavigate
305 }
306 if local {
307 actions |= protocol.PickerCanKill
308 }
309 return actions
310}
311
312// pickerActiveTabIndex is the session's active tab, or its first tab.
313func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int {
314 for i, tab := range session.Tabs {
315 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
316 return i
317 }
318 }
319 return 0
320}
321
322// sortPickerBlocks orders sessions by recency, then name, then lifecycle.
323func sortPickerBlocks(blocks []pickerSessionBlock) {
324 sort.SliceStable(blocks, func(i, j int) bool {
325 if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq {
326 return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq
327 }
328 if blocks[i].name != blocks[j].name {
329 return blocks[i].name < blocks[j].name
330 }
331 return blocks[i].lifecycle.String() < blocks[j].lifecycle.String()
332 })
333}
334
335// pickerProjectionLocked flattens one projection and computes its cursor:
336// the current attachment's tab, else its session, else the first default row
337// (the most recent session's active tab), else the first actionable row.
338func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) {
339 lines := make([]protocol.PickerLine, len(rows))
340 for i := range rows {
341 lines[i] = rows[i].line
342 }
343 if cursor, ok := c.currentCursorLocked(rows); ok {
344 return lines, cursor
345 }
346 for i, row := range rows {
347 if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 {
348 return lines, protocol.PickerCursor{Key: row.line.Key, Index: i}
349 }
350 }
351 return lines, pickerCatalogueCursor(lines)
352}
353
354func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) {
355 current := c.current
356 if !current.known {
357 return protocol.PickerCursor{}, false
358 }
359 matches := func(ref pickerSelectionRef) bool {
360 if ref.lifecycle != current.lifecycle || ref.local != current.local {
361 return false
362 }
363 return ref.local || ref.endpoint == current.endpoint
364 }
365 // Exact tab first, then the session's own row (no tab metadata), then its
366 // active tab.
367 session, active := -1, -1
368 for i, row := range rows {
369 if !row.hasRef || !row.line.Focusable || !matches(row.ref) {
370 continue
371 }
372 if !row.ref.tab.present {
373 session = i
374 continue
375 }
376 if current.tab != "" && row.ref.tab.id == current.tab {
377 return protocol.PickerCursor{Key: row.line.Key, Index: i}, true
378 }
379 if row.defaultCursor && active < 0 {
380 active = i
381 }
382 }
383 for _, index := range []int{session, active} {
384 if index >= 0 {
385 return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true
386 }
387 }
388 return protocol.PickerCursor{}, false
389}
390
391// pickerTabRowKey is the stable, opaque key of one tab row: the session
392// identity plus the tab's stable ID, or its ordinal and raw name when the
393// catalogue carries no ID.
394func pickerTabRowKey(ref pickerSelectionRef) string {
395 tab := string(ref.tab.id)
396 if tab == "" {
397 tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name)
398 }
399 lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0)
400 lifecycle = append(lifecycle, tab...)
401 return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle)
402}
403
404// attachmentTab is the client-owned tab selection an attachment Hello
405// carries: a stable tab to prefer on a live or stopped-local session, or an
406// exact stopped-remote selector. Its zero value attaches at session level.
407type attachmentTab struct {
408 preferred domain.TabStableID
409 stopped *protocol.SessionAttachTarget
410}
411
412// pickerResolveTab turns a tab ref into the attachment tab it names, checked
413// against the latest session metadata. A tab that left the session is gone.
414func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) {
415 if !ref.tab.present {
416 return attachmentTab{}, nil
417 }
418 gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"}
419 stopped := session.State == catalogue.RemoteCatalogSessionDown
420 if ref.tab.id != "" {
421 found := false
422 for _, tab := range session.Tabs {
423 if domain.TabStableID(tab.ID) == ref.tab.id {
424 found = true
425 break
426 }
427 }
428 if !found {
429 return attachmentTab{}, gone
430 }
431 if stopped && !ref.local {
432 target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true}
433 if target.Validate() != nil {
434 return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
435 }
436 return attachmentTab{stopped: &target}, nil
437 }
438 return attachmentTab{preferred: ref.tab.id}, nil
439 }
440 if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count {
441 return attachmentTab{}, gone
442 }
443 if !stopped {
444 return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"}
445 }
446 if ref.local || ref.tab.count > int(^uint16(0)) {
447 // A local restore without a stable tab falls back to the session.
448 return attachmentTab{}, nil
449 }
450 target := protocol.SessionAttachTarget{
451 LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true,
452 TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count),
453 }
454 if target.Validate() != nil {
455 return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
456 }
457 return attachmentTab{stopped: &target}, nil
458}
459
460// pickerObservationFailing reports a host the broker observed failing: its
461// last probe could not reach it, authenticate, or read its catalogue. An
462// incompatible host is reachable and handled apart.
463func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
464 return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible
465}
466
467// pickerUnavailableNotice is main's remote-unavailable notice: the session
468// and its origin, then the reason in words.
469func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string {
470 message := "Remote session unavailable"
471 origin := pickerOriginLabel(observation)
472 if ref.name != "" {
473 message += ": " + ref.name + "@" + origin
474 } else {
475 message += ": " + origin
476 }
477 return message + " — " + pickerReasonText(reason)
478}
479
480// pickerReasonText renders one domain.RemoteReason in words (main's
481// remotePickerReasonText).
482func pickerReasonText(reason string) string {
483 switch reason {
484 case domain.RemoteReasonCatalogStale:
485 return "catalog stale"
486 case domain.RemoteReasonHostUnreachable:
487 return "host unreachable"
488 case domain.RemoteReasonVersionMismatch:
489 return "version mismatch"
490 case domain.RemoteReasonSessionStopped:
491 return "session stopped"
492 case domain.RemoteReasonSessionBroken:
493 return "session broken"
494 case domain.RemoteReasonMalformed:
495 return "catalog malformed"
496 case domain.RemoteReasonAuthFailure:
497 return "authentication failed"
498 case domain.RemoteReasonNoDaemon:
499 return "no daemon — Enter to create a session"
500 case domain.RemoteReasonRefreshing:
501 return "refreshing"
502 case domain.RemoteReasonIdentityChanged:
503 return "session identity changed"
504 default:
505 return "unavailable"
506 }
507}
508
509// pickerKillTarget is one resolved kill: the exact local control route and
510// the session name the daemon kills (live) or deletes from history
511// (stopped).
512type pickerKillTarget struct {
513 route BrokerOperationRoute
514 name string
515 stopped bool
516}
517
518// ResolveKill revalidates one row key for destruction against the latest
519// publication. Only local sessions are killable, exactly as on main; the row
520// must still name the same lifecycle and name.
521func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
522 if c == nil {
523 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
524 }
525 c.mu.Lock()
526 defer c.mu.Unlock()
527 ref, ok := c.refs[key]
528 if !ok {
529 ref, ok = c.retired[key]
530 }
531 if !ok || ref.kind != pickerSelectionExact {
532 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
533 }
534 if !ref.local {
535 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"}
536 }
537 epoch := c.snapshot.Epoch
538 if epoch == 0 {
539 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
540 }
541 if ref.epoch != 0 && ref.epoch != epoch {
542 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
543 }
544 authority := c.authorityForRefLocked(ref)
545 if !authority.found {
546 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
547 }
548 session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
549 if !found {
550 if pickerSessionReplaced(authority.observation.Sessions, ref) {
551 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
552 }
553 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
554 }
555 if session.Name != ref.name {
556 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
557 }
558 if domain.ValidateSessionName(session.Name) != nil {
559 return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
560 }
561 return pickerKillTarget{
562 route: BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}},
563 name: session.Name,
564 stopped: session.State != catalogue.RemoteCatalogSessionUp,
565 }, nil
566}
567
568// pickerHostHealth is one remote host's notice-relevant state.
569type pickerHostHealth struct {
570 key string
571 observation ports.BrokerDaemonObservation
572 health domain.RemoteHealth
573}
574
575// remoteHealth reports every remote host's state, in catalogue order.
576// Transition policy lives in domain.RemoteHealthTransition.
577func (c *pickerCatalogue) remoteHealth() []pickerHostHealth {
578 if c == nil {
579 return nil
580 }
581 c.mu.Lock()
582 defer c.mu.Unlock()
583 hosts := make([]pickerHostHealth, 0, len(c.order))
584 for _, key := range c.order {
585 host, ok := c.hosts[key]
586 if !ok || host.observation.Local {
587 continue
588 }
589 observation := host.observation
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 }})
595 }
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.
601func 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
615}
616
617func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string {
618 if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch {
619 return "remote vev version is incompatible"
620 }
621 switch observation.LastFailure.Kind {
622 case domain.RemoteFailureAuthentication:
623 return "SSH authentication failed; verify non-interactive SSH access"
624 case domain.RemoteFailureTrust:
625 return "SSH host verification failed; verify the host key policy"
626 case domain.RemoteFailureIncompatible:
627 return "remote vev version is incompatible"
628 case domain.RemoteFailureInvalidResponse:
629 return "remote catalog response is invalid"
630 case domain.RemoteFailureTimeout:
631 return "SSH timed out"
632 }
633 switch observation.Availability {
634 case domain.RemoteAvailabilityAuthFailed:
635 return "SSH authentication failed; verify non-interactive SSH access"
636 case domain.RemoteAvailabilityInvalidResponse:
637 return "remote catalog response is invalid"
638 case domain.RemoteAvailabilityNoDaemon:
639 return "no vev daemon is running"
640 default:
641 return "SSH connection failed; verify SSH access"
642 }
643}