From 2c290a3916e79beb8329eac7cc1b99f4526d7451 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 13 Sep 2025 23:52:54 +0800 Subject: [PATCH] feat(homepage): enhance homepage functionality with new item click tracking, sort methods and category management - Added ItemClick endpoint to increment item click counts. - Refactored Categories function to dynamically generate categories based on available items. - Introduced sorting methods for homepage items and categories. - Updated item configuration to include visibility, favorite status, and sort orders. - Improved handling of item URLs and added support for websocket connections in item retrieval. --- internal/api/v1/homepage/categories.go | 22 +++- internal/api/v1/homepage/item_click.go | 36 +++++ internal/api/v1/homepage/items.go | 99 +++++++++++++- internal/api/v1/homepage/overrides.go | 140 +++++++++++++++----- internal/docker/container.go | 9 +- internal/homepage/homepage.go | 175 +++++++++++++++++++++---- internal/homepage/homepage_test.go | 108 ++++++++++++++- internal/homepage/list_icons.go | 11 +- internal/homepage/list_icons_test.go | 6 +- internal/homepage/override_config.go | 105 +++++++++++---- internal/types/docker.go | 2 + internal/types/routes.go | 4 +- 12 files changed, 612 insertions(+), 105 deletions(-) create mode 100644 internal/api/v1/homepage/item_click.go diff --git a/internal/api/v1/homepage/categories.go b/internal/api/v1/homepage/categories.go index 08e524cf..b1a676f6 100644 --- a/internal/api/v1/homepage/categories.go +++ b/internal/api/v1/homepage/categories.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/route/routes" ) @@ -18,5 +19,24 @@ import ( // @Failure 403 {object} apitypes.ErrorResponse // @Router /homepage/categories [get] func Categories(c *gin.Context) { - c.JSON(http.StatusOK, routes.HomepageCategories()) + c.JSON(http.StatusOK, HomepageCategories()) +} + +func HomepageCategories() []string { + check := make(map[string]struct{}) + categories := make([]string, 0) + categories = append(categories, homepage.CategoryAll) + categories = append(categories, homepage.CategoryFavorites) + for _, r := range routes.HTTP.Iter { + item := r.HomepageItem() + if item.Category == "" { + continue + } + if _, ok := check[item.Category]; ok { + continue + } + check[item.Category] = struct{}{} + categories = append(categories, item.Category) + } + return categories } diff --git a/internal/api/v1/homepage/item_click.go b/internal/api/v1/homepage/item_click.go new file mode 100644 index 00000000..654ddf17 --- /dev/null +++ b/internal/api/v1/homepage/item_click.go @@ -0,0 +1,36 @@ +package homepageapi + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/homepage" +) + +type HomepageOverrideItemClickParams struct { + Which string `form:"which" binding:"required"` +} // @name HomepageOverrideItemClickParams + +// @x-id "item-click" +// @BasePath /api/v1 +// @Summary Increment item click +// @Description Increment item click. +// @Tags homepage +// @Accept json +// @Produce json +// @Param request query HomepageOverrideItemClickParams true "Increment item click" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/item_click [post] +func ItemClick(c *gin.Context) { + var params HomepageOverrideItemClickParams + if err := c.ShouldBindQuery(¶ms); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + overrides := homepage.GetOverrideConfig() + overrides.IncrementItemClicks(params.Which) + c.JSON(http.StatusOK, apitypes.Success("success")) +} diff --git a/internal/api/v1/homepage/items.go b/internal/api/v1/homepage/items.go index 60d1f986..823f3cfc 100644 --- a/internal/api/v1/homepage/items.go +++ b/internal/api/v1/homepage/items.go @@ -1,27 +1,38 @@ package homepageapi import ( + "fmt" "net/http" + "net/url" + "slices" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/lithammer/fuzzysearch/fuzzy" apitypes "github.com/yusing/go-proxy/internal/api/types" + "github.com/yusing/go-proxy/internal/homepage" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" + "github.com/yusing/go-proxy/internal/net/gphttp/websocket" "github.com/yusing/go-proxy/internal/route/routes" ) type HomepageItemsRequest struct { - Category string `form:"category" validate:"omitempty"` - Provider string `form:"provider" validate:"omitempty"` + SearchQuery string `form:"search"` // Search query + Category string `form:"category"` // Category filter + Provider string `form:"provider"` // Provider filter + // Sort method + SortMethod homepage.SortMethod `form:"sort_method" default:"alphabetical" binding:"omitempty,oneof=clicks alphabetical custom"` } // @name HomepageItemsRequest // @x-id "items" // @BasePath /api/v1 // @Summary Homepage items // @Description Homepage items -// @Tags homepage +// @Tags homepage,websocket // @Accept json // @Produce json -// @Param category query string false "Category filter" -// @Param provider query string false "Provider filter" +// @Param query query HomepageItemsRequest false "Query parameters" // @Success 200 {object} homepage.Homepage // @Failure 400 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse @@ -42,5 +53,81 @@ func Items(c *gin.Context) { hostname = host } - c.JSON(http.StatusOK, routes.HomepageItems(proto, hostname, request.Category, request.Provider)) + if httpheaders.IsWebsocket(c.Request.Header) { + websocket.PeriodicWrite(c, 2*time.Second, func() (any, error) { + return HomepageItems(proto, hostname, &request), nil + }) + } else { + c.JSON(http.StatusOK, HomepageItems(proto, hostname, &request)) + } +} + +func HomepageItems(proto, hostname string, request *HomepageItemsRequest) homepage.Homepage { + switch proto { + case "http", "https": + default: + proto = "http" + } + + hp := homepage.NewHomepageMap(routes.HTTP.Size()) + + if strings.Count(hostname, ".") > 1 { + _, hostname, _ = strings.Cut(hostname, ".") // remove the subdomain + } + + for _, r := range routes.HTTP.Iter { + if request.Provider != "" && r.ProviderName() != request.Provider { + continue + } + item := r.HomepageItem() + if request.Category != "" && item.Category != request.Category { + continue + } + if request.SearchQuery != "" && !fuzzy.MatchFold(request.SearchQuery, item.Name) { + continue + } + + // clear url if invalid + _, err := url.Parse(item.URL) + if err != nil { + item.URL = "" + } + + // append hostname if provided and only if alias is not FQDN + if hostname != "" && item.URL == "" { + isFQDNAlias := strings.Contains(item.Alias, ".") + if !isFQDNAlias { + item.URL = fmt.Sprintf("%s://%s.%s", proto, item.Alias, hostname) + } else { + item.URL = fmt.Sprintf("%s://%s", proto, item.Alias) + } + } + + // prepend protocol if not exists + if !strings.HasPrefix(item.URL, "http://") && !strings.HasPrefix(item.URL, "https://") { + item.URL = fmt.Sprintf("%s://%s", proto, item.URL) + } + + hp.Add(&item) + } + + ret := hp.Values() + // sort items in each category + for _, category := range ret { + category.Sort(request.SortMethod) + } + // sort categories + overrides := homepage.GetOverrideConfig() + slices.SortStableFunc(ret, func(a, b *homepage.Category) int { + // if category is "Hidden", move it to the end of the list + if a.Name == homepage.CategoryHidden { + return 1 + } + if b.Name == homepage.CategoryHidden { + return -1 + } + // sort categories by order in config + return overrides.CategoryOrder[a.Name] - overrides.CategoryOrder[b.Name] + }) + return ret } diff --git a/internal/api/v1/homepage/overrides.go b/internal/api/v1/homepage/overrides.go index b67dc4af..6a987c9e 100644 --- a/internal/api/v1/homepage/overrides.go +++ b/internal/api/v1/homepage/overrides.go @@ -1,7 +1,6 @@ package homepageapi import ( - "encoding/json" "net/http" "github.com/gin-gonic/gin" @@ -15,16 +14,22 @@ type ( Value homepage.ItemConfig `json:"value"` } // @name HomepageOverrideItemParams HomepageOverrideItemsBatchParams struct { - Value map[string]*homepage.ItemConfig `json:"value"` + Value map[string]homepage.ItemConfig `json:"value"` } // @name HomepageOverrideItemsBatchParams + HomepageOverrideCategoryOrderParams struct { Which string `json:"which"` Value int `json:"value"` } // @name HomepageOverrideCategoryOrderParams + HomepageOverrideItemSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemSortOrderParams + HomepageOverrideItemAllSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemAllSortOrderParams + HomepageOverrideItemFavSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemFavSortOrderParams + HomepageOverrideItemVisibleParams struct { Which []string `json:"which"` Value bool `json:"value"` } // @name HomepageOverrideItemVisibleParams + HomepageOverrideItemFavoriteParams HomepageOverrideItemVisibleParams // @name HomepageOverrideItemFavoriteParams ) // @x-id "set-item" @@ -46,7 +51,7 @@ func SetItem(c *gin.Context) { return } overrides := homepage.GetOverrideConfig() - overrides.OverrideItem(params.Which, ¶ms.Value) + overrides.OverrideItem(params.Which, params.Value) c.JSON(http.StatusOK, apitypes.Success("success")) } @@ -65,15 +70,8 @@ func SetItem(c *gin.Context) { func SetItemsBatch(c *gin.Context) { var params HomepageOverrideItemsBatchParams if err := c.ShouldBindJSON(¶ms); err != nil { - data, derr := c.GetRawData() - if derr != nil { - c.Error(apitypes.InternalServerError(derr, "failed to get raw data")) - return - } - if uerr := json.Unmarshal(data, ¶ms); uerr != nil { - c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr)) - return - } + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return } overrides := homepage.GetOverrideConfig() overrides.OverrideItems(params.Value) @@ -95,22 +93,103 @@ func SetItemsBatch(c *gin.Context) { func SetItemVisible(c *gin.Context) { var params HomepageOverrideItemVisibleParams if err := c.ShouldBindJSON(¶ms); err != nil { - data, derr := c.GetRawData() - if derr != nil { - c.Error(apitypes.InternalServerError(derr, "failed to get raw data")) - return - } - if uerr := json.Unmarshal(data, ¶ms); uerr != nil { - c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr)) - return - } + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return } overrides := homepage.GetOverrideConfig() - if params.Value { - overrides.UnhideItems(params.Which) - } else { - overrides.HideItems(params.Which) + overrides.SetItemsVisibility(params.Which, params.Value) + c.JSON(http.StatusOK, apitypes.Success("success")) +} + +// @x-id "set-item-favorite" +// @BasePath /api/v1 +// @Summary Set homepage item favorite +// @Description Set homepage item favorite. +// @Tags homepage +// @Accept json +// @Produce json +// @Param request body HomepageOverrideItemFavoriteParams true "Set item favorite" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/set/item_favorite [post] +func SetItemFavorite(c *gin.Context) { + var params HomepageOverrideItemFavoriteParams + if err := c.ShouldBindJSON(¶ms); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return } + overrides := homepage.GetOverrideConfig() + overrides.SetItemsFavorite(params.Which, params.Value) + c.JSON(http.StatusOK, apitypes.Success("success")) +} + +// @x-id "set-item-sort-order" +// @BasePath /api/v1 +// @Summary Set homepage item sort order +// @Description Set homepage item sort order. +// @Tags homepage +// @Accept json +// @Produce json +// @Param request body HomepageOverrideItemSortOrderParams true "Set item sort order" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/set/item_sort_order [post] +func SetItemSortOrder(c *gin.Context) { + var params HomepageOverrideItemSortOrderParams + if err := c.ShouldBindJSON(¶ms); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + overrides := homepage.GetOverrideConfig() + overrides.SetSortOrder(params.Which, params.Value) + c.JSON(http.StatusOK, apitypes.Success("success")) +} + +// @x-id "set-item-all-sort-order" +// @BasePath /api/v1 +// @Summary Set homepage item all sort order +// @Description Set homepage item all sort order. +// @Tags homepage +// @Accept json +// @Produce json +// @Param request body HomepageOverrideItemAllSortOrderParams true "Set item all sort order" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/set/item_all_sort_order [post] +func SetItemAllSortOrder(c *gin.Context) { + var params HomepageOverrideItemAllSortOrderParams + if err := c.ShouldBindJSON(¶ms); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + overrides := homepage.GetOverrideConfig() + overrides.SetAllSortOrder(params.Which, params.Value) + c.JSON(http.StatusOK, apitypes.Success("success")) +} + +// @x-id "set-item-fav-sort-order" +// @BasePath /api/v1 +// @Summary Set homepage item fav sort order +// @Description Set homepage item fav sort order. +// @Tags homepage +// @Accept json +// @Produce json +// @Param request body HomepageOverrideItemFavSortOrderParams true "Set item fav sort order" +// @Success 200 {object} apitypes.SuccessResponse +// @Failure 400 {object} apitypes.ErrorResponse +// @Failure 500 {object} apitypes.ErrorResponse +// @Router /homepage/set/item_fav_sort_order [post] +func SetItemFavSortOrder(c *gin.Context) { + var params HomepageOverrideItemFavSortOrderParams + if err := c.ShouldBindJSON(¶ms); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + overrides := homepage.GetOverrideConfig() + overrides.SetFavSortOrder(params.Which, params.Value) c.JSON(http.StatusOK, apitypes.Success("success")) } @@ -129,15 +208,8 @@ func SetItemVisible(c *gin.Context) { func SetCategoryOrder(c *gin.Context) { var params HomepageOverrideCategoryOrderParams if err := c.ShouldBindJSON(¶ms); err != nil { - data, derr := c.GetRawData() - if derr != nil { - c.Error(apitypes.InternalServerError(derr, "failed to get raw data")) - return - } - if uerr := json.Unmarshal(data, ¶ms); uerr != nil { - c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr)) - return - } + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return } overrides := homepage.GetOverrideConfig() overrides.SetCategoryOrder(params.Which, params.Value) diff --git a/internal/docker/container.go b/internal/docker/container.go index c46fa6ba..b0a93e07 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -66,6 +66,7 @@ func FromDocker(c *container.Summary, dockerHost string) (res *types.Container) IsExplicit: isExplicit, IsHostNetworkMode: c.HostConfig.NetworkMode == "host", Running: c.Status == "running" || c.State == "running", + State: c.State, } if agent.IsDockerHostAgent(dockerHost) { @@ -143,9 +144,11 @@ var databaseMPs = map[string]struct{}{ } func isDatabase(c *types.Container) bool { - for _, m := range c.Mounts.Iter { - if _, ok := databaseMPs[m]; ok { - return true + if c.Mounts != nil { // only happens in test + for _, m := range c.Mounts.Iter { + if _, ok := databaseMPs[m]; ok { + return true + } } } diff --git a/internal/homepage/homepage.go b/internal/homepage/homepage.go index c2da470b..df459fb2 100644 --- a/internal/homepage/homepage.go +++ b/internal/homepage/homepage.go @@ -2,34 +2,72 @@ package homepage import ( "slices" + "strings" + "github.com/yusing/ds/ordered" "github.com/yusing/go-proxy/internal/homepage/widgets" "github.com/yusing/go-proxy/internal/serialization" ) type ( - Homepage map[string]Category // @name HomepageItems - Category []*Item // @name HomepageCategory + HomepageMap struct { + ordered.Map[string, *Category] + } // @name HomepageItemsMap + + Homepage []*Category // @name HomepageItems + Category struct { + Items []*Item `json:"items"` + Name string `json:"name"` + } // @name HomepageCategory ItemConfig struct { Show bool `json:"show"` Name string `json:"name"` // display name Icon *IconURL `json:"icon" swaggertype:"string"` - Category string `json:"category"` + Category string `json:"category" validate:"omitempty"` Description string `json:"description" aliases:"desc"` URL string `json:"url,omitempty"` - SortOrder int `json:"sort_order"` - } - - Item struct { - *ItemConfig + Favorite bool `json:"favorite"` WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget" extensions:"x-nullable"` + } // @name HomepageItemConfig - Alias string `json:"alias"` - Provider string `json:"provider"` - OriginURL string `json:"origin_url"` - } + Widget struct { + Label string `json:"label"` + Value string `json:"value"` + } // @name HomepageItemWidget + + Item struct { + ItemConfig + + SortOrder int `json:"sort_order"` // sort order in category + FavSortOrder int `json:"fav_sort_order"` // sort order in favorite + AllSortOrder int `json:"all_sort_order"` // sort order in all + + Clicks int `json:"clicks"` + + Widgets []Widget `json:"widgets,omitempty"` + + Alias string `json:"alias"` + Provider string `json:"provider"` + OriginURL string `json:"origin_url"` + ContainerID string `json:"container_id,omitempty" extensions:"x-nullable"` + } // @name HomepageItem + + SortMethod string // @name HomepageSortMethod +) + +const ( + CategoryAll = "All" + CategoryFavorites = "Favorites" + CategoryHidden = "Hidden" + CategoryOthers = "Others" +) + +const ( + SortMethodClicks = "clicks" // @name HomepageSortMethodClicks + SortMethodAlphabetical = "alphabetical" // @name HomepageSortMethodAlphabetical + SortMethodCustom = "custom" // @name HomepageSortMethodCustom ) func init() { @@ -40,22 +78,115 @@ func init() { }) } -func (cfg *ItemConfig) GetOverride(alias string) *ItemConfig { - return overrideConfigInstance.GetOverride(alias, cfg) +func NewHomepageMap(total int) *HomepageMap { + m := &HomepageMap{ + Map: *ordered.NewMap[string, *Category](ordered.WithCapacity(10)), + } + m.Set(CategoryFavorites, &Category{ + Items: make([]*Item, 0), // no capacity reserved for this category + Name: CategoryFavorites, + }) + m.Set(CategoryAll, &Category{ + Items: make([]*Item, 0, total), + Name: CategoryAll, + }) + m.Set(CategoryHidden, &Category{ + Items: make([]*Item, 0), + Name: CategoryHidden, + }) + return m } -func (c Homepage) Add(item *Item) { - if c[item.Category] == nil { - c[item.Category] = make(Category, 0) +func (cfg Item) GetOverride() Item { + return overrideConfigInstance.GetOverride(cfg) +} + +func (c *HomepageMap) Add(item *Item) { + c.add(item, item.Category) + // add to all category even if item is hidden + c.add(item, CategoryAll) + if item.Show { + if item.Favorite { + c.add(item, CategoryFavorites) + } + } else { + c.add(item, CategoryHidden) } - c[item.Category] = append(c[item.Category], item) - slices.SortStableFunc(c[item.Category], func(a, b *Item) int { - if a.SortOrder < b.SortOrder { +} + +func (c *HomepageMap) add(item *Item, categoryName string) { + category := c.Get(categoryName) + if category == nil { + category = &Category{ + Items: make([]*Item, 0), + Name: categoryName, + } + c.Set(categoryName, category) + } + category.Items = append(category.Items, item) +} + +func (c *Category) Sort(method SortMethod) { + switch method { + case SortMethodClicks: + c.sortByClicks() + case SortMethodAlphabetical: + c.sortByAlphabetical() + case SortMethodCustom: + c.sortByCustom() + } +} + +func (c *Category) sortByClicks() { + slices.SortStableFunc(c.Items, func(a, b *Item) int { + if a.Clicks > b.Clicks { return -1 } - if a.SortOrder > b.SortOrder { + if a.Clicks < b.Clicks { return 1 } - return 0 + // fallback to alphabetical + return strings.Compare(a.Name, b.Name) }) } + +func (c *Category) sortByAlphabetical() { + slices.SortStableFunc(c.Items, func(a, b *Item) int { + return strings.Compare(a.Name, b.Name) + }) +} + +func (c *Category) sortByCustom() { + switch c.Name { + case CategoryFavorites: + slices.SortStableFunc(c.Items, func(a, b *Item) int { + if a.FavSortOrder < b.FavSortOrder { + return -1 + } + if a.FavSortOrder > b.FavSortOrder { + return 1 + } + return 0 + }) + case CategoryAll: + slices.SortStableFunc(c.Items, func(a, b *Item) int { + if a.AllSortOrder < b.AllSortOrder { + return -1 + } + if a.AllSortOrder > b.AllSortOrder { + return 1 + } + return 0 + }) + default: + slices.SortStableFunc(c.Items, func(a, b *Item) int { + if a.SortOrder < b.SortOrder { + return -1 + } + if a.SortOrder > b.SortOrder { + return 1 + } + return 0 + }) + } +} diff --git a/internal/homepage/homepage_test.go b/internal/homepage/homepage_test.go index cda36899..3602e75d 100644 --- a/internal/homepage/homepage_test.go +++ b/internal/homepage/homepage_test.go @@ -10,7 +10,7 @@ import ( func TestOverrideItem(t *testing.T) { a := &Item{ Alias: "foo", - ItemConfig: &ItemConfig{ + ItemConfig: ItemConfig{ Show: false, Name: "Foo", Icon: &IconURL{ @@ -20,7 +20,7 @@ func TestOverrideItem(t *testing.T) { Category: "App", }, } - want := &ItemConfig{ + want := ItemConfig{ Show: true, Name: "Bar", Category: "Test", @@ -30,7 +30,107 @@ func TestOverrideItem(t *testing.T) { }, } overrides := GetOverrideConfig() + overrides.Initialize() overrides.OverrideItem(a.Alias, want) - got := a.GetOverride(a.Alias) - ExpectEqual(t, got, want) + got := a.GetOverride() + ExpectEqual(t, got, Item{ + ItemConfig: want, + Alias: a.Alias, + }) +} + +func TestOverrideItem_PreservesURL(t *testing.T) { + a := &Item{ + Alias: "svc", + ItemConfig: ItemConfig{ + Show: true, + Name: "Service", + URL: "http://origin.local", + }, + } + wantCfg := ItemConfig{ + Show: true, + Name: "Overridden", + URL: "http://should-not-apply", + } + overrides := GetOverrideConfig() + overrides.Initialize() + overrides.OverrideItem(a.Alias, wantCfg) + + got := a.GetOverride() + ExpectEqual(t, got.URL, "http://origin.local") + ExpectEqual(t, got.Name, "Overridden") +} + +func TestVisibilityFavoriteAndSortOrders(t *testing.T) { + a := &Item{ + Alias: "alpha", + ItemConfig: ItemConfig{ + Show: true, + Name: "Alpha", + Category: "Apps", + Favorite: false, + }, + } + overrides := GetOverrideConfig() + overrides.Initialize() + overrides.SetItemsVisibility([]string{a.Alias}, false) + overrides.SetItemsFavorite([]string{a.Alias}, true) + overrides.SetSortOrder(a.Alias, 5) + overrides.SetAllSortOrder(a.Alias, 9) + overrides.SetFavSortOrder(a.Alias, 2) + + got := a.GetOverride() + ExpectEqual(t, got.Show, false) + ExpectEqual(t, got.Favorite, true) + ExpectEqual(t, got.SortOrder, 5) + ExpectEqual(t, got.AllSortOrder, 9) + ExpectEqual(t, got.FavSortOrder, 2) +} + +func TestCategoryDefaultedWhenEmpty(t *testing.T) { + a := &Item{ + Alias: "no-cat", + ItemConfig: ItemConfig{ + Show: true, + Name: "NoCat", + }, + } + got := a.GetOverride() + ExpectEqual(t, got.Category, CategoryOthers) +} + +func TestOverrideItems_Bulk(t *testing.T) { + a := &Item{ + Alias: "bulk-1", + ItemConfig: ItemConfig{ + Show: true, + Name: "Bulk1", + Category: "X", + }, + } + b := &Item{ + Alias: "bulk-2", + ItemConfig: ItemConfig{ + Show: true, + Name: "Bulk2", + Category: "Y", + }, + } + + overrides := GetOverrideConfig() + overrides.Initialize() + overrides.OverrideItems(map[string]ItemConfig{ + a.Alias: {Show: true, Name: "A*", Category: "AX"}, + b.Alias: {Show: false, Name: "B*", Category: "BY"}, + }) + + ga := a.GetOverride() + gb := b.GetOverride() + + ExpectEqual(t, ga.Name, "A*") + ExpectEqual(t, ga.Category, "AX") + ExpectEqual(t, gb.Name, "B*") + ExpectEqual(t, gb.Category, "BY") + ExpectEqual(t, gb.Show, false) } diff --git a/internal/homepage/list_icons.go b/internal/homepage/list_icons.go index ceb10722..5d7a365d 100644 --- a/internal/homepage/list_icons.go +++ b/internal/homepage/list_icons.go @@ -94,8 +94,8 @@ func NewIconKey(source IconSource, reference string) IconKey { } func (k IconKey) SourceRef() (IconSource, string) { - parts := strings.Split(string(k), "/") - return IconSource(parts[0]), parts[1] + source, ref, _ := strings.Cut(string(k), "/") + return IconSource(source), ref } func InitIconListCache() { @@ -120,6 +120,10 @@ func InitIconListCache() { }) } +func TestClearIconsCache() { + clear(iconsCache.Icons) +} + func ListAvailableIcons() (*Cache, error) { if common.IsTest { return iconsCache, nil @@ -384,7 +388,8 @@ func UpdateSelfhstIcons() error { for _, item := range data { var tag string if item.Tags != "" { - tag = strutils.CommaSeperatedList(item.Tags)[0] + tag, _, _ = strings.Cut(item.Tags, ",") + tag = strings.TrimSpace(tag) } icon := &IconMeta{ DisplayName: item.Name, diff --git a/internal/homepage/list_icons_test.go b/internal/homepage/list_icons_test.go index 9569233e..d54cab3d 100644 --- a/internal/homepage/list_icons_test.go +++ b/internal/homepage/list_icons_test.go @@ -91,6 +91,8 @@ func runTests(t *testing.T, iconsCache *Cache, test []testCases) { } func TestListWalkxCodeIcons(t *testing.T) { + t.Cleanup(TestClearIconsCache) + MockHTTPGet([]byte(walkxcodeIcons)) if err := UpdateWalkxCodeIcons(); err != nil { t.Fatal(err) @@ -124,6 +126,7 @@ func TestListWalkxCodeIcons(t *testing.T) { } func TestListSelfhstIcons(t *testing.T) { + t.Cleanup(TestClearIconsCache) MockHTTPGet([]byte(selfhstIcons)) if err := UpdateSelfhstIcons(); err != nil { t.Fatal(err) @@ -135,9 +138,6 @@ func TestListSelfhstIcons(t *testing.T) { if len(iconsCache.Icons) != 3 { t.Fatalf("expect 3 icons, got %d", len(iconsCache.Icons)) } - // if len(iconsCache.IconList) != 8 { - // t.Fatalf("expect 8 icons, got %d", len(iconsCache.IconList)) - // } test := []testCases{ { Key: NewIconKey(IconSourceSelfhSt, "2fauth"), diff --git a/internal/homepage/override_config.go b/internal/homepage/override_config.go index d520ec21..fb1791c2 100644 --- a/internal/homepage/override_config.go +++ b/internal/homepage/override_config.go @@ -9,10 +9,14 @@ import ( ) type OverrideConfig struct { - ItemOverrides map[string]*ItemConfig `json:"item_overrides"` - DisplayOrder map[string]int `json:"display_order"` - CategoryOrder map[string]int `json:"category_order"` - ItemVisibility map[string]bool `json:"item_visibility"` + ItemOverrides map[string]ItemConfig `json:"item_overrides"` + DisplayOrder map[string]int `json:"display_order"` + CategoryOrder map[string]int `json:"category_order"` + AllSortOrder map[string]int `json:"all_sort_order"` + FavSortOrder map[string]int `json:"fav_sort_order"` + ItemClicks map[string]int `json:"item_clicks"` + ItemVisibility map[string]bool `json:"item_visibility"` + ItemFavorite map[string]bool `json:"item_favorite"` mu sync.RWMutex } @@ -23,57 +27,104 @@ func GetOverrideConfig() *OverrideConfig { } func (c *OverrideConfig) Initialize() { - c.ItemOverrides = make(map[string]*ItemConfig) + c.ItemOverrides = make(map[string]ItemConfig) c.DisplayOrder = make(map[string]int) c.CategoryOrder = make(map[string]int) + c.AllSortOrder = make(map[string]int) + c.FavSortOrder = make(map[string]int) + c.ItemClicks = make(map[string]int) c.ItemVisibility = make(map[string]bool) + c.ItemFavorite = make(map[string]bool) } -func (c *OverrideConfig) OverrideItem(alias string, override *ItemConfig) { +func (c *OverrideConfig) OverrideItem(alias string, override ItemConfig) { c.mu.Lock() defer c.mu.Unlock() c.ItemOverrides[alias] = override } -func (c *OverrideConfig) OverrideItems(items map[string]*ItemConfig) { +func (c *OverrideConfig) OverrideItems(items map[string]ItemConfig) { c.mu.Lock() defer c.mu.Unlock() maps.Copy(c.ItemOverrides, items) } -func (c *OverrideConfig) GetOverride(alias string, item *ItemConfig) *ItemConfig { +func (c *OverrideConfig) GetOverride(item Item) Item { c.mu.RLock() defer c.mu.RUnlock() - if itemOverride, hasOverride := c.ItemOverrides[alias]; hasOverride { - itemOverride.URL = item.URL // NOTE: we don't want to override the URL - item = itemOverride + + if overrides, hasOverride := c.ItemOverrides[item.Alias]; hasOverride { + overrides.URL = item.URL // NOTE: we don't want to override the URL + item.ItemConfig = overrides } - if show, ok := c.ItemVisibility[alias]; ok { - clone := *item - clone.Show = show - return &clone + + if show, ok := c.ItemVisibility[item.Alias]; ok { + item.Show = show + } + if fav, ok := c.ItemFavorite[item.Alias]; ok { + item.Favorite = fav + } + if displayOrder, ok := c.DisplayOrder[item.Alias]; ok { + item.SortOrder = displayOrder + } + if allSortOrder, ok := c.AllSortOrder[item.Alias]; ok { + item.AllSortOrder = allSortOrder + } + if favSortOrder, ok := c.FavSortOrder[item.Alias]; ok { + item.FavSortOrder = favSortOrder + } + if clicks, ok := c.ItemClicks[item.Alias]; ok { + item.Clicks = clicks + } + + if item.Category == "" { + item.Category = CategoryOthers } return item } +func (c *OverrideConfig) SetSortOrder(key string, value int) { + c.mu.Lock() + defer c.mu.Unlock() + c.DisplayOrder[key] = value +} + +func (c *OverrideConfig) SetAllSortOrder(key string, value int) { + c.mu.Lock() + defer c.mu.Unlock() + c.AllSortOrder[key] = value +} + +func (c *OverrideConfig) SetFavSortOrder(key string, value int) { + c.mu.Lock() + defer c.mu.Unlock() + c.FavSortOrder[key] = value +} + +func (c *OverrideConfig) SetItemsVisibility(keys []string, value bool) { + c.mu.Lock() + defer c.mu.Unlock() + for _, key := range keys { + c.ItemVisibility[key] = value + } +} + +func (c *OverrideConfig) SetItemsFavorite(keys []string, value bool) { + c.mu.Lock() + defer c.mu.Unlock() + for _, key := range keys { + c.ItemFavorite[key] = value + } +} + func (c *OverrideConfig) SetCategoryOrder(key string, value int) { c.mu.Lock() defer c.mu.Unlock() c.CategoryOrder[key] = value } -func (c *OverrideConfig) UnhideItems(keys []string) { +func (c *OverrideConfig) IncrementItemClicks(key string) { c.mu.Lock() defer c.mu.Unlock() - for _, key := range keys { - c.ItemVisibility[key] = true - } -} - -func (c *OverrideConfig) HideItems(keys []string) { - c.mu.Lock() - defer c.mu.Unlock() - for _, key := range keys { - c.ItemVisibility[key] = false - } + c.ItemClicks[key]++ } diff --git a/internal/types/docker.go b/internal/types/docker.go index f61fc9e3..8f5930e8 100644 --- a/internal/types/docker.go +++ b/internal/types/docker.go @@ -22,6 +22,8 @@ type ( ContainerName string `json:"container_name"` ContainerID string `json:"container_id"` + State container.ContainerState `json:"state"` + Agent *agent.AgentConfig `json:"agent"` Labels map[string]string `json:"-"` // for creating routes diff --git a/internal/types/routes.go b/internal/types/routes.go index 761eeb61..cf62f37a 100644 --- a/internal/types/routes.go +++ b/internal/types/routes.go @@ -28,8 +28,8 @@ type ( IdlewatcherConfig() *IdlewatcherConfig HealthCheckConfig() *HealthCheckConfig LoadBalanceConfig() *LoadBalancerConfig - HomepageConfig() *homepage.ItemConfig - HomepageItem() *homepage.Item + HomepageItem() homepage.Item + DisplayName() string ContainerInfo() *Container GetAgent() *agent.AgentConfig