32 Commits
0.5 ... 0.7.3

Author SHA1 Message Date
Roman Vynar
7a3bc551b3 Show the root namespace "library" in the dropdown even when there are no repos in it 2018-08-14 15:39:21 +03:00
Roman Vynar
7a752d3f8d Add version to UI 2018-08-14 15:37:21 +03:00
Roman Vynar
80bdad8c91 Switch to alpine:3.8 2018-08-14 15:36:54 +03:00
Roman Vynar
734afe56b5 Improve no data UI messages on empty tables. 2018-08-14 15:16:21 +03:00
Roman Vynar
0f6bf65015 Trim \n from password file. 2018-08-14 14:40:32 +03:00
Roman Vynar
7525e87c1a Merge pull request #13 from Area128/master
Allow reading password from file so that docker secrets can be used
2018-08-14 14:29:02 +03:00
Roman Vynar
97c189b195 Make web root accessible w/o trailing slash when base_path is configured. 2018-07-30 11:22:48 +03:00
Roman Vynar
2a0159b73e Fix panic when using MySQL for events storage and no table created yet 2018-07-18 18:16:37 +03:00
Roman Vynar
b563c6d1a1 Add note about deleting tags. 2018-07-10 16:55:50 +03:00
Antonios Vamporakis
1b8c502a60 Allow reading password from file so that docker secrets can be used 2018-07-07 11:05:14 +02:00
Roman Vynar
9597890b67 Prepare 0.7 release. 2018-07-04 17:57:29 +03:00
Roman Vynar
e1656debc5 Merge pull request #10 from uphy/cron
Add built-in cron feature.
2018-07-04 17:32:35 +03:00
Roman Vynar
d1ce70490c A bit refactoring. 2018-07-04 17:31:15 +03:00
Roman Vynar
e163b5af27 Merge branch 'master' of github.com:Quiq/docker-registry-ui 2018-07-04 16:47:58 +03:00
Roman Vynar
51c9a66195 Merge pull request #8 from uphy/master
Add feature to change the base path
2018-07-04 16:47:31 +03:00
Roman Vynar
4047019a3f Merge branch 'master' into master 2018-07-04 16:47:07 +03:00
Roman Vynar
0a268746c9 Merge branch 'master' of github.com:Quiq/docker-registry-ui 2018-07-04 16:38:22 +03:00
Roman Vynar
aaf65ed718 Merge pull request #11 from rmwpl/feature/nexus-compatibility
[feature/nexus-compatibility] Enable Sonatype Nexus compatibility
2018-07-04 16:38:06 +03:00
Roman Vynar
9a0674ea6b Bump go version to 1.10.3 2018-07-04 16:36:00 +03:00
Roman Vynar
34fe1742cf Show last events for repos containing / 2018-07-04 16:35:50 +03:00
Roman Vynar
0889821117 Merge pull request #12 from drExciter/url_encode_repos
URL encode repo variable in templates to support images with slash in…
2018-07-04 15:58:02 +03:00
drExciter
8aa9ac86f7 URL encode repo variable in templates to support images with slash in the name 2018-06-21 09:44:10 +02:00
Robert Wysocki
6b404abeaf [feature/nexus-compatibility] Enable Sonatype Nexus compatibility 2018-06-15 13:19:40 +01:00
Roman Vynar
16fd6d944f Last events were not shown when viewing a repo of non-default namespace. 2018-06-12 12:27:29 +03:00
Roman Vynar
4db9ceb0a6 When using MySQL for event storage, do not leak connections. 2018-06-12 12:19:13 +03:00
Yuhi Ishikura
a8d57564fa Add built-in cron feature. 2018-06-09 09:36:35 +09:00
Yuhi Ishikura
a5fc3a5d30 Add feature to change the base path. 2018-06-07 23:04:16 +09:00
Yuhi Ishikura
eabcf48494 Add example. 2018-06-07 21:46:53 +09:00
Roman Vynar
c5a5fe2e8d Add MySQL along with sqlite3 support as a registry events storage. 2018-05-28 15:36:27 +03:00
Roman Vynar
20daa971e5 Bump go and dependency versions. 2018-05-28 11:54:32 +03:00
Roman Vynar
aa8bdb41fc Improve intial sqlite db table creation. 2018-05-28 11:54:19 +03:00
Roman Vynar
217058cda7 Add a comment about --read-only option usage. 2018-05-16 17:34:12 +03:00
25 changed files with 731 additions and 268 deletions

35
CHANGELOG.md Normal file
View File

@@ -0,0 +1,35 @@
## Changelog
### 0.7.3 (2018-08-14)
* Add `registry_password_file` option to the config file.
* Improve no data message on empty tables on UI.
* Show the root namespace "library" in the dropdown even when there are no repos in it.
* Switch alpine Docker image to 3.8.
### 0.7.2 (2018-07-30)
* Make web root accessible w/o trailing slash when base_path is configured.
### 0.7.1 (2018-07-18)
* Fix panic when using MySQL for events storage and no table created yet.
### 0.7 (2018-07-04)
* When using MySQL for events storage, do not leak connections.
* Last events were not shown when viewing a repo of non-default namespace.
* Support repos with slash in the name.
* Enable Sonatype Nexus compatibility.
* Add `base_path` option to the config to run UI from non-root.
* Add built-in cron feature for purging tags task.
### 0.6 (2018-05-28)
* Add MySQL along with sqlite3 support as a registry events storage.
New config settings `event_database_driver`, `event_database_location`.
* Bump Go version and dependencies.
### 0.5 (2018-03-06)
* Initial public version.

View File

@@ -1,4 +1,4 @@
FROM golang:1.10.0-alpine3.7 as builder FROM golang:1.10.3-alpine as builder
ENV GOPATH /opt ENV GOPATH /opt
@@ -10,14 +10,15 @@ ADD glide.* /opt/src/github.com/quiq/docker-registry-ui/
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \ RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
/opt/bin/glide install /opt/bin/glide install
ADD events /opt/src/github.com/quiq/docker-registry-ui/events
ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry
ADD main.go /opt/src/github.com/quiq/docker-registry-ui/ ADD *.go /opt/src/github.com/quiq/docker-registry-ui/
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \ RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
go test -v ./registry && \ go test -v ./registry && \
go build -o /opt/docker-registry-ui github.com/quiq/docker-registry-ui go build -o /opt/docker-registry-ui github.com/quiq/docker-registry-ui
FROM alpine:3.7 FROM alpine:3.8
WORKDIR /opt WORKDIR /opt
RUN apk add --no-cache ca-certificates && \ RUN apk add --no-cache ca-certificates && \

