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.
This commit is contained in:
yusing
2025-09-13 23:52:54 +08:00
parent 58a2dc73dd
commit 2c290a3916
12 changed files with 612 additions and 105 deletions

View File

@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/route/routes"
) )
@@ -18,5 +19,24 @@ import (
// @Failure 403 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse
// @Router /homepage/categories [get] // @Router /homepage/categories [get]
func Categories(c *gin.Context) { 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
} }

View File

@@ -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(&params); 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"))
}

View File

@@ -1,27 +1,38 @@
package homepageapi package homepageapi
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/lithammer/fuzzysearch/fuzzy"
apitypes "github.com/yusing/go-proxy/internal/api/types" 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" "github.com/yusing/go-proxy/internal/route/routes"
) )
type HomepageItemsRequest struct { type HomepageItemsRequest struct {
Category string `form:"category" validate:"omitempty"` SearchQuery string `form:"search"` // Search query
Provider string `form:"provider" validate:"omitempty"` 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 } // @name HomepageItemsRequest
// @x-id "items" // @x-id "items"
// @BasePath /api/v1 // @BasePath /api/v1
// @Summary Homepage items // @Summary Homepage items
// @Description Homepage items // @Description Homepage items
// @Tags homepage // @Tags homepage,websocket
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param category query string false "Category filter" // @Param query query HomepageItemsRequest false "Query parameters"
// @Param provider query string false "Provider filter"
// @Success 200 {object} homepage.Homepage // @Success 200 {object} homepage.Homepage
// @Failure 400 {object} apitypes.ErrorResponse // @Failure 400 {object} apitypes.ErrorResponse
// @Failure 403 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse
@@ -42,5 +53,81 @@ func Items(c *gin.Context) {
hostname = host 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
} }

View File

