├── .babelrc ├── .gitignore ├── Dockerfile ├── Jenkinsfile ├── LICENCE ├── README.md ├── bin ├── build ├── build-dev ├── npm ├── watch └── yarn ├── doc └── full-stack │ └── docker-compose.yml ├── docker-compose.yml ├── nodemon.json ├── package.json ├── src ├── backend │ ├── app.js │ ├── index.js │ ├── internal │ │ └── repo.js │ ├── lib │ │ ├── docker-registry.js │ │ ├── error.js │ │ ├── express │ │ │ ├── cors.js │ │ │ └── pagination.js │ │ ├── helpers.js │ │ └── validator │ │ │ ├── api.js │ │ │ └── index.js │ ├── logger.js │ ├── routes │ │ ├── api │ │ │ ├── main.js │ │ │ └── repos.js │ │ └── main.js │ └── schema │ │ ├── definitions.json │ │ ├── endpoints │ │ ├── rules.json │ │ ├── services.json │ │ ├── templates.json │ │ ├── tokens.json │ │ └── users.json │ │ ├── examples.json │ │ └── index.json └── frontend │ ├── app-images │ └── favicons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── fonts │ ├── html │ └── index.html │ ├── images │ ├── js │ ├── actions.js │ ├── components │ │ ├── app │ │ │ ├── image-tag.js │ │ │ └── insecure-registries.js │ │ └── tabler │ │ │ ├── big-error.js │ │ │ ├── icon-stat-card.js │ │ │ ├── modal.js │ │ │ ├── nav.js │ │ │ ├── stat-card.js │ │ │ ├── table-body.js │ │ │ ├── table-card.js │ │ │ ├── table-head.js │ │ │ └── table-row.js │ ├── index.js │ ├── lib │ │ ├── api.js │ │ ├── manipulators.js │ │ └── utils.js │ ├── router.js │ ├── routes │ │ ├── image.js │ │ ├── images.js │ │ └── instructions │ │ │ ├── deleting.js │ │ │ ├── pulling.js │ │ │ └── pushing.js │ └── state.js │ └── scss │ └── styles.scss └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["Chrome >= 65"] 6 | }, 7 | "debug": false, 8 | "modules": false, 9 | "useBuiltIns": "usage" 10 | }] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | ._* 3 | .DS_Store 4 | node_modules 5 | dist/* 6 | package-lock.json 7 | yarn-error.log 8 | yarn.lock 9 | webpack_stats.html 10 | tmp/* 11 | .env 12 | .yarnrc 13 | 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jc21/node:latest 2 | 3 | MAINTAINER Jamie Curnow 4 | LABEL maintainer="Jamie Curnow " 5 | 6 | RUN apt-get update \ 7 | && apt-get install -y curl \ 8 | && apt-get clean 9 | 10 | ENV NODE_ENV=production 11 | 12 | ADD dist /app/dist 13 | ADD node_modules /app/node_modules 14 | ADD LICENCE /app/LICENCE 15 | ADD package.json /app/package.json 16 | ADD src/backend /app/src/backend 17 | 18 | WORKDIR /app 19 | 20 | CMD node --max_old_space_size=250 --abort_on_uncaught_exception src/backend/index.js 21 | 22 | HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://localhost/ || exit 1 23 | 24 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | options { 4 | buildDiscarder(logRotator(numToKeepStr: '10')) 5 | disableConcurrentBuilds() 6 | } 7 | environment { 8 | IMAGE = "registry-ui" 9 | TAG_VERSION = getPackageVersion() 10 | BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('/', '-')}" 11 | BUILDX_NAME = "${COMPOSE_PROJECT_NAME}" 12 | BASE_IMAGE_NAME = "jc21/node:latest" 13 | TEMP_IMAGE_NAME = "${IMAGE}-build_${BUILD_NUMBER}" 14 | } 15 | stages { 16 | stage('Prepare') { 17 | steps { 18 | sh 'docker pull "${BASE_IMAGE_NAME}"' 19 | sh 'docker pull "${DOCKER_CI_TOOLS}"' 20 | } 21 | } 22 | stage('Build') { 23 | steps { 24 | // Codebase 25 | sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn install' 26 | sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn build' 27 | sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" chown -R "$(id -u):$(id -g)" *' 28 | sh 'rm -rf node_modules' 29 | sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn install --prod' 30 | sh 'docker run --rm -v $(pwd):/data "${DOCKER_CI_TOOLS}" node-prune' 31 | 32 | // Docker Build 33 | sh 'docker build --pull --no-cache --squash --compress -t "${TEMP_IMAGE_NAME}" .' 34 | 35 | // Zip it 36 | sh 'rm -rf zips' 37 | sh 'mkdir -p zips' 38 | sh '''docker run --rm -v "$(pwd):/data/docker-registry-ui" -w /data "${DOCKER_CI_TOOLS}" zip -qr "/data/docker-registry-ui/zips/docker-registry-ui_${TAG_VERSION}.zip" docker-registry-ui -x \\ 39 | \\*.gitkeep \\ 40 | docker-registry-ui/zips\\* \\ 41 | docker-registry-ui/bin\\* \\ 42 | docker-registry-ui/src/frontend\\* \\ 43 | docker-registry-ui/tmp\\* \\ 44 | docker-registry-ui/node_modules\\* \\ 45 | docker-registry-ui/.git\\* \\ 46 | docker-registry-ui/.env \\ 47 | docker-registry-ui/.babelrc \\ 48 | docker-registry-ui/yarn\\* \\ 49 | docker-registry-ui/.gitignore \\ 50 | docker-registry-ui/Dockerfile \\ 51 | docker-registry-ui/nodemon.json \\ 52 | docker-registry-ui/webpack.config.js \\ 53 | docker-registry-ui/webpack_stats.html 54 | ''' 55 | } 56 | post { 57 | always { 58 | sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" chown -R "$(id -u):$(id -g)" *' 59 | } 60 | } 61 | } 62 | stage('Publish Develop') { 63 | when { 64 | branch 'develop' 65 | } 66 | steps { 67 | sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:develop"' 68 | withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { 69 | sh "docker login -u '${duser}' -p '$dpass'" 70 | sh 'docker push "jc21/${IMAGE}:develop"' 71 | } 72 | 73 | // Artifacts 74 | dir(path: 'zips') { 75 | archiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true) 76 | } 77 | } 78 | } 79 | stage('Publish Master') { 80 | when { 81 | branch 'master' 82 | } 83 | steps { 84 | // Public Registry 85 | sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:latest"' 86 | sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:${TAG_VERSION}"' 87 | withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { 88 | sh "docker login -u '${duser}' -p '$dpass'" 89 | sh 'docker push "jc21/${IMAGE}:latest"' 90 | sh 'docker push "jc21/${IMAGE}:${TAG_VERSION}"' 91 | } 92 | 93 | // Artifacts 94 | dir(path: 'zips') { 95 | archiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true) 96 | } 97 | } 98 | } 99 | } 100 | triggers { 101 | bitbucketPush() 102 | } 103 | post { 104 | success { 105 | juxtapose event: 'success' 106 | sh 'figlet "SUCCESS"' 107 | } 108 | failure { 109 | juxtapose event: 'failure' 110 | sh 'figlet "FAILURE"' 111 | } 112 | always { 113 | sh 'docker rmi "${TEMP_IMAGE_NAME}"' 114 | } 115 | } 116 | } 117 | 118 | def getPackageVersion() { 119 | ver = sh(script: 'docker run --rm -v $(pwd):/data "${DOCKER_CI_TOOLS}" bash -c "cat /data/package.json|jq -r \'.version\'"', returnStdout: true) 120 | return ver.trim() 121 | } 122 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Jamie Curnow, Brisbane Australia (https://jc21.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Docker Registry UI](https://public.jc21.com/docker-registry-ui/github.png "Docker Registry UI") 2 | 3 | # Docker Registry UI 4 | 5 | ![Version](https://img.shields.io/badge/version-2.0.2-green.svg) 6 | ![Stars](https://img.shields.io/docker/stars/jc21/registry-ui.svg) 7 | ![Pulls](https://img.shields.io/docker/pulls/jc21/registry-ui.svg) 8 | 9 | Have you ever wanted a visual website to show you the contents of your Docker Registry? Look no further. Now you can list your Images, Tags and info in style. 10 | 11 | This project comes as a [pre-built docker image](https://hub.docker.com/r/jc21/registry-ui/) capable of connecting to another registry. 12 | 13 | Note: This project only works with Docker Registry v2. 14 | 15 | 16 | ## Getting started 17 | 18 | ### Creating a full Docker Registry Stack with this UI 19 | 20 | By far the easiest way to get up and running. Refer to the example [docker-compose.yml](https://github.com/jc21/docker-registry-ui/blob/master/doc/full-stack/docker-compose.yml) 21 | example file, put it on your Docker host and run: 22 | 23 | ```bash 24 | docker-compose up -d 25 | ``` 26 | 27 | Then hit your server on http://127.0.0.1 28 | 29 | 30 | ### If you have your own Docker Registry to connect to 31 | 32 | Here's a `docker-compose.yml` for you: 33 | 34 | ```bash 35 | version: "2" 36 | services: 37 | app: 38 | image: jc21/registry-ui 39 | ports: 40 | - 80:80 41 | environment: 42 | - REGISTRY_HOST=your-registry-server.com:5000 43 | - REGISTRY_SSL=true 44 | - REGISTRY_DOMAIN=your-registry-server.com:5000 45 | - REGISTRY_STORAGE_DELETE_ENABLED= 46 | - REGISTRY_USER= 47 | - REGISTRY_PASS= 48 | restart: on-failure 49 | ``` 50 | 51 | If you are like most people and want your docker registry and your docker ui to co-exist on the same domain on the same port, please 52 | refer to the Nginx configuration used by the [docker-registry-ui-proxy image](https://github.com/jc21/docker-registry-ui-proxy/blob/master/conf.d/proxy.conf) 53 | as an example. Note that there are some tweaks in there that you will need to be able to push successfully. 54 | 55 | 56 | ## Environment Variables 57 | 58 | - **`REGISTRY_HOST`** - *Required:* The registry hostname and optional port to connect to for API calls 59 | - **`REGISTRY_SSL`** - *Optional:* Specify `true` for this if the registry is accessed via HTTPS 60 | - **`REGISTRY_DOMAIN`** - *Optional:* This is the registry domain to display in the UI for example push/pull code 61 | - **`REGISTRY_STORAGE_DELETE_ENABLED`** - *Optional:* Specify `true` or `1` to enable deletion features, but see below first! 62 | - **`REGISTRY_USER`** - *Optional:* If your docker registry is behind basic auth, specify the username 63 | - **`REGISTRY_PASS`** - *Optional:* If your docker registry is behind basic auth, specify the password 64 | 65 | Refer to the docker documentation for setting up [native basic auth](https://docs.docker.com/registry/deploying/#restricting-access). 66 | 67 | 68 | ## Deletion Support 69 | 70 | Registry deletion support sux. It is disabled by default in this project on purpose 71 | because you need to accomplish extra steps to get it up and running, sort of. 72 | 73 | #### Permit deleting on the Registry 74 | 75 | This step is pretty simple and involves adding an environment variable to your Docker Registry Container when you start it up: 76 | 77 | ```bash 78 | docker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --name my-registry registry:2 79 | ``` 80 | 81 | #### Enabling Deletions in the UI 82 | 83 | Same as the Registry, just add the **`REGISTRY_STORAGE_DELETE_ENABLED=true`** environment variable to the `registry-ui` container. Note that `true` is the only 84 | acceptable value for this environment variable. 85 | 86 | 87 | #### Cleaning up the Registry 88 | 89 | When you delete an image from the registry this won't actually remove the blob layers as you might expect. For this reason you have to run this command on your docker registry host to perform garbage collection: 90 | 91 | ```bash 92 | docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml 93 | ``` 94 | 95 | And if you wanted to make a cron job that runs every 30 mins: 96 | 97 | ``` 98 | 0,30 * * * * /bin/docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml >> /dev/null 2>&1 99 | ``` 100 | 101 | 102 | ## Screenshots 103 | 104 | [![Dashboard](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-1.jpg "Dashboard")](https://public.jc21.com/docker-registry-ui/screenshots/drui-1.jpg) 105 | [![Image](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-2.jpg "Image")](https://public.jc21.com/docker-registry-ui/screenshots/drui-2.jpg) 106 | [![Pulling](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-3.jpg "Pulling")](https://public.jc21.com/docker-registry-ui/screenshots/drui-3.jpg) 107 | [![Pushing](https://public.jc21.com/docker-registry-ui/screenshots/small/drui-4.jpg "Pushing")](https://public.jc21.com/docker-registry-ui/screenshots/drui-4.jpg) 108 | 109 | 110 | ## TODO 111 | 112 | - Add pagination to Repositories, currently only 300 images will be fetched 113 | - Add support for token based registry authentication mechanisms 114 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script build 4 | exit $? 5 | -------------------------------------------------------------------------------- /bin/build-dev: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script dev 4 | exit $? 5 | -------------------------------------------------------------------------------- /bin/npm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo /usr/local/bin/docker-compose run --no-deps --rm app npm $@ 4 | exit $? 5 | -------------------------------------------------------------------------------- /bin/watch: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo docker run --rm -it \ 4 | -p 8124:8080 \ 5 | -v $(pwd):/app \ 6 | -w /app \ 7 | jc21/node:latest npm run-script watch 8 | 9 | exit $? 10 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo /usr/local/bin/docker-compose run --no-deps --rm app yarn $@ 4 | exit $? 5 | -------------------------------------------------------------------------------- /doc/full-stack/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | registry: 4 | image: registry:2 5 | environment: 6 | - REGISTRY_HTTP_SECRET=o43g2kjgn2iuhv2k4jn2f23f290qfghsdg 7 | - REGISTRY_STORAGE_DELETE_ENABLED= 8 | volumes: 9 | - ./registry-data:/var/lib/registry 10 | ui: 11 | image: jc21/registry-ui 12 | environment: 13 | - NODE_ENV=production 14 | - REGISTRY_HOST=registry:5000 15 | - REGISTRY_SSL= 16 | - REGISTRY_DOMAIN= 17 | - REGISTRY_STORAGE_DELETE_ENABLED= 18 | links: 19 | - registry 20 | restart: on-failure 21 | proxy: 22 | image: jc21/registry-ui-proxy 23 | ports: 24 | - 80:80 25 | depends_on: 26 | - ui 27 | - registry 28 | links: 29 | - ui 30 | - registry 31 | restart: on-failure 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | app: 4 | image: jc21/node:latest 5 | ports: 6 | - 4000:80 7 | environment: 8 | - DEBUG= 9 | - FORCE_COLOR=1 10 | - NODE_ENV=development 11 | - REGISTRY_HOST=${REGISTRY_HOST} 12 | - REGISTRY_DOMAIN=${REGISTRY_HOST} 13 | - REGISTRY_STORAGE_DELETE_ENABLED=true 14 | - REGISTRY_SSL=${REGISTRY_SSL} 15 | - REGISTRY_USER=${REGISTRY_USER} 16 | - REGISTRY_PASS=${REGISTRY_PASS} 17 | volumes: 18 | - .:/app 19 | working_dir: /app 20 | command: node --max_old_space_size=250 --abort_on_uncaught_exception node_modules/nodemon/bin/nodemon.js 21 | 22 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": false, 3 | "ignore": ["dist", "data", "src/frontend"], 4 | "ext": "js json ejs" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-registry-ui", 3 | "version": "2.0.2", 4 | "description": "A nice web interface for managing your Docker Registry images", 5 | "main": "src/backend/index.js", 6 | "dependencies": { 7 | "ajv": "^6.5.4", 8 | "batchflow": "^0.4.0", 9 | "body-parser": "^1.18.3", 10 | "compression": "^1.7.3", 11 | "config": "^2.0.1", 12 | "ejs": "^2.6.1", 13 | "express": "^4.16.4", 14 | "express-winston": "^3.0.1", 15 | "html-entities": "^1.2.1", 16 | "json-schema-ref-parser": "^6.0.1", 17 | "lodash": "^4.17.11", 18 | "path": "^0.12.7", 19 | "restler": "^3.4.0", 20 | "signale": "^1.2.1" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^6.26.3", 24 | "babel-loader": "^7.1.4", 25 | "babel-minify-webpack-plugin": "^0.3.1", 26 | "babel-preset-env": "^1.7.0", 27 | "@hyperapp/html": "git+https://github.com/maxholman/hyperapp-html.git#5bde674d42c87bb8191f8cc11a8a3c7d334e3dfb", 28 | "babel-plugin-transform-react-jsx": "^6.24.1", 29 | "copy-webpack-plugin": "^4.5.4", 30 | "css-loader": "^1.0.0", 31 | "file-loader": "^2.0.0", 32 | "html-loader": "^0.5.5", 33 | "html-webpack-plugin": "^3.2.0", 34 | "hyperapp": "^1.2.9", 35 | "hyperapp-hash-router": "^0.1.0", 36 | "imports-loader": "^0.8.0", 37 | "jquery": "^3.3.1", 38 | "jquery-serializejson": "^2.8.1", 39 | "mini-css-extract-plugin": "^0.4.4", 40 | "moment": "^2.22.2", 41 | "node-sass": "^4.9.4", 42 | "nodemon": "^1.18.4", 43 | "numeral": "^2.0.6", 44 | "sass-loader": "^7.1.0", 45 | "style-loader": "^0.23.1", 46 | "tabler-ui": "git+https://github.com/tabler/tabler.git#a09fd463309f2b395653e3615c98d1e8aca35b31", 47 | "uglifyjs-webpack-plugin": "^2.0.1", 48 | "webpack": "^4.12.0", 49 | "webpack-cli": "^3.0.8", 50 | "webpack-visualizer-plugin": "^0.1.11" 51 | }, 52 | "scripts": { 53 | "test": "echo \"Error: no test specified\" && exit 1", 54 | "dev": "webpack --mode development", 55 | "build": "webpack --mode production", 56 | "watch": "webpack-dev-server --mode development" 57 | }, 58 | "signale": { 59 | "displayDate": true, 60 | "displayTimestamp": true 61 | }, 62 | "author": "", 63 | "license": "MIT" 64 | } 65 | -------------------------------------------------------------------------------- /src/backend/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const compression = require('compression'); 6 | const log = require('./logger').express; 7 | 8 | /** 9 | * App 10 | */ 11 | const app = express(); 12 | app.use(bodyParser.json()); 13 | app.use(bodyParser.urlencoded({extended: true})); 14 | app.use(compression()); 15 | 16 | /** 17 | * General Logging, BEFORE routes 18 | */ 19 | app.disable('x-powered-by'); 20 | app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); 21 | app.enable('strict routing'); 22 | 23 | // pretty print JSON when not live 24 | if (process.env.NODE_ENV !== 'production') { 25 | app.set('json spaces', 2); 26 | } 27 | 28 | // set the view engine to ejs 29 | app.set('view engine', 'ejs'); 30 | 31 | // CORS for everything 32 | app.use(require('./lib/express/cors')); 33 | 34 | // General security/cache related headers + server header 35 | app.use(function (req, res, next) { 36 | res.set({ 37 | 'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload', 38 | 'X-XSS-Protection': '0', 39 | 'X-Content-Type-Options': 'nosniff', 40 | 'X-Frame-Options': 'DENY', 41 | 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', 42 | Pragma: 'no-cache', 43 | Expires: 0 44 | }); 45 | next(); 46 | }); 47 | 48 | /** 49 | * Routes 50 | */ 51 | app.use('/assets', express.static('dist/assets')); 52 | app.use('/css', express.static('dist/css')); 53 | app.use('/fonts', express.static('dist/fonts')); 54 | app.use('/images', express.static('dist/images')); 55 | app.use('/js', express.static('dist/js')); 56 | app.use('/api', require('./routes/api/main')); 57 | app.use('/', require('./routes/main')); 58 | 59 | // production error handler 60 | // no stacktraces leaked to user 61 | app.use(function (err, req, res, next) { 62 | 63 | let payload = { 64 | error: { 65 | code: err.status, 66 | message: err.public ? err.message : 'Internal Error' 67 | } 68 | }; 69 | 70 | if (process.env.NODE_ENV === 'development') { 71 | payload.debug = { 72 | stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null, 73 | previous: err.previous 74 | }; 75 | } 76 | 77 | // Not every error is worth logging - but this is good for now until it gets annoying. 78 | if (typeof err.stack !== 'undefined' && err.stack) { 79 | log.warn(err.stack); 80 | } 81 | 82 | res 83 | .status(err.status || 500) 84 | .send(payload); 85 | }); 86 | 87 | module.exports = app; 88 | -------------------------------------------------------------------------------- /src/backend/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const logger = require('./logger').global; 6 | const config = require('config'); 7 | 8 | let port = process.env.PORT || 80; 9 | 10 | if (config.has('port')) { 11 | port = config.get('port'); 12 | } 13 | 14 | if (!process.env.REGISTRY_HOST) { 15 | logger.error('Error: REGISTRY_HOST environment variable was not found!'); 16 | process.exit(1); 17 | } 18 | 19 | function appStart () { 20 | 21 | const app = require('./app'); 22 | const apiValidator = require('./lib/validator/api'); 23 | 24 | return apiValidator.loadSchemas 25 | .then(() => { 26 | const server = app.listen(port, () => { 27 | logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...'); 28 | logger.info('Registry Host: ' + process.env.REGISTRY_HOST); 29 | 30 | process.on('SIGTERM', () => { 31 | logger.info('PID ' + process.pid + ' received SIGTERM'); 32 | server.close(() => { 33 | logger.info('Stopping.'); 34 | process.exit(0); 35 | }); 36 | }); 37 | }); 38 | }) 39 | .catch(err => { 40 | logger.error(err.message); 41 | setTimeout(appStart, 1000); 42 | }); 43 | } 44 | 45 | try { 46 | appStart(); 47 | } catch (err) { 48 | logger.error(err.message, err); 49 | process.exit(1); 50 | } 51 | -------------------------------------------------------------------------------- /src/backend/internal/repo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const REGISTRY_HOST = process.env.REGISTRY_HOST; 4 | const REGISTRY_SSL = process.env.REGISTRY_SSL && process.env.REGISTRY_SSL.toLowerCase() === 'true' || parseInt(process.env.REGISTRY_SSL, 10) === 1; 5 | const REGISTRY_USER = process.env.REGISTRY_USER; 6 | const REGISTRY_PASS = process.env.REGISTRY_PASS; 7 | 8 | const _ = require('lodash'); 9 | const Docker = require('../lib/docker-registry'); 10 | const batchflow = require('batchflow'); 11 | const registry = new Docker(REGISTRY_HOST, REGISTRY_SSL, REGISTRY_USER, REGISTRY_PASS); 12 | const errors = require('../lib/error'); 13 | const logger = require('../logger').registry; 14 | 15 | const internalRepo = { 16 | 17 | /** 18 | * @param {String} name 19 | * @param {Boolean} full 20 | * @return {Promise} 21 | */ 22 | get: (name, full) => { 23 | return registry.getImageTags(name) 24 | .then(tags_data => { 25 | // detect errors 26 | if (typeof tags_data.errors !== 'undefined' && tags_data.errors.length) { 27 | let top_err = tags_data.errors.shift(); 28 | if (top_err.code === 'NAME_UNKNOWN') { 29 | throw new errors.ItemNotFoundError(name); 30 | } else { 31 | throw new errors.RegistryError(top_err.code, top_err.message); 32 | } 33 | } 34 | 35 | if (full && tags_data.tags !== null) { 36 | // Order the tags naturally, but put latest at the top if it exists 37 | let latest_idx = tags_data.tags.indexOf('latest'); 38 | if (latest_idx !== -1) { 39 | _.pullAt(tags_data.tags, [latest_idx]); 40 | } 41 | 42 | // sort 43 | tags_data.tags = tags_data.tags.sort((a, b) => a.localeCompare(b)); 44 | 45 | if (latest_idx !== -1) { 46 | tags_data.tags.unshift('latest'); 47 | } 48 | 49 | return new Promise((resolve, reject) => { 50 | batchflow(tags_data.tags).sequential() 51 | .each((i, tag, next) => { 52 | // for each tag, we want to get 2 manifests. 53 | // Version 2 returns the layers and the correct image id 54 | // Version 1 returns the history we want to pluck from 55 | registry.getManifest(tags_data.name, tag, 2) 56 | .then(manifest2_result => { 57 | manifest2_result.name = tag; 58 | manifest2_result.image_name = name; 59 | 60 | return registry.getManifest(tags_data.name, tag, 1) 61 | .then(manifest1_result => { 62 | manifest2_result.info = null; 63 | 64 | if (typeof manifest1_result.history !== 'undefined' && manifest1_result.history.length) { 65 | let info = manifest1_result.history.shift(); 66 | if (typeof info.v1Compatibility !== undefined) { 67 | info = JSON.parse(info.v1Compatibility); 68 | 69 | // Remove cruft 70 | if (typeof info.config !== 'undefined') { 71 | delete info.config; 72 | } 73 | 74 | if (typeof info.container_config !== 'undefined') { 75 | delete info.container_config; 76 | } 77 | } 78 | 79 | manifest2_result.info = info; 80 | } 81 | 82 | next(manifest2_result); 83 | }); 84 | }) 85 | .catch(err => { 86 | logger.error(err); 87 | next(null); 88 | }); 89 | }) 90 | .error(err => { 91 | reject(err); 92 | }) 93 | .end(results => { 94 | tags_data.tags = results || null; 95 | resolve(tags_data); 96 | }); 97 | }); 98 | } else { 99 | return tags_data; 100 | } 101 | }); 102 | }, 103 | 104 | /** 105 | * All repos 106 | * 107 | * @param {Boolean} [with_tags] 108 | * @returns {Promise} 109 | */ 110 | getAll: with_tags => { 111 | return registry.getImages() 112 | .then(result => { 113 | if (typeof result.errors !== 'undefined' && result.errors.length) { 114 | let first_err = result.errors.shift(); 115 | throw new errors.RegistryError(first_err.code, first_err.message); 116 | } else if (typeof result.repositories !== 'undefined') { 117 | let repositories = []; 118 | 119 | // sort images 120 | result.repositories = result.repositories.sort((a, b) => a.localeCompare(b)); 121 | 122 | _.map(result.repositories, function (repo) { 123 | repositories.push({ 124 | name: repo 125 | }); 126 | }); 127 | 128 | return repositories; 129 | } 130 | 131 | return result; 132 | }) 133 | .then(images => { 134 | if (with_tags) { 135 | return new Promise((resolve, reject) => { 136 | batchflow(images).sequential() 137 | .each((i, image, next) => { 138 | let image_result = image; 139 | // for each image 140 | registry.getImageTags(image.name) 141 | .then(tags_result => { 142 | if (typeof tags_result === 'string') { 143 | // usually some sort of error 144 | logger.error('Tags result was: ', tags_result); 145 | image_result.tags = null; 146 | } else if (typeof tags_result.tags !== 'undefined' && tags_result.tags !== null) { 147 | // Order the tags naturally, but put latest at the top if it exists 148 | let latest_idx = tags_result.tags.indexOf('latest'); 149 | if (latest_idx !== -1) { 150 | _.pullAt(tags_result.tags, [latest_idx]); 151 | } 152 | 153 | // sort tags 154 | image_result.tags = tags_result.tags.sort((a, b) => a.localeCompare(b)); 155 | 156 | if (latest_idx !== -1) { 157 | image_result.tags.unshift('latest'); 158 | } 159 | } 160 | 161 | next(image_result); 162 | }) 163 | .catch(err => { 164 | logger.error(err); 165 | image_result.tags = null; 166 | next(image_result); 167 | }); 168 | }) 169 | .error(err => { 170 | reject(err); 171 | }) 172 | .end(results => { 173 | resolve(results); 174 | }); 175 | }); 176 | } else { 177 | return images; 178 | } 179 | }); 180 | }, 181 | 182 | /** 183 | * Delete a image/tag 184 | * 185 | * @param {String} name 186 | * @param {String} digest 187 | * @returns {Promise} 188 | */ 189 | delete: (name, digest) => { 190 | return registry.deleteImage(name, digest); 191 | } 192 | }; 193 | 194 | module.exports = internalRepo; 195 | -------------------------------------------------------------------------------- /src/backend/lib/docker-registry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const rest = require('restler'); 5 | 6 | /** 7 | * 8 | * @param {String} domain 9 | * @param {Boolean} use_ssl 10 | * @param {String} [username] 11 | * @param {String} [password] 12 | * @returns {module} 13 | */ 14 | module.exports = function (domain, use_ssl, username, password) { 15 | 16 | this._baseurl = 'http' + (use_ssl ? 's' : '') + '://' + (username ? username + ':' + password + '@' : '') + domain + '/v2/'; 17 | 18 | /** 19 | * @param {Integer} [version] 20 | * @returns {Object} 21 | */ 22 | this.getUrlOptions = function (version) { 23 | let options = { 24 | headers: { 25 | 'User-Agent': 'Docker Registry UI' 26 | } 27 | }; 28 | 29 | if (version === 2) { 30 | options.headers.Accept = 'application/vnd.docker.distribution.manifest.v2+json'; 31 | } 32 | 33 | return options; 34 | }; 35 | 36 | /** 37 | * @param {Integer} [limit] 38 | * @returns {Promise} 39 | */ 40 | this.getImages = function (limit) { 41 | limit = limit || 300; 42 | 43 | return new Promise((resolve, reject) => { 44 | rest.get(this._baseurl + '_catalog?n=' + limit, this.getUrlOptions()) 45 | .on('timeout', function (ms) { 46 | reject(new Error('Request timed out after ' + ms + 'ms')); 47 | }) 48 | .on('complete', function (result) { 49 | if (result instanceof Error) { 50 | reject(result); 51 | } else { 52 | resolve(result); 53 | } 54 | }); 55 | }); 56 | }; 57 | 58 | /** 59 | * @param {String} image 60 | * @param {Integer} [limit] 61 | * @returns {Promise} 62 | */ 63 | this.getImageTags = function (image, limit) { 64 | limit = limit || 300; 65 | 66 | return new Promise((resolve, reject) => { 67 | rest.get(this._baseurl + image + '/tags/list?n=' + limit, this.getUrlOptions()) 68 | .on('timeout', function (ms) { 69 | reject(new Error('Request timed out after ' + ms + 'ms')); 70 | }) 71 | .on('complete', function (result) { 72 | if (result instanceof Error) { 73 | reject(result); 74 | } else { 75 | resolve(result); 76 | } 77 | }); 78 | }); 79 | }; 80 | 81 | /** 82 | * @param {String} image 83 | * @param {String} digest 84 | * @returns {Promise} 85 | */ 86 | this.deleteImage = function (image, digest) { 87 | return new Promise((resolve, reject) => { 88 | rest.del(this._baseurl + image + '/manifests/' + digest, this.getUrlOptions()) 89 | .on('timeout', function (ms) { 90 | reject(new Error('Request timed out after ' + ms + 'ms')); 91 | }) 92 | .on('202', function () { 93 | resolve(true); 94 | }) 95 | .on('404', function () { 96 | resolve(false); 97 | }) 98 | .on('complete', function (result) { 99 | if (result instanceof Error) { 100 | reject(result); 101 | } else { 102 | if (typeof result.errors !== 'undefined' && result.errors.length) { 103 | let err = result.errors.shift(); 104 | resolve(err); 105 | } 106 | } 107 | }); 108 | }); 109 | }; 110 | 111 | /** 112 | * @param {String} image 113 | * @param {String} layer_digest 114 | * @returns {Promise} 115 | */ 116 | this.deleteLayer = function (image, layer_digest) { 117 | return new Promise((resolve, reject) => { 118 | rest.del(this._baseurl + image + '/blobs/' + layer_digest, this.getUrlOptions()) 119 | .on('timeout', function (ms) { 120 | reject(new Error('Request timed out after ' + ms + 'ms')); 121 | }) 122 | .on('202', function () { 123 | resolve(true); 124 | }) 125 | .on('404', function () { 126 | resolve(false); 127 | }) 128 | .on('complete', function (result) { 129 | if (result instanceof Error) { 130 | reject(result); 131 | } else { 132 | if (typeof result.errors !== 'undefined' && result.errors.length) { 133 | let err = result.errors.shift(); 134 | resolve(err); 135 | } 136 | } 137 | }); 138 | }); 139 | }; 140 | 141 | /** 142 | * @param {String} image 143 | * @param {String} reference can be a tag or digest 144 | * @param {Integer} [version] 1 or 2, defaults to 1 145 | * @returns {Promise} 146 | */ 147 | this.getManifest = function (image, reference, version) { 148 | version = version || 1; 149 | 150 | return new Promise((resolve, reject) => { 151 | rest.get(this._baseurl + image + '/manifests/' + reference, this.getUrlOptions(version)) 152 | .on('timeout', function (ms) { 153 | reject(new Error('Request timed out after ' + ms + 'ms')); 154 | }) 155 | .on('complete', function (result, response) { 156 | if (result instanceof Error) { 157 | reject(result); 158 | } else { 159 | if (typeof result === 'string') { 160 | result = JSON.parse(result); 161 | } 162 | 163 | result.digest = null; 164 | if (typeof response.headers['docker-content-digest'] !== 'undefined') { 165 | result.digest = response.headers['docker-content-digest']; 166 | } 167 | 168 | resolve(result); 169 | } 170 | }); 171 | }); 172 | }; 173 | 174 | return this; 175 | }; 176 | -------------------------------------------------------------------------------- /src/backend/lib/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const util = require('util'); 5 | 6 | module.exports = { 7 | 8 | ItemNotFoundError: function (id, previous) { 9 | Error.captureStackTrace(this, this.constructor); 10 | this.name = this.constructor.name; 11 | this.previous = previous; 12 | this.message = 'Item Not Found - ' + id; 13 | this.public = true; 14 | this.status = 404; 15 | }, 16 | 17 | RegistryError: function (code, message, previous) { 18 | Error.captureStackTrace(this, this.constructor); 19 | this.name = this.constructor.name; 20 | this.previous = previous; 21 | this.message = code + ': ' + message; 22 | this.public = true; 23 | this.status = 500; 24 | }, 25 | 26 | InternalValidationError: function (message, previous) { 27 | Error.captureStackTrace(this, this.constructor); 28 | this.name = this.constructor.name; 29 | this.previous = previous; 30 | this.message = message; 31 | this.status = 400; 32 | this.public = false; 33 | }, 34 | 35 | ValidationError: function (message, previous) { 36 | Error.captureStackTrace(this, this.constructor); 37 | this.name = this.constructor.name; 38 | this.previous = previous; 39 | this.message = message; 40 | this.public = true; 41 | this.status = 400; 42 | } 43 | }; 44 | 45 | _.forEach(module.exports, function (error) { 46 | util.inherits(error, Error); 47 | }); 48 | -------------------------------------------------------------------------------- /src/backend/lib/express/cors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const validator = require('../validator'); 4 | 5 | module.exports = function (req, res, next) { 6 | 7 | if (req.headers.origin) { 8 | 9 | // very relaxed validation.... 10 | validator({ 11 | type: 'string', 12 | pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$' 13 | }, req.headers.origin) 14 | .then(function () { 15 | res.set({ 16 | 'Access-Control-Allow-Origin': req.headers.origin, 17 | 'Access-Control-Allow-Credentials': true, 18 | 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 19 | 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit', 20 | 'Access-Control-Max-Age': 5 * 60, 21 | 'Access-Control-Expose-Headers': 'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit' 22 | }); 23 | next(); 24 | }) 25 | .catch(next); 26 | 27 | } else { 28 | // No origin 29 | next(); 30 | } 31 | 32 | }; 33 | -------------------------------------------------------------------------------- /src/backend/lib/express/pagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let _ = require('lodash'); 4 | 5 | module.exports = function (default_sort, default_offset, default_limit, max_limit) { 6 | 7 | /** 8 | * This will setup the req query params with filtered data and defaults 9 | * 10 | * sort will be an array of fields and their direction 11 | * offset will be an int, defaulting to zero if no other default supplied 12 | * limit will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied 13 | * 14 | */ 15 | 16 | return function (req, res, next) { 17 | 18 | req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10); 19 | req.query.limit = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10); 20 | 21 | if (max_limit && req.query.limit > max_limit) { 22 | req.query.limit = max_limit; 23 | } 24 | 25 | // Sorting 26 | let sort = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort; 27 | let myRegexp = /.*\.(asc|desc)$/ig; 28 | let sort_array = []; 29 | 30 | sort = sort.split(','); 31 | _.map(sort, function (val) { 32 | let matches = myRegexp.exec(val); 33 | 34 | if (matches !== null) { 35 | let dir = matches[1]; 36 | sort_array.push({ 37 | field: val.substr(0, val.length - (dir.length + 1)), 38 | dir: dir.toLowerCase() 39 | }); 40 | } else { 41 | sort_array.push({ 42 | field: val, 43 | dir: 'asc' 44 | }); 45 | } 46 | }); 47 | 48 | // Sort will now be in this format: 49 | // [ 50 | // { field: 'field1', dir: 'asc' }, 51 | // { field: 'field2', dir: 'desc' } 52 | // ] 53 | 54 | req.query.sort = sort_array; 55 | next(); 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/backend/lib/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | const _ = require('lodash'); 5 | 6 | module.exports = { 7 | 8 | /** 9 | * Takes an expression such as 30d and returns a moment object of that date in future 10 | * 11 | * Key Shorthand 12 | * ================== 13 | * years y 14 | * quarters Q 15 | * months M 16 | * weeks w 17 | * days d 18 | * hours h 19 | * minutes m 20 | * seconds s 21 | * milliseconds ms 22 | * 23 | * @param {String} expression 24 | * @returns {Object} 25 | */ 26 | parseDatePeriod: function (expression) { 27 | let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m); 28 | if (matches) { 29 | return moment().add(matches[1], matches[2]); 30 | } 31 | 32 | return null; 33 | }, 34 | 35 | /** 36 | * This will return an object that has the defaults supplied applied to it 37 | * if they didn't exist already. 38 | * 39 | * @param {Object} obj 40 | * @param {Object} defaults 41 | * @return {Object} 42 | */ 43 | applyObjectDefaults: function (obj, defaults) { 44 | return _.assign({}, defaults, obj); 45 | }, 46 | 47 | /** 48 | * Returns a random integer between min (included) and max (excluded) 49 | * Using Math.round() will give you a non-uniform distribution! 50 | * 51 | * @param {Integer} min 52 | * @param {Integer} max 53 | * @returns {Integer} 54 | */ 55 | getRandomInt: function (min, max) { 56 | min = Math.ceil(min); 57 | max = Math.floor(max); 58 | return Math.floor(Math.random() * (max - min)) + min; 59 | }, 60 | 61 | /** 62 | * Removes any fields with . joins in them, to avoid table joining select exposure 63 | * Also makes sure an 'id' field exists 64 | * 65 | * @param {Array} fields 66 | * @returns {Array} 67 | */ 68 | sanitizeFields: function (fields) { 69 | if (fields.indexOf('id') === -1) { 70 | fields.unshift('id'); 71 | } 72 | 73 | let sanitized = []; 74 | for (let x = 0; x < fields.length; x++) { 75 | if (fields[x].indexOf('.') === -1) { 76 | sanitized.push(fields[x]); 77 | } 78 | } 79 | 80 | return sanitized; 81 | }, 82 | 83 | /** 84 | * 85 | * @param {String} input 86 | * @param {String} [allowed] 87 | * @returns {String} 88 | */ 89 | stripHtml: function (input, allowed) { 90 | allowed = (((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join(''); 91 | 92 | let tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi; 93 | let commentsAndPhpTags = /|<\?(?:php)?[\s\S]*?\?>/gi; 94 | 95 | return input.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) { 96 | return allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : ''; 97 | }); 98 | }, 99 | 100 | /** 101 | * 102 | * @param {String} text 103 | * @returns {String} 104 | */ 105 | stripJiraMarkup: function (text) { 106 | return text.replace(/(?:^|[^{]{)[^}]+}/gi, "\n"); 107 | }, 108 | 109 | /** 110 | * @param {String} content 111 | * @returns {String} 112 | */ 113 | compactWhitespace: function (content) { 114 | return content 115 | .replace(/(\r|\n)+/gim, ' ') 116 | .replace(/ +/gim, ' '); 117 | }, 118 | 119 | /** 120 | * @param {String} content 121 | * @param {Integer} length 122 | * @returns {String} 123 | */ 124 | trimString: function (content, length) { 125 | if (content.length > (length - 3)) { 126 | //trim the string to the maximum length 127 | let trimmed = content.substr(0, length - 3); 128 | 129 | //re-trim if we are in the middle of a word 130 | return trimmed.substr(0, Math.min(trimmed.length, trimmed.lastIndexOf(' '))) + '...'; 131 | } 132 | 133 | return content; 134 | }, 135 | 136 | /** 137 | * @param {String} str 138 | * @returns {String} 139 | */ 140 | ucwords: function (str) { 141 | return (str + '') 142 | .replace(/^(.)|\s+(.)/g, function ($1) { 143 | return $1.toUpperCase() 144 | }) 145 | }, 146 | 147 | niceVarName: function (name) { 148 | return name.replace('_', ' ') 149 | .replace(/^(.)|\s+(.)/g, function ($1) { 150 | return $1.toUpperCase(); 151 | }); 152 | } 153 | 154 | }; 155 | -------------------------------------------------------------------------------- /src/backend/lib/validator/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const error = require('../error'); 4 | const path = require('path'); 5 | const parser = require('json-schema-ref-parser'); 6 | 7 | const ajv = require('ajv')({ 8 | verbose: true, 9 | validateSchema: true, 10 | allErrors: false, 11 | format: 'full', // strict regexes for format checks 12 | coerceTypes: true 13 | }); 14 | 15 | /** 16 | * @param {Object} schema 17 | * @param {Object} payload 18 | * @returns {Promise} 19 | */ 20 | function apiValidator(schema, payload/*, description*/) { 21 | return new Promise(function Promise_apiValidator(resolve, reject) { 22 | if (typeof payload === 'undefined') { 23 | reject(new error.ValidationError('Payload is undefined')); 24 | } 25 | 26 | let validate = ajv.compile(schema); 27 | let valid = validate(payload); 28 | 29 | if (valid && !validate.errors) { 30 | resolve(payload); 31 | } else { 32 | let message = ajv.errorsText(validate.errors); 33 | 34 | //console.log(schema); 35 | //console.log(payload); 36 | //console.log(validate.errors); 37 | 38 | //var first_error = validate.errors.slice(0, 1).pop(); 39 | let err = new error.ValidationError(message); 40 | err.debug = [validate.errors, payload]; 41 | reject(err); 42 | } 43 | }); 44 | } 45 | 46 | apiValidator.loadSchemas = parser 47 | .dereference(path.resolve('src/backend/schema/index.json')) 48 | .then((schema) => { 49 | ajv.addSchema(schema); 50 | return schema; 51 | }); 52 | 53 | module.exports = apiValidator; 54 | -------------------------------------------------------------------------------- /src/backend/lib/validator/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const error = require('../error'); 5 | const definitions = require('../../schema/definitions.json'); 6 | 7 | RegExp.prototype.toJSON = RegExp.prototype.toString; 8 | 9 | const ajv = require('ajv')({ 10 | verbose: true, //process.env.NODE_ENV === 'development', 11 | allErrors: true, 12 | format: 'full', // strict regexes for format checks 13 | coerceTypes: true, 14 | schemas: [ 15 | definitions 16 | ] 17 | }); 18 | 19 | /** 20 | * 21 | * @param {Object} schema 22 | * @param {Object} payload 23 | * @returns {Promise} 24 | */ 25 | function validator (schema, payload) { 26 | return new Promise(function (resolve, reject) { 27 | if (!payload) { 28 | reject(new error.InternalValidationError('Payload is falsy')); 29 | } else { 30 | try { 31 | let validate = ajv.compile(schema); 32 | 33 | let valid = validate(payload); 34 | if (valid && !validate.errors) { 35 | resolve(_.cloneDeep(payload)); 36 | } else { 37 | console.log('SCHEMA:', schema); 38 | console.log('PAYLOAD:', payload); 39 | 40 | let message = ajv.errorsText(validate.errors); 41 | reject(new error.InternalValidationError(message)); 42 | } 43 | 44 | } catch (err) { 45 | reject(err); 46 | } 47 | 48 | } 49 | 50 | }); 51 | 52 | } 53 | 54 | module.exports = validator; 55 | -------------------------------------------------------------------------------- /src/backend/logger.js: -------------------------------------------------------------------------------- 1 | const {Signale} = require('signale'); 2 | 3 | module.exports = { 4 | global: new Signale({scope: 'Global '}), 5 | migrate: new Signale({scope: 'Migrate '}), 6 | express: new Signale({scope: 'Express '}), 7 | registry: new Signale({scope: 'Registry'}), 8 | }; 9 | -------------------------------------------------------------------------------- /src/backend/routes/api/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const pjson = require('../../../../package.json'); 5 | 6 | let router = express.Router({ 7 | caseSensitive: true, 8 | strict: true, 9 | mergeParams: true 10 | }); 11 | 12 | /** 13 | * Health Check 14 | * GET /api 15 | */ 16 | router.get('/', (req, res/*, next*/) => { 17 | let version = pjson.version.split('-').shift().split('.'); 18 | 19 | res.status(200).send({ 20 | status: 'OK', 21 | version: { 22 | major: parseInt(version.shift(), 10), 23 | minor: parseInt(version.shift(), 10), 24 | revision: parseInt(version.shift(), 10) 25 | }, 26 | config: { 27 | REGISTRY_STORAGE_DELETE_ENABLED: process.env.REGISTRY_STORAGE_DELETE_ENABLED && process.env.REGISTRY_STORAGE_DELETE_ENABLED.toLowerCase() === 'true' || parseInt(process.env.REGISTRY_STORAGE_DELETE_ENABLED, 10) === 1, 28 | REGISTRY_DOMAIN: process.env.REGISTRY_DOMAIN || null 29 | } 30 | }); 31 | }); 32 | 33 | router.use('/repos', require('./repos')); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /src/backend/routes/api/repos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const validator = require('../../lib/validator'); 5 | const pagination = require('../../lib/express/pagination'); 6 | const internalRepo = require('../../internal/repo'); 7 | 8 | let router = express.Router({ 9 | caseSensitive: true, 10 | strict: true, 11 | mergeParams: true 12 | }); 13 | 14 | /** 15 | * /api/repos 16 | */ 17 | router 18 | .route('/') 19 | .options((req, res) => { 20 | res.sendStatus(204); 21 | }) 22 | 23 | /** 24 | * GET /api/repos 25 | * 26 | * Retrieve all repos 27 | */ 28 | .get(pagination('name', 0, 50, 300), (req, res, next) => { 29 | validator({ 30 | additionalProperties: false, 31 | properties: { 32 | tags: { 33 | type: 'boolean' 34 | } 35 | } 36 | }, { 37 | tags: (typeof req.query.tags !== 'undefined' ? !!req.query.tags : false) 38 | }) 39 | .then(data => { 40 | return internalRepo.getAll(data.tags); 41 | }) 42 | .then(repos => { 43 | res.status(200) 44 | .send(repos); 45 | }) 46 | .catch(next); 47 | }); 48 | 49 | /** 50 | * Specific repo 51 | * 52 | * /api/repos/abc123 53 | */ 54 | router 55 | .route('/:name([-a-zA-Z0-9/.,_]+)') 56 | .options((req, res) => { 57 | res.sendStatus(204); 58 | }) 59 | 60 | /** 61 | * GET /api/repos/abc123 62 | * 63 | * Retrieve a specific repo 64 | */ 65 | .get((req, res, next) => { 66 | validator({ 67 | required: ['name'], 68 | additionalProperties: false, 69 | properties: { 70 | name: { 71 | type: 'string', 72 | minLength: 1 73 | }, 74 | full: { 75 | type: 'boolean' 76 | } 77 | } 78 | }, { 79 | name: req.params.name, 80 | full: (typeof req.query.full !== 'undefined' ? !!req.query.full : false) 81 | }) 82 | .then(data => { 83 | return internalRepo.get(data.name, data.full); 84 | }) 85 | .then(repo => { 86 | res.status(200) 87 | .send(repo); 88 | }) 89 | .catch(next); 90 | }) 91 | 92 | /** 93 | * DELETE /api/repos/abc123 94 | * 95 | * Delete a specific image/tag 96 | */ 97 | .delete((req, res, next) => { 98 | validator({ 99 | required: ['name', 'digest'], 100 | additionalProperties: false, 101 | properties: { 102 | name: { 103 | type: 'string', 104 | minLength: 1 105 | }, 106 | digest: { 107 | type: 'string', 108 | minLength: 1 109 | } 110 | } 111 | }, { 112 | name: req.params.name, 113 | digest: (typeof req.query.digest !== 'undefined' ? req.query.digest : '') 114 | }) 115 | .then(data => { 116 | return internalRepo.delete(data.name, data.digest); 117 | }) 118 | .then(result => { 119 | res.status(200) 120 | .send(result); 121 | }) 122 | .catch(next); 123 | }); 124 | module.exports = router; 125 | -------------------------------------------------------------------------------- /src/backend/routes/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const fs = require('fs'); 5 | 6 | const router = express.Router({ 7 | caseSensitive: true, 8 | strict: true, 9 | mergeParams: true 10 | }); 11 | 12 | /** 13 | * GET .* 14 | */ 15 | router.get(/(.*)/, function (req, res, next) { 16 | req.params.page = req.params['0']; 17 | if (req.params.page === '/') { 18 | req.params.page = '/index.html'; 19 | } 20 | 21 | fs.readFile('dist' + req.params.page, 'utf8', function(err, data) { 22 | if (err) { 23 | if (req.params.page !== '/index.html') { 24 | fs.readFile('dist/index.html', 'utf8', function(err2, data) { 25 | if (err2) { 26 | next(err); 27 | } else { 28 | res.contentType('text/html').end(data); 29 | } 30 | }); 31 | } else { 32 | next(err); 33 | } 34 | } else { 35 | res.contentType('text/html').end(data); 36 | } 37 | }); 38 | }); 39 | 40 | module.exports = router; 41 | -------------------------------------------------------------------------------- /src/backend/schema/definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "definitions", 4 | "definitions": { 5 | "id": { 6 | "description": "Unique identifier", 7 | "example": 123456, 8 | "readOnly": true, 9 | "type": "integer", 10 | "minimum": 1 11 | }, 12 | "token": { 13 | "type": "string", 14 | "minLength": 10 15 | }, 16 | "expand": { 17 | "anyOf": [ 18 | { 19 | "type": "null" 20 | }, 21 | { 22 | "type": "array", 23 | "minItems": 1, 24 | "items": { 25 | "type": "string" 26 | } 27 | } 28 | ] 29 | }, 30 | "sort": { 31 | "type": "array", 32 | "minItems": 1, 33 | "items": { 34 | "type": "object", 35 | "required": [ 36 | "field", 37 | "dir" 38 | ], 39 | "additionalProperties": false, 40 | "properties": { 41 | "field": { 42 | "type": "string" 43 | }, 44 | "dir": { 45 | "type": "string", 46 | "pattern": "^(asc|desc)$" 47 | } 48 | } 49 | } 50 | }, 51 | "query": { 52 | "anyOf": [ 53 | { 54 | "type": "null" 55 | }, 56 | { 57 | "type": "string", 58 | "minLength": 1, 59 | "maxLength": 255 60 | } 61 | ] 62 | }, 63 | "criteria": { 64 | "anyOf": [ 65 | { 66 | "type": "null" 67 | }, 68 | { 69 | "type": "object" 70 | } 71 | ] 72 | }, 73 | "fields": { 74 | "anyOf": [ 75 | { 76 | "type": "null" 77 | }, 78 | { 79 | "type": "array", 80 | "minItems": 1, 81 | "items": { 82 | "type": "string" 83 | } 84 | } 85 | ] 86 | }, 87 | "omit": { 88 | "anyOf": [ 89 | { 90 | "type": "null" 91 | }, 92 | { 93 | "type": "array", 94 | "minItems": 1, 95 | "items": { 96 | "type": "string" 97 | } 98 | } 99 | ] 100 | }, 101 | "created_on": { 102 | "description": "Date and time of creation", 103 | "format": "date-time", 104 | "readOnly": true, 105 | "type": "string" 106 | }, 107 | "modified_on": { 108 | "description": "Date and time of last update", 109 | "format": "date-time", 110 | "readOnly": true, 111 | "type": "string" 112 | }, 113 | "user_id": { 114 | "description": "User ID", 115 | "example": 1234, 116 | "type": "integer", 117 | "minimum": 1 118 | }, 119 | "name": { 120 | "type": "string", 121 | "minLength": 1, 122 | "maxLength": 255 123 | }, 124 | "email": { 125 | "description": "Email Address", 126 | "example": "john@example.com", 127 | "format": "email", 128 | "type": "string", 129 | "minLength": 8, 130 | "maxLength": 100 131 | }, 132 | "password": { 133 | "description": "Password", 134 | "type": "string", 135 | "minLength": 8, 136 | "maxLength": 255 137 | }, 138 | "jira_webhook_data": { 139 | "type": "object", 140 | "additionalProperties": true, 141 | "required": [ 142 | "webhookEvent", 143 | "timestamp" 144 | ], 145 | "properties": { 146 | "webhookEvent": { 147 | "type": "string", 148 | "minLength": 2 149 | }, 150 | "timestamp": { 151 | "type": "integer", 152 | "minimum": 1 153 | }, 154 | "user": { 155 | "type": "object" 156 | }, 157 | "issue": { 158 | "type": "object" 159 | } 160 | } 161 | }, 162 | "bitbucket_webhook_data": { 163 | "type": "object", 164 | "additionalProperties": true, 165 | "required": [ 166 | "eventKey", 167 | "date" 168 | ], 169 | "properties": { 170 | "eventKey": { 171 | "type": "string", 172 | "minLength": 2 173 | }, 174 | "date": { 175 | "type": "string", 176 | "minimum": 19 177 | }, 178 | "actor": { 179 | "type": "object" 180 | }, 181 | "pullRequest": { 182 | "type": "object" 183 | } 184 | } 185 | }, 186 | "dockerhub_webhook_data": { 187 | "type": "object", 188 | "additionalProperties": true, 189 | "required": [ 190 | "push_data", 191 | "repository" 192 | ], 193 | "properties": { 194 | "push_data": { 195 | "type": "object" 196 | }, 197 | "repository": { 198 | "type": "object" 199 | } 200 | } 201 | }, 202 | "zendesk_webhook_data": { 203 | "type": "object", 204 | "additionalProperties": true, 205 | "required": [ 206 | "ticket", 207 | "current_user" 208 | ], 209 | "properties": { 210 | "ticket": { 211 | "type": "object" 212 | }, 213 | "current_user": { 214 | "type": "object" 215 | } 216 | } 217 | }, 218 | "service_type": { 219 | "description": "Service Type", 220 | "example": "slack", 221 | "type": "string", 222 | "minLength": 2, 223 | "maxLength": 30, 224 | "pattern": "^(slack|jira-webhook|bitbucket-webhook|dockerhub-webhook|zendesk-webhook|jabber)$" 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/backend/schema/endpoints/rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "endpoints/rules", 4 | "title": "Rules", 5 | "description": "Endpoints relating to Rules", 6 | "stability": "stable", 7 | "type": "object", 8 | "definitions": { 9 | "id": { 10 | "$ref": "../definitions.json#/definitions/id" 11 | }, 12 | "created_on": { 13 | "$ref": "../definitions.json#/definitions/created_on" 14 | }, 15 | "modified_on": { 16 | "$ref": "../definitions.json#/definitions/modified_on" 17 | }, 18 | "user_id": { 19 | "$ref": "../definitions.json#/definitions/user_id" 20 | }, 21 | "priority_order": { 22 | "description": "Priority Order", 23 | "example": 1, 24 | "type": "integer", 25 | "minimum": 0 26 | }, 27 | "in_service_id": { 28 | "description": "Incoming Service ID", 29 | "example": 1234, 30 | "type": "integer", 31 | "minimum": 1 32 | }, 33 | "trigger": { 34 | "description": "Trigger Type", 35 | "example": "assigned", 36 | "type": "string", 37 | "minLength": 2, 38 | "maxLength": 50 39 | }, 40 | "extra_conditions": { 41 | "description": "Extra Incoming Trigger Conditions", 42 | "example": { 43 | "project": "BB" 44 | }, 45 | "type": "object" 46 | }, 47 | "out_service_id": { 48 | "description": "Outgoing Service ID", 49 | "example": 1234, 50 | "type": "integer", 51 | "minimum": 1 52 | }, 53 | "out_template_id": { 54 | "description": "Outgoing Template ID", 55 | "example": 1234, 56 | "type": "integer", 57 | "minimum": 1 58 | }, 59 | "out_template_options": { 60 | "description": "Custom options for Outgoing Template", 61 | "example": { 62 | "panel_color": "#ff00aa" 63 | }, 64 | "type": "object" 65 | }, 66 | "fired_count": { 67 | "description": "Fired Count", 68 | "example": 854, 69 | "readOnly": true, 70 | "type": "integer", 71 | "minimum": 1 72 | } 73 | }, 74 | "links": [ 75 | { 76 | "title": "List", 77 | "description": "Returns a list of Rules", 78 | "href": "/rules", 79 | "access": "private", 80 | "method": "GET", 81 | "rel": "self", 82 | "http_header": { 83 | "$ref": "../examples.json#/definitions/auth_header" 84 | }, 85 | "targetSchema": { 86 | "type": "array", 87 | "items": { 88 | "$ref": "#/properties" 89 | } 90 | } 91 | }, 92 | { 93 | "title": "Create", 94 | "description": "Creates a new Rule", 95 | "href": "/rules", 96 | "access": "private", 97 | "method": "POST", 98 | "rel": "create", 99 | "http_header": { 100 | "$ref": "../examples.json#/definitions/auth_header" 101 | }, 102 | "schema": { 103 | "type": "object", 104 | "required": [ 105 | "in_service_id", 106 | "trigger", 107 | "out_service_id", 108 | "out_template_id" 109 | ], 110 | "properties": { 111 | "user_id": { 112 | "$ref": "#/definitions/user_id" 113 | }, 114 | "priority_order": { 115 | "$ref": "#/definitions/priority_order" 116 | }, 117 | "in_service_id": { 118 | "$ref": "#/definitions/in_service_id" 119 | }, 120 | "trigger": { 121 | "$ref": "#/definitions/trigger" 122 | }, 123 | "extra_conditions": { 124 | "$ref": "#/definitions/extra_conditions" 125 | }, 126 | "out_service_id": { 127 | "$ref": "#/definitions/out_service_id" 128 | }, 129 | "out_template_id": { 130 | "$ref": "#/definitions/out_template_id" 131 | }, 132 | "out_template_options": { 133 | "$ref": "#/definitions/out_template_options" 134 | } 135 | } 136 | }, 137 | "targetSchema": { 138 | "properties": { 139 | "$ref": "#/properties" 140 | } 141 | } 142 | }, 143 | { 144 | "title": "Update", 145 | "description": "Updates a existing Rule", 146 | "href": "/rules/{definitions.identity.example}", 147 | "access": "private", 148 | "method": "PUT", 149 | "rel": "update", 150 | "http_header": { 151 | "$ref": "../examples.json#/definitions/auth_header" 152 | }, 153 | "schema": { 154 | "type": "object", 155 | "properties": { 156 | "priority_order": { 157 | "$ref": "#/definitions/priority_order" 158 | }, 159 | "in_service_id": { 160 | "$ref": "#/definitions/in_service_id" 161 | }, 162 | "trigger": { 163 | "$ref": "#/definitions/trigger" 164 | }, 165 | "extra_conditions": { 166 | "$ref": "#/definitions/extra_conditions" 167 | }, 168 | "out_service_id": { 169 | "$ref": "#/definitions/out_service_id" 170 | }, 171 | "out_template_id": { 172 | "$ref": "#/definitions/out_template_id" 173 | }, 174 | "out_template_options": { 175 | "$ref": "#/definitions/out_template_options" 176 | } 177 | } 178 | }, 179 | "targetSchema": { 180 | "properties": { 181 | "$ref": "#/properties" 182 | } 183 | } 184 | }, 185 | { 186 | "title": "Delete", 187 | "description": "Deletes a existing Rule", 188 | "href": "/rules/{definitions.identity.example}", 189 | "access": "private", 190 | "method": "DELETE", 191 | "rel": "delete", 192 | "http_header": { 193 | "$ref": "../examples.json#/definitions/auth_header" 194 | }, 195 | "targetSchema": { 196 | "type": "boolean" 197 | } 198 | }, 199 | { 200 | "title": "Order", 201 | "description": "Sets the order for the rules", 202 | "href": "/rules/order", 203 | "access": "private", 204 | "method": "POST", 205 | "http_header": { 206 | "$ref": "../examples.json#/definitions/auth_header" 207 | }, 208 | "schema": { 209 | "type": "array", 210 | "items": { 211 | "type": "object", 212 | "required": [ 213 | "order", 214 | "rule_id" 215 | ], 216 | "properties": { 217 | "order": { 218 | "type": "integer", 219 | "minimum": 0 220 | }, 221 | "rule_id": { 222 | "$ref": "../definitions.json#/definitions/id" 223 | } 224 | } 225 | } 226 | }, 227 | "targetSchema": { 228 | "type": "boolean" 229 | } 230 | }, 231 | { 232 | "title": "Copy", 233 | "description": "Copies rules from one user to another", 234 | "href": "/rules/copy", 235 | "access": "private", 236 | "method": "POST", 237 | "http_header": { 238 | "$ref": "../examples.json#/definitions/auth_header" 239 | }, 240 | "schema": { 241 | "type": "object", 242 | "required": [ 243 | "from", 244 | "to" 245 | ], 246 | "properties": { 247 | "from": { 248 | "type": "integer", 249 | "minimum": 1 250 | }, 251 | "to": { 252 | "type": "integer", 253 | "minimum": 1 254 | }, 255 | "service_type": { 256 | "$ref": "../definitions.json#/definitions/service_type" 257 | } 258 | } 259 | }, 260 | "targetSchema": { 261 | "type": "boolean" 262 | } 263 | } 264 | ], 265 | "properties": { 266 | "id": { 267 | "$ref": "#/definitions/id" 268 | }, 269 | "created_on": { 270 | "$ref": "#/definitions/created_on" 271 | }, 272 | "modified_on": { 273 | "$ref": "#/definitions/modified_on" 274 | }, 275 | "user_id": { 276 | "$ref": "#/definitions/user_id" 277 | }, 278 | "priority_order": { 279 | "$ref": "#/definitions/priority_order" 280 | }, 281 | "in_service_id": { 282 | "$ref": "#/definitions/in_service_id" 283 | }, 284 | "trigger": { 285 | "$ref": "#/definitions/trigger" 286 | }, 287 | "extra_conditions": { 288 | "$ref": "#/definitions/extra_conditions" 289 | }, 290 | "out_service_id": { 291 | "$ref": "#/definitions/out_service_id" 292 | }, 293 | "out_template_id": { 294 | "$ref": "#/definitions/out_template_id" 295 | }, 296 | "out_template_options": { 297 | "$ref": "#/definitions/out_template_options" 298 | }, 299 | "fired_count": { 300 | "$ref": "#/definitions/fired_count" 301 | } 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/backend/schema/endpoints/services.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "endpoints/services", 4 | "title": "Services", 5 | "description": "Endpoints relating to Services", 6 | "stability": "stable", 7 | "type": "object", 8 | "definitions": { 9 | "id": { 10 | "$ref": "../definitions.json#/definitions/id" 11 | }, 12 | "created_on": { 13 | "$ref": "../definitions.json#/definitions/created_on" 14 | }, 15 | "modified_on": { 16 | "$ref": "../definitions.json#/definitions/modified_on" 17 | }, 18 | "type": { 19 | "$ref": "../definitions.json#/definitions/service_type" 20 | }, 21 | "name": { 22 | "description": "Name", 23 | "example": "JiraBot", 24 | "type": "string", 25 | "minLength": 2, 26 | "maxLength": 100 27 | }, 28 | "data": { 29 | "description": "Data", 30 | "example": {"api_token": "xox-somethingrandom"}, 31 | "type": "object" 32 | } 33 | }, 34 | "links": [ 35 | { 36 | "title": "List", 37 | "description": "Returns a list of Services", 38 | "href": "/services", 39 | "access": "private", 40 | "method": "GET", 41 | "rel": "self", 42 | "http_header": { 43 | "$ref": "../examples.json#/definitions/auth_header" 44 | }, 45 | "targetSchema": { 46 | "type": "array", 47 | "items": { 48 | "$ref": "#/properties" 49 | } 50 | } 51 | }, 52 | { 53 | "title": "Create", 54 | "description": "Creates a new Service", 55 | "href": "/services", 56 | "access": "private", 57 | "method": "POST", 58 | "rel": "create", 59 | "http_header": { 60 | "$ref": "../examples.json#/definitions/auth_header" 61 | }, 62 | "schema": { 63 | "type": "object", 64 | "required": [ 65 | "type", 66 | "name", 67 | "data" 68 | ], 69 | "properties": { 70 | "type": { 71 | "$ref": "#/definitions/type" 72 | }, 73 | "name": { 74 | "$ref": "#/definitions/name" 75 | }, 76 | "data": { 77 | "$ref": "#/definitions/data" 78 | } 79 | } 80 | }, 81 | "targetSchema": { 82 | "properties": { 83 | "$ref": "#/properties" 84 | } 85 | } 86 | }, 87 | { 88 | "title": "Update", 89 | "description": "Updates a existing Service", 90 | "href": "/services/{definitions.identity.example}", 91 | "access": "private", 92 | "method": "PUT", 93 | "rel": "update", 94 | "http_header": { 95 | "$ref": "../examples.json#/definitions/auth_header" 96 | }, 97 | "schema": { 98 | "type": "object", 99 | "properties": { 100 | "type": { 101 | "$ref": "#/definitions/type" 102 | }, 103 | "name": { 104 | "$ref": "#/definitions/name" 105 | }, 106 | "data": { 107 | "$ref": "#/definitions/data" 108 | } 109 | } 110 | }, 111 | "targetSchema": { 112 | "properties": { 113 | "$ref": "#/properties" 114 | } 115 | } 116 | }, 117 | { 118 | "title": "Delete", 119 | "description": "Deletes a existing Service", 120 | "href": "/services/{definitions.identity.example}", 121 | "access": "private", 122 | "method": "DELETE", 123 | "rel": "delete", 124 | "http_header": { 125 | "$ref": "../examples.json#/definitions/auth_header" 126 | }, 127 | "targetSchema": { 128 | "type": "boolean" 129 | } 130 | }, 131 | { 132 | "title": "Test", 133 | "description": "Tests a existing Service", 134 | "href": "/services/{definitions.identity.example}/test", 135 | "access": "private", 136 | "method": "POST", 137 | "rel": "test", 138 | "http_header": { 139 | "$ref": "../examples.json#/definitions/auth_header" 140 | }, 141 | "schema": { 142 | "type": "object", 143 | "required": [ 144 | "username", 145 | "message" 146 | ], 147 | "properties": { 148 | "username": { 149 | "type": "string", 150 | "minLength": 1 151 | }, 152 | "message": { 153 | "type": "string", 154 | "minLength": 1 155 | } 156 | } 157 | }, 158 | "targetSchema": { 159 | "type": "boolean" 160 | } 161 | }, 162 | { 163 | "title": "User List", 164 | "description": "Get User List of a Service", 165 | "href": "/services/{definitions.identity.example}/users", 166 | "access": "private", 167 | "method": "GET", 168 | "rel": "users", 169 | "http_header": { 170 | "$ref": "../examples.json#/definitions/auth_header" 171 | }, 172 | "schema": { 173 | "type": "object", 174 | "required": [ 175 | "username", 176 | "message" 177 | ], 178 | "properties": { 179 | "username": { 180 | "type": "string", 181 | "minLength": 1 182 | }, 183 | "message": { 184 | "type": "string", 185 | "minLength": 1 186 | } 187 | } 188 | }, 189 | "targetSchema": { 190 | "type": "boolean" 191 | } 192 | } 193 | ], 194 | "properties": { 195 | "id": { 196 | "$ref": "#/definitions/id" 197 | }, 198 | "created_on": { 199 | "$ref": "#/definitions/created_on" 200 | }, 201 | "modified_on": { 202 | "$ref": "#/definitions/modified_on" 203 | }, 204 | "type": { 205 | "$ref": "#/definitions/type" 206 | }, 207 | "name": { 208 | "$ref": "#/definitions/name" 209 | }, 210 | "data": { 211 | "$ref": "#/definitions/data" 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/backend/schema/endpoints/templates.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "endpoints/templates", 4 | "title": "Templates", 5 | "description": "Endpoints relating to Templates", 6 | "stability": "stable", 7 | "type": "object", 8 | "definitions": { 9 | "id": { 10 | "$ref": "../definitions.json#/definitions/id" 11 | }, 12 | "created_on": { 13 | "$ref": "../definitions.json#/definitions/created_on" 14 | }, 15 | "modified_on": { 16 | "$ref": "../definitions.json#/definitions/modified_on" 17 | }, 18 | "service_type": { 19 | "$ref": "../definitions.json#/definitions/service_type" 20 | }, 21 | "in_service_type": { 22 | "$ref": "../definitions.json#/definitions/service_type" 23 | }, 24 | "name": { 25 | "description": "Name of Template", 26 | "example": "Assigned Task Compact", 27 | "type": "string", 28 | "minLength": 1, 29 | "maxLength": 100 30 | }, 31 | "content": { 32 | "description": "Content", 33 | "example": "{\"text\": \"Hello World\"}", 34 | "type": "string" 35 | }, 36 | "default_options": { 37 | "description": "Default Options", 38 | "example": { 39 | "panel_color": "#ff0000" 40 | }, 41 | "type": "object" 42 | }, 43 | "example_data": { 44 | "description": "Example Data", 45 | "example": { 46 | "summary": "Example Jira Summary" 47 | }, 48 | "type": "object" 49 | }, 50 | "event_types": { 51 | "description": "Event Types", 52 | "example": { 53 | "summary": ["assigned", "resolved"] 54 | }, 55 | "type": "array", 56 | "minItems": 1, 57 | "items": { 58 | "type": "string", 59 | "minLength": 1 60 | } 61 | }, 62 | "render_engine": { 63 | "description": "Render Engine", 64 | "example": "liquid", 65 | "type": "string", 66 | "pattern": "^(ejs|liquid)$" 67 | } 68 | }, 69 | "links": [ 70 | { 71 | "title": "List", 72 | "description": "Returns a list of Templates", 73 | "href": "/templates", 74 | "access": "private", 75 | "method": "GET", 76 | "rel": "self", 77 | "http_header": { 78 | "$ref": "../examples.json#/definitions/auth_header" 79 | }, 80 | "targetSchema": { 81 | "type": "array", 82 | "items": { 83 | "$ref": "#/properties" 84 | } 85 | } 86 | }, 87 | { 88 | "title": "Create", 89 | "description": "Creates a new Templates", 90 | "href": "/templates", 91 | "access": "private", 92 | "method": "POST", 93 | "rel": "create", 94 | "http_header": { 95 | "$ref": "../examples.json#/definitions/auth_header" 96 | }, 97 | "schema": { 98 | "type": "object", 99 | "required": [ 100 | "service_type", 101 | "in_service_type", 102 | "name", 103 | "content", 104 | "default_options", 105 | "example_data", 106 | "event_types" 107 | ], 108 | "properties": { 109 | "service_type": { 110 | "$ref": "#/definitions/service_type" 111 | }, 112 | "in_service_type": { 113 | "$ref": "#/definitions/in_service_type" 114 | }, 115 | "name": { 116 | "$ref": "#/definitions/name" 117 | }, 118 | "content": { 119 | "$ref": "#/definitions/content" 120 | }, 121 | "default_options": { 122 | "$ref": "#/definitions/default_options" 123 | }, 124 | "example_data": { 125 | "$ref": "#/definitions/default_options" 126 | }, 127 | "event_types": { 128 | "$ref": "#/definitions/event_types" 129 | } 130 | } 131 | }, 132 | "targetSchema": { 133 | "properties": { 134 | "$ref": "#/properties" 135 | } 136 | } 137 | }, 138 | { 139 | "title": "Update", 140 | "description": "Updates a existing Template", 141 | "href": "/templates/{definitions.identity.example}", 142 | "access": "private", 143 | "method": "PUT", 144 | "rel": "update", 145 | "http_header": { 146 | "$ref": "../examples.json#/definitions/auth_header" 147 | }, 148 | "schema": { 149 | "type": "object", 150 | "properties": { 151 | "service_type": { 152 | "$ref": "#/definitions/service_type" 153 | }, 154 | "in_service_type": { 155 | "$ref": "#/definitions/in_service_type" 156 | }, 157 | "name": { 158 | "$ref": "#/definitions/name" 159 | }, 160 | "content": { 161 | "$ref": "#/definitions/content" 162 | }, 163 | "default_options": { 164 | "$ref": "#/definitions/default_options" 165 | }, 166 | "example_data": { 167 | "$ref": "#/definitions/default_options" 168 | }, 169 | "event_types": { 170 | "$ref": "#/definitions/event_types" 171 | } 172 | } 173 | }, 174 | "targetSchema": { 175 | "properties": { 176 | "$ref": "#/properties" 177 | } 178 | } 179 | }, 180 | { 181 | "title": "Delete", 182 | "description": "Deletes a existing Template", 183 | "href": "/templates/{definitions.identity.example}", 184 | "access": "private", 185 | "method": "DELETE", 186 | "rel": "delete", 187 | "http_header": { 188 | "$ref": "../examples.json#/definitions/auth_header" 189 | }, 190 | "targetSchema": { 191 | "type": "boolean" 192 | } 193 | } 194 | ], 195 | "properties": { 196 | "id": { 197 | "$ref": "#/definitions/id" 198 | }, 199 | "created_on": { 200 | "$ref": "#/definitions/created_on" 201 | }, 202 | "modified_on": { 203 | "$ref": "#/definitions/modified_on" 204 | }, 205 | "service_type": { 206 | "$ref": "#/definitions/service_type" 207 | }, 208 | "in_service_type": { 209 | "$ref": "#/definitions/in_service_type" 210 | }, 211 | "name": { 212 | "$ref": "#/definitions/name" 213 | }, 214 | "content": { 215 | "$ref": "#/definitions/content" 216 | }, 217 | "default_options": { 218 | "$ref": "#/definitions/default_options" 219 | }, 220 | "example_data": { 221 | "$ref": "#/definitions/example_data" 222 | }, 223 | "event_types": { 224 | "$ref": "#/definitions/event_types" 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/backend/schema/endpoints/tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "endpoints/tokens", 4 | "title": "Token", 5 | "description": "Tokens are required to authenticate against the JiraBot API", 6 | "stability": "stable", 7 | "type": "object", 8 | "definitions": { 9 | "identity": { 10 | "description": "Email Address or other 3rd party providers identifier", 11 | "example": "john@example.com", 12 | "type": "string" 13 | }, 14 | "secret": { 15 | "description": "A password or key", 16 | "example": "correct horse battery staple", 17 | "type": "string" 18 | }, 19 | "token": { 20 | "description": "JWT", 21 | "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", 22 | "type": "string" 23 | }, 24 | "expires": { 25 | "description": "Token expiry time", 26 | "format": "date-time", 27 | "type": "string" 28 | }, 29 | "scope": { 30 | "description": "Scope of the Token, defaults to 'user'", 31 | "example": "user", 32 | "type": "string" 33 | } 34 | }, 35 | "links": [ 36 | { 37 | "title": "Create", 38 | "description": "Creates a new token.", 39 | "href": "/tokens", 40 | "access": "public", 41 | "method": "POST", 42 | "rel": "create", 43 | "schema": { 44 | "type": "object", 45 | "required": [ 46 | "identity", 47 | "secret" 48 | ], 49 | "properties": { 50 | "identity": { 51 | "$ref": "#/definitions/identity" 52 | }, 53 | "secret": { 54 | "$ref": "#/definitions/secret" 55 | }, 56 | "scope": { 57 | "$ref": "#/definitions/scope" 58 | } 59 | } 60 | }, 61 | "targetSchema": { 62 | "type": "object", 63 | "properties": { 64 | "token": { 65 | "$ref": "#/definitions/token" 66 | }, 67 | "expires": { 68 | "$ref": "#/definitions/expires" 69 | } 70 | } 71 | } 72 | }, 73 | { 74 | "title": "Refresh", 75 | "description": "Returns a new token.", 76 | "href": "/tokens", 77 | "access": "private", 78 | "method": "GET", 79 | "rel": "self", 80 | "http_header": { 81 | "$ref": "../examples.json#/definitions/auth_header" 82 | }, 83 | "schema": {}, 84 | "targetSchema": { 85 | "type": "object", 86 | "properties": { 87 | "token": { 88 | "$ref": "#/definitions/token" 89 | }, 90 | "expires": { 91 | "$ref": "#/definitions/expires" 92 | }, 93 | "scope": { 94 | "$ref": "#/definitions/scope" 95 | } 96 | } 97 | } 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /src/backend/schema/endpoints/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "endpoints/users", 4 | "title": "Users", 5 | "description": "Endpoints relating to Users", 6 | "stability": "stable", 7 | "type": "object", 8 | "definitions": { 9 | "id": { 10 | "$ref": "../definitions.json#/definitions/id" 11 | }, 12 | "created_on": { 13 | "$ref": "../definitions.json#/definitions/created_on" 14 | }, 15 | "modified_on": { 16 | "$ref": "../definitions.json#/definitions/modified_on" 17 | }, 18 | "name": { 19 | "description": "Name", 20 | "example": "Jamie Curnow", 21 | "type": "string", 22 | "minLength": 2, 23 | "maxLength": 100 24 | }, 25 | "nickname": { 26 | "description": "Nickname", 27 | "example": "Jamie", 28 | "type": "string", 29 | "minLength": 2, 30 | "maxLength": 50 31 | }, 32 | "email": { 33 | "$ref": "../definitions.json#/definitions/email" 34 | }, 35 | "avatar": { 36 | "description": "Avatar", 37 | "example": "http://somewhere.jpg", 38 | "type": "string", 39 | "minLength": 2, 40 | "maxLength": 150, 41 | "readOnly": true 42 | }, 43 | "roles": { 44 | "description": "Roles", 45 | "example": [ 46 | "admin" 47 | ], 48 | "type": "array" 49 | }, 50 | "is_disabled": { 51 | "description": "Is Disabled", 52 | "example": false, 53 | "type": "boolean" 54 | } 55 | }, 56 | "links": [ 57 | { 58 | "title": "List", 59 | "description": "Returns a list of Users", 60 | "href": "/users", 61 | "access": "private", 62 | "method": "GET", 63 | "rel": "self", 64 | "http_header": { 65 | "$ref": "../examples.json#/definitions/auth_header" 66 | }, 67 | "targetSchema": { 68 | "type": "array", 69 | "items": { 70 | "$ref": "#/properties" 71 | } 72 | } 73 | }, 74 | { 75 | "title": "Create", 76 | "description": "Creates a new User", 77 | "href": "/users", 78 | "access": "private", 79 | "method": "POST", 80 | "rel": "create", 81 | "http_header": { 82 | "$ref": "../examples.json#/definitions/auth_header" 83 | }, 84 | "schema": { 85 | "type": "object", 86 | "required": [ 87 | "name", 88 | "nickname", 89 | "email" 90 | ], 91 | "properties": { 92 | "name": { 93 | "$ref": "#/definitions/name" 94 | }, 95 | "nickname": { 96 | "$ref": "#/definitions/nickname" 97 | }, 98 | "email": { 99 | "$ref": "#/definitions/email" 100 | }, 101 | "roles": { 102 | "$ref": "#/definitions/roles" 103 | }, 104 | "is_disabled": { 105 | "$ref": "#/definitions/is_disabled" 106 | }, 107 | "auth": { 108 | "type": "object", 109 | "description": "Auth Credentials", 110 | "example": { 111 | "type": "password", 112 | "secret": "bigredhorsebanana" 113 | } 114 | } 115 | } 116 | }, 117 | "targetSchema": { 118 | "properties": { 119 | "$ref": "#/properties" 120 | } 121 | } 122 | }, 123 | { 124 | "title": "Update", 125 | "description": "Updates a existing User", 126 | "href": "/users/{definitions.identity.example}", 127 | "access": "private", 128 | "method": "PUT", 129 | "rel": "update", 130 | "http_header": { 131 | "$ref": "../examples.json#/definitions/auth_header" 132 | }, 133 | "schema": { 134 | "type": "object", 135 | "properties": { 136 | "name": { 137 | "$ref": "#/definitions/name" 138 | }, 139 | "nickname": { 140 | "$ref": "#/definitions/nickname" 141 | }, 142 | "email": { 143 | "$ref": "#/definitions/email" 144 | }, 145 | "roles": { 146 | "$ref": "#/definitions/roles" 147 | }, 148 | "is_disabled": { 149 | "$ref": "#/definitions/is_disabled" 150 | } 151 | } 152 | }, 153 | "targetSchema": { 154 | "properties": { 155 | "$ref": "#/properties" 156 | } 157 | } 158 | }, 159 | { 160 | "title": "Delete", 161 | "description": "Deletes a existing User", 162 | "href": "/users/{definitions.identity.example}", 163 | "access": "private", 164 | "method": "DELETE", 165 | "rel": "delete", 166 | "http_header": { 167 | "$ref": "../examples.json#/definitions/auth_header" 168 | }, 169 | "targetSchema": { 170 | "type": "boolean" 171 | } 172 | }, 173 | { 174 | "title": "Set Password", 175 | "description": "Sets a password for an existing User", 176 | "href": "/users/{definitions.identity.example}/auth", 177 | "access": "private", 178 | "method": "PUT", 179 | "rel": "update", 180 | "http_header": { 181 | "$ref": "../examples.json#/definitions/auth_header" 182 | }, 183 | "schema": { 184 | "type": "object", 185 | "required": [ 186 | "type", 187 | "secret" 188 | ], 189 | "properties": { 190 | "type": { 191 | "type": "string", 192 | "pattern": "^password$" 193 | }, 194 | "current": { 195 | "type": "string", 196 | "minLength": 1, 197 | "maxLength": 64 198 | }, 199 | "secret": { 200 | "type": "string", 201 | "minLength": 8, 202 | "maxLength": 64 203 | } 204 | } 205 | }, 206 | "targetSchema": { 207 | "type": "boolean" 208 | } 209 | }, 210 | { 211 | "title": "Set Service Settings", 212 | "description": "Sets service settings for an existing User", 213 | "href": "/users/{definitions.identity.example}/services", 214 | "access": "private", 215 | "method": "POST", 216 | "rel": "update", 217 | "http_header": { 218 | "$ref": "../examples.json#/definitions/auth_header" 219 | }, 220 | "schema": { 221 | "type": "object", 222 | "required": [ 223 | "settings" 224 | ], 225 | "properties": { 226 | "settings": { 227 | "type": "object" 228 | } 229 | } 230 | }, 231 | "targetSchema": { 232 | "type": "boolean" 233 | } 234 | } 235 | ], 236 | "properties": { 237 | "id": { 238 | "$ref": "#/definitions/id" 239 | }, 240 | "created_on": { 241 | "$ref": "#/definitions/created_on" 242 | }, 243 | "modified_on": { 244 | "$ref": "#/definitions/modified_on" 245 | }, 246 | "name": { 247 | "$ref": "#/definitions/name" 248 | }, 249 | "nickname": { 250 | "$ref": "#/definitions/nickname" 251 | }, 252 | "email": { 253 | "$ref": "#/definitions/email" 254 | }, 255 | "avatar": { 256 | "$ref": "#/definitions/avatar" 257 | }, 258 | "roles": { 259 | "$ref": "#/definitions/roles" 260 | }, 261 | "is_disabled": { 262 | "$ref": "#/definitions/is_disabled" 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/backend/schema/examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "examples", 4 | "type": "object", 5 | "definitions": { 6 | "name": { 7 | "description": "Name", 8 | "example": "John Smith", 9 | "type": "string", 10 | "minLength": 1, 11 | "maxLength": 255 12 | }, 13 | "auth_header": { 14 | "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", 15 | "X-API-Version": "next" 16 | }, 17 | "token": { 18 | "type": "string", 19 | "description": "JWT", 20 | "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/backend/schema/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Juxtapose REST API", 4 | "description": "This is the Juxtapose REST API", 5 | "$id": "root", 6 | "version": "1.0.0", 7 | "links": [ 8 | { 9 | "href": "http://juxtapose/api", 10 | "rel": "self" 11 | } 12 | ], 13 | "properties": { 14 | "tokens": { 15 | "$ref": "endpoints/tokens.json" 16 | }, 17 | "users": { 18 | "$ref": "endpoints/users.json" 19 | }, 20 | "services": { 21 | "$ref": "endpoints/services.json" 22 | }, 23 | "templates": { 24 | "$ref": "endpoints/templates.json" 25 | }, 26 | "rules": { 27 | "$ref": "endpoints/rules.json" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #f5f5f5 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/favicon.ico -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jc21/docker-registry-ui/2643028aeda5b36bd01555e8954b7245017b75d1/src/frontend/app-images/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 47 | 49 | 51 | 53 | 55 | 67 | 69 | 71 | 72 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/frontend/app-images/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/images/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/images/favicons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/frontend/fonts: -------------------------------------------------------------------------------- 1 | ../../node_modules/tabler-ui/dist/assets/fonts -------------------------------------------------------------------------------- /src/frontend/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Docker Registry UI 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 |
32 | 37 |
38 |
39 |
40 | 41 | 42 |
43 |
44 | 62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /src/frontend/images: -------------------------------------------------------------------------------- 1 | ../../node_modules/tabler-ui/dist/assets/images -------------------------------------------------------------------------------- /src/frontend/js/actions.js: -------------------------------------------------------------------------------- 1 | import {location} from 'hyperapp-hash-router'; 2 | import Api from './lib/api'; 3 | import $ from 'jquery'; 4 | import moment from 'moment'; 5 | 6 | const fetching = {}; 7 | 8 | const actions = { 9 | location: location.actions, 10 | 11 | /** 12 | * @param state 13 | * @returns {*} 14 | */ 15 | updateState: state => state, 16 | 17 | /** 18 | * @returns {Function} 19 | */ 20 | bootstrap: () => async (state, actions) => { 21 | try { 22 | let status = await Api.status(); 23 | $('#version_number').text([status.version.major, status.version.minor, status.version.revision].join('.')); 24 | let repos = await Api.Repos.getAll(true); 25 | 26 | // Hack to remove any image that has no tags 27 | let clean_repos = []; 28 | repos.map(repo => { 29 | if (typeof repo.tags !== 'undefined' && repo.tags !== null && repo.tags.length) { 30 | clean_repos.push(repo); 31 | } 32 | }); 33 | 34 | actions.updateState({isLoading: false, status: status, repos: clean_repos, globalError: null}); 35 | } catch (err) { 36 | actions.updateState({isLoading: false, globalError: err}); 37 | } 38 | }, 39 | 40 | /** 41 | * @returns {Function} 42 | */ 43 | fetchImage: image_id => async (state, actions) => { 44 | if (typeof fetching[image_id] === 'undefined' || !fetching[image_id]) { 45 | fetching[image_id] = true; 46 | 47 | let image_item = { 48 | err: null, 49 | timestamp: parseInt(moment().format('X'), 10), 50 | data: null 51 | }; 52 | 53 | try { 54 | image_item.data = await Api.Repos.get(image_id, true); 55 | } catch (err) { 56 | image_item.err = err; 57 | } 58 | 59 | let new_state = {images: state.images}; 60 | new_state.images[image_id] = image_item; 61 | actions.updateState(new_state); 62 | fetching[image_id] = false; 63 | } 64 | }, 65 | 66 | deleteImageClicked: e => async (state, actions) => { 67 | let $btn = $(e.currentTarget).addClass('btn-loading disabled').prop('disabled', true); 68 | let $modal = $btn.parents('.modal').first(); 69 | let image_id = $btn.data('image_id'); 70 | 71 | Api.Repos.delete(image_id, $btn.data('digest')) 72 | .then(result => { 73 | if (typeof result.code !== 'undefined' && result.code === 'UNSUPPORTED') { 74 | throw new Error('Deleting is not enabled on the Registry'); 75 | } else if (result === true) { 76 | $modal.modal('hide'); 77 | 78 | let new_state = { 79 | isLoaded: true, 80 | images: state.images 81 | }; 82 | 83 | delete new_state.images[image_id]; 84 | 85 | setTimeout(function () { 86 | actions.updateState(new_state); 87 | 88 | actions.location.go('/'); 89 | actions.bootstrap(); 90 | }, 300); 91 | } else { 92 | throw new Error('Unrecognized response: ' + JSON.stringify(result)); 93 | } 94 | }) 95 | .catch(err => { 96 | console.error(err); 97 | $modal.find('.modal-body').append($('

').addClass('text-danger').text(err.message)); 98 | $btn.removeClass('btn-loading disabled').prop('disabled', false); 99 | }); 100 | } 101 | }; 102 | 103 | export default actions; 104 | -------------------------------------------------------------------------------- /src/frontend/js/components/app/image-tag.js: -------------------------------------------------------------------------------- 1 | import {div, h3, p, a} from '@hyperapp/html'; 2 | import Utils from '../../lib/utils'; 3 | 4 | export default (tag, config) => { 5 | let total_size = 0; 6 | if (typeof tag.layers !== 'undefined' && tag.layers) { 7 | tag.layers.map(layer => total_size += layer.size); 8 | total_size = total_size / 1024 / 1024; 9 | total_size = total_size.toFixed(0); 10 | } 11 | 12 | let domain = config.REGISTRY_DOMAIN || window.location.hostname; 13 | 14 | return div({class: 'card tag-card'}, [ 15 | div({class: 'card-header'}, 16 | h3({class: 'card-title'}, tag.name) 17 | ), 18 | div({class: 'card-alert alert alert-secondary mb-0 pull-command'}, 19 | 'docker pull ' + domain + '/' + tag.image_name + ':' + tag.name 20 | ), 21 | div({class: 'card-body'}, 22 | div({class: 'row'}, [ 23 | div({class: 'col-lg-3 col-sm-6'}, [ 24 | div({class: 'h6'}, 'Image ID'), 25 | p(Utils.getShortDigestId(tag.config.digest)) 26 | ]), 27 | div({class: 'col-lg-3 col-sm-6'}, [ 28 | div({class: 'h6'}, 'Author'), 29 | p(tag.info.author) 30 | ]), 31 | div({class: 'col-lg-3 col-sm-6'}, [ 32 | div({class: 'h6'}, 'Docker Version'), 33 | p(tag.info.docker_version) 34 | ]), 35 | div({class: 'col-lg-3 col-sm-6'}, [ 36 | div({class: 'h6'}, 'Size'), 37 | p(total_size ? total_size + ' mb' : 'Unknown') 38 | ]) 39 | ]) 40 | ) 41 | ]); 42 | } 43 | -------------------------------------------------------------------------------- /src/frontend/js/components/app/insecure-registries.js: -------------------------------------------------------------------------------- 1 | import {div, h3, h4, p, pre, code} from '@hyperapp/html'; 2 | 3 | export default domain => div({class: 'card'}, 4 | div({class: 'card-header'}, 5 | h3({class: 'card-title'}, 'Insecure Registries') 6 | ), 7 | div({class: 'card-body'}, [ 8 | p('If this registry is insecure and doesn\'t hide behind SSL certificates then you will need to configure your Docker client to allow pushing to this insecure registry.'), 9 | h4('Linux'), 10 | p('Edit or you may even need to create the following file on your Linux server:'), 11 | pre( 12 | code('/etc/docker/daemon.json') 13 | ), 14 | p('And save the following content:'), 15 | pre( 16 | code(JSON.stringify({'insecure-registries': [domain]}, null, 2)) 17 | ), 18 | p('You will need to restart your Docker service before these changes will take effect.') 19 | ]) 20 | ); 21 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/big-error.js: -------------------------------------------------------------------------------- 1 | import {div, i, h1, p, a} from '@hyperapp/html'; 2 | 3 | /** 4 | * @param {Number} code 5 | * @param {String} message 6 | * @param {*} [detail] 7 | * @para, {Boolean} [hide_back_button] 8 | */ 9 | export default (code, message, detail, hide_back_button) => 10 | div({class: 'container text-center'}, [ 11 | div({class: 'display-1 text-muted mb-5'}, [ 12 | i({class: 'si si-exclamation'}), 13 | code 14 | ]), 15 | h1({class: 'h2 mb-3'}, message), 16 | p({class: 'h4 text-muted font-weight-normal mb-7'}, detail), 17 | hide_back_button ? null : a({class: 'btn btn-primary', href: 'javascript:history.back();'}, [ 18 | i({class: 'fe fe-arrow-left mr-2'}), 19 | 'Go back' 20 | ]) 21 | ]); 22 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/icon-stat-card.js: -------------------------------------------------------------------------------- 1 | import {div, i, span, h4, small} from '@hyperapp/html'; 2 | import Utils from '../../lib/utils'; 3 | 4 | /** 5 | * @param {String|Number} stat_number 6 | * @param {String} stat_text 7 | * @param {String} icon without 'fe-' prefix 8 | * @param {String} color ie: 'green' from tabler 'bg-' class names 9 | */ 10 | export default (stat_number, stat_text, icon, color) => 11 | div({class: 'card p-3'}, 12 | div({class: 'd-flex align-items-center'}, [ 13 | span({class: 'stamp stamp-md bg-' + color + ' mr-3'}, 14 | i({class: 'fe fe-' + icon}) 15 | ), 16 | div({}, 17 | h4({class: 'm-0'}, [ 18 | typeof stat_number === 'number' ? Utils.niceNumber(stat_number) : stat_number, 19 | small(' ' + stat_text) 20 | ]) 21 | ) 22 | ]) 23 | ); 24 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/modal.js: -------------------------------------------------------------------------------- 1 | import {div} from '@hyperapp/html'; 2 | import $ from 'jquery'; 3 | 4 | export default (content, onclose) => div({class: 'modal fade', tabindex: '-1', role: 'dialog', ariaHidden: 'true', oncreate: function (elm) { 5 | let modal = $(elm); 6 | modal.modal('show'); 7 | 8 | if (typeof onclose === 'function') { 9 | modal.on('hidden.bs.modal', onclose); 10 | } 11 | }}, content); 12 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/nav.js: -------------------------------------------------------------------------------- 1 | import {div, i, ul, li, a} from '@hyperapp/html'; 2 | import {Link} from 'hyperapp-hash-router'; 3 | 4 | export default (show_delete) => { 5 | 6 | let selected = 'images'; 7 | if (window.location.hash.substr(0, 14) === '#/instructions') { 8 | selected = 'instructions'; 9 | } 10 | 11 | return div({class: 'header collapse d-lg-flex p-0', id: 'headerMenuCollapse'}, 12 | div({class: 'container'}, 13 | div({class: 'row align-items-center'}, 14 | div({class: 'col-lg order-lg-first'}, [ 15 | ul({class: 'nav nav-tabs border-0 flex-column flex-lg-row'}, [ 16 | li({class: 'nav-item'}, 17 | Link({class: 'nav-link' + (selected === 'images' ? ' active' : ''), to: '/'}, [ 18 | i({class: 'fe fe-box'}), 19 | 'Images' 20 | ]) 21 | ), 22 | li({class: 'nav-item'}, [ 23 | a({class: 'nav-link' + (selected === 'instructions' ? ' active' : ''), href: 'javascript:void(0)', 'data-toggle': 'dropdown'}, [ 24 | i({class: 'fe fe-feather'}), 25 | 'Instructions' 26 | ]), 27 | div({class: 'dropdown-menu dropdown-menu-arrow'}, [ 28 | Link({class: 'dropdown-item', to: '/instructions/pulling'}, 'Pulling'), 29 | Link({class: 'dropdown-item', to: '/instructions/pushing'}, 'Pushing'), 30 | show_delete ? Link({class: 'dropdown-item', to: '/instructions/deleting'}, 'Deleting') : null 31 | ]) 32 | ]) 33 | ]) 34 | ]) 35 | ) 36 | ) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/stat-card.js: -------------------------------------------------------------------------------- 1 | import {div, i} from '@hyperapp/html'; 2 | import Utils from '../../lib/utils'; 3 | 4 | /** 5 | * @param {String|Number} big_stat 6 | * @param {String} stat_text 7 | * @param {String} small_stat 8 | * @param {Boolean} negative If truthy, shows as red. Otherwise, green. 9 | */ 10 | export default (big_stat, stat_text, small_stat, negative) => 11 | div({class: 'card'}, 12 | div({class: 'card-body p-3 text-center'}, [ 13 | small_stat ? div({class: 'text-right ' + (negative ? 'text-red' : 'text-green')}, [ 14 | small_stat, 15 | i({class: 'fe ' + (negative ? 'fe-chevron-down' : 'fe-chevron-up')}) 16 | ]) : null, 17 | div({class: 'h1 m-0'}, typeof big_stat === 'number' ? Utils.niceNumber(big_stat) : big_stat), 18 | div({class: 'text-muted mb-4'}, stat_text) 19 | ]) 20 | ); 21 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/table-body.js: -------------------------------------------------------------------------------- 1 | import {tbody} from '@hyperapp/html'; 2 | import Trow from './table-row'; 3 | import _ from 'lodash'; 4 | 5 | /** 6 | * @param {Object} fields 7 | * @param {Array} rows 8 | */ 9 | export default (fields, rows) => { 10 | let field_keys = []; 11 | 12 | _.map(fields, (val, key) => { 13 | field_keys.push(key); 14 | }); 15 | 16 | return tbody(rows.map(row => { 17 | return Trow(_.pick(row, field_keys), fields); 18 | })); 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/table-card.js: -------------------------------------------------------------------------------- 1 | import {div, table} from '@hyperapp/html'; 2 | import Thead from './table-head'; 3 | import Tbody from './table-body'; 4 | 5 | /** 6 | * @param {Array} header 7 | * @param {Object} fields 8 | * @param {Array} rows 9 | */ 10 | export default (header, fields, rows) => 11 | div({class: 'card'}, 12 | div({class: 'table-responsive'}, 13 | table({class: 'table table-hover table-outline table-vcenter text-nowrap card-table'}, [ 14 | Thead(header), 15 | Tbody(fields, rows) 16 | ]) 17 | ) 18 | ); 19 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/table-head.js: -------------------------------------------------------------------------------- 1 | import {thead, tr, th} from '@hyperapp/html'; 2 | import _ from 'lodash'; 3 | 4 | /** 5 | * @param {Array} header 6 | */ 7 | export default function (header) { 8 | let cells = []; 9 | 10 | _.map(header, cell => { 11 | if (typeof cell === 'object' && typeof cell.class !== 'undefined' && cell.class) { 12 | cells.push(th({class: cell.class}, cell.value)); 13 | } else { 14 | cells.push(th(cell)); 15 | } 16 | }); 17 | 18 | return thead({}, 19 | tr({}, cells) 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/frontend/js/components/tabler/table-row.js: -------------------------------------------------------------------------------- 1 | import {tr, td} from '@hyperapp/html'; 2 | import _ from 'lodash'; 3 | 4 | /** 5 | * @param {Object} row 6 | * @param {Object} fields 7 | */ 8 | export default function (row, fields) { 9 | let cells = []; 10 | 11 | _.map(row, (cell, key) => { 12 | let manipulator = fields[key].manipulator || null; 13 | let value = cell; 14 | 15 | if (typeof cell === 'object' && cell !== null && typeof cell.value !== 'undefined') { 16 | value = cell.value; 17 | } 18 | 19 | if (typeof manipulator === 'function') { 20 | value = manipulator(value, cell); 21 | } 22 | 23 | if (typeof cell.attributes !== 'undefined' && cell.attributes) { 24 | cells.push(td(cell.attributes, value)); 25 | } else { 26 | cells.push(td(value)); 27 | } 28 | }); 29 | 30 | return tr(cells); 31 | }; 32 | -------------------------------------------------------------------------------- /src/frontend/js/index.js: -------------------------------------------------------------------------------- 1 | // This has to exist here so that Webpack picks it up 2 | import '../scss/styles.scss'; 3 | 4 | import $ from 'jquery'; 5 | import {app} from 'hyperapp'; 6 | import actions from './actions'; 7 | import state from './state'; 8 | import {location} from 'hyperapp-hash-router'; 9 | import router from './router'; 10 | 11 | global.jQuery = $; 12 | global.$ = $; 13 | 14 | window.tabler = { 15 | colors: { 16 | 'blue': '#467fcf', 17 | 'blue-darkest': '#0e1929', 18 | 'blue-darker': '#1c3353', 19 | 'blue-dark': '#3866a6', 20 | 'blue-light': '#7ea5dd', 21 | 'blue-lighter': '#c8d9f1', 22 | 'blue-lightest': '#edf2fa', 23 | 'azure': '#45aaf2', 24 | 'azure-darkest': '#0e2230', 25 | 'azure-darker': '#1c4461', 26 | 'azure-dark': '#3788c2', 27 | 'azure-light': '#7dc4f6', 28 | 'azure-lighter': '#c7e6fb', 29 | 'azure-lightest': '#ecf7fe', 30 | 'indigo': '#6574cd', 31 | 'indigo-darkest': '#141729', 32 | 'indigo-darker': '#282e52', 33 | 'indigo-dark': '#515da4', 34 | 'indigo-light': '#939edc', 35 | 'indigo-lighter': '#d1d5f0', 36 | 'indigo-lightest': '#f0f1fa', 37 | 'purple': '#a55eea', 38 | 'purple-darkest': '#21132f', 39 | 'purple-darker': '#42265e', 40 | 'purple-dark': '#844bbb', 41 | 'purple-light': '#c08ef0', 42 | 'purple-lighter': '#e4cff9', 43 | 'purple-lightest': '#f6effd', 44 | 'pink': '#f66d9b', 45 | 'pink-darkest': '#31161f', 46 | 'pink-darker': '#622c3e', 47 | 'pink-dark': '#c5577c', 48 | 'pink-light': '#f999b9', 49 | 'pink-lighter': '#fcd3e1', 50 | 'pink-lightest': '#fef0f5', 51 | 'red': '#e74c3c', 52 | 'red-darkest': '#2e0f0c', 53 | 'red-darker': '#5c1e18', 54 | 'red-dark': '#b93d30', 55 | 'red-light': '#ee8277', 56 | 'red-lighter': '#f8c9c5', 57 | 'red-lightest': '#fdedec', 58 | 'orange': '#fd9644', 59 | 'orange-darkest': '#331e0e', 60 | 'orange-darker': '#653c1b', 61 | 'orange-dark': '#ca7836', 62 | 'orange-light': '#feb67c', 63 | 'orange-lighter': '#fee0c7', 64 | 'orange-lightest': '#fff5ec', 65 | 'yellow': '#f1c40f', 66 | 'yellow-darkest': '#302703', 67 | 'yellow-darker': '#604e06', 68 | 'yellow-dark': '#c19d0c', 69 | 'yellow-light': '#f5d657', 70 | 'yellow-lighter': '#fbedb7', 71 | 'yellow-lightest': '#fef9e7', 72 | 'lime': '#7bd235', 73 | 'lime-darkest': '#192a0b', 74 | 'lime-darker': '#315415', 75 | 'lime-dark': '#62a82a', 76 | 'lime-light': '#a3e072', 77 | 'lime-lighter': '#d7f2c2', 78 | 'lime-lightest': '#f2fbeb', 79 | 'green': '#5eba00', 80 | 'green-darkest': '#132500', 81 | 'green-darker': '#264a00', 82 | 'green-dark': '#4b9500', 83 | 'green-light': '#8ecf4d', 84 | 'green-lighter': '#cfeab3', 85 | 'green-lightest': '#eff8e6', 86 | 'teal': '#2bcbba', 87 | 'teal-darkest': '#092925', 88 | 'teal-darker': '#11514a', 89 | 'teal-dark': '#22a295', 90 | 'teal-light': '#6bdbcf', 91 | 'teal-lighter': '#bfefea', 92 | 'teal-lightest': '#eafaf8', 93 | 'cyan': '#17a2b8', 94 | 'cyan-darkest': '#052025', 95 | 'cyan-darker': '#09414a', 96 | 'cyan-dark': '#128293', 97 | 'cyan-light': '#5dbecd', 98 | 'cyan-lighter': '#b9e3ea', 99 | 'cyan-lightest': '#e8f6f8', 100 | 'gray': '#868e96', 101 | 'gray-darkest': '#1b1c1e', 102 | 'gray-darker': '#36393c', 103 | 'gray-light': '#aab0b6', 104 | 'gray-lighter': '#dbdde0', 105 | 'gray-lightest': '#f3f4f5', 106 | 'gray-dark': '#343a40', 107 | 'gray-dark-darkest': '#0a0c0d', 108 | 'gray-dark-darker': '#15171a', 109 | 'gray-dark-dark': '#2a2e33', 110 | 'gray-dark-light': '#717579', 111 | 'gray-dark-lighter': '#c2c4c6', 112 | 'gray-dark-lightest': '#ebebec' 113 | } 114 | }; 115 | 116 | import tabler from 'tabler-core'; 117 | 118 | const main = app( 119 | state, 120 | actions, 121 | router, 122 | document.getElementById('app') 123 | ); 124 | 125 | location.subscribe(main.location); 126 | 127 | main.bootstrap(); 128 | setInterval(main.bootstrap, 30000); 129 | -------------------------------------------------------------------------------- /src/frontend/js/lib/api.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | /** 4 | * @param {String} message 5 | * @param {*} debug 6 | * @param {Integer} [code] 7 | * @constructor 8 | */ 9 | const ApiError = function (message, debug, code) { 10 | let temp = Error.call(this, message); 11 | temp.name = this.name = 'ApiError'; 12 | this.stack = temp.stack; 13 | this.message = temp.message; 14 | this.debug = debug; 15 | this.code = code; 16 | }; 17 | 18 | ApiError.prototype = Object.create(Error.prototype, { 19 | constructor: { 20 | value: ApiError, 21 | writable: true, 22 | configurable: true 23 | } 24 | }); 25 | 26 | /** 27 | * 28 | * @param {String} verb 29 | * @param {String} path 30 | * @param {Object} [data] 31 | * @param {Object} [options] 32 | * @returns {Promise} 33 | */ 34 | function fetch (verb, path, data, options) { 35 | options = options || {}; 36 | 37 | return new Promise(function (resolve, reject) { 38 | let api_url = '/api/'; 39 | let url = api_url + path; 40 | 41 | $.ajax({ 42 | url: url, 43 | data: typeof data === 'object' ? JSON.stringify(data) : data, 44 | type: verb, 45 | dataType: 'json', 46 | contentType: 'application/json; charset=UTF-8', 47 | crossDomain: true, 48 | timeout: (options.timeout ? options.timeout : 15000), 49 | xhrFields: { 50 | withCredentials: true 51 | }, 52 | 53 | success: function (data, textStatus, response) { 54 | let total = response.getResponseHeader('X-Dataset-Total'); 55 | if (total !== null) { 56 | resolve({ 57 | data: data, 58 | pagination: { 59 | total: parseInt(total, 10), 60 | offset: parseInt(response.getResponseHeader('X-Dataset-Offset'), 10), 61 | limit: parseInt(response.getResponseHeader('X-Dataset-Limit'), 10) 62 | } 63 | }); 64 | } else { 65 | resolve(response); 66 | } 67 | }, 68 | 69 | error: function (xhr, status, error_thrown) { 70 | let code = 400; 71 | 72 | if (typeof xhr.responseJSON !== 'undefined' && typeof xhr.responseJSON.error !== 'undefined' && typeof xhr.responseJSON.error.message !== 'undefined') { 73 | error_thrown = xhr.responseJSON.error.message; 74 | code = xhr.responseJSON.error.code || 500; 75 | } 76 | 77 | reject(new ApiError(error_thrown, xhr.responseText, code)); 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | export default { 84 | status: function () { 85 | return fetch('get', ''); 86 | }, 87 | 88 | Repos: { 89 | /** 90 | * @param {Boolean} [with_tags] 91 | * @returns {Promise} 92 | */ 93 | getAll: function (with_tags) { 94 | return fetch('get', 'repos' + (with_tags ? '?tags=1' : '')); 95 | }, 96 | 97 | /** 98 | * @param {String} name 99 | * @param {Boolean} [full] 100 | * @returns {Promise} 101 | */ 102 | get: function (name, full) { 103 | return fetch('get', 'repos/' + name + (full ? '?full=1' : '')); 104 | }, 105 | 106 | /** 107 | * @param {String} name 108 | * @param {String} [digest] 109 | * @returns {Promise} 110 | */ 111 | delete: function (name, digest) { 112 | return fetch('delete', 'repos/' + name + '?digest=' + digest); 113 | } 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /src/frontend/js/lib/manipulators.js: -------------------------------------------------------------------------------- 1 | import {div} from '@hyperapp/html'; 2 | import {Link} from 'hyperapp-hash-router'; 3 | 4 | export default { 5 | 6 | /** 7 | * @returns {Function} 8 | */ 9 | imageName: function () { 10 | return (value, cell) => { 11 | return Link({to: '/image/' + value}, value); 12 | } 13 | }, 14 | 15 | /** 16 | * @param {String} delimiter 17 | * @returns {Function} 18 | */ 19 | joiner: delimiter => (value, cell) => value.join(delimiter) 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /src/frontend/js/lib/utils.js: -------------------------------------------------------------------------------- 1 | import numeral from 'numeral'; 2 | 3 | export default { 4 | 5 | /** 6 | * @param {Integer} number 7 | * @returns {String} 8 | */ 9 | niceNumber: function (number) { 10 | return numeral(number).format('0,0'); 11 | }, 12 | 13 | /** 14 | * @param {String} digest 15 | * @returns {String} 16 | */ 17 | getShortDigestId: function (digest) { 18 | return digest.replace(/^sha256:(.{12}).*/gim, '$1'); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/frontend/js/router.js: -------------------------------------------------------------------------------- 1 | import {Route} from 'hyperapp-hash-router'; 2 | import {div, span, a, p} from '@hyperapp/html'; 3 | import ImagesRoute from './routes/images'; 4 | import ImageRoute from './routes/image'; 5 | import PushingRoute from './routes/instructions/pushing'; 6 | import PullingRoute from './routes/instructions/pulling'; 7 | import DeletingRoute from './routes/instructions/deleting'; 8 | import BigError from './components/tabler/big-error'; 9 | 10 | export default (state, actions) => { 11 | if (state.isLoading) { 12 | return span({class: 'loader'}); 13 | } else { 14 | 15 | if (state.globalError !== null && state.globalError) { 16 | return BigError(state.globalError.code || '500', state.globalError.message, 17 | [ 18 | p('There may be a problem communicating with the Registry'), 19 | a({ 20 | class: 'btn btn-link', onclick: function () { 21 | actions.bootstrap(); 22 | } 23 | }, 'Refresh') 24 | ], 25 | true 26 | ); 27 | } else { 28 | return div( 29 | Route({path: '/', render: ImagesRoute(state, actions)}), 30 | Route({path: '/image/:imageId', render: ImageRoute(state, actions)}), 31 | Route({path: '/image/:imageDomain/:imageId', render: ImageRoute(state, actions)}), 32 | Route({path: '/instructions/pushing', render: PushingRoute(state, actions)}), 33 | Route({path: '/instructions/pulling', render: PullingRoute(state, actions)}), 34 | Route({path: '/instructions/deleting', render: DeletingRoute(state, actions)}) 35 | ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/frontend/js/routes/image.js: -------------------------------------------------------------------------------- 1 | import {div, h1, span, a, h4, button, p} from '@hyperapp/html'; 2 | import Nav from '../components/tabler/nav'; 3 | import BigError from '../components/tabler/big-error'; 4 | import ImageTag from '../components/app/image-tag'; 5 | import Modal from '../components/tabler/modal'; 6 | import moment from 'moment'; 7 | 8 | export default (state, actions) => params => { 9 | let image_id = params.match.params.imageId; 10 | let view = []; 11 | let delete_enabled = state.status.config.REGISTRY_STORAGE_DELETE_ENABLED || false; 12 | let refresh = false; 13 | let digest = null; 14 | let now = parseInt(moment().format('X'), 10); 15 | let append_delete_model = false; 16 | let image = null; 17 | 18 | if (typeof params.match.params.imageDomain !== 'undefined' && params.match.params.imageDomain.length > 0) { 19 | image_id = [params.match.params.imageDomain, image_id].join('/'); 20 | } 21 | 22 | // if image doesn't exist in state: refresh 23 | if (typeof state.images[image_id] === 'undefined' || !state.images[image_id]) { 24 | refresh = true; 25 | } else { 26 | image = state.images[image_id]; 27 | 28 | // if image does exist, but hasn't been refreshed in < 30 seconds, refresh 29 | if (image.timestamp < (now - 30)) { 30 | refresh = true; 31 | 32 | // if image does exist, but has error, show error 33 | } else if (image.err) { 34 | view.push(BigError(image.err.code, image.err.message, 35 | a({ 36 | class: 'btn btn-link', onclick: function () { 37 | actions.fetchImage(image_id); 38 | } 39 | }, 'Refresh') 40 | )); 41 | 42 | // if image does exist, but has no error and no data, 404 43 | } else if (!image.data || typeof image.data.tags === 'undefined' || image.data.tags === null || !image.data.tags.length) { 44 | view.push(BigError(404, image_id + ' does not exist in this Registry', 45 | a({ 46 | class: 'btn btn-link', onclick: function () { 47 | actions.fetchImage(image_id); 48 | } 49 | }, 'Refresh') 50 | )); 51 | } else { 52 | // Show it 53 | // This is where shit gets weird. Digest is the same for all tags, but only stored with a tag. 54 | digest = image.data.tags[0].digest; 55 | append_delete_model = delete_enabled && state.confirmDeleteImage === image_id; 56 | 57 | view.push(h1({class: 'page-title mb-5'}, [ 58 | delete_enabled ? a({ 59 | class: 'btn btn-secondary btn-sm ml-2 pull-right', onclick: function () { 60 | actions.updateState({confirmDeleteImage: image_id}); 61 | } 62 | }, 'Delete') : null, 63 | image_id 64 | ])); 65 | view.push(div(image.data.tags.map(tag => ImageTag(tag, state.status.config)))); 66 | } 67 | } 68 | 69 | if (refresh) { 70 | view.push(span({class: 'loader'})); 71 | actions.fetchImage(image_id); 72 | } 73 | 74 | return div( 75 | Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED), 76 | div({class: 'my-3 my-md-5'}, 77 | div({class: 'container'}, view) 78 | ), 79 | // Delete modal 80 | append_delete_model ? Modal( 81 | div({class: 'modal-dialog'}, 82 | div({class: 'modal-content'}, [ 83 | div({class: 'modal-header text-left'}, 84 | h4({class: 'modal-title'}, 'Confirm Delete') 85 | ), 86 | div({class: 'modal-body'}, 87 | p('Are you sure you want to delete this image and tag' + (image.data.tags.length === 1 ? '' : 's') + '?') 88 | ), 89 | div({class: 'modal-footer'}, [ 90 | button({ 91 | class: 'btn btn-danger', 92 | type: 'button', 93 | onclick: actions.deleteImageClicked, 94 | 'data-image_id': image_id, 95 | 'data-digest': digest 96 | }, 'Yes I\'m sure'), 97 | button({class: 'btn btn-default', type: 'button', 'data-dismiss': 'modal'}, 'Cancel') 98 | ]) 99 | ]) 100 | ), 101 | // onclose function 102 | function () { 103 | actions.updateState({confirmDeleteImage: null}); 104 | }) : null 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/frontend/js/routes/images.js: -------------------------------------------------------------------------------- 1 | import {Link} from 'hyperapp-hash-router'; 2 | import {div, h4, p} from '@hyperapp/html'; 3 | import Nav from '../components/tabler/nav'; 4 | import TableCard from '../components/tabler/table-card'; 5 | import Manipulators from '../lib/manipulators'; 6 | import {a} from '@hyperapp/html/dist/html'; 7 | 8 | export default (state, actions) => params => { 9 | let content = null; 10 | 11 | if (!state.repos || !state.repos.length) { 12 | // empty 13 | content = div({class: 'alert alert-success'}, [ 14 | h4('Nothing to see here!'), 15 | p('There are no images in this Registry yet.'), 16 | div({class: 'btn-list'}, 17 | Link({class: 'btn btn-success', to: '/instructions/pushing'}, 'How to push an image') 18 | ) 19 | ]); 20 | 21 | } else { 22 | content = TableCard([ 23 | 'Name', 24 | 'Tags' 25 | ], { 26 | name: {manipulator: Manipulators.imageName()}, 27 | tags: {manipulator: Manipulators.joiner(', ')} 28 | }, state.repos); 29 | } 30 | 31 | return div( 32 | Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED), 33 | div({class: 'my-3 my-md-5'}, 34 | div({class: 'container'}, content) 35 | ), 36 | p({class: 'text-center'}, 37 | a({ 38 | class: 'btn btn-link text-faded', onclick: function () { 39 | actions.bootstrap(); 40 | } 41 | }, 'Refresh') 42 | ) 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/frontend/js/routes/instructions/deleting.js: -------------------------------------------------------------------------------- 1 | import {div, h1, h3, p, pre, code} from '@hyperapp/html'; 2 | import Nav from '../../components/tabler/nav'; 3 | 4 | export default (state, actions) => params => div( 5 | Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED), 6 | div({class: 'my-3 my-md-5'}, 7 | div({class: 'container'}, [ 8 | h1({class: 'page-title mb-5'}, 'Deleting from this Registry'), 9 | div({class: 'card'}, 10 | div({class: 'card-body'}, 11 | p('Deleting from a Docker Registry is possible, but not very well implemented. For this reason, deletion options were disabled in this Registry UI project by default. However if you still want to be able to delete images from this registry you will need to set a few things up.'), 12 | ) 13 | ), 14 | div({class: 'card'}, [ 15 | div({class: 'card-header'}, 16 | h3({class: 'card-title'}, 'Permit deleting on the Registry') 17 | ), 18 | div({class: 'card-body'}, [ 19 | p('This step is pretty simple and involves adding an environment variable to your Docker Registry Container when you start it up:'), 20 | pre( 21 | code('docker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --name my-registry registry:2') 22 | ) 23 | ]) 24 | ]), 25 | div({class: 'card'}, [ 26 | div({class: 'card-header'}, 27 | h3({class: 'card-title'}, 'Cleaning up the Registry') 28 | ), 29 | div({class: 'card-body'}, [ 30 | p('When you delete an image from the registry this won\'t actually remove the blob layers as you might expect. For this reason you have to run this command on your docker registry host to perform garbage collection:'), 31 | pre( 32 | code('docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml') 33 | ), 34 | p('And if you wanted to make a cron job that runs every 30 mins:'), 35 | pre( 36 | code('0,30 * * * * /bin/docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml >> /dev/null 2>&1') 37 | ) 38 | ]) 39 | ]) 40 | ]) 41 | ) 42 | ); 43 | 44 | -------------------------------------------------------------------------------- /src/frontend/js/routes/instructions/pulling.js: -------------------------------------------------------------------------------- 1 | import {div, h1, p, pre, code} from '@hyperapp/html'; 2 | import Nav from '../../components/tabler/nav'; 3 | import Insecure from '../../components/app/insecure-registries'; 4 | 5 | export default (state, actions) => params => { 6 | let domain = state.status.config.REGISTRY_DOMAIN || window.location.hostname; 7 | 8 | return div( 9 | Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED), 10 | div({class: 'my-3 my-md-5'}, 11 | div({class: 'container'}, [ 12 | h1({class: 'page-title mb-5'}, 'Pulling from this Registry'), 13 | div({class: 'card'}, 14 | div({class: 'card-body'}, 15 | p('Viewing any Image from the Repositories menu will give you a command in the following format:'), 16 | pre( 17 | code('docker pull ' + domain + '/:') 18 | ) 19 | ) 20 | ), 21 | Insecure(domain) 22 | ]) 23 | ) 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/frontend/js/routes/instructions/pushing.js: -------------------------------------------------------------------------------- 1 | import {div, h1, p, pre, code} from '@hyperapp/html'; 2 | import Nav from '../../components/tabler/nav'; 3 | import Insecure from '../../components/app/insecure-registries'; 4 | 5 | export default (state, actions) => params => { 6 | let domain = state.status.config.REGISTRY_DOMAIN || window.location.hostname; 7 | 8 | return div( 9 | Nav(state.status.config.REGISTRY_STORAGE_DELETE_ENABLED), 10 | div({class: 'my-3 my-md-5'}, 11 | div({class: 'container'}, [ 12 | h1({class: 'page-title mb-5'}, 'Pushing to this Registry'), 13 | div({class: 'card'}, 14 | div({class: 'card-body'}, 15 | p('After you pull or build an image:'), 16 | pre( 17 | code('docker tag ' + domain + '/:' + "\n" + 18 | 'docker push ' + domain + '/:') 19 | ) 20 | ) 21 | ), 22 | Insecure(domain) 23 | ]) 24 | ) 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/frontend/js/state.js: -------------------------------------------------------------------------------- 1 | import {location} from 'hyperapp-hash-router'; 2 | 3 | export default { 4 | location: location.state, 5 | isLoading: true, 6 | globalError: null, 7 | confirmDeleteImage: null, 8 | images: {} 9 | }; 10 | -------------------------------------------------------------------------------- /src/frontend/scss/styles.scss: -------------------------------------------------------------------------------- 1 | @import "~tabler-ui/dist/assets/css/dashboard"; 2 | 3 | /* Before any JS content is loaded */ 4 | #app > .loader, .container > .loader { 5 | position: absolute; 6 | left: 49%; 7 | top: 40%; 8 | display: block; 9 | } 10 | 11 | .tag-card { 12 | .pull-command { 13 | font-family: Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 14 | } 15 | } 16 | 17 | .pull-right { 18 | float: right; 19 | } 20 | 21 | .text-faded { 22 | opacity: 0.5; 23 | } 24 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const Visualizer = require('webpack-visualizer-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = { 9 | entry: './src/frontend/js/index.js', 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'js/main.js', 13 | publicPath: '/' 14 | }, 15 | resolve: { 16 | alias: { 17 | 'tabler-core': 'tabler-ui/dist/assets/js/core', 18 | 'bootstrap': 'tabler-ui/dist/assets/js/vendors/bootstrap.bundle.min', 19 | 'sparkline': 'tabler-ui/dist/assets/js/vendors/jquery.sparkline.min', 20 | 'selectize': 'tabler-ui/dist/assets/js/vendors/selectize.min', 21 | 'tablesorter': 'tabler-ui/dist/assets/js/vendors/jquery.tablesorter.min', 22 | 'vector-map': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-2.0.3.min', 23 | 'vector-map-de': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-de-merc', 24 | 'vector-map-world': 'tabler-ui/dist/assets/js/vendors/jquery-jvectormap-world-mill', 25 | 'circle-progress': 'tabler-ui/dist/assets/js/vendors/circle-progress.min' 26 | } 27 | }, 28 | module: { 29 | rules: [ 30 | // Shims for tabler-ui 31 | { 32 | test: /assets\/js\/core/, 33 | loader: 'imports-loader?bootstrap' 34 | }, 35 | { 36 | test: /jquery-jvectormap-de-merc/, 37 | loader: 'imports-loader?vector-map' 38 | }, 39 | { 40 | test: /jquery-jvectormap-world-mill/, 41 | loader: 'imports-loader?vector-map' 42 | }, 43 | 44 | // other: 45 | { 46 | test: /\.js$/, 47 | exclude: /node_modules/, 48 | use: { 49 | loader: 'babel-loader' 50 | } 51 | }, 52 | { 53 | test: /\.html$/, 54 | use: [ 55 | { 56 | loader: 'html-loader', 57 | options: { 58 | minimize: false, 59 | hash: true 60 | } 61 | } 62 | ] 63 | }, 64 | { 65 | test: /\.scss$/, 66 | use: [ 67 | MiniCssExtractPlugin.loader, 68 | 'css-loader', 69 | 'sass-loader' 70 | ] 71 | }, 72 | { 73 | test: /.*tabler.*\.(jpe?g|gif|png|svg|eot|woff|ttf)$/, 74 | use: [ 75 | { 76 | loader: 'file-loader', 77 | options: { 78 | outputPath: 'assets/tabler-ui/' 79 | } 80 | } 81 | ] 82 | } 83 | ] 84 | }, 85 | plugins: [ 86 | new webpack.ProvidePlugin({ 87 | $: 'jquery', 88 | jQuery: 'jquery' 89 | }), 90 | new HtmlWebPackPlugin({ 91 | template: './src/frontend/html/index.html', 92 | filename: './index.html' 93 | }), 94 | new MiniCssExtractPlugin({ 95 | filename: 'css/[name].css', 96 | chunkFilename: 'css/[id].css' 97 | }), 98 | new Visualizer({ 99 | filename: '../webpack_stats.html' 100 | }), 101 | new CopyWebpackPlugin([{ 102 | from: 'src/frontend/app-images', 103 | to: 'images', 104 | toType: 'dir', 105 | context: '/app' 106 | }]) 107 | ] 108 | }; 109 | --------------------------------------------------------------------------------