From afd8997324d34d11486980c361c4fc385b778dfe Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Tue, 21 Jan 2025 22:01:04 +0000 Subject: [PATCH 1/4] feat(ui): service tags --- config/help_test.go | 2 +- service/dashboard.go | 92 ++- service/dashboard_test.go | 215 +++++++ service/help_test.go | 2 +- service/init.go | 2 +- service/new.go | 2 +- service/types.go | 38 +- service/types_test.go | 179 +++++- test/main.go | 5 +- util/slice.go | 78 +++ util/slice_test.go | 242 ++++++++ util/types.go | 9 + util/util.go | 37 +- util/util_test.go | 172 +----- web/api/types/argus.go | 49 +- web/api/types/argus_test.go | 29 + web/api/v1/convert.go | 3 +- web/api/v1/convert_test.go | 3 +- web/help_test.go | 2 +- web/ui/package-lock.json | 529 +++++++++++++++++- web/ui/react-app/package.json | 3 + .../src/components/approvals/index.tsx | 2 +- .../src/components/approvals/service-info.tsx | 14 +- .../src/components/approvals/toolbar.tsx | 223 -------- .../approvals/toolbar/edit-mode-toggle.tsx | 34 ++ .../approvals/toolbar/filter-dropdown.tsx | 95 ++++ .../approvals/toolbar/search-bar.tsx | 90 +++ .../approvals/toolbar/tag-select.tsx | 48 ++ .../components/approvals/toolbar/toolbar.tsx | 44 ++ .../form-select-creatable-sortable.tsx | 253 +++++++++ .../generic/form-select-creatable.tsx | 204 +++++++ .../components/generic/form-select-shared.tsx | 356 ++++++++++++ .../src/components/generic/form-select.tsx | 96 +++- .../react-app/src/components/generic/form.tsx | 2 + .../modals/service-edit/dashboard.tsx | 46 +- .../modals/service-edit/notifies.tsx | 18 +- .../modals/service-edit/notify-types/bark.tsx | 2 +- .../service-edit/notify-types/discord.tsx | 1 + .../components/modals/service-edit/notify.tsx | 44 +- .../components/modals/service-edit/root.tsx | 6 +- .../modals/service-edit/service.tsx | 1 + .../service-edit/url-commands/render.tsx | 1 + .../service-edit/util/api-ui-conversions.tsx | 9 +- .../service-edit/util/ui-api-conversions.tsx | 1 + .../service-edit/version-with-refresh.tsx | 2 +- .../modals/service-edit/webhook.tsx | 38 +- .../modals/service-edit/webhooks.tsx | 18 +- web/ui/react-app/src/contexts/websocket.tsx | 10 +- web/ui/react-app/src/index.css | 375 +++++++------ .../react-app/src/pages/approvals/index.tsx | 16 +- web/ui/react-app/src/reducers/monitor.tsx | 17 +- web/ui/react-app/src/types/config.tsx | 1 + web/ui/react-app/src/types/summary.tsx | 2 + web/ui/react-app/src/types/util.tsx | 6 +- 54 files changed, 2924 insertions(+), 844 deletions(-) create mode 100644 util/slice.go create mode 100644 util/slice_test.go delete mode 100644 web/ui/react-app/src/components/approvals/toolbar.tsx create mode 100644 web/ui/react-app/src/components/approvals/toolbar/edit-mode-toggle.tsx create mode 100644 web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx create mode 100644 web/ui/react-app/src/components/approvals/toolbar/search-bar.tsx create mode 100644 web/ui/react-app/src/components/approvals/toolbar/tag-select.tsx create mode 100644 web/ui/react-app/src/components/approvals/toolbar/toolbar.tsx create mode 100644 web/ui/react-app/src/components/generic/form-select-creatable-sortable.tsx create mode 100644 web/ui/react-app/src/components/generic/form-select-creatable.tsx create mode 100644 web/ui/react-app/src/components/generic/form-select-shared.tsx diff --git a/config/help_test.go b/config/help_test.go index 573176b7..31e139ba 100644 --- a/config/help_test.go +++ b/config/help_test.go @@ -180,7 +180,7 @@ func testServiceURL(id string) *service.Service { LatestVersion: lv, DeployedVersionLookup: dv, Dashboard: *service.NewDashboardOptions( - test.BoolPtr(false), "test", "https://release-argus.io", "https://release-argus.io/docs", + test.BoolPtr(false), "test", "https://release-argus.io", "https://release-argus.io/docs", nil, &service.DashboardOptionsDefaults{}, &service.DashboardOptionsDefaults{}), Options: *options, Status: *status.New( diff --git a/service/dashboard.go b/service/dashboard.go index 5500c0a2..963cd684 100644 --- a/service/dashboard.go +++ b/service/dashboard.go @@ -16,9 +16,12 @@ package service import ( + "encoding/json" + "errors" "fmt" "github.com/release-argus/Argus/util" + "gopkg.in/yaml.v3" ) // DashboardOptionsBase are the base options for the Dashboard. @@ -44,9 +47,10 @@ func NewDashboardOptionsDefaults( type DashboardOptions struct { DashboardOptionsBase `yaml:",inline" json:",inline"` - Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // Icon URL to use for messages/Web UI. - IconLinkTo string `yaml:"icon_link_to,omitempty" json:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. - WebURL string `yaml:"web_url,omitempty" json:"web_url,omitempty"` // URL to provide on the Web UI. + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // Icon URL to use for messages/Web UI. + IconLinkTo string `yaml:"icon_link_to,omitempty" json:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. + WebURL string `yaml:"web_url,omitempty" json:"web_url,omitempty"` // URL to provide on the Web UI. + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` // Tags for the Service. Defaults *DashboardOptionsDefaults `yaml:"-" json:"-"` // Defaults. HardDefaults *DashboardOptionsDefaults `yaml:"-" json:"-"` // Hard defaults. @@ -58,6 +62,7 @@ func NewDashboardOptions( icon string, iconLinkTo string, webURL string, + tags []string, defaults, hardDefaults *DashboardOptionsDefaults, ) *DashboardOptions { return &DashboardOptions{ @@ -66,10 +71,91 @@ func NewDashboardOptions( Icon: icon, IconLinkTo: iconLinkTo, WebURL: webURL, + Tags: tags, Defaults: defaults, HardDefaults: hardDefaults} } +// UnmarshalJSON handles the unmarshalling of a DashboardOptions. +func (d *DashboardOptions) UnmarshalJSON(data []byte) error { + aux := &struct { + *DashboardOptionsBase `json:",inline"` + + Icon *string `json:"icon,omitempty"` // Icon URL to use for messages/Web UI. + IconLinkTo *string `json:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. + WebURL *string `json:"web_url,omitempty"` // URL to provide on the Web UI. + Tags json.RawMessage `json:"tags,omitempty"` // Tags for the Service. + }{ + DashboardOptionsBase: &d.DashboardOptionsBase, + Icon: &d.Icon, + IconLinkTo: &d.IconLinkTo, + WebURL: &d.WebURL, + } + + // Unmarshal into aux. + if err := json.Unmarshal(data, &aux); err != nil { + return fmt.Errorf("failed to unmarshal DashboardOptions:\n%w", err) + } + + // Tags + if len(aux.Tags) > 0 { + var tagsAsString string + var tagsAsArray []string + + // Try to unmarshal as a list of strings + if err := json.Unmarshal(aux.Tags, &tagsAsArray); err == nil { + d.Tags = tagsAsArray + // Try to unmarshal as a single string + } else if err := json.Unmarshal(aux.Tags, &tagsAsString); err == nil { + d.Tags = []string{tagsAsString} + } else { + return errors.New("error in tags field:\ntype: (expected string or list of strings)") + } + } + + return nil +} + +// UnmarshalYAML handles the unmarshalling of a DashboardOptions. +func (d *DashboardOptions) UnmarshalYAML(value *yaml.Node) error { + aux := &struct { + *DashboardOptionsBase `yaml:",inline"` + + Icon *string `yaml:"icon,omitempty"` // Icon URL to use for messages/Web UI. + IconLinkTo *string `yaml:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. + WebURL *string `yaml:"web_url,omitempty"` // URL to provide on the Web UI. + Tags util.RawNode `yaml:"tags,omitempty"` // Tags for the Service. + }{ + DashboardOptionsBase: &d.DashboardOptionsBase, + Icon: &d.Icon, + IconLinkTo: &d.IconLinkTo, + WebURL: &d.WebURL, + } + + // Unmarshal into aux. + if err := value.Decode(&aux); err != nil { + return fmt.Errorf("failed to unmarshal DashboardOptions:\n%w", err) + } + + // Tags + if aux.Tags.Node != nil { + var tagsAsString string + var tagsAsArray []string + + // Try to unmarshal as a list of strings + if err := aux.Tags.Decode(&tagsAsArray); err == nil { + d.Tags = tagsAsArray + // Try to unmarshal as a single string + } else if err := aux.Tags.Decode(&tagsAsString); err == nil { + d.Tags = []string{tagsAsString} + } else { + return errors.New("error in tags field:\ntype: (expected string or list of strings)") + } + } + + return nil +} + // GetAutoApprove returns whether new releases are auto-approved. func (d *DashboardOptions) GetAutoApprove() bool { return *util.FirstNonDefault( diff --git a/service/dashboard_test.go b/service/dashboard_test.go index 2ebba783..611b15f2 100644 --- a/service/dashboard_test.go +++ b/service/dashboard_test.go @@ -22,8 +22,223 @@ import ( "github.com/release-argus/Argus/test" "github.com/release-argus/Argus/util" + "gopkg.in/yaml.v3" ) +func TestNewDashboardOptions(t *testing.T) { + // GIVEN a set of input values + tests := map[string]struct { + autoApprove *bool + icon string + iconLinkTo string + webURL string + tags []string + defaults *DashboardOptionsDefaults + hardDefaults *DashboardOptionsDefaults + want *DashboardOptions + }{ + "all fields set": { + autoApprove: test.BoolPtr(true), + icon: "icon-url", + iconLinkTo: "icon-link", + webURL: "web-url", + tags: []string{"tag1", "tag2"}, + defaults: &DashboardOptionsDefaults{DashboardOptionsBase: DashboardOptionsBase{AutoApprove: test.BoolPtr(false)}}, + hardDefaults: &DashboardOptionsDefaults{DashboardOptionsBase: DashboardOptionsBase{AutoApprove: test.BoolPtr(false)}}, + want: &DashboardOptions{ + DashboardOptionsBase: DashboardOptionsBase{AutoApprove: test.BoolPtr(true)}, + Icon: "icon-url", + IconLinkTo: "icon-link", + WebURL: "web-url", + Tags: []string{"tag1", "tag2"}, + Defaults: &DashboardOptionsDefaults{DashboardOptionsBase: DashboardOptionsBase{AutoApprove: test.BoolPtr(false)}}, + HardDefaults: &DashboardOptionsDefaults{DashboardOptionsBase: DashboardOptionsBase{AutoApprove: test.BoolPtr(false)}}, + }, + }, + "defaults": { + autoApprove: nil, + icon: "", + iconLinkTo: "", + webURL: "", + tags: nil, + defaults: nil, + hardDefaults: nil, + want: &DashboardOptions{ + DashboardOptionsBase: DashboardOptionsBase{AutoApprove: nil}, + Icon: "", + IconLinkTo: "", + WebURL: "", + Tags: nil, + Defaults: nil, + HardDefaults: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // WHEN NewDashboardOptions is called with them. + got := NewDashboardOptions(tc.autoApprove, tc.icon, tc.iconLinkTo, tc.webURL, tc.tags, tc.defaults, tc.hardDefaults) + + // THEN the result is as expected. + gotStr := util.ToJSONString(got) + wantStr := util.ToJSONString(tc.want) + if gotStr != wantStr { + t.Errorf("NewDashboardOptions() result mismatch\n%q\ngot:\n%v", + wantStr, gotStr) + } + }) + } +} + +func TestDashboardOptions_UnmarshalJSON(t *testing.T) { + // GIVEN a JSON string that represents a DashboardOptions. + tests := map[string]struct { + jsonData string + errRegex string + want *DashboardOptions + }{ + "invalid json": { + jsonData: `{invalid: json}`, + errRegex: test.TrimYAML(` + failed to unmarshal DashboardOptions: + invalid character.*$`), + want: &DashboardOptions{}, + }, + "tags - []string": { + jsonData: `{ + "tags": [ + "foo", + "bar" + ] + }`, + errRegex: `^$`, + want: &DashboardOptions{ + Tags: []string{"foo", "bar"}, + }, + }, + "tags - string": { + jsonData: `{ + "tags": "foo" + }`, + errRegex: `^$`, + want: &DashboardOptions{ + Tags: []string{"foo"}, + }, + }, + "tags - invalid": { + jsonData: `{ + "tags": { + "foo": "bar" + } + }`, + errRegex: test.TrimYAML(` + ^error in tags field: + type: .*$`), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Default to an empty DashboardOptions. + dashboardOptions := &DashboardOptions{} + + // WHEN the JSON is unmarshalled into a DashboardOptions. + err := dashboardOptions.UnmarshalJSON([]byte(test.TrimJSON(tc.jsonData))) + + // THEN the error is as expected. + e := util.ErrorToString(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf("DashboardOptions.UnmarshalJSON() error mismatch\nwant: %q\ngot: %q", + tc.errRegex, e) + } + // AND the result is as expected. + gotString := util.ToJSONString(dashboardOptions) + wantString := util.ToJSONString(tc.want) + if tc.want != nil && gotString != wantString { + t.Errorf("DashboardOptions.UnmarshalJSON() result mismatch\n%q\ngot:\n%q", + wantString, gotString) + } + }) + } +} + +func TestDashboardOptions_UnmarshalYAML(t *testing.T) { + tests := map[string]struct { + yamlData string + errRegex string + want *DashboardOptions + }{ + "invalid yaml": { + yamlData: `invalid yaml`, + errRegex: test.TrimYAML(` + failed to unmarshal DashboardOptions: + yaml: unmarshal errors: + .*cannot unmarshal.*$`), + want: &DashboardOptions{}, + }, + "tags - []string": { + yamlData: ` + tags: + - foo + - bar + `, + errRegex: `^$`, + want: &DashboardOptions{ + Tags: []string{"foo", "bar"}, + }, + }, + "tags - string": { + yamlData: ` + tags: foo + `, + errRegex: `^$`, + want: &DashboardOptions{ + Tags: []string{"foo"}, + }, + }, + "tags - invalid": { + yamlData: ` + tags: + foo: bar + `, + errRegex: test.TrimYAML(` + ^error in tags field: + type: .*$`), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Default to an empty DashboardOptions. + dashboardOptions := &DashboardOptions{} + + // WHEN the YAML is unmarshalled into a DashboardOptions + err := yaml.Unmarshal([]byte(test.TrimYAML(tc.yamlData)), &dashboardOptions) + + // THEN the error is as expected + e := util.ErrorToString(err) + if !util.RegexCheck(tc.errRegex, e) { + t.Errorf("DashboardOptions.UnmarshalYAML() error mismatch\nwant: %q\ngot: %q", + tc.errRegex, e) + } + // AND the result is as expected + gotStr := util.ToJSONString(dashboardOptions) + wantStr := util.ToJSONString(tc.want) + if tc.want != nil && gotStr != wantStr { + t.Errorf("DashboardOptions.UnmarshalYAML() result mismatch\nwant: %s\ngot: %s", + wantStr, gotStr) + } + }) + } +} + func TestDashboardOptions_GetAutoApprove(t *testing.T) { // GIVEN a DashboardOptions tests := map[string]struct { diff --git a/service/help_test.go b/service/help_test.go index b2021529..5d7a06a8 100644 --- a/service/help_test.go +++ b/service/help_test.go @@ -74,7 +74,7 @@ func testService(t *testing.T, id string, sType string) *Service { LatestVersion: testLatestVersion(t, sType, false), DeployedVersionLookup: testDeployedVersionLookup(t, false), Dashboard: *NewDashboardOptions( - test.BoolPtr(false), "test", "https://release-argus.io", "https://release-argus.io", + test.BoolPtr(false), "test", "https://release-argus.io", "https://release-argus.io", nil, &DashboardOptionsDefaults{}, &DashboardOptionsDefaults{}), Status: *testStatus(), Options: *testOptions(), diff --git a/service/init.go b/service/init.go index a24b7a2b..1602b71d 100644 --- a/service/init.go +++ b/service/init.go @@ -96,7 +96,7 @@ func (s *Service) Init( // Notify/ // use defaults? - if s.Notify == nil && len(defaults.Notify) != 0 { + if len(s.Notify) == 0 && len(defaults.Notify) != 0 { s.Notify = make(shoutrrr.Slice, len(defaults.Notify)) for key := range defaults.Notify { s.Notify[key] = &shoutrrr.Shoutrrr{} diff --git a/service/new.go b/service/new.go index cff23b3b..971571d3 100644 --- a/service/new.go +++ b/service/new.go @@ -192,7 +192,7 @@ func (s *Service) giveSecretsDeployedVersion(oldDeployedVersion *deployedver.Loo // giveSecretsNotify from the `oldNotifies`. func (s *Service) giveSecretsNotify(oldNotifies shoutrrr.Slice, secretRefs map[string]oldStringIndex) { //nolint:typecheck - if s.Notify == nil || oldNotifies == nil || + if len(s.Notify) == 0 || len(oldNotifies) == 0 || len(secretRefs) == 0 { return } diff --git a/service/types.go b/service/types.go index ad896634..35e2e5ad 100644 --- a/service/types.go +++ b/service/types.go @@ -175,6 +175,11 @@ func (s *Service) Summary() *apitype.ServiceSummary { summary.Name = &s.Name } + // Tags + if len(s.Dashboard.Tags) != 0 { + summary.Tags = &s.Dashboard.Tags + } + return summary } @@ -193,11 +198,11 @@ func (s *Service) UnmarshalJSON(data []byte) error { // Alias to avoid recursion. type Alias Service aux := &struct { - *Alias `json:",inline"` - Name *string `json:"name,omitempty"` // Name of the Service. - Comment *string `json:"comment,omitempty"` // Comment on the Service. - Options *opt.Options `json:"options,omitempty"` // Options to give the Service. - LatestVersion json.RawMessage `json:"latest_version,omitempty"` // Temp LatestVersion field to get Type. + *Alias `json:",inline"` // Embed the original struct. + Name *string `json:"name,omitempty"` // Name of the Service. + Comment *string `json:"comment,omitempty"` // Comment on the Service. + Options *opt.Options `json:"options,omitempty"` // Options to give the Service. + LatestVersion json.RawMessage `json:"latest_version,omitempty"` // Temp LatestVersion field to get Type. }{ Alias: (*Alias)(s), Name: &s.Name, @@ -266,7 +271,7 @@ func (s *Service) MarshalJSON() ([]byte, error) { Comment string `json:"comment,omitempty"` // Comment on the Service. Options opt.Options `json:"options,omitempty"` // Options to give the Service. LatestVersion latestver.Lookup `json:"latest_version,omitempty"` // Vars to getting the latest version of the Service. - *Alias `json:",inline"` + *Alias `json:",inline"` // Embed the original struct. }{ Name: s.Name, Comment: s.Comment, @@ -289,11 +294,11 @@ func (s *Service) UnmarshalYAML(value *yaml.Node) error { // Alias to avoid recursion. type Alias Service aux := &struct { - *Alias `yaml:",inline"` - Name *string `yaml:"name,omitempty"` // Name of the Service. - Comment *string `yaml:"comment,omitempty"` // Comment on the Service. - Options *opt.Options `yaml:"options,omitempty"` // Options to give the Service. - LatestVersion RawNode `yaml:"latest_version,omitempty"` // Temp LatestVersion field to get Type. + *Alias `yaml:",inline"` // Embed the original struct. + Name *string `yaml:"name,omitempty"` // Name of the Service. + Comment *string `yaml:"comment,omitempty"` // Comment on the Service. + Options *opt.Options `yaml:"options,omitempty"` // Options to give the Service. + LatestVersion util.RawNode `yaml:"latest_version,omitempty"` // Temp LatestVersion field to get Type. }{ Alias: (*Alias)(s), Name: &s.Name, @@ -357,15 +362,6 @@ func (s *Service) UnmarshalYAML(value *yaml.Node) error { return nil } -// RawNode is a struct that holds a *yaml.Node. -type RawNode struct{ *yaml.Node } - -// UnmarshalYAML handles the unmarshalling of a RawNode. -func (n *RawNode) UnmarshalYAML(node *yaml.Node) error { - n.Node = node - return nil -} - // MarshalYAML handles the marshalling of a Service. // // (dynamic typing). @@ -377,7 +373,7 @@ func (s *Service) MarshalYAML() (interface{}, error) { Comment string `yaml:"comment,omitempty"` // Comment on the Service. Options opt.Options `yaml:"options,omitempty"` // Options to give the Service. LatestVersion latestver.Lookup `yaml:"latest_version,omitempty"` // Vars to getting the latest version of the Service. - *Alias `yaml:",inline"` + *Alias `yaml:",inline"` // Embed the original struct. }{ Name: s.Name, Comment: s.Comment, diff --git a/service/types_test.go b/service/types_test.go index 8964a3a2..f51d1eee 100644 --- a/service/types_test.go +++ b/service/types_test.go @@ -480,7 +480,7 @@ func TestService_String(t *testing.T) { "https://example.com", nil, nil, nil)}, Dashboard: *NewDashboardOptions( - test.BoolPtr(true), "", "", "", + test.BoolPtr(true), "", "", "", nil, nil, nil), Defaults: &Defaults{ Options: *opt.NewDefaults( @@ -561,12 +561,8 @@ func TestService_String(t *testing.T) { func TestService_Summary(t *testing.T) { // GIVEN a Service tests := map[string]struct { - svc *Service - approvedVersion string - deployedVersion, deployedVersionTimestamp string - latestVersion, latestVersionTimestamp string - lastQueried string - want *apitype.ServiceSummary + svc *Service + want *apitype.ServiceSummary }{ "nil": { svc: nil, @@ -693,6 +689,15 @@ func TestService_Summary(t *testing.T) { HasDeployedVersionLookup: test.BoolPtr(false), Status: &apitype.Status{}}, }, + "only dashboard.tags": { + svc: &Service{ + Dashboard: DashboardOptions{ + Tags: []string{"hello", "there"}}}, + want: &apitype.ServiceSummary{ + Tags: &[]string{"hello", "there"}, + HasDeployedVersionLookup: test.BoolPtr(false), + Status: &apitype.Status{}}, + }, "only deployed_version": { svc: &Service{ DeployedVersionLookup: &deployedver.Lookup{}}, @@ -747,13 +752,12 @@ func TestService_Summary(t *testing.T) { }, "only status": { svc: &Service{ - Status: status.Status{}}, - approvedVersion: "1", - deployedVersion: "2", - deployedVersionTimestamp: "2-", - latestVersion: "3", - latestVersionTimestamp: "3-", - lastQueried: "4", + Status: *status.New( + nil, nil, nil, + "1", + "2", "2-", + "3", "3-", + "4")}, want: &apitype.ServiceSummary{ HasDeployedVersionLookup: test.BoolPtr(false), Status: &apitype.Status{ @@ -776,12 +780,6 @@ func TestService_Summary(t *testing.T) { len(tc.svc.Notify), len(tc.svc.Command), len(tc.svc.WebHook), &tc.svc.ID, &name, &tc.svc.Dashboard.WebURL) - if tc.approvedVersion != "" { - tc.svc.Status.SetApprovedVersion(tc.approvedVersion, false) - tc.svc.Status.SetDeployedVersion(tc.deployedVersion, tc.deployedVersionTimestamp, false) - tc.svc.Status.SetLatestVersion(tc.latestVersion, tc.latestVersionTimestamp, false) - tc.svc.Status.SetLastQueried(tc.lastQueried) - } } // WHEN the Service is converted to a ServiceSummary @@ -1116,6 +1114,50 @@ func TestService_UnmarshalJSON(t *testing.T) { URL: "https://valid.release-argus.io/plain", }}, }, + "dashboard.tags - []string": { + jsonData: `{ + "dashboard": { + "tags": [ + "foo", + "bar" + ] + } + }`, + errRegex: `^$`, + want: &Service{ + Dashboard: *NewDashboardOptions( + nil, "", "", "", + []string{"foo", "bar"}, + nil, nil), + }, + }, + "dashboard.tags - string": { + jsonData: `{ + "dashboard": { + "tags": "foo" + } + }`, + errRegex: `^$`, + want: &Service{ + Dashboard: *NewDashboardOptions( + nil, "", "", "", + []string{"foo"}, + nil, nil), + }, + }, + "dashboard.tags - invalid": { + jsonData: `{ + "dashboard": { + "tags": { + "foo": "bar" + } + } + }`, + errRegex: test.TrimYAML(` + failed to unmarshal Service: + error in tags field: + type: .*$`), + }, } for name, tc := range tests { @@ -1249,6 +1291,32 @@ func TestService_MarshalJSON(t *testing.T) { }`), errRegex: `^$`, }, + "service with tag": { + svc: &Service{ + Dashboard: DashboardOptions{ + Tags: []string{"foo"}}, + }, + want: test.TrimJSON(`{ + "options":{}, + "dashboard":{ + "tags":["foo"] + } + }`), + errRegex: `^$`, + }, + "service with tags": { + svc: &Service{ + Dashboard: DashboardOptions{ + Tags: []string{"foo", "bar"}}, + }, + want: test.TrimJSON(`{ + "options":{}, + "dashboard":{ + "tags":["foo","bar"] + } + }`), + errRegex: `^$`, + }, } for name, tc := range tests { @@ -1511,6 +1579,41 @@ func TestService_UnmarshalYAML(t *testing.T) { URL: "https://valid.release-argus.io/plain", }}, }, + "tags - []string": { + yamlData: ` + dashboard: + tags: + - foo + - bar + `, + errRegex: `^$`, + want: &Service{ + Dashboard: DashboardOptions{ + Tags: []string{"foo", "bar"}}, + }, + }, + "tags - string": { + yamlData: ` + dashboard: + tags: foo + `, + errRegex: `^$`, + want: &Service{ + Dashboard: DashboardOptions{ + Tags: []string{"foo"}}, + }, + }, + "tags - invalid": { + yamlData: ` + dashboard: + tags: + foo: bar + `, + errRegex: test.TrimYAML(` + ^failed to unmarshal Service: + error in tags field: + type: .*$`), + }, } for name, tc := range tests { @@ -1556,7 +1659,7 @@ func TestService_MarshalYAML(t *testing.T) { want: "{}\n", errRegex: `^$`, }, - "service with comment": { + "comment": { svc: &Service{ Comment: "test comment", }, @@ -1565,11 +1668,10 @@ func TestService_MarshalYAML(t *testing.T) { `), errRegex: `^$`, }, - "service with options": { + "options": { svc: &Service{ Options: opt.Options{ - Active: test.BoolPtr(true), - }, + Active: test.BoolPtr(true)}, }, want: test.TrimYAML(` options: @@ -1577,6 +1679,35 @@ func TestService_MarshalYAML(t *testing.T) { `), errRegex: `^$`, }, + "tags - single": { + svc: &Service{ + Dashboard: *NewDashboardOptions( + nil, "", "", "", + []string{"foo"}, + nil, nil), + }, + want: test.TrimYAML(` + dashboard: + tags: + - foo + `), + errRegex: `^$`, + }, + "tags - multiple": { + svc: &Service{ + Dashboard: *NewDashboardOptions( + nil, "", "", "", + []string{"foo", "bar"}, + nil, nil), + }, + want: test.TrimYAML(` + dashboard: + tags: + - foo + - bar + `), + errRegex: `^$`, + }, "service with latest version (GitHub)": { svc: &Service{ LatestVersion: &github.Lookup{ diff --git a/test/main.go b/test/main.go index 951009f7..b2846ec3 100644 --- a/test/main.go +++ b/test/main.go @@ -1,4 +1,4 @@ -// Copyright [2024] [Argus] +// Copyright [2025] [Argus] // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -50,6 +50,9 @@ func IntPtr(val int) *int { return &val } // StringPtr returns a pointer to the given string value. func StringPtr(val string) *string { return &val } +// StringSlicePtr returns a pointer to the given string slice. +func StringSlicePtr(val []string) *[]string { return &val } + // UInt8Ptr returns a pointer to the given unsigned integer value. func UInt8Ptr(val int) *uint8 { converted := uint8(val) diff --git a/util/slice.go b/util/slice.go new file mode 100644 index 00000000..1fe1c9cf --- /dev/null +++ b/util/slice.go @@ -0,0 +1,78 @@ +// Copyright [2025] [Argus] +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package util provides utility functions for the Argus project. +package util + +import "bytes" + +type customComparable interface { + bool | int | map[string]string | string | uint8 | uint16 +} + +// FirstNonNilPtr will return the first non-nil pointer in `pointers`. +func FirstNonNilPtr[T customComparable](pointers ...*T) *T { + for _, pointer := range pointers { + if pointer != nil { + return pointer + } + } + return nil +} + +// FirstNonDefault will return the first non-default var in `vars`. +func FirstNonDefault[T comparable](vars ...T) T { + var fresh T + for _, v := range vars { + if v != fresh { + return v + } + } + return fresh +} + +type comparableElement interface { + comparable + bool | int | string | uint8 | uint16 +} + +// AreStringSlicesEqual compares two slices of strings and returns true if they are identical. +// It checks both the length of the slices and the values at each index. +// If the slices have different lengths or any corresponding elements differ, it returns false. +func AreSlicesEqual[T comparableElement](slice1, slice2 []T) bool { + // Check if the lengths of the slices differ. + if len(slice1) != len(slice2) { + return false + } + + // Compare each element in the slices. + for i := range slice1 { + if slice1[i] != slice2[i] { + return false + } + } + + // All elements are identical. + return true +} + +// NormaliseNewlines all newlines in `data` to \n. +func NormaliseNewlines(data []byte) []byte { + // replace CR LF \r\n (Windows) with LF \n (Unix). + data = bytes.ReplaceAll(data, []byte{13, 10}, []byte{10}) + // replace CF \r (Mac) with LF \n (Unix). + data = bytes.ReplaceAll(data, []byte{13}, []byte{10}) + + return data +} diff --git a/util/slice_test.go b/util/slice_test.go new file mode 100644 index 00000000..c3232a0f --- /dev/null +++ b/util/slice_test.go @@ -0,0 +1,242 @@ +// Copyright [2025] [Argus] +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build unit + +package util + +import ( + "testing" + + "github.com/release-argus/Argus/test" +) + +func TestFirstNonNilPtr(t *testing.T) { + // GIVEN a bunch of pointers + tests := map[string]struct { + pointers []*string + allNil bool + wantIndex int + }{ + "no pointers": { + pointers: []*string{}, + allNil: true, + }, + "all nil pointers": { + pointers: []*string{ + nil, + nil, + nil, + nil}, + allNil: true, + }, + "1 non-nil pointer": { + pointers: []*string{ + nil, + nil, + nil, + test.StringPtr("bar")}, + wantIndex: 3, + }, + "2 non-nil pointers": { + pointers: []*string{ + test.StringPtr("foo"), + nil, + nil, + test.StringPtr("bar")}, + wantIndex: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // WHEN FirstNonNilPtr is run on a slice of pointers + got := FirstNonNilPtr(tc.pointers...) + + // THEN the correct pointer (or nil) is returned + if tc.allNil { + if got != nil { + t.Fatalf("got: %v\nfrom: %v", + got, tc.pointers) + } + return + } + if got != tc.pointers[tc.wantIndex] { + t.Errorf("want: %v\ngot: %v", + tc.pointers[tc.wantIndex], got) + } + }) + } +} + +func TestFirstNonDefault(t *testing.T) { + // GIVEN a bunch of comparables + tests := map[string]struct { + slice []string + allDefault bool + wantIndex int + }{ + "no vars": { + slice: []string{}, + allDefault: true, + }, + "all default vars": { + slice: []string{ + "", + "", + "", + ""}, + allDefault: true, + }, + "1 non-default var": { + slice: []string{ + "", + "", + "", + "bar"}, + wantIndex: 3, + }, + "2 non-default vars": { + slice: []string{ + "foo", + "", + "", + "bar"}, + wantIndex: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // WHEN FirstNonDefault is run on a slice of slice + got := FirstNonDefault(tc.slice...) + + // THEN the correct var (or "") is returned + if tc.allDefault { + if got != "" { + t.Fatalf("got: %v\nfrom: %v", + got, tc.slice) + } + return + } + if got != tc.slice[tc.wantIndex] { + t.Errorf("want: %v\ngot: %v", + tc.slice[tc.wantIndex], got) + } + }) + } +} + +func TestAreSlicesEqual(t *testing.T) { + // GIVEN different slices. + tests := map[string]struct { + slice1, slice2 []string + want bool + }{ + "both empty": { + slice1: []string{}, + slice2: []string{}, + want: true, + }, + "one empty": { + slice1: []string{"foo"}, + slice2: []string{}, + want: false, + }, + "same length, same elements": { + slice1: []string{"foo", "bar"}, + slice2: []string{"foo", "bar"}, + want: true, + }, + "different elements": { + slice1: []string{"foo", "bar"}, + slice2: []string{"bar", "foo"}, + want: false, + }, + "different lengths": { + slice1: []string{"foo", "bar"}, + slice2: []string{"foo"}, + want: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // WHEN AreSlicesEqual is called. + got := AreSlicesEqual(tc.slice1, tc.slice2) + + // THEN the result is as expected. + if got != tc.want { + t.Errorf("want: %v\ngot: %v", + tc.want, got) + } + }) + } +} + +func TestNormaliseNewlines(t *testing.T) { + // GIVEN different byte strings + tests := map[string]struct { + input, want []byte + }{ + "string with no newlines": { + input: []byte("hello there"), + want: []byte("hello there")}, + "string with linux newlines": { + input: []byte("hello\nthere"), + want: []byte("hello\nthere")}, + "string with multiple linux newlines": { + input: []byte("hello\nthere\n"), + want: []byte("hello\nthere\n")}, + "string with windows newlines": { + input: []byte("hello\r\nthere"), + want: []byte("hello\nthere")}, + "string with multiple windows newlines": { + input: []byte("hello\r\nthere\r\n"), + want: []byte("hello\nthere\n")}, + "string with mac newlines": { + input: []byte("hello\r\nthere"), + want: []byte("hello\nthere")}, + "string with multiple mac newlines": { + input: []byte("hello\r\nthere\r\n"), + want: []byte("hello\nthere\n")}, + "string with multiple mac and windows newlines": { + input: []byte("\rhello\r\nthere\r\n. hi\r"), + want: []byte("\nhello\nthere\n. hi\n")}, + "string with multiple mac, windows and linux newlines": { + input: []byte("\rhello\r\nthere\r\n. hi\r. foo\nbar\n"), + want: []byte("\nhello\nthere\n. hi\n. foo\nbar\n")}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // WHEN NormaliseNewlines is called + got := NormaliseNewlines(tc.input) + + // THEN the newlines are normalised correctly + if string(got) != string(tc.want) { + t.Errorf("want: %q\ngot: %q", + string(tc.want), string(got)) + } + }) + } +} diff --git a/util/types.go b/util/types.go index a3607e15..733e5058 100644 --- a/util/types.go +++ b/util/types.go @@ -22,6 +22,15 @@ import ( "gopkg.in/yaml.v3" ) +// RawNode is a struct that holds a *yaml.Node. +type RawNode struct{ *yaml.Node } + +// UnmarshalYAML handles the unmarshalling of a RawNode. +func (n *RawNode) UnmarshalYAML(node *yaml.Node) error { + n.Node = node + return nil +} + // UnmarshalConfig will unmarshal configuration data. // // Parameters: diff --git a/util/util.go b/util/util.go index a5afb4d6..0e5b294e 100644 --- a/util/util.go +++ b/util/util.go @@ -1,4 +1,4 @@ -// Copyright [2024] [Argus] +// Copyright [2025] [Argus] // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -90,31 +90,6 @@ func DereferenceOrValue[T comparable](check *T, value T) T { return value } -type customComparable interface { - bool | int | map[string]string | string | uint8 | uint16 -} - -// FirstNonNilPtr will return the first non-nil pointer in `pointers`. -func FirstNonNilPtr[T customComparable](pointers ...*T) *T { - for _, pointer := range pointers { - if pointer != nil { - return pointer - } - } - return nil -} - -// FirstNonDefault will return the first non-default var in `vars`. -func FirstNonDefault[T comparable](vars ...T) T { - var fresh T - for _, v := range vars { - if v != fresh { - return v - } - } - return fresh -} - // PtrValueOrValue will return the value of `ptr` if non-nil, otherwise `fallback`. func PtrValueOrValue[T comparable](ptr *T, fallback T) T { if ptr != nil { @@ -133,16 +108,6 @@ func CopyPointer[T comparable](ptr *T) *T { return &val } -// NormaliseNewlines all newlines in `data` to \n. -func NormaliseNewlines(data []byte) []byte { - // replace CR LF \r\n (Windows) with LF \n (Unix). - data = bytes.ReplaceAll(data, []byte{13, 10}, []byte{10}) - // replace CF \r (Mac) with LF \n (Unix). - data = bytes.ReplaceAll(data, []byte{13}, []byte{10}) - - return data -} - // CopySecretValues loops through 'fields' and replace values in 'to' of 'SecretValue' with values in 'from'. // if non-empty. func CopySecretValues(from, to map[string]string, fields []string) { diff --git a/util/util_test.go b/util/util_test.go index 032e6c70..f8788055 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -1,4 +1,4 @@ -// Copyright [2024] [Argus] +// Copyright [2025] [Argus] // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -223,126 +223,6 @@ func TestDereferenceOrValue(t *testing.T) { } } -func TestFirstNonNilPtr(t *testing.T) { - // GIVEN a bunch of pointers - tests := map[string]struct { - pointers []*string - allNil bool - wantIndex int - }{ - "no pointers": { - pointers: []*string{}, - allNil: true, - }, - "all nil pointers": { - pointers: []*string{ - nil, - nil, - nil, - nil}, - allNil: true, - }, - "1 non-nil pointer": { - pointers: []*string{ - nil, - nil, - nil, - test.StringPtr("bar")}, - wantIndex: 3, - }, - "2 non-nil pointers": { - pointers: []*string{ - test.StringPtr("foo"), - nil, - nil, - test.StringPtr("bar")}, - wantIndex: 0, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - // WHEN FirstNonNilPtr is run on a slice of pointers - got := FirstNonNilPtr(tc.pointers...) - - // THEN the correct pointer (or nil) is returned - if tc.allNil { - if got != nil { - t.Fatalf("got: %v\nfrom: %v", - got, tc.pointers) - } - return - } - if got != tc.pointers[tc.wantIndex] { - t.Errorf("want: %v\ngot: %v", - tc.pointers[tc.wantIndex], got) - } - }) - } -} - -func TestFirstNonDefault(t *testing.T) { - // GIVEN a bunch of comparables - tests := map[string]struct { - slice []string - allDefault bool - wantIndex int - }{ - "no vars": { - slice: []string{}, - allDefault: true, - }, - "all default vars": { - slice: []string{ - "", - "", - "", - ""}, - allDefault: true, - }, - "1 non-default var": { - slice: []string{ - "", - "", - "", - "bar"}, - wantIndex: 3, - }, - "2 non-default vars": { - slice: []string{ - "foo", - "", - "", - "bar"}, - wantIndex: 0, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - // WHEN FirstNonDefault is run on a slice of slice - got := FirstNonDefault(tc.slice...) - - // THEN the correct var (or "") is returned - if tc.allDefault { - if got != "" { - t.Fatalf("got: %v\nfrom: %v", - got, tc.slice) - } - return - } - if got != tc.slice[tc.wantIndex] { - t.Errorf("want: %v\ngot: %v", - tc.slice[tc.wantIndex], got) - } - }) - } -} - func TestPtrValueOrValue(t *testing.T) { // GIVEN a bunch of comparables pointers and values tests := map[string]struct { @@ -423,56 +303,6 @@ func TestCopyPointer(t *testing.T) { } } -func TestNormaliseNewlines(t *testing.T) { - // GIVEN different byte strings - tests := map[string]struct { - input, want []byte - }{ - "string with no newlines": { - input: []byte("hello there"), - want: []byte("hello there")}, - "string with linux newlines": { - input: []byte("hello\nthere"), - want: []byte("hello\nthere")}, - "string with multiple linux newlines": { - input: []byte("hello\nthere\n"), - want: []byte("hello\nthere\n")}, - "string with windows newlines": { - input: []byte("hello\r\nthere"), - want: []byte("hello\nthere")}, - "string with multiple windows newlines": { - input: []byte("hello\r\nthere\r\n"), - want: []byte("hello\nthere\n")}, - "string with mac newlines": { - input: []byte("hello\r\nthere"), - want: []byte("hello\nthere")}, - "string with multiple mac newlines": { - input: []byte("hello\r\nthere\r\n"), - want: []byte("hello\nthere\n")}, - "string with multiple mac and windows newlines": { - input: []byte("\rhello\r\nthere\r\n. hi\r"), - want: []byte("\nhello\nthere\n. hi\n")}, - "string with multiple mac, windows and linux newlines": { - input: []byte("\rhello\r\nthere\r\n. hi\r. foo\nbar\n"), - want: []byte("\nhello\nthere\n. hi\n. foo\nbar\n")}, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - // WHEN NormaliseNewlines is called - got := NormaliseNewlines(tc.input) - - // THEN the newlines are normalised correctly - if string(got) != string(tc.want) { - t.Errorf("want: %q\ngot: %q", - string(tc.want), string(got)) - } - }) - } -} - func TestCopySecretValues(t *testing.T) { // GIVEN maps with secrets to be copied tests := map[string]struct { diff --git a/web/api/types/argus.go b/web/api/types/argus.go index 90a62412..96ad8053 100644 --- a/web/api/types/argus.go +++ b/web/api/types/argus.go @@ -26,18 +26,19 @@ import ( // ServiceSummary is the Summary of a Service. type ServiceSummary struct { - ID string `json:"id,omitempty" yaml:"id,omitempty"` - Name *string `json:"name,omitempty" yaml:"name,omitempty"` // Name for this Service. - Active *bool `json:"active,omitempty" yaml:"active,omitempty"` // Active Service? - Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` // Comment on the Service. - Type string `json:"type,omitempty" yaml:"type,omitempty"` // "github"/"URL". - WebURL string `json:"url,omitempty" yaml:"url,omitempty"` // URL to provide on the Web UI. - Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` // Service.Dashboard.Icon / Service.Notify.*.Params.Icon / Service.Notify.*.Defaults.Params.Icon. - IconLinkTo string `json:"icon_link_to,omitempty" yaml:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. - HasDeployedVersionLookup *bool `json:"has_deployed_version,omitempty" yaml:"has_deployed_version,omitempty"` // Whether this service has a DeployedVersionLookup. - Command int `json:"command,omitempty" yaml:"command,omitempty"` // Amount of Commands to send on a new release. - WebHook int `json:"webhook,omitempty" yaml:"webhook,omitempty"` // Amount of WebHooks to send on a new release. - Status *Status `json:"status,omitempty" yaml:"status,omitempty"` // Track the Status of this source (version and regex misses). + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name *string `json:"name,omitempty" yaml:"name,omitempty"` // Name for this Service. + Active *bool `json:"active,omitempty" yaml:"active,omitempty"` // Active Service? + Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` // Comment on the Service. + Type string `json:"type,omitempty" yaml:"type,omitempty"` // "github"/"URL". + WebURL string `json:"url,omitempty" yaml:"url,omitempty"` // URL to provide on the Web UI. + Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` // Service.Dashboard.Icon / Service.Notify.*.Params.Icon / Service.Notify.*.Defaults.Params.Icon. + IconLinkTo string `json:"icon_link_to,omitempty" yaml:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. + HasDeployedVersionLookup *bool `json:"has_deployed_version,omitempty" yaml:"has_deployed_version,omitempty"` // Whether this service has a DeployedVersionLookup. + Command int `json:"command,omitempty" yaml:"command,omitempty"` // Amount of Commands to send on a new release. + WebHook int `json:"webhook,omitempty" yaml:"webhook,omitempty"` // Amount of WebHooks to send on a new release. + Status *Status `json:"status,omitempty" yaml:"status,omitempty"` // Track the Status of this source (version and regex misses). + Tags *[]string `json:"tags,omitempty" yaml:"tags,omitempty"` // Tags for the Service. } // String returns a JSON string representation of the ServiceSummary. @@ -106,18 +107,18 @@ func (s *ServiceSummary) RemoveUnchanged(oldData *ServiceSummary) { // Status statusSameCount := 0 - // Status.ApprovedVersion + // ApprovedVersion if oldData.Status.ApprovedVersion == s.Status.ApprovedVersion { s.Status.ApprovedVersion = "" statusSameCount++ } - // Status.DeployedVersion + // DeployedVersion if oldData.Status.DeployedVersion == s.Status.DeployedVersion { s.Status.DeployedVersion = "" s.Status.DeployedVersionTimestamp = "" statusSameCount++ } - // Status.LatestVersion + // LatestVersion if oldData.Status.LatestVersion == s.Status.LatestVersion { s.Status.LatestVersion = "" s.Status.LatestVersionTimestamp = "" @@ -127,6 +128,15 @@ func (s *ServiceSummary) RemoveUnchanged(oldData *ServiceSummary) { if statusSameCount == 3 { s.Status = nil } + + // Tags - Removed. + if oldData.Tags != nil && s.Tags == nil { + emptyTags := []string{} + s.Tags = &emptyTags + // Unchanged. + } else if oldData.Tags != nil && s.Tags != nil && util.AreSlicesEqual(*oldData.Tags, *s.Tags) { + s.Tags = nil + } } // Status is the Status of a Service. @@ -407,10 +417,11 @@ type ServiceOptions struct { // DashboardOptions defines configuration options for a service on the Web UI dashboard. type DashboardOptions struct { - AutoApprove *bool `json:"auto_approve,omitempty" yaml:"auto_approve,omitempty"` // Default - true = Require approval before actioning new releases. - Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` // Icon URL to use for messages/Web UI. - IconLinkTo string `json:"icon_link_to,omitempty" yaml:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. - WebURL string `json:"web_url,omitempty" yaml:"web_url,omitempty"` // URL to provide on the Web UI. + AutoApprove *bool `json:"auto_approve,omitempty" yaml:"auto_approve,omitempty"` // Default - true = Require approval before actioning new releases. + Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` // Icon URL to use for messages/Web UI. + IconLinkTo string `json:"icon_link_to,omitempty" yaml:"icon_link_to,omitempty"` // URL to redirect Icon clicks to. + WebURL string `json:"web_url,omitempty" yaml:"web_url,omitempty"` // URL to provide on the Web UI. + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` // Tags for the Service. } // LatestVersion lookup of the service. diff --git a/web/api/types/argus_test.go b/web/api/types/argus_test.go index a7165d68..b6acab76 100644 --- a/web/api/types/argus_test.go +++ b/web/api/types/argus_test.go @@ -822,6 +822,35 @@ func TestServiceSummary_RemoveUnchanged(t *testing.T) { DeployedVersion: "4.5.6", DeployedVersionTimestamp: "2020-02-02T00:00:00Z"}}, }, + "tags added": { + old: &ServiceSummary{}, + new: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + want: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + }, + "tags removed": { + old: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + new: &ServiceSummary{}, + want: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{})}, + }, + "same tags": { + old: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + new: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + want: &ServiceSummary{}, + }, + "different tags": { + old: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"foo"})}, + new: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"bar"})}, + want: &ServiceSummary{ + Tags: test.StringSlicePtr([]string{"bar"})}, + }, } for name, tc := range tests { diff --git a/web/api/v1/convert.go b/web/api/v1/convert.go index 13912845..d4c87bcd 100644 --- a/web/api/v1/convert.go +++ b/web/api/v1/convert.go @@ -101,7 +101,8 @@ func convertAndCensorService(service *service.Service) *apitype.Service { AutoApprove: service.Dashboard.AutoApprove, Icon: service.Dashboard.Icon, IconLinkTo: service.Dashboard.IconLinkTo, - WebURL: service.Dashboard.WebURL} + WebURL: service.Dashboard.WebURL, + Tags: service.Dashboard.Tags} return &apiService } diff --git a/web/api/v1/convert_test.go b/web/api/v1/convert_test.go index 5a4b5606..c8ff1b7b 100644 --- a/web/api/v1/convert_test.go +++ b/web/api/v1/convert_test.go @@ -209,7 +209,8 @@ func TestConvertAndCensorService(t *testing.T) { Dashboard: *service.NewDashboardOptions( nil, "https://example.com/icon.png", - "", "", nil, nil), + "", "", nil, + nil, nil), Status: *status.New( nil, nil, nil, "2.0.0", diff --git a/web/help_test.go b/web/help_test.go index 1a8760b4..bbca262b 100644 --- a/web/help_test.go +++ b/web/help_test.go @@ -183,7 +183,7 @@ func testService(t *testing.T, id string) (svc *service.Service) { test.BoolPtr(true), &opt.Defaults{}, &opt.Defaults{}), Dashboard: *service.NewDashboardOptions( - test.BoolPtr(false), "test", "", "https://release-argus.io", + test.BoolPtr(false), "test", "", "https://release-argus.io", nil, &service.DashboardOptionsDefaults{}, &service.DashboardOptionsDefaults{}), Defaults: &service.Defaults{}, HardDefaults: &service.Defaults{}, diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 01824521..925eaa43 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -31,7 +31,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", @@ -87,7 +86,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.26.3", @@ -121,7 +119,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.9", @@ -163,7 +160,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -173,7 +169,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -207,7 +202,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.26.3" @@ -272,7 +266,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.25.9", @@ -287,7 +280,6 @@ "version": "7.26.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -306,7 +298,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -316,6 +307,179 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -741,6 +905,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", @@ -802,7 +991,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -814,7 +1002,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -824,7 +1011,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -843,14 +1029,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -1295,6 +1479,12 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -1372,6 +1562,21 @@ "resolved": "react-app", "link": true }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/bootstrap": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", @@ -1431,10 +1636,19 @@ "optional": true, "peer": true }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001692", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz", - "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==", + "version": "1.0.30001695", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", + "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", "dev": true, "funding": [ { @@ -1473,6 +1687,31 @@ "node": ">=18" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -1490,7 +1729,6 @@ }, "node_modules/debug": { "version": "4.3.4", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -1529,6 +1767,15 @@ "dev": true, "license": "ISC" }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/esbuild": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", @@ -1580,6 +1827,24 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1594,6 +1859,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1608,7 +1882,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -1620,6 +1893,43 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -1628,6 +1938,27 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -1636,7 +1967,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -1645,6 +1975,12 @@ "node": ">=6" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1658,6 +1994,12 @@ "node": ">=6" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -1678,9 +2020,14 @@ "yallist": "^3.0.2" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1716,11 +2063,55 @@ "node": ">=0.10.0" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/postcss": { @@ -1902,6 +2293,27 @@ "react-dom": ">=18" } }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -1921,6 +2333,35 @@ "version": "4.4.0", "license": "MIT" }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rollup": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", @@ -1982,6 +2423,15 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2015,6 +2465,24 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/terser": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.0.tgz", @@ -2141,6 +2609,20 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.9.tgz", @@ -2263,6 +2745,8 @@ "name": "argus", "version": "0.19.4", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", @@ -2275,6 +2759,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router-dom": "^7.1.1", + "react-select": "^5.9.0", "reconnecting-websocket": "^4.4.0", "yaml": "^2.7.0" }, diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 73d55c3e..5ab718cd 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -6,6 +6,8 @@ "private": true, "type": "module", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", @@ -18,6 +20,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router-dom": "^7.1.1", + "react-select": "^5.9.0", "reconnecting-websocket": "^4.4.0", "yaml": "^2.7.0" }, diff --git a/web/ui/react-app/src/components/approvals/index.tsx b/web/ui/react-app/src/components/approvals/index.tsx index 2d75dbd1..429cb9d6 100644 --- a/web/ui/react-app/src/components/approvals/index.tsx +++ b/web/ui/react-app/src/components/approvals/index.tsx @@ -1,4 +1,4 @@ -import ApprovalsToolbar from './toolbar'; +import ApprovalsToolbar from './toolbar/toolbar'; import Service from './service'; import { ServiceImage } from './service-image'; import { ServiceInfo } from './service-info'; diff --git a/web/ui/react-app/src/components/approvals/service-info.tsx b/web/ui/react-app/src/components/approvals/service-info.tsx index ea64643e..7fe01a0e 100644 --- a/web/ui/react-app/src/components/approvals/service-info.tsx +++ b/web/ui/react-app/src/components/approvals/service-info.tsx @@ -31,7 +31,7 @@ interface Props { } /** - * The service's information, including the lqtest version, the deployed version, + * The service's information, including the latest version, the deployed version, * the last time the service was queried, and the update options if latest and deployed versions differ. * * @param service - The service the information belongs to. @@ -149,8 +149,8 @@ export const ServiceInfo: FC = ({ status.not_found ? delayedRender(() => 'service-warning rounded-bottom', 'default') : status.warning - ? 'service-warning rounded-bottom' - : 'default' + ? 'service-warning rounded-bottom' + : 'default' } > @@ -215,8 +215,8 @@ export const ServiceInfo: FC = ({ status.not_found ? delayedRender(() => 'warning', 'secondary') : status.warning - ? 'warning' - : 'secondary' + ? 'warning' + : 'secondary' } className={ 'service-item' + @@ -267,8 +267,8 @@ export const ServiceInfo: FC = ({ (status.not_found ? delayedRender(() => ' service-warning rounded-bottom', '') : status.warning - ? ' service-warning rounded-bottom' - : '') + ? ' service-warning rounded-bottom' + : '') } > {service?.status?.last_queried ? ( diff --git a/web/ui/react-app/src/components/approvals/toolbar.tsx b/web/ui/react-app/src/components/approvals/toolbar.tsx deleted file mode 100644 index 70f86300..00000000 --- a/web/ui/react-app/src/components/approvals/toolbar.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { - Button, - Dropdown, - DropdownButton, - Form, - FormControl, - InputGroup, -} from 'react-bootstrap'; -import { FC, memo, useContext, useEffect, useMemo, useRef } from 'react'; -import { ModalType, ServiceSummaryType } from 'types/summary'; -import { - faEye, - faPen, - faPlus, - faTimes, -} from '@fortawesome/free-solid-svg-icons'; - -import { ApprovalsToolbarOptions } from 'types/util'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { ModalContext } from 'contexts/modal'; - -type TypeMappingItem = string | boolean | number | number[]; -type Props = { - values: ApprovalsToolbarOptions; - setValues: React.Dispatch>; -}; - -/** - * The toolbar for the approvals page, including a search bar, hide options, and edit mode toggle. - * - Hide options - Select box with filters to hide services that are up-to-date, updatable, skipped, or inactive. - * - Edit mode - Toggles the ability to add/edit services. - * - Search bar - Filter services by name. - * - * @param values - The values of the toolbar. - * @param setValues - The function to set the values of the toolbar. - * @returns A component that displays the toolbar for the approvals page. - */ -const ApprovalsToolbar: FC = ({ values, setValues }) => { - const setValue = (param: keyof typeof values, value: TypeMappingItem) => { - setValues((prevState) => ({ - ...prevState, - [param]: value as (typeof values)[typeof param], - })); - }; - - const optionsMap = useMemo( - () => ({ - upToDate: () => setValue('hide', toggleHideValue(0)), - updatable: () => setValue('hide', toggleHideValue(1)), - skipped: () => setValue('hide', toggleHideValue(2)), - inactive: () => setValue('hide', toggleHideValue(3)), - reset: () => setValue('hide', [3]), - flipAllHideOptions: () => setValue('hide', toggleAllHideValues()), - }), - [values.hide], - ); - - const toggleHideValue = (value: number) => - values.hide.includes(value) - ? values.hide.filter((v) => v !== value) - : [...values.hide, value]; - - const toggleAllHideValues = () => - [0, 1, 2, 3].filter((n) => !(n !== 3 && values.hide.includes(n))); - - const handleOption = (option: string) => { - const hideUpdatable = values.hide.includes(0); - const hideUpToDate = values.hide.includes(1); - const hideSkipped = values.hide.includes(2); - switch (option) { - case 'upToDate': // 0 - hideUpToDate && hideSkipped // 1 && 2 - ? optionsMap.flipAllHideOptions() - : optionsMap.upToDate(); - break; - case 'updatable': // 1 - hideUpdatable && hideSkipped // 0 && 2 - ? optionsMap.flipAllHideOptions() - : optionsMap.updatable(); - break; - case 'skipped': // 2 - hideUpdatable && hideUpToDate // 0 && 1 - ? optionsMap.flipAllHideOptions() - : optionsMap.skipped(); - break; - case 'inactive': // 3 - optionsMap.inactive(); - break; - case 'reset': - optionsMap.reset(); - break; - } - }; - - const toggleEditMode = () => { - setValue('editMode', !values.editMode); - }; - - const { handleModal } = useContext(ModalContext); - const showModal = useMemo( - () => (type: ModalType, service: ServiceSummaryType) => { - handleModal(type, service); - }, - [], - ); - - const searchInputRef = useRef(null); - useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { - // Ignore when in an input/textarea - if ( - event.target instanceof HTMLInputElement || - event.target instanceof HTMLTextAreaElement - ) { - return; - } - - if (event.key === '/') { - // Focus on the search box. - event.preventDefault(); - searchInputRef.current?.focus(); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - // Escape pressed and we are in the search box. - if ( - event.key === 'Escape' && - searchInputRef.current && - document.activeElement === searchInputRef.current - ) { - searchInputRef.current.blur(); - setValue('search', ''); // Clear search on escape - } - }; - - // Add event listener - window.addEventListener('keydown', handleKeyPress); - window.addEventListener('keydown', handleEscape); - - // Clean up the event listener on unmount - return () => { - window.removeEventListener('keydown', handleKeyPress); - window.removeEventListener('keydown', handleEscape); - }; - }, []); - - return ( -
- - setValue('search', e.target.value)} - aria-label="Filter services" - /> - {values.search.length > 0 && ( - - )} - - } - > - handleOption('upToDate')} - > - Hide up-to-date - - handleOption('updatable')} - > - Hide updatable - - handleOption('skipped')} - > - Hide skipped - - handleOption('inactive')} - > - Hide inactive - - - handleOption('reset')}> - Reset - - - {values.editMode && ( - - )} - -
- ); -}; - -export default memo(ApprovalsToolbar); diff --git a/web/ui/react-app/src/components/approvals/toolbar/edit-mode-toggle.tsx b/web/ui/react-app/src/components/approvals/toolbar/edit-mode-toggle.tsx new file mode 100644 index 00000000..d35b55da --- /dev/null +++ b/web/ui/react-app/src/components/approvals/toolbar/edit-mode-toggle.tsx @@ -0,0 +1,34 @@ +import { FC, useContext } from 'react'; +import { faPen, faPlus } from '@fortawesome/free-solid-svg-icons'; + +import { Button } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ModalContext } from 'contexts/modal'; + +type Props = { + editMode: boolean; + toggleEditMode: () => void; +}; + +const EditModeToggle: FC = ({ editMode, toggleEditMode }) => { + const { handleModal } = useContext(ModalContext); + + return ( + <> + {editMode && ( + + )} + + + ); +}; + +export default EditModeToggle; diff --git a/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx b/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx new file mode 100644 index 00000000..744da286 --- /dev/null +++ b/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx @@ -0,0 +1,95 @@ +import { ButtonGroup, Dropdown } from 'react-bootstrap'; +import { FC, useMemo } from 'react'; + +import { ApprovalsToolbarOptions } from 'types/util'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEye } from '@fortawesome/free-solid-svg-icons'; + +type Props = { + values: number[]; + setValue: (key: keyof ApprovalsToolbarOptions, value: number[]) => void; +}; + +const FilterDropdown: FC = ({ values, setValue }) => { + const hideOptions = [ + { key: 'upToDate', label: 'Hide up-to-date', value: 0 }, + { key: 'updatable', label: 'Hide updatable', value: 1 }, + { key: 'skipped', label: 'Hide skipped', value: 2 }, + { key: 'inactive', label: 'Hide inactive', value: 3 }, + ]; + + const optionsMap = useMemo( + () => ({ + upToDate: () => setValue('hide', toggleHideValue(0)), + updatable: () => setValue('hide', toggleHideValue(1)), + skipped: () => setValue('hide', toggleHideValue(2)), + inactive: () => setValue('hide', toggleHideValue(3)), + reset: () => setValue('hide', [3]), + flipAllHideOptions: () => setValue('hide', toggleAllHideValues()), + }), + [values], + ); + + const toggleHideValue = (value: number) => + values.includes(value) + ? values.filter((v) => v !== value) + : [...values, value]; + + const toggleAllHideValues = () => + [0, 1, 2, 3].filter((n) => !(n !== 3 && values.includes(n))); + + const handleOption = (option: string) => { + const hideUpdatable = values.includes(0); + const hideUpToDate = values.includes(1); + const hideSkipped = values.includes(2); + switch (option) { + case 'upToDate': // 0 + hideUpToDate && hideSkipped // 1 && 2 + ? optionsMap.flipAllHideOptions() + : optionsMap.upToDate(); + break; + case 'updatable': // 1 + hideUpdatable && hideSkipped // 0 && 2 + ? optionsMap.flipAllHideOptions() + : optionsMap.updatable(); + break; + case 'skipped': // 2 + hideUpdatable && hideUpToDate // 0 && 1 + ? optionsMap.flipAllHideOptions() + : optionsMap.skipped(); + break; + case 'inactive': // 3 + optionsMap.inactive(); + break; + case 'reset': + optionsMap.reset(); + break; + } + }; + + return ( + + + + + + {hideOptions.map(({ key, label, value }) => ( + handleOption(key)} + > + {label} + + ))} + + handleOption('reset')}> + Reset + + + + ); +}; + +export default FilterDropdown; diff --git a/web/ui/react-app/src/components/approvals/toolbar/search-bar.tsx b/web/ui/react-app/src/components/approvals/toolbar/search-bar.tsx new file mode 100644 index 00000000..bec363d7 --- /dev/null +++ b/web/ui/react-app/src/components/approvals/toolbar/search-bar.tsx @@ -0,0 +1,90 @@ +import { Button, FormControl, InputGroup } from 'react-bootstrap'; +import { FC, useEffect, useRef, useState } from 'react'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +type Props = { + search: string; + setSearch: (value: string) => void; +}; + +const SearchBar: FC = ({ search, setSearch }) => { + const searchInputRef = useRef(null); + const [placeholder, setPlaceholder] = useState(''); + + // Update placeholder based on screen size + const updatePlaceholder = () => { + const newPlaceholder = + window.innerWidth <= 576 + ? 'Filter services' + : 'Type "/" to filter services'; + + // Only update if the placeholder text is different + if (newPlaceholder !== placeholder) { + setPlaceholder(newPlaceholder); + } + }; + + useEffect(() => { + updatePlaceholder(); + window.addEventListener('resize', updatePlaceholder); + + // Cleanup listener on unmount + return () => window.removeEventListener('resize', updatePlaceholder); + }, []); + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + return; + } + if (event.key === '/') { + event.preventDefault(); + searchInputRef.current?.focus(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if ( + event.key === 'Escape' && + searchInputRef.current && + document.activeElement === searchInputRef.current + ) { + searchInputRef.current.blur(); + setSearch(''); + } + }; + + window.addEventListener('keydown', handleKeyPress); + window.addEventListener('keydown', handleEscape); + + return () => { + window.removeEventListener('keydown', handleKeyPress); + window.removeEventListener('keydown', handleEscape); + }; + }, [setSearch]); + + return ( + + setSearch(e.target.value)} + /> + {search && ( + + )} + + ); +}; + +export default SearchBar; diff --git a/web/ui/react-app/src/components/approvals/toolbar/tag-select.tsx b/web/ui/react-app/src/components/approvals/toolbar/tag-select.tsx new file mode 100644 index 00000000..54fa4a73 --- /dev/null +++ b/web/ui/react-app/src/components/approvals/toolbar/tag-select.tsx @@ -0,0 +1,48 @@ +import { FC, useMemo } from 'react'; +import Select, { MultiValue } from 'react-select'; +import { + convertStringArrayToOptionTypeArray, + customComponents, + customStylesFixedHeight, +} from 'components/generic/form-select-shared'; + +import { Col } from 'react-bootstrap'; +import { OptionType } from 'types/util'; +import { useWebSocket } from 'contexts/websocket'; + +type Props = { + tags: string[]; + setTags: (tags: string[]) => void; +}; + +const TagSelect: FC = ({ tags, setTags }) => { + const { monitorData } = useWebSocket(); + + const tagOptions = useMemo( + () => convertStringArrayToOptionTypeArray(Array.from(monitorData.tags)), + [Array.from(monitorData?.tags ?? []).join(',')], + ); + if (tagOptions.length === 0) return null; + + return ( + +