Repository: jc21/docker-registry-ui Branch: master Commit: 2643028aeda5 Files: 65 Total size: 120.3 KB Directory structure: gitextract_kgk3g63p/ ├── .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/ │ │ ├── browserconfig.xml │ │ └── site.webmanifest │ ├── html/ │ │ └── index.html │ ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ ["env", { "targets": { "browsers": ["Chrome >= 65"] }, "debug": false, "modules": false, "useBuiltIns": "usage" }] ] } ================================================ FILE: .gitignore ================================================ .idea ._* .DS_Store node_modules dist/* package-lock.json yarn-error.log yarn.lock webpack_stats.html tmp/* .env .yarnrc ================================================ FILE: Dockerfile ================================================ FROM jc21/node:latest MAINTAINER Jamie Curnow LABEL maintainer="Jamie Curnow " RUN apt-get update \ && apt-get install -y curl \ && apt-get clean ENV NODE_ENV=production ADD dist /app/dist ADD node_modules /app/node_modules ADD LICENCE /app/LICENCE ADD package.json /app/package.json ADD src/backend /app/src/backend WORKDIR /app CMD node --max_old_space_size=250 --abort_on_uncaught_exception src/backend/index.js HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://localhost/ || exit 1 ================================================ FILE: Jenkinsfile ================================================ pipeline { agent any options { buildDiscarder(logRotator(numToKeepStr: '10')) disableConcurrentBuilds() } environment { IMAGE = "registry-ui" TAG_VERSION = getPackageVersion() BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('/', '-')}" BUILDX_NAME = "${COMPOSE_PROJECT_NAME}" BASE_IMAGE_NAME = "jc21/node:latest" TEMP_IMAGE_NAME = "${IMAGE}-build_${BUILD_NUMBER}" } stages { stage('Prepare') { steps { sh 'docker pull "${BASE_IMAGE_NAME}"' sh 'docker pull "${DOCKER_CI_TOOLS}"' } } stage('Build') { steps { // Codebase sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn install' sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn build' sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" chown -R "$(id -u):$(id -g)" *' sh 'rm -rf node_modules' sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" yarn install --prod' sh 'docker run --rm -v $(pwd):/data "${DOCKER_CI_TOOLS}" node-prune' // Docker Build sh 'docker build --pull --no-cache --squash --compress -t "${TEMP_IMAGE_NAME}" .' // Zip it sh 'rm -rf zips' sh 'mkdir -p zips' 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 \\ \\*.gitkeep \\ docker-registry-ui/zips\\* \\ docker-registry-ui/bin\\* \\ docker-registry-ui/src/frontend\\* \\ docker-registry-ui/tmp\\* \\ docker-registry-ui/node_modules\\* \\ docker-registry-ui/.git\\* \\ docker-registry-ui/.env \\ docker-registry-ui/.babelrc \\ docker-registry-ui/yarn\\* \\ docker-registry-ui/.gitignore \\ docker-registry-ui/Dockerfile \\ docker-registry-ui/nodemon.json \\ docker-registry-ui/webpack.config.js \\ docker-registry-ui/webpack_stats.html ''' } post { always { sh 'docker run --rm -v $(pwd):/app -w /app "${BASE_IMAGE_NAME}" chown -R "$(id -u):$(id -g)" *' } } } stage('Publish Develop') { when { branch 'develop' } steps { sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:develop"' withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { sh "docker login -u '${duser}' -p '$dpass'" sh 'docker push "jc21/${IMAGE}:develop"' } // Artifacts dir(path: 'zips') { archiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true) } } } stage('Publish Master') { when { branch 'master' } steps { // Public Registry sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:latest"' sh 'docker tag "${TEMP_IMAGE_NAME}" "jc21/${IMAGE}:${TAG_VERSION}"' withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { sh "docker login -u '${duser}' -p '$dpass'" sh 'docker push "jc21/${IMAGE}:latest"' sh 'docker push "jc21/${IMAGE}:${TAG_VERSION}"' } // Artifacts dir(path: 'zips') { archiveArtifacts(artifacts: '**/*.zip', caseSensitive: true, onlyIfSuccessful: true) } } } } triggers { bitbucketPush() } post { success { juxtapose event: 'success' sh 'figlet "SUCCESS"' } failure { juxtapose event: 'failure' sh 'figlet "FAILURE"' } always { sh 'docker rmi "${TEMP_IMAGE_NAME}"' } } } def getPackageVersion() { ver = sh(script: 'docker run --rm -v $(pwd):/data "${DOCKER_CI_TOOLS}" bash -c "cat /data/package.json|jq -r \'.version\'"', returnStdout: true) return ver.trim() } ================================================ FILE: LICENCE ================================================ The MIT License (MIT) Copyright (c) 2017 Jamie Curnow, Brisbane Australia (https://jc21.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![Docker Registry UI](https://public.jc21.com/docker-registry-ui/github.png "Docker Registry UI") # Docker Registry UI ![Version](https://img.shields.io/badge/version-2.0.2-green.svg) ![Stars](https://img.shields.io/docker/stars/jc21/registry-ui.svg) ![Pulls](https://img.shields.io/docker/pulls/jc21/registry-ui.svg) 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. This project comes as a [pre-built docker image](https://hub.docker.com/r/jc21/registry-ui/) capable of connecting to another registry. Note: This project only works with Docker Registry v2. ## Getting started ### Creating a full Docker Registry Stack with this UI 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) example file, put it on your Docker host and run: ```bash docker-compose up -d ``` Then hit your server on http://127.0.0.1 ### If you have your own Docker Registry to connect to Here's a `docker-compose.yml` for you: ```bash version: "2" services: app: image: jc21/registry-ui ports: - 80:80 environment: - REGISTRY_HOST=your-registry-server.com:5000 - REGISTRY_SSL=true - REGISTRY_DOMAIN=your-registry-server.com:5000 - REGISTRY_STORAGE_DELETE_ENABLED= - REGISTRY_USER= - REGISTRY_PASS= restart: on-failure ``` 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 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) as an example. Note that there are some tweaks in there that you will need to be able to push successfully. ## Environment Variables - **`REGISTRY_HOST`** - *Required:* The registry hostname and optional port to connect to for API calls - **`REGISTRY_SSL`** - *Optional:* Specify `true` for this if the registry is accessed via HTTPS - **`REGISTRY_DOMAIN`** - *Optional:* This is the registry domain to display in the UI for example push/pull code - **`REGISTRY_STORAGE_DELETE_ENABLED`** - *Optional:* Specify `true` or `1` to enable deletion features, but see below first! - **`REGISTRY_USER`** - *Optional:* If your docker registry is behind basic auth, specify the username - **`REGISTRY_PASS`** - *Optional:* If your docker registry is behind basic auth, specify the password Refer to the docker documentation for setting up [native basic auth](https://docs.docker.com/registry/deploying/#restricting-access). ## Deletion Support Registry deletion support sux. It is disabled by default in this project on purpose because you need to accomplish extra steps to get it up and running, sort of. #### Permit deleting on the Registry This step is pretty simple and involves adding an environment variable to your Docker Registry Container when you start it up: ```bash docker run -d -p 5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true --name my-registry registry:2 ``` #### Enabling Deletions in the UI 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 acceptable value for this environment variable. #### Cleaning up the Registry 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: ```bash docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml ``` And if you wanted to make a cron job that runs every 30 mins: ``` 0,30 * * * * /bin/docker exec -it my-registry bin/registry garbage-collect /etc/docker/registry/config.yml >> /dev/null 2>&1 ``` ## Screenshots [![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) [![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) [![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) [![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) ## TODO - Add pagination to Repositories, currently only 300 images will be fetched - Add support for token based registry authentication mechanisms ================================================ FILE: bin/build ================================================ #!/bin/bash sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script build exit $? ================================================ FILE: bin/build-dev ================================================ #!/bin/bash sudo /usr/local/bin/docker-compose run --no-deps --rm app npm run-script dev exit $? ================================================ FILE: bin/npm ================================================ #!/bin/bash sudo /usr/local/bin/docker-compose run --no-deps --rm app npm $@ exit $? ================================================ FILE: bin/watch ================================================ #!/bin/bash sudo docker run --rm -it \ -p 8124:8080 \ -v $(pwd):/app \ -w /app \ jc21/node:latest npm run-script watch exit $? ================================================ FILE: bin/yarn ================================================ #!/bin/bash sudo /usr/local/bin/docker-compose run --no-deps --rm app yarn $@ exit $? ================================================ FILE: doc/full-stack/docker-compose.yml ================================================ version: "2" services: registry: image: registry:2 environment: - REGISTRY_HTTP_SECRET=o43g2kjgn2iuhv2k4jn2f23f290qfghsdg - REGISTRY_STORAGE_DELETE_ENABLED= volumes: - ./registry-data:/var/lib/registry ui: image: jc21/registry-ui environment: - NODE_ENV=production - REGISTRY_HOST=registry:5000 - REGISTRY_SSL= - REGISTRY_DOMAIN= - REGISTRY_STORAGE_DELETE_ENABLED= links: - registry restart: on-failure proxy: image: jc21/registry-ui-proxy ports: - 80:80 depends_on: - ui - registry links: - ui - registry restart: on-failure ================================================ FILE: docker-compose.yml ================================================ version: "2" services: app: image: jc21/node:latest ports: - 4000:80 environment: - DEBUG= - FORCE_COLOR=1 - NODE_ENV=development - REGISTRY_HOST=${REGISTRY_HOST} - REGISTRY_DOMAIN=${REGISTRY_HOST} - REGISTRY_STORAGE_DELETE_ENABLED=true - REGISTRY_SSL=${REGISTRY_SSL} - REGISTRY_USER=${REGISTRY_USER} - REGISTRY_PASS=${REGISTRY_PASS} volumes: - .:/app working_dir: /app command: node --max_old_space_size=250 --abort_on_uncaught_exception node_modules/nodemon/bin/nodemon.js ================================================ FILE: nodemon.json ================================================ { "verbose": false, "ignore": ["dist", "data", "src/frontend"], "ext": "js json ejs" } ================================================ FILE: package.json ================================================ { "name": "docker-registry-ui", "version": "2.0.2", "description": "A nice web interface for managing your Docker Registry images", "main": "src/backend/index.js", "dependencies": { "ajv": "^6.5.4", "batchflow": "^0.4.0", "body-parser": "^1.18.3", "compression": "^1.7.3", "config": "^2.0.1", "ejs": "^2.6.1", "express": "^4.16.4", "express-winston": "^3.0.1", "html-entities": "^1.2.1", "json-schema-ref-parser": "^6.0.1", "lodash": "^4.17.11", "path": "^0.12.7", "restler": "^3.4.0", "signale": "^1.2.1" }, "devDependencies": { "babel-core": "^6.26.3", "babel-loader": "^7.1.4", "babel-minify-webpack-plugin": "^0.3.1", "babel-preset-env": "^1.7.0", "@hyperapp/html": "git+https://github.com/maxholman/hyperapp-html.git#5bde674d42c87bb8191f8cc11a8a3c7d334e3dfb", "babel-plugin-transform-react-jsx": "^6.24.1", "copy-webpack-plugin": "^4.5.4", "css-loader": "^1.0.0", "file-loader": "^2.0.0", "html-loader": "^0.5.5", "html-webpack-plugin": "^3.2.0", "hyperapp": "^1.2.9", "hyperapp-hash-router": "^0.1.0", "imports-loader": "^0.8.0", "jquery": "^3.3.1", "jquery-serializejson": "^2.8.1", "mini-css-extract-plugin": "^0.4.4", "moment": "^2.22.2", "node-sass": "^4.9.4", "nodemon": "^1.18.4", "numeral": "^2.0.6", "sass-loader": "^7.1.0", "style-loader": "^0.23.1", "tabler-ui": "git+https://github.com/tabler/tabler.git#a09fd463309f2b395653e3615c98d1e8aca35b31", "uglifyjs-webpack-plugin": "^2.0.1", "webpack": "^4.12.0", "webpack-cli": "^3.0.8", "webpack-visualizer-plugin": "^0.1.11" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "webpack --mode development", "build": "webpack --mode production", "watch": "webpack-dev-server --mode development" }, "signale": { "displayDate": true, "displayTimestamp": true }, "author": "", "license": "MIT" } ================================================ FILE: src/backend/app.js ================================================ 'use strict'; const express = require('express'); const bodyParser = require('body-parser'); const compression = require('compression'); const log = require('./logger').express; /** * App */ const app = express(); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({extended: true})); app.use(compression()); /** * General Logging, BEFORE routes */ app.disable('x-powered-by'); app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); app.enable('strict routing'); // pretty print JSON when not live if (process.env.NODE_ENV !== 'production') { app.set('json spaces', 2); } // set the view engine to ejs app.set('view engine', 'ejs'); // CORS for everything app.use(require('./lib/express/cors')); // General security/cache related headers + server header app.use(function (req, res, next) { res.set({ 'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload', 'X-XSS-Protection': '0', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', Pragma: 'no-cache', Expires: 0 }); next(); }); /** * Routes */ app.use('/assets', express.static('dist/assets')); app.use('/css', express.static('dist/css')); app.use('/fonts', express.static('dist/fonts')); app.use('/images', express.static('dist/images')); app.use('/js', express.static('dist/js')); app.use('/api', require('./routes/api/main')); app.use('/', require('./routes/main')); // production error handler // no stacktraces leaked to user app.use(function (err, req, res, next) { let payload = { error: { code: err.status, message: err.public ? err.message : 'Internal Error' } }; if (process.env.NODE_ENV === 'development') { payload.debug = { stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null, previous: err.previous }; } // Not every error is worth logging - but this is good for now until it gets annoying. if (typeof err.stack !== 'undefined' && err.stack) { log.warn(err.stack); } res .status(err.status || 500) .send(payload); }); module.exports = app; ================================================ FILE: src/backend/index.js ================================================ #!/usr/bin/env node 'use strict'; const logger = require('./logger').global; const config = require('config'); let port = process.env.PORT || 80; if (config.has('port')) { port = config.get('port'); } if (!process.env.REGISTRY_HOST) { logger.error('Error: REGISTRY_HOST environment variable was not found!'); process.exit(1); } function appStart () { const app = require('./app'); const apiValidator = require('./lib/validator/api'); return apiValidator.loadSchemas .then(() => { const server = app.listen(port, () => { logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...'); logger.info('Registry Host: ' + process.env.REGISTRY_HOST); process.on('SIGTERM', () => { logger.info('PID ' + process.pid + ' received SIGTERM'); server.close(() => { logger.info('Stopping.'); process.exit(0); }); }); }); }) .catch(err => { logger.error(err.message); setTimeout(appStart, 1000); }); } try { appStart(); } catch (err) { logger.error(err.message, err); process.exit(1); } ================================================ FILE: src/backend/internal/repo.js ================================================ 'use strict'; const REGISTRY_HOST = process.env.REGISTRY_HOST; const REGISTRY_SSL = process.env.REGISTRY_SSL && process.env.REGISTRY_SSL.toLowerCase() === 'true' || parseInt(process.env.REGISTRY_SSL, 10) === 1; const REGISTRY_USER = process.env.REGISTRY_USER; const REGISTRY_PASS = process.env.REGISTRY_PASS; const _ = require('lodash'); const Docker = require('../lib/docker-registry'); const batchflow = require('batchflow'); const registry = new Docker(REGISTRY_HOST, REGISTRY_SSL, REGISTRY_USER, REGISTRY_PASS); const errors = require('../lib/error'); const logger = require('../logger').registry; const internalRepo = { /** * @param {String} name * @param {Boolean} full * @return {Promise} */ get: (name, full) => { return registry.getImageTags(name) .then(tags_data => { // detect errors if (typeof tags_data.errors !== 'undefined' && tags_data.errors.length) { let top_err = tags_data.errors.shift(); if (top_err.code === 'NAME_UNKNOWN') { throw new errors.ItemNotFoundError(name); } else { throw new errors.RegistryError(top_err.code, top_err.message); } } if (full && tags_data.tags !== null) { // Order the tags naturally, but put latest at the top if it exists let latest_idx = tags_data.tags.indexOf('latest'); if (latest_idx !== -1) { _.pullAt(tags_data.tags, [latest_idx]); } // sort tags_data.tags = tags_data.tags.sort((a, b) => a.localeCompare(b)); if (latest_idx !== -1) { tags_data.tags.unshift('latest'); } return new Promise((resolve, reject) => { batchflow(tags_data.tags).sequential() .each((i, tag, next) => { // for each tag, we want to get 2 manifests. // Version 2 returns the layers and the correct image id // Version 1 returns the history we want to pluck from registry.getManifest(tags_data.name, tag, 2) .then(manifest2_result => { manifest2_result.name = tag; manifest2_result.image_name = name; return registry.getManifest(tags_data.name, tag, 1) .then(manifest1_result => { manifest2_result.info = null; if (typeof manifest1_result.history !== 'undefined' && manifest1_result.history.length) { let info = manifest1_result.history.shift(); if (typeof info.v1Compatibility !== undefined) { info = JSON.parse(info.v1Compatibility); // Remove cruft if (typeof info.config !== 'undefined') { delete info.config; } if (typeof info.container_config !== 'undefined') { delete info.container_config; } } manifest2_result.info = info; } next(manifest2_result); }); }) .catch(err => { logger.error(err); next(null); }); }) .error(err => { reject(err); }) .end(results => { tags_data.tags = results || null; resolve(tags_data); }); }); } else { return tags_data; } }); }, /** * All repos * * @param {Boolean} [with_tags] * @returns {Promise} */ getAll: with_tags => { return registry.getImages() .then(result => { if (typeof result.errors !== 'undefined' && result.errors.length) { let first_err = result.errors.shift(); throw new errors.RegistryError(first_err.code, first_err.message); } else if (typeof result.repositories !== 'undefined') { let repositories = []; // sort images result.repositories = result.repositories.sort((a, b) => a.localeCompare(b)); _.map(result.repositories, function (repo) { repositories.push({ name: repo }); }); return repositories; } return result; }) .then(images => { if (with_tags) { return new Promise((resolve, reject) => { batchflow(images).sequential() .each((i, image, next) => { let image_result = image; // for each image registry.getImageTags(image.name) .then(tags_result => { if (typeof tags_result === 'string') { // usually some sort of error logger.error('Tags result was: ', tags_result); image_result.tags = null; } else if (typeof tags_result.tags !== 'undefined' && tags_result.tags !== null) { // Order the tags naturally, but put latest at the top if it exists let latest_idx = tags_result.tags.indexOf('latest'); if (latest_idx !== -1) { _.pullAt(tags_result.tags, [latest_idx]); } // sort tags image_result.tags = tags_result.tags.sort((a, b) => a.localeCompare(b)); if (latest_idx !== -1) { image_result.tags.unshift('latest'); } } next(image_result); }) .catch(err => { logger.error(err); image_result.tags = null; next(image_result); }); }) .error(err => { reject(err); }) .end(results => { resolve(results); }); }); } else { return images; } }); }, /** * Delete a image/tag * * @param {String} name * @param {String} digest * @returns {Promise} */ delete: (name, digest) => { return registry.deleteImage(name, digest); } }; module.exports = internalRepo; ================================================ FILE: src/backend/lib/docker-registry.js ================================================ 'use strict'; const _ = require('lodash'); const rest = require('restler'); /** * * @param {String} domain * @param {Boolean} use_ssl * @param {String} [username] * @param {String} [password] * @returns {module} */ module.exports = function (domain, use_ssl, username, password) { this._baseurl = 'http' + (use_ssl ? 's' : '') + '://' + (username ? username + ':' + password + '@' : '') + domain + '/v2/'; /** * @param {Integer} [version] * @returns {Object} */ this.getUrlOptions = function (version) { let options = { headers: { 'User-Agent': 'Docker Registry UI' } }; if (version === 2) { options.headers.Accept = 'application/vnd.docker.distribution.manifest.v2+json'; } return options; }; /** * @param {Integer} [limit] * @returns {Promise} */ this.getImages = function (limit) { limit = limit || 300; return new Promise((resolve, reject) => { rest.get(this._baseurl + '_catalog?n=' + limit, this.getUrlOptions()) .on('timeout', function (ms) { reject(new Error('Request timed out after ' + ms + 'ms')); }) .on('complete', function (result) { if (result instanceof Error) { reject(result); } else { resolve(result); } }); }); }; /** * @param {String} image * @param {Integer} [limit] * @returns {Promise} */ this.getImageTags = function (image, limit) { limit = limit || 300; return new Promise((resolve, reject) => { rest.get(this._baseurl + image + '/tags/list?n=' + limit, this.getUrlOptions()) .on('timeout', function (ms) { reject(new Error('Request timed out after ' + ms + 'ms')); }) .on('complete', function (result) { if (result instanceof Error) { reject(result); } else { resolve(result); } }); }); }; /** * @param {String} image * @param {String} digest * @returns {Promise} */ this.deleteImage = function (image, digest) { return new Promise((resolve, reject) => { rest.del(this._baseurl + image + '/manifests/' + digest, this.getUrlOptions()) .on('timeout', function (ms) { reject(new Error('Request timed out after ' + ms + 'ms')); }) .on('202', function () { resolve(true); }) .on('404', function () { resolve(false); }) .on('complete', function (result) { if (result instanceof Error) { reject(result); } else { if (typeof result.errors !== 'undefined' && result.errors.length) { let err = result.errors.shift(); resolve(err); } } }); }); }; /** * @param {String} image * @param {String} layer_digest * @returns {Promise} */ this.deleteLayer = function (image, layer_digest) { return new Promise((resolve, reject) => { rest.del(this._baseurl + image + '/blobs/' + layer_digest, this.getUrlOptions()) .on('timeout', function (ms) { reject(new Error('Request timed out after ' + ms + 'ms')); }) .on('202', function () { resolve(true); }) .on('404', function () { resolve(false); }) .on('complete', function (result) { if (result instanceof Error) { reject(result); } else { if (typeof result.errors !== 'undefined' && result.errors.length) { let err = result.errors.shift(); resolve(err); } } }); }); }; /** * @param {String} image * @param {String} reference can be a tag or digest * @param {Integer} [version] 1 or 2, defaults to 1 * @returns {Promise} */ this.getManifest = function (image, reference, version) { version = version || 1; return new Promise((resolve, reject) => { rest.get(this._baseurl + image + '/manifests/' + reference, this.getUrlOptions(version)) .on('timeout', function (ms) { reject(new Error('Request timed out after ' + ms + 'ms')); }) .on('complete', function (result, response) { if (result instanceof Error) { reject(result); } else { if (typeof result === 'string') { result = JSON.parse(result); } result.digest = null; if (typeof response.headers['docker-content-digest'] !== 'undefined') { result.digest = response.headers['docker-content-digest']; } resolve(result); } }); }); }; return this; }; ================================================ FILE: src/backend/lib/error.js ================================================ 'use strict'; const _ = require('lodash'); const util = require('util'); module.exports = { ItemNotFoundError: function (id, previous) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.previous = previous; this.message = 'Item Not Found - ' + id; this.public = true; this.status = 404; }, RegistryError: function (code, message, previous) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.previous = previous; this.message = code + ': ' + message; this.public = true; this.status = 500; }, InternalValidationError: function (message, previous) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.previous = previous; this.message = message; this.status = 400; this.public = false; }, ValidationError: function (message, previous) { Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.previous = previous; this.message = message; this.public = true; this.status = 400; } }; _.forEach(module.exports, function (error) { util.inherits(error, Error); }); ================================================ FILE: src/backend/lib/express/cors.js ================================================ 'use strict'; const validator = require('../validator'); module.exports = function (req, res, next) { if (req.headers.origin) { // very relaxed validation.... validator({ type: 'string', pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$' }, req.headers.origin) .then(function () { res.set({ 'Access-Control-Allow-Origin': req.headers.origin, 'Access-Control-Allow-Credentials': true, 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit', 'Access-Control-Max-Age': 5 * 60, 'Access-Control-Expose-Headers': 'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit' }); next(); }) .catch(next); } else { // No origin next(); } }; ================================================ FILE: src/backend/lib/express/pagination.js ================================================ 'use strict'; let _ = require('lodash'); module.exports = function (default_sort, default_offset, default_limit, max_limit) { /** * This will setup the req query params with filtered data and defaults * * sort will be an array of fields and their direction * offset will be an int, defaulting to zero if no other default supplied * limit will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied * */ return function (req, res, next) { req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10); req.query.limit = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10); if (max_limit && req.query.limit > max_limit) { req.query.limit = max_limit; } // Sorting let sort = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort; let myRegexp = /.*\.(asc|desc)$/ig; let sort_array = []; sort = sort.split(','); _.map(sort, function (val) { let matches = myRegexp.exec(val); if (matches !== null) { let dir = matches[1]; sort_array.push({ field: val.substr(0, val.length - (dir.length + 1)), dir: dir.toLowerCase() }); } else { sort_array.push({ field: val, dir: 'asc' }); } }); // Sort will now be in this format: // [ // { field: 'field1', dir: 'asc' }, // { field: 'field2', dir: 'desc' } // ] req.query.sort = sort_array; next(); }; }; ================================================ FILE: src/backend/lib/helpers.js ================================================ 'use strict'; const moment = require('moment'); const _ = require('lodash'); module.exports = { /** * Takes an expression such as 30d and returns a moment object of that date in future * * Key Shorthand * ================== * years y * quarters Q * months M * weeks w * days d * hours h * minutes m * seconds s * milliseconds ms * * @param {String} expression * @returns {Object} */ parseDatePeriod: function (expression) { let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m); if (matches) { return moment().add(matches[1], matches[2]); } return null; }, /** * This will return an object that has the defaults supplied applied to it * if they didn't exist already. * * @param {Object} obj * @param {Object} defaults * @return {Object} */ applyObjectDefaults: function (obj, defaults) { return _.assign({}, defaults, obj); }, /** * Returns a random integer between min (included) and max (excluded) * Using Math.round() will give you a non-uniform distribution! * * @param {Integer} min * @param {Integer} max * @returns {Integer} */ getRandomInt: function (min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min)) + min; }, /** * Removes any fields with . joins in them, to avoid table joining select exposure * Also makes sure an 'id' field exists * * @param {Array} fields * @returns {Array} */ sanitizeFields: function (fields) { if (fields.indexOf('id') === -1) { fields.unshift('id'); } let sanitized = []; for (let x = 0; x < fields.length; x++) { if (fields[x].indexOf('.') === -1) { sanitized.push(fields[x]); } } return sanitized; }, /** * * @param {String} input * @param {String} [allowed] * @returns {String} */ stripHtml: function (input, allowed) { allowed = (((allowed || '') + '').toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join(''); let tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi; let commentsAndPhpTags = /|<\?(?:php)?[\s\S]*?\?>/gi; return input.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) { return allowed.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : ''; }); }, /** * * @param {String} text * @returns {String} */ stripJiraMarkup: function (text) { return text.replace(/(?:^|[^{]{)[^}]+}/gi, "\n"); }, /** * @param {String} content * @returns {String} */ compactWhitespace: function (content) { return content .replace(/(\r|\n)+/gim, ' ') .replace(/ +/gim, ' '); }, /** * @param {String} content * @param {Integer} length * @returns {String} */ trimString: function (content, length) { if (content.length > (length - 3)) { //trim the string to the maximum length let trimmed = content.substr(0, length - 3); //re-trim if we are in the middle of a word return trimmed.substr(0, Math.min(trimmed.length, trimmed.lastIndexOf(' '))) + '...'; } return content; }, /** * @param {String} str * @returns {String} */ ucwords: function (str) { return (str + '') .replace(/^(.)|\s+(.)/g, function ($1) { return $1.toUpperCase() }) }, niceVarName: function (name) { return name.replace('_', ' ') .replace(/^(.)|\s+(.)/g, function ($1) { return $1.toUpperCase(); }); } }; ================================================ FILE: src/backend/lib/validator/api.js ================================================ 'use strict'; const error = require('../error'); const path = require('path'); const parser = require('json-schema-ref-parser'); const ajv = require('ajv')({ verbose: true, validateSchema: true, allErrors: false, format: 'full', // strict regexes for format checks coerceTypes: true }); /** * @param {Object} schema * @param {Object} payload * @returns {Promise} */ function apiValidator(schema, payload/*, description*/) { return new Promise(function Promise_apiValidator(resolve, reject) { if (typeof payload === 'undefined') { reject(new error.ValidationError('Payload is undefined')); } let validate = ajv.compile(schema); let valid = validate(payload); if (valid && !validate.errors) { resolve(payload); } else { let message = ajv.errorsText(validate.errors); //console.log(schema); //console.log(payload); //console.log(validate.errors); //var first_error = validate.errors.slice(0, 1).pop(); let err = new error.ValidationError(message); err.debug = [validate.errors, payload]; reject(err); } }); } apiValidator.loadSchemas = parser .dereference(path.resolve('src/backend/schema/index.json')) .then((schema) => { ajv.addSchema(schema); return schema; }); module.exports = apiValidator; ================================================ FILE: src/backend/lib/validator/index.js ================================================ 'use strict'; const _ = require('lodash'); const error = require('../error'); const definitions = require('../../schema/definitions.json'); RegExp.prototype.toJSON = RegExp.prototype.toString; const ajv = require('ajv')({ verbose: true, //process.env.NODE_ENV === 'development', allErrors: true, format: 'full', // strict regexes for format checks coerceTypes: true, schemas: [ definitions ] }); /** * * @param {Object} schema * @param {Object} payload * @returns {Promise} */ function validator (schema, payload) { return new Promise(function (resolve, reject) { if (!payload) { reject(new error.InternalValidationError('Payload is falsy')); } else { try { let validate = ajv.compile(schema); let valid = validate(payload); if (valid && !validate.errors) { resolve(_.cloneDeep(payload)); } else { console.log('SCHEMA:', schema); console.log('PAYLOAD:', payload); let message = ajv.errorsText(validate.errors); reject(new error.InternalValidationError(message)); } } catch (err) { reject(err); } } }); } module.exports = validator; ================================================ FILE: src/backend/logger.js ================================================ const {Signale} = require('signale'); module.exports = { global: new Signale({scope: 'Global '}), migrate: new Signale({scope: 'Migrate '}), express: new Signale({scope: 'Express '}), registry: new Signale({scope: 'Registry'}), }; ================================================ FILE: src/backend/routes/api/main.js ================================================ 'use strict'; const express = require('express'); const pjson = require('../../../../package.json'); let router = express.Router({ caseSensitive: true, strict: true, mergeParams: true }); /** * Health Check * GET /api */ router.get('/', (req, res/*, next*/) => { let version = pjson.version.split('-').shift().split('.'); res.status(200).send({ status: 'OK', version: { major: parseInt(version.shift(), 10), minor: parseInt(version.shift(), 10), revision: parseInt(version.shift(), 10) }, config: { 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, REGISTRY_DOMAIN: process.env.REGISTRY_DOMAIN || null } }); }); router.use('/repos', require('./repos')); module.exports = router; ================================================ FILE: src/backend/routes/api/repos.js ================================================ 'use strict'; const express = require('express'); const validator = require('../../lib/validator'); const pagination = require('../../lib/express/pagination'); const internalRepo = require('../../internal/repo'); let router = express.Router({ caseSensitive: true, strict: true, mergeParams: true }); /** * /api/repos */ router .route('/') .options((req, res) => { res.sendStatus(204); }) /** * GET /api/repos * * Retrieve all repos */ .get(pagination('name', 0, 50, 300), (req, res, next) => { validator({ additionalProperties: false, properties: { tags: { type: 'boolean' } } }, { tags: (typeof req.query.tags !== 'undefined' ? !!req.query.tags : false) }) .then(data => { return internalRepo.getAll(data.tags); }) .then(repos => { res.status(200) .send(repos); }) .catch(next); }); /** * Specific repo * * /api/repos/abc123 */ router .route('/:name([-a-zA-Z0-9/.,_]+)') .options((req, res) => { res.sendStatus(204); }) /** * GET /api/repos/abc123 * * Retrieve a specific repo */ .get((req, res, next) => { validator({ required: ['name'], additionalProperties: false, properties: { name: { type: 'string', minLength: 1 }, full: { type: 'boolean' } } }, { name: req.params.name, full: (typeof req.query.full !== 'undefined' ? !!req.query.full : false) }) .then(data => { return internalRepo.get(data.name, data.full); }) .then(repo => { res.status(200) .send(repo); }) .catch(next); }) /** * DELETE /api/repos/abc123 * * Delete a specific image/tag */ .delete((req, res, next) => { validator({ required: ['name', 'digest'], additionalProperties: false, properties: { name: { type: 'string', minLength: 1 }, digest: { type: 'string', minLength: 1 } } }, { name: req.params.name, digest: (typeof req.query.digest !== 'undefined' ? req.query.digest : '') }) .then(data => { return internalRepo.delete(data.name, data.digest); }) .then(result => { res.status(200) .send(result); }) .catch(next); }); module.exports = router; ================================================ FILE: src/backend/routes/main.js ================================================ 'use strict'; const express = require('express'); const fs = require('fs'); const router = express.Router({ caseSensitive: true, strict: true, mergeParams: true }); /** * GET .* */ router.get(/(.*)/, function (req, res, next) { req.params.page = req.params['0']; if (req.params.page === '/') { req.params.page = '/index.html'; } fs.readFile('dist' + req.params.page, 'utf8', function(err, data) { if (err) { if (req.params.page !== '/index.html') { fs.readFile('dist/index.html', 'utf8', function(err2, data) { if (err2) { next(err); } else { res.contentType('text/html').end(data); } }); } else { next(err); } } else { res.contentType('text/html').end(data); } }); }); module.exports = router; ================================================ FILE: src/backend/schema/definitions.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "definitions", "definitions": { "id": { "description": "Unique identifier", "example": 123456, "readOnly": true, "type": "integer", "minimum": 1 }, "token": { "type": "string", "minLength": 10 }, "expand": { "anyOf": [ { "type": "null" }, { "type": "array", "minItems": 1, "items": { "type": "string" } } ] }, "sort": { "type": "array", "minItems": 1, "items": { "type": "object", "required": [ "field", "dir" ], "additionalProperties": false, "properties": { "field": { "type": "string" }, "dir": { "type": "string", "pattern": "^(asc|desc)$" } } } }, "query": { "anyOf": [ { "type": "null" }, { "type": "string", "minLength": 1, "maxLength": 255 } ] }, "criteria": { "anyOf": [ { "type": "null" }, { "type": "object" } ] }, "fields": { "anyOf": [ { "type": "null" }, { "type": "array", "minItems": 1, "items": { "type": "string" } } ] }, "omit": { "anyOf": [ { "type": "null" }, { "type": "array", "minItems": 1, "items": { "type": "string" } } ] }, "created_on": { "description": "Date and time of creation", "format": "date-time", "readOnly": true, "type": "string" }, "modified_on": { "description": "Date and time of last update", "format": "date-time", "readOnly": true, "type": "string" }, "user_id": { "description": "User ID", "example": 1234, "type": "integer", "minimum": 1 }, "name": { "type": "string", "minLength": 1, "maxLength": 255 }, "email": { "description": "Email Address", "example": "john@example.com", "format": "email", "type": "string", "minLength": 8, "maxLength": 100 }, "password": { "description": "Password", "type": "string", "minLength": 8, "maxLength": 255 }, "jira_webhook_data": { "type": "object", "additionalProperties": true, "required": [ "webhookEvent", "timestamp" ], "properties": { "webhookEvent": { "type": "string", "minLength": 2 }, "timestamp": { "type": "integer", "minimum": 1 }, "user": { "type": "object" }, "issue": { "type": "object" } } }, "bitbucket_webhook_data": { "type": "object", "additionalProperties": true, "required": [ "eventKey", "date" ], "properties": { "eventKey": { "type": "string", "minLength": 2 }, "date": { "type": "string", "minimum": 19 }, "actor": { "type": "object" }, "pullRequest": { "type": "object" } } }, "dockerhub_webhook_data": { "type": "object", "additionalProperties": true, "required": [ "push_data", "repository" ], "properties": { "push_data": { "type": "object" }, "repository": { "type": "object" } } }, "zendesk_webhook_data": { "type": "object", "additionalProperties": true, "required": [ "ticket", "current_user" ], "properties": { "ticket": { "type": "object" }, "current_user": { "type": "object" } } }, "service_type": { "description": "Service Type", "example": "slack", "type": "string", "minLength": 2, "maxLength": 30, "pattern": "^(slack|jira-webhook|bitbucket-webhook|dockerhub-webhook|zendesk-webhook|jabber)$" } } } ================================================ FILE: src/backend/schema/endpoints/rules.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "endpoints/rules", "title": "Rules", "description": "Endpoints relating to Rules", "stability": "stable", "type": "object", "definitions": { "id": { "$ref": "../definitions.json#/definitions/id" }, "created_on": { "$ref": "../definitions.json#/definitions/created_on" }, "modified_on": { "$ref": "../definitions.json#/definitions/modified_on" }, "user_id": { "$ref": "../definitions.json#/definitions/user_id" }, "priority_order": { "description": "Priority Order", "example": 1, "type": "integer", "minimum": 0 }, "in_service_id": { "description": "Incoming Service ID", "example": 1234, "type": "integer", "minimum": 1 }, "trigger": { "description": "Trigger Type", "example": "assigned", "type": "string", "minLength": 2, "maxLength": 50 }, "extra_conditions": { "description": "Extra Incoming Trigger Conditions", "example": { "project": "BB" }, "type": "object" }, "out_service_id": { "description": "Outgoing Service ID", "example": 1234, "type": "integer", "minimum": 1 }, "out_template_id": { "description": "Outgoing Template ID", "example": 1234, "type": "integer", "minimum": 1 }, "out_template_options": { "description": "Custom options for Outgoing Template", "example": { "panel_color": "#ff00aa" }, "type": "object" }, "fired_count": { "description": "Fired Count", "example": 854, "readOnly": true, "type": "integer", "minimum": 1 } }, "links": [ { "title": "List", "description": "Returns a list of Rules", "href": "/rules", "access": "private", "method": "GET", "rel": "self", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "array", "items": { "$ref": "#/properties" } } }, { "title": "Create", "description": "Creates a new Rule", "href": "/rules", "access": "private", "method": "POST", "rel": "create", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "in_service_id", "trigger", "out_service_id", "out_template_id" ], "properties": { "user_id": { "$ref": "#/definitions/user_id" }, "priority_order": { "$ref": "#/definitions/priority_order" }, "in_service_id": { "$ref": "#/definitions/in_service_id" }, "trigger": { "$ref": "#/definitions/trigger" }, "extra_conditions": { "$ref": "#/definitions/extra_conditions" }, "out_service_id": { "$ref": "#/definitions/out_service_id" }, "out_template_id": { "$ref": "#/definitions/out_template_id" }, "out_template_options": { "$ref": "#/definitions/out_template_options" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Update", "description": "Updates a existing Rule", "href": "/rules/{definitions.identity.example}", "access": "private", "method": "PUT", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "properties": { "priority_order": { "$ref": "#/definitions/priority_order" }, "in_service_id": { "$ref": "#/definitions/in_service_id" }, "trigger": { "$ref": "#/definitions/trigger" }, "extra_conditions": { "$ref": "#/definitions/extra_conditions" }, "out_service_id": { "$ref": "#/definitions/out_service_id" }, "out_template_id": { "$ref": "#/definitions/out_template_id" }, "out_template_options": { "$ref": "#/definitions/out_template_options" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Delete", "description": "Deletes a existing Rule", "href": "/rules/{definitions.identity.example}", "access": "private", "method": "DELETE", "rel": "delete", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "boolean" } }, { "title": "Order", "description": "Sets the order for the rules", "href": "/rules/order", "access": "private", "method": "POST", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "array", "items": { "type": "object", "required": [ "order", "rule_id" ], "properties": { "order": { "type": "integer", "minimum": 0 }, "rule_id": { "$ref": "../definitions.json#/definitions/id" } } } }, "targetSchema": { "type": "boolean" } }, { "title": "Copy", "description": "Copies rules from one user to another", "href": "/rules/copy", "access": "private", "method": "POST", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "from", "to" ], "properties": { "from": { "type": "integer", "minimum": 1 }, "to": { "type": "integer", "minimum": 1 }, "service_type": { "$ref": "../definitions.json#/definitions/service_type" } } }, "targetSchema": { "type": "boolean" } } ], "properties": { "id": { "$ref": "#/definitions/id" }, "created_on": { "$ref": "#/definitions/created_on" }, "modified_on": { "$ref": "#/definitions/modified_on" }, "user_id": { "$ref": "#/definitions/user_id" }, "priority_order": { "$ref": "#/definitions/priority_order" }, "in_service_id": { "$ref": "#/definitions/in_service_id" }, "trigger": { "$ref": "#/definitions/trigger" }, "extra_conditions": { "$ref": "#/definitions/extra_conditions" }, "out_service_id": { "$ref": "#/definitions/out_service_id" }, "out_template_id": { "$ref": "#/definitions/out_template_id" }, "out_template_options": { "$ref": "#/definitions/out_template_options" }, "fired_count": { "$ref": "#/definitions/fired_count" } } } ================================================ FILE: src/backend/schema/endpoints/services.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "endpoints/services", "title": "Services", "description": "Endpoints relating to Services", "stability": "stable", "type": "object", "definitions": { "id": { "$ref": "../definitions.json#/definitions/id" }, "created_on": { "$ref": "../definitions.json#/definitions/created_on" }, "modified_on": { "$ref": "../definitions.json#/definitions/modified_on" }, "type": { "$ref": "../definitions.json#/definitions/service_type" }, "name": { "description": "Name", "example": "JiraBot", "type": "string", "minLength": 2, "maxLength": 100 }, "data": { "description": "Data", "example": {"api_token": "xox-somethingrandom"}, "type": "object" } }, "links": [ { "title": "List", "description": "Returns a list of Services", "href": "/services", "access": "private", "method": "GET", "rel": "self", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "array", "items": { "$ref": "#/properties" } } }, { "title": "Create", "description": "Creates a new Service", "href": "/services", "access": "private", "method": "POST", "rel": "create", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "type", "name", "data" ], "properties": { "type": { "$ref": "#/definitions/type" }, "name": { "$ref": "#/definitions/name" }, "data": { "$ref": "#/definitions/data" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Update", "description": "Updates a existing Service", "href": "/services/{definitions.identity.example}", "access": "private", "method": "PUT", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "properties": { "type": { "$ref": "#/definitions/type" }, "name": { "$ref": "#/definitions/name" }, "data": { "$ref": "#/definitions/data" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Delete", "description": "Deletes a existing Service", "href": "/services/{definitions.identity.example}", "access": "private", "method": "DELETE", "rel": "delete", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "boolean" } }, { "title": "Test", "description": "Tests a existing Service", "href": "/services/{definitions.identity.example}/test", "access": "private", "method": "POST", "rel": "test", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "username", "message" ], "properties": { "username": { "type": "string", "minLength": 1 }, "message": { "type": "string", "minLength": 1 } } }, "targetSchema": { "type": "boolean" } }, { "title": "User List", "description": "Get User List of a Service", "href": "/services/{definitions.identity.example}/users", "access": "private", "method": "GET", "rel": "users", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "username", "message" ], "properties": { "username": { "type": "string", "minLength": 1 }, "message": { "type": "string", "minLength": 1 } } }, "targetSchema": { "type": "boolean" } } ], "properties": { "id": { "$ref": "#/definitions/id" }, "created_on": { "$ref": "#/definitions/created_on" }, "modified_on": { "$ref": "#/definitions/modified_on" }, "type": { "$ref": "#/definitions/type" }, "name": { "$ref": "#/definitions/name" }, "data": { "$ref": "#/definitions/data" } } } ================================================ FILE: src/backend/schema/endpoints/templates.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "endpoints/templates", "title": "Templates", "description": "Endpoints relating to Templates", "stability": "stable", "type": "object", "definitions": { "id": { "$ref": "../definitions.json#/definitions/id" }, "created_on": { "$ref": "../definitions.json#/definitions/created_on" }, "modified_on": { "$ref": "../definitions.json#/definitions/modified_on" }, "service_type": { "$ref": "../definitions.json#/definitions/service_type" }, "in_service_type": { "$ref": "../definitions.json#/definitions/service_type" }, "name": { "description": "Name of Template", "example": "Assigned Task Compact", "type": "string", "minLength": 1, "maxLength": 100 }, "content": { "description": "Content", "example": "{\"text\": \"Hello World\"}", "type": "string" }, "default_options": { "description": "Default Options", "example": { "panel_color": "#ff0000" }, "type": "object" }, "example_data": { "description": "Example Data", "example": { "summary": "Example Jira Summary" }, "type": "object" }, "event_types": { "description": "Event Types", "example": { "summary": ["assigned", "resolved"] }, "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, "render_engine": { "description": "Render Engine", "example": "liquid", "type": "string", "pattern": "^(ejs|liquid)$" } }, "links": [ { "title": "List", "description": "Returns a list of Templates", "href": "/templates", "access": "private", "method": "GET", "rel": "self", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "array", "items": { "$ref": "#/properties" } } }, { "title": "Create", "description": "Creates a new Templates", "href": "/templates", "access": "private", "method": "POST", "rel": "create", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "service_type", "in_service_type", "name", "content", "default_options", "example_data", "event_types" ], "properties": { "service_type": { "$ref": "#/definitions/service_type" }, "in_service_type": { "$ref": "#/definitions/in_service_type" }, "name": { "$ref": "#/definitions/name" }, "content": { "$ref": "#/definitions/content" }, "default_options": { "$ref": "#/definitions/default_options" }, "example_data": { "$ref": "#/definitions/default_options" }, "event_types": { "$ref": "#/definitions/event_types" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Update", "description": "Updates a existing Template", "href": "/templates/{definitions.identity.example}", "access": "private", "method": "PUT", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "properties": { "service_type": { "$ref": "#/definitions/service_type" }, "in_service_type": { "$ref": "#/definitions/in_service_type" }, "name": { "$ref": "#/definitions/name" }, "content": { "$ref": "#/definitions/content" }, "default_options": { "$ref": "#/definitions/default_options" }, "example_data": { "$ref": "#/definitions/default_options" }, "event_types": { "$ref": "#/definitions/event_types" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Delete", "description": "Deletes a existing Template", "href": "/templates/{definitions.identity.example}", "access": "private", "method": "DELETE", "rel": "delete", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "boolean" } } ], "properties": { "id": { "$ref": "#/definitions/id" }, "created_on": { "$ref": "#/definitions/created_on" }, "modified_on": { "$ref": "#/definitions/modified_on" }, "service_type": { "$ref": "#/definitions/service_type" }, "in_service_type": { "$ref": "#/definitions/in_service_type" }, "name": { "$ref": "#/definitions/name" }, "content": { "$ref": "#/definitions/content" }, "default_options": { "$ref": "#/definitions/default_options" }, "example_data": { "$ref": "#/definitions/example_data" }, "event_types": { "$ref": "#/definitions/event_types" } } } ================================================ FILE: src/backend/schema/endpoints/tokens.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "endpoints/tokens", "title": "Token", "description": "Tokens are required to authenticate against the JiraBot API", "stability": "stable", "type": "object", "definitions": { "identity": { "description": "Email Address or other 3rd party providers identifier", "example": "john@example.com", "type": "string" }, "secret": { "description": "A password or key", "example": "correct horse battery staple", "type": "string" }, "token": { "description": "JWT", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", "type": "string" }, "expires": { "description": "Token expiry time", "format": "date-time", "type": "string" }, "scope": { "description": "Scope of the Token, defaults to 'user'", "example": "user", "type": "string" } }, "links": [ { "title": "Create", "description": "Creates a new token.", "href": "/tokens", "access": "public", "method": "POST", "rel": "create", "schema": { "type": "object", "required": [ "identity", "secret" ], "properties": { "identity": { "$ref": "#/definitions/identity" }, "secret": { "$ref": "#/definitions/secret" }, "scope": { "$ref": "#/definitions/scope" } } }, "targetSchema": { "type": "object", "properties": { "token": { "$ref": "#/definitions/token" }, "expires": { "$ref": "#/definitions/expires" } } } }, { "title": "Refresh", "description": "Returns a new token.", "href": "/tokens", "access": "private", "method": "GET", "rel": "self", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": {}, "targetSchema": { "type": "object", "properties": { "token": { "$ref": "#/definitions/token" }, "expires": { "$ref": "#/definitions/expires" }, "scope": { "$ref": "#/definitions/scope" } } } } ] } ================================================ FILE: src/backend/schema/endpoints/users.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "endpoints/users", "title": "Users", "description": "Endpoints relating to Users", "stability": "stable", "type": "object", "definitions": { "id": { "$ref": "../definitions.json#/definitions/id" }, "created_on": { "$ref": "../definitions.json#/definitions/created_on" }, "modified_on": { "$ref": "../definitions.json#/definitions/modified_on" }, "name": { "description": "Name", "example": "Jamie Curnow", "type": "string", "minLength": 2, "maxLength": 100 }, "nickname": { "description": "Nickname", "example": "Jamie", "type": "string", "minLength": 2, "maxLength": 50 }, "email": { "$ref": "../definitions.json#/definitions/email" }, "avatar": { "description": "Avatar", "example": "http://somewhere.jpg", "type": "string", "minLength": 2, "maxLength": 150, "readOnly": true }, "roles": { "description": "Roles", "example": [ "admin" ], "type": "array" }, "is_disabled": { "description": "Is Disabled", "example": false, "type": "boolean" } }, "links": [ { "title": "List", "description": "Returns a list of Users", "href": "/users", "access": "private", "method": "GET", "rel": "self", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "array", "items": { "$ref": "#/properties" } } }, { "title": "Create", "description": "Creates a new User", "href": "/users", "access": "private", "method": "POST", "rel": "create", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "name", "nickname", "email" ], "properties": { "name": { "$ref": "#/definitions/name" }, "nickname": { "$ref": "#/definitions/nickname" }, "email": { "$ref": "#/definitions/email" }, "roles": { "$ref": "#/definitions/roles" }, "is_disabled": { "$ref": "#/definitions/is_disabled" }, "auth": { "type": "object", "description": "Auth Credentials", "example": { "type": "password", "secret": "bigredhorsebanana" } } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Update", "description": "Updates a existing User", "href": "/users/{definitions.identity.example}", "access": "private", "method": "PUT", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "properties": { "name": { "$ref": "#/definitions/name" }, "nickname": { "$ref": "#/definitions/nickname" }, "email": { "$ref": "#/definitions/email" }, "roles": { "$ref": "#/definitions/roles" }, "is_disabled": { "$ref": "#/definitions/is_disabled" } } }, "targetSchema": { "properties": { "$ref": "#/properties" } } }, { "title": "Delete", "description": "Deletes a existing User", "href": "/users/{definitions.identity.example}", "access": "private", "method": "DELETE", "rel": "delete", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "targetSchema": { "type": "boolean" } }, { "title": "Set Password", "description": "Sets a password for an existing User", "href": "/users/{definitions.identity.example}/auth", "access": "private", "method": "PUT", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "type", "secret" ], "properties": { "type": { "type": "string", "pattern": "^password$" }, "current": { "type": "string", "minLength": 1, "maxLength": 64 }, "secret": { "type": "string", "minLength": 8, "maxLength": 64 } } }, "targetSchema": { "type": "boolean" } }, { "title": "Set Service Settings", "description": "Sets service settings for an existing User", "href": "/users/{definitions.identity.example}/services", "access": "private", "method": "POST", "rel": "update", "http_header": { "$ref": "../examples.json#/definitions/auth_header" }, "schema": { "type": "object", "required": [ "settings" ], "properties": { "settings": { "type": "object" } } }, "targetSchema": { "type": "boolean" } } ], "properties": { "id": { "$ref": "#/definitions/id" }, "created_on": { "$ref": "#/definitions/created_on" }, "modified_on": { "$ref": "#/definitions/modified_on" }, "name": { "$ref": "#/definitions/name" }, "nickname": { "$ref": "#/definitions/nickname" }, "email": { "$ref": "#/definitions/email" }, "avatar": { "$ref": "#/definitions/avatar" }, "roles": { "$ref": "#/definitions/roles" }, "is_disabled": { "$ref": "#/definitions/is_disabled" } } } ================================================ FILE: src/backend/schema/examples.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "examples", "type": "object", "definitions": { "name": { "description": "Name", "example": "John Smith", "type": "string", "minLength": 1, "maxLength": 255 }, "auth_header": { "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk", "X-API-Version": "next" }, "token": { "type": "string", "description": "JWT", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk" } } } ================================================ FILE: src/backend/schema/index.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Juxtapose REST API", "description": "This is the Juxtapose REST API", "$id": "root", "version": "1.0.0", "links": [ { "href": "http://juxtapose/api", "rel": "self" } ], "properties": { "tokens": { "$ref": "endpoints/tokens.json" }, "users": { "$ref": "endpoints/users.json" }, "services": { "$ref": "endpoints/services.json" }, "templates": { "$ref": "endpoints/templates.json" }, "rules": { "$ref": "endpoints/rules.json" } } } ================================================ FILE: src/frontend/app-images/favicons/browserconfig.xml ================================================ #f5f5f5 ================================================ FILE: src/frontend/app-images/favicons/site.webmanifest ================================================ { "name": "", "short_name": "", "icons": [ { "src": "/images/favicons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/images/favicons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: src/frontend/html/index.html ================================================ Docker Registry UI ================================================ FILE: src/frontend/js/actions.js ================================================ import {location} from 'hyperapp-hash-router'; import Api from './lib/api'; import $ from 'jquery'; import moment from 'moment'; const fetching = {}; const actions = { location: location.actions, /** * @param state * @returns {*} */ updateState: state => state, /** * @returns {Function} */ bootstrap: () => async (state, actions) => { try { let status = await Api.status(); $('#version_number').text([status.version.major, status.version.minor, status.version.revision].join('.')); let repos = await Api.Repos.getAll(true); // Hack to remove any image that has no tags let clean_repos = []; repos.map(repo => { if (typeof repo.tags !== 'undefined' && repo.tags !== null && repo.tags.length) { clean_repos.push(repo); } }); actions.updateState({isLoading: false, status: status, repos: clean_repos, globalError: null}); } catch (err) { actions.updateState({isLoading: false, globalError: err}); } }, /** * @returns {Function} */ fetchImage: image_id => async (state, actions) => { if (typeof fetching[image_id] === 'undefined' || !fetching[image_id]) { fetching[image_id] = true; let image_item = { err: null, timestamp: parseInt(moment().format('X'), 10), data: null }; try { image_item.data = await Api.Repos.get(image_id, true); } catch (err) { image_item.err = err; } let new_state = {images: state.images}; new_state.images[image_id] = image_item; actions.updateState(new_state); fetching[image_id] = false; } }, deleteImageClicked: e => async (state, actions) => { let $btn = $(e.currentTarget).addClass('btn-loading disabled').prop('disabled', true); let $modal = $btn.parents('.modal').first(); let image_id = $btn.data('image_id'); Api.Repos.delete(image_id, $btn.data('digest')) .then(result => { if (typeof result.code !== 'undefined' && result.code === 'UNSUPPORTED') { throw new Error('Deleting is not enabled on the Registry'); } else if (result === true) { $modal.modal('hide'); let new_state = { isLoaded: true, images: state.images }; delete new_state.images[image_id]; setTimeout(function () { actions.updateState(new_state); actions.location.go('/'); actions.bootstrap(); }, 300); } else { throw new Error('Unrecognized response: ' + JSON.stringify(result)); } }) .catch(err => { console.error(err); $modal.find('.modal-body').append($('

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