@@ -1,7 +1,6 @@
package homepageapi package homepageapi
import ( import (
"encoding/json"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,16 +14,22 @@ type (
Value homepage.ItemConfig `json:"value"` Value homepage.ItemConfig `json:"value"`
} // @name HomepageOverrideItemParams } // @name HomepageOverrideItemParams
HomepageOverrideItemsBatchParams struct { HomepageOverrideItemsBatchParams struct {
Value map[string]*homepage.ItemConfig `json:"value"` Value map[string]homepage.ItemConfig `json:"value"`
} // @name HomepageOverrideItemsBatchParams } // @name HomepageOverrideItemsBatchParams
HomepageOverrideCategoryOrderParams struct { HomepageOverrideCategoryOrderParams struct {
Which string `json:"which"` Which string `json:"which"`
Value int `json:"value"` Value int `json:"value"`
} // @name HomepageOverrideCategoryOrderParams } // @name HomepageOverrideCategoryOrderParams
HomepageOverrideItemSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemSortOrderParams
HomepageOverrideItemAllSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemAllSortOrderParams
HomepageOverrideItemFavSortOrderParams HomepageOverrideCategoryOrderParams // @name HomepageOverrideItemFavSortOrderParams
HomepageOverrideItemVisibleParams struct { HomepageOverrideItemVisibleParams struct {
Which []string `json:"which"` Which []string `json:"which"`
Value bool `json:"value"` Value bool `json:"value"`
} // @name HomepageOverrideItemVisibleParams } // @name HomepageOverrideItemVisibleParams
HomepageOverrideItemFavoriteParams HomepageOverrideItemVisibleParams // @name HomepageOverrideItemFavoriteParams
) )
// @x-id "set-item" // @x-id "set-item"
@@ -46,7 +51,7 @@ func SetItem(c *gin.Context) {
return return
} }
overrides := homepage.GetOverrideConfig() overrides := homepage.GetOverrideConfig()
overrides.OverrideItem(params.Which, &params.Value) overrides.OverrideItem(params.Which, params.Value)
c.JSON(http.StatusOK, apitypes.Success("success")) c.JSON(http.StatusOK, apitypes.Success("success"))
} }
@@ -65,15 +70,8 @@ func SetItem(c *gin.Context) {
func SetItemsBatch(c *gin.Context) { func SetItemsBatch(c *gin.Context) {
var params HomepageOverrideItemsBatchParams var params HomepageOverrideItemsBatchParams
if err := c.ShouldBindJSON(&params); err != nil { if err := c.ShouldBindJSON(&params); err != nil {
data, derr := c.GetRawData() c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
if derr != nil { return
c.Error(apitypes.InternalServerError(derr, "failed to get raw data"))
return
}
if uerr := json.Unmarshal(data, &params); uerr != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr))
return
}
} }
overrides := homepage.GetOverrideConfig() overrides := homepage.GetOverrideConfig()
overrides.OverrideItems(params.Value) overrides.OverrideItems(params.Value)
@@ -95,22 +93,103 @@ func SetItemsBatch(c *gin.Context) {
func SetItemVisible(c *gin.Context) { func SetItemVisible(c *gin.Context) {
var params HomepageOverrideItemVisibleParams var params HomepageOverrideItemVisibleParams
if err := c.ShouldBindJSON(&params); err != nil { if err := c.ShouldBindJSON(&params); err != nil {
data, derr := c.GetRawData() c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
if derr != nil { return
c.Error(apitypes.InternalServerError(derr, "failed to get raw data"))
return
}
if uerr := json.Unmarshal(data, &params); uerr != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr))
return
}
} }
overrides := homepage.GetOverrideConfig() overrides := homepage.GetOverrideConfig()
if params.Value { overrides.SetItemsVisibility(params.Which, params.Value)
overrides.UnhideItems(params.Which) c.JSON(http.StatusOK, apitypes.Success("success"))
} else { }
overrides.HideItems(params.Which)
// @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(&params); 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(&params); 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(&params); 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(&params); 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")) c.JSON(http.StatusOK, apitypes.Success("success"))
} }
@@ -129,15 +208,8 @@ func SetItemVisible(c *gin.Context) {
func SetCategoryOrder(c *gin.Context) { func SetCategoryOrder(c *gin.Context) {
var params HomepageOverrideCategoryOrderParams var params HomepageOverrideCategoryOrderParams
if err := c.ShouldBindJSON(&params); err != nil { if err := c.ShouldBindJSON(&params); err != nil {
data, derr := c.GetRawData() c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
if derr != nil { return
c.Error(apitypes.InternalServerError(derr, "failed to get raw data"))
return
}
if uerr := json.Unmarshal(data, &params); uerr != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", uerr))
return
}
} }
overrides := homepage.GetOverrideConfig() overrides := homepage.GetOverrideConfig()
overrides.SetCategoryOrder(params.Which, params.Value) overrides.SetCategoryOrder(params.Which, params.Value)

View File

@@ -66,6 +66,7 @@ func FromDocker(c *container.Summary, dockerHost string) (res *types.Container)
IsExplicit: isExplicit, IsExplicit: isExplicit,
IsHostNetworkMode: c.HostConfig.NetworkMode == "host", IsHostNetworkMode: c.HostConfig.NetworkMode == "host",
Running: c.Status == "running" || c.State == "running", Running: c.Status == "running" || c.State == "running",
State: c.State,
} }
if agent.IsDockerHostAgent(dockerHost) { if agent.IsDockerHostAgent(dockerHost) {
@@ -143,9 +144,11 @@ var databaseMPs = map[string]struct{}{
} }
func isDatabase(c *types.Container) bool { func isDatabase(c *types.Container) bool {
for _, m := range c.Mounts.Iter { if c.Mounts != nil { // only happens in test
if _, ok := databaseMPs[m]; ok { for _, m := range c.Mounts.Iter {
return true if _, ok := databaseMPs[m]; ok {
return true
}
} }
} }

View File

@@ -2,34 +2,72 @@ package homepage
import ( import (
"slices" "slices"
"strings"
"github.com/yusing/ds/ordered"
"github.com/yusing/go-proxy/internal/homepage/widgets" "github.com/yusing/go-proxy/internal/homepage/widgets"
"github.com/yusing/go-proxy/internal/serialization" "github.com/yusing/go-proxy/internal/serialization"
) )
type ( type (
Homepage map[string]Category // @name HomepageItems HomepageMap struct {
Category []*Item // @name HomepageCategory ordered.Map[string, *Category]
} // @name HomepageItemsMap
Homepage []*Category // @name HomepageItems
Category struct {
Items []*Item `json:"items"`
Name string `json:"name"`
} // @name HomepageCategory
ItemConfig struct { ItemConfig struct {
Show bool `json:"show"` Show bool `json:"show"`
Name string `json:"name"` // display name Name string `json:"name"` // display name
Icon *IconURL `json:"icon" swaggertype:"string"` Icon *IconURL `json:"icon" swaggertype:"string"`
Category string `json:"category"` Category string `json:"category" validate:"omitempty"`
Description string `json:"description" aliases:"desc"` Description string `json:"description" aliases:"desc"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
SortOrder int `json:"sort_order"` Favorite bool `json:"favorite"`
}
Item struct {
*ItemConfig
WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget" extensions:"x-nullable"` WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget" extensions:"x-nullable"`
} // @name HomepageItemConfig
Alias string `json:"alias"` Widget struct {
Provider string `json:"provider"` Label string `json:"label"`
OriginURL string `json:"origin_url"` 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() { func init() {
@@ -40,22 +78,115 @@ func init() {
}) })
} }
func (cfg *ItemConfig) GetOverride(alias string) *ItemConfig { func NewHomepageMap(total int) *HomepageMap {
return overrideConfigInstance.GetOverride(alias, cfg) 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) { func (cfg Item) GetOverride() Item {
if c[item.Category] == nil { return overrideConfigInstance.GetOverride(cfg)
c[item.Category] = make(Category, 0) }
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 return -1
} }
if a.SortOrder > b.SortOrder { if a.Clicks < b.Clicks {
return 1 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
})
}
}

View File

@@ -10,7 +10,7 @@ import (
func TestOverrideItem(t *testing.T) { func TestOverrideItem(t *testing.T) {
a := &Item{ a := &Item{
Alias: "foo", Alias: "foo",
ItemConfig: &ItemConfig{ ItemConfig: ItemConfig{
Show: false, Show: false,
Name: "Foo", Name: "Foo",
Icon: &IconURL{ Icon: &IconURL{
@@ -20,7 +20,7 @@ func TestOverrideItem(t *testing.T) {
Category: "App", Category: "App",
}, },
} }
want := &ItemConfig{ want := ItemConfig{
Show: true, Show: true,
Name: "Bar", Name: "Bar",
Category: "Test", Category: "Test",
@@ -30,7 +30,107 @@ func TestOverrideItem(t *testing.T) {
}, },
} }
overrides := GetOverrideConfig() overrides := GetOverrideConfig()
overrides.Initialize()
overrides.OverrideItem(a.Alias, want) overrides.OverrideItem(a.Alias, want)
got := a.GetOverride(a.Alias) got := a.GetOverride()
ExpectEqual(t, got, want) 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)
} }

View File

@@ -94,8 +94,8 @@ func NewIconKey(source IconSource, reference string) IconKey {
} }
func (k IconKey) SourceRef() (IconSource, string) { func (k IconKey) SourceRef() (IconSource, string) {
parts := strings.Split(string(k), "/") source, ref, _ := strings.Cut(string(k), "/")
return IconSource(parts[0]), parts[1] return IconSource(source), ref
} }
func InitIconListCache() { func InitIconListCache() {
@@ -120,6 +120,10 @@ func InitIconListCache() {
}) })
} }
func TestClearIconsCache() {
clear(iconsCache.Icons)
}
func ListAvailableIcons() (*Cache, error) { func ListAvailableIcons() (*Cache, error) {
if common.IsTest { if common.IsTest {
return iconsCache, nil return iconsCache, nil
@@ -384,7 +388,8 @@ func UpdateSelfhstIcons() error {
for _, item := range data { for _, item := range data {
var tag string var tag string
if item.Tags != "" { if item.Tags != "" {
tag = strutils.CommaSeperatedList(item.Tags)[0] tag, _, _ = strings.Cut(item.Tags, ",")
tag = strings.TrimSpace(tag)
} }
icon := &IconMeta{ icon := &IconMeta{
DisplayName: item.Name, DisplayName: item.Name,

View File

@@ -91,6 +91,8 @@ func runTests(t *testing.T, iconsCache *Cache, test []testCases) {
} }
func TestListWalkxCodeIcons(t *testing.T) { func TestListWalkxCodeIcons(t *testing.T) {
t.Cleanup(TestClearIconsCache)
MockHTTPGet([]byte(walkxcodeIcons)) MockHTTPGet([]byte(walkxcodeIcons))
if err := UpdateWalkxCodeIcons(); err != nil { if err := UpdateWalkxCodeIcons(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -124,6 +126,7 @@ func TestListWalkxCodeIcons(t *testing.T) {
} }
func TestListSelfhstIcons(t *testing.T) { func TestListSelfhstIcons(t *testing.T) {
t.Cleanup(TestClearIconsCache)
MockHTTPGet([]byte(selfhstIcons)) MockHTTPGet([]byte(selfhstIcons))
if err := UpdateSelfhstIcons(); err != nil { if err := UpdateSelfhstIcons(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -135,9 +138,6 @@ func TestListSelfhstIcons(t *testing.T) {
if len(iconsCache.Icons) != 3 { if len(iconsCache.Icons) != 3 {
t.Fatalf("expect 3 icons, got %d", len(iconsCache.Icons)) 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{ test := []testCases{
{ {
Key: NewIconKey(IconSourceSelfhSt, "2fauth"), Key: NewIconKey(IconSourceSelfhSt, "2fauth"),

View File

@@ -9,10 +9,14 @@ import (
) )
type OverrideConfig struct { type OverrideConfig struct {
ItemOverrides map[string]*ItemConfig `json:"item_overrides"` ItemOverrides map[string]ItemConfig `json:"item_overrides"`
DisplayOrder map[string]int `json:"display_order"` DisplayOrder map[string]int `json:"display_order"`
CategoryOrder map[string]int `json:"category_order"` CategoryOrder map[string]int `json:"category_order"`
ItemVisibility map[string]bool `json:"item_visibility"` 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 mu sync.RWMutex
} }
@@ -23,57 +27,104 @@ func GetOverrideConfig() *OverrideConfig {
} }
func (c *OverrideConfig) Initialize() { func (c *OverrideConfig) Initialize() {
c.ItemOverrides = make(map[string]*ItemConfig) c.ItemOverrides = make(map[string]ItemConfig)
c.DisplayOrder = make(map[string]int) c.DisplayOrder = make(map[string]int)
c.CategoryOrder = 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.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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.ItemOverrides[alias] = override c.ItemOverrides[alias] = override
} }
func (c *OverrideConfig) OverrideItems(items map[string]*ItemConfig) { func (c *OverrideConfig) OverrideItems(items map[string]ItemConfig) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
maps.Copy(c.ItemOverrides, items) maps.Copy(c.ItemOverrides, items)
} }
func (c *OverrideConfig) GetOverride(alias string, item *ItemConfig) *ItemConfig { func (c *OverrideConfig) GetOverride(item Item) Item {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
if itemOverride, hasOverride := c.ItemOverrides[alias]; hasOverride {
itemOverride.URL = item.URL // NOTE: we don't want to override the URL if overrides, hasOverride := c.ItemOverrides[item.Alias]; hasOverride {
item = itemOverride 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 if show, ok := c.ItemVisibility[item.Alias]; ok {
clone.Show = show item.Show = show
return &clone }
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 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) { func (c *OverrideConfig) SetCategoryOrder(key string, value int) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.CategoryOrder[key] = value c.CategoryOrder[key] = value
} }
func (c *OverrideConfig) UnhideItems(keys []string) { func (c *OverrideConfig) IncrementItemClicks(key string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
for _, key := range keys { c.ItemClicks[key]++
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
}
} }

View File

@@ -22,6 +22,8 @@ type (
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
ContainerID string `json:"container_id"` ContainerID string `json:"container_id"`
State container.ContainerState `json:"state"`
Agent *agent.AgentConfig `json:"agent"` Agent *agent.AgentConfig `json:"agent"`
Labels map[string]string `json:"-"` // for creating routes Labels map[string]string `json:"-"` // for creating routes

View File

@@ -28,8 +28,8 @@ type (
IdlewatcherConfig() *IdlewatcherConfig IdlewatcherConfig() *IdlewatcherConfig
HealthCheckConfig() *HealthCheckConfig HealthCheckConfig() *HealthCheckConfig
LoadBalanceConfig() *LoadBalancerConfig LoadBalanceConfig() *LoadBalancerConfig
HomepageConfig() *homepage.ItemConfig HomepageItem() homepage.Item
HomepageItem() *homepage.Item DisplayName() string
ContainerInfo() *Container ContainerInfo() *Container
GetAgent() *agent.AgentConfig GetAgent() *agent.AgentConfig