Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# Finder (MacOS) folder config
.DS_Store
tags
hot
2 changes: 2 additions & 0 deletions bin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
38 changes: 38 additions & 0 deletions explorer/explorer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package explorer

import (
"context"
)

type ExploreRequest struct {
Architecture string
Query string
Tags []string
Untagged bool
}

type ExploreResponse struct {
Registries []Registry
Repositories []Repository
}

type Registry struct {
Name string
Status string
}

type Repository struct {
Name string
Registry string
Size int
Architectures []string
CrawlState string
LastSyncedAt string
}

type DetailsResponse struct{}

type Explorer interface {
Explore(ctx context.Context, req ExploreRequest) (*ExploreResponse, error)
Details(ctx context.Context, registry, image string) (*DetailsResponse, error)
}
175 changes: 175 additions & 0 deletions explorer/fake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package explorer

import (
"context"
"strings"
)

type Fake struct{}

func (f Fake) Explore(ctx context.Context, req ExploreRequest) (*ExploreResponse, error) {
registries := []Registry{
{
Name: "docker.io",
Status: "online",
},
{
Name: "gcr.io",
Status: "online",
},
{
Name: "registry.k8s.io",
Status: "online",
},
{
Name: "quay.io",
Status: "online",
},
{
Name: "localhost:5000",
Status: "offline",
},
}

repositories := []Repository{
{
Name: "nginx",
Registry: "docker.io",
Size: 142857600, // ~136 MB
Architectures: []string{"amd64", "arm64", "arm/v7"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T10:30:00Z",
},
{
Name: "redis",
Registry: "docker.io",
Size: 104857600, // ~100 MB
Architectures: []string{"amd64", "arm64", "arm/v7", "ppc64le", "s390x"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T09:15:00Z",
},
{
Name: "postgres",
Registry: "docker.io",
Size: 314572800, // ~300 MB
Architectures: []string{"veryunique"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T08:45:00Z",
},
{
Name: "alpine",
Registry: "docker.io",
Size: 5242880, // ~5 MB
Architectures: []string{"amd64", "arm64", "arm/v7", "arm/v6", "ppc64le", "s390x", "386"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T11:00:00Z",
},
{
Name: "ubuntu",
Registry: "docker.io",
Size: 73400320, // ~70 MB
Architectures: []string{"amd64", "arm64", "arm/v7", "ppc64le", "s390x"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T07:20:00Z",
},
{
Name: "pause",
Registry: "registry.k8s.io",
Size: 716800, // ~700 KB
Architectures: []string{"amd64", "arm64", "arm", "ppc64le", "s390x"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T06:30:00Z",
},
{
Name: "etcd",
Registry: "registry.k8s.io",
Size: 52428800, // ~50 MB
Architectures: []string{"amd64", "arm64", "ppc64le", "s390x"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T05:45:00Z",
},
{
Name: "kube-proxy",
Registry: "registry.k8s.io",
Size: 83886080, // ~80 MB
Architectures: []string{"amd64", "arm64", "arm", "ppc64le", "s390x"},
CrawlState: "in-progress",
LastSyncedAt: "2025-10-08T04:15:00Z",
},
{
Name: "prometheus/prometheus",
Registry: "quay.io",
Size: 209715200, // ~200 MB
Architectures: []string{"amd64", "arm64", "arm/v7", "ppc64le", "s390x"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T03:30:00Z",
},
{
Name: "grafana/grafana",
Registry: "docker.io",
Size: 268435456, // ~256 MB
Architectures: []string{"amd64", "arm64", "arm/v7"},
CrawlState: "failed",
LastSyncedAt: "2025-10-07T22:10:00Z",
},
{
Name: "google-containers/pause",
Registry: "gcr.io",
Size: 716800, // ~700 KB
Architectures: []string{"amd64", "arm64"},
CrawlState: "completed",
LastSyncedAt: "2025-10-08T02:45:00Z",
},
{
Name: "my-app",
Registry: "localhost:5000",
Size: 67108864, // ~64 MB
Architectures: []string{"amd64"},
CrawlState: "pending",
LastSyncedAt: "2025-10-07T20:00:00Z",
},
}

// Simple filtering based on request parameters
filteredRepositories := []Repository{}
for _, repo := range repositories {
// Filter by query if provided
if req.Query != "" {
if !contains(repo.Name, req.Query) && !contains(repo.Registry, req.Query) {
continue
}
}

// Filter by architecture if provided
if req.Architecture != "" {
if !containsString(repo.Architectures, req.Architecture) {
continue
}
}

filteredRepositories = append(filteredRepositories, repo)
}

return &ExploreResponse{
Registries: registries,
Repositories: filteredRepositories,
}, nil
}

func (f Fake) Details(ctx context.Context, registry, image string) (*DetailsResponse, error) {
return &DetailsResponse{}, nil
}

// Helper functions for filtering
func contains(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}

func containsString(slice []string, item string) bool {
for _, s := range slice {
if strings.EqualFold(s, item) {
return true
}
}
return false
}
23 changes: 23 additions & 0 deletions explorer/inertia/adapters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package inertia

import (
"github.com/eznix86/docker-registry-ui/explorer"
"github.com/romsar/gonertia/v2"
)

func toExploreResponse(r *explorer.ExploreResponse) gonertia.Props {
return gonertia.Props{
"Registries": gonertia.DeferProp{
Group: "",
Value: r.Registries,
},
"Repositories": gonertia.DeferProp{
Group: "",
Value: r.Repositories,
},
}
}

func toDetailsResponse(_ *explorer.DetailsResponse) gonertia.Props {
return gonertia.Props{}
}
145 changes: 145 additions & 0 deletions explorer/inertia/inertia.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package inertia

import (
"io"
"io/fs"
"log"
"log/slog"
"net/http"
"strings"

"github.com/eznix86/docker-registry-ui/explorer"
"github.com/eznix86/docker-registry-ui/explorer/inertia/internal/inertia"
"github.com/eznix86/docker-registry-ui/ui"
"github.com/romsar/gonertia/v2"
)

type renderer interface {
Render(w http.ResponseWriter, r *http.Request, component string, props ...gonertia.Props) (err error)
}

func createMux(h *handler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/details", h.Details)
mux.HandleFunc("/", h.Explore)

return mux
}

func New(logger *slog.Logger, exp explorer.Explorer) http.Handler {
inertia, err := gonertia.NewFromBytes(ui.BuildHtml())
if err != nil {
panic(err)
}
Comment on lines +30 to +33
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use inertia.NewFromFileFS(embedFS, "...") https://github.com/romsar/gonertia?tab=readme-ov-file#basic-example


inertia.ShareTemplateFunc("vite", ui.Manifest())
inertia.ShareTemplateFunc("viteReactRefresh", func() string { return "" })

h := &handler{
logger: logger,
explorer: exp,
i: inertia,
assets: ui.Assets(),
}

mux := http.NewServeMux()
mux.HandleFunc("/details", h.Details)
mux.HandleFunc("/assets/", h.Assets)
mux.HandleFunc("/", h.Explore)

return mux
}

func Dev(logger *slog.Logger, exp explorer.Explorer) http.Handler {
in, err := gonertia.NewFromFile("ui/views/app.html")
if err != nil {
panic(err)
}

i, err := inertia.NewWithVite(in)
if err != nil {
log.Fatal(err)
}
Comment on lines +54 to +62
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for dual func, the inertia.go for vite handle both cases.


h := &handler{
logger: logger,
i: i,
explorer: exp,
}

mux := createMux(h)
return mux
}

type handler struct {
logger *slog.Logger
i renderer
explorer explorer.Explorer
assets fs.FS
}

func (h *handler) Explore(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

h.logger.Debug("Explore", slog.String("url", r.URL.String()))

q := r.URL.Query()
req := explorer.ExploreRequest{
Architecture: q.Get("arch"),
Query: q.Get("q"),
Tags: []string{q.Get("tags")},
Untagged: false, //q.Get("untagged"), // TODO
}

res, err := h.explorer.Explore(ctx, req)
if err != nil {
h.logger.Error(err.Error())
}

err = h.i.Render(w, r, "Explore", toExploreResponse(res))
if err != nil {
h.logger.Error(err.Error())
}
}

func (h *handler) Details(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("Details", slog.String("url", r.URL.String()))

w.WriteHeader(http.StatusOK)
w.Write([]byte("Details"))
}

func (h *handler) Assets(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("Assets", slog.String("url", r.URL.String()))
if h.assets == nil {
http.NotFound(w, r)
return
}

path := strings.TrimPrefix(r.URL.Path, "/assets/")
file, err := h.assets.Open(path)
if err != nil {
h.logger.Error("failed to open asset", slog.String("path", path), slog.String("error", err.Error()))
http.NotFound(w, r)
return
}

contentType := "text/plain"
if strings.HasSuffix(path, ".js") {
contentType = "application/javascript"
} else if strings.HasSuffix(path, ".css") {
contentType = "text/css"
} else if strings.HasSuffix(path, ".png") {
contentType = "image/png"
} else if strings.HasSuffix(path, ".svg") {
contentType = "image/svg+xml"
} else if strings.HasSuffix(path, ".woff2") {
contentType = "font/woff2"
}

w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)

defer file.Close()
io.Copy(w, file)
}
Loading