From 3b5c2ea633729096cd34763c87632657cf7856a3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Thu, 11 Dec 2025 00:11:33 +0100 Subject: [PATCH] feat(ui): allow to order search results (#7507) * feat(ui): improve table view and let items to be sorted Signed-off-by: Ettore Di Giacinto * refactorings Signed-off-by: Ettore Di Giacinto * chore: add tests Signed-off-by: Ettore Di Giacinto * chore: use constants Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto --- core/gallery/backend_types.go | 8 + core/gallery/gallery.go | 66 +++++ core/gallery/gallery_test.go | 465 ++++++++++++++++++++++++++++++++++ core/gallery/models_types.go | 8 + core/http/routes/ui_api.go | 44 ++++ core/http/views/backends.html | 63 ++++- core/http/views/models.html | 99 ++++++-- 7 files changed, 727 insertions(+), 26 deletions(-) create mode 100644 core/gallery/gallery_test.go diff --git a/core/gallery/backend_types.go b/core/gallery/backend_types.go index a8b930be1..42b5421ce 100644 --- a/core/gallery/backend_types.go +++ b/core/gallery/backend_types.go @@ -45,6 +45,14 @@ func (backend *GalleryBackend) FindBestBackendFromMeta(systemState *system.Syste return backends.FindByName(realBackend) } +func (m *GalleryBackend) GetInstalled() bool { + return m.Installed +} + +func (m *GalleryBackend) GetLicense() string { + return m.License +} + type GalleryBackends []*GalleryBackend func (m *GalleryBackend) SetGallery(gallery config.Gallery) { diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index 0475b898c..f36c355a2 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -67,6 +68,8 @@ type GalleryElement interface { GetName() string GetDescription() string GetTags() []string + GetInstalled() bool + GetLicense() string GetGallery() config.Gallery } @@ -89,6 +92,69 @@ func (gm GalleryElements[T]) Search(term string) GalleryElements[T] { return filteredModels } +func (gm GalleryElements[T]) SortByName(sortOrder string) GalleryElements[T] { + sort.Slice(gm, func(i, j int) bool { + if sortOrder == "asc" { + return strings.ToLower(gm[i].GetName()) < strings.ToLower(gm[j].GetName()) + } else { + return strings.ToLower(gm[i].GetName()) > strings.ToLower(gm[j].GetName()) + } + }) + return gm +} + +func (gm GalleryElements[T]) SortByRepository(sortOrder string) GalleryElements[T] { + sort.Slice(gm, func(i, j int) bool { + if sortOrder == "asc" { + return strings.ToLower(gm[i].GetGallery().Name) < strings.ToLower(gm[j].GetGallery().Name) + } else { + return strings.ToLower(gm[i].GetGallery().Name) > strings.ToLower(gm[j].GetGallery().Name) + } + }) + return gm +} + +func (gm GalleryElements[T]) SortByLicense(sortOrder string) GalleryElements[T] { + sort.Slice(gm, func(i, j int) bool { + licenseI := gm[i].GetLicense() + licenseJ := gm[j].GetLicense() + var result bool + if licenseI == "" && licenseJ != "" { + return sortOrder == "desc" + } else if licenseI != "" && licenseJ == "" { + return sortOrder == "asc" + } else if licenseI == "" && licenseJ == "" { + return false + } else { + result = strings.ToLower(licenseI) < strings.ToLower(licenseJ) + } + if sortOrder == "desc" { + return !result + } else { + return result + } + }) + return gm +} + +func (gm GalleryElements[T]) SortByInstalled(sortOrder string) GalleryElements[T] { + sort.Slice(gm, func(i, j int) bool { + var result bool + // Sort by installed status: installed items first (true > false) + if gm[i].GetInstalled() != gm[j].GetInstalled() { + result = gm[i].GetInstalled() + } else { + result = strings.ToLower(gm[i].GetName()) < strings.ToLower(gm[j].GetName()) + } + if sortOrder == "desc" { + return !result + } else { + return result + } + }) + return gm +} + func (gm GalleryElements[T]) FindByName(name string) T { for _, m := range gm { if strings.EqualFold(m.GetName(), name) { diff --git a/core/gallery/gallery_test.go b/core/gallery/gallery_test.go new file mode 100644 index 000000000..3ba65f2d9 --- /dev/null +++ b/core/gallery/gallery_test.go @@ -0,0 +1,465 @@ +package gallery_test + +import ( + "os" + "path/filepath" + + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/gallery" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v2" +) + +var _ = Describe("Gallery", func() { + var tempDir string + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "gallery-test-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + Describe("ReadConfigFile", func() { + It("should read and unmarshal a valid YAML file", func() { + testConfig := map[string]interface{}{ + "name": "test-model", + "description": "A test model", + "license": "MIT", + } + yamlData, err := yaml.Marshal(testConfig) + Expect(err).NotTo(HaveOccurred()) + + filePath := filepath.Join(tempDir, "test.yaml") + err = os.WriteFile(filePath, yamlData, 0644) + Expect(err).NotTo(HaveOccurred()) + + var result map[string]interface{} + config, err := ReadConfigFile[map[string]interface{}](filePath) + Expect(err).NotTo(HaveOccurred()) + Expect(config).NotTo(BeNil()) + result = *config + Expect(result["name"]).To(Equal("test-model")) + Expect(result["description"]).To(Equal("A test model")) + Expect(result["license"]).To(Equal("MIT")) + }) + + It("should return error when file does not exist", func() { + _, err := ReadConfigFile[map[string]interface{}]("nonexistent.yaml") + Expect(err).To(HaveOccurred()) + }) + + It("should return error when YAML is invalid", func() { + filePath := filepath.Join(tempDir, "invalid.yaml") + err := os.WriteFile(filePath, []byte("invalid: yaml: content: [unclosed"), 0644) + Expect(err).NotTo(HaveOccurred()) + + _, err = ReadConfigFile[map[string]interface{}](filePath) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("GalleryElements Search", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + { + Metadata: Metadata{ + Name: "bert-embeddings", + Description: "BERT model for embeddings", + Tags: []string{"embeddings", "bert", "nlp"}, + License: "Apache-2.0", + Gallery: config.Gallery{ + Name: "huggingface", + }, + }, + }, + { + Metadata: Metadata{ + Name: "gpt-2", + Description: "GPT-2 language model", + Tags: []string{"gpt", "language-model"}, + License: "MIT", + Gallery: config.Gallery{ + Name: "openai", + }, + }, + }, + { + Metadata: Metadata{ + Name: "llama-7b", + Description: "LLaMA 7B model", + Tags: []string{"llama", "llm"}, + License: "LLaMA", + Gallery: config.Gallery{ + Name: "meta", + }, + }, + }, + } + }) + + It("should find elements by exact name match", func() { + results := elements.Search("bert-embeddings") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + + It("should find elements by partial name match", func() { + results := elements.Search("bert") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + + It("should find elements by description", func() { + results := elements.Search("embeddings") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + + It("should find elements by gallery name", func() { + results := elements.Search("huggingface") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetGallery().Name).To(Equal("huggingface")) + }) + + It("should find elements by tags", func() { + results := elements.Search("nlp") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + + It("should be case insensitive", func() { + results := elements.Search("BERT") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + + It("should find multiple elements", func() { + results := elements.Search("gpt") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("gpt-2")) + }) + + It("should return empty results for no matches", func() { + results := elements.Search("nonexistent") + Expect(results).To(HaveLen(0)) + }) + + It("should use fuzzy matching", func() { + results := elements.Search("bert-emb") + Expect(results).To(HaveLen(1)) + Expect(results[0].GetName()).To(Equal("bert-embeddings")) + }) + }) + + Describe("GalleryElements SortByName", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{Name: "zebra"}}, + {Metadata: Metadata{Name: "alpha"}}, + {Metadata: Metadata{Name: "beta"}}, + } + }) + + It("should sort ascending", func() { + sorted := elements.SortByName("asc") + Expect(sorted).To(HaveLen(3)) + Expect(sorted[0].GetName()).To(Equal("alpha")) + Expect(sorted[1].GetName()).To(Equal("beta")) + Expect(sorted[2].GetName()).To(Equal("zebra")) + }) + + It("should sort descending", func() { + sorted := elements.SortByName("desc") + Expect(sorted).To(HaveLen(3)) + Expect(sorted[0].GetName()).To(Equal("zebra")) + Expect(sorted[1].GetName()).To(Equal("beta")) + Expect(sorted[2].GetName()).To(Equal("alpha")) + }) + + It("should be case insensitive", func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{Name: "Zebra"}}, + {Metadata: Metadata{Name: "alpha"}}, + {Metadata: Metadata{Name: "Beta"}}, + } + sorted := elements.SortByName("asc") + Expect(sorted[0].GetName()).To(Equal("alpha")) + Expect(sorted[1].GetName()).To(Equal("Beta")) + Expect(sorted[2].GetName()).To(Equal("Zebra")) + }) + }) + + Describe("GalleryElements SortByRepository", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + { + Metadata: Metadata{ + Gallery: config.Gallery{Name: "zebra-repo"}, + }, + }, + { + Metadata: Metadata{ + Gallery: config.Gallery{Name: "alpha-repo"}, + }, + }, + { + Metadata: Metadata{ + Gallery: config.Gallery{Name: "beta-repo"}, + }, + }, + } + }) + + It("should sort ascending", func() { + sorted := elements.SortByRepository("asc") + Expect(sorted).To(HaveLen(3)) + Expect(sorted[0].GetGallery().Name).To(Equal("alpha-repo")) + Expect(sorted[1].GetGallery().Name).To(Equal("beta-repo")) + Expect(sorted[2].GetGallery().Name).To(Equal("zebra-repo")) + }) + + It("should sort descending", func() { + sorted := elements.SortByRepository("desc") + Expect(sorted).To(HaveLen(3)) + Expect(sorted[0].GetGallery().Name).To(Equal("zebra-repo")) + Expect(sorted[1].GetGallery().Name).To(Equal("beta-repo")) + Expect(sorted[2].GetGallery().Name).To(Equal("alpha-repo")) + }) + }) + + Describe("GalleryElements SortByLicense", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{License: "MIT"}}, + {Metadata: Metadata{License: "Apache-2.0"}}, + {Metadata: Metadata{License: ""}}, + {Metadata: Metadata{License: "GPL-3.0"}}, + } + }) + + It("should sort ascending with empty licenses at end", func() { + sorted := elements.SortByLicense("asc") + Expect(sorted).To(HaveLen(4)) + Expect(sorted[0].GetLicense()).To(Equal("Apache-2.0")) + Expect(sorted[1].GetLicense()).To(Equal("GPL-3.0")) + Expect(sorted[2].GetLicense()).To(Equal("MIT")) + Expect(sorted[3].GetLicense()).To(Equal("")) + }) + + It("should sort descending with empty licenses at beginning", func() { + sorted := elements.SortByLicense("desc") + Expect(sorted).To(HaveLen(4)) + Expect(sorted[0].GetLicense()).To(Equal("")) + Expect(sorted[1].GetLicense()).To(Equal("MIT")) + Expect(sorted[2].GetLicense()).To(Equal("GPL-3.0")) + Expect(sorted[3].GetLicense()).To(Equal("Apache-2.0")) + }) + + It("should handle all empty licenses", func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{License: ""}}, + {Metadata: Metadata{License: ""}}, + } + sorted := elements.SortByLicense("asc") + Expect(sorted).To(HaveLen(2)) + }) + }) + + Describe("GalleryElements SortByInstalled", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{Name: "installed-2", Installed: true}}, + {Metadata: Metadata{Name: "not-installed-1", Installed: false}}, + {Metadata: Metadata{Name: "installed-1", Installed: true}}, + {Metadata: Metadata{Name: "not-installed-2", Installed: false}}, + } + }) + + It("should sort ascending with installed first, then by name", func() { + sorted := elements.SortByInstalled("asc") + Expect(sorted).To(HaveLen(4)) + Expect(sorted[0].GetInstalled()).To(BeTrue()) + Expect(sorted[0].GetName()).To(Equal("installed-1")) + Expect(sorted[1].GetInstalled()).To(BeTrue()) + Expect(sorted[1].GetName()).To(Equal("installed-2")) + Expect(sorted[2].GetInstalled()).To(BeFalse()) + Expect(sorted[2].GetName()).To(Equal("not-installed-1")) + Expect(sorted[3].GetInstalled()).To(BeFalse()) + Expect(sorted[3].GetName()).To(Equal("not-installed-2")) + }) + + It("should sort descending with not-installed first, then by name", func() { + sorted := elements.SortByInstalled("desc") + Expect(sorted).To(HaveLen(4)) + Expect(sorted[0].GetInstalled()).To(BeFalse()) + Expect(sorted[0].GetName()).To(Equal("not-installed-2")) + Expect(sorted[1].GetInstalled()).To(BeFalse()) + Expect(sorted[1].GetName()).To(Equal("not-installed-1")) + Expect(sorted[2].GetInstalled()).To(BeTrue()) + Expect(sorted[2].GetName()).To(Equal("installed-2")) + Expect(sorted[3].GetInstalled()).To(BeTrue()) + Expect(sorted[3].GetName()).To(Equal("installed-1")) + }) + }) + + Describe("GalleryElements FindByName", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{Name: "bert-embeddings"}}, + {Metadata: Metadata{Name: "gpt-2"}}, + {Metadata: Metadata{Name: "llama-7b"}}, + } + }) + + It("should find element by exact name", func() { + result := elements.FindByName("bert-embeddings") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert-embeddings")) + }) + + It("should be case insensitive", func() { + result := elements.FindByName("BERT-EMBEDDINGS") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert-embeddings")) + }) + + It("should return zero value when not found", func() { + result := elements.FindByName("nonexistent") + Expect(result).To(BeNil()) + }) + }) + + Describe("GalleryElements Paginate", func() { + var elements GalleryElements[*GalleryModel] + + BeforeEach(func() { + elements = GalleryElements[*GalleryModel]{ + {Metadata: Metadata{Name: "model-1"}}, + {Metadata: Metadata{Name: "model-2"}}, + {Metadata: Metadata{Name: "model-3"}}, + {Metadata: Metadata{Name: "model-4"}}, + {Metadata: Metadata{Name: "model-5"}}, + } + }) + + It("should return first page", func() { + page := elements.Paginate(1, 2) + Expect(page).To(HaveLen(2)) + Expect(page[0].GetName()).To(Equal("model-1")) + Expect(page[1].GetName()).To(Equal("model-2")) + }) + + It("should return second page", func() { + page := elements.Paginate(2, 2) + Expect(page).To(HaveLen(2)) + Expect(page[0].GetName()).To(Equal("model-3")) + Expect(page[1].GetName()).To(Equal("model-4")) + }) + + It("should return partial last page", func() { + page := elements.Paginate(3, 2) + Expect(page).To(HaveLen(1)) + Expect(page[0].GetName()).To(Equal("model-5")) + }) + + It("should handle page beyond range", func() { + page := elements.Paginate(10, 2) + Expect(page).To(HaveLen(0)) + }) + + It("should handle empty elements", func() { + empty := GalleryElements[*GalleryModel]{} + page := empty.Paginate(1, 10) + Expect(page).To(HaveLen(0)) + }) + }) + + Describe("FindGalleryElement", func() { + var models []*GalleryModel + + BeforeEach(func() { + models = []*GalleryModel{ + { + Metadata: Metadata{ + Name: "bert-embeddings", + Gallery: config.Gallery{ + Name: "huggingface", + }, + }, + }, + { + Metadata: Metadata{ + Name: "gpt-2", + Gallery: config.Gallery{ + Name: "openai", + }, + }, + }, + } + }) + + It("should find element by name without @ notation", func() { + result := FindGalleryElement(models, "bert-embeddings") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert-embeddings")) + }) + + It("should find element by name with @ notation", func() { + result := FindGalleryElement(models, "huggingface@bert-embeddings") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert-embeddings")) + Expect(result.GetGallery().Name).To(Equal("huggingface")) + }) + + It("should be case insensitive", func() { + result := FindGalleryElement(models, "BERT-EMBEDDINGS") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert-embeddings")) + }) + + It("should handle path separators in name", func() { + // Path separators are replaced with __, so bert/embeddings becomes bert__embeddings + // This test verifies the replacement happens, but won't match unless model name has __ + modelsWithPath := []*GalleryModel{ + { + Metadata: Metadata{ + Name: "bert__embeddings", + Gallery: config.Gallery{ + Name: "huggingface", + }, + }, + }, + } + result := FindGalleryElement(modelsWithPath, "bert/embeddings") + Expect(result).NotTo(BeNil()) + Expect(result.GetName()).To(Equal("bert__embeddings")) + }) + + It("should return zero value when not found", func() { + result := FindGalleryElement(models, "nonexistent") + Expect(result).To(BeNil()) + }) + + It("should return zero value when gallery@name not found", func() { + result := FindGalleryElement(models, "nonexistent@model") + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/core/gallery/models_types.go b/core/gallery/models_types.go index b2d3944de..000aa2b26 100644 --- a/core/gallery/models_types.go +++ b/core/gallery/models_types.go @@ -17,6 +17,14 @@ type GalleryModel struct { Overrides map[string]interface{} `json:"overrides,omitempty" yaml:"overrides,omitempty"` } +func (m *GalleryModel) GetInstalled() bool { + return m.Installed +} + +func (m *GalleryModel) GetLicense() string { + return m.License +} + func (m *GalleryModel) SetGallery(gallery config.Gallery) { m.Gallery = gallery } diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 95b20410f..f6e57905c 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -22,6 +22,14 @@ import ( "github.com/rs/zerolog/log" ) +const ( + nameSortFieldName = "name" + repositorySortFieldName = "repository" + licenseSortFieldName = "license" + statusSortFieldName = "status" + ascSortOrder = "asc" +) + // RegisterUIAPIRoutes registers JSON API routes for the web UI func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, applicationInstance *application.Application) { @@ -189,6 +197,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model // Get model statuses processingModelsData, taskTypes := opcache.GetStatus() + // Apply sorting if requested + sortBy := c.QueryParam("sort") + sortOrder := c.QueryParam("order") + if sortOrder == "" { + sortOrder = ascSortOrder + } + + switch sortBy { + case nameSortFieldName: + models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByName(sortOrder) + case repositorySortFieldName: + models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByRepository(sortOrder) + case licenseSortFieldName: + models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByLicense(sortOrder) + case statusSortFieldName: + models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByInstalled(sortOrder) + } + pageNum, err := strconv.Atoi(page) if err != nil || pageNum < 1 { pageNum = 1 @@ -493,6 +519,24 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model // Get backend statuses processingBackendsData, taskTypes := opcache.GetStatus() + // Apply sorting if requested + sortBy := c.QueryParam("sort") + sortOrder := c.QueryParam("order") + if sortOrder == "" { + sortOrder = ascSortOrder + } + + switch sortBy { + case nameSortFieldName: + backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByName(sortOrder) + case repositorySortFieldName: + backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByRepository(sortOrder) + case licenseSortFieldName: + backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByLicense(sortOrder) + case statusSortFieldName: + backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByInstalled(sortOrder) + } + pageNum, err := strconv.Atoi(page) if err != nil || pageNum < 1 { pageNum = 1 diff --git a/core/http/views/backends.html b/core/http/views/backends.html index 8a3928a47..b5ce41594 100644 --- a/core/http/views/backends.html +++ b/core/http/views/backends.html @@ -153,11 +153,47 @@ Icon - Backend Name + +
+ Backend Name + +
+ Description - Repository - License - Status + +
+ Repository + +
+ + +
+ License + +
+ + +
+ Status + +
+ Actions @@ -495,6 +531,8 @@ function backendsGallery() { selectedBackend: null, jobProgress: {}, notifications: [], + sortBy: '', + sortOrder: 'asc', init() { this.fetchBackends(); @@ -521,6 +559,10 @@ function backendsGallery() { items: 21, term: this.searchTerm }); + if (this.sortBy) { + params.append('sort', this.sortBy); + params.append('order', this.sortOrder); + } const response = await fetch(`/api/backends?${params}`); const data = await response.json(); @@ -544,6 +586,19 @@ function backendsGallery() { this.fetchBackends(); }, + setSort(column) { + if (this.sortBy === column) { + // Toggle sort order if clicking the same column + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // Set new column and default to ascending + this.sortBy = column; + this.sortOrder = 'asc'; + } + this.currentPage = 1; + this.fetchBackends(); + }, + goToPage(page) { if (page >= 1 && page <= this.totalPages) { this.currentPage = page; diff --git a/core/http/views/models.html b/core/http/views/models.html index 9b131f552..42ae8317c 100644 --- a/core/http/views/models.html +++ b/core/http/views/models.html @@ -186,41 +186,77 @@ -
+
- - - - - - - - + + + + + + + +
IconModel NameDescriptionRepositoryLicenseStatusActions
Icon +
+ Model Name + +
+
Description +
+ Repository + +
+
+
+ License + +
+
+
+ Status + +
+
Actions