View File

@@ -11,6 +11,7 @@
* Automatically discover an authentication method (basic auth, token service etc.) * Automatically discover an authentication method (basic auth, token service etc.)
* Caching the list of repositories, tag counts and refreshing in background * Caching the list of repositories, tag counts and refreshing in background
* Event listener of notification events coming from Registry * Event listener of notification events coming from Registry
* Store events in sqlite or MySQL database
* CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags * CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags
No TLS or authentication implemented on the UI web server itself. No TLS or authentication implemented on the UI web server itself.
@@ -24,7 +25,7 @@ The configuration is stored in `config.yml` and the options are self-descriptive
### Run UI ### Run UI
docker run -d -p 8000:8000 --read-only -v /local/config.yml:/opt/config.yml:ro \ docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro \
--name=registry-ui quiq/docker-registry-ui --name=registry-ui quiq/docker-registry-ui
To run with your own root CA certificate, add to the command: To run with your own root CA certificate, add to the command:
@@ -35,6 +36,9 @@ To preserve sqlite db file with event notifications data, add to the command:
-v /local/data:/opt/data -v /local/data:/opt/data
You can also run the container with `--read-only` option, however when using using event listener functionality
you need to ensure the sqlite db can be written, i.e. mount a folder as listed above.
## Configure event listener on Docker Registry ## Configure event listener on Docker Registry
To receive events you need to configure Registry as follow: To receive events you need to configure Registry as follow:
@@ -52,9 +56,33 @@ To receive events you need to configure Registry as follow:
- application/octet-stream - application/octet-stream
Adjust url and token as appropriate. Adjust url and token as appropriate.
If you are running UI from non-root base path, e.g. /ui, the URL path for above will be `/ui/api/events`.
## Using MySQL instead of sqlite3 for event listener
To use MySQL as a storage you need to change `event_database_driver` and `event_database_location`
settings in the config file. It is expected you create a database mentioned in the location DSN.
Minimal privileges are `SELECT`, `INSERT`, `DELETE`.
You can create a table manually if you don't want to grant `CREATE` permission:
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
action CHAR(4) NULL,
repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL,
user VARCHAR(50) NULL,
created DATETIME NULL
);
### Schedule a cron task for purging tags ### Schedule a cron task for purging tags
To delete tags you need to enable the corresponding option in Docker Registry config. For example:
storage:
delete:
enabled: true
The following example shows how to run a cron task to purge tags older than X days but also keep The following example shows how to run a cron task to purge tags older than X days but also keep
at least Y tags no matter how old. Assuming container has been already running. at least Y tags no matter how old. Assuming container has been already running.
@@ -64,6 +92,14 @@ You can try to run in dry-run mode first to see what is going to be purged:
docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run
Alternatively, you can schedule the purging task with built-in cron feature:
purge_tags_keep_days: 90
purge_tags_keep_count: 2
purge_tags_schedule: '0 10 3 * * *'
Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
### Debug mode ### Debug mode
To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`. To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`.

View File

@@ -1,5 +1,7 @@
# Listen interface. # Listen interface.
listen_addr: 0.0.0.0:8000 listen_addr: 0.0.0.0:8000
# Base path of Docker Registry UI.
base_path: /
# Registry URL with schema and port. # Registry URL with schema and port.
registry_url: https://docker-registry.local registry_url: https://docker-registry.local
@@ -10,8 +12,11 @@ verify_tls: true
# They need to have a full access to the registry. # They need to have a full access to the registry.
# If token authentication service is enabled, it will be auto-discovered and those credentials # If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens. # will be used to obtain access tokens.
# When the registry_password_file entry is used, the password can be passed as a docker secret
# and read from file. This overides the registry_password entry.
registry_username: user registry_username: user
registry_password: pass registry_password: pass
# registry_password_file: /run/secrets/registry_password_file
# Event listener token. # Event listener token.
# The same one should be configured on Docker registry as Authorization Bearer token. # The same one should be configured on Docker registry as Authorization Bearer token.
@@ -19,6 +24,12 @@ event_listener_token: token
# Retention of records to keep. # Retention of records to keep.
event_retention_days: 7 event_retention_days: 7
# Event listener storage.
event_database_driver: sqlite3
event_database_location: data/registry_events.db
# event_database_driver: mysql
# event_database_location: user:password@tcp(localhost:3306)/docker_events
# Cache refresh interval in minutes. # Cache refresh interval in minutes.
# How long to cache repository list and tag counts. # How long to cache repository list and tag counts.
cache_refresh_interval: 10 cache_refresh_interval: 10
@@ -32,7 +43,11 @@ admins: []
# Debug mode. Affects only templates. # Debug mode. Affects only templates.
debug: true debug: true
# CLI options.
# How many days to keep tags but also keep the minimal count provided no matter how old. # How many days to keep tags but also keep the minimal count provided no matter how old.
purge_tags_keep_days: 90 purge_tags_keep_days: 90
purge_tags_keep_count: 2 purge_tags_keep_count: 2
# Enable built-in cron to schedule purging tags in server mode.
# Empty string disables this feature.
# Example: '25 54 17 * * *' will run it at 17:54:25 daily.
# Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
purge_tags_schedule: ''

190
events/event_listener.go Normal file
View File

