Skip to content

Commit 0452a6a

Browse files
committed
Improved search functionality
+ Implemented support for search via "/" across Services, Buckets, Clusters and Details panels, including substrings.
1 parent c7d8f35 commit 0452a6a

File tree

6 files changed

+236
-10
lines changed

6 files changed

+236
-10
lines changed

pkg/tui/app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ func Run(ctx context.Context, conf *config.Config) error {
3939
failedClusters: make(map[string]string),
4040
mode: modeServices,
4141
bucketObjects: make(map[string]*bucketObjectState),
42+
serviceDefinitions: make(map[string]string),
4243
}
4344

4445
state.statusView.SetBorder(false)
4546
state.detailsView.SetBorder(true)
47+
state.detailsView.SetScrollable(true)
4648
state.detailsView.SetTitle("Details")
4749
state.detailsView.SetText("Select a cluster to view details")
4850
state.bucketObjectsTable.SetBorder(true)
@@ -217,6 +219,9 @@ func Run(ctx context.Context, conf *config.Config) error {
217219
state.requestDeletion()
218220
return nil
219221
}
222+
case 'v', 'V':
223+
state.focusDetailsPane()
224+
return nil
220225
case 'l', 'L':
221226
if app.GetFocus() == state.serviceTable {
222227
state.showServiceLogs()
@@ -380,6 +385,12 @@ func (s *uiState) modeIsBuckets() bool {
380385
return mode == modeBuckets
381386
}
382387

388+
func (s *uiState) focusDetailsPane() {
389+
s.queueUpdate(func() {
390+
s.app.SetFocus(s.detailsView)
391+
})
392+
}
393+
383394
func (s *uiState) setStatus(message string) {
384395
s.mutex.Lock()
385396
started := s.started

pkg/tui/buckets_view.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ func (s *uiState) searchBuckets(query string) bool {
235235
if bucket == nil {
236236
continue
237237
}
238-
haystack := strings.ToLower(bucket.Name + " " + bucket.Owner)
238+
haystack := strings.ToLower(bucket.Name + " " + bucket.Owner + " " + bucket.Visibility)
239239
if strings.Contains(haystack, query) {
240240
row := idx + 1
241241
s.queueUpdate(func() {

pkg/tui/helpers_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/gdamore/tcell/v2"
8+
"github.com/grycap/oscar/v3/pkg/types"
89
)
910

1011
func TestDefaultIfEmpty(t *testing.T) {
@@ -56,3 +57,26 @@ func TestBucketVisibilityColor(t *testing.T) {
5657
}
5758
}
5859
}
60+
61+
func TestFormatServiceDefinition(t *testing.T) {
62+
svc := &types.Service{
63+
Name: "demo",
64+
Image: "demo:v1",
65+
Memory: "128Mi",
66+
Replicas: []types.Replica{{Type: "oscar", ServiceName: "demo"}},
67+
}
68+
69+
rendered, err := formatServiceDefinition(svc)
70+
if err != nil {
71+
t.Fatalf("formatServiceDefinition returned error: %v", err)
72+
}
73+
if rendered == "" {
74+
t.Fatal("expected formatted definition")
75+
}
76+
if !strings.Contains(rendered, "[yellow]name") {
77+
t.Fatalf("expected colored key in output, got %q", rendered)
78+
}
79+
if !strings.Contains(rendered, "[green]\"demo\"") {
80+
t.Fatalf("expected colored value in output, got %q", rendered)
81+
}
82+
}

pkg/tui/search.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ func (s *uiState) initiateSearch(ctx context.Context) {
2929
} else {
3030
target = searchTargetServices
3131
}
32+
case s.detailsView:
33+
target = searchTargetDetails
34+
}
35+
36+
if target == searchTargetNone && len(s.clusterNames) > 0 {
37+
target = searchTargetClusters
3238
}
3339

3440
if target == searchTargetNone {
@@ -55,6 +61,13 @@ func (s *uiState) initiateSearch(ctx context.Context) {
5561
s.setStatus("[yellow]No buckets to search")
5662
return
5763
}
64+
case searchTargetDetails:
65+
text := s.detailsView.GetText(true)
66+
if strings.TrimSpace(text) == "" {
67+
s.mutex.Unlock()
68+
s.setStatus("[yellow]Nothing to search in details")
69+
return
70+
}
5871
}
5972
s.mutex.Unlock()
6073

@@ -81,6 +94,8 @@ func (s *uiState) showSearch(target searchTarget) {
8194
label = "Services: "
8295
case searchTargetBuckets:
8396
label = "Buckets: "
97+
case searchTargetDetails:
98+
label = "Details: "
8499
}
85100

86101
input := tview.NewInputField().
@@ -153,6 +168,8 @@ func (s *uiState) handleSearchInput(query string) {
153168
found = s.searchServices(lower)
154169
case searchTargetBuckets:
155170
found = s.searchBuckets(lower)
171+
case searchTargetDetails:
172+
found = s.searchDetails(lower)
156173
}
157174
if !found {
158175
s.setStatus("[yellow]No matches found")
@@ -164,7 +181,16 @@ func (s *uiState) searchClusters(query string) bool {
164181
names := append([]string(nil), s.clusterNames...)
165182
s.mutex.Unlock()
166183
for idx, name := range names {
167-
if strings.Contains(strings.ToLower(name), query) {
184+
haystack := name
185+
if cfg := s.conf.Oscar[name]; cfg != nil {
186+
haystack = strings.Join([]string{
187+
name,
188+
cfg.Endpoint,
189+
cfg.AuthUser,
190+
cfg.OIDCAccountName,
191+
}, " ")
192+
}
193+
if containsQuery(haystack, query) {
168194
s.queueUpdate(func() {
169195
s.clusterList.SetCurrentItem(idx)
170196
})
@@ -173,3 +199,22 @@ func (s *uiState) searchClusters(query string) bool {
173199
}
174200
return false
175201
}
202+
203+
func (s *uiState) searchDetails(query string) bool {
204+
text := s.detailsView.GetText(true)
205+
lines := strings.Split(text, "\n")
206+
for idx, line := range lines {
207+
if containsQuery(line, query) {
208+
lineNum := idx
209+
s.queueUpdate(func() {
210+
s.detailsView.ScrollTo(lineNum, 0)
211+
})
212+
return true
213+
}
214+
}
215+
return false
216+
}
217+
218+
func containsQuery(haystack, query string) bool {
219+
return strings.Contains(strings.ToLower(haystack), query)
220+
}

pkg/tui/services_view.go

Lines changed: 148 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package tui
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
8+
"sort"
79
"strings"
810
"time"
911

@@ -253,9 +255,7 @@ func (s *uiState) handleServiceSelection(row int, immediate bool) {
253255
}
254256

255257
if immediate {
256-
s.queueUpdate(func() {
257-
s.detailsView.SetText(formatServiceDetails(&svc))
258-
})
258+
s.showServiceDefinition(s.currentCluster, svc.Name)
259259
return
260260
}
261261

@@ -267,9 +267,7 @@ func (s *uiState) handleServiceSelection(row int, immediate bool) {
267267
}
268268
s.detailTimer = nil
269269
s.mutex.Unlock()
270-
s.queueUpdate(func() {
271-
s.detailsView.SetText(formatServiceDetails(&svc))
272-
})
270+
s.showServiceDefinition(s.currentCluster, svc.Name)
273271
})
274272

275273
s.mutex.Lock()
@@ -423,7 +421,8 @@ func (s *uiState) searchServices(query string) bool {
423421
if svc == nil {
424422
continue
425423
}
426-
if strings.Contains(strings.ToLower(svc.Name), query) {
424+
fields := []string{svc.Name, svc.Image, svc.CPU, svc.Memory}
425+
if containsQuery(strings.Join(fields, " "), query) {
427426
row := idx + 1
428427
s.queueUpdate(func() {
429428
s.serviceTable.Select(row, 0)
@@ -434,3 +433,145 @@ func (s *uiState) searchServices(query string) bool {
434433
}
435434
return false
436435
}
436+
437+
func (s *uiState) showServiceDefinition(clusterName, serviceName string) {
438+
clusterName = strings.TrimSpace(clusterName)
439+
serviceName = strings.TrimSpace(serviceName)
440+
if clusterName == "" || serviceName == "" {
441+
s.setServiceDetailsText("Select a service to inspect details")
442+
return
443+
}
444+
445+
key := makeServiceDefinitionKey(clusterName, serviceName)
446+
s.mutex.Lock()
447+
cached := s.serviceDefinitions[key]
448+
s.serviceDefinitionSeq++
449+
seq := s.serviceDefinitionSeq
450+
s.currentServiceDefinition = key
451+
s.mutex.Unlock()
452+
453+
if cached != "" {
454+
s.queueUpdate(func() {
455+
s.detailsView.SetText(cached)
456+
})
457+
return
458+
}
459+
460+
loadingText := fmt.Sprintf("Loading definition for %s…", serviceName)
461+
s.queueUpdate(func() {
462+
s.detailsView.SetText(loadingText)
463+
})
464+
s.setStatus(fmt.Sprintf("[yellow]Loading definition for %q…", serviceName))
465+
466+
go s.fetchServiceDefinition(clusterName, serviceName, key, seq)
467+
}
468+
469+
func (s *uiState) fetchServiceDefinition(clusterName, serviceName, key string, seq int) {
470+
clusterCfg := s.conf.Oscar[clusterName]
471+
if clusterCfg == nil {
472+
s.setStatus(fmt.Sprintf("[red]Cluster %q configuration not found", clusterName))
473+
return
474+
}
475+
476+
def, err := service.GetService(clusterCfg, serviceName)
477+
if err != nil {
478+
s.setStatus(fmt.Sprintf("[red]Failed to load definition for %q: %v", serviceName, err))
479+
return
480+
}
481+
482+
rendered, err := formatServiceDefinition(def)
483+
if err != nil {
484+
s.setStatus(fmt.Sprintf("[red]Failed to format definition for %q: %v", serviceName, err))
485+
return
486+
}
487+
488+
s.mutex.Lock()
489+
if seq != s.serviceDefinitionSeq {
490+
s.mutex.Unlock()
491+
return
492+
}
493+
s.serviceDefinitions[key] = rendered
494+
active := s.currentServiceDefinition
495+
s.mutex.Unlock()
496+
497+
if active == key {
498+
s.queueUpdate(func() {
499+
s.detailsView.SetText(rendered)
500+
})
501+
s.setStatus(fmt.Sprintf("[green]Loaded definition for %q", serviceName))
502+
}
503+
}
504+
505+
func makeServiceDefinitionKey(clusterName, serviceName string) string {
506+
return fmt.Sprintf("%s\x00%s", clusterName, serviceName)
507+
}
508+
509+
func formatServiceDefinition(svc *types.Service) (string, error) {
510+
if svc == nil {
511+
return "", nil
512+
}
513+
514+
data, err := json.Marshal(svc)
515+
if err != nil {
516+
return "", err
517+
}
518+
var val interface{}
519+
if err := json.Unmarshal(data, &val); err != nil {
520+
return "", err
521+
}
522+
builder := &strings.Builder{}
523+
colorizeJSON(builder, val, 0)
524+
return builder.String(), nil
525+
}
526+
527+
func colorizeJSON(builder *strings.Builder, val interface{}, level int) {
528+
indent := strings.Repeat(" ", level)
529+
switch v := val.(type) {
530+
case map[string]interface{}:
531+
keys := make([]string, 0, len(v))
532+
for k := range v {
533+
keys = append(keys, k)
534+
}
535+
sort.Strings(keys)
536+
builder.WriteString("{\n")
537+
for i, k := range keys {
538+
builder.WriteString(indent + " ")
539+
builder.WriteString("[yellow]")
540+
builder.WriteString(tview.Escape(k))
541+
builder.WriteString("[-]: ")
542+
colorizeJSON(builder, v[k], level+1)
543+
if i < len(keys)-1 {
544+
builder.WriteString(",")
545+
}
546+
builder.WriteString("\n")
547+
}
548+
builder.WriteString(indent + "}")
549+
case []interface{}:
550+
builder.WriteString("[\n")
551+
for i, item := range v {
552+
builder.WriteString(indent + " ")
553+
colorizeJSON(builder, item, level+1)
554+
if i < len(v)-1 {
555+
builder.WriteString(",")
556+
}
557+
builder.WriteString("\n")
558+
}
559+
builder.WriteString(indent + "]")
560+
case string:
561+
builder.WriteString("[green]\"")
562+
builder.WriteString(tview.Escape(v))
563+
builder.WriteString("\"[-]")
564+
case float64, int, int64, uint64, float32:
565+
builder.WriteString("[cyan]")
566+
builder.WriteString(fmt.Sprintf("%v", v))
567+
builder.WriteString("[-]")
568+
case bool:
569+
builder.WriteString("[magenta]")
570+
builder.WriteString(fmt.Sprintf("%v", v))
571+
builder.WriteString("[-]")
572+
case nil:
573+
builder.WriteString("[gray]null[-]")
574+
default:
575+
builder.WriteString(tview.Escape(fmt.Sprintf("%v", v)))
576+
}
577+
}

pkg/tui/state.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
const legendText = `[yellow]Navigation[-]
1616
↑/↓ Move selection
1717
←/→ or Tab Switch pane
18+
v Focus details panel
1819
1920
[yellow]Actions[-]
2021
r Refresh current view
@@ -31,7 +32,7 @@ const legendText = `[yellow]Navigation[-]
3132
q Quit
3233
? Toggle this help`
3334

34-
const statusHelpText = "[yellow]Keys: [::b]q[::-] Quit · [::b]r[::-] Refresh · [::b]d[::-] Delete selection · [::b]i[::-] Cluster info · [::b]l[::-] Service logs · [::b]w[::-] Auto refresh · [::b]b[::-] Buckets · [::b]s[::-] Services · [::b]Enter/n/p/a/o[::-] Bucket objects · [::b]?[::-] Help · [::b]←/→[::-] Switch pane · [::b]/[::-] Search"
35+
const statusHelpText = "[yellow]Keys: [::b]q[::-] Quit · [::b]r[::-] Refresh · [::b]d[::-] Delete selection · [::b]i[::-] Cluster info · [::b]l[::-] Service logs · [::b]w[::-] Auto refresh · [::b]b[::-] Buckets · [::b]s[::-] Services · [::b]v[::-] Focus details · [::b]Enter/n/p/a/o[::-] Bucket objects · [::b]?[::-] Help · [::b]←/→[::-] Switch pane · [::b]/[::-] Search"
3536

3637
type panelMode int
3738

@@ -53,6 +54,7 @@ const (
5354
searchTargetClusters
5455
searchTargetServices
5556
searchTargetBuckets
57+
searchTargetDetails
5658
)
5759

5860
type uiState struct {
@@ -98,6 +100,9 @@ type uiState struct {
98100
searchInput *tview.InputField
99101
searchTarget searchTarget
100102
originalFocus tview.Primitive
103+
serviceDefinitions map[string]string
104+
serviceDefinitionSeq int
105+
currentServiceDefinition string
101106
autoRefreshCancel context.CancelFunc
102107
autoRefreshTicker *time.Ticker
103108
autoRefreshPeriod time.Duration

0 commit comments

Comments
 (0)