├── version.go ├── data └── README.md ├── .gitignore ├── screenshots ├── 1.png ├── 2.png ├── 3.png └── 4.png ├── static ├── favicon.ico ├── css │ ├── fonts │ │ ├── bootstrap-icons.woff │ │ └── bootstrap-icons.woff2 │ ├── custom.css │ └── datatables.min.css └── js │ ├── bs5-confirmation.js │ └── sorting_natural.js ├── SECURITY.md ├── templates ├── breadcrumb.html ├── json_to_table.html ├── base.html ├── image_info.html ├── event_log.html └── catalog.html ├── Makefile ├── Dockerfile ├── registry ├── common.go ├── common_test.go ├── tasks.go └── client.go ├── template.go ├── go.mod ├── middleware.go ├── config.yml ├── main.go ├── web.go ├── README.md ├── events └── event_listener.go ├── CHANGELOG.md ├── go.sum └── LICENSE /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const version = "0.11.0" 4 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | Directory for sqlite db file `registry_events.db`. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/registry_events.db 2 | config-dev*.yml 3 | keep_tags.json 4 | -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/screenshots/3.png -------------------------------------------------------------------------------- /screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/screenshots/4.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | To report any security issues, please use this email address security@quiq.com 2 | -------------------------------------------------------------------------------- /static/css/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/static/css/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /static/css/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quiq/registry-ui/HEAD/static/css/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /templates/breadcrumb.html: -------------------------------------------------------------------------------- 1 | {{ block breadcrumb() }} 2 | 7 | {{if . != nil}} 8 | {{x := ""}} 9 | {{range _, p := split(., "/")}} 10 | {{x = x + "/" + p}} 11 | 14 | {{end}} 15 | {{end}} 16 | {{ end }} 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE=quiq/registry-ui 2 | VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go` 3 | NOCACHE=--no-cache 4 | 5 | .DEFAULT_GOAL := dummy 6 | 7 | dummy: 8 | @echo "Nothing to do here." 9 | 10 | build: 11 | docker build ${NOCACHE} -t ${IMAGE}:${VERSION} . 12 | 13 | public: 14 | docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push . 15 | 16 | debug: 17 | docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:debug --push . 18 | 19 | test: 20 | docker buildx build ${NOCACHE} --platform linux/arm64 -t docker.quiq.im/registry-ui:test -t docker.quiq.sh/registry-ui:test --push . 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.4-alpine3.22 AS builder 2 | 3 | RUN apk update && \ 4 | apk add ca-certificates git bash gcc musl-dev 5 | 6 | WORKDIR /opt/src 7 | ADD events events 8 | ADD registry registry 9 | ADD *.go go.mod go.sum ./ 10 | 11 | RUN go test -v ./registry && \ 12 | go build -o /opt/registry-ui *.go 13 | 14 | 15 | FROM alpine:3.22 16 | 17 | WORKDIR /opt 18 | RUN apk add --no-cache ca-certificates tzdata && \ 19 | mkdir /opt/data && \ 20 | chown nobody /opt/data 21 | 22 | ADD templates /opt/templates 23 | ADD static /opt/static 24 | ADD config.yml /opt 25 | COPY --from=builder /opt/registry-ui /opt/ 26 | 27 | USER nobody 28 | ENTRYPOINT ["/opt/registry-ui"] 29 | -------------------------------------------------------------------------------- /templates/json_to_table.html: -------------------------------------------------------------------------------- 1 | {{ block json_to_table() }} 2 | 3 | {{ try }} 4 | 5 | {{range i, k := sort_map_keys(.) }} 6 | {{ v := .[k] }} 7 | 8 | 9 | 14 | 15 | {{end}} 16 |
{{k}} 10 | {{if ii.IsImage && k == "size"}}{{ pretty_size(v) }} 11 | {{else if ii.IsImageIndex && k == "digest"}}{{ v }} 12 | {{else}}{{ yield json_to_table() v }}{{end}} 13 |
17 | 18 | {{ catch err }} 19 | {{if err.Error() == "reflect: call of reflect.Value.MapKeys on slice Value"}} 20 | 21 | {{range _, e := . }} 22 | 23 | 24 | 25 | {{end}} 26 |
{{ yield json_to_table() e }}
27 | {{else}} 28 | {{ . }} 29 | {{end}} 30 | {{end}} {* end try *} 31 | 32 | {{ end }} 33 | -------------------------------------------------------------------------------- /registry/common.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "sort" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // SetupLogging setup logger 14 | func SetupLogging(name string) *logrus.Entry { 15 | logrus.SetFormatter(&logrus.TextFormatter{ 16 | TimestampFormat: time.RFC3339, 17 | FullTimestamp: true, 18 | }) 19 | // Output to stdout instead of the default stderr. 20 | logrus.SetOutput(os.Stdout) 21 | 22 | return logrus.WithFields(logrus.Fields{"logger": name}) 23 | } 24 | 25 | // SortedMapKeys sort keys of the map where values can be of any type. 26 | func SortedMapKeys(m interface{}) []string { 27 | v := reflect.ValueOf(m) 28 | keys := make([]string, 0, len(v.MapKeys())) 29 | for _, key := range v.MapKeys() { 30 | keys = append(keys, key.String()) 31 | } 32 | sort.Strings(keys) 33 | return keys 34 | } 35 | 36 | // PrettySize format bytes in more readable units. 37 | func PrettySize(size float64) string { 38 | units := []string{"B", "KB", "MB", "GB"} 39 | i := 0 40 | for size > 1024 && i < len(units) { 41 | size = size / 1024 42 | i = i + 1 43 | } 44 | // Format decimals as follow: 0 B, 0 KB, 0.0 MB, 0.00 GB 45 | decimals := i - 1 46 | if decimals < 0 { 47 | decimals = 0 48 | } 49 | return fmt.Sprintf("%.*f %s", decimals, size, units[i]) 50 | } 51 | 52 | // ItemInSlice check if item is an element of slice 53 | func ItemInSlice(item string, slice []string) bool { 54 | for _, i := range slice { 55 | if i == item { 56 | return true 57 | } 58 | } 59 | return false 60 | } 61 | 62 | // UniqueSortedSlice filter out duplicate items from slice 63 | func UniqueSortedSlice(slice []string) []string { 64 | sort.Strings(slice) 65 | seen := make(map[string]struct{}, len(slice)) 66 | j := 0 67 | for _, i := range slice { 68 | if _, ok := seen[i]; ok { 69 | continue 70 | } 71 | seen[i] = struct{}{} 72 | slice[j] = i 73 | j++ 74 | } 75 | return slice[:j] 76 | } 77 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/CloudyKit/jet/v6" 9 | "github.com/labstack/echo/v4" 10 | "github.com/quiq/registry-ui/registry" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // Template Jet template. 15 | type Template struct { 16 | View *jet.Set 17 | } 18 | 19 | // Render render template. 20 | func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 21 | t, err := r.View.GetTemplate(name) 22 | if err != nil { 23 | panic(fmt.Errorf("fatal error template file: %s", err)) 24 | } 25 | vars, ok := data.(jet.VarMap) 26 | if !ok { 27 | vars = jet.VarMap{} 28 | } 29 | err = t.Execute(w, vars, nil) 30 | if err != nil { 31 | panic(fmt.Errorf("error rendering template %s: %s", name, err)) 32 | } 33 | return nil 34 | } 35 | 36 | // setupRenderer template engine init. 37 | func setupRenderer(basePath string) *Template { 38 | var opts []jet.Option 39 | if viper.GetBool("debug.templates") { 40 | opts = append(opts, jet.InDevelopmentMode()) 41 | } 42 | view := jet.NewSet(jet.NewOSFileSystemLoader("templates"), opts...) 43 | 44 | view.AddGlobal("version", version) 45 | view.AddGlobal("basePath", basePath) 46 | view.AddGlobal("registryHost", viper.GetString("registry.hostname")) 47 | view.AddGlobal("pretty_size", func(val interface{}) string { 48 | var s float64 49 | switch i := val.(type) { 50 | case int64: 51 | s = float64(i) 52 | case float64: 53 | s = i 54 | default: 55 | fmt.Printf("Unhandled type when calling pretty_size(): %T\n", i) 56 | } 57 | return registry.PrettySize(s) 58 | }) 59 | view.AddGlobal("pretty_time", func(val interface{}) string { 60 | var t time.Time 61 | switch i := val.(type) { 62 | case string: 63 | var err error 64 | t, err = time.Parse("2006-01-02T15:04:05Z", i) 65 | if err != nil { 66 | // mysql case 67 | t, _ = time.Parse("2006-01-02 15:04:05", i) 68 | } 69 | default: 70 | t = i.(time.Time) 71 | } 72 | return t.In(time.Local).Format("2006-01-02 15:04:05 MST") 73 | }) 74 | view.AddGlobal("sort_map_keys", func(m interface{}) []string { 75 | return registry.SortedMapKeys(m) 76 | }) 77 | return &Template{View: view} 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/quiq/registry-ui 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/CloudyKit/jet/v6 v6.3.1 9 | github.com/fatih/color v1.18.0 10 | github.com/go-sql-driver/mysql v1.9.3 11 | github.com/google/go-containerregistry v0.20.7 12 | github.com/labstack/echo/v4 v4.13.4 13 | github.com/mattn/go-sqlite3 v1.14.32 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/smartystreets/goconvey v1.8.1 16 | github.com/spf13/viper v1.21.0 17 | github.com/tidwall/gjson v1.18.0 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect 23 | github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect 24 | github.com/docker/cli v29.0.4+incompatible // indirect 25 | github.com/docker/distribution v2.8.3+incompatible // indirect 26 | github.com/docker/docker-credential-helpers v0.9.4 // indirect 27 | github.com/fsnotify/fsnotify v1.9.0 // indirect 28 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 29 | github.com/gopherjs/gopherjs v1.17.2 // indirect 30 | github.com/jtolds/gls v4.20.0+incompatible // indirect 31 | github.com/klauspost/compress v1.18.1 // indirect 32 | github.com/labstack/gommon v0.4.2 // indirect 33 | github.com/mattn/go-colorable v0.1.14 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/mitchellh/go-homedir v1.1.0 // indirect 36 | github.com/opencontainers/go-digest v1.0.0 // indirect 37 | github.com/opencontainers/image-spec v1.1.1 // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 39 | github.com/rogpeppe/go-internal v1.12.0 // indirect 40 | github.com/sagikazarmark/locafero v0.12.0 // indirect 41 | github.com/smarty/assertions v1.16.0 // indirect 42 | github.com/spf13/afero v1.15.0 // indirect 43 | github.com/spf13/cast v1.10.0 // indirect 44 | github.com/spf13/pflag v1.0.10 // indirect 45 | github.com/subosito/gotenv v1.6.0 // indirect 46 | github.com/tidwall/match v1.2.0 // indirect 47 | github.com/tidwall/pretty v1.2.1 // indirect 48 | github.com/valyala/bytebufferpool v1.0.0 // indirect 49 | github.com/valyala/fasttemplate v1.2.2 // indirect 50 | github.com/vbatts/tar-split v0.12.2 // indirect 51 | go.yaml.in/yaml/v3 v3.0.4 // indirect 52 | golang.org/x/crypto v0.45.0 // indirect 53 | golang.org/x/net v0.47.0 // indirect 54 | golang.org/x/sync v0.18.0 // indirect 55 | golang.org/x/sys v0.38.0 // indirect 56 | golang.org/x/text v0.31.0 // indirect 57 | golang.org/x/time v0.14.0 // indirect 58 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | "github.com/fatih/color" 12 | "github.com/labstack/echo/v4" 13 | "github.com/quiq/registry-ui/registry" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // loggingMiddleware logging of the web framework 18 | func loggingMiddleware() echo.MiddlewareFunc { 19 | logger := registry.SetupLogging("echo") 20 | return func(next echo.HandlerFunc) echo.HandlerFunc { 21 | return func(ctx echo.Context) (err error) { 22 | req := ctx.Request() 23 | 24 | // Skip logging for specific paths. 25 | if strings.HasSuffix(req.RequestURI, "/event-receiver") { 26 | return next(ctx) 27 | } 28 | 29 | // Log the original request in DEBUG mode. 30 | if logrus.GetLevel() == logrus.DebugLevel && req.Body != nil { 31 | bodyBytes, _ := io.ReadAll(req.Body) 32 | req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 33 | if len(bodyBytes) > 0 { 34 | logger.Debugf("Incoming HTTP %s request: %s", req.Method, string(bodyBytes)) 35 | } 36 | } 37 | 38 | res := ctx.Response() 39 | start := time.Now() 40 | if err = next(ctx); err != nil { 41 | ctx.Error(err) 42 | } 43 | stop := time.Now() 44 | 45 | statusCode := color.GreenString("%d", res.Status) 46 | switch { 47 | case res.Status >= 500: 48 | statusCode = color.RedString("%d", res.Status) 49 | case res.Status >= 400: 50 | statusCode = color.YellowString("%d", res.Status) 51 | case res.Status >= 300: 52 | statusCode = color.CyanString("%d", res.Status) 53 | } 54 | 55 | latency := stop.Sub(start).Round(1 * time.Millisecond).String() // human readable 56 | // latency := strconv.FormatInt(int64(stop.Sub(start)), 10) // in ns 57 | // Do main logging. 58 | logger.Infof("%s %s %s %s %s %s", ctx.RealIP(), req.Method, req.RequestURI, statusCode, latency, req.UserAgent()) 59 | return 60 | } 61 | } 62 | } 63 | 64 | // recoverMiddleware recover from panics 65 | func recoverMiddleware() echo.MiddlewareFunc { 66 | logger := registry.SetupLogging("echo") 67 | return func(next echo.HandlerFunc) echo.HandlerFunc { 68 | return func(ctx echo.Context) error { 69 | defer func() { 70 | if r := recover(); r != nil { 71 | err, ok := r.(error) 72 | if !ok { 73 | err = fmt.Errorf("%v", r) 74 | } 75 | stackSize := 4 << 10 // 4 KB 76 | stack := make([]byte, stackSize) 77 | length := runtime.Stack(stack, true) 78 | logger.Errorf("[PANIC RECOVER] %v %s\n", err, stack[:length]) 79 | } 80 | }() 81 | return next(ctx) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /registry/common_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | "time" 7 | 8 | "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestKeepMinCount(t *testing.T) { 12 | keepTags := []string{"1.8.15"} 13 | purgeTags := []string{"1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5", "1.8.4", "1.8.3"} 14 | purgeTagsKeepCount := 10 15 | 16 | // Keep minimal count of tags no matter how old they are. 17 | if len(keepTags) < purgeTagsKeepCount { 18 | // Min of threshold-keep but not more than purge. 19 | takeFromPurge := int(math.Min(float64(purgeTagsKeepCount-len(keepTags)), float64(len(purgeTags)))) 20 | keepTags = append(keepTags, purgeTags[:takeFromPurge]...) 21 | purgeTags = purgeTags[takeFromPurge:] 22 | } 23 | 24 | convey.Convey("Test keep min count logic", t, func() { 25 | convey.So(keepTags, convey.ShouldResemble, []string{"1.8.15", "1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5"}) 26 | convey.So(purgeTags, convey.ShouldResemble, []string{"1.8.4", "1.8.3"}) 27 | }) 28 | } 29 | 30 | func TestSortedMapKeys(t *testing.T) { 31 | a := map[string]string{ 32 | "foo": "bar", 33 | "abc": "bar", 34 | "zoo": "bar", 35 | } 36 | b := map[string]timeSlice{ 37 | "zoo": []TagData{{name: "1", created: time.Now()}}, 38 | "abc": []TagData{{name: "1", created: time.Now()}}, 39 | "foo": []TagData{{name: "1", created: time.Now()}}, 40 | } 41 | c := map[string][]string{ 42 | "zoo": {"1", "2"}, 43 | "foo": {"1", "2"}, 44 | "abc": {"1", "2"}, 45 | } 46 | expect := []string{"abc", "foo", "zoo"} 47 | convey.Convey("Sort map keys", t, func() { 48 | convey.So(SortedMapKeys(a), convey.ShouldResemble, expect) 49 | convey.So(SortedMapKeys(b), convey.ShouldResemble, expect) 50 | convey.So(SortedMapKeys(c), convey.ShouldResemble, expect) 51 | }) 52 | } 53 | 54 | func TestPrettySize(t *testing.T) { 55 | convey.Convey("Format bytes", t, func() { 56 | input := map[float64]string{ 57 | 123: "123 B", 58 | 23123: "23 KB", 59 | 23923: "23 KB", 60 | 723425120: "689.9 MB", 61 | 8534241213: "7.95 GB", 62 | } 63 | for key, val := range input { 64 | convey.So(PrettySize(key), convey.ShouldEqual, val) 65 | } 66 | }) 67 | } 68 | 69 | func TestItemInSlice(t *testing.T) { 70 | a := []string{"abc", "def", "ghi"} 71 | convey.Convey("Check whether element is in slice", t, func() { 72 | convey.So(ItemInSlice("abc", a), convey.ShouldBeTrue) 73 | convey.So(ItemInSlice("ghi", a), convey.ShouldBeTrue) 74 | convey.So(ItemInSlice("abc1", a), convey.ShouldBeFalse) 75 | convey.So(ItemInSlice("gh", a), convey.ShouldBeFalse) 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # Listen interface. 2 | listen_addr: 0.0.0.0:8000 3 | 4 | # Base path of Registry UI. 5 | uri_base_path: / 6 | 7 | # Background tasks. 8 | performance: 9 | # Catalog list page size. It depends from the underlying storage performance. 10 | catalog_page_size: 100 11 | 12 | # Catalog (repo list) refresh interval in minutes. 13 | # If set to 0 it will never refresh but will run once. 14 | catalog_refresh_interval: 10 15 | 16 | # Tags counting refresh interval in minutes. 17 | # If set to 0 it will never run. This is fast operation. 18 | tags_count_refresh_interval: 60 19 | 20 | # Registry endpoint and authentication. 21 | registry: 22 | # Registry hostname (without protocol but may include port). 23 | hostname: docker-registry.local 24 | # Allow to access non-https enabled registry. 25 | insecure: false 26 | 27 | # Registry credentials. 28 | # They need to have a full access to the registry. 29 | # If token authentication service is enabled, it will be auto-discovered and those credentials 30 | # will be used to obtain access tokens. 31 | username: user 32 | password: pass 33 | # Set password to '' in order to read it from the file below. Otherwise, it is ignored. 34 | password_file: /run/secrets/registry_password_file 35 | 36 | # Alternatively, you can do auth with Keychain, useful for local development. 37 | # When enabled the above credentials will not be used. 38 | auth_with_keychain: false 39 | 40 | # UI access management. 41 | access_control: 42 | # Whether users can the event log. Otherwise, only admins listed below. 43 | anyone_can_view_events: true 44 | # Whether users can delete tags. Otherwise, only admins listed below. 45 | anyone_can_delete_tags: false 46 | # The list of users to do everything. 47 | # User identifier should be set via X-WEBAUTH-USER header from your proxy 48 | # because registry UI itself does not employ any auth. 49 | admins: [] 50 | 51 | # Event listener configuration. 52 | event_listener: 53 | # The same token should be configured on Docker registry as Authorization Bearer token. 54 | bearer_token: xxx 55 | # Retention of records to keep. 56 | retention_days: 7 57 | 58 | # Event listener storage. 59 | database_driver: sqlite3 60 | database_location: data/registry_events.db 61 | # database_driver: mysql 62 | # database_location: user:password@tcp(localhost:3306)/docker_events 63 | 64 | # You can disable event deletion on some hosts when you are running registry UI on MySQL master-master or 65 | # cluster setup to avoid deadlocks or replication breaks. 66 | deletion_enabled: true 67 | 68 | # Options for tag purging. 69 | purge_tags: 70 | # How many days to keep tags but also keep the minimal count provided no matter how old. 71 | keep_days: 90 72 | keep_count: 10 73 | 74 | # Keep tags matching regexp no matter how old, e.g. '^latest$' 75 | # Empty string disables this feature. 76 | keep_regexp: '' 77 | 78 | # Keep tags listed in the file no matter how old. 79 | # File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]} 80 | # Empty string disables this feature. 81 | keep_from_file: '' 82 | 83 | # Debug mode. 84 | debug: 85 | # Affects only templates. 86 | templates: false 87 | -------------------------------------------------------------------------------- /static/js/bs5-confirmation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap 5 Confirmation Popover 3 | * A lightweight jQuery plugin for confirmation dialogs using Bootstrap 5 Popovers 4 | */ 5 | (function($) { 6 | 'use strict'; 7 | 8 | $.fn.confirmationPopover = function(options) { 9 | var defaults = { 10 | title: 'Are you sure?', 11 | btnOkText: 'Yes', 12 | btnCancelText: 'No', 13 | btnOkClass: 'btn-sm btn-danger', 14 | btnCancelClass: 'btn-sm btn-secondary', 15 | placement: 'left', 16 | onConfirm: function() {}, 17 | onCancel: function() {} 18 | }; 19 | 20 | var settings = $.extend({}, defaults, options); 21 | 22 | return this.each(function() { 23 | var $element = $(this); 24 | var href = $element.attr('href'); 25 | var popoverInstance = null; 26 | 27 | // Prevent default action 28 | $element.on('click', function(e) { 29 | e.preventDefault(); 30 | e.stopPropagation(); 31 | 32 | // If popover already exists, dispose it 33 | if (popoverInstance) { 34 | popoverInstance.dispose(); 35 | popoverInstance = null; 36 | return; 37 | } 38 | 39 | // Create popover content with buttons 40 | var content = '
' + 41 | '' + 44 | '' + 47 | '
'; 48 | 49 | // Initialize Bootstrap 5 popover 50 | popoverInstance = new bootstrap.Popover($element[0], { 51 | content: content, 52 | html: true, 53 | title: settings.title, 54 | trigger: 'manual', 55 | placement: settings.placement, 56 | sanitize: false 57 | }); 58 | 59 | popoverInstance.show(); 60 | 61 | // Handle confirm button 62 | $(document).one('click', '.confirm-ok', function(e) { 63 | e.stopPropagation(); 64 | settings.onConfirm.call($element[0]); 65 | if (popoverInstance) { 66 | popoverInstance.dispose(); 67 | popoverInstance = null; 68 | } 69 | // Navigate to the href 70 | if (href) { 71 | window.location.href = href; 72 | } 73 | }); 74 | 75 | // Handle cancel button 76 | $(document).one('click', '.confirm-cancel', function(e) { 77 | e.stopPropagation(); 78 | settings.onCancel.call($element[0]); 79 | if (popoverInstance) { 80 | popoverInstance.dispose(); 81 | popoverInstance = null; 82 | } 83 | }); 84 | 85 | // Close on outside click after a brief delay 86 | setTimeout(function() { 87 | $(document).one('click', function(e) { 88 | if (popoverInstance && 89 | !$(e.target).closest('.popover').length && 90 | !$(e.target).is($element)) { 91 | popoverInstance.dispose(); 92 | popoverInstance = null; 93 | } 94 | }); 95 | }, 100); 96 | }); 97 | }); 98 | }; 99 | })(jQuery); 100 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/labstack/echo/v4" 10 | "github.com/labstack/echo/v4/middleware" 11 | "github.com/quiq/registry-ui/events" 12 | "github.com/quiq/registry-ui/registry" 13 | "github.com/sirupsen/logrus" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | type apiClient struct { 18 | client *registry.Client 19 | eventListener *events.EventListener 20 | } 21 | 22 | func main() { 23 | var ( 24 | a apiClient 25 | 26 | configFile, loggingLevel string 27 | purgeTags, purgeDryRun bool 28 | purgeIncludeRepos, purgeExcludeRepos string 29 | ) 30 | flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file") 31 | flag.StringVar(&loggingLevel, "log-level", "info", "logging level") 32 | 33 | flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server") 34 | flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything") 35 | flag.StringVar(&purgeIncludeRepos, "purge-include-repos", "", "comma-separated list of repos to purge tags from, otherwise all") 36 | flag.StringVar(&purgeExcludeRepos, "purge-exclude-repos", "", "comma-separated list of repos to skip from purging tags, otherwise none") 37 | flag.Parse() 38 | 39 | // Setup logging 40 | if loggingLevel != "info" { 41 | if level, err := logrus.ParseLevel(loggingLevel); err == nil { 42 | logrus.SetLevel(level) 43 | } 44 | } 45 | 46 | // Read config file 47 | viper.SetConfigName(strings.Split(filepath.Base(configFile), ".")[0]) 48 | viper.AddConfigPath(filepath.Dir(configFile)) 49 | viper.AddConfigPath(".") 50 | err := viper.ReadInConfig() 51 | if err != nil { 52 | panic(fmt.Errorf("fatal error reading config file: %w", err)) 53 | } 54 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 55 | viper.AutomaticEnv() 56 | 57 | // Init registry API client. 58 | a.client = registry.NewClient() 59 | 60 | // Execute CLI task and exit. 61 | if purgeTags { 62 | registry.PurgeOldTags(a.client, purgeDryRun, purgeIncludeRepos, purgeExcludeRepos) 63 | return 64 | } 65 | 66 | go a.client.StartBackgroundJobs() 67 | a.eventListener = events.NewEventListener() 68 | 69 | // Template engine init. 70 | e := echo.New() 71 | // e.Use(middleware.Logger()) 72 | e.Use(loggingMiddleware()) 73 | e.Use(recoverMiddleware()) 74 | 75 | basePath := viper.GetString("uri_base_path") 76 | // Normalize base path. 77 | basePath = strings.Trim(basePath, "/") 78 | if basePath != "" { 79 | basePath = "/" + basePath 80 | } 81 | e.Renderer = setupRenderer(basePath) 82 | 83 | // Web routes. 84 | e.File("/favicon.ico", "static/favicon.ico") 85 | e.Static(basePath+"/static", "static") 86 | 87 | p := e.Group(basePath) 88 | if basePath != "" { 89 | e.GET(basePath, a.viewCatalog) 90 | } 91 | p.GET("/", a.viewCatalog) 92 | p.GET("/:repoPath", a.viewCatalog) 93 | p.GET("/event-log", a.viewEventLog) 94 | p.GET("/delete-tag", a.deleteTag) 95 | 96 | // Protected event listener. 97 | pp := e.Group("/event-receiver") 98 | pp.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ 99 | Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) { 100 | return token == viper.GetString("event_listener.bearer_token"), nil 101 | }), 102 | })) 103 | pp.POST("", a.receiveEvents) 104 | 105 | e.Logger.Fatal(e.Start(viper.GetString("listen_addr"))) 106 | } 107 | -------------------------------------------------------------------------------- /web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/CloudyKit/jet/v6" 9 | "github.com/labstack/echo/v4" 10 | "github.com/quiq/registry-ui/registry" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | const usernameHTTPHeader = "X-WEBAUTH-USER" 15 | 16 | func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap { 17 | user := c.Request().Header.Get(usernameHTTPHeader) 18 | 19 | data := jet.VarMap{} 20 | data.Set("user", user) 21 | admins := viper.GetStringSlice("access_control.admins") 22 | data.Set("eventsAllowed", viper.GetBool("access_control.anyone_can_view_events") || registry.ItemInSlice(user, admins)) 23 | data.Set("deleteAllowed", viper.GetBool("access_control.anyone_can_delete_tags") || registry.ItemInSlice(user, admins)) 24 | return data 25 | } 26 | 27 | func (a *apiClient) viewCatalog(c echo.Context) error { 28 | repoPath := strings.Trim(c.Param("repoPath"), "/") 29 | // fmt.Println("repoPath:", repoPath) 30 | 31 | data := a.setUserPermissions(c) 32 | data.Set("repoPath", repoPath) 33 | 34 | showTags := false 35 | showImageInfo := false 36 | allRepoPaths := a.client.GetRepos() 37 | repos := []string{} 38 | if repoPath == "" { 39 | // Show all repos 40 | for _, r := range allRepoPaths { 41 | repos = append(repos, strings.Split(r, "/")[0]) 42 | } 43 | } else if strings.Contains(repoPath, ":") { 44 | // Show image info 45 | showImageInfo = true 46 | } else { 47 | for _, r := range allRepoPaths { 48 | if r == repoPath { 49 | // Show tags 50 | showTags = true 51 | } 52 | if strings.HasPrefix(r, repoPath+"/") { 53 | // Show sub-repos 54 | r = strings.TrimPrefix(r, repoPath+"/") 55 | repos = append(repos, strings.Split(r, "/")[0]) 56 | } 57 | } 58 | } 59 | 60 | if showImageInfo { 61 | // Show image info 62 | imageInfo, err := a.client.GetImageInfo(repoPath) 63 | if err != nil { 64 | basePath := viper.GetString("uri_base_path") 65 | return c.Redirect(http.StatusSeeOther, basePath) 66 | } 67 | data.Set("ii", imageInfo) 68 | return c.Render(http.StatusOK, "image_info.html", data) 69 | } else { 70 | // Show repos, tags or both. 71 | repos = registry.UniqueSortedSlice(repos) 72 | tags := []string{} 73 | if showTags { 74 | tags = a.client.ListTags(repoPath) 75 | 76 | } 77 | data.Set("repos", repos) 78 | data.Set("isCatalogReady", a.client.IsCatalogReady()) 79 | data.Set("tagCounts", a.client.SubRepoTagCounts(repoPath, repos)) 80 | data.Set("tags", tags) 81 | if repoPath != "" && (len(repos) > 0 || len(tags) > 0) { 82 | // Do not show events in the root of catalog. 83 | data.Set("events", a.eventListener.GetEvents(repoPath)) 84 | } 85 | return c.Render(http.StatusOK, "catalog.html", data) 86 | } 87 | } 88 | 89 | func (a *apiClient) deleteTag(c echo.Context) error { 90 | repoPath := c.QueryParam("repoPath") 91 | tag := c.QueryParam("tag") 92 | 93 | data := a.setUserPermissions(c) 94 | if data["deleteAllowed"].Bool() { 95 | a.client.DeleteTag(repoPath, tag) 96 | } 97 | basePath := viper.GetString("uri_base_path") 98 | return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s%s", basePath, repoPath)) 99 | } 100 | 101 | // viewLog view events from sqlite. 102 | func (a *apiClient) viewEventLog(c echo.Context) error { 103 | data := a.setUserPermissions(c) 104 | data.Set("events", a.eventListener.GetEvents("")) 105 | return c.Render(http.StatusOK, "event_log.html", data) 106 | } 107 | 108 | // receiveEvents receive events. 109 | func (a *apiClient) receiveEvents(c echo.Context) error { 110 | a.eventListener.ProcessEvents(c.Request()) 111 | return c.String(http.StatusOK, "OK") 112 | } 113 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Registry UI 8 | 9 | 10 | 11 | 12 | 13 | {{yield head()}} 14 | 15 | 16 | 17 | 39 | 40 | 41 |
42 | {{yield body()}} 43 |
44 | 45 | 46 | 57 | 58 | 59 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /templates/image_info.html: -------------------------------------------------------------------------------- 1 | {{extends "base.html"}} 2 | {{import "breadcrumb.html"}} 3 | {{import "json_to_table.html"}} 4 | 5 | {{block head()}} 6 | 16 | {{end}} 17 | 18 | {{block body()}} 19 | 25 | 26 |
27 |

28 | {{if ii.IsImage}}Image Details{{end}} 29 | {{if ii.IsImageIndex}}Image Index Details{{end}} 30 |

31 |
32 | 33 |
34 |
35 |
Summary
36 |
37 |
38 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | 55 | 56 | 57 | {{if ii.IsImageIndex}} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {{end}} 67 | {{if ii.IsImage}} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {{end}} 85 | 86 |
Image Reference{{ registryHost }}/{{ repoPath }}
Digest 48 | 49 | {{ ii.ImageRefDigest }} 50 | 51 |
Media Type{{ ii.MediaType }}
Sub-Images{{ len(ii.Manifest["manifests"]) }}
Platforms{{ ii.Platforms }}
Image ID{{ ii.ConfigImageID }}
Image Size{{ ii.ImageSize|pretty_size }}
Platform{{ ii.Platforms }}
Created On{{ ii.Created|pretty_time }}
87 |
88 |
89 |
90 | 91 |
92 |
93 |
{{if ii.IsImage}}Manifest{{else}}Index Manifest{{end}}
94 |
95 |
96 | {{ yield json_to_table() ii.Manifest }} 97 |
98 |
99 | 100 | {{if ii.IsImage}} 101 |
102 |
103 |
Config File
104 |
105 |
106 | {{ yield json_to_table() ii.ConfigFile }} 107 |
108 |
109 | {{end}} 110 | 111 | {{end}} 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Registry UI 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/quiq/registry-ui)](https://goreportcard.com/report/github.com/quiq/registry-ui) 4 | 5 | ### Overview 6 | 7 | * Web UI for Docker Registry or similar alternatives 8 | * Fast, simple and small package 9 | * Browse catalog of repositories and tags 10 | * Show an arbitrary level of repository tree 11 | * Support Docker and OCI image formats 12 | * Support image and image index manifests (multi-platform images) 13 | * Display full information about image index and links to the underlying sub-images 14 | * Display full information about image, its layers and config file (command history) 15 | * Event listener for notification events coming from Registry 16 | * Store events in Sqlite or MySQL database 17 | * CLI option to maintain the tag retention: purge tags older than X days keeping at least Y tags etc. 18 | * Automatically discover an authentication method: basic auth, token service, keychain etc. 19 | * The list of repositories and tag counts are cached and refreshed in background 20 | 21 | No TLS or authentication is implemented on the UI instance itself. 22 | Assuming you will put it behind nginx, oauth2_proxy or similar. 23 | 24 | Docker images [quiq/registry-ui](https://hub.docker.com/r/quiq/registry-ui/tags/) 25 | 26 | ### Quick start 27 | 28 | Run a Docker registry in your host (if you don't already had one): 29 | 30 | docker run -d --network host \ 31 | --name registry registry:2 32 | 33 | Run registry UI directly connected to it: 34 | 35 | docker run -d --network host \ 36 | -e REGISTRY_HOSTNAME=127.0.0.1:5000 \ 37 | -e REGISTRY_INSECURE=true \ 38 | --name registry-ui quiq/registry-ui 39 | 40 | Push any Docker image to 127.0.0.1:5000/owner/name and go into http://127.0.0.1:8000 with 41 | your web browser. 42 | 43 | ### Configuration 44 | 45 | The configuration is stored in `config.yml` and the options are self-descriptive. 46 | 47 | You can override any config option via environment variables using SECTION_KEY_NAME syntax, 48 | e.g. `LISTEN_ADDR`, `PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL`, `REGISTRY_HOSTNAME` etc. 49 | 50 | Passing the full config file through: 51 | 52 | docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro quiq/registry-ui 53 | 54 | To run with your own root CA certificate, add to the command: 55 | 56 | -v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro 57 | 58 | To preserve sqlite db file with event data, add to the command: 59 | 60 | -v /local/data:/opt/data 61 | 62 | Ensure /local/data is owner by nobody (alpine user id is 65534). 63 | 64 | You can also run the container with `--read-only` option, however when using using event listener functionality 65 | you need to ensure the sqlite db can be written, i.e. mount a folder as listed above (rw mode). 66 | 67 | To run with a custom TZ: 68 | 69 | -e TZ=America/Los_Angeles 70 | 71 | ## Configure event listener on Docker Registry 72 | 73 | To receive events you need to configure Registry as follow: 74 | 75 | notifications: 76 | endpoints: 77 | - name: registry-ui 78 | url: http://registry-ui.local:8000/event-receiver 79 | headers: 80 | Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890] 81 | timeout: 1s 82 | threshold: 5 83 | backoff: 10s 84 | ignoredmediatypes: 85 | - application/octet-stream 86 | 87 | Adjust url and token as appropriate. 88 | If you are running UI with non-default base path, e.g. /ui, the URL path for above will be `/ui/event-receiver` etc. 89 | 90 | ## Using MySQL instead of sqlite3 for event listener 91 | 92 | To use MySQL as a storage you need to change `event_database_driver` and `event_database_location` 93 | settings in the config file. It is expected you create a database mentioned in the location DSN. 94 | Minimal privileges are `SELECT`, `INSERT`, `DELETE`. 95 | You can create a table manually if you don't want to grant `CREATE` permission: 96 | 97 | CREATE TABLE events ( 98 | id INTEGER PRIMARY KEY AUTO_INCREMENT, 99 | action CHAR(4) NULL, 100 | repository VARCHAR(100) NULL, 101 | tag VARCHAR(100) NULL, 102 | ip VARCHAR(45) NULL, 103 | user VARCHAR(50) NULL, 104 | created DATETIME NULL 105 | ); 106 | 107 | ### Schedule a cron task for purging tags 108 | 109 | To delete tags you need to enable the corresponding option in Docker Registry config. For example: 110 | 111 | storage: 112 | delete: 113 | enabled: true 114 | 115 | The following example shows how to run a cron task to purge tags older than X days but also keep 116 | at least Y tags no matter how old. Assuming container has been already running. 117 | 118 | 10 3 * * * root docker exec -t registry-ui /opt/registry-ui -purge-tags 119 | 120 | You can try to run in dry-run mode first to see what is going to be purged: 121 | 122 | docker exec -t registry-ui /opt/registry-ui -purge-tags -dry-run 123 | 124 | ### Screenshots 125 | 126 | Repository list: 127 | 128 | ![image](screenshots/1.png) 129 | 130 | Tag list: 131 | 132 | ![image](screenshots/2.png) 133 | 134 | Image Index info: 135 | 136 | ![image](screenshots/3.png) 137 | 138 | Image info: 139 | 140 | ![image](screenshots/4.png) 141 | -------------------------------------------------------------------------------- /templates/event_log.html: -------------------------------------------------------------------------------- 1 | {{extends "base.html"}} 2 | {{import "breadcrumb.html"}} 3 | 4 | {{block head()}} 5 | 53 | {{end}} 54 | 55 | {{block body()}} 56 | 62 | 63 | {{if eventsAllowed}} 64 |
65 |
66 | 67 |
68 |
69 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | {{range _, e := events}} 92 | 93 | 94 | {{if hasPrefix(e.Tag,"sha256:") }} 95 | 100 | {{else}} 101 | 106 | {{end}} 107 | 108 | 109 | 110 | 111 | {{end}} 112 | 113 |
ActionImageIP AddressUserTime
{{ e.Action }} 96 | 97 | {{ e.Repository }}@{{ e.Tag[:19] }}... 98 | 99 | 102 | 103 | {{ e.Repository }}:{{ e.Tag }} 104 | 105 | {{ e.IP }}{{ e.User }}{{ e.Created|pretty_time }}
114 |
115 |
116 |
117 | {{else}} 118 | 123 | {{end}} 124 | {{end}} 125 | -------------------------------------------------------------------------------- /registry/tasks.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "os" 7 | "regexp" 8 | "sort" 9 | "strings" 10 | "time" 11 | 12 | "github.com/spf13/viper" 13 | "github.com/tidwall/gjson" 14 | ) 15 | 16 | type TagData struct { 17 | name string 18 | created time.Time 19 | } 20 | 21 | func (t TagData) String() string { 22 | return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05")) 23 | } 24 | 25 | type timeSlice []TagData 26 | 27 | func (p timeSlice) Len() int { 28 | return len(p) 29 | } 30 | 31 | func (p timeSlice) Less(i, j int) bool { 32 | // reverse sort tags on name if equal dates (OCI image case) 33 | // see https://github.com/Quiq/registry-ui/pull/62 34 | if p[i].created.Equal(p[j].created) { 35 | return p[i].name > p[j].name 36 | } 37 | return p[i].created.After(p[j].created) 38 | } 39 | 40 | func (p timeSlice) Swap(i, j int) { 41 | p[i], p[j] = p[j], p[i] 42 | } 43 | 44 | // PurgeOldTags purge old tags. 45 | func PurgeOldTags(client *Client, purgeDryRun bool, purgeIncludeRepos, purgeExcludeRepos string) { 46 | logger := SetupLogging("registry.tasks.PurgeOldTags") 47 | keepDays := viper.GetInt("purge_tags.keep_days") 48 | keepCount := viper.GetInt("purge_tags.keep_count") 49 | keepRegexp := viper.GetString("purge_tags.keep_regexp") 50 | keepFromFile := viper.GetString("purge_tags.keep_from_file") 51 | 52 | dryRunText := "" 53 | if purgeDryRun { 54 | logger.Warn("Dry-run mode enabled.") 55 | dryRunText = "skipped" 56 | } 57 | 58 | var dataFromFile gjson.Result 59 | if keepFromFile != "" { 60 | if _, err := os.Stat(keepFromFile); os.IsNotExist(err) { 61 | logger.Warnf("Cannot open %s: %s", keepFromFile, err) 62 | logger.Error("Not purging anything!") 63 | return 64 | } 65 | data, err := os.ReadFile(keepFromFile) 66 | if err != nil { 67 | logger.Warnf("Cannot read %s: %s", keepFromFile, err) 68 | logger.Error("Not purging anything!") 69 | return 70 | } 71 | dataFromFile = gjson.ParseBytes(data) 72 | } 73 | 74 | catalog := []string{} 75 | if purgeIncludeRepos != "" { 76 | logger.Infof("Including repositories: %s", purgeIncludeRepos) 77 | catalog = append(catalog, strings.Split(purgeIncludeRepos, ",")...) 78 | } else { 79 | client.RefreshCatalog() 80 | catalog = client.GetRepos() 81 | } 82 | if purgeExcludeRepos != "" { 83 | logger.Infof("Excluding repositories: %s", purgeExcludeRepos) 84 | tmpCatalog := []string{} 85 | for _, repo := range catalog { 86 | if !ItemInSlice(repo, strings.Split(purgeExcludeRepos, ",")) { 87 | tmpCatalog = append(tmpCatalog, repo) 88 | } 89 | } 90 | catalog = tmpCatalog 91 | } 92 | logger.Infof("Working on repositories: %s", catalog) 93 | 94 | now := time.Now().UTC() 95 | repos := map[string]timeSlice{} 96 | count := 0 97 | for _, repo := range catalog { 98 | tags := client.ListTags(repo) 99 | if len(tags) == 0 { 100 | continue 101 | } 102 | logger.Infof("[%s] scanning %d tags...", repo, len(tags)) 103 | for _, tag := range tags { 104 | imageRef := repo + ":" + tag 105 | created := client.GetImageCreated(imageRef) 106 | if created.IsZero() { 107 | // Image manifest with zero creation time, e.g. cosign w/o --record-creation-timestamp 108 | logger.Debugf("[%s] tag with zero creation time: %s", repo, tag) 109 | continue 110 | } 111 | repos[repo] = append(repos[repo], TagData{name: tag, created: created}) 112 | } 113 | } 114 | 115 | logger.Infof("Scanned %d repositories.", len(catalog)) 116 | logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", keepDays, keepCount) 117 | if keepRegexp != "" { 118 | logger.Infof("Keeping tags matching regexp: %s", keepRegexp) 119 | } 120 | if keepFromFile != "" { 121 | logger.Infof("Keeping tags from file: %+v", dataFromFile) 122 | } 123 | purgeTags := map[string][]string{} 124 | keepTags := map[string][]string{} 125 | count = 0 126 | for _, repo := range SortedMapKeys(repos) { 127 | // Sort tags by "created" from newest to oldest. 128 | sort.Sort(repos[repo]) 129 | 130 | // Prep the list of tags to preserve if defined in the file 131 | tagsFromFile := []string{} 132 | for _, i := range dataFromFile.Get(repo).Array() { 133 | tagsFromFile = append(tagsFromFile, i.String()) 134 | } 135 | 136 | // Filter out tags 137 | for _, tag := range repos[repo] { 138 | daysOld := int(now.Sub(tag.created).Hours() / 24) 139 | matchByRegexp := false 140 | if keepRegexp != "" { 141 | matchByRegexp, _ = regexp.MatchString(keepRegexp, tag.name) 142 | } 143 | 144 | if daysOld > keepDays && !matchByRegexp && !ItemInSlice(tag.name, tagsFromFile) { 145 | purgeTags[repo] = append(purgeTags[repo], tag.name) 146 | } else { 147 | keepTags[repo] = append(keepTags[repo], tag.name) 148 | } 149 | } 150 | 151 | // Keep minimal count of tags no matter how old they are. 152 | if len(keepTags[repo]) < keepCount { 153 | // At least "threshold"-"keep" but not more than available for "purge". 154 | takeFromPurge := int(math.Min(float64(keepCount-len(keepTags[repo])), float64(len(purgeTags[repo])))) 155 | keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...) 156 | purgeTags[repo] = purgeTags[repo][takeFromPurge:] 157 | } 158 | 159 | count = count + len(purgeTags[repo]) 160 | logger.Infof("[%s] All %d: %v", repo, len(repos[repo]), repos[repo]) 161 | logger.Infof("[%s] Keep %d: %v", repo, len(keepTags[repo]), keepTags[repo]) 162 | logger.Infof("[%s] Purge %d: %v", repo, len(purgeTags[repo]), purgeTags[repo]) 163 | } 164 | 165 | logger.Infof("There are %d tags to purge.", count) 166 | if count > 0 { 167 | logger.Info("Purging old tags...") 168 | } 169 | 170 | for _, repo := range SortedMapKeys(purgeTags) { 171 | if len(purgeTags[repo]) == 0 { 172 | continue 173 | } 174 | logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText) 175 | if purgeDryRun { 176 | continue 177 | } 178 | for _, tag := range purgeTags[repo] { 179 | client.DeleteTag(repo, tag) 180 | } 181 | } 182 | logger.Info("Done.") 183 | } 184 | -------------------------------------------------------------------------------- /static/js/sorting_natural.js: -------------------------------------------------------------------------------- 1 | /*! © SpryMedia Ltd, Jim Palmer, Michael Buehler, Mike Grier, Clint Priest, Kyle Adams, guillermo - datatables.net/license */ 2 | 3 | (function( factory ){ 4 | if ( typeof define === 'function' && define.amd ) { 5 | // AMD 6 | define( ['jquery', 'datatables.net'], function ( $ ) { 7 | return factory( $, window, document ); 8 | } ); 9 | } 10 | else if ( typeof exports === 'object' ) { 11 | // CommonJS 12 | var jq = require('jquery'); 13 | var cjsRequires = function (root, $) { 14 | if ( ! $.fn.dataTable ) { 15 | require('datatables.net')(root, $); 16 | } 17 | }; 18 | 19 | if (typeof window === 'undefined') { 20 | module.exports = function (root, $) { 21 | if ( ! root ) { 22 | // CommonJS environments without a window global must pass a 23 | // root. This will give an error otherwise 24 | root = window; 25 | } 26 | 27 | if ( ! $ ) { 28 | $ = jq( root ); 29 | } 30 | 31 | cjsRequires( root, $ ); 32 | return factory( $, root, root.document ); 33 | }; 34 | } 35 | else { 36 | cjsRequires( window, jq ); 37 | module.exports = factory( jq, window, window.document ); 38 | } 39 | } 40 | else { 41 | // Browser 42 | factory( jQuery, window, document ); 43 | } 44 | }(function( $, window, document, undefined ) { 45 | 'use strict'; 46 | var DataTable = $.fn.dataTable; 47 | 48 | 49 | /** 50 | * Data can often be a complicated mix of numbers and letters (file names 51 | * are a common example) and sorting them in a natural manner is quite a 52 | * difficult problem. 53 | * 54 | * Fortunately a deal of work has already been done in this area by other 55 | * authors - the following plug-in uses the [naturalSort() function by Jim 56 | * Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support) to provide natural sorting in DataTables. 57 | * 58 | * @name Natural sorting 59 | * @summary Sort data with a mix of numbers and letters _naturally_. 60 | * @author [Jim Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support) 61 | * @author [Michael Buehler] (https://github.com/AnimusMachina) 62 | * 63 | * @example 64 | * $('#example').dataTable( { 65 | * columnDefs: [ 66 | * { type: 'natural', targets: 0 } 67 | * ] 68 | * } ); 69 | * 70 | * Html can be stripped from sorting by using 'natural-nohtml' such as 71 | * 72 | * $('#example').dataTable( { 73 | * columnDefs: [ 74 | * { type: 'natural-nohtml', targets: 0 } 75 | * ] 76 | * } ); 77 | * 78 | */ 79 | /* 80 | * Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license 81 | * Author: Jim Palmer (based on chunking idea from Dave Koelle) 82 | * Contributors: Mike Grier (mgrier.com), Clint Priest, Kyle Adams, guillermo 83 | * See: http://js-naturalsort.googlecode.com/svn/trunk/naturalSort.js 84 | */ 85 | function naturalSort(a, b, html) { 86 | var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?%?$|^0x[0-9a-f]+$|[0-9]+)/gi, sre = /(^[ ]*|[ ]*$)/g, dre = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/, hre = /^0x[0-9a-f]+$/i, ore = /^0/, htmre = /(<([^>]+)>)/gi, 87 | // convert all to strings and trim() 88 | x = a.toString().replace(sre, '') || '', y = b.toString().replace(sre, '') || ''; 89 | // remove html from strings if desired 90 | if (!html) { 91 | x = x.replace(htmre, ''); 92 | y = y.replace(htmre, ''); 93 | } 94 | // chunk/tokenize 95 | var xN = x 96 | .replace(re, '\0$1\0') 97 | .replace(/\0$/, '') 98 | .replace(/^\0/, '') 99 | .split('\0'), yN = y 100 | .replace(re, '\0$1\0') 101 | .replace(/\0$/, '') 102 | .replace(/^\0/, '') 103 | .split('\0'), 104 | // numeric, hex or date detection 105 | xD = parseInt(x.match(hre), 10) || 106 | (xN.length !== 1 && x.match(dre) && Date.parse(x)), yD = parseInt(y.match(hre), 10) || 107 | (xD && y.match(dre) && Date.parse(y)) || 108 | null; 109 | // first try and sort Hex codes or Dates 110 | if (yD) { 111 | if (xD < yD) { 112 | return -1; 113 | } 114 | else if (xD > yD) { 115 | return 1; 116 | } 117 | } 118 | // natural sorting through split numeric strings and default strings 119 | for (var cLoc = 0, numS = Math.max(xN.length, yN.length); cLoc < numS; cLoc++) { 120 | // find floats not starting with '0', string or 0 if not defined (Clint Priest) 121 | var oFxNcL = (!(xN[cLoc] || '').match(ore) && parseFloat(xN[cLoc])) || xN[cLoc] || 0; 122 | var oFyNcL = (!(yN[cLoc] || '').match(ore) && parseFloat(yN[cLoc])) || yN[cLoc] || 0; 123 | // handle numeric vs string comparison - number < string - (Kyle Adams) 124 | if (isNaN(oFxNcL) !== isNaN(oFyNcL)) { 125 | return isNaN(oFxNcL) ? 1 : -1; 126 | } 127 | // rely on string comparison if different types - i.e. '02' < 2 != '02' < '2' 128 | else if (typeof oFxNcL !== typeof oFyNcL) { 129 | oFxNcL += ''; 130 | oFyNcL += ''; 131 | } 132 | if (oFxNcL < oFyNcL) { 133 | return -1; 134 | } 135 | if (oFxNcL > oFyNcL) { 136 | return 1; 137 | } 138 | } 139 | return 0; 140 | } 141 | DataTable.ext.type.order['natural-asc'] = function (a, b) { 142 | return naturalSort(a, b, true); 143 | }; 144 | DataTable.ext.type.order['natural-desc'] = function (a, b) { 145 | return naturalSort(a, b, true) * -1; 146 | }; 147 | DataTable.ext.type.order['natural-nohtml-asc'] = function (a, b) { 148 | return naturalSort(a, b, false); 149 | }; 150 | DataTable.ext.type.order['natural-nohtml-desc'] = function (a, b) { 151 | return naturalSort(a, b, false) * -1; 152 | }; 153 | DataTable.ext.type.order['natural-ci-asc'] = function (a, b) { 154 | a = a.toString().toLowerCase(); 155 | b = b.toString().toLowerCase(); 156 | return naturalSort(a, b, true); 157 | }; 158 | DataTable.ext.type.order['natural-ci-desc'] = function (a, b) { 159 | a = a.toString().toLowerCase(); 160 | b = b.toString().toLowerCase(); 161 | return naturalSort(a, b, true) * -1; 162 | }; 163 | 164 | 165 | return DataTable; 166 | })); 167 | -------------------------------------------------------------------------------- /events/event_listener.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/quiq/registry-ui/registry" 13 | "github.com/sirupsen/logrus" 14 | "github.com/spf13/viper" 15 | 16 | // 🐒 patching of "database/sql". 17 | _ "github.com/go-sql-driver/mysql" 18 | _ "github.com/mattn/go-sqlite3" 19 | "github.com/tidwall/gjson" 20 | ) 21 | 22 | const ( 23 | userAgent = "registry-ui" 24 | schemaSQLite = ` 25 | CREATE TABLE events ( 26 | id INTEGER PRIMARY KEY AUTOINCREMENT, 27 | action CHAR(5) NULL, 28 | repository VARCHAR(100) NULL, 29 | tag VARCHAR(100) NULL, 30 | ip VARCHAR(45) NULL, 31 | user VARCHAR(50) NULL, 32 | created DATETIME NULL 33 | ); 34 | ` 35 | ) 36 | 37 | // EventListener event listener 38 | type EventListener struct { 39 | databaseDriver string 40 | databaseLocation string 41 | retention int 42 | eventDeletion bool 43 | logger *logrus.Entry 44 | } 45 | 46 | type eventData struct { 47 | Events []interface{} `json:"events"` 48 | } 49 | 50 | // EventRow event row from sqlite 51 | type EventRow struct { 52 | ID int 53 | Action string 54 | Repository string 55 | Tag string 56 | IP string 57 | User string 58 | Created string 59 | } 60 | 61 | // NewEventListener initialize EventListener. 62 | func NewEventListener() *EventListener { 63 | databaseDriver := viper.GetString("event_listener.database_driver") 64 | databaseLocation := viper.GetString("event_listener.database_location") 65 | retention := viper.GetInt("event_listener.retention_days") 66 | eventDeletion := viper.GetBool("event_listener.deletion_enabled") 67 | 68 | if databaseDriver != "sqlite3" && databaseDriver != "mysql" { 69 | panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql")) 70 | } 71 | 72 | return &EventListener{ 73 | databaseDriver: databaseDriver, 74 | databaseLocation: databaseLocation, 75 | retention: retention, 76 | eventDeletion: eventDeletion, 77 | logger: registry.SetupLogging("events.event_listener"), 78 | } 79 | } 80 | 81 | // ProcessEvents parse and store registry events 82 | func (e *EventListener) ProcessEvents(request *http.Request) { 83 | decoder := json.NewDecoder(request.Body) 84 | var t eventData 85 | if err := decoder.Decode(&t); err != nil { 86 | e.logger.Errorf("Problem decoding event from request: %+v", request) 87 | return 88 | } 89 | e.logger.Debugf("Received event: %+v", t) 90 | j, _ := json.Marshal(t) 91 | 92 | db, err := e.getDatabaseHandler() 93 | if err != nil { 94 | e.logger.Error(err) 95 | return 96 | } 97 | defer db.Close() 98 | 99 | now := "DateTime('now')" 100 | if e.databaseDriver == "mysql" { 101 | now = "NOW()" 102 | } 103 | stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")") 104 | for _, i := range gjson.GetBytes(j, "events").Array() { 105 | // Ignore calls by registry-ui itself. 106 | if strings.HasPrefix(i.Get("request.useragent").String(), userAgent) { 107 | continue 108 | } 109 | action := i.Get("action").String() 110 | repository := i.Get("target.repository").String() 111 | tag := i.Get("target.tag").String() 112 | // Tag is empty in case of signed pull. 113 | if tag == "" { 114 | tag = i.Get("target.digest").String() 115 | } 116 | ip := i.Get("request.addr").String() 117 | if x, _, _ := net.SplitHostPort(ip); x != "" { 118 | ip = x 119 | } 120 | user := i.Get("actor.name").String() 121 | e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user) 122 | 123 | res, err := stmt.Exec(action, repository, tag, ip, user) 124 | if err != nil { 125 | e.logger.Error("Error inserting a row: ", err) 126 | return 127 | } 128 | id, _ := res.LastInsertId() 129 | e.logger.Debug("New event added with id ", id) 130 | } 131 | 132 | // Purge old records. 133 | if !e.eventDeletion { 134 | return 135 | } 136 | var res sql.Result 137 | if e.databaseDriver == "mysql" { 138 | stmt, _ := db.Prepare("DELETE FROM events WHERE created < DATE_SUB(NOW(), INTERVAL ? DAY)") 139 | res, _ = stmt.Exec(e.retention) 140 | } else { 141 | stmt, _ := db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)") 142 | res, _ = stmt.Exec(fmt.Sprintf("-%d day", e.retention)) 143 | } 144 | count, _ := res.RowsAffected() 145 | e.logger.Debug("Rows deleted: ", count) 146 | } 147 | 148 | // GetEvents retrieve events from sqlite db 149 | func (e *EventListener) GetEvents(repository string) []EventRow { 150 | var events []EventRow 151 | 152 | db, err := e.getDatabaseHandler() 153 | if err != nil { 154 | e.logger.Error(err) 155 | return events 156 | } 157 | defer db.Close() 158 | 159 | query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000" 160 | if repository != "" { 161 | query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' OR repository LIKE '%s/%%' ORDER BY id DESC LIMIT 5", 162 | repository, repository) 163 | } 164 | rows, err := db.Query(query) 165 | if err != nil { 166 | e.logger.Error("Error selecting from table: ", err) 167 | return events 168 | } 169 | defer rows.Close() 170 | 171 | for rows.Next() { 172 | var row EventRow 173 | rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created) 174 | events = append(events, row) 175 | } 176 | return events 177 | } 178 | 179 | func (e *EventListener) getDatabaseHandler() (*sql.DB, error) { 180 | firstRun := false 181 | schema := schemaSQLite 182 | if e.databaseDriver == "sqlite3" { 183 | if _, err := os.Stat(e.databaseLocation); os.IsNotExist(err) { 184 | firstRun = true 185 | } 186 | } 187 | 188 | // Open db connection. 189 | db, err := sql.Open(e.databaseDriver, e.databaseLocation) 190 | if err != nil { 191 | return nil, fmt.Errorf("Error opening %s db: %s", e.databaseDriver, err) 192 | } 193 | 194 | if e.databaseDriver == "mysql" { 195 | schema = strings.Replace(schema, "AUTOINCREMENT", "AUTO_INCREMENT", 1) 196 | rows, err := db.Query("SELECT * FROM events LIMIT 1") 197 | if err != nil { 198 | firstRun = true 199 | } 200 | if rows != nil { 201 | rows.Close() 202 | } 203 | } 204 | 205 | // Create table on first run. 206 | if firstRun { 207 | if _, err = db.Exec(schema); err != nil { 208 | return nil, fmt.Errorf("Error creating a table: %s", err) 209 | } 210 | } 211 | return db, nil 212 | } 213 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ## 0.11.0 (2025-11-27) 4 | 5 | * Major UI rewrite. 6 | * Upgrade go version to 1.25.4 and all dependencies, alpine to 3.22. 7 | 8 | ## 0.10.4 (2025-06-11) 9 | 10 | * Include the default config file into the Docker image. 11 | * Upgrade go version to 1.24.4 and all dependencies, alpine to 3.21. 12 | 13 | ## 0.10.3 (2024-08-15) 14 | 15 | * Add `registry.insecure` option to the config (alternatively REGISTRY_INSECURE env var) to support non-https registries. 16 | Thanks to @KanagawaNezumi 17 | * Fix concurrent map iteration and write in rare cases. 18 | * Upgrade go version to 1.22.6 and all dependencies, alpine to 3.20. 19 | * IPv6 addresses were not displayed correctly. 20 | In case you need to store registry events with IPv6 addresses in MySQL, you need to run `ALTER TABLE events MODIFY column ip varchar(45) NULL`. 21 | For sqlite, you can start a new db file or migrate events manually as it doesn't support ALTER. 22 | 23 | ## 0.10.2 (2024-05-31) 24 | 25 | * Fix repo tag count when a repo name is a prefix for another repo name(s) 26 | * Allow to override any config option via environment variables using SECTION_KEY_NAME syntax, e.g. 27 | LISTEN_ADDR, PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL, REGISTRY_HOSTNAME etc. 28 | 29 | ## 0.10.1 (2024-04-19) 30 | 31 | * Rename cmd flag `-purge-from-repos` to `-purge-include-repos` to purge tags only for the specified repositories. 32 | * Add a new cmd flag `-purge-exclude-repos` to skip the specified repositories from the tag purging. 33 | * Make image column clickable in Event Log. 34 | 35 | ### 0.10.0 (2024-04-16) 36 | 37 | **JUST BREAKING CHANGES** 38 | 39 | * We have made a full rewrite. Over 6 years many things have been changed. 40 | * Renamed github/dockerhub repo from docker-registry-ui -> registry-ui 41 | * Switched from doing raw http calls to `github.com/google/go-containerregistry` 42 | * URLs and links are now matching the image references, no more "library" or other weird URL parts. 43 | * No namespace or only 2-level deep concept 44 | * An arbitrary repository levels are supported 45 | * It is even possible to list both sub-repos and tags within the same repo path if you have those 46 | * Added support for OCI images, so now both Docker + OCI are supported 47 | * Proper support of Image Index (Index Manifest) 48 | * Display full information available about Image or Image Index 49 | * Sub-images (multi-platform ones) are linked under Image Index 50 | * Changed format of config.yml but the same concept is preserved 51 | * Event listener path has been changed from /api/events to /event-receiver and you may need to update your registry config 52 | * Removed built-in cron scheduler for purging tags, please use the normal cron :) 53 | * Now you can tune the refresh of catalog and separately refresh of tag counting, disable them etc. 54 | * Everything has been made better! :) 55 | 56 | ### 0.9.7 (2024-02-21) 57 | 58 | * Fix timezone support: now when running a container with `TZ` env var, e.g. "-e TZ=America/Los_Angeles", it will be reflected everywhere on UI. 59 | * Amend tag info page: add long line break, better format a caption column. 60 | * Upgrade Go version to 1.22, alpine to 3.19 and other dependencies. 61 | 62 | ### 0.9.6 (2023-03-30) 63 | 64 | * Upgrade Go version to 1.20.2, alpine to 3.17 and other dependencies. 65 | 66 | ### 0.9.5 (2022-09-05) 67 | 68 | * Upgrade Go version to 1.19.0, alpine to 3.16 and other dependencies. 69 | * Add an option `anyone_can_view_events` to restrict access to the event log. Set it to `true` to make event log accessible to anyone (to restore the previous behaviour), otherwise the default `false` will hide it and only admins can view it (thanks to @ribbybibby). 70 | * Add an option `purge_tags_keep_regexp` to preserve tags based on regexp (thanks to @dmaes). 71 | * Add an option `purge_tags_keep_from_file` to preserve tags for repos listed in the file provided. 72 | * When purging tags sort them by name reversibly when no date available, e.g. for OCI image format (thanks to @dmaes). 73 | * Fix a bug when there was a bit more tags preserved than defined by `purge_tags_keep_count`. 74 | 75 | Also see `config.yml` in this repo for the description of new options. 76 | 77 | ### 0.9.4 (2022-04-06) 78 | 79 | * Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies. 80 | * Build docker image with ARM support. 81 | 82 | ### 0.9.3 (2021-04-26) 83 | 84 | * Upgrade Go version to 1.16.3, alpine to 3.13 and other dependencies. 85 | * Support deletion of manifest lists. 86 | 87 | ### 0.9.2 (2020-07-10) 88 | 89 | * Upgrade Go version to 1.14.4, alpine to 3.12 and other dependencies. 90 | * Enable default logging for purge tags task. 91 | 92 | ### 0.9.1 (2020-02-20) 93 | 94 | * Minor amendments for the tag info page to account the cache type of sub-image. 95 | 96 | ### 0.9.0 (2020-02-19) 97 | 98 | * Upgrade Go version to 1.13.7, alpine to 3.11 and other dependencies. 99 | * Support Manifest List v2. This enables the proper display of multi-arch images, 100 | such as those generated by Docker BuildX or manually (thanks to Christoph Honal @StarGate01). 101 | So now we support the following formats: Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 102 | and all their confusing combinations. 103 | * Amend representation of the tag info page. 104 | * Change logging library, add "-log-level" argument and put most of the logging into DEBUG mode. 105 | * You can define timezone when running container by adding `TZ` env var, e.g. "-e TZ=America/Los_Angeles" 106 | (thanks to @gminog). 107 | * Fix initial ownership of /opt/data dir in Dockerfile. 108 | * Hide repositories with 0 tags count. 109 | * Compatibility fix with docker_auth v1.5.0. 110 | 111 | ### 0.8.2 (2019-07-30) 112 | 113 | * Add event_deletion_enabled option to the config, useful for master-master/cluster setups. 114 | * Generate SHA256 from response body if no Docker-Content-Digest header is present, e.g. with AWS ECR. 115 | * Bump go version. 116 | 117 | ### 0.8.1 (2019-02-20) 118 | 119 | * Add favicon. 120 | 121 | ### 0.8.0 (2019-02-19) 122 | 123 | * Use go 1.11.5, alpine 3.9, echo 3.3.10. 124 | * Put all static files to the docker image instead of loading from CDN. 125 | * Now discover more than 100 repositories (thanks to Yuhi Ishikura @uphy). 126 | 127 | ### 0.7.4 (2018-10-30) 128 | 129 | * Switch to Go 1.11 and Go Modules to track dependencies. 130 | 131 | ### 0.7.3 (2018-08-14) 132 | 133 | * Add `registry_password_file` option to the config file. 134 | * Improve no data message on empty tables on UI. 135 | * Show the root namespace "library" in the dropdown even when there are no repos in it. 136 | * Switch alpine Docker image to 3.8. 137 | 138 | ### 0.7.2 (2018-07-30) 139 | 140 | * Make web root accessible w/o trailing slash when base_path is configured. 141 | 142 | ### 0.7.1 (2018-07-18) 143 | 144 | * Fix panic when using MySQL for events storage and no table created yet. 145 | 146 | ### 0.7 (2018-07-04) 147 | 148 | * When using MySQL for events storage, do not leak connections. 149 | * Last events were not shown when viewing a repo of non-default namespace. 150 | * Support repos with slash in the name. 151 | * Enable Sonatype Nexus compatibility. 152 | * Add `base_path` option to the config to run UI from non-root. 153 | * Add built-in cron feature for purging tags task. 154 | 155 | ### 0.6 (2018-05-28) 156 | 157 | * Add MySQL along with sqlite3 support as a registry events storage. 158 | New config settings `event_database_driver`, `event_database_location`. 159 | * Bump Go version and dependencies. 160 | 161 | ### 0.5 (2018-03-06) 162 | 163 | * Initial public version. 164 | -------------------------------------------------------------------------------- /static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Custom CSS for Registry UI */ 2 | 3 | /* Navbar with geometric pattern background */ 4 | .navbar.bg-dark { 5 | background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) !important; 6 | position: relative; 7 | overflow: hidden; 8 | } 9 | 10 | .navbar.bg-dark::before { 11 | content: ''; 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | right: 0; 16 | bottom: 0; 17 | background-image: 18 | linear-gradient(45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%), 19 | linear-gradient(-45deg, rgba(255, 255, 255, 0.08) 25%, transparent 25%), 20 | linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.08) 75%), 21 | linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.08) 75%); 22 | background-size: 30px 30px; 23 | background-position: 0 0, 0 15px, 15px -15px, -15px 0px; 24 | opacity: 0.8; 25 | pointer-events: none; 26 | } 27 | 28 | .navbar.bg-dark .container { 29 | position: relative; 30 | z-index: 1; 31 | } 32 | 33 | /* Footer styling for both themes */ 34 | footer.bg-light { 35 | background-color: #f8f9fa !important; 36 | border-top-color: #dee2e6 !important; 37 | } 38 | 39 | footer.bg-light a { 40 | color: #000000 !important; 41 | transition: color 0.2s ease; 42 | } 43 | 44 | footer.bg-light a:hover { 45 | color: #6c757d !important; 46 | } 47 | 48 | [data-bs-theme="dark"] footer.bg-light { 49 | background-color: #212529 !important; 50 | border-top-color: #495057 !important; 51 | } 52 | 53 | [data-bs-theme="dark"] footer.bg-light a { 54 | color: #adb5bd !important; 55 | transition: color 0.2s ease; 56 | } 57 | 58 | [data-bs-theme="dark"] footer.bg-light a:hover { 59 | color: #dee2e6 !important; 60 | } 61 | 62 | /* Breadcrumb styling for both themes */ 63 | .breadcrumb { 64 | background: linear-gradient(135deg, #e0e7ff 0%, #cffafe 100%); 65 | border: 1px solid #d1d5db; 66 | padding: 0.375rem 0.75rem; 67 | min-height: 38px; 68 | } 69 | 70 | [data-bs-theme="dark"] .breadcrumb { 71 | background: linear-gradient(135deg, #374151 0%, #1f2937 100%); 72 | border: 1px solid #4b5563; 73 | } 74 | 75 | /* Table header styling for both themes */ 76 | .table-light { 77 | --bs-table-bg: var(--bs-secondary-bg); 78 | --bs-table-color: var(--bs-body-color); 79 | --bs-table-border-color: var(--bs-border-color); 80 | } 81 | 82 | .table-light th { 83 | background-color: var(--bs-secondary-bg) !important; 84 | color: #6c757d !important; 85 | font-weight: 500 !important; 86 | } 87 | 88 | [data-bs-theme="dark"] .table-light th { 89 | color: #adb5bd !important; 90 | } 91 | 92 | /* DataTables 2.x - controls at bottom styling with Bootstrap row classes from dom config */ 93 | .table-responsive > div.dt-container > div.row:last-child { 94 | margin-top: 1rem; 95 | padding-bottom: 0.75rem; 96 | } 97 | 98 | /* Prevent horizontal scrollbar in table-responsive */ 99 | .table-responsive { 100 | overflow-x: visible !important; 101 | } 102 | 103 | .table-responsive > div.dt-container { 104 | overflow: visible !important; 105 | } 106 | 107 | .table-responsive table { 108 | margin-bottom: 0; 109 | } 110 | 111 | /* Target the column divs directly for padding */ 112 | .table-responsive > div.dt-container > div.row:last-child > div.col-sm-4:first-child, 113 | .table-responsive > div.dt-container > div.row:last-child > [class*="col-"]:first-child { 114 | padding-left: 0.75rem !important; 115 | } 116 | 117 | .table-responsive > div.dt-container > div.row:last-child > div.col-sm-4:last-child, 118 | .table-responsive > div.dt-container > div.row:last-child > [class*="col-"]:last-child { 119 | padding-right: 0.75rem !important; 120 | } 121 | 122 | /* Also target new layout system if used */ 123 | .table-responsive > div.dt-container > div.dt-layout-row:last-child { 124 | padding-left: 0.75rem !important; 125 | padding-right: 0.75rem !important; 126 | margin-top: 1rem; 127 | } 128 | 129 | /* Add spacing to individual control elements */ 130 | .dt-container .dt-info { 131 | padding-top: 0.5rem; 132 | padding-bottom: 0.5rem; 133 | padding-left: 0.75rem !important; 134 | color: #868e96 !important; 135 | } 136 | 137 | .dt-container .dt-paging { 138 | padding-top: 0.5rem; 139 | padding-bottom: 0.5rem; 140 | } 141 | 142 | .dt-container .dt-length { 143 | padding-top: 0.5rem; 144 | padding-bottom: 0.5rem; 145 | padding-right: 0.75rem !important; 146 | color: #868e96 !important; 147 | } 148 | 149 | [data-bs-theme="dark"] .dt-container .dt-info, 150 | [data-bs-theme="dark"] .dt-container .dt-length { 151 | color: #adb5bd !important; 152 | } 153 | 154 | /* Add spacing between length label and select dropdown */ 155 | .dt-container .dt-length select { 156 | margin-left: 0.5rem; 157 | margin-right: 0.5rem; 158 | } 159 | 160 | /* DataTables 2.x pagination styling - restore Bootstrap 5 look */ 161 | div.dt-container .dt-paging .dt-paging-button { 162 | padding: 0.375rem 0.75rem !important; 163 | border: 1px solid #adb5bd !important; 164 | border-radius: 0.375rem !important; 165 | margin: 0 0.125rem !important; 166 | min-width: auto !important; 167 | background: white !important; 168 | color: #212529 !important; 169 | } 170 | 171 | [data-bs-theme="dark"] div.dt-container .dt-paging .dt-paging-button { 172 | background: var(--bs-dark) !important; 173 | border-color: #6c757d !important; 174 | color: var(--bs-body-color) !important; 175 | } 176 | 177 | div.dt-container .dt-paging .dt-paging-button.current, 178 | div.dt-container .dt-paging .dt-paging-button.current:hover { 179 | background-color: #0d6efd !important; 180 | border-color: #0d6efd !important; 181 | color: white !important; 182 | } 183 | 184 | div.dt-container .dt-paging .dt-paging-button.disabled, 185 | div.dt-container .dt-paging .dt-paging-button.disabled:hover { 186 | opacity: 0.5 !important; 187 | background: white !important; 188 | color: #6c757d !important; 189 | cursor: not-allowed !important; 190 | } 191 | 192 | [data-bs-theme="dark"] div.dt-container .dt-paging .dt-paging-button.disabled, 193 | [data-bs-theme="dark"] div.dt-container .dt-paging .dt-paging-button.disabled:hover { 194 | background: var(--bs-dark) !important; 195 | } 196 | 197 | div.dt-container .dt-paging .dt-paging-button:hover:not(.disabled):not(.current) { 198 | background-color: #e9ecef !important; 199 | border-color: #dee2e6 !important; 200 | color: #212529 !important; 201 | } 202 | 203 | [data-bs-theme="dark"] div.dt-container .dt-paging .dt-paging-button:hover:not(.disabled):not(.current) { 204 | background-color: #495057 !important; 205 | border-color: var(--bs-border-color) !important; 206 | color: var(--bs-body-color) !important; 207 | } 208 | 209 | /* Dark mode specific adjustments */ 210 | [data-bs-theme="dark"] .text-muted { 211 | color: var(--bs-secondary-color) !important; 212 | } 213 | 214 | [data-bs-theme="dark"] .card { 215 | border-color: var(--bs-border-color); 216 | } 217 | 218 | [data-bs-theme="dark"] .shadow-sm { 219 | box-shadow: 0 .125rem .25rem rgba(255, 255, 255, .075) !important; 220 | } 221 | 222 | /* Event log table - prevent Time column from wrapping */ 223 | #datatable td:last-child { 224 | white-space: nowrap; 225 | } 226 | 227 | /* Image details page - nested table styling */ 228 | /* All first columns (keys) should be grey, regardless of nesting */ 229 | .table-striped.table-bordered td:first-child { 230 | color: #838383 !important; 231 | } 232 | 233 | [data-bs-theme="dark"] .table-striped.table-bordered td:first-child { 234 | color: #adb5bd !important; 235 | } 236 | 237 | /* Exception: Single-column tables (arrays) - first column is a value, not a key */ 238 | .table-striped.table-bordered td:first-child:last-child { 239 | color: #212529 !important; 240 | } 241 | 242 | [data-bs-theme="dark"] .table-striped.table-bordered td:first-child:last-child { 243 | color: #dee2e6 !important; 244 | } 245 | 246 | /* All other cells (second column values) should use theme color */ 247 | .table-striped.table-bordered td:not(:first-child) { 248 | color: #212529 !important; 249 | } 250 | 251 | [data-bs-theme="dark"] .table-striped.table-bordered td:not(:first-child) { 252 | color: #dee2e6 !important; 253 | } 254 | -------------------------------------------------------------------------------- /templates/catalog.html: -------------------------------------------------------------------------------- 1 | {{extends "base.html"}} 2 | {{import "breadcrumb.html"}} 3 | 4 | {{block head()}} 5 | 6 | 7 | 75 | {{end}} 76 | 77 | {{block body()}} 78 |
79 | 84 |
85 | 86 | 89 |
90 |
91 | 92 | {{if len(repos)>0 || !isCatalogReady}} 93 |
94 |
95 |
Repositories
96 |
97 |
98 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | {{range _, repo := repos}} 108 | {{ full_repo_path := repoPath != "" ? repoPath+"/"+repo : repo }} 109 | {{if !isset(tagCounts[full_repo_path]) || (isset(tagCounts[full_repo_path]) && tagCounts[full_repo_path] > 0)}} 110 | 111 | 115 | 116 | 117 | {{end}} 118 | {{end}} 119 | 120 |
RepositoryTags
112 | 113 | {{ repo }} 114 | {{ tagCounts[full_repo_path] }}
121 |
122 |
123 |
124 | {{end}} {* end repos *} 125 | 126 | {{if len(tags)>0}} 127 |
128 |
129 |
Tags
130 |
131 |
132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | {{range _, tag := tags}} 141 | 142 | 158 | 159 | {{end}} 160 | 161 |
Tag Name
143 |
144 |
145 | 146 | {{ tag }} 147 |
148 | {{if deleteAllowed}} 149 | 153 | Delete 154 | 155 | {{end}} 156 |
157 |
162 |
163 |
164 |
165 | {{end}} {* end tags *} 166 | 167 | {{if eventsAllowed and isset(events) }} 168 |
169 |
170 |
Recent Activity
171 |
172 |
173 |
174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | {{range _, e := events}} 186 | 187 | 188 | {{if hasPrefix(e.Tag,"sha256:") }} 189 | 190 | {{else}} 191 | 192 | {{end}} 193 | 194 | 195 | 196 | 197 | {{end}} 198 | 199 |
ActionImageIP AddressUserTime
{{ e.Action }}{{ e.Repository }}@{{ e.Tag[:19] }}...{{ e.Repository }}:{{ e.Tag }}{{ e.IP }}{{ e.User }}{{ e.Created|pretty_time }}
200 |
201 |
202 |
203 | {{end}} 204 | 205 | {{end}} 206 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= 4 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= 5 | github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw= 6 | github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw= 7 | github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= 8 | github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/docker/cli v29.0.4+incompatible h1:mffN/hPqaI39vx/4QiSkdldHeM0rP1ZZBIXRUOPI5+I= 13 | github.com/docker/cli v29.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 14 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 15 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 16 | github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI= 17 | github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= 18 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 19 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 20 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 21 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 22 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 23 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 24 | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 25 | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 26 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 27 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 28 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 | github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= 31 | github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= 32 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 33 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 34 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 35 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 36 | github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= 37 | github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 38 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 39 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 40 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 41 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 42 | github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 43 | github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 44 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 45 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 46 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 47 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 48 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 49 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 50 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 51 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 52 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 53 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 54 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 55 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 56 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 57 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 58 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 59 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 60 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 61 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 65 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 66 | github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= 67 | github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= 68 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 69 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 70 | github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= 71 | github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= 72 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 73 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 74 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 75 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 76 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 77 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 78 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 79 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 80 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 81 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 82 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 83 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 84 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 85 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 86 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 87 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 88 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 89 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 90 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 91 | github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= 92 | github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 93 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 94 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 95 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 96 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 97 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 98 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 99 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 100 | github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= 101 | github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 102 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 103 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 104 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 105 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 106 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 107 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 108 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 109 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 110 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 113 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 114 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 115 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 116 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 117 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 118 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 120 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 121 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 123 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 124 | gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= 125 | gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= 126 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /registry/client.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/google/go-containerregistry/pkg/authn" 12 | "github.com/google/go-containerregistry/pkg/name" 13 | v1 "github.com/google/go-containerregistry/pkg/v1" 14 | "github.com/google/go-containerregistry/pkg/v1/remote" 15 | "github.com/sirupsen/logrus" 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | const userAgent = "registry-ui" 20 | 21 | // Client main class. 22 | type Client struct { 23 | puller *remote.Puller 24 | pusher *remote.Pusher 25 | logger *logrus.Entry 26 | repos []string 27 | tagCountsMux sync.Mutex 28 | tagCounts map[string]int 29 | isCatalogReady bool 30 | nameOptions []name.Option 31 | } 32 | 33 | type ImageInfo struct { 34 | IsImageIndex bool 35 | IsImage bool 36 | ImageRefRepo string 37 | ImageRefTag string 38 | ImageRefDigest string 39 | MediaType string 40 | Platforms string 41 | Manifest map[string]interface{} 42 | 43 | // Image specific 44 | ImageSize int64 45 | Created time.Time 46 | ConfigImageID string 47 | ConfigFile map[string]interface{} 48 | } 49 | 50 | // NewClient initialize Client. 51 | func NewClient() *Client { 52 | var authOpt remote.Option 53 | if viper.GetBool("registry.auth_with_keychain") { 54 | authOpt = remote.WithAuthFromKeychain(authn.DefaultKeychain) 55 | } else { 56 | password := viper.GetString("registry.password") 57 | if password == "" { 58 | passwdFile := viper.GetString("registry.password_file") 59 | if _, err := os.Stat(passwdFile); os.IsNotExist(err) { 60 | panic(err) 61 | } 62 | data, err := os.ReadFile(passwdFile) 63 | if err != nil { 64 | panic(err) 65 | } 66 | password = strings.TrimSuffix(string(data[:]), "\n") 67 | } 68 | 69 | authOpt = remote.WithAuth(authn.FromConfig(authn.AuthConfig{ 70 | Username: viper.GetString("registry.username"), Password: password, 71 | })) 72 | } 73 | 74 | pageSize := viper.GetInt("performance.catalog_page_size") 75 | puller, _ := remote.NewPuller(authOpt, remote.WithUserAgent(userAgent), remote.WithPageSize(pageSize)) 76 | pusher, _ := remote.NewPusher(authOpt, remote.WithUserAgent(userAgent)) 77 | 78 | insecure := viper.GetBool("registry.insecure") 79 | nameOptions := []name.Option{} 80 | if insecure { 81 | nameOptions = append(nameOptions, name.Insecure) 82 | } 83 | 84 | c := &Client{ 85 | puller: puller, 86 | pusher: pusher, 87 | logger: SetupLogging("registry.client"), 88 | repos: []string{}, 89 | tagCounts: map[string]int{}, 90 | nameOptions: nameOptions, 91 | } 92 | return c 93 | } 94 | 95 | func (c *Client) StartBackgroundJobs() { 96 | catalogInterval := viper.GetInt("performance.catalog_refresh_interval") 97 | tagsCountInterval := viper.GetInt("performance.tags_count_refresh_interval") 98 | isStarted := false 99 | for { 100 | c.RefreshCatalog() 101 | if !isStarted && tagsCountInterval > 0 { 102 | // Start after the first catalog refresh 103 | go c.CountTags(tagsCountInterval) 104 | isStarted = true 105 | } 106 | if catalogInterval == 0 { 107 | c.logger.Warn("Catalog refresh is disabled in the config and will not run anymore.") 108 | break 109 | } 110 | time.Sleep(time.Duration(catalogInterval) * time.Minute) 111 | } 112 | 113 | } 114 | 115 | func (c *Client) RefreshCatalog() { 116 | ctx := context.Background() 117 | start := time.Now() 118 | c.logger.Info("[RefreshCatalog] Started reading catalog...") 119 | registry, _ := name.NewRegistry(viper.GetString("registry.hostname"), c.nameOptions...) 120 | cat, err := c.puller.Catalogger(ctx, registry) 121 | if err != nil { 122 | c.logger.Errorf("[RefreshCatalog] Error fetching catalog: %s", err) 123 | if !c.isCatalogReady { 124 | os.Exit(1) 125 | } 126 | return 127 | } 128 | repos := []string{} 129 | // The library itself does retries under the hood. 130 | for cat.HasNext() { 131 | data, err := cat.Next(ctx) 132 | if err != nil { 133 | c.logger.Errorf("[RefreshCatalog] Error listing catalog: %s", err) 134 | } 135 | if data != nil { 136 | repos = append(repos, data.Repos...) 137 | if !c.isCatalogReady { 138 | c.repos = append(c.repos, data.Repos...) 139 | c.logger.Debug("[RefreshCatalog] Repo batch received:", data.Repos) 140 | } 141 | } 142 | } 143 | 144 | if len(repos) > 0 { 145 | c.repos = repos 146 | } else { 147 | c.logger.Warn("[RefreshCatalog] Catalog looks empty, preserving previous list if any.") 148 | } 149 | c.logger.Debugf("[RefreshCatalog] Catalog: %s", c.repos) 150 | c.logger.Infof("[RefreshCatalog] Job complete (%v): %d repos found", time.Since(start), len(c.repos)) 151 | c.isCatalogReady = true 152 | } 153 | 154 | // IsCatalogReady whether catalog is ready for the first time use 155 | func (c *Client) IsCatalogReady() bool { 156 | return c.isCatalogReady 157 | } 158 | 159 | // GetRepos get all repos 160 | func (c *Client) GetRepos() []string { 161 | return c.repos 162 | } 163 | 164 | // ListTags get tags for the repo 165 | func (c *Client) ListTags(repoName string) []string { 166 | ctx := context.Background() 167 | repo, _ := name.NewRepository(viper.GetString("registry.hostname")+"/"+repoName, c.nameOptions...) 168 | tags, err := c.puller.List(ctx, repo) 169 | if err != nil { 170 | c.logger.Errorf("Error listing tags for repo %s: %s", repoName, err) 171 | } 172 | c.tagCountsMux.Lock() 173 | c.tagCounts[repoName] = len(tags) 174 | c.tagCountsMux.Unlock() 175 | return tags 176 | } 177 | 178 | // GetImageInfo get image info by the reference - tag name or digest sha256. 179 | func (c *Client) GetImageInfo(imageRef string) (ImageInfo, error) { 180 | ctx := context.Background() 181 | ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...) 182 | if err != nil { 183 | c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err) 184 | return ImageInfo{}, err 185 | } 186 | descr, err := c.puller.Get(ctx, ref) 187 | if err != nil { 188 | c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err) 189 | return ImageInfo{}, err 190 | } 191 | 192 | ii := ImageInfo{ 193 | ImageRefRepo: ref.Context().RepositoryStr(), 194 | ImageRefTag: ref.Identifier(), 195 | ImageRefDigest: descr.Digest.String(), 196 | MediaType: string(descr.MediaType), 197 | } 198 | if descr.MediaType.IsIndex() { 199 | ii.IsImageIndex = true 200 | } else if descr.MediaType.IsImage() { 201 | ii.IsImage = true 202 | } else { 203 | c.logger.Errorf("Image reference %s is neither Index nor Image", imageRef) 204 | return ImageInfo{}, err 205 | } 206 | 207 | if ii.IsImage { 208 | img, err := descr.Image() 209 | if err != nil { 210 | c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err) 211 | return ImageInfo{}, err 212 | } 213 | cfg, err := img.ConfigFile() 214 | if err != nil { 215 | c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err) 216 | return ImageInfo{}, err 217 | } 218 | ii.Created = cfg.Created.Time 219 | ii.Platforms = getPlatform(cfg.Platform()) 220 | ii.ConfigFile = structToMap(cfg) 221 | // ImageID is what is shown in the terminal when doing "docker images". 222 | // This is a config sha256 of the corresponding image manifest (single platform). 223 | if x, _ := img.ConfigName(); len(x.String()) > 19 { 224 | ii.ConfigImageID = x.String()[7:19] 225 | } 226 | mf, _ := img.Manifest() 227 | for _, l := range mf.Layers { 228 | ii.ImageSize += l.Size 229 | } 230 | ii.Manifest = structToMap(mf) 231 | } else if ii.IsImageIndex { 232 | // In case of Image Index, if we request for Image() > ConfigFile(), it will be resolved 233 | // to a config of one of the manifests (one of the platforms). 234 | // It doesn't make a lot of sense, even they are usually identical. Also extra API calls which slows things down. 235 | imgIdx, err := descr.ImageIndex() 236 | if err != nil { 237 | c.logger.Errorf("Cannot convert descriptor to ImageIndex for image reference %s: %s", imageRef, err) 238 | return ImageInfo{}, err 239 | } 240 | IdxMf, _ := imgIdx.IndexManifest() 241 | platforms := []string{} 242 | for _, m := range IdxMf.Manifests { 243 | platforms = append(platforms, getPlatform(m.Platform)) 244 | } 245 | ii.Platforms = strings.Join(UniqueSortedSlice(platforms), ", ") 246 | ii.Manifest = structToMap(IdxMf) 247 | } 248 | 249 | return ii, nil 250 | } 251 | 252 | func getPlatform(p *v1.Platform) string { 253 | if p != nil { 254 | return p.String() 255 | } 256 | return "" 257 | } 258 | 259 | // structToMap convert struct to map so it can be formatted as HTML table easily 260 | func structToMap(obj interface{}) map[string]interface{} { 261 | var res map[string]interface{} 262 | jsonBytes, _ := json.Marshal(obj) 263 | json.Unmarshal(jsonBytes, &res) 264 | return res 265 | } 266 | 267 | // GetImageCreated get image created time 268 | func (c *Client) GetImageCreated(imageRef string) time.Time { 269 | zeroTime := new(time.Time) 270 | ctx := context.Background() 271 | ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...) 272 | if err != nil { 273 | c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err) 274 | return *zeroTime 275 | } 276 | descr, err := c.puller.Get(ctx, ref) 277 | if err != nil { 278 | c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err) 279 | return *zeroTime 280 | } 281 | // In case of ImageIndex, it is resolved to a random sub-image which should be fine. 282 | img, err := descr.Image() 283 | if err != nil { 284 | c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err) 285 | return *zeroTime 286 | } 287 | cfg, err := img.ConfigFile() 288 | if err != nil { 289 | c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err) 290 | return *zeroTime 291 | } 292 | return cfg.Created.Time 293 | } 294 | 295 | // SubRepoTagCounts return map with tag counts according to the provided list of repos/sub-repos etc. 296 | func (c *Client) SubRepoTagCounts(repoPath string, repos []string) map[string]int { 297 | counts := map[string]int{} 298 | for _, r := range repos { 299 | subRepo := r 300 | if repoPath != "" { 301 | subRepo = repoPath + "/" + r 302 | } 303 | 304 | // Acquire lock to prevent concurrent map iteration and map write. 305 | c.tagCountsMux.Lock() 306 | for k, v := range c.tagCounts { 307 | if k == subRepo || strings.HasPrefix(k, subRepo+"/") { 308 | counts[subRepo] = counts[subRepo] + v 309 | } 310 | } 311 | c.tagCountsMux.Unlock() 312 | } 313 | return counts 314 | } 315 | 316 | // CountTags count repository tags in background regularly. 317 | func (c *Client) CountTags(interval int) { 318 | for { 319 | start := time.Now() 320 | c.logger.Info("[CountTags] Started counting tags...") 321 | for _, r := range c.repos { 322 | c.ListTags(r) 323 | } 324 | c.logger.Infof("[CountTags] Job complete (%v).", time.Since(start)) 325 | time.Sleep(time.Duration(interval) * time.Minute) 326 | } 327 | } 328 | 329 | // DeleteTag delete image tag. 330 | func (c *Client) DeleteTag(repoPath, tag string) { 331 | ctx := context.Background() 332 | imageRef := repoPath + ":" + tag 333 | ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...) 334 | if err != nil { 335 | c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err) 336 | return 337 | } 338 | // Get manifest so we have a digest to delete by 339 | descr, err := c.puller.Get(ctx, ref) 340 | if err != nil { 341 | c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err) 342 | return 343 | } 344 | // Parse image reference by digest now 345 | imageRefDigest := ref.Context().RepositoryStr() + "@" + descr.Digest.String() 346 | ref, err = name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRefDigest, c.nameOptions...) 347 | if err != nil { 348 | c.logger.Errorf("Error parsing image reference %s: %s", imageRefDigest, err) 349 | return 350 | } 351 | 352 | // Delete tag using digest. 353 | // Note, it will also delete any other tags pointing to the same digest! 354 | err = c.pusher.Delete(ctx, ref) 355 | if err != nil { 356 | c.logger.Errorf("Error deleting image %s: %s", imageRef, err) 357 | return 358 | } 359 | c.tagCountsMux.Lock() 360 | c.tagCounts[repoPath]-- 361 | c.tagCountsMux.Unlock() 362 | c.logger.Infof("Image %s has been successfully deleted.", imageRef) 363 | } 364 | -------------------------------------------------------------------------------- /static/css/datatables.min.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This combined file was created by the DataTables downloader builder: 3 | * https://datatables.net/download 4 | * 5 | * To rebuild or modify this file with the latest versions of the included 6 | * software please visit: 7 | * https://datatables.net/download/#dt/jq-3.7.0/dt-2.3.5 8 | * 9 | * Included libraries: 10 | * jQuery 3.7.0, DataTables 2.3.5 11 | */ 12 | 13 | :root{--dt-row-selected: 13, 110, 253;--dt-row-selected-text: 255, 255, 255;--dt-row-selected-link: 228, 228, 228;--dt-row-stripe: 0, 0, 0;--dt-row-hover: 0, 0, 0;--dt-column-ordering: 0, 0, 0;--dt-header-align-items: center;--dt-header-vertical-align: middle;--dt-html-background: white}:root.dark{--dt-html-background: rgb(33, 37, 41)}table.dataTable tbody td.dt-control{text-align:center;cursor:pointer}table.dataTable tbody td.dt-control:before{display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable tbody tr.dt-hasChild td.dt-control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}table.dataTable tfoot:empty{display:none}html.dark table.dataTable td.dt-control:before,:root[data-bs-theme=dark] table.dataTable td.dt-control:before,:root[data-theme=dark] table.dataTable td.dt-control:before{border-left-color:rgba(255, 255, 255, 0.5)}html.dark table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before{border-top-color:rgba(255, 255, 255, 0.5);border-left-color:transparent}div.dt-scroll{width:100%}div.dt-scroll-body thead tr,div.dt-scroll-body tfoot tr{height:0}div.dt-scroll-body thead tr th,div.dt-scroll-body thead tr td,div.dt-scroll-body tfoot tr th,div.dt-scroll-body tfoot tr td{height:0 !important;padding-top:0px !important;padding-bottom:0px !important;border-top-width:0px !important;border-bottom-width:0px !important}div.dt-scroll-body thead tr th div.dt-scroll-sizing,div.dt-scroll-body thead tr td div.dt-scroll-sizing,div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,div.dt-scroll-body tfoot tr td div.dt-scroll-sizing{height:0 !important;overflow:hidden !important}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before{position:absolute;display:block;bottom:50%;content:"▲";content:"▲"/""}table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{position:absolute;display:block;top:50%;content:"▼";content:"▼"/""}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order{position:relative;width:12px;height:20px}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{left:0;opacity:.125;line-height:9px;font-size:.8em}table.dataTable thead>tr>th.dt-orderable-asc,table.dataTable thead>tr>th.dt-orderable-desc,table.dataTable thead>tr>td.dt-orderable-asc,table.dataTable thead>tr>td.dt-orderable-desc{cursor:pointer}table.dataTable thead>tr>th.dt-orderable-asc:hover,table.dataTable thead>tr>th.dt-orderable-desc:hover,table.dataTable thead>tr>td.dt-orderable-asc:hover,table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:2px solid rgba(0, 0, 0, 0.05);outline-offset:-2px}table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{opacity:.6}table.dataTable thead>tr>th.dt-orderable-none:not(.dt-ordering-asc,.dt-ordering-desc) span.dt-column-order:empty,table.dataTable thead>tr>th.sorting_desc_disabled span.dt-column-order:after,table.dataTable thead>tr>th.sorting_asc_disabled span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-none:not(.dt-ordering-asc,.dt-ordering-desc) span.dt-column-order:empty,table.dataTable thead>tr>td.sorting_desc_disabled span.dt-column-order:after,table.dataTable thead>tr>td.sorting_asc_disabled span.dt-column-order:before{display:none}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th div.dt-column-header,table.dataTable thead>tr>th div.dt-column-footer,table.dataTable thead>tr>td div.dt-column-header,table.dataTable thead>tr>td div.dt-column-footer,table.dataTable tfoot>tr>th div.dt-column-header,table.dataTable tfoot>tr>th div.dt-column-footer,table.dataTable tfoot>tr>td div.dt-column-header,table.dataTable tfoot>tr>td div.dt-column-footer{display:flex;justify-content:space-between;align-items:var(--dt-header-align-items);gap:4px}table.dataTable thead>tr>th div.dt-column-header span.dt-column-title,table.dataTable thead>tr>th div.dt-column-footer span.dt-column-title,table.dataTable thead>tr>td div.dt-column-header span.dt-column-title,table.dataTable thead>tr>td div.dt-column-footer span.dt-column-title,table.dataTable tfoot>tr>th div.dt-column-header span.dt-column-title,table.dataTable tfoot>tr>th div.dt-column-footer span.dt-column-title,table.dataTable tfoot>tr>td div.dt-column-header span.dt-column-title,table.dataTable tfoot>tr>td div.dt-column-footer span.dt-column-title{flex-grow:1}table.dataTable thead>tr>th div.dt-column-header span.dt-column-title:empty,table.dataTable thead>tr>th div.dt-column-footer span.dt-column-title:empty,table.dataTable thead>tr>td div.dt-column-header span.dt-column-title:empty,table.dataTable thead>tr>td div.dt-column-footer span.dt-column-title:empty,table.dataTable tfoot>tr>th div.dt-column-header span.dt-column-title:empty,table.dataTable tfoot>tr>th div.dt-column-footer span.dt-column-title:empty,table.dataTable tfoot>tr>td div.dt-column-header span.dt-column-title:empty,table.dataTable tfoot>tr>td div.dt-column-footer span.dt-column-title:empty{display:none}div.dt-scroll-body>table.dataTable>thead>tr>th,div.dt-scroll-body>table.dataTable>thead>tr>td{overflow:hidden}:root.dark table.dataTable thead>tr>th.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>th.dt-orderable-desc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:2px solid rgba(255, 255, 255, 0.05)}div.dt-processing{position:absolute;top:50%;left:50%;width:200px;margin-left:-100px;margin-top:-22px;text-align:center;padding:2px;z-index:10}div.dt-processing>div:last-child{position:relative;width:80px;height:15px;margin:1em auto}div.dt-processing>div:last-child>div{position:absolute;top:0;width:13px;height:13px;border-radius:50%;background:rgb(13, 110, 253);background:rgb(var(--dt-row-selected));animation-timing-function:cubic-bezier(0, 1, 1, 0)}div.dt-processing>div:last-child>div:nth-child(1){left:8px;animation:datatables-loader-1 .6s infinite}div.dt-processing>div:last-child>div:nth-child(2){left:8px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(3){left:32px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(4){left:56px;animation:datatables-loader-3 .6s infinite}@keyframes datatables-loader-1{0%{transform:scale(0)}100%{transform:scale(1)}}@keyframes datatables-loader-3{0%{transform:scale(1)}100%{transform:scale(0)}}@keyframes datatables-loader-2{0%{transform:translate(0, 0)}100%{transform:translate(24px, 0)}}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}table.dataTable th,table.dataTable td{box-sizing:border-box}table.dataTable th.dt-type-numeric,table.dataTable th.dt-type-date,table.dataTable td.dt-type-numeric,table.dataTable td.dt-type-date{text-align:right}table.dataTable th.dt-type-numeric div.dt-column-header,table.dataTable th.dt-type-numeric div.dt-column-footer,table.dataTable th.dt-type-date div.dt-column-header,table.dataTable th.dt-type-date div.dt-column-footer,table.dataTable td.dt-type-numeric div.dt-column-header,table.dataTable td.dt-type-numeric div.dt-column-footer,table.dataTable td.dt-type-date div.dt-column-header,table.dataTable td.dt-type-date div.dt-column-footer{flex-direction:row-reverse}table.dataTable th.dt-left,table.dataTable td.dt-left{text-align:left}table.dataTable th.dt-left div.dt-column-header,table.dataTable th.dt-left div.dt-column-footer,table.dataTable td.dt-left div.dt-column-header,table.dataTable td.dt-left div.dt-column-footer{flex-direction:row}table.dataTable th.dt-center,table.dataTable td.dt-center{text-align:center}table.dataTable th.dt-right,table.dataTable td.dt-right{text-align:right}table.dataTable th.dt-right div.dt-column-header,table.dataTable th.dt-right div.dt-column-footer,table.dataTable td.dt-right div.dt-column-header,table.dataTable td.dt-right div.dt-column-footer{flex-direction:row-reverse}table.dataTable th.dt-justify,table.dataTable td.dt-justify{text-align:justify}table.dataTable th.dt-justify div.dt-column-header,table.dataTable th.dt-justify div.dt-column-footer,table.dataTable td.dt-justify div.dt-column-header,table.dataTable td.dt-justify div.dt-column-footer{flex-direction:row}table.dataTable th.dt-nowrap,table.dataTable td.dt-nowrap{white-space:nowrap}table.dataTable th.dt-empty,table.dataTable td.dt-empty{text-align:center;vertical-align:top}table.dataTable thead th,table.dataTable thead td,table.dataTable tfoot th,table.dataTable tfoot td{text-align:left;vertical-align:var(--dt-header-vertical-align)}table.dataTable thead th.dt-head-left,table.dataTable thead td.dt-head-left,table.dataTable tfoot th.dt-head-left,table.dataTable tfoot td.dt-head-left{text-align:left}table.dataTable thead th.dt-head-left div.dt-column-header,table.dataTable thead th.dt-head-left div.dt-column-footer,table.dataTable thead td.dt-head-left div.dt-column-header,table.dataTable thead td.dt-head-left div.dt-column-footer,table.dataTable tfoot th.dt-head-left div.dt-column-header,table.dataTable tfoot th.dt-head-left div.dt-column-footer,table.dataTable tfoot td.dt-head-left div.dt-column-header,table.dataTable tfoot td.dt-head-left div.dt-column-footer{flex-direction:row}table.dataTable thead th.dt-head-center,table.dataTable thead td.dt-head-center,table.dataTable tfoot th.dt-head-center,table.dataTable tfoot td.dt-head-center{text-align:center}table.dataTable thead th.dt-head-right,table.dataTable thead td.dt-head-right,table.dataTable tfoot th.dt-head-right,table.dataTable tfoot td.dt-head-right{text-align:right}table.dataTable thead th.dt-head-right div.dt-column-header,table.dataTable thead th.dt-head-right div.dt-column-footer,table.dataTable thead td.dt-head-right div.dt-column-header,table.dataTable thead td.dt-head-right div.dt-column-footer,table.dataTable tfoot th.dt-head-right div.dt-column-header,table.dataTable tfoot th.dt-head-right div.dt-column-footer,table.dataTable tfoot td.dt-head-right div.dt-column-header,table.dataTable tfoot td.dt-head-right div.dt-column-footer{flex-direction:row-reverse}table.dataTable thead th.dt-head-justify,table.dataTable thead td.dt-head-justify,table.dataTable tfoot th.dt-head-justify,table.dataTable tfoot td.dt-head-justify{text-align:justify}table.dataTable thead th.dt-head-justify div.dt-column-header,table.dataTable thead th.dt-head-justify div.dt-column-footer,table.dataTable thead td.dt-head-justify div.dt-column-header,table.dataTable thead td.dt-head-justify div.dt-column-footer,table.dataTable tfoot th.dt-head-justify div.dt-column-header,table.dataTable tfoot th.dt-head-justify div.dt-column-footer,table.dataTable tfoot td.dt-head-justify div.dt-column-header,table.dataTable tfoot td.dt-head-justify div.dt-column-footer{flex-direction:row}table.dataTable thead th.dt-head-nowrap,table.dataTable thead td.dt-head-nowrap,table.dataTable tfoot th.dt-head-nowrap,table.dataTable tfoot td.dt-head-nowrap{white-space:nowrap}table.dataTable tbody th.dt-body-left,table.dataTable tbody td.dt-body-left{text-align:left}table.dataTable tbody th.dt-body-center,table.dataTable tbody td.dt-body-center{text-align:center}table.dataTable tbody th.dt-body-right,table.dataTable tbody td.dt-body-right{text-align:right}table.dataTable tbody th.dt-body-justify,table.dataTable tbody td.dt-body-justify{text-align:justify}table.dataTable tbody th.dt-body-nowrap,table.dataTable tbody td.dt-body-nowrap{white-space:nowrap}:root{--dt-row-hover-alpha: 0.035;--dt-row-stripe-alpha: 0.023;--dt-column-ordering-alpha: 0.019;--dt-row-selected-stripe-alpha: 0.923;--dt-row-selected-column-ordering-alpha: 0.919}table.dataTable{width:100%;margin:0 auto;border-spacing:0}table.dataTable thead th,table.dataTable tfoot th{font-weight:bold}table.dataTable>thead>tr>th,table.dataTable>thead>tr>td{padding:10px;border-bottom:1px solid rgba(0, 0, 0, 0.3)}table.dataTable>thead>tr>th:active,table.dataTable>thead>tr>td:active{outline:none}table.dataTable>tfoot>tr>th,table.dataTable>tfoot>tr>td{border-top:1px solid rgba(0, 0, 0, 0.3);padding:10px 10px 6px 10px}table.dataTable>tbody>tr{background-color:transparent}table.dataTable>tbody>tr:first-child>*{border-top:none}table.dataTable>tbody>tr:last-child>*{border-bottom:none}table.dataTable>tbody>tr.selected>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.9);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.9);color:rgb(255, 255, 255);color:rgb(var(--dt-row-selected-text))}table.dataTable>tbody>tr.selected a{color:rgb(228, 228, 228);color:rgb(var(--dt-row-selected-link))}table.dataTable>tbody>tr>th,table.dataTable>tbody>tr>td{padding:8px 10px}table.dataTable.row-border>tbody>tr>*,table.dataTable.display>tbody>tr>*{border-top:1px solid rgba(0, 0, 0, 0.15)}table.dataTable.row-border>tbody>tr:first-child>*,table.dataTable.display>tbody>tr:first-child>*{border-top:none}table.dataTable.row-border>tbody>tr.selected+tr.selected>td,table.dataTable.display>tbody>tr.selected+tr.selected>td{border-top-color:rgba(13, 110, 253, 0.65);border-top-color:rgba(var(--dt-row-selected), 0.65)}table.dataTable.cell-border>tbody>tr>*{border-top:1px solid rgba(0, 0, 0, 0.15);border-right:1px solid rgba(0, 0, 0, 0.15)}table.dataTable.cell-border>tbody>tr>*:first-child{border-left:1px solid rgba(0, 0, 0, 0.15)}table.dataTable.cell-border>tbody>tr:first-child>*{border-top:1px solid rgba(0, 0, 0, 0.3)}table.dataTable.stripe>tbody>tr:nth-child(odd)>*,table.dataTable.display>tbody>tr:nth-child(odd)>*{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.023);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-stripe), var(--dt-row-stripe-alpha))}table.dataTable.stripe>tbody>tr:nth-child(odd).selected>*,table.dataTable.display>tbody>tr:nth-child(odd).selected>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.923);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), var(--dt-row-selected-stripe-alpha))}table.dataTable.hover>tbody>tr:hover>*,table.dataTable.display>tbody>tr:hover>*{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.035);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), var(--dt-row-hover-alpha))}table.dataTable.hover>tbody>tr.selected:hover>*,table.dataTable.display>tbody>tr.selected:hover>*{box-shadow:inset 0 0 0 9999px #0d6efd !important;box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 1) !important}table.dataTable.order-column>tbody tr>.sorting_1,table.dataTable.order-column>tbody tr>.sorting_2,table.dataTable.order-column>tbody tr>.sorting_3,table.dataTable.display>tbody tr>.sorting_1,table.dataTable.display>tbody tr>.sorting_2,table.dataTable.display>tbody tr>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.019);box-shadow:inset 0 0 0 9999px rgba(var(--dt-column-ordering), var(--dt-column-ordering-alpha))}table.dataTable.order-column>tbody tr.selected>.sorting_1,table.dataTable.order-column>tbody tr.selected>.sorting_2,table.dataTable.order-column>tbody tr.selected>.sorting_3,table.dataTable.display>tbody tr.selected>.sorting_1,table.dataTable.display>tbody tr.selected>.sorting_2,table.dataTable.display>tbody tr.selected>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.919);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), var(--dt-row-selected-column-ordering-alpha))}table.dataTable.display>tbody>tr:nth-child(odd)>.sorting_1,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd)>.sorting_1{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.054);box-shadow:inset 0 0 0 9999px rgba(var(--dt-column-ordering), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha)))}table.dataTable.display>tbody>tr:nth-child(odd)>.sorting_2,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd)>.sorting_2{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.047);box-shadow:inset 0 0 0 9999px rgba(var(--dt-column-ordering), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha) - 0.007))}table.dataTable.display>tbody>tr:nth-child(odd)>.sorting_3,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd)>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.039);box-shadow:inset 0 0 0 9999px rgba(var(--dt-column-ordering), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha) - 0.015))}table.dataTable.display>tbody>tr:nth-child(odd).selected>.sorting_1,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd).selected>.sorting_1{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.954);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha)))}table.dataTable.display>tbody>tr:nth-child(odd).selected>.sorting_2,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd).selected>.sorting_2{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.947);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha) - 0.007))}table.dataTable.display>tbody>tr:nth-child(odd).selected>.sorting_3,table.dataTable.order-column.stripe>tbody>tr:nth-child(odd).selected>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.939);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha) - 0.015))}table.dataTable.display tbody tr:hover>.sorting_1,table.dataTable.order-column.hover tbody tr:hover>.sorting_1{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.082);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha) + var(--dt-row-hover-alpha)))}table.dataTable.display tbody tr:hover>.sorting_2,table.dataTable.order-column.hover tbody tr:hover>.sorting_2{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.074);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha) + var(--dt-row-hover-alpha) - 0.007))}table.dataTable.display tbody tr:hover>.sorting_3,table.dataTable.order-column.hover tbody tr:hover>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(0, 0, 0, 0.062);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), calc(var(--dt-row-stripe-alpha) + var(--dt-column-ordering-alpha) + var(--dt-row-hover-alpha) - 0.015))}table.dataTable.display tbody tr:hover.selected>.sorting_1,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_1{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.982);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha)))}table.dataTable.display tbody tr:hover.selected>.sorting_2,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_2{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.974);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha) + var(--dt-row-hover-alpha) - 0.007))}table.dataTable.display tbody tr:hover.selected>.sorting_3,table.dataTable.order-column.hover tbody tr:hover.selected>.sorting_3{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.962);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), calc(var(--dt-row-selected-stripe-alpha) + var(--dt-column-ordering-alpha) + var(--dt-row-hover-alpha) - 0.015))}table.dataTable.compact thead th,table.dataTable.compact thead td,table.dataTable.compact tfoot th,table.dataTable.compact tfoot td,table.dataTable.compact tbody th,table.dataTable.compact tbody td{padding:4px}div.dt-container div.dt-layout-row{display:flex;justify-content:space-between;align-items:center;width:100%;margin:.75em 0}div.dt-container div.dt-layout-row div.dt-layout-cell{display:flex;justify-content:space-between;align-items:center}div.dt-container div.dt-layout-row div.dt-layout-cell.dt-layout-start{justify-content:flex-start;margin-right:auto}div.dt-container div.dt-layout-row div.dt-layout-cell.dt-layout-end{justify-content:flex-end;margin-left:auto}div.dt-container div.dt-layout-row div.dt-layout-cell:empty{display:none}@media screen and (max-width: 767px){div.dt-container div.dt-layout-row:not(.dt-layout-table){display:block}div.dt-container div.dt-layout-row:not(.dt-layout-table) div.dt-layout-cell{display:block;text-align:center}div.dt-container div.dt-layout-row:not(.dt-layout-table) div.dt-layout-cell>*{margin:.5em 0}div.dt-container div.dt-layout-row:not(.dt-layout-table) div.dt-layout-cell.dt-layout-start{margin-right:0}div.dt-container div.dt-layout-row:not(.dt-layout-table) div.dt-layout-cell.dt-layout-end{margin-left:0}}div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:1em}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:1em}div.dt-container div.dt-layout-full{width:100%}div.dt-container div.dt-layout-full>*:only-child{margin-left:auto;margin-right:auto}div.dt-container div.dt-layout-table>div{display:block !important}@media screen and (max-width: 767px){div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:0}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:0}}div.dt-container{position:relative;clear:both}div.dt-container .dt-search input{border:1px solid #aaa;border-radius:3px;padding:5px;background-color:transparent;color:inherit;margin-left:3px}div.dt-container .dt-input{border:1px solid #aaa;border-radius:3px;padding:5px;background-color:transparent;color:inherit}div.dt-container select.dt-input{padding:4px}div.dt-container .dt-paging .dt-paging-button{box-sizing:border-box;display:inline-block;min-width:1.5em;padding:.5em 1em;margin-left:2px;text-align:center;text-decoration:none !important;cursor:pointer;color:inherit !important;border:1px solid transparent;border-radius:2px;background:transparent}div.dt-container .dt-paging .dt-paging-button.current,div.dt-container .dt-paging .dt-paging-button.current:hover{color:inherit !important;border:1px solid rgba(0, 0, 0, 0.3);background-color:rgba(0, 0, 0, 0.05);background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(229.5, 229.5, 229.5, 0.05)), color-stop(100%, rgba(0, 0, 0, 0.05)));background:-webkit-linear-gradient(top, rgba(229.5, 229.5, 229.5, 0.05) 0%, rgba(0, 0, 0, 0.05) 100%);background:-moz-linear-gradient(top, rgba(229.5, 229.5, 229.5, 0.05) 0%, rgba(0, 0, 0, 0.05) 100%);background:-ms-linear-gradient(top, rgba(229.5, 229.5, 229.5, 0.05) 0%, rgba(0, 0, 0, 0.05) 100%);background:-o-linear-gradient(top, rgba(229.5, 229.5, 229.5, 0.05) 0%, rgba(0, 0, 0, 0.05) 100%);background:linear-gradient(to bottom, rgba(229.5, 229.5, 229.5, 0.05) 0%, rgba(0, 0, 0, 0.05) 100%)}div.dt-container .dt-paging .dt-paging-button.disabled,div.dt-container .dt-paging .dt-paging-button.disabled:hover,div.dt-container .dt-paging .dt-paging-button.disabled:active{cursor:default;color:rgba(0, 0, 0, 0.5) !important;border:1px solid transparent;background:transparent;box-shadow:none}div.dt-container .dt-paging .dt-paging-button:hover{color:white !important;border:1px solid #111;background-color:#111;background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, rgb(88.4, 88.4, 88.4)), color-stop(100%, #111));background:-webkit-linear-gradient(top, rgb(88.4, 88.4, 88.4) 0%, #111 100%);background:-moz-linear-gradient(top, rgb(88.4, 88.4, 88.4) 0%, #111 100%);background:-ms-linear-gradient(top, rgb(88.4, 88.4, 88.4) 0%, #111 100%);background:-o-linear-gradient(top, rgb(88.4, 88.4, 88.4) 0%, #111 100%);background:linear-gradient(to bottom, rgb(88.4, 88.4, 88.4) 0%, #111 100%)}div.dt-container .dt-paging .dt-paging-button:active{outline:none;background-color:rgb(11.9, 11.9, 11.9);background:-webkit-gradient(linear, left top, left bottom, color-stop(0%, rgb(42.5, 42.5, 42.5)), color-stop(100%, rgb(11.9, 11.9, 11.9)));background:-webkit-linear-gradient(top, rgb(42.5, 42.5, 42.5) 0%, rgb(11.9, 11.9, 11.9) 100%);background:-moz-linear-gradient(top, rgb(42.5, 42.5, 42.5) 0%, rgb(11.9, 11.9, 11.9) 100%);background:-ms-linear-gradient(top, rgb(42.5, 42.5, 42.5) 0%, rgb(11.9, 11.9, 11.9) 100%);background:-o-linear-gradient(top, rgb(42.5, 42.5, 42.5) 0%, rgb(11.9, 11.9, 11.9) 100%);background:linear-gradient(to bottom, rgb(42.5, 42.5, 42.5) 0%, rgb(11.9, 11.9, 11.9) 100%);box-shadow:inset 0 0 3px #111}div.dt-container .dt-paging .ellipsis{padding:0 1em}div.dt-container .dt-length,div.dt-container .dt-search,div.dt-container .dt-info,div.dt-container .dt-processing,div.dt-container .dt-paging{color:inherit}div.dt-container .dataTables_scroll{clear:both}div.dt-container .dataTables_scroll div.dt-scroll-body{-webkit-overflow-scrolling:touch}div.dt-container .dataTables_scroll div.dt-scroll-body>table>thead>tr>th,div.dt-container .dataTables_scroll div.dt-scroll-body>table>thead>tr>td,div.dt-container .dataTables_scroll div.dt-scroll-body>table>tbody>tr>th,div.dt-container .dataTables_scroll div.dt-scroll-body>table>tbody>tr>td{vertical-align:middle}div.dt-container .dataTables_scroll div.dt-scroll-body>table>thead>tr>th>div.dataTables_sizing,div.dt-container .dataTables_scroll div.dt-scroll-body>table>thead>tr>td>div.dataTables_sizing,div.dt-container .dataTables_scroll div.dt-scroll-body>table>tbody>tr>th>div.dataTables_sizing,div.dt-container .dataTables_scroll div.dt-scroll-body>table>tbody>tr>td>div.dataTables_sizing{height:0;overflow:hidden;margin:0 !important;padding:0 !important}div.dt-container.dt-empty-footer tbody>tr:last-child>*{border-bottom:1px solid rgba(0, 0, 0, 0.3)}div.dt-container.dt-empty-footer .dt-scroll-body{border-bottom:1px solid rgba(0, 0, 0, 0.3)}div.dt-container.dt-empty-footer .dt-scroll-body tbody>tr:last-child>*{border-bottom:none}html.dark{--dt-row-hover: 255, 255, 255;--dt-row-stripe: 255, 255, 255;--dt-column-ordering: 255, 255, 255}html.dark table.dataTable>thead>tr>th,html.dark table.dataTable>thead>tr>td{border-bottom:1px solid rgb(89, 91, 94)}html.dark table.dataTable>thead>tr>th:active,html.dark table.dataTable>thead>tr>td:active{outline:none}html.dark table.dataTable>tfoot>tr>th,html.dark table.dataTable>tfoot>tr>td{border-top:1px solid rgb(89, 91, 94)}html.dark table.dataTable.row-border>tbody>tr>*,html.dark table.dataTable.display>tbody>tr>*{border-top:1px solid rgb(64, 67, 70)}html.dark table.dataTable.row-border>tbody>tr:first-child>*,html.dark table.dataTable.display>tbody>tr:first-child>*{border-top:none}html.dark table.dataTable.row-border>tbody>tr.selected+tr.selected>td,html.dark table.dataTable.display>tbody>tr.selected+tr.selected>td{border-top-color:rgba(13, 110, 253, 0.65);border-top-color:rgba(var(--dt-row-selected), 0.65)}html.dark table.dataTable.cell-border>tbody>tr>th,html.dark table.dataTable.cell-border>tbody>tr>td{border-top:1px solid rgb(64, 67, 70);border-right:1px solid rgb(64, 67, 70)}html.dark table.dataTable.cell-border>tbody>tr>th:first-child,html.dark table.dataTable.cell-border>tbody>tr>td:first-child{border-left:1px solid rgb(64, 67, 70)}html.dark .dt-container.dt-empty-footer table.dataTable{border-bottom:1px solid rgb(89, 91, 94)}html.dark .dt-container .dt-search input,html.dark .dt-container .dt-length select{border:1px solid rgba(255, 255, 255, 0.2);background-color:var(--dt-html-background)}html.dark .dt-container .dt-paging .dt-paging-button.current,html.dark .dt-container .dt-paging .dt-paging-button.current:hover{border:1px solid rgb(89, 91, 94);background:rgba(255, 255, 255, 0.15)}html.dark .dt-container .dt-paging .dt-paging-button.disabled,html.dark .dt-container .dt-paging .dt-paging-button.disabled:hover,html.dark .dt-container .dt-paging .dt-paging-button.disabled:active{color:#666 !important}html.dark .dt-container .dt-paging .dt-paging-button:hover{border:1px solid rgb(53, 53, 53);background:rgb(53, 53, 53)}html.dark .dt-container .dt-paging .dt-paging-button:active{background:rgb(58.1, 58.1, 58.1)}*[dir=rtl] table.dataTable thead th,*[dir=rtl] table.dataTable thead td,*[dir=rtl] table.dataTable tfoot th,*[dir=rtl] table.dataTable tfoot td{text-align:right}*[dir=rtl] table.dataTable th.dt-type-numeric,*[dir=rtl] table.dataTable th.dt-type-date,*[dir=rtl] table.dataTable td.dt-type-numeric,*[dir=rtl] table.dataTable td.dt-type-date{text-align:left}*[dir=rtl] div.dt-container div.dt-layout-cell.dt-start{text-align:right}*[dir=rtl] div.dt-container div.dt-layout-cell.dt-end{text-align:left}*[dir=rtl] div.dt-container div.dt-search input{margin:0 3px 0 0} 14 | 15 | 16 | --------------------------------------------------------------------------------