@@ -0,0 +1,190 @@
package events
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/hhkbp2/go-logging"
"github.com/quiq/docker-registry-ui/registry"
// 🐒 patching of "database/sql".
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
"github.com/tidwall/gjson"
)
const (
schemaSQLite = `
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action CHAR(4) NULL,
repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL,
user VARCHAR(50) NULL,
created DATETIME NULL
);
`
)
// EventListener event listener
type EventListener struct {
databaseDriver string
databaseLocation string
retention int
logger logging.Logger
}
type eventData struct {
Events []interface{} `json:"events"`
}
// EventRow event row from sqlite
type EventRow struct {
ID int
Action string
Repository string
Tag string
IP string
User string
Created string
}
// NewEventListener initialize EventListener.
func NewEventListener(databaseDriver, databaseLocation string, retention int) *EventListener {
return &EventListener{
databaseDriver: databaseDriver,
databaseLocation: databaseLocation,
retention: retention,
logger: registry.SetupLogging("events.event_listener"),
}
}
// ProcessEvents parse and store registry events
func (e *EventListener) ProcessEvents(request *http.Request) {
decoder := json.NewDecoder(request.Body)
var t eventData
if err := decoder.Decode(&t); err != nil {
e.logger.Errorf("Problem decoding event from request: %+v", request)
return
}
e.logger.Debugf("Received event: %+v", t)
j, _ := json.Marshal(t)
db, err := e.getDababaseHandler()
if err != nil {
e.logger.Error(err)
return
}
defer db.Close()
now := "DateTime('now')"
if e.databaseDriver == "mysql" {
now = "NOW()"
}
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
for _, i := range gjson.GetBytes(j, "events").Array() {
// Ignore calls by docker-registry-ui itself.
if i.Get("request.useragent").String() == "docker-registry-ui" {
continue
}
action := i.Get("action").String()
repository := i.Get("target.repository").String()
tag := i.Get("target.tag").String()
// Tag is empty in case of signed pull.
if tag == "" {
tag = i.Get("target.digest").String()
}
ip := strings.Split(i.Get("request.addr").String(), ":")[0]
user := i.Get("actor.name").String()
e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
res, err := stmt.Exec(action, repository, tag, ip, user)
if err != nil {
e.logger.Error("Error inserting a row: ", err)
return
}
id, _ := res.LastInsertId()
e.logger.Debug("New event added with id ", id)
}
// Purge old records.
var res sql.Result
if e.databaseDriver == "mysql" {
stmt, _ := db.Prepare("DELETE FROM events WHERE created < DATE_SUB(NOW(), INTERVAL ? DAY)")
res, _ = stmt.Exec(e.retention)
} else {
stmt, _ := db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)")
res, _ = stmt.Exec(fmt.Sprintf("-%d day", e.retention))
}
count, _ := res.RowsAffected()
e.logger.Debug("Rows deleted: ", count)
}
// GetEvents retrieve events from sqlite db
func (e *EventListener) GetEvents(repository string) []EventRow {
var events []EventRow
db, err := e.getDababaseHandler()
if err != nil {
e.logger.Error(err)
return events
}
defer db.Close()
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
if repository != "" {
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository)
}
rows, err := db.Query(query)
if err != nil {
e.logger.Error("Error selecting from table: ", err)
return events
}
defer rows.Close()
for rows.Next() {
var row EventRow
rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created)
events = append(events, row)
}
return events
}
func (e *EventListener) getDababaseHandler() (*sql.DB, error) {
firstRun := false
schema := schemaSQLite
if e.databaseDriver == "sqlite3" {
if _, err := os.Stat(e.databaseLocation); os.IsNotExist(err) {
firstRun = true
}
}
// Open db connection.
db, err := sql.Open(e.databaseDriver, e.databaseLocation)
if err != nil {
return nil, fmt.Errorf("Error opening %s db: %s", e.databaseDriver, err)
}
if e.databaseDriver == "mysql" {
schema = strings.Replace(schema, "AUTOINCREMENT", "AUTO_INCREMENT", 1)
rows, err := db.Query("SELECT * FROM events LIMIT 1")
if err != nil {
firstRun = true
}
if rows != nil {
rows.Close()
}
}
// Create table on first run.
if firstRun {
if _, err = db.Exec(schema); err != nil {
return nil, fmt.Errorf("Error creating a table: %s", err)
}
}
return db, nil
}

1
example/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/data

25
example/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Docker Registry UI Example
Start the local Docker Registry and UI.
```bash
$ docker-compose up -d
```
As an example, push the docker-registry-ui image to the local Docker Registry.
```bash
$ docker tag quiq/docker-registry-ui localhost/quiq/docker-registry-ui
$ docker push localhost/quiq/docker-registry-ui
The push refers to repository [localhost:5000/quiq/docker-registry-ui]
ab414a599bf8: Pushed
a8da33adf86e: Pushed
71a0e0a972a7: Pushed
96dc74eb5456: Pushed
ac362bf380d0: Pushed
04a094fe844e: Pushed
latest: digest: sha256:d88c1ca40986a358e59795992e87e364a0b3b97833aade5abcd79dda0a0477e8 size: 1571
```
Then you will find the pushed repository 'quiq/docker-registry-ui' in the following URL.
http://localhost/ui/quiq/docker-registry-ui

79
example/config/httpd.conf Normal file
View File

@@ -0,0 +1,79 @@
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule headers_module modules/mod_headers.so
LoadModule authn_file_module modules/mod_authn_file.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_groupfile_module modules/mod_authz_groupfile.so
LoadModule authz_user_module modules/mod_authz_user.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule auth_basic_module modules/mod_auth_basic.so
LoadModule access_compat_module modules/mod_access_compat.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
LoadModule unixd_module modules/mod_unixd.so
<IfModule unixd_module>
User daemon
Group daemon
</IfModule>
ServerAdmin you@example.com
ErrorLog /proc/self/fd/2
LogLevel warn
<IfModule log_config_module>
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %b" common
<IfModule logio_module>
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio
</IfModule>
CustomLog /proc/self/fd/1 common
</IfModule>
ServerRoot "/usr/local/apache2"
Listen 80
<Directory />
AllowOverride none
Require all denied
</Directory>
<VirtualHost *:80>
ServerName myregistrydomain.com
Header always set "Docker-Distribution-Api-Version" "registry/2.0"
Header onsuccess set "Docker-Distribution-Api-Version" "registry/2.0"
ProxyRequests off
ProxyPreserveHost on
# no proxy for /error/ (Apache HTTPd errors messages)
ProxyPass /error/ !
ProxyPass /v2 http://registry:5000/v2
ProxyPassReverse /v2 http://registry:5000/v2
<Location /v2>
Order deny,allow
Allow from all
</Location>
ProxyPass /ui/ http://registry-ui:8000/ui/
ProxyPassReverse /ui/ http://registry-ui:8000/ui/
<Location /ui>
Order deny,allow
Allow from all
</Location>
</VirtualHost>

View File

