From 8174df6fd75fb1be9c65aa1982e9fc98bef33d17 Mon Sep 17 00:00:00 2001 From: Roman Vynar Date: Mon, 19 Feb 2018 17:12:59 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 + Dockerfile | 31 +++ LICENSE.md | 13 ++ README.md | 65 ++++++ config.yml | 38 ++++ data/README.md | 1 + glide.lock | 76 +++++++ glide.yaml | 20 ++ main.go | 293 +++++++++++++++++++++++++++ registry/client.go | 245 ++++++++++++++++++++++ registry/common.go | 44 ++++ registry/common_test.go | 47 +++++ registry/event_listener.go | 128 ++++++++++++ registry/tasks.go | 132 ++++++++++++ static/bootstrap-confirmation.min.js | 7 + templates/base.html | 31 +++ templates/event_log.html | 47 +++++ templates/repositories.html | 53 +++++ templates/tag_info.html | 85 ++++++++ templates/tags.html | 52 +++++ 20 files changed, 1411 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 config.yml create mode 100644 data/README.md create mode 100644 glide.lock create mode 100644 glide.yaml create mode 100644 main.go create mode 100644 registry/client.go create mode 100644 registry/common.go create mode 100644 registry/common_test.go create mode 100644 registry/event_listener.go create mode 100644 registry/tasks.go create mode 100755 static/bootstrap-confirmation.min.js create mode 100644 templates/base.html create mode 100644 templates/event_log.html create mode 100644 templates/repositories.html create mode 100644 templates/tag_info.html create mode 100644 templates/tags.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d21d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config-dev.yml +data/registry_events.db +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db1dfbb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.9.4-alpine3.7 as builder + +ENV GOPATH /opt + +RUN apk update && \ + apk add ca-certificates git build-base && \ + go get github.com/Masterminds/glide + +ADD glide.* /opt/src/github.com/quiq/docker-registry-ui/ +RUN cd /opt/src/github.com/quiq/docker-registry-ui && \ + /opt/bin/glide install + +ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry +ADD main.go /opt/src/github.com/quiq/docker-registry-ui/ +RUN cd /opt/src/github.com/quiq/docker-registry-ui && \ + go test -v ./registry && \ + go build -o /opt/docker-registry-ui github.com/quiq/docker-registry-ui + + +FROM alpine:3.7 + +WORKDIR /opt +RUN apk add --no-cache ca-certificates && \ + mkdir /opt/data + +ADD templates /opt/templates +ADD static /opt/static +COPY --from=builder /opt/docker-registry-ui /opt/ + +USER nobody +ENTRYPOINT ["/opt/docker-registry-ui"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9e48596 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright 2017-2018 Quiq Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7c54e2 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +## Docker Registry UI + +### Overview + +* Web UI for Docker Registry 2.6+ +* Browse repositories and tags +* Display Docker image details by layers including both manifests v1 and v2 +* Fast and small, written on Go +* Automatically discover an authentication method (basic auth, token service etc.) +* Caching the list of repositories, tag counts and refreshing in background +* Event listener of notification events coming from Registry +* 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. +Assuming you will proxy it behind nginx, oauth2_proxy or something. + +### Configuration + +The configuration is stored in `config.yml` and the options are self-descriptive. + +### Run UI + + docker run -d -p 8000:8000 --read-only -v /local/config.yml:/opt/config.yml:ro \ + --name=registry-ui quiq/docker-registry-ui + +To run with your own root CA certificate, add to the command: + + -v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro + +To preserve sqlite db file with event notifications data, add to the command: + + -v /local/data:/opt/data + +## Configure event listener on Docker Registry + +To receive events you need to configure Registry as follow: + + notifications: + endpoints: + - name: docker-registry-ui + url: http://docker-registry-ui.local:8000/api/events + headers: + Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890] + timeout: 1s + threshold: 5 + backoff: 10s + ignoredmediatypes: + - application/octet-stream + +Adjust url and token as appropriate. + +### Schedule a cron task for purging tags + +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. + + 10 3 * * * root docker exec -t registry-ui /opt/docker-registry-ui -purge-tags + +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 + +### Debug mode + +To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`. diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..af4ce40 --- /dev/null +++ b/config.yml @@ -0,0 +1,38 @@ +# Listen interface. +listen_addr: 0.0.0.0:8000 + +# Registry URL with schema and port. +registry_url: https://docker-registry.local +# 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 + +# 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 diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..d53ce8f --- /dev/null +++ b/data/README.md @@ -0,0 +1 @@ +Directory for sqlite db file `registry_events.db`. diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..9d34276 --- /dev/null +++ b/glide.lock @@ -0,0 +1,76 @@ +hash: 29246065eafa5aaec8848881fb6c99995e91a3f9b0082724db71c71590937e29 +updated: 2018-02-19T16:47:40.847725+02:00 +imports: +- name: github.com/CloudyKit/fastprinter + version: 74b38d55f37af5d6c05ca11147d616b613a3420e +- name: github.com/CloudyKit/jet + version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe +- name: github.com/dgrijalva/jwt-go + version: a539ee1a749a2b895533f979515ac7e6e0f5b650 +- name: github.com/grafana/grafana + version: c4683f1ae85a9c80dfd04fce09318c466885f3c0 + subpackages: + - pkg/cmd/grafana-cli/logger +- name: github.com/hhkbp2/go-logging + version: 1bf77adfece4a2018ac4bcc84e1f20509157a534 +- name: github.com/hhkbp2/go-strftime + version: d82166ec6782f870431668391c2e321069632fe7 +- name: github.com/labstack/echo + version: b338075a0fc6e1a0683dbf03d09b4957a289e26f + subpackages: + - middleware +- name: github.com/labstack/gommon + version: 779b8a8b9850a97acba6a3fe20feb628c39e17c1 + subpackages: + - bytes + - color + - log + - random +- name: github.com/mattn/go-colorable + version: 3fa8c76f9daed4067e4a806fb7e4dc86455c6d6a +- name: github.com/mattn/go-isatty + version: fc9e8d8ef48496124e79ae0df75490096eccf6fe +- name: github.com/mattn/go-sqlite3 + version: 6c771bb9887719704b210e87e934f08be014bdb1 +- name: github.com/moul/http2curl + version: 4e24498b31dba4683efb9d35c1c8a91e2eda28c8 +- name: github.com/parnurzeal/gorequest + version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3 +- name: github.com/pkg/errors + version: c605e284fe17294bda444b34710735b29d1a9d90 +- name: github.com/tidwall/gjson + version: 87033efcaec6215741137e8ca61952c53ef2685d +- name: github.com/tidwall/match + version: 173748da739a410c5b0b813b956f89ff94730b4c +- name: github.com/valyala/bytebufferpool + version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7 +- name: github.com/valyala/fasttemplate + version: dcecefd839c4193db0d35b88ec65b4c12d360ab0 +- name: golang.org/x/crypto + version: 4d70248d17d12d1edb7153434a74001c1540938b + subpackages: + - acme + - acme/autocert +- name: golang.org/x/net + version: 02ac38e2528ff4adea90f184d71a3faa04b4b1b0 + subpackages: + - publicsuffix +- name: golang.org/x/sys + version: cd2c276457edda6df7fb04895d3fd6a6add42926 + subpackages: + - unix +- name: gopkg.in/yaml.v2 + version: 3b4ad1db5b2a649883ff3782f5f9f6fb52be71af +testImports: +- name: github.com/jtolds/gls + version: 9a4a02dbe491bef4bab3c24fd9f3087d6c4c6690 +- name: github.com/smartystreets/assertions + version: 01fedaa993c0a9f9aa55111501cd7c81a49e812e + subpackages: + - internal/oglematchers +- name: github.com/smartystreets/goconvey + version: d4c757aa9afd1e2fc1832aaab209b5794eb336e1 + subpackages: + - convey + - convey/gotest + - convey/reporting diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..40251c5 --- /dev/null +++ b/glide.yaml @@ -0,0 +1,20 @@ +package: github.com/quiq/docker-registry-ui +import: +- package: github.com/CloudyKit/jet + version: v2.1.2 +- package: github.com/labstack/echo + version: v3.2.6 + subpackages: + - middleware +- package: github.com/parnurzeal/gorequest + version: v0.2.15 +- package: github.com/hhkbp2/go-logging +- package: github.com/tidwall/gjson + version: v1.0.6 +- package: github.com/mattn/go-sqlite3 + version: 1.6.0 +testImport: +- package: github.com/smartystreets/goconvey + version: 1.6.2 + subpackages: + - convey diff --git a/main.go b/main.go new file mode 100644 index 0000000..5699246 --- /dev/null +++ b/main.go @@ -0,0 +1,293 @@ +package main + +import ( + "flag" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + + "github.com/CloudyKit/jet" + "github.com/labstack/echo" + "github.com/labstack/echo/middleware" + "github.com/quiq/docker-registry-ui/registry" + "github.com/tidwall/gjson" + "gopkg.in/yaml.v2" +) + +type configData struct { + ListenAddr string `yaml:"listen_addr"` + RegistryURL string `yaml:"registry_url"` + VerifyTLS bool `yaml:"verify_tls"` + Username string `yaml:"registry_username"` + Password string `yaml:"registry_password"` + EventListenerToken string `yaml:"event_listener_token"` + EventRetentionDays int `yaml:"event_retention_days"` + CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"` + 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"` +} + +type template struct { + View *jet.Set +} + +type apiClient struct { + client *registry.Client + config configData +} + +func main() { + var ( + a apiClient + configFile string + purgeTags bool + purgeDryRun bool + ) + flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file") + flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server") + flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything") + flag.Parse() + + // Read config file. + if _, err := os.Stat(configFile); os.IsNotExist(err) { + panic(err) + } + bytes, err := ioutil.ReadFile(configFile) + if err != nil { + panic(err) + } + if err := yaml.Unmarshal(bytes, &a.config); err != nil { + panic(err) + } + // Validate registry URL. + u, err := url.Parse(a.config.RegistryURL) + if err != nil { + panic(err) + } + + // Init registry API client. + a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password) + if a.client == nil { + panic(fmt.Errorf("cannot initialize api client or unsupported auth method")) + } + + // Execute CLI task and exit. + if purgeTags { + registry.PurgeOldTags(a.client, purgeDryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount) + return + } + + // Count tags in background. + go a.client.CountTags(a.config.CacheRefreshInterval) + + // 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(`%s%v`, k, m.(map[string]interface{})[k]) + } + return res + }) + e := echo.New() + e.Renderer = &template{View: view} + + // Web routes. + e.Static("/static", "static") + e.GET("/", a.viewRepositories) + e.GET("/:namespace", a.viewRepositories) + e.GET("/:namespace/:repo", a.viewTags) + e.GET("/:namespace/:repo/:tag", a.viewTagInfo) + e.GET("/:namespace/:repo/:tag/delete", a.deleteTag) + e.GET("/events", a.viewLog) + + // Protected event listener. + p := e.Group("/api") + p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ + Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) { + return token == a.config.EventListenerToken, nil + }), + })) + p.POST("/events", a.eventListener) + + 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 { + namespace := c.Param("namespace") + if namespace == "" { + namespace = "library" + } + + repos, _ := a.client.Repositories(true)[namespace] + + data := jet.VarMap{} + data.Set("namespace", namespace) + data.Set("namespaces", a.client.Namespaces()) + data.Set("repos", repos) + data.Set("tagCounts", a.client.TagCounts()) + + return c.Render(http.StatusOK, "repositories.html", data) +} + +func (a *apiClient) viewTags(c echo.Context) error { + namespace := c.Param("namespace") + repo := c.Param("repo") + repoPath := repo + if namespace != "library" { + repoPath = fmt.Sprintf("%s/%s", namespace, repo) + } + + tags := a.client.Tags(repoPath) + deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) + + data := jet.VarMap{} + data.Set("namespace", namespace) + data.Set("repo", repo) + data.Set("tags", tags) + data.Set("deleteAllowed", deleteAllowed) + + return c.Render(http.StatusOK, "tags.html", data) +} + +func (a *apiClient) viewTagInfo(c echo.Context) error { + namespace := c.Param("namespace") + repo := c.Param("repo") + tag := c.Param("tag") + repoPath := repo + if namespace != "library" { + repoPath = fmt.Sprintf("%s/%s", namespace, repo) + } + + sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false) + if infoV1 == "" || infoV2 == "" { + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo)) + } + + var imageSize int64 + if gjson.Get(infoV2, "layers").Exists() { + for _, s := range gjson.Get(infoV2, "layers.#.size").Array() { + imageSize = imageSize + s.Int() + } + } else { + for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() { + imageSize = imageSize + gjson.Get(s.String(), "Size").Int() + } + } + + var layersV2 []map[string]gjson.Result + for _, s := range gjson.Get(infoV2, "layers").Array() { + layersV2 = append(layersV2, s.Map()) + } + + var layersV1 []map[string]interface{} + for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() { + m, _ := gjson.Parse(s.String()).Value().(map[string]interface{}) + // Sort key in the map to show the ordered on UI. + m["ordered_keys"] = registry.SortedMapKeys(m) + layersV1 = append(layersV1, m) + } + + layersCount := len(layersV2) + if layersCount == 0 { + layersCount = len(gjson.Get(infoV1, "fsLayers").Array()) + } + + data := jet.VarMap{} + data.Set("namespace", namespace) + data.Set("repo", repo) + data.Set("sha256", sha256) + data.Set("imageSize", imageSize) + data.Set("tag", gjson.Get(infoV1, "tag").String()) + data.Set("repoPath", gjson.Get(infoV1, "name").String()) + data.Set("created", gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()) + data.Set("layersCount", layersCount) + data.Set("layersV2", layersV2) + data.Set("layersV1", layersV1) + + return c.Render(http.StatusOK, "tag_info.html", data) +} + +func (a *apiClient) deleteTag(c echo.Context) error { + namespace := c.Param("namespace") + repo := c.Param("repo") + tag := c.Param("tag") + repoPath := repo + if namespace != "library" { + repoPath = fmt.Sprintf("%s/%s", namespace, repo) + } + + if a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) { + a.client.DeleteTag(repoPath, tag) + } + + return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo)) +} + +// checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users. +func (a *apiClient) checkDeletePermission(user string) bool { + deleteAllowed := a.config.AnyoneCanDelete + if !deleteAllowed { + for _, u := range a.config.Admins { + if u == user { + deleteAllowed = true + break + } + } + } + return deleteAllowed +} + +// viewLog view events from sqlite. +func (a *apiClient) viewLog(c echo.Context) error { + events := registry.GetEvents() + data := jet.VarMap{} + data.Set("events", events) + + return c.Render(http.StatusOK, "event_log.html", data) +} + +// eventListener listen events from registry. +func (a *apiClient) eventListener(c echo.Context) error { + registry.ProcessEvents(c.Request(), a.config.EventRetentionDays) + return c.String(http.StatusOK, "OK") +} diff --git a/registry/client.go b/registry/client.go new file mode 100644 index 0000000..41fe7f8 --- /dev/null +++ b/registry/client.go @@ -0,0 +1,245 @@ +package registry + +import ( + "crypto/tls" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/hhkbp2/go-logging" + "github.com/parnurzeal/gorequest" + "github.com/tidwall/gjson" +) + +// Client main class. +type Client struct { + url string + verifyTLS bool + username string + password string + request *gorequest.SuperAgent + logger logging.Logger + mux sync.Mutex + tokens map[string]string + repos map[string][]string + tagCounts map[string]int + authURL string +} + +// NewClient initialize Client. +func NewClient(url string, verifyTLS bool, username, password string) *Client { + c := &Client{ + url: strings.TrimRight(url, "/"), + verifyTLS: verifyTLS, + username: username, + password: password, + + request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}), + logger: setupLogging("registry.client"), + tokens: map[string]string{}, + repos: map[string][]string{}, + tagCounts: map[string]int{}, + } + resp, _, errs := c.request.Get(c.url+"/v2/").Set("User-Agent", "docker-registry-ui").End() + if len(errs) > 0 { + c.logger.Error(errs[0]) + return nil + } + + authHeader := "" + if resp.StatusCode == 200 { + return c + } else if resp.StatusCode == 401 { + authHeader = resp.Header.Get("WWW-Authenticate") + } else { + c.logger.Error(resp.Status) + return nil + } + + if strings.HasPrefix(authHeader, "Bearer") { + r, _ := regexp.Compile(`^Bearer realm="(http.+)",service="(.+)"`) + if m := r.FindStringSubmatch(authHeader); len(m) > 0 { + c.authURL = fmt.Sprintf("%s?service=%s", m[1], m[2]) + c.logger.Info("Token auth service discovered at ", c.authURL) + } + if c.authURL == "" { + c.logger.Warn("No token auth service discovered from ", c.url) + return nil + } + } else if strings.HasPrefix(authHeader, "Basic") { + c.request = c.request.SetBasicAuth(c.username, c.password) + c.logger.Info("It was discovered the registry is configured with HTTP basic auth.") + } + + return c +} + +// getToken get existing or new auth token. +func (c *Client) getToken(scope string) string { + // Check if we have already a token and it's not expired. + if token, ok := c.tokens[scope]; ok { + resp, _, _ := c.request.Get(c.url+"/v2/").Set("Authorization", fmt.Sprintf("Bearer %s", token)).Set("User-Agent", "docker-registry-ui").End() + if resp != nil && resp.StatusCode == 200 { + return token + } + } + + request := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !c.verifyTLS}) + resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).SetBasicAuth(c.username, c.password).Set("User-Agent", "docker-registry-ui").End() + if len(errs) > 0 { + c.logger.Error(errs[0]) + return "" + } + if resp.StatusCode != 200 { + c.logger.Error("Failed to get token for scope ", scope, " from ", c.authURL) + return "" + } + + c.tokens[scope] = gjson.Get(data, "token").String() + c.logger.Info("Received new token for scope ", scope) + + return c.tokens[scope] +} + +// callRegistry make an HTTP request to Docker registry. +func (c *Client) callRegistry(uri, scope string, manifest uint, delete bool) (rdata, rdigest string) { + acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.manifest.v%d+json", manifest) + authHeader := "" + if c.authURL != "" { + authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope)) + } + + resp, data, errs := c.request.Get(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End() + if len(errs) > 0 { + c.logger.Error(errs[0]) + return "", "" + } + + c.logger.Info("GET ", uri, " ", resp.Status) + // Returns 404 when no tags in the repo. + if resp.StatusCode != 200 { + return "", "" + } + digest := resp.Header.Get("Docker-Content-Digest") + + if delete { + // Delete by manifest digest reference. + parts := strings.Split(uri, "/manifests/") + uri = parts[0] + "/manifests/" + digest + resp, _, errs := c.request.Delete(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End() + if len(errs) > 0 { + c.logger.Error(errs[0]) + } else { + // Returns 202 on success. + c.logger.Info("DELETE ", uri, " (", parts[1], ") ", resp.Status) + } + return "", "" + } + + return data, digest +} + +// Namespaces list repo namespaces. +func (c *Client) Namespaces() []string { + namespaces := make([]string, 0, len(c.repos)) + for k := range c.repos { + namespaces = append(namespaces, k) + } + sort.Strings(namespaces) + return namespaces +} + +// Repositories list repos by namespaces where 'library' is the default one. +func (c *Client) Repositories(useCache bool) map[string][]string { + // Return from cache if available. + if len(c.repos) > 0 && useCache { + return c.repos + } + + c.mux.Lock() + defer c.mux.Unlock() + scope := "registry:catalog:*" + data, _ := c.callRegistry("/v2/_catalog", scope, 2, false) + if data == "" { + return c.repos + } + + c.repos = map[string][]string{} + for _, r := range gjson.Get(data, "repositories").Array() { + namespace := "library" + repo := r.String() + if strings.Contains(repo, "/") { + f := strings.SplitN(repo, "/", 2) + namespace = f[0] + repo = f[1] + } + c.repos[namespace] = append(c.repos[namespace], repo) + } + + return c.repos +} + +// Tags get tags for the repo. +func (c *Client) Tags(repo string) []string { + scope := fmt.Sprintf("repository:%s:*", repo) + data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, 2, false) + var tags []string + for _, t := range gjson.Get(data, "tags").Array() { + tags = append(tags, t.String()) + } + return tags +} + +// TagInfo get image info for the repo tag. +func (c *Client) TagInfo(repo, tag string, v1only bool) (rsha256, rinfoV1, rinfoV2 string) { + scope := fmt.Sprintf("repository:%s:*", repo) + infoV1, _ := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 1, false) + if infoV1 == "" { + return "", "", "" + } + + if v1only { + return "", infoV1, "" + } + + infoV2, digest := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, false) + if infoV2 == "" || digest == "" { + return "", "", "" + } + + sha256 := digest[7:] + return sha256, infoV1, infoV2 +} + +// TagCounts return map with tag counts. +func (c *Client) TagCounts() map[string]int { + return c.tagCounts +} + +// CountTags count repository tags in background regularly. +func (c *Client) CountTags(interval uint8) { + for { + c.logger.Info("Calculating tags in background...") + catalog := c.Repositories(false) + for n, repos := range catalog { + for _, r := range repos { + repoPath := r + if n != "library" { + repoPath = fmt.Sprintf("%s/%s", n, r) + } + c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath)) + } + } + c.logger.Info("Tags calculation complete.") + time.Sleep(time.Duration(interval) * time.Minute) + } +} + +// DeleteTag delete image tag. +func (c *Client) DeleteTag(repo, tag string) { + scope := fmt.Sprintf("repository:%s:*", repo) + c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, true) +} diff --git a/registry/common.go b/registry/common.go new file mode 100644 index 0000000..c683b33 --- /dev/null +++ b/registry/common.go @@ -0,0 +1,44 @@ +package registry + +import ( + "fmt" + "reflect" + "sort" + + "github.com/hhkbp2/go-logging" +) + +// setupLogging configure logging. +func setupLogging(name string) logging.Logger { + logger := logging.GetLogger(name) + handler := logging.NewStdoutHandler() + format := "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + dateFormat := "%Y-%m-%d %H:%M:%S" + formatter := logging.NewStandardFormatter(format, dateFormat) + handler.SetFormatter(formatter) + logger.SetLevel(logging.LevelInfo) + logger.AddHandler(handler) + return logger +} + +// SortedMapKeys sort keys of the map where values can be of any type. +func SortedMapKeys(m interface{}) []string { + v := reflect.ValueOf(m) + keys := make([]string, 0, len(v.MapKeys())) + for _, key := range v.MapKeys() { + keys = append(keys, key.String()) + } + sort.Strings(keys) + return keys +} + +// PrettySize format bytes in more readable units. +func PrettySize(size float64) string { + units := []string{"B", "KB", "MB", "GB"} + i := 0 + for size > 1024 && i < len(units) { + size = size / 1024 + i = i + 1 + } + return fmt.Sprintf("%.*f %s", 0, size, units[i]) +} diff --git a/registry/common_test.go b/registry/common_test.go new file mode 100644 index 0000000..cd4fc17 --- /dev/null +++ b/registry/common_test.go @@ -0,0 +1,47 @@ +package registry + +import ( + "testing" + "time" + + "github.com/smartystreets/goconvey/convey" +) + +func TestSortedMapKeys(t *testing.T) { + a := map[string]string{ + "foo": "bar", + "abc": "bar", + "zoo": "bar", + } + b := map[string]timeSlice{ + "zoo": []tagData{tagData{name: "1", created: time.Now()}}, + "abc": []tagData{tagData{name: "1", created: time.Now()}}, + "foo": []tagData{tagData{name: "1", created: time.Now()}}, + } + c := map[string][]string{ + "zoo": []string{"1", "2"}, + "foo": []string{"1", "2"}, + "abc": []string{"1", "2"}, + } + expect := []string{"abc", "foo", "zoo"} + convey.Convey("Sort map keys", t, func() { + convey.So(SortedMapKeys(a), convey.ShouldResemble, expect) + convey.So(SortedMapKeys(b), convey.ShouldResemble, expect) + convey.So(SortedMapKeys(c), convey.ShouldResemble, expect) + }) +} + +func TestPrettySize(t *testing.T) { + convey.Convey("Format bytes", t, func() { + input := map[float64]string{ + 123: "123 B", + 23123: "23 KB", + 23923: "23 KB", + 723425120: "690 MB", + 8534241213: "8 GB", + } + for key, val := range input { + convey.So(PrettySize(key), convey.ShouldEqual, val) + } + }) +} diff --git a/registry/event_listener.go b/registry/event_listener.go new file mode 100644 index 0000000..7627039 --- /dev/null +++ b/registry/event_listener.go @@ -0,0 +1,128 @@ +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() []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() + + rows, err := db.Query("SELECT * FROM events ORDER BY id DESC LIMIT 1000") + 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 +} diff --git a/registry/tasks.go b/registry/tasks.go new file mode 100644 index 0000000..0656e02 --- /dev/null +++ b/registry/tasks.go @@ -0,0 +1,132 @@ +package registry + +import ( + "fmt" + "sort" + "time" + + "github.com/hhkbp2/go-logging" + "github.com/tidwall/gjson" +) + +type tagData struct { + name string + created time.Time +} + +func (t tagData) String() string { + return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05")) +} + +type timeSlice []tagData + +func (p timeSlice) Len() int { + return len(p) +} + +func (p timeSlice) Less(i, j int) bool { + return p[i].created.After(p[j].created) +} + +func (p timeSlice) Swap(i, j int) { + p[i], p[j] = p[j], p[i] +} + +// PurgeOldTags purge old tags. +func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) { + logger := setupLogging("registry.tasks.PurgeOldTags") + // Reduce client logging. + client.logger.SetLevel(logging.LevelError) + + dryRunText := "" + if purgeDryRun { + logger.Warn("Dry-run mode enabled.") + dryRunText = "skipped" + } + logger.Info("Scanning registry for repositories, tags and their creation dates...") + catalog := client.Repositories(true) + // catalog := map[string][]string{"library": []string{""}} + now := time.Now().UTC() + repos := map[string]timeSlice{} + count := 0 + for namespace := range catalog { + count = count + len(catalog[namespace]) + for _, repo := range catalog[namespace] { + if namespace != "library" { + repo = fmt.Sprintf("%s/%s", namespace, repo) + } + + tags := client.Tags(repo) + logger.Infof("[%s] scanning %d tags...", repo, len(tags)) + if len(tags) == 0 { + continue + } + for _, tag := range tags { + _, infoV1, _ := client.TagInfo(repo, tag, true) + if infoV1 == "" { + logger.Errorf("[%s] manifest missed for tag %s", repo, tag) + continue + } + created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").Time() + repos[repo] = append(repos[repo], tagData{name: tag, created: created}) + } + } + } + + logger.Infof("Scanned %d repositories.", count) + logger.Info("Filtering out tags for purging...") + purgeTags := map[string][]string{} + keepTags := map[string][]string{} + count = 0 + for _, repo := range SortedMapKeys(repos) { + // Sort tags by "created" from newest to oldest. + sortedTags := make(timeSlice, 0, len(repos[repo])) + for _, d := range repos[repo] { + sortedTags = append(sortedTags, d) + } + sort.Sort(sortedTags) + repos[repo] = sortedTags + + // Filter out tags by retention days. + for _, tag := range repos[repo] { + delta := int(now.Sub(tag.created).Hours() / 24) + if delta > purgeTagsKeepDays { + purgeTags[repo] = append(purgeTags[repo], tag.name) + } else { + keepTags[repo] = append(keepTags[repo], tag.name) + } + } + + // Keep minimal count of tags no matter how old they are. + if len(repos[repo])-len(purgeTags[repo]) < purgeTagsKeepCount { + if len(purgeTags[repo]) > purgeTagsKeepCount { + keepTags[repo] = append(keepTags[repo], purgeTags[repo][:purgeTagsKeepCount]...) + purgeTags[repo] = purgeTags[repo][purgeTagsKeepCount:] + } else { + keepTags[repo] = append(keepTags[repo], purgeTags[repo]...) + delete(purgeTags, repo) + } + } + + count = count + len(purgeTags[repo]) + logger.Infof("[%s] All %d: %v", repo, len(repos[repo]), repos[repo]) + logger.Infof("[%s] Keep %d: %v", repo, len(keepTags[repo]), keepTags[repo]) + logger.Infof("[%s] Purge %d: %v", repo, len(purgeTags[repo]), purgeTags[repo]) + } + + logger.Infof("There are %d tags to purge.", count) + if count > 0 { + logger.Info("Purging old tags...") + } + + for _, repo := range SortedMapKeys(purgeTags) { + logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText) + if purgeDryRun { + continue + } + for _, tag := range purgeTags[repo] { + client.DeleteTag(repo, tag) + } + } + logger.Info("Done.") +} diff --git a/static/bootstrap-confirmation.min.js b/static/bootstrap-confirmation.min.js new file mode 100755 index 0000000..45755b6 --- /dev/null +++ b/static/bootstrap-confirmation.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap Confirmation 2.4.0 + * Copyright 2013 Nimit Suwannagate + * Copyright 2014-2016 Damien "Mistic" Sorel + * Licensed under the Apache License, Version 2.0 + */ +!function($){"use strict";function a(a){for(var b=window,c=a.split("."),d=c.pop(),e=0,f=c.length;e

'}),c.prototype=$.extend({},$.fn.popover.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.init=function(a,b){if($.fn.popover.Constructor.prototype.init.call(this,"confirmation",a,b),(this.options.popout||this.options.singleton)&&!b.rootSelector)throw new Error("The rootSelector option is required to use popout and singleton features since jQuery 3.");this.options._isDelegate=!1,b.selector?this.options._selector=this._options._selector=b.rootSelector+" "+b.selector:b._selector?(this.options._selector=b._selector,this.options._isDelegate=!0):this.options._selector=b.rootSelector;var c=this;this.options.selector?this.$element.on(this.options.trigger,this.options.selector,function(a,b){b||(a.preventDefault(),a.stopPropagation(),a.stopImmediatePropagation())}):(this.options._attributes={},this.options.copyAttributes?"string"==typeof this.options.copyAttributes&&(this.options.copyAttributes=this.options.copyAttributes.split(" ")):this.options.copyAttributes=[],this.options.copyAttributes.forEach(function(a){this.options._attributes[a]=this.$element.attr(a)},this),this.$element.on(this.options.trigger,function(a,b){b||(a.preventDefault(),a.stopPropagation(),a.stopImmediatePropagation())}),this.$element.on("show.bs.confirmation",function(a){c.options.singleton&&$(c.options._selector).not($(this)).filter(function(){return void 0!==$(this).data("bs.confirmation")}).confirmation("hide")})),this.options._isDelegate||(this.eventBody=!1,this.uid=this.$element[0].id||this.getUID("group_"),this.$element.on("shown.bs.confirmation",function(a){c.options.popout&&!c.eventBody&&(c.eventBody=$("body").on("click.bs.confirmation."+c.uid,function(a){$(c.options._selector).is(a.target)||($(c.options._selector).filter(function(){return void 0!==$(this).data("bs.confirmation")}).confirmation("hide"),$("body").off("click.bs."+c.uid),c.eventBody=!1)}))}))},c.prototype.hasContent=function(){return!0},c.prototype.setContent=function(){var a=this,c=this.tip(),d=this.getTitle(),e=this.getContent();if(c.find(".popover-title")[this.options.html?"html":"text"](d),c.find(".confirmation-content").toggle(!!e).children().detach().end()[this.options.html?"string"==typeof e?"html":"append":"text"](e),c.on("click",function(a){a.stopPropagation()}),this.options.buttons){var f=c.find(".confirmation-buttons .btn-group").empty();this.options.buttons.forEach(function(b){f.append($('').addClass(b["class"]||"btn btn-xs btn-default").html(b.label||"").attr(b.attr||{}).prepend($("").addClass(b.icon)," ").one("click",function(c){"#"===$(this).attr("href")&&c.preventDefault(),b.onClick&&b.onClick.call(a.$element),b.cancel?(a.getOnCancel.call(a).call(a.$element),a.$element.trigger("canceled.bs.confirmation")):(a.getOnConfirm.call(a).call(a.$element),a.$element.trigger("confirmed.bs.confirmation")),a.inState&&(a.inState.click=!1),a.hide()}))},this)}else c.find('[data-apply="confirmation"]').addClass(this.options.btnOkClass).html(this.options.btnOkLabel).attr(this.options._attributes).prepend($("").addClass(this.options.btnOkIcon)," ").off("click").one("click",function(b){"#"===$(this).attr("href")&&b.preventDefault(),a.getOnConfirm.call(a).call(a.$element),a.$element.trigger("confirmed.bs.confirmation"),a.$element.trigger(a.options.trigger,[!0]),a.hide()}),c.find('[data-dismiss="confirmation"]').addClass(this.options.btnCancelClass).html(this.options.btnCancelLabel).prepend($("").addClass(this.options.btnCancelIcon)," ").off("click").one("click",function(b){b.preventDefault(),a.getOnCancel.call(a).call(a.$element),a.$element.trigger("canceled.bs.confirmation"),a.inState&&(a.inState.click=!1),a.hide()});c.removeClass("fade top bottom left right in"),c.find(".popover-title").html()||c.find(".popover-title").hide(),b=this,$(window).off("keyup.bs.confirmation").on("keyup.bs.confirmation",this._onKeyup.bind(this))},c.prototype.destroy=function(){b===this&&(b=void 0,$(window).off("keyup.bs.confirmation")),$.fn.popover.Constructor.prototype.destroy.call(this)},c.prototype.hide=function(){b===this&&(b=void 0,$(window).off("keyup.bs.confirmation")),$.fn.popover.Constructor.prototype.hide.call(this)},c.prototype._onKeyup=function(a){if(!this.$tip)return b=void 0,void $(window).off("keyup.bs.confirmation");var d,e=a.key||c.KEYMAP[a.keyCode||a.which],f=this.$tip.find(".confirmation-buttons .btn-group"),g=f.find(".active");switch(e){case"Escape":this.hide();break;case"ArrowRight":d=g.length&&g.next().length?g.next():f.children().first(),g.removeClass("active"),d.addClass("active").focus();break;case"ArrowLeft":d=g.length&&g.prev().length?g.prev():f.children().last(),g.removeClass("active"),d.addClass("active").focus()}},c.prototype.getOnConfirm=function(){return this.$element.attr("data-on-confirm")?a(this.$element.attr("data-on-confirm")):this.options.onConfirm},c.prototype.getOnCancel=function(){return this.$element.attr("data-on-cancel")?a(this.$element.attr("data-on-cancel")):this.options.onCancel};var d=$.fn.confirmation;$.fn.confirmation=function(a){var b="object"==typeof a&&a||{};return b.rootSelector=this.selector||b.rootSelector,this.each(function(){var d=$(this),e=d.data("bs.confirmation");(e||"destroy"!=a)&&(e||d.data("bs.confirmation",e=new c(this,b)),"string"==typeof a&&(e[a](),"hide"==a&&e.inState&&(e.inState.click=!1)))})},$.fn.confirmation.Constructor=c,$.fn.confirmation.noConflict=function(){return $.fn.confirmation=d,this}}(jQuery); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..579fa3d --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ + + + + + + + Docker Registry UI + + + {{yield head()}} + + +
+
+

Docker Registry UI

+
+
+ Event Log +
+
+ + {{yield body()}} + +
+
+ © 2017-2018 Quiq Inc. +
+
+
+ + diff --git a/templates/event_log.html b/templates/event_log.html new file mode 100644 index 0000000..c01d9bd --- /dev/null +++ b/templates/event_log.html @@ -0,0 +1,47 @@ +{{extends "base.html"}} + +{{block head()}} + +{{end}} + +{{block body()}} + + + + + + + + + + + + + + {{range e := events}} + + + {{if hasPrefix(e.Tag,"sha256") }} + + {{else}} + + {{end}} + + + + + {{end}} + +
ActionImageIP AddressUserTime
{{ e.Action }}{{ e.Repository }}@{{ e.Tag[:12] }}.....{{ e.Tag[66:] }}{{ e.Repository }}:{{ e.Tag }}{{ e.IP }}{{ e.User }}{{ e.Created }}
+{{end}} diff --git a/templates/repositories.html b/templates/repositories.html new file mode 100644 index 0000000..d2f998d --- /dev/null +++ b/templates/repositories.html @@ -0,0 +1,53 @@ +{{extends "base.html"}} + +{{block head()}} + +{{end}} + +{{block body()}} +
+ +
+ + + + + + + + + + + + {{range repo := repos}} + + + + + {{end}} + +
RepositoryTags
{{ repo }}{{ tagCounts[namespace+"/"+repo] }}
+{{end}} diff --git a/templates/tag_info.html b/templates/tag_info.html new file mode 100644 index 0000000..299c111 --- /dev/null +++ b/templates/tag_info.html @@ -0,0 +1,85 @@ +{{extends "base.html"}} + +{{block head()}}{{end}} + +{{block body()}} + + + + + + + + + + + + + + + + + + + + + + +
Image Details
Image{{ registryHost }}/{{ repoPath }}:{{ tag }}
sha256{{ sha256 }}
Created On{{ created|pretty_time }}
Image Size{{ imageSize|pretty_size }}
Layer Count{{ layersCount }}
+ +{{if layersV2}} +

Manifest v2

+ + + + + + + + +{{range index, layer := layersV2}} + + + + + +{{end}} +
Layer #DigestSize
{{ len(layersV2)-index }}{{ layer["digest"] }}{{ layer["size"]|pretty_size }}
+{{end}} + +

Manifest v1

+{{range index, layer := layersV1}} + + + + + + + {{range key := layer["ordered_keys"]}} + + + {{if key == "config" || key == "container_config"}} + + {{else if key == "created"}} + + {{else}} + + {{end}} + + {{end}} +
Layer #{{ len(layersV1)-index }}
{{ key }} + + + {{ layer[key]|parse_map|raw }} +
+
{{ layer[key]|pretty_time }}{{ layer[key] }}
+{{end}} + +{{end}} diff --git a/templates/tags.html b/templates/tags.html new file mode 100644 index 0000000..6cc628f --- /dev/null +++ b/templates/tags.html @@ -0,0 +1,52 @@ +{{extends "base.html"}} + +{{block head()}} + + +{{end}} + +{{block body()}} + + + + + + + + + + {{range tag := tags}} + + + + {{end}} + +
Tag Name
+ {{ tag }} + {{if deleteAllowed}} + Delete + {{end}} +
+{{end}}