├── 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 |
119 |
120 |
Access Denied
121 |
User "{{user}}" is not permitted to view the Event Log.
122 |
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 |