@@ -0,0 +1,46 @@
# Listen interface.
listen_addr: 0.0.0.0:8000
# Base path of Docker Registry UI.
base_path: /ui
# Registry URL with schema and port.
registry_url: http://registry:5000
# Verify TLS certificate when using https.
verify_tls: true
# Docker registry credentials.
# They need to have a full access to the registry.
# If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens.
# registry_username: user
# registry_password: pass
# Event listener token.
# The same one should be configured on Docker registry as Authorization Bearer token.
event_listener_token: token
# Retention of records to keep.
event_retention_days: 7
# Event listener storage.
event_database_driver: sqlite3
event_database_location: data/registry_events.db
# event_database_driver: mysql
# event_database_location: user:password@tcp(localhost:3306)/docker_events
# Cache refresh interval in minutes.
# How long to cache repository list and tag counts.
cache_refresh_interval: 10
# If users can delete tags. If set to False, then only admins listed below.
anyone_can_delete: false
# Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy.
admins: []
# Debug mode. Affects only templates.
debug: true
# CLI options.
# How many days to keep tags but also keep the minimal count provided no matter how old.
purge_tags_keep_days: 90
purge_tags_keep_count: 2

View File

@@ -0,0 +1,31 @@
version: "2.3"
services:
httpd:
image: httpd:2.4
ports:
- "80:80"
volumes:
- "./config/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro"
registry:
image: registry:2
ports:
- "5000"
volumes:
- "./data/registry:/var/lib/registry"
healthcheck:
test: ["CMD", "wget", "-s", "localhost:5000/v2/"]
interval: 5s
timeout: 10s
registry-ui:
image: quiq/docker-registry-ui:latest
ports:
- "8000"
volumes:
- "./data/registry-ui:/opt/data"
- "./config/registry-ui.yml:/opt/config.yml:ro"
depends_on:
registry:
condition: service_healthy

46
glide.lock generated
View File

@@ -1,66 +1,70 @@
hash: 29246065eafa5aaec8848881fb6c99995e91a3f9b0082724db71c71590937e29 hash: fea96c473a02b07acc1d600ee0f71c6a5143f34e5eb04a4c5e3e14378fca46f0
updated: 2018-02-19T16:47:40.847725+02:00 updated: 2018-06-09T09:03:51.972089+09:00
imports: imports:
- name: github.com/CloudyKit/fastprinter - name: github.com/CloudyKit/fastprinter
version: 74b38d55f37af5d6c05ca11147d616b613a3420e version: 74b38d55f37af5d6c05ca11147d616b613a3420e
- name: github.com/CloudyKit/jet - name: github.com/CloudyKit/jet
version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe
- name: github.com/dgrijalva/jwt-go - name: github.com/dgrijalva/jwt-go
version: a539ee1a749a2b895533f979515ac7e6e0f5b650 version: 06ea1031745cb8b3dab3f6a236daf2b0aa468b7e
- name: github.com/grafana/grafana - name: github.com/go-sql-driver/mysql
version: c4683f1ae85a9c80dfd04fce09318c466885f3c0 version: 64db0f7ebe171b596aa9b26f39a79f7413a3b617
subpackages:
- pkg/cmd/grafana-cli/logger
- name: github.com/hhkbp2/go-logging - name: github.com/hhkbp2/go-logging
version: 1bf77adfece4a2018ac4bcc84e1f20509157a534 version: 377ba05d98977baa2a0d9e13f13aac2f3a47ac4d
- name: github.com/hhkbp2/go-strftime - name: github.com/hhkbp2/go-strftime
version: d82166ec6782f870431668391c2e321069632fe7 version: d82166ec6782f870431668391c2e321069632fe7
- name: github.com/labstack/echo - name: github.com/labstack/echo
version: b338075a0fc6e1a0683dbf03d09b4957a289e26f version: 6d227dfea4d2e52cb76856120b3c17f758139b4e
subpackages: subpackages:
- middleware - middleware
- name: github.com/labstack/gommon - name: github.com/labstack/gommon
version: 779b8a8b9850a97acba6a3fe20feb628c39e17c1 version: 0a22a0df01a7c84944c607e8a6e91cfe421ea7ed
subpackages: subpackages:
- bytes - bytes
- color - color
- log - log
- random - random
- name: github.com/mattn/go-colorable - name: github.com/mattn/go-colorable
version: 3fa8c76f9daed4067e4a806fb7e4dc86455c6d6a version: efa589957cd060542a26d2dd7832fd6a6c6c3ade
- name: github.com/mattn/go-isatty - name: github.com/mattn/go-isatty
version: fc9e8d8ef48496124e79ae0df75490096eccf6fe version: 6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c
- name: github.com/mattn/go-sqlite3 - name: github.com/mattn/go-sqlite3
version: 6c771bb9887719704b210e87e934f08be014bdb1 version: 323a32be5a2421b8c7087225079c6c900ec397cd
- name: github.com/moul/http2curl - name: github.com/moul/http2curl
version: 4e24498b31dba4683efb9d35c1c8a91e2eda28c8 version: 9ac6cf4d929b2fa8fd2d2e6dec5bb0feb4f4911d
- name: github.com/parnurzeal/gorequest - name: github.com/parnurzeal/gorequest
version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3 version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3
- name: github.com/pkg/errors - name: github.com/pkg/errors
version: c605e284fe17294bda444b34710735b29d1a9d90 version: 816c9085562cd7ee03e7f8188a1cfd942858cded
- name: github.com/robfig/cron
version: b41be1df696709bb6395fe435af20370037c0b4c
- name: github.com/tidwall/gjson - name: github.com/tidwall/gjson
version: 87033efcaec6215741137e8ca61952c53ef2685d version: 01f00f129617a6fe98941fb920d6c760241b54d2
- name: github.com/tidwall/match - name: github.com/tidwall/match
version: 173748da739a410c5b0b813b956f89ff94730b4c version: 1731857f09b1f38450e2c12409748407822dc6be
- name: github.com/valyala/bytebufferpool - name: github.com/valyala/bytebufferpool
version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7 version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7
- name: github.com/valyala/fasttemplate - name: github.com/valyala/fasttemplate
version: dcecefd839c4193db0d35b88ec65b4c12d360ab0 version: dcecefd839c4193db0d35b88ec65b4c12d360ab0
- name: golang.org/x/crypto - name: golang.org/x/crypto
version: 4d70248d17d12d1edb7153434a74001c1540938b version: 1a580b3eff7814fc9b40602fd35256c63b50f491
subpackages: subpackages:
- acme - acme
- acme/autocert - acme/autocert
- name: golang.org/x/net - name: golang.org/x/net
version: 02ac38e2528ff4adea90f184d71a3faa04b4b1b0 version: dfa909b99c79129e1100513e5cd36307665e5723
subpackages: subpackages:
- publicsuffix - publicsuffix
- name: golang.org/x/sys - name: golang.org/x/sys
version: cd2c276457edda6df7fb04895d3fd6a6add42926 version: 7c87d13f8e835d2fb3a70a2912c811ed0c1d241b
subpackages: subpackages:
- unix - unix
- name: google.golang.org/appengine
version: b1f26356af11148e710935ed1ac8a7f5702c7612
subpackages:
- cloudsql
- name: gopkg.in/yaml.v2 - name: gopkg.in/yaml.v2
version: 3b4ad1db5b2a649883ff3782f5f9f6fb52be71af version: 5420a8b6744d3b0345ab293f6fcba19c978f1183
testImports: testImports:
- name: github.com/jtolds/gls - name: github.com/jtolds/gls
version: 9a4a02dbe491bef4bab3c24fd9f3087d6c4c6690 version: 9a4a02dbe491bef4bab3c24fd9f3087d6c4c6690

