Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions internal/server/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"html/template"
"sort"
"strings"

"github.com/git-pkgs/proxy/internal/database"
Expand Down Expand Up @@ -110,13 +111,21 @@ type SearchResultItem struct {
// PackagesListPageData contains data for rendering the packages list page.
type PackagesListPageData struct {
Layout
Ecosystem string
SortBy string
Results []SearchResultItem
Count int
Page int
PerPage int
TotalPages int
Ecosystem string
SortBy string
Results []SearchResultItem
Count int
TotalPackages int64
EcosystemFilters []EcosystemFilter
Page int
PerPage int
TotalPages int
}

// EcosystemFilter represents an ecosystem filter pill with a package count.
type EcosystemFilter struct {
Ecosystem string
Count int64
}

func supportedEcosystems() []string {
Expand Down Expand Up @@ -157,49 +166,82 @@ func ecosystemBadgeLabel(ecosystem string) string {
}
}

func ecosystemBadgeClasses(ecosystem string) string {
base := "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"

func ecosystemColorClasses(ecosystem string) string {
switch ecosystem {
case "npm", "maven":
return base + " bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
return "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
case "cargo":
return base + " bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
return "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
case "gem":
return base + " bg-pink-100 text-pink-700 dark:bg-pink-900/50 dark:text-pink-300"
case "go":
return base + " bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
return "bg-pink-100 text-pink-700 dark:bg-pink-900/50 dark:text-pink-300"
case "go", "golang":
return "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
case "hex":
return base + " bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
return "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
case "pub":
return base + " bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
return "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
case "pypi":
return base + " bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
case "nuget":
return base + " bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300"
return "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300"
case "composer":
return base + " bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
return "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
case "conan":
return base + " bg-teal-100 text-teal-700 dark:bg-teal-900/50 dark:text-teal-300"
return "bg-teal-100 text-teal-700 dark:bg-teal-900/50 dark:text-teal-300"
case "conda":
return base + " bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
return "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
case "cran":
return base + " bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"
return "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300"
case "julia":
return base + " bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300"
case "swift":
return base + " bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
return "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300"
case "oci":
return base + " bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
return "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
case "deb":
return base + " bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300"
return "bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300"
case "rpm":
return base + " bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300"
return "bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300"
case "alpine":
return base + " bg-lime-100 text-lime-800 dark:bg-lime-900/50 dark:text-lime-300"
return "bg-lime-100 text-lime-800 dark:bg-lime-900/50 dark:text-lime-300"
default:
return base + " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
}
}

func ecosystemBadgeClasses(ecosystem string) string {
return "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium " + ecosystemColorClasses(ecosystem)
}

func ecosystemPillClasses(ecosystem string) string {
return "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium hover:opacity-90 " + ecosystemColorClasses(ecosystem)
}

func buildEcosystemFilters(counts map[string]int64) []EcosystemFilter {
var filters []EcosystemFilter
seen := make(map[string]bool, len(counts))

for _, eco := range supportedEcosystems() {
count := counts[eco]
if count > 0 {
filters = append(filters, EcosystemFilter{Ecosystem: eco, Count: count})
seen[eco] = true
}
}

var extra []string
for eco, count := range counts {
if count > 0 && !seen[eco] {
extra = append(extra, eco)
}
}
sort.Strings(extra)

for _, eco := range extra {
filters = append(filters, EcosystemFilter{Ecosystem: eco, Count: counts[eco]})
}

return filters
}

func getRegistryConfigs(baseURL string) []RegistryConfig {
Expand Down
27 changes: 19 additions & 8 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,15 +798,26 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {

totalPages := int((total + int64(limit) - 1) / int64(limit))

var ecosystemFilters []EcosystemFilter
var totalPackages int64
if stats, err := s.db.GetCacheStats(); err != nil {
s.logger.Error("failed to get cache stats for ecosystem filters", "error", err)
} else {
totalPackages = stats.TotalPackages
ecosystemFilters = buildEcosystemFilters(stats.EcosystemCounts)
}
Comment on lines +801 to +808

data := PackagesListPageData{
Layout: s.layoutFor(r),
Ecosystem: ecosystem,
SortBy: sortBy,
Results: items,
Count: int(total),
Page: page,
PerPage: limit,
TotalPages: totalPages,
Layout: s.layoutFor(r),
Ecosystem: ecosystem,
SortBy: sortBy,
Results: items,
Count: int(total),
TotalPackages: totalPackages,
EcosystemFilters: ecosystemFilters,
Page: page,
PerPage: limit,
TotalPages: totalPages,
}

if err := s.templates.Render(w, "packages_list", data); err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/server/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (t *Templates) load() error {
"sub": func(a, b int) int { return a - b },
"supportedEcosystems": supportedEcosystems,
"ecosystemBadgeClass": ecosystemBadgeClasses,
"ecosystemPillClass": ecosystemPillClasses,
"ecosystemBadgeLabel": ecosystemBadgeLabel,
}

Expand Down
60 changes: 30 additions & 30 deletions internal/server/templates/pages/packages_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,31 @@
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Cached Packages</h1>
<p class="text-gray-600 dark:text-gray-400">
{{.Count}} packages{{if .Ecosystem}} in {{.Ecosystem}}{{end}}
{{.Count}} packages{{if .Ecosystem}} in {{ecosystemBadgeLabel .Ecosystem}}{{end}}
</p>
</div>

<div class="mb-6 flex items-center gap-4">
<div class="flex-1">
<label for="ecosystem-filter" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Filter by ecosystem</label>
<select id="ecosystem-filter" class="w-full max-w-xs px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400">
<option value="">All ecosystems</option>
{{range supportedEcosystems}}
<option value="{{.}}"{{if eq $.Ecosystem .}} selected{{end}}>{{ecosystemBadgeLabel .}}</option>
<div class="mb-6 flex flex-col sm:flex-row sm:items-start gap-4">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Filter by ecosystem</div>
<div class="flex flex-wrap gap-2">
<a href="/ui/packages{{if .SortBy}}?sort={{.SortBy}}{{end}}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:opacity-90{{if not .Ecosystem}} ring-2 ring-gray-400 dark:ring-gray-500 ring-offset-1 dark:ring-offset-gray-900{{end}}">
Comment on lines +15 to +16
All
<span class="opacity-70 tabular-nums">{{.TotalPackages}}</span>
</a>
{{range .EcosystemFilters}}
<a href="/ui/packages?ecosystem={{.Ecosystem}}{{if $.SortBy}}&sort={{$.SortBy}}{{end}}"
class="{{ecosystemPillClass .Ecosystem}}{{if eq $.Ecosystem .Ecosystem}} ring-2 ring-current ring-offset-1 dark:ring-offset-gray-900{{end}}">
Comment on lines +21 to +22
{{ecosystemBadgeLabel .Ecosystem}}
<span class="opacity-70 tabular-nums">{{.Count}}</span>
</a>
{{end}}
</select>
</div>
</div>
<div class="flex-1">
<div class="shrink-0 w-full sm:w-48">
<label for="sort-by" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Sort by</label>
<select id="sort-by" class="w-full max-w-xs px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400">
<select id="sort-by" class="w-full px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400">
<option value="hits"{{if eq .SortBy "hits"}} selected{{end}}>Cache hits</option>
<option value="name"{{if eq .SortBy "name"}} selected{{end}}>Name</option>
<option value="size"{{if eq .SortBy "size"}} selected{{end}}>Size</option>
Expand Down Expand Up @@ -53,11 +61,14 @@ <h1 class="text-3xl font-bold mb-2">Cached Packages</h1>
{{end}}
</div>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>{{.Hits}} hits</span>
<span>{{.SizeFormatted}}</span>
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 shrink-0">
<span class="inline-grid grid-cols-[minmax(4ch,auto)_auto] gap-x-1 tabular-nums">
<span class="text-right">{{.Hits}}</span>
<span>hits</span>
</span>
<span class="w-16 text-right">{{.SizeFormatted}}</span>
{{if .CachedAt}}
<span>{{.CachedAt}}</span>
<span class="w-20 text-right">{{.CachedAt}}</span>
{{end}}
</div>
</div>
Expand All @@ -66,7 +77,7 @@ <h1 class="text-3xl font-bold mb-2">Cached Packages</h1>
</div>
{{else}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{.Ecosystem}}{{end}}</p>
<p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{ecosystemBadgeLabel .Ecosystem}}{{end}}</p>
<a href="/ui/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a>
</div>
{{end}}
Expand Down Expand Up @@ -94,21 +105,10 @@ <h1 class="text-3xl font-bold mb-2">Cached Packages</h1>
{{end}}

<script>
document.getElementById('ecosystem-filter').addEventListener('change', function(e) {
const ecosystem = e.target.value;
const sortBy = document.getElementById('sort-by').value;
const params = new URLSearchParams();
if (ecosystem) params.set('ecosystem', ecosystem);
if (sortBy) params.set('sort', sortBy);
window.location.href = '/ui/packages?' + params.toString();
});

document.getElementById('sort-by').addEventListener('change', function(e) {
const sortBy = e.target.value;
const ecosystem = document.getElementById('ecosystem-filter').value;
const params = new URLSearchParams();
if (ecosystem) params.set('ecosystem', ecosystem);
if (sortBy) params.set('sort', sortBy);
const params = new URLSearchParams(window.location.search);
params.set('sort', e.target.value);
params.delete('page');
window.location.href = '/ui/packages?' + params.toString();
});
</script>
Expand Down
49 changes: 42 additions & 7 deletions internal/server/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@ func TestTemplatesRenderAllPages(t *testing.T) {
TotalPages: 0,
}},
{"packages_list", PackagesListPageData{
Ecosystem: "",
SortBy: defaultSortBy,
Results: []SearchResultItem{{Ecosystem: "npm", Name: "express", Hits: 200, SizeFormatted: "2 MB"}},
Count: 1,
Page: 1,
PerPage: 50,
TotalPages: 1,
Ecosystem: "",
SortBy: defaultSortBy,
Results: []SearchResultItem{{Ecosystem: "npm", Name: "express", Hits: 200, SizeFormatted: "2 MB"}},
Count: 1,
TotalPackages: 1,
EcosystemFilters: []EcosystemFilter{{Ecosystem: "npm", Count: 1}},
Page: 1,
PerPage: 50,
TotalPages: 1,
}},
{"package_show", PackageShowData{
Package: &database.Package{
Expand Down Expand Up @@ -568,6 +570,39 @@ func TestEcosystemBadgeClasses(t *testing.T) {
}
}

func TestBuildEcosystemFilters(t *testing.T) {
filters := buildEcosystemFilters(map[string]int64{
"npm": 3,
"cargo": 2,
"empty": 0,
})

if len(filters) != 2 {
t.Fatalf("expected 2 filters, got %d", len(filters))
}
if filters[0].Ecosystem != "cargo" || filters[0].Count != 2 {
t.Errorf("first filter = %#v, want cargo with count 2", filters[0])
}
if filters[1].Ecosystem != "npm" || filters[1].Count != 3 {
t.Errorf("second filter = %#v, want npm with count 3", filters[1])
}

extra := buildEcosystemFilters(map[string]int64{"custom": 1})
if len(extra) != 1 || extra[0].Ecosystem != "custom" {
t.Errorf("unexpected extra filters: %#v", extra)
}
}

func TestEcosystemPillClasses(t *testing.T) {
classes := ecosystemPillClasses("npm")
if !strings.Contains(classes, "rounded-full") {
t.Error("pill classes should use rounded-full")
}
if !strings.Contains(classes, "bg-red-100") {
t.Error("npm pill should use npm colors")
}
}

func TestFormatSize(t *testing.T) {
tests := []struct {
bytes int64
Expand Down
Loading