diff --git a/internal/describer/crd.go b/internal/describer/crd.go index 779c5d47f5..13c35a03d8 100644 --- a/internal/describer/crd.go +++ b/internal/describer/crd.go @@ -8,51 +8,42 @@ package describer import ( "context" "fmt" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "github.com/vmware-tanzu/octant/internal/config" "github.com/vmware-tanzu/octant/internal/gvk" - "github.com/vmware-tanzu/octant/internal/link" - "github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer" "github.com/vmware-tanzu/octant/internal/octant" - "github.com/vmware-tanzu/octant/internal/printer" - "github.com/vmware-tanzu/octant/internal/queryer" - "github.com/vmware-tanzu/octant/internal/resourceviewer" "github.com/vmware-tanzu/octant/pkg/icon" "github.com/vmware-tanzu/octant/pkg/store" "github.com/vmware-tanzu/octant/pkg/view/component" ) -type crdPrinter func(ctx context.Context, crd, object *unstructured.Unstructured, options printer.Options) (component.Component, error) -type metadataPrinter func(runtime.Object, link.Interface) (*component.FlexLayout, error) -type resourceViewerPrinter func(ctx context.Context, object *unstructured.Unstructured, dashConfig config.Dash, q queryer.Queryer) (component.Component, error) -type yamlPrinter func(runtime.Object) (*component.Editor, error) +func defaultCustomResourceTabs(crdName string) []Tab { + return []Tab{ + {Name: "Summary", Factory: CustomResourceSummaryTab(crdName)}, + {Name: "Metadata", Factory: MetadataTab}, + {Name: "Resource Viewer", Factory: ResourceViewerTab}, + {Name: "YAML", Factory: YAMLViewerTab}, + } +} type crdOption func(*crd) type crd struct { base - path string - name string - summaryPrinter crdPrinter - metadataPrinter metadataPrinter - resourceViewerPrinter resourceViewerPrinter - yamlPrinter yamlPrinter + path string + name string + tabsGenerator TabsGenerator + tabFuncDescriptors []Tab } var _ Describer = (*crd)(nil) func newCRD(name, path string, options ...crdOption) *crd { d := &crd{ - path: path, - name: name, - summaryPrinter: printer.CustomResourceHandler, - metadataPrinter: printer.MetadataHandler, - resourceViewerPrinter: createCRDResourceViewer, - yamlPrinter: yamlviewer.ToComponent, + path: path, + name: name, + tabsGenerator: NewObjectTabsGenerator(), + tabFuncDescriptors: defaultCustomResourceTabs(name), } for _, option := range options { @@ -110,57 +101,18 @@ func (c *crd) Describe(ctx context.Context, namespace string, options Options) ( cr.IconName = iconName cr.IconSource = iconSource - linkGenerator, err := link.NewFromDashConfig(options) - if err != nil { - return component.EmptyContentResponse, err - } - - printOptions := printer.Options{ - DashConfig: options, - Link: linkGenerator, - } - - summary, err := c.summaryPrinter(ctx, crd, object, printOptions) - if err != nil { - return component.EmptyContentResponse, err - } - summary.SetAccessor("summary") - - cr.Add(summary) - - metadata, err := c.metadataPrinter(object, linkGenerator) - if err != nil { - return component.EmptyContentResponse, err - } - metadata.SetAccessor("metadata") - cr.Add(metadata) - - resourceViewerComponent, err := c.resourceViewerPrinter(ctx, object, options, options.Queryer) - if err != nil { - return component.EmptyContentResponse, err - } - - resourceViewerComponent.SetAccessor("resourceViewer") - cr.Add(resourceViewerComponent) - - yvComponent, err := c.yamlPrinter(object) - if err != nil { - return component.EmptyContentResponse, err + generatorConfig := TabsGeneratorConfig{ + Object: object, + TabsFactory: objectTabsFactory(ctx, object, c.tabFuncDescriptors, options), + Options: options, } - yvComponent.SetAccessor("yaml") - cr.Add(yvComponent) - - pluginPrinter := options.PluginManager() - tabs, err := pluginPrinter.Tabs(ctx, object) + tabComponents, err := c.tabsGenerator.Generate(ctx, generatorConfig) if err != nil { - return component.EmptyContentResponse, errors.Wrap(err, "getting tabs from plugins") + return component.EmptyContentResponse, fmt.Errorf("generate tabs: %w", err) } - for _, tab := range tabs { - tab.Contents.SetAccessor(tab.Name) - cr.Add(&tab.Contents) - } + cr.Add(tabComponents...) return *cr, nil } @@ -170,21 +122,3 @@ func (c *crd) PathFilters() []PathFilter { *NewPathFilter(c.path, c), } } - -func createCRDResourceViewer(ctx context.Context, object *unstructured.Unstructured, dashConfig config.Dash, q queryer.Queryer) (component.Component, error) { - rv, err := resourceviewer.New(dashConfig, resourceviewer.WithDefaultQueryer(dashConfig, q)) - if err != nil { - return nil, err - } - - handler, err := resourceviewer.NewHandler(dashConfig) - if err != nil { - return nil, err - } - - if err := rv.Visit(ctx, object, handler); err != nil { - return nil, err - } - - return resourceviewer.GenerateComponent(ctx, handler, "") -} diff --git a/internal/describer/describer_test.go b/internal/describer/describer_test.go index ac5deb5f94..df9d4a1231 100644 --- a/internal/describer/describer_test.go +++ b/internal/describer/describer_test.go @@ -59,10 +59,10 @@ func createPodTable(pods ...corev1.Pod) *component.Table { return table } -func podListType() interface{} { +func PodListType() interface{} { return &corev1.PodList{} } -func podObjectType() interface{} { +func PodObjectType() interface{} { return &corev1.Pod{} } diff --git a/internal/describer/fake/mock_tabs_generator.go b/internal/describer/fake/mock_tabs_generator.go new file mode 100644 index 0000000000..fc3024b4eb --- /dev/null +++ b/internal/describer/fake/mock_tabs_generator.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/vmware-tanzu/octant/internal/describer (interfaces: TabsGenerator) + +// Package fake is a generated GoMock package. +package fake + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + describer "github.com/vmware-tanzu/octant/internal/describer" + component "github.com/vmware-tanzu/octant/pkg/view/component" + reflect "reflect" +) + +// MockTabsGenerator is a mock of TabsGenerator interface +type MockTabsGenerator struct { + ctrl *gomock.Controller + recorder *MockTabsGeneratorMockRecorder +} + +// MockTabsGeneratorMockRecorder is the mock recorder for MockTabsGenerator +type MockTabsGeneratorMockRecorder struct { + mock *MockTabsGenerator +} + +// NewMockTabsGenerator creates a new mock instance +func NewMockTabsGenerator(ctrl *gomock.Controller) *MockTabsGenerator { + mock := &MockTabsGenerator{ctrl: ctrl} + mock.recorder = &MockTabsGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockTabsGenerator) EXPECT() *MockTabsGeneratorMockRecorder { + return m.recorder +} + +// Generate mocks base method +func (m *MockTabsGenerator) Generate(arg0 context.Context, arg1 describer.TabsGeneratorConfig) ([]component.Component, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", arg0, arg1) + ret0, _ := ret[0].([]component.Component) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate +func (mr *MockTabsGeneratorMockRecorder) Generate(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockTabsGenerator)(nil).Generate), arg0, arg1) +} diff --git a/internal/describer/list_test.go b/internal/describer/list_test.go index 1cd7f6ff6a..aa407a5378 100644 --- a/internal/describer/list_test.go +++ b/internal/describer/list_test.go @@ -64,8 +64,8 @@ func TestListDescriber(t *testing.T) { Path: thePath, Title: "list", StoreKey: key, - ListType: podListType, - ObjectType: podObjectType, + ListType: PodListType, + ObjectType: PodObjectType, IsClusterWide: false, IconName: "icon-name", IconSource: "icon-source", diff --git a/internal/describer/object.go b/internal/describer/object.go index 3b4259e6ad..597ce6ee36 100644 --- a/internal/describer/object.go +++ b/internal/describer/object.go @@ -8,36 +8,40 @@ package describer import ( "context" "fmt" - "path/filepath" - "strings" "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "github.com/vmware-tanzu/octant/internal/api" - "github.com/vmware-tanzu/octant/internal/log" - "github.com/vmware-tanzu/octant/internal/modules/overview/logviewer" - "github.com/vmware-tanzu/octant/internal/modules/overview/terminalviewer" - "github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer" "github.com/vmware-tanzu/octant/internal/octant" - "github.com/vmware-tanzu/octant/internal/printer" - "github.com/vmware-tanzu/octant/internal/resourceviewer" "github.com/vmware-tanzu/octant/pkg/action" "github.com/vmware-tanzu/octant/pkg/store" "github.com/vmware-tanzu/octant/pkg/view/component" ) +// defaultObjectTabs are the default tabs for an object (that is not a custom resource). +func defaultObjectTabs() []Tab { + return []Tab{ + {Name: "Summary", Factory: SummaryTab}, + {Name: "Metadata", Factory: MetadataTab}, + {Name: "Resource Viewer", Factory: ResourceViewerTab}, + {Name: "YAML", Factory: YAMLViewerTab}, + {Name: "Logs", Factory: LogsTab}, + {Name: "Terminal", Factory: TerminalTab}, + } +} + +// ObjectConfig is configuration for Object. type ObjectConfig struct { - Path string - BaseTitle string - ObjectType func() interface{} - StoreKey store.Key - DisableResourceViewer bool - IconName string - IconSource string - RootPath ResourceLink + Path string + BaseTitle string + ObjectType func() interface{} + StoreKey store.Key + IconName string + IconSource string + TabsGenerator TabsGenerator + TabDescriptors []Tab } // Object describes an object. @@ -49,55 +53,53 @@ type Object struct { objectType func() interface{} objectStoreKey store.Key disableResourceViewer bool - tabFuncDescriptors []tabFuncDescriptor + tabFuncDescriptors []Tab iconName string iconSource string - rootPath ResourceLink + tabsGenerator TabsGenerator } // NewObject creates an instance of Object. func NewObject(c ObjectConfig) *Object { - o := &Object{ - path: c.Path, - baseTitle: c.BaseTitle, - base: newBaseDescriber(), - objectStoreKey: c.StoreKey, - objectType: c.ObjectType, - disableResourceViewer: c.DisableResourceViewer, - iconName: c.IconName, - iconSource: c.IconSource, - rootPath: c.RootPath, + tg := c.TabsGenerator + if tg == nil { + tg = NewObjectTabsGenerator() } - o.tabFuncDescriptors = []tabFuncDescriptor{ - {name: "Summary", tabFunc: o.addSummaryTab}, - {name: "Metadata", tabFunc: o.addMetadataTab}, - {name: "Resource Viewer", tabFunc: o.addResourceViewerTab}, - {name: "YAML", tabFunc: o.addYAMLViewerTab}, - {name: "Logs", tabFunc: o.addLogsTab}, - {name: "Terminal", tabFunc: o.addTerminalTab}, + td := c.TabDescriptors + if td == nil { + td = defaultObjectTabs() } - return o -} - -type tabFunc func(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error + o := &Object{ + path: c.Path, + baseTitle: c.BaseTitle, + base: newBaseDescriber(), + objectStoreKey: c.StoreKey, + objectType: c.ObjectType, + iconName: c.IconName, + iconSource: c.IconSource, + tabsGenerator: tg, + tabFuncDescriptors: td, + } -type tabFuncDescriptor struct { - name string - tabFunc tabFunc + return o } -// Describe describes an object. +// Describe describes an object. An object description is comprised of multiple tabs of content. +// By default, there will be the following tabs: summary, metadata, resource viewer, and yaml. +// If the object is a pod, there will also be a log and terminal tab. If plugins can contribute +// tabs to this object, those tabs will be included as well. +// +// This function should always return a content response even if there is an error. func (d *Object) Describe(ctx context.Context, namespace string, options Options) (component.ContentResponse, error) { - logger := log.From(ctx) - object, err := options.LoadObject(ctx, namespace, options.Fields, d.objectStoreKey) if err != nil { return component.EmptyContentResponse, api.NewNotFoundError(d.path) } else if object == nil { cr := component.NewContentResponse(component.TitleFromString("LoadObject Error")) - addErrorTab(ctx, "Error", fmt.Errorf("unable to load object %s", d.objectStoreKey), cr) + c := CreateErrorTab("Error", fmt.Errorf("unable to load object %s", d.objectStoreKey)) + cr.Add(c) return *cr, nil } @@ -105,26 +107,22 @@ func (d *Object) Describe(ctx context.Context, namespace string, options Options if err := scheme.Scheme.Convert(object, item, nil); err != nil { cr := component.NewContentResponse(component.TitleFromString("Converting Dynamic Object Error")) - addErrorTab(ctx, "Error", fmt.Errorf("converting dynamic object to a type: %w", err), cr) + c := CreateErrorTab("Error", fmt.Errorf("converting dynamic object to a type: %w", err)) + cr.Add(c) return *cr, nil } if err := copyObjectMeta(item, object); err != nil { cr := component.NewContentResponse(component.TitleFromString("Copying Object Metadata Error")) - addErrorTab(ctx, "Error", fmt.Errorf("copying object metadata: %w", err), cr) + c := CreateErrorTab("Error", fmt.Errorf("copying object metadata: %w", err)) + cr.Add(c) return *cr, nil } accessor := meta.NewAccessor() objectName, _ := accessor.Name(object) - kind, _ := accessor.Kind(object) - - nameLink, err := options.Link.ForObject(object, kind) - if err != nil { - return component.EmptyContentResponse, err - } - title := getBreadcrumb(d.rootPath, d.baseTitle, filepath.Dir(nameLink.Ref()), namespace) + title := append([]component.TitleComponent{}, component.NewText(d.baseTitle)) if objectName != "" { title = append(title, component.NewText(objectName)) } @@ -135,7 +133,8 @@ func (d *Object) Describe(ctx context.Context, namespace string, options Options currentObject, ok := item.(runtime.Object) if !ok { - addErrorTab(ctx, "Error", fmt.Errorf("expected item to be a runtime object. It was a %T", item), cr) + c := CreateErrorTab("Error", fmt.Errorf("expected item to be a runtime object. It was a %T", item)) + cr.Add(c) return *cr, nil } @@ -150,7 +149,7 @@ func (d *Object) Describe(ctx context.Context, namespace string, options Options return component.EmptyContentResponse, err } - confirmation, err := deleteObjectConfirmation(currentObject) + confirmation, err := DeleteObjectConfirmation(currentObject) if err != nil { return component.EmptyContentResponse, err } @@ -159,36 +158,23 @@ func (d *Object) Describe(ctx context.Context, namespace string, options Options key.ToActionPayload()), confirmation) } - hasTabError := false - for _, tfd := range d.tabFuncDescriptors { - if err := tfd.tabFunc(ctx, currentObject, cr, options); err != nil { - if ctx.Err() == context.Canceled { - continue - } - hasTabError = true - addErrorTab(ctx, tfd.name, err, cr) - } - } - - if hasTabError { - logger.With("tab-object", object).Errorf("generated tabs with errors") + config := TabsGeneratorConfig{ + Object: currentObject, + TabsFactory: objectTabsFactory(ctx, currentObject, d.tabFuncDescriptors, options), + Options: options, } - - tabs, err := options.PluginManager().Tabs(ctx, object) + tabComponents, err := d.tabsGenerator.Generate(ctx, config) if err != nil { - addErrorTab(ctx, "Plugin Error", fmt.Errorf("getting tabs from plugins: %w", err), cr) - return *cr, nil + return component.EmptyContentResponse, fmt.Errorf("generate tabs: %w", err) } - for i := range tabs { - tabs[i].Contents.SetAccessor(tabs[i].Name) - cr.Add(&tabs[i].Contents) - } + cr.Add(tabComponents...) return *cr, nil } -func deleteObjectConfirmation(object runtime.Object) (component.ButtonOption, error) { +// DeleteObjectConfirmation create a button option confirmation for deleting an object. +func DeleteObjectConfirmation(object runtime.Object) (component.ButtonOption, error) { if object == nil { return nil, fmt.Errorf("object is nil") } @@ -204,108 +190,9 @@ func deleteObjectConfirmation(object runtime.Object) (component.ButtonOption, er return component.WithButtonConfirmation(confirmationTitle, confirmationBody), nil } -func addErrorTab(ctx context.Context, name string, err error, cr *component.ContentResponse) { - errComponent := component.NewError(component.TitleFromString(name), err) - - accessor := name - strings.ReplaceAll(name, " ", "") - - errComponent.SetAccessor(accessor) - cr.Add(errComponent) -} - +// PathFilters returns the path filters for this object. func (d *Object) PathFilters() []PathFilter { return []PathFilter{ *NewPathFilter(d.path, d), } } - -func (d *Object) addSummaryTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error { - vc, err := options.Printer.Print(ctx, object, options.PluginManager()) - if vc == nil { - return fmt.Errorf("unable to print a nil object: %w", err) - } - - if err != nil { - return err - } - - vc.SetAccessor("summary") - cr.Add(vc) - - return nil -} - -func (d *Object) addResourceViewerTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error { - if !d.disableResourceViewer { - m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object) - if err != nil { - component.NewError(component.TitleFromString("Show resource viewer for object"), err) - } - - u := &unstructured.Unstructured{Object: m} - - resourceViewerComponent, err := resourceviewer.Create(ctx, options.Dash, options.Queryer, u) - if err != nil { - return err - } - - resourceViewerComponent.SetAccessor("resourceViewer") - cr.Add(resourceViewerComponent) - } - - return nil -} - -func (d *Object) addMetadataTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error { - metadataComponent, err := printer.MetadataHandler(object, options.Link) - if err != nil { - return err - } - - metadataComponent.SetAccessor("metadata") - cr.Add(metadataComponent) - - return nil -} - -func (d *Object) addYAMLViewerTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, _ Options) error { - yvComponent, err := yamlviewer.ToComponent(object) - if err != nil { - return err - } - - yvComponent.SetAccessor("yaml") - cr.Add(yvComponent) - return nil -} - -func (d *Object) addLogsTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, _ Options) error { - if isPod(object) { - logsComponent, err := logviewer.ToComponent(object) - if err != nil { - return err - } - - logsComponent.SetAccessor("logs") - cr.Add(logsComponent) - } - - return nil -} - -func (d *Object) addTerminalTab(ctx context.Context, object runtime.Object, cr *component.ContentResponse, options Options) error { - if isPod(object) { - logger := log.From(ctx) - - terminalComponent, err := terminalviewer.ToComponent(ctx, object, options.TerminalManager(), logger) - if err != nil { - return err - } - - terminalComponent.SetAccessor("terminal") - cr.Add(terminalComponent) - } - - return nil -} diff --git a/internal/describer/object_test.go b/internal/describer/object_test.go index 174c47f5cd..a1df1f256c 100644 --- a/internal/describer/object_test.go +++ b/internal/describer/object_test.go @@ -3,13 +3,18 @@ Copyright (c) 2019 the Octant contributors. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ -package describer +package describer_test import ( "context" - "github.com/vmware-tanzu/octant/internal/link" "testing" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/vmware-tanzu/octant/internal/describer" + describerFake "github.com/vmware-tanzu/octant/internal/describer/fake" + "github.com/vmware-tanzu/octant/internal/link" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,7 +22,6 @@ import ( configFake "github.com/vmware-tanzu/octant/internal/config/fake" "github.com/vmware-tanzu/octant/internal/octant" - printerFake "github.com/vmware-tanzu/octant/internal/printer/fake" "github.com/vmware-tanzu/octant/internal/testutil" "github.com/vmware-tanzu/octant/pkg/action" "github.com/vmware-tanzu/octant/pkg/plugin" @@ -46,10 +50,10 @@ func TestObjectDescriber(t *testing.T) { pluginManager := plugin.NewManager(nil, moduleRegistrar, actionRegistrar) dashConfig.EXPECT().PluginManager().Return(pluginManager).AnyTimes() - objectPrinter := printerFake.NewMockPrinter(controller) - podSummary := component.NewText("summary") - objectPrinter.EXPECT().Print(gomock.Any(), pod, pluginManager).Return(podSummary, nil) + + tg := describerFake.NewMockTabsGenerator(controller) + tg.EXPECT().Generate(gomock.Any(), gomock.Any()).Return([]component.Component{podSummary}, nil) dashConfig.EXPECT(). ObjectPath(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). @@ -59,35 +63,41 @@ func TestObjectDescriber(t *testing.T) { lnk, err := link.NewFromDashConfig(dashConfig) require.NoError(t, err) - options := Options{ - Dash: dashConfig, - Printer: objectPrinter, - Link: lnk, + options := describer.Options{ + Dash: dashConfig, + Link: lnk, LoadObject: func(ctx context.Context, namespace string, fields map[string]string, objectStoreKey store.Key) (*unstructured.Unstructured, error) { return testutil.ToUnstructured(t, pod), nil }, } - objectConfig := ObjectConfig{ - Path: thePath, - BaseTitle: "object", - StoreKey: key, - ObjectType: podObjectType, - DisableResourceViewer: true, - IconName: "icon-name", - IconSource: "icon-source", + tabDescriptors := []describer.Tab{ + { + Name: "summary", + Factory: func(_ context.Context, _ runtime.Object, _ describer.Options) (component.Component, error) { + c := component.NewText("summary") + c.SetAccessor("summary") + return c, nil + }, + }, } - d := NewObject(objectConfig) - d.tabFuncDescriptors = []tabFuncDescriptor{ - {name: "summary", tabFunc: d.addSummaryTab}, + objectConfig := describer.ObjectConfig{ + Path: thePath, + BaseTitle: "object", + StoreKey: key, + ObjectType: describer.PodObjectType, + IconName: "icon-name", + IconSource: "icon-source", + TabsGenerator: tg, + TabDescriptors: tabDescriptors, } + d := describer.NewObject(objectConfig) cResponse, err := d.Describe(ctx, pod.Namespace, options) require.NoError(t, err) summary := component.NewText("summary") - summary.SetAccessor("summary") buttonGroup := component.NewButtonGroup() @@ -100,7 +110,7 @@ func TestObjectDescriber(t *testing.T) { ))) expected := component.ContentResponse{ - Title: component.Title(component.NewLink("", "object", "."), component.NewText("pod")), + Title: component.Title(component.NewText("object"), component.NewText("pod")), IconName: "icon-name", IconSource: "icon-source", Components: []component.Component{ @@ -108,13 +118,13 @@ func TestObjectDescriber(t *testing.T) { }, ButtonGroup: buttonGroup, } - assert.Equal(t, expected, cResponse) + testutil.AssertJSONEqual(t, &expected, &cResponse) } -func Test_deleteObjectConfirmation(t *testing.T) { +func Test_DeleteObjectConfirmation(t *testing.T) { pod := testutil.CreatePod("pod") - option, err := deleteObjectConfirmation(pod) + option, err := describer.DeleteObjectConfirmation(pod) require.NoError(t, err) button := component.Button{} diff --git a/internal/describer/resource.go b/internal/describer/resource.go index 97a0b69639..7f4568a82a 100644 --- a/internal/describer/resource.go +++ b/internal/describer/resource.go @@ -28,8 +28,8 @@ type ResourceTitle struct { } type ResourceLink struct { - Title string - Url string + Title string + Url string } type ResourceOptions struct { @@ -93,10 +93,8 @@ func (r *Resource) Object() *Object { ObjectType: func() interface{} { return reflect.New(reflect.ValueOf(r.ObjectType).Elem().Type()).Interface() }, - DisableResourceViewer: r.DisableResourceViewer, - IconName: iconName, - IconSource: iconSource, - RootPath: r.RootPath, + IconName: iconName, + IconSource: iconSource, }, ) } diff --git a/internal/describer/tab.go b/internal/describer/tab.go new file mode 100644 index 0000000000..eb438cbe7b --- /dev/null +++ b/internal/describer/tab.go @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2020 the Octant contributors. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package describer + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/vmware-tanzu/octant/internal/link" + "github.com/vmware-tanzu/octant/internal/log" + "github.com/vmware-tanzu/octant/internal/modules/overview/logviewer" + "github.com/vmware-tanzu/octant/internal/modules/overview/terminalviewer" + "github.com/vmware-tanzu/octant/internal/modules/overview/yamlviewer" + "github.com/vmware-tanzu/octant/internal/printer" + "github.com/vmware-tanzu/octant/internal/resourceviewer" + "github.com/vmware-tanzu/octant/pkg/view/component" +) + +// CustomResourceSummaryTab generates a summary tab for a custom resource. This function +// returns a TabFactory since the crd name might not be available when this factory +// is invoked. +func CustomResourceSummaryTab(crdName string) TabFactory { + return func(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + crd, err := CustomResourceDefinition(ctx, crdName, options.ObjectStore()) + if err != nil { + return nil, fmt.Errorf("unable to find custom resource definition: %w", err) + } + + cr, ok := object.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("invalid custom resource") + } + + linkGenerator, err := link.NewFromDashConfig(options) + if err != nil { + return nil, fmt.Errorf("create link generator: %w", err) + } + + printOptions := printer.Options{ + DashConfig: options, + Link: linkGenerator, + } + + return printer.CustomResourceHandler(ctx, crd, cr, printOptions) + } +} + +// SummaryTab generates a summary tab for an object. +func SummaryTab(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + vc, err := options.Printer.Print(ctx, object, options.PluginManager()) + if err != nil { + return nil, fmt.Errorf("print summary tab: %w", err) + } else if vc == nil { + return nil, fmt.Errorf("printer generated a nil object") + } + + vc.SetAccessor("summary") + + return vc, nil +} + +// MetadataTab generates a metadata tab for an object. +func MetadataTab(_ context.Context, object runtime.Object, options Options) (component.Component, error) { + metadataComponent, err := printer.MetadataHandler(object, options.Link) + if err != nil { + return nil, fmt.Errorf("print metadata: %w", err) + } + + metadataComponent.SetAccessor("metadata") + + return metadataComponent, nil +} + +// ResourceViewerTab generates a resource viewer tab for an object. +func ResourceViewerTab(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object) + if err != nil { + component.NewError(component.TitleFromString("Show resource viewer for object"), err) + } + + u := &unstructured.Unstructured{Object: m} + + resourceViewerComponent, err := resourceviewer.Create(ctx, options.Dash, options.Queryer, u) + if err != nil { + return nil, fmt.Errorf("create resource viewer: %w", err) + } + + resourceViewerComponent.SetAccessor("resourceViewer") + return resourceViewerComponent, nil +} + +// YAMLViewerTab generates a yaml viewer for an object. +func YAMLViewerTab(_ context.Context, object runtime.Object, _ Options) (component.Component, error) { + yvComponent, err := yamlviewer.ToComponent(object) + if err != nil { + return nil, fmt.Errorf("create yaml viewer: %w", err) + } + + yvComponent.SetAccessor("yaml") + return yvComponent, nil +} + +// LogsTab generates a logs tab for a pod. If the object is not a pod, the +// returned component will be nil with a nil error. +func LogsTab(_ context.Context, object runtime.Object, _ Options) (component.Component, error) { + if isPod(object) { + logsComponent, err := logviewer.ToComponent(object) + if err != nil { + return nil, fmt.Errorf("create log viewer: %w", err) + } + + logsComponent.SetAccessor("logs") + return logsComponent, nil + } + + return nil, nil +} + +// TerminalTab generates a terminal tab for a pod. If the object is not a pod, +// the returned component will be nil with a nil error. +func TerminalTab(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + if isPod(object) { + logger := log.From(ctx) + + terminalComponent, err := terminalviewer.ToComponent(ctx, object, options.TerminalManager(), logger) + if err != nil { + return nil, fmt.Errorf("create terminal viewer: %w", err) + } + + terminalComponent.SetAccessor("terminal") + return terminalComponent, nil + } + + return nil, nil +} diff --git a/internal/describer/tab_generator.go b/internal/describer/tab_generator.go new file mode 100644 index 0000000000..3d72e30fdf --- /dev/null +++ b/internal/describer/tab_generator.go @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2020 the Octant contributors. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package describer + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/vmware-tanzu/octant/internal/log" + "github.com/vmware-tanzu/octant/pkg/view/component" +) + +//go:generate mockgen -destination=./fake/mock_tabs_generator.go -package=fake github.com/vmware-tanzu/octant/internal/describer TabsGenerator + +// TabsFactory creates a list of tab descriptors. +type TabsFactory func() ([]Tab, error) + +// TabsGeneratorConfig is configuration for TabsGenerator. +type TabsGeneratorConfig struct { + Object runtime.Object + TabsFactory TabsFactory + Options Options +} + +// TabsGenerator generates tabs for an object. +type TabsGenerator interface { + // Generate generates tabs given a configuration and returns a list of components. + Generate(ctx context.Context, config TabsGeneratorConfig) ([]component.Component, error) +} + +// ObjectTabsGenerator generates tabs for an object. +type ObjectTabsGenerator struct { +} + +var _ TabsGenerator = &ObjectTabsGenerator{} + +// NewObjectTabsGenerator creates an instance of ObjectTabsGenerator. +func NewObjectTabsGenerator() *ObjectTabsGenerator { + tg := ObjectTabsGenerator{} + + return &tg +} + +// Generate generates tabs for an object. +func (t ObjectTabsGenerator) Generate(ctx context.Context, config TabsGeneratorConfig) ([]component.Component, error) { + if config.Object == nil { + return nil, fmt.Errorf("can't generate tabs for nil object") + } + + if config.TabsFactory == nil { + return nil, fmt.Errorf("tabs factory is nil") + } + + logger := log.From(ctx) + + var indexedComponents []indexedComponent + var mu sync.Mutex + + var g errgroup.Group + + descriptors, err := config.TabsFactory() + if err != nil { + return []component.Component{ + CreateErrorTab("Error", fmt.Errorf("generate tabs: %w", err)), + }, nil + } + + for i := range descriptors { + i := i + descriptor := descriptors[i] + g.Go(func() error { + c, err := descriptor.Factory(ctx, config.Object, config.Options) + if err != nil { + c = CreateErrorTab(descriptor.Name, err) + } + + mu.Lock() + indexedComponents = append(indexedComponents, indexedComponent{ + c: c, + index: i, + }) + mu.Unlock() + + return nil + }) + } + + if err := g.Wait(); err != nil { + logger.WithErr(err).Errorf("create tabs") + } + + sort.Slice(indexedComponents, func(i, j int) bool { + return indexedComponents[i].index < indexedComponents[j].index + }) + + var list []component.Component + for _, ci := range indexedComponents { + if ci.c != nil { + list = append(list, ci.c) + } + } + + return list, nil +} + +// CreateErrorTab creates an error tab given a name and an error. +func CreateErrorTab(name string, err error) component.Component { + errComponent := component.NewError(component.TitleFromString(name), err) + + accessor := name + strings.ReplaceAll(name, " ", "") + + errComponent.SetAccessor(accessor) + + return errComponent +} + +// TabFactory is a function that generates a component which describes an object as a component. +type TabFactory func( + ctx context.Context, + object runtime.Object, + options Options) (component.Component, error) + +// Tab describes a tab. It contains the name and a factory function to generate the content for the tab. +type Tab struct { + // Name is the name of the tab. + Name string + // Factory is a function that generates the contents for a tab (as a component). + Factory TabFactory +} + +type indexedComponent struct { + c component.Component + index int +} + +// objectTabsFactory generates tabs for an object. It includes plugin tabs. +func objectTabsFactory( + ctx context.Context, + object runtime.Object, + descriptors []Tab, options Options) TabsFactory { + return func() ([]Tab, error) { + list := append(descriptors) + pluginList, err := pluginTabsFactory(ctx, object, options) + if err != nil { + return nil, fmt.Errorf("generate plugin tabs: %w", err) + } + + return append(list, pluginList...), nil + } +} + +// pluginTabsFactory generates plugin tabs for an object. +func pluginTabsFactory( + ctx context.Context, + object runtime.Object, + options Options) ([]Tab, error) { + var list []Tab + + tabs, err := options.PluginManager().Tabs(ctx, object) + if err != nil { + list = append(list, Tab{ + Name: "Plugin", + Factory: func(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + tab := CreateErrorTab("Plugin Error", fmt.Errorf("getting tabs from plugins: %w", err)) + return tab, nil + }, + }) + + return list, nil + } + + for _, tab := range tabs { + list = append(list, Tab{ + Name: tab.Name, + Factory: func(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + return &tab.Contents, nil + }, + }) + } + + return list, nil +} diff --git a/internal/describer/tab_generator_test.go b/internal/describer/tab_generator_test.go new file mode 100644 index 0000000000..5c9427f210 --- /dev/null +++ b/internal/describer/tab_generator_test.go @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 the Octant contributors. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package describer + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/vmware-tanzu/octant/internal/testutil" + "github.com/vmware-tanzu/octant/pkg/view/component" +) + +func TestObjectTabsGenerator_Generate(t *testing.T) { + g := NewObjectTabsGenerator() + + c := component.NewText("text") + c.SetAccessor("accessor") + + object := testutil.CreatePod("pod") + tabsFactory := func() ([]Tab, error) { + return []Tab{ + { + Name: "tab", + Factory: func(ctx context.Context, object runtime.Object, options Options) (component.Component, error) { + return c, nil + }, + }, + }, nil + } + + config := TabsGeneratorConfig{ + Object: object, + TabsFactory: tabsFactory, + Options: Options{}, + } + + ctx := context.Background() + actual, err := g.Generate(ctx, config) + require.NoError(t, err) + + wanted := []component.Component{c} + testutil.AssertJSONEqual(t, wanted, actual) +} + +func TestCreateErrorTab(t *testing.T) { + actual := CreateErrorTab("Name", fmt.Errorf("error")) + wanted := component.NewError(component.TitleFromString("Name"), fmt.Errorf("error")) + wanted.SetAccessor("Name") + + testutil.AssertJSONEqual(t, wanted, actual) +} diff --git a/internal/testutil/assert.go b/internal/testutil/assert.go index 5774f4630f..ecf70ed0d2 100644 --- a/internal/testutil/assert.go +++ b/internal/testutil/assert.go @@ -10,10 +10,10 @@ import ( // AssertJSONEqual asserts two object's generated JSON is equal. func AssertJSONEqual(t *testing.T, expected, actual interface{}) { - a, err := json.Marshal(expected) + a, err := json.MarshalIndent(expected, "", " ") require.NoError(t, err) - b, err := json.Marshal(actual) + b, err := json.MarshalIndent(actual, "", " ") require.NoError(t, err) assert.JSONEq(t, string(a), string(b)) diff --git a/pkg/view/component/component.go b/pkg/view/component/component.go index e331d81a16..a3ac91f52e 100644 --- a/pkg/view/component/component.go +++ b/pkg/view/component/component.go @@ -35,9 +35,14 @@ func NewContentResponse(title []TitleComponent) *ContentResponse { } } -// Add adds zero or more components to a content response. +// Add adds zero or more components to a content response. Nil components +// will be ignored. func (c *ContentResponse) Add(components ...Component) { - c.Components = append(c.Components, components...) + for i := range components { + if components[i] != nil { + c.Components = append(c.Components, components[i]) + } + } } // SetExtension adds zero or more components to an extension content response. diff --git a/pkg/view/component/component_test.go b/pkg/view/component/component_test.go index 4799f86204..26251de21e 100644 --- a/pkg/view/component/component_test.go +++ b/pkg/view/component/component_test.go @@ -11,6 +11,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/vmware-tanzu/octant/internal/testutil" ) func TestMetadata_UnmarshalJSON(t *testing.T) { @@ -29,3 +31,29 @@ func TestMetadata_UnmarshalJSON(t *testing.T) { } require.Equal(t, expected, got) } + +func TestContentResponse_Add(t *testing.T) { + tests := []struct { + name string + components []Component + wanted []Component + }{ + { + name: "in general", + components: []Component{NewText("test")}, + wanted: []Component{NewText("test")}, + }, + { + name: "with nil components", + components: []Component{nil, NewText("test"), nil}, + wanted: []Component{NewText("test")}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cr := NewContentResponse(TitleFromString("cr")) + cr.Add(test.components...) + testutil.AssertJSONEqual(t, test.wanted, cr.Components) + }) + } +} diff --git a/web/src/app/modules/shared/services/content/content.service.ts b/web/src/app/modules/shared/services/content/content.service.ts index eb28a19c9c..a4e7dc4ffd 100644 --- a/web/src/app/modules/shared/services/content/content.service.ts +++ b/web/src/app/modules/shared/services/content/content.service.ts @@ -63,6 +63,21 @@ export class ContentService { this.setContent(response.content); namespaceService.setNamespace(response.namespace); + if (response.contentPath) { + if (this.previousContentPath.length > 0) { + if (response.contentPath !== this.previousContentPath) { + const segments = response.contentPath.split('/'); + this.router + .navigate(segments, { + queryParams: response.queryParams, + }) + .catch(reason => + console.error(`unable to navigate`, { segments, reason }) + ); + } + } + } + this.previousContentPath = response.contentPath; }); diff --git a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts index 4f383fd80e..5a1a12c8ba 100644 --- a/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts +++ b/web/src/app/modules/sugarloaf/components/smart/content/content.component.ts @@ -22,6 +22,8 @@ import { IconService } from '../../../../shared/services/icon/icon.service'; import { ContentService } from '../../../../shared/services/content/content.service'; import isEqual from 'lodash/isEqual'; import { Subscription } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { Location } from '@angular/common'; @Component({ selector: 'app-overview', @@ -46,7 +48,8 @@ export class ContentComponent implements OnInit, OnDestroy { constructor( private router: Router, private iconService: IconService, - private contentService: ContentService + private contentService: ContentService, + private location: Location ) {} updatePath(url: string) {