View File

@@ -3,16 +3,19 @@ import:
- package: github.com/CloudyKit/jet - package: github.com/CloudyKit/jet
version: v2.1.2 version: v2.1.2
- package: github.com/labstack/echo - package: github.com/labstack/echo
version: v3.2.6 version: v3.3.5
subpackages: subpackages:
- middleware - middleware
- package: github.com/parnurzeal/gorequest - package: github.com/parnurzeal/gorequest
version: v0.2.15 version: v0.2.15
- package: github.com/hhkbp2/go-logging - package: github.com/hhkbp2/go-logging
- package: github.com/tidwall/gjson - package: github.com/tidwall/gjson
version: v1.0.6 version: v1.1.0
- package: github.com/mattn/go-sqlite3 - package: github.com/mattn/go-sqlite3
version: 1.6.0 version: 1.7.0
- package: github.com/go-sql-driver/mysql
- package: github.com/robfig/cron
version: ~1.1.0
testImport: testImport:
- package: github.com/smartystreets/goconvey - package: github.com/smartystreets/goconvey
version: 1.6.2 version: 1.6.2

161
main.go
View File

@@ -3,7 +3,6 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
@@ -13,25 +12,32 @@ import (
"github.com/CloudyKit/jet" "github.com/CloudyKit/jet"
"github.com/labstack/echo" "github.com/labstack/echo"
"github.com/labstack/echo/middleware" "github.com/labstack/echo/middleware"
"github.com/quiq/docker-registry-ui/events"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/docker-registry-ui/registry"
"github.com/robfig/cron"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
type configData struct { type configData struct {
ListenAddr string `yaml:"listen_addr"` ListenAddr string `yaml:"listen_addr"`
RegistryURL string `yaml:"registry_url"` BasePath string `yaml:"base_path"`
VerifyTLS bool `yaml:"verify_tls"` RegistryURL string `yaml:"registry_url"`
Username string `yaml:"registry_username"` VerifyTLS bool `yaml:"verify_tls"`
Password string `yaml:"registry_password"` Username string `yaml:"registry_username"`
EventListenerToken string `yaml:"event_listener_token"` Password string `yaml:"registry_password"`
EventRetentionDays int `yaml:"event_retention_days"` PasswordFile string `yaml:"registry_password_file"`
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"` EventListenerToken string `yaml:"event_listener_token"`
AnyoneCanDelete bool `yaml:"anyone_can_delete"` EventRetentionDays int `yaml:"event_retention_days"`
Admins []string `yaml:"admins"` EventDatabaseDriver string `yaml:"event_database_driver"`
Debug bool `yaml:"debug"` EventDatabaseLocation string `yaml:"event_database_location"`
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"` CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"` AnyoneCanDelete bool `yaml:"anyone_can_delete"`
Admins []string `yaml:"admins"`
Debug bool `yaml:"debug"`
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
PurgeTagsSchedule string `yaml:"purge_tags_schedule"`
} }
type template struct { type template struct {
@@ -39,8 +45,9 @@ type template struct {
} }
type apiClient struct { type apiClient struct {
client *registry.Client client *registry.Client
config configData eventListener *events.EventListener
config configData
} }
func main() { func main() {
@@ -71,6 +78,26 @@ func main() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
// Normalize base path.
if a.config.BasePath != "" {
if !strings.HasPrefix(a.config.BasePath, "/") {
a.config.BasePath = "/" + a.config.BasePath
}
if strings.HasSuffix(a.config.BasePath, "/") {
a.config.BasePath = a.config.BasePath[0 : len(a.config.BasePath)-1]
}
}
// Read password from file.
if a.config.PasswordFile != "" {
if _, err := os.Stat(a.config.PasswordFile); os.IsNotExist(err) {
panic(err)
}
passwordBytes, err := ioutil.ReadFile(a.config.PasswordFile)
if err != nil {
panic(err)
}
a.config.Password = strings.TrimSuffix(string(passwordBytes[:]), "\n")
}
// Init registry API client. // Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password) a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
@@ -80,78 +107,57 @@ func main() {
// Execute CLI task and exit. // Execute CLI task and exit.
if purgeTags { if purgeTags {
registry.PurgeOldTags(a.client, purgeDryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount) a.purgeOldTags(purgeDryRun)
return return
} }
// Schedules to purge tags.
if a.config.PurgeTagsSchedule != "" {
c := cron.New()
task := func() {
a.purgeOldTags(purgeDryRun)
}
if err := c.AddFunc(a.config.PurgeTagsSchedule, task); err != nil {
panic(fmt.Errorf("Invalid schedule format: %s", a.config.PurgeTagsSchedule))
}
c.Start()
}
// Count tags in background. // Count tags in background.
go a.client.CountTags(a.config.CacheRefreshInterval) go a.client.CountTags(a.config.CacheRefreshInterval)
if a.config.EventDatabaseDriver != "sqlite3" && a.config.EventDatabaseDriver != "mysql" {
panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql"))
}
a.eventListener = events.NewEventListener(a.config.EventDatabaseDriver, a.config.EventDatabaseLocation, a.config.EventRetentionDays)
// Template engine init. // Template engine init.
view := jet.NewHTMLSet("templates")
view.SetDevelopmentMode(a.config.Debug)
view.AddGlobal("registryHost", u.Host)
view.AddGlobal("pretty_size", func(size interface{}) string {
var value float64
switch i := size.(type) {
case gjson.Result:
value = float64(i.Int())
case int64:
value = float64(i)
}
return registry.PrettySize(value)
})
view.AddGlobal("pretty_time", func(datetime interface{}) string {
return strings.Split(strings.Replace(datetime.(string), "T", " ", 1), ".")[0]
})
view.AddGlobal("parse_map", func(m interface{}) string {
var res string
for _, k := range registry.SortedMapKeys(m) {
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
}
return res
})
e := echo.New() e := echo.New()
e.Renderer = &template{View: view} e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath)
// Web routes. // Web routes.
e.Static("/static", "static") e.Static(a.config.BasePath+"/static", "static")
e.GET("/", a.viewRepositories) if a.config.BasePath != "" {
e.GET("/:namespace", a.viewRepositories) e.GET(a.config.BasePath, a.viewRepositories)
e.GET("/:namespace/:repo", a.viewTags) }
e.GET("/:namespace/:repo/:tag", a.viewTagInfo) e.GET(a.config.BasePath+"/", a.viewRepositories)
e.GET("/:namespace/:repo/:tag/delete", a.deleteTag) e.GET(a.config.BasePath+"/:namespace", a.viewRepositories)
e.GET("/events", a.viewLog) e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
e.GET(a.config.BasePath+"/events", a.viewLog)
// Protected event listener. // Protected event listener.
p := e.Group("/api") p := e.Group(a.config.BasePath + "/api")
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) { Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
return token == a.config.EventListenerToken, nil return token == a.config.EventListenerToken, nil
}), }),
})) }))
p.POST("/events", a.eventListener) p.POST("/events", a.receiveEvents)
e.Logger.Fatal(e.Start(a.config.ListenAddr)) e.Logger.Fatal(e.Start(a.config.ListenAddr))
} }
// Render render template.
func (r *template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
t, err := r.View.GetTemplate(name)
if err != nil {
panic(fmt.Errorf("Fatal error template file: %s", err))
}
vars, ok := data.(jet.VarMap)
if !ok {
vars = jet.VarMap{}
}
err = t.Execute(w, vars, nil)
if err != nil {
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
}
return nil
}
func (a *apiClient) viewRepositories(c echo.Context) error { func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace") namespace := c.Param("namespace")
if namespace == "" { if namespace == "" {
@@ -159,7 +165,6 @@ func (a *apiClient) viewRepositories(c echo.Context) error {
} }
repos, _ := a.client.Repositories(true)[namespace] repos, _ := a.client.Repositories(true)[namespace]
data := jet.VarMap{} data := jet.VarMap{}
data.Set("namespace", namespace) data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces()) data.Set("namespaces", a.client.Namespaces())
@@ -185,7 +190,8 @@ func (a *apiClient) viewTags(c echo.Context) error {
data.Set("repo", repo) data.Set("repo", repo)
data.Set("tags", tags) data.Set("tags", tags)
data.Set("deleteAllowed", deleteAllowed) data.Set("deleteAllowed", deleteAllowed)
data.Set("events", registry.GetEvents(repo)) repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data) return c.Render(http.StatusOK, "tags.html", data)
} }
@@ -201,7 +207,7 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false) sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
if infoV1 == "" || infoV2 == "" { if infoV1 == "" || infoV2 == "" {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
} }
var imageSize int64 var imageSize int64
@@ -261,7 +267,7 @@ func (a *apiClient) deleteTag(c echo.Context) error {
a.client.DeleteTag(repoPath, tag) a.client.DeleteTag(repoPath, tag)
} }
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
} }
// checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users. // checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users.
@@ -281,13 +287,18 @@ func (a *apiClient) checkDeletePermission(user string) bool {
// viewLog view events from sqlite. // viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error { func (a *apiClient) viewLog(c echo.Context) error {
data := jet.VarMap{} data := jet.VarMap{}
data.Set("events", registry.GetEvents("")) data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data) return c.Render(http.StatusOK, "event_log.html", data)
} }
// eventListener listen events from registry. // receiveEvents receive events.
func (a *apiClient) eventListener(c echo.Context) error { func (a *apiClient) receiveEvents(c echo.Context) error {
registry.ProcessEvents(c.Request(), a.config.EventRetentionDays) a.eventListener.ProcessEvents(c.Request())
return c.String(http.StatusOK, "OK") return c.String(http.StatusOK, "OK")
} }
// purgeOldTags purges old tags.
func (a *apiClient) purgeOldTags(dryRun bool) {
registry.PurgeOldTags(a.client, dryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount)
}

View File

@@ -38,7 +38,7 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
password: password, password: password,
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}), request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
logger: setupLogging("registry.client"), logger: SetupLogging("registry.client"),
tokens: map[string]string{}, tokens: map[string]string{},
repos: map[string][]string{}, repos: map[string][]string{},
tagCounts: map[string]int{}, tagCounts: map[string]int{},
@@ -69,7 +69,7 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
c.logger.Warn("No token auth service discovered from ", c.url) c.logger.Warn("No token auth service discovered from ", c.url)
return nil return nil
} }
} else if strings.HasPrefix(authHeader, "Basic") { } else if strings.HasPrefix(strings.ToLower(authHeader), "basic") {
c.request = c.request.SetBasicAuth(c.username, c.password) c.request = c.request.SetBasicAuth(c.username, c.password)
c.logger.Info("It was discovered the registry is configured with HTTP basic auth.") c.logger.Info("It was discovered the registry is configured with HTTP basic auth.")
} }
@@ -148,6 +148,9 @@ func (c *Client) Namespaces() []string {
for k := range c.repos { for k := range c.repos {
namespaces = append(namespaces, k) namespaces = append(namespaces, k)
} }
if !ItemInSlice("library", namespaces) {
namespaces = append(namespaces, "library")
}
sort.Strings(namespaces) sort.Strings(namespaces)
return namespaces return namespaces
} }

View File

@@ -8,8 +8,8 @@ import (
"github.com/hhkbp2/go-logging" "github.com/hhkbp2/go-logging"
) )
// setupLogging configure logging. // SetupLogging configure logging.
func setupLogging(name string) logging.Logger { func SetupLogging(name string) logging.Logger {
logger := logging.GetLogger(name) logger := logging.GetLogger(name)
handler := logging.NewStdoutHandler() handler := logging.NewStdoutHandler()
format := "%(asctime)s - %(name)s - %(levelname)s - %(message)s" format := "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
@@ -42,3 +42,13 @@ func PrettySize(size float64) string {
} }
return fmt.Sprintf("%.*f %s", 0, size, units[i]) return fmt.Sprintf("%.*f %s", 0, size, units[i])
} }
// ItemInSlice check if item is an element of slice
func ItemInSlice(item string, slice []string) bool {
for _, i := range slice {
if i == item {
return true
}
}
return false
}

View File

@@ -45,3 +45,13 @@ func TestPrettySize(t *testing.T) {
} }
}) })
} }
func TestItemInSlice(t *testing.T) {
a := []string{"abc", "def", "ghi"}
convey.Convey("Check whether element is in slice", t, func() {
convey.So(ItemInSlice("abc", a), convey.ShouldBeTrue)
convey.So(ItemInSlice("ghi", a), convey.ShouldBeTrue)
convey.So(ItemInSlice("abc1", a), convey.ShouldBeFalse)
convey.So(ItemInSlice("gh", a), convey.ShouldBeFalse)
})
}

View File

@@ -1,132 +0,0 @@
package registry
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
// 🐒 patching of "database/sql".
_ "github.com/mattn/go-sqlite3"
"github.com/tidwall/gjson"
)
const (
dbFile = "data/registry_events.db"
schema = `
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action CHAR(4) NULL,
repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL,
user VARCHAR(50) NULL,
created DATETIME NULL
);
`
)
type eventData struct {
Events []interface{} `json:"events"`
}
// EventRow event row from sqlite
type EventRow struct {
ID int
Action string
Repository string
Tag string
IP string
User string
Created time.Time
}
// ProcessEvents parse and store registry events
func ProcessEvents(request *http.Request, retention int) {
logger := setupLogging("registry.event_listener")
decoder := json.NewDecoder(request.Body)
var t eventData
if err := decoder.Decode(&t); err != nil {
logger.Errorf("Problem decoding event from request: %+v", request)
return
}
logger.Debugf("Received event: %+v", t)
j, _ := json.Marshal(t)
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
logger.Error("Error opening sqlite db: ", err)
return
}
defer db.Close()
_, err = db.Exec(schema)
if err != nil {
logger.Error("Error creating a table: ", err)
return
}
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?,DateTime('now'))")
for _, e := range gjson.GetBytes(j, "events").Array() {
// Ignore calls by docker-registry-ui itself.
if e.Get("request.useragent").String() == "docker-registry-ui" {
continue
}
action := e.Get("action").String()
repository := e.Get("target.repository").String()
tag := e.Get("target.tag").String()
// Tag is empty in case of signed pull.
if tag == "" {
tag = e.Get("target.digest").String()
}
ip := strings.Split(e.Get("request.addr").String(), ":")[0]
user := e.Get("actor.name").String()
logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
res, err := stmt.Exec(action, repository, tag, ip, user)
if err != nil {
logger.Error("Error inserting a row: ", err)
return
}
id, _ := res.LastInsertId()
logger.Debug("New event added with id ", id)
}
// Purge old records.
stmt, _ = db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)")
res, _ := stmt.Exec(fmt.Sprintf("-%d day", retention))
count, _ := res.RowsAffected()
logger.Debug("Rows deleted: ", count)
}
// GetEvents retrieve events from sqlite db
func GetEvents(repository string) []EventRow {
var events []EventRow
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
logger.Error("Error opening sqlite db: ", err)
return events
}
defer db.Close()
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
if repository != "" {
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository)
}
rows, err := db.Query(query)
if err != nil {
logger.Error("Error selecting from table: ", err)
return events
}
for rows.Next() {
var row EventRow
rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created)
events = append(events, row)
}
rows.Close()
return events
}

View File

@@ -34,7 +34,7 @@ func (p timeSlice) Swap(i, j int) {
// PurgeOldTags purge old tags. // PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) { func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) {
logger := setupLogging("registry.tasks.PurgeOldTags") logger := SetupLogging("registry.tasks.PurgeOldTags")
// Reduce client logging. // Reduce client logging.
client.logger.SetLevel(logging.LevelError) client.logger.SetLevel(logging.LevelError)

76
template.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
)
// Template Jet template.
type Template struct {
View *jet.Set
}
// Render render template.
func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
t, err := r.View.GetTemplate(name)
if err != nil {
panic(fmt.Errorf("Fatal error template file: %s", err))
}
vars, ok := data.(jet.VarMap)
if !ok {
vars = jet.VarMap{}
}
err = t.Execute(w, vars, nil)
if err != nil {
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
}
return nil
}
// setupRenderer template engine init.
func setupRenderer(debug bool, registryHost, basePath string) *Template {
view := jet.NewHTMLSet("templates")
view.SetDevelopmentMode(debug)
view.AddGlobal("version", version)
view.AddGlobal("basePath", basePath)
view.AddGlobal("registryHost", registryHost)
view.AddGlobal("pretty_size", func(size interface{}) string {
var value float64
switch i := size.(type) {
case gjson.Result:
value = float64(i.Int())
case int64:
value = float64(i)
}
return registry.PrettySize(value)
})
view.AddGlobal("pretty_time", func(datetime interface{}) string {
d := strings.Replace(datetime.(string), "T", " ", 1)
d = strings.Replace(d, "Z", "", 1)
return strings.Split(d, ".")[0]
})
view.AddGlobal("parse_map", func(m interface{}) string {
var res string
for _, k := range registry.SortedMapKeys(m) {
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
}
return res
})
view.AddGlobal("url_decode", func(m interface{}) string {
res, err := url.PathUnescape(m.(string))
if err != nil {
return m.(string)
}
return res
})
return &Template{View: view}
}

View File

@@ -15,15 +15,15 @@
<h2>Docker Registry UI</h2> <h2>Docker Registry UI</h2>
</div> </div>
<div style="float: right"> <div style="float: right">
<a href="/events">Event Log</a> <a href="{{ basePath }}/events">Event Log</a>
</div> </div>
<div style="clear: both"></div> <div style="clear: both"></div>
{{yield body()}} {{yield body()}}
<div style="padding: 10px 0; margin-bottom: 20px"> <div style="padding: 10px 0; margin-bottom: 20px">
<div style="float: left"> <div style="text-align: center; color:darkgrey">
&copy; 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a> Docker Registry UI v{{version}} &copy; 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,10 @@
$('#datatable').DataTable({ $('#datatable').DataTable({
"pageLength": 10, "pageLength": 10,
"order": [[ 4, 'desc' ]], "order": [[ 4, 'desc' ]],
"stateSave": true "stateSave": true,
"language": {
"emptyTable": "No events."
}
}); });
}); });
</script> </script>
@@ -14,7 +17,7 @@
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="/">Home</a></li> <li><a href="{{ basePath }}/">Home</a></li>
<li class="active">Event Log</li> <li class="active">Event Log</li>
</ol> </ol>
@@ -39,7 +42,7 @@
{{end}} {{end}}
<td>{{ e.IP }}</td> <td>{{ e.IP }}</td>
<td>{{ e.User }}</td> <td>{{ e.User }}</td>
<td>{{ e.Created }}</td> <td>{{ e.Created|pretty_time }}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

View File

@@ -3,19 +3,25 @@
{{block head()}} {{block head()}}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
$('#datatable').DataTable({
"pageLength": 25,
"stateSave": true
});
$('#namespace').on('change', function (e) { $('#namespace').on('change', function (e) {
window.location = '/' + this.value; window.location = '{{ basePath }}/' + this.value;
}); });
if (window.location.pathname == '/') { namespace = window.location.pathname;
namespace = namespace.replace("{{ basePath }}", "");
if (namespace == '/') {
namespace = 'library'; namespace = 'library';
} else { } else {
namespace = window.location.pathname.split('/')[1] namespace = namespace.split('/')[1]
} }
$('#namespace').val(namespace); $('#namespace').val(namespace);
$('#datatable').DataTable({
"pageLength": 25,
"stateSave": true,
"language": {
"emptyTable": "No repositories in \"" + namespace + "\" namespace."
}
});
}); });
</script> </script>
{{end}} {{end}}
@@ -23,15 +29,19 @@
{{block body()}} {{block body()}}
<div style="float: right"> <div style="float: right">
<select id="namespace" class="form-control input-sm" style="height: 36px"> <select id="namespace" class="form-control input-sm" style="height: 36px">
<option value="" disabled>-- Namespace --</option>
{{range namespace := namespaces}} {{range namespace := namespaces}}
<option value="{{ namespace }}">{{ namespace }}</option> <option value="{{ namespace }}">{{ namespace }}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div style="float: right">
<ol class="breadcrumb">
<li class="active">Namespace</li>
</ol>
</div>
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="/">Home</a></li> <li><a href="{{ basePath }}/">Home</a></li>
</ol> </ol>
<table id="datatable" class="table table-striped table-bordered"> <table id="datatable" class="table table-striped table-bordered">
@@ -44,7 +54,7 @@
<tbody> <tbody>
{{range repo := repos}} {{range repo := repos}}
<tr> <tr>
<td><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></td> <td><a href="{{ basePath }}/{{ namespace }}/{{ repo|url }}">{{ repo }}</a></td>
<td>{{ tagCounts[namespace+"/"+repo] }}</td> <td>{{ tagCounts[namespace+"/"+repo] }}</td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -4,11 +4,11 @@
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="/">Home</a></li> <li><a href="{{ basePath }}/">Home</a></li>
{{if namespace != "library"}} {{if namespace != "library"}}
<li><a href="/{{ namespace }}">{{ namespace }}</a></li> <li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
{{end}} {{end}}
<li><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></li> <li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li>
<li class="active">{{ tag }}</li> <li class="active">{{ tag }}</li>
</ol> </ol>
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">

View File

@@ -1,7 +1,7 @@
{{extends "base.html"}} {{extends "base.html"}}
{{block head()}} {{block head()}}
<script type="text/javascript" src="/static/bootstrap-confirmation.min.js"></script> <script type="text/javascript" src="{{ basePath }}/static/bootstrap-confirmation.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js"></script> <script type="text/javascript" src="https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js"></script>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
@@ -11,7 +11,10 @@
"stateSave": true, "stateSave": true,
columnDefs: [ columnDefs: [
{ type: 'natural', targets: 0 } { type: 'natural', targets: 0 }
] ],
"language": {
"emptyTable": "No tags in this repository."
}
}) })
function populateConfirmation() { function populateConfirmation() {
$('[data-toggle=confirmation]').confirmation({ $('[data-toggle=confirmation]').confirmation({
@@ -29,11 +32,11 @@
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="/">Home</a></li> <li><a href="{{ basePath }}/">Home</a></li>
{{if namespace != "library"}} {{if namespace != "library"}}
<li><a href="/{{ namespace }}">{{ namespace }}</a></li> <li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
{{end}} {{end}}
<li class="active">{{ repo }}</li> <li class="active">{{ repo|url_decode }}</li>
</ol> </ol>
<table id="datatable" class="table table-striped table-bordered"> <table id="datatable" class="table table-striped table-bordered">
@@ -46,9 +49,9 @@
{{range tag := tags}} {{range tag := tags}}
<tr> <tr>
<td> <td>
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a> <a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a>
{{if deleteAllowed}} {{if deleteAllowed}}
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a> <a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
{{end}} {{end}}
</td> </td>
</tr> </tr>
@@ -78,7 +81,7 @@
{{end}} {{end}}
<td>{{ e.IP }}</td> <td>{{ e.IP }}</td>
<td>{{ e.User }}</td> <td>{{ e.User }}</td>
<td>{{ e.Created }}</td> <td>{{ e.Created|pretty_time }}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

3
version.go Normal file
View File

@@ -0,0 +1,3 @@
package main
const version = "0.7.3"