Use react single page app for browsing registry
|
@ -1 +1,4 @@
|
|||
.env
|
||||
node_modules
|
||||
bower
|
||||
public/bundle.js
|
||||
|
|
|
@ -6,7 +6,7 @@ FROM niche/ruby-base:0.1
|
|||
MAINTAINER Mike Heijmans <parabuzzle@gmail.com>
|
||||
|
||||
# Add env variables
|
||||
ENV PORT 4567
|
||||
ENV PORT 80
|
||||
ENV REGISTRY_HOST localhost
|
||||
ENV REGISTRY_PORT=5000
|
||||
ENV REGISTRY_PROTO=https
|
||||
|
@ -25,8 +25,5 @@ WORKDIR $APP_HOME
|
|||
# Add the app
|
||||
ADD . $APP_HOME
|
||||
|
||||
# Expose needed ports
|
||||
EXPOSE 4567
|
||||
|
||||
# Run the app
|
||||
CMD bundle exec foreman start
|
||||
|
|
2
Gemfile
|
@ -26,3 +26,5 @@ gem 'httparty'
|
|||
gem 'pry'
|
||||
|
||||
gem 'memoist'
|
||||
|
||||
gem "sinatra-cross_origin", "~> 0.3.1"
|
||||
|
|
|
@ -28,6 +28,7 @@ GEM
|
|||
rack (~> 1.4)
|
||||
rack-protection (~> 1.4)
|
||||
tilt (>= 1.3, < 3)
|
||||
sinatra-cross_origin (0.3.2)
|
||||
slim (3.0.6)
|
||||
temple (~> 0.7.3)
|
||||
tilt (>= 1.3.3, < 2.1)
|
||||
|
@ -56,9 +57,10 @@ DEPENDENCIES
|
|||
rack (~> 1.6.0)
|
||||
rake
|
||||
sinatra
|
||||
sinatra-cross_origin (~> 0.3.1)
|
||||
slim
|
||||
thin
|
||||
unicorn
|
||||
|
||||
BUNDLED WITH
|
||||
1.10.6
|
||||
1.11.2
|
||||
|
|
20
README.md
|
@ -1,23 +1,29 @@
|
|||
# CraneOperator
|
||||
Just as crane operators can see where all the containers are in the shipyard, CraneOp gives you a simple web interface for browsing around a Docker Registry running version 2.0+
|
||||
Just as crane operators can see where all the containers that are in the shipyard, CraneOp gives you a simple web interface for browsing around a Docker Registry running version 2.0+
|
||||
|
||||
[](https://circleci.com/gh/parabuzzle/craneoperator)
|
||||
|
||||

|
||||
|
||||
## Why Crane Operator?
|
||||
|
||||
When you run your own internal docker registry, it can be challenging to find out what has been saved there. I wanted to create a simple and lightweight frontend for browsing my registry. Most solutions that exist are built for registry v1 and don't work with the newer registry v2. (to be honest, its hard enough to even get registry v2 working... browsing it shouldn't be)
|
||||
|
||||
## How do I run it?
|
||||
|
||||
```
|
||||
docker run -d -p 4567:4567 parabuzzle/craneoperator:latest
|
||||
docker run -d -p 80:80 parabuzzle/craneoperator:latest
|
||||
```
|
||||
|
||||
## Customizing the Crane
|
||||
## How do I configure it?
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-p 4567:4567 \
|
||||
-p 80:80 \
|
||||
-e REGISTRY_HOST=registry.yourdomain.com \
|
||||
-e REGISTRY_PORT=443 \
|
||||
-e REGISTRY_PROTO=https \
|
||||
-e REGISTRY_SSL_VERIFY=false \
|
||||
parabuzzle/craneoperator:latest
|
||||
```
|
||||
|
||||
|
||||

|
||||

|
||||
|
|
3
Rakefile
|
@ -37,6 +37,9 @@ task :push => :tag do
|
|||
end
|
||||
|
||||
task :build do
|
||||
sh "npm install"
|
||||
sh "npm install webpack"
|
||||
sh "node_modules/webpack/bin/webpack.js"
|
||||
sh "docker build -t parabuzzle/craneoperator:latest ."
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
apiserver.rb
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import RepoBrowser from './components/RepoBrowser'
|
||||
|
||||
ReactDOM.render(
|
||||
<RepoBrowser />,
|
||||
document.getElementById('app')
|
||||
);
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import Footer from './sections/Footer';
|
||||
import Header from './sections/Header';
|
||||
import Repos from './Repos';
|
||||
import Tags from './Tags';
|
||||
import TagInfo from './TagInfo';
|
||||
|
||||
class RepoBrowser extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
repo: undefined,
|
||||
tag: undefined,
|
||||
getinfo: false
|
||||
}
|
||||
}
|
||||
|
||||
handleSetTag(name){
|
||||
this.setState({
|
||||
tag: name,
|
||||
getinfo: true
|
||||
})
|
||||
}
|
||||
|
||||
handleSetRepo(name){
|
||||
this.setState({
|
||||
getinfo: false,
|
||||
repo: name
|
||||
})
|
||||
}
|
||||
|
||||
render(){
|
||||
return(
|
||||
<div className="main-container">
|
||||
<Header />
|
||||
<div className="container">
|
||||
<div className="col-sm-3">
|
||||
<Repos repo={this.state.repo} setRepo={(name) => this.handleSetRepo(name)}/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Tags repo={this.state.repo} setTag={(name) => this.handleSetTag(name)}/>
|
||||
</div>
|
||||
<div className="col-sm-6 col-left-border">
|
||||
<TagInfo tag={this.state.tag} repo={this.state.repo} getinfo={this.state.getinfo}/>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)};
|
||||
}
|
||||
|
||||
export default RepoBrowser;
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import List from './Repos/List';
|
||||
|
||||
class Repos extends React.Component {
|
||||
render(){
|
||||
return(
|
||||
<div>
|
||||
<h3>Repos</h3>
|
||||
<List setRepo={this.props.setRepo}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Repos;
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import Loader from 'react-loader';
|
||||
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
export default class List extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
repos: [],
|
||||
repo: undefined,
|
||||
loaded: false,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
|
||||
init(){
|
||||
this.props.setRepo;
|
||||
this.getReposList()
|
||||
.then(function(data){
|
||||
this.setState({
|
||||
repos: data,
|
||||
loaded: true,
|
||||
repo: this.props.repo
|
||||
})
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
this.init();
|
||||
}
|
||||
|
||||
getReposList(){
|
||||
return axios.get(`/containers.json`)
|
||||
.then(function (response) {
|
||||
return(response.data);
|
||||
})
|
||||
.catch(function (response){
|
||||
this.setState({
|
||||
loaded: true,
|
||||
error: response
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(name){
|
||||
this.props.setRepo(name);
|
||||
this.setState({
|
||||
repo: name
|
||||
})
|
||||
}
|
||||
|
||||
render(){
|
||||
return(
|
||||
<ul className="list-group">
|
||||
<Loader loaded={this.state.loaded} color="red" scale={0.75}>
|
||||
{this.state.error && "Error Fetching Repos"}
|
||||
{this.state.repos.map((repo, index) => (
|
||||
this.state.repo === repo ?
|
||||
<Button bsClass="list-group-item active" key={index} onClick={() => this.handleClick(repo)}>{repo}</Button>
|
||||
:
|
||||
<Button bsClass="list-group-item" key={index} onClick={() => this.handleClick(repo)}>{repo}</Button>
|
||||
))}
|
||||
</Loader>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
|
||||
export default class RepoConfig extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h3>Configuration</h3>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<b>Entrypoint:</b>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
{this.props.config.Entrypoint && this.props.config.Entrypoint.map((point, index) => (
|
||||
<span key={index}>{point} </span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<b>CMD:</b>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
{this.props.config.Cmd && this.props.config.Cmd.map((cmd, index) => (
|
||||
<span key={index}>{cmd} </span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<b>ENV:</b>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
{this.props.config.Env && this.props.config.Env.map((env, index) => (
|
||||
<div key={index}>{env}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<b>Exposed Ports:</b>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
{this.props.config.ExposedPorts && Object.keys(this.props.config.ExposedPorts).map((port, index) => (
|
||||
<div key={index}>{port}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<b>Volumes:</b>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
{this.props.config.Volumes && Object.keys(this.props.config.Volumes).map((volume, index) => (
|
||||
<div key={index}>{volume}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
import Time from 'react-time';
|
||||
import Loader from 'react-loader';
|
||||
import RepoConfig from './RepoConfig';
|
||||
|
||||
|
||||
require('react-datetime');
|
||||
|
||||
export default class RepoTagInfo extends React.Component {
|
||||
|
||||
render(){
|
||||
console.log(this.props.info)
|
||||
return(
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>Architecture:</b></div>
|
||||
<div className="col-md-7">{this.props.info.architecture}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>OS:</b></div>
|
||||
<div className="col-md-7">{this.props.info.information && this.props.info.information.os}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>Created:</b></div>
|
||||
<div className="col-md-7">
|
||||
{this.props.info.information && <Time value={this.props.info.information.created_millis} format="MM/DD/YYYY hh:mma" />} UTC
|
||||
<span className='small text-muted'> ({this.props.info.information && <Time value={this.props.info.information.created_millis} titleFormat="YYYY/MM/DD HH:mm" relative />})</span></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>Author:</b></div>
|
||||
<div className="col-md-7">{this.props.info.information && this.props.info.information.author}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>ID:</b></div>
|
||||
<div className="col-md-7">{this.props.info.information && this.props.info.information.id}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>Container:</b></div>
|
||||
<div className="col-md-7">{this.props.info.information && this.props.info.information.container}</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-3"><b>Docker Version:</b></div>
|
||||
<div className="col-md-7">{this.props.info.information && this.props.info.information.docker_version}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import Time from 'react-time';
|
||||
import axios from 'axios';
|
||||
import Loader from 'react-loader';
|
||||
import RepoConfig from './RepoConfig';
|
||||
import RepoInfo from './RepoInfo';
|
||||
|
||||
require('react-datetime');
|
||||
|
||||
export default class RepoTagInfo extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
repo: undefined,
|
||||
tag: undefined,
|
||||
info: undefined,
|
||||
loaded: true,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if(nextProps.getinfo){
|
||||
this.setState({
|
||||
repo: nextProps.repo,
|
||||
loaded: false,
|
||||
tag: nextProps.tag
|
||||
})
|
||||
this.updateList(nextProps.repo, nextProps.tag)
|
||||
} else {
|
||||
this.setState({
|
||||
info: undefined,
|
||||
tag: undefined,
|
||||
repo: undefined,
|
||||
loaded: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateList(repo, tag){
|
||||
this.getTagInfo(repo, tag)
|
||||
.then(function(data){
|
||||
this.setState({
|
||||
info: data,
|
||||
loaded: true
|
||||
})
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
getTagInfo(repo, tag){
|
||||
return axios.get(`/container/${repo}/${tag}.json`)
|
||||
.then(function (response) {
|
||||
return(response.data);
|
||||
})
|
||||
.catch(function (response){
|
||||
this.setState({
|
||||
loaded: true,
|
||||
error: response
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
render(){
|
||||
const {info} = this.state;
|
||||
return(
|
||||
<div>
|
||||
<Loader loaded={this.state.loaded} color="red" scale={0.75} >
|
||||
{this.state.error && "Error Fetching Repos"}
|
||||
{ this.state.info && <RepoInfo info={this.state.info} />}
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
{this.state.info && <RepoConfig config={this.state.info.information.config} />}
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import Loader from 'react-loader';
|
||||
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
export default class RepoTags extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
tags: [],
|
||||
repo: undefined,
|
||||
tag: undefined,
|
||||
loaded: true
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if(nextProps.repo !== this.state.repo){
|
||||
this.setState({
|
||||
repo: nextProps.repo,
|
||||
loaded: false,
|
||||
tag: undefined
|
||||
})
|
||||
this.updateList(nextProps.repo)
|
||||
}
|
||||
}
|
||||
|
||||
updateList(repo){
|
||||
this.getTags(repo)
|
||||
.then(function(data){
|
||||
this.setState({
|
||||
tags: data,
|
||||
loaded: true
|
||||
})
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
getTags(repo){
|
||||
return axios.get(`/container/${repo}/tags.json`)
|
||||
.then(function (response) {
|
||||
return(response.data);
|
||||
})
|
||||
.catch(function (response){
|
||||
this.setState({
|
||||
loaded: true,
|
||||
error: response
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(tag){
|
||||
this.props.setTag(tag);
|
||||
this.setState({
|
||||
tag: tag
|
||||
})
|
||||
}
|
||||
|
||||
render(){
|
||||
return(
|
||||
<div>
|
||||
<h3>Tags</h3>
|
||||
<ul className="list-group">
|
||||
<Loader loaded={this.state.loaded} color="red" scale={0.75}>
|
||||
{this.state.error && "Error Fetching Repos"}
|
||||
{this.state.tags.map((tag, index) => (
|
||||
this.state.tag === tag ?
|
||||
<Button bsClass="list-group-item active" key={index} onClick={() => this.handleClick(tag)}>{tag}</Button>
|
||||
:
|
||||
<Button bsClass="list-group-item" key={index} onClick={() => this.handleClick(tag)}>{tag}</Button>
|
||||
))}
|
||||
</Loader>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import RepoTags from './Repos/RepoTags';
|
||||
import RepoTagInfo from './Repos/RepoTagInfo';
|
||||
|
||||
class TagInfo extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h3>Information</h3>
|
||||
<RepoTagInfo tag={this.props.tag} repo={this.props.repo} getinfo={this.props.getinfo}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default TagInfo;
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import RepoTags from './Repos/RepoTags';
|
||||
import List from './Repos/List';
|
||||
|
||||
class Tags extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
repo: undefined
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({
|
||||
repo: nextProps.repo
|
||||
})
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<RepoTags repo={this.state.repo} setTag={this.props.setTag}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default Tags;
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { Navbar } from 'react-bootstrap';
|
||||
|
||||
export default class Footer extends React.Component {
|
||||
constructor(props){
|
||||
super(props);
|
||||
this.state = {
|
||||
registry: {}
|
||||
}
|
||||
}
|
||||
|
||||
getRegistryInfo(){
|
||||
return axios.get(`/registryinfo`)
|
||||
.then(function (response) {
|
||||
return(response.data);
|
||||
})
|
||||
.catch(function (response) {
|
||||
console.log('ERROR IN AXIOS! ' + response);
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount(){
|
||||
this.getRegistryInfo()
|
||||
.then(function(data){
|
||||
this.setState({
|
||||
registry: data
|
||||
})
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className='footer-pad'>
|
||||
<Navbar inverse={true} fixedBottom={true}>
|
||||
<Navbar.Text>
|
||||
Crane Operator browsing {this.state.registry.host}
|
||||
</Navbar.Text>
|
||||
<Navbar.Text pullRight>
|
||||
<Navbar.Link href="https://github.com/parabuzzle/craneoperator" target="_blank">GitHub Project</Navbar.Link>
|
||||
</Navbar.Text>
|
||||
</Navbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Navbar } from 'react-bootstrap';
|
||||
|
||||
export default class Header extends React.Component {
|
||||
render(){
|
||||
return (
|
||||
<div>
|
||||
<Navbar staticTop={true}>
|
||||
<Navbar.Header>
|
||||
<img className='navbar-brand' src="mini-logo.svg"/>
|
||||
<Navbar.Brand>
|
||||
Crane Operator
|
||||
</Navbar.Brand>
|
||||
</Navbar.Header>
|
||||
</Navbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ dependencies:
|
|||
- docker info
|
||||
- gem install httparty
|
||||
- gem install memoist
|
||||
- npm install
|
||||
- npm install webpack
|
||||
- rake build
|
||||
|
||||
test:
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "craneoperator",
|
||||
"version": "2.0.0",
|
||||
"description": "The Docker Registry Viewer",
|
||||
"main": "bundle.js",
|
||||
"dependencies": {
|
||||
"axios": "^0.8.0",
|
||||
"history": "^1.13.1",
|
||||
"moment": "^2.13.0",
|
||||
"re-base": "^1.5.1",
|
||||
"react": "^0.14.3",
|
||||
"react-bootstrap": "^0.29.3",
|
||||
"react-datetime": "^2.1.0",
|
||||
"react-dom": "^0.14.3",
|
||||
"react-intl": "^2.1.2",
|
||||
"react-loader": "^2.4.0",
|
||||
"react-router": "^1.0.1",
|
||||
"react-time": "^4.0.3",
|
||||
"babel-core": "^6.3.13",
|
||||
"babel-loader": "^6.2.0",
|
||||
"babel-preset-es2015": "^6.3.13",
|
||||
"babel-preset-react": "^6.3.13",
|
||||
"webpack": "^1.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack-dev-server": "^1.14.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "webpack-dev-server --content-base public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/parabuzzle/craneop.git"
|
||||
},
|
||||
"keywords": [
|
||||
"docker",
|
||||
"registry",
|
||||
"browser"
|
||||
],
|
||||
"author": "Michael Heijmans",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/parabuzzle/craneop/issues"
|
||||
},
|
||||
"homepage": "https://github.com/parabuzzle/craneop#readme"
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Crane Operator Not Found</title>
|
||||
|
||||
<!-- bootstrap css -->
|
||||
<link rel="stylesheet" href="bootstrap.min.css" crossorigin="anonymous">
|
||||
|
||||
<!-- bootstrap theme -->
|
||||
<link rel="stylesheet" href="bootstrap-theme.css" crossorigin="anonymous">
|
||||
|
||||
<!-- application css -->
|
||||
<link rel="stylesheet" href="app.css" crossorigin="anonymous">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<img class="navbar-brand" src="mini-logo.svg">
|
||||
<a href="/" class="navbar-brand">Crane Operator</a>
|
||||
</div>
|
||||
<div id="navbar" class="collapse navbar-collapse">
|
||||
|
||||
</div><!--/.nav-collapse -->
|
||||
</div>
|
||||
</nav>
|
||||
<br/><br/><br/>
|
||||
<div class="container">
|
||||
<h1>Not Found</h1>
|
||||
<div>
|
||||
UhOh! the page you're looking for doesn't exist here... Perhaps it fell off the boat?
|
||||
</div>
|
||||
<div>
|
||||
<br/><br/>
|
||||
<img class="img-responsive" src="404.png"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Crane Operator Not Found</title>
|
||||
|
||||
<!-- bootstrap css -->
|
||||
<link rel="stylesheet" href="bootstrap.min.css" crossorigin="anonymous">
|
||||
|
||||
<!-- bootstrap theme -->
|
||||
<link rel="stylesheet" href="bootstrap-theme.css" crossorigin="anonymous">
|
||||
|
||||
<!-- application css -->
|
||||
<link rel="stylesheet" href="app.css" crossorigin="anonymous">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<img class="navbar-brand" src="mini-logo.svg">
|
||||
<a href="/" class="navbar-brand">Crane Operator</a>
|
||||
</div>
|
||||
<div id="navbar" class="collapse navbar-collapse">
|
||||
|
||||
</div><!--/.nav-collapse -->
|
||||
</div>
|
||||
</nav>
|
||||
<br/><br/><br/>
|
||||
<div class="container">
|
||||
<h1>Error!</h1>
|
||||
<div>
|
||||
Oh No! Something's gone terribly wrong!
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +1,32 @@
|
|||
#main {
|
||||
margin-top: 35px;
|
||||
min-height: 500px;
|
||||
html, body{
|
||||
height:100%;
|
||||
}
|
||||
|
||||
.footer-pad {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: white;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: fixed;
|
||||
top: 500px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: white;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.col-left-border {
|
||||
min-height: 400px;
|
||||
height:100%;
|
||||
border-left: 1px solid #aaa;
|
||||
}
|
||||
|
|
BIN
public/crane.jpg
Before Width: | Height: | Size: 89 KiB |
After Width: | Height: | Size: 16 KiB |
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Crane Operator</title>
|
||||
|
||||
<!-- bootstrap css -->
|
||||
<link rel="stylesheet" href="bootstrap.min.css" crossorigin="anonymous">
|
||||
|
||||
<!-- bootstrap theme -->
|
||||
<link rel="stylesheet" href="bootstrap-theme.css" crossorigin="anonymous">
|
||||
|
||||
<!-- application css -->
|
||||
<link rel="stylesheet" href="app.css" crossorigin="anonymous">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1 @@
|
|||
<svg width="60" height="43" viewBox="0 0 60 43" xmlns="http://www.w3.org/2000/svg"><title>logo copy</title><g fill="#FFF" fill-rule="evenodd"><path d="M3.757 15.768h6.04v5.873h-6.04V15.77zM11.17 15.768h6.04v5.873h-6.04V15.77zM11.17 8.24h6.04v5.87h-6.04V8.24zM18.58 15.768h6.043v5.873H18.58V15.77zM18.58 8.24h6.043v5.87H18.58V8.24zM25.993 15.768h6.042v5.873h-6.042V15.77zM25.993 8.24h6.042v5.87h-6.042V8.24zM33.405 15.768h6.042v5.873h-6.042V15.77zM25.993.708h6.042V6.58h-6.042V.708zM12.194 30.12c-.93 0-1.684.733-1.684 1.636 0 .902.755 1.635 1.684 1.635.928 0 1.683-.732 1.683-1.634 0-.903-.755-1.637-1.683-1.637"/><path d="M58.905 18.806c-2.03-1.138-4.73-1.294-7.03-.636-.283-2.377-1.89-4.46-3.8-5.953l-.758-.593-.638.716c-1.28 1.438-1.66 3.83-1.487 5.666.13 1.35.565 2.722 1.42 3.806-.65.38-1.388.682-2.045.9-1.34.44-2.795.685-4.21.685H.613l-.085.89c-.285 2.972.134 5.947 1.398 8.66l.544 1.078.062.1c3.737 6.17 10.3 8.768 17.452 8.768 13.846 0 25.265-6.01 30.51-18.708 3.505.178 7.09-.83 8.805-4.083l.437-.83-.832-.466zm-46.71 16.056c-1.764 0-3.198-1.394-3.198-3.106 0-1.713 1.434-3.107 3.197-3.107 1.763 0 3.197 1.393 3.197 3.106 0 1.712-1.433 3.106-3.196 3.106z"/></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -1 +0,0 @@
|
|||
test.html
|
After Width: | Height: | Size: 323 KiB |
Before Width: | Height: | Size: 352 KiB |
After Width: | Height: | Size: 2.1 MiB |
Before Width: | Height: | Size: 354 KiB |
118
server.rb
|
@ -2,17 +2,29 @@ require 'oj'
|
|||
require 'httparty'
|
||||
require 'pry'
|
||||
require 'erb'
|
||||
require 'time'
|
||||
require 'sinatra/base'
|
||||
require 'sinatra/cross_origin'
|
||||
|
||||
class CraneOp < Sinatra::Base
|
||||
register Sinatra::CrossOrigin
|
||||
|
||||
configure do
|
||||
enable :cross_origin
|
||||
mime_type :javascript, 'application/javascript'
|
||||
mime_type :javascript, 'text/javascript'
|
||||
set :logging, true
|
||||
set :static, true
|
||||
set :allow_origin, :any
|
||||
set :allow_methods, [:get, :post, :options]
|
||||
set :allow_credentials, true
|
||||
set :max_age, "1728000"
|
||||
set :expose_headers, ['Content-Type']
|
||||
set :json_encoder, :to_json
|
||||
end
|
||||
|
||||
## Setup ##
|
||||
|
||||
def registry_host
|
||||
ENV['REGISTRY_HOST'] || 'localhost'
|
||||
end
|
||||
|
@ -29,37 +41,30 @@ class CraneOp < Sinatra::Base
|
|||
ENV['REGISTRY_SSL_VERIFY'] || 'true'
|
||||
end
|
||||
|
||||
## Helpers ##
|
||||
|
||||
def to_bool(str)
|
||||
str.downcase == 'true'
|
||||
end
|
||||
|
||||
def html(view)
|
||||
File.read(File.join('public', "#{view.to_s}.html"))
|
||||
end
|
||||
|
||||
def sort_versions(ary)
|
||||
valid_version_numbers = ary.select { |i| i if i.match(/(0-9|\.|\-)/)}
|
||||
non_valid_version_numbers = ary - valid_version_numbers
|
||||
(valid_version_numbers.sort_by {|v| Gem::Version.new( v ) } + non_valid_version_numbers)
|
||||
end
|
||||
|
||||
## Registry API Methods ##
|
||||
|
||||
def containers
|
||||
response = HTTParty.get( "#{registry_proto}://#{registry_host}:#{registry_port}/v2/_catalog", verify: to_bool(registry_ssl_verify) )
|
||||
json = Oj.load response.body
|
||||
json['repositories']
|
||||
end
|
||||
|
||||
def sort_versions(ary)
|
||||
ary.sort { |x,y|
|
||||
matcher = /[a-z]/i
|
||||
|
||||
if x.match(matcher)
|
||||
a = nil
|
||||
else
|
||||
a = x.split('.').last.to_i
|
||||
end
|
||||
|
||||
if y.match(matcher)
|
||||
b = nil
|
||||
else
|
||||
b = y.split('.').last.to_i
|
||||
end
|
||||
|
||||
a && b ? a <=> b : a ? -1 : 1
|
||||
}
|
||||
#ary.sort_by! {|v| v.split('.') }
|
||||
end
|
||||
|
||||
def container_tags(repo)
|
||||
response = HTTParty.get( "#{registry_proto}://#{registry_host}:#{registry_port}/v2/#{repo}/tags/list", verify: to_bool(registry_ssl_verify) )
|
||||
json = Oj.load response.body
|
||||
|
@ -70,50 +75,65 @@ class CraneOp < Sinatra::Base
|
|||
def container_info(repo, manifest)
|
||||
response = HTTParty.get( "#{registry_proto}://#{registry_host}:#{registry_port}/v2/#{repo}/manifests/#{manifest}", verify: to_bool(registry_ssl_verify) )
|
||||
json = Oj.load response.body
|
||||
|
||||
# Add extra fields for easy display
|
||||
json['information'] = Oj.load(json['history'].first['v1Compatibility'])
|
||||
|
||||
created_at = Time.parse(json['information']['created'])
|
||||
json['information']['created_formatted'] = created_at.to_s
|
||||
json['information']['created_millis'] = (created_at.to_f * 1000).to_i
|
||||
return json
|
||||
end
|
||||
|
||||
def container_blob(repo, digest='HEAD')
|
||||
response = HTTParty.get( "#{registry_proto}://#{registry_host}:#{registry_port}/v2/#{repo}/blobs/#{digest}", verify: to_bool(registry_ssl_verify) )
|
||||
json = Oj.load response.body
|
||||
end
|
||||
|
||||
get '/test' do
|
||||
erb :index
|
||||
end
|
||||
## Endpoints ##
|
||||
|
||||
get '/' do
|
||||
@containers = containers
|
||||
erb :index
|
||||
html :index
|
||||
end
|
||||
|
||||
get '/about' do
|
||||
erb :about
|
||||
get '/containers.json' do
|
||||
content_type :json
|
||||
|
||||
containers.to_json
|
||||
end
|
||||
|
||||
get '/container/:name' do |name|
|
||||
@container_tags = container_tags(name)
|
||||
@name = name
|
||||
halt 404 if @container_tags.nil?
|
||||
erb :container
|
||||
get '/container/:container/tags.json' do |container|
|
||||
content_type :json
|
||||
|
||||
tags = container_tags(container)
|
||||
halt 404 if tags.nil?
|
||||
tags.to_json
|
||||
end
|
||||
|
||||
get '/container/:name/:tag' do |name, tag|
|
||||
@tag = tag
|
||||
@name = name
|
||||
@container_info = container_info(name, tag)
|
||||
@container_tags = container_tags(name)
|
||||
halt 404 if @container_info['errors']
|
||||
halt 404 if @container_info['fsLayers'].nil?
|
||||
erb :tag
|
||||
get '/container/:container/:tag.json' do |container, tag|
|
||||
content_type :json
|
||||
|
||||
info = container_info(container, tag)
|
||||
|
||||
halt 404 if info['errors']
|
||||
halt 404 if info['fsLayers'].nil?
|
||||
|
||||
info.to_json
|
||||
end
|
||||
|
||||
error 404 do
|
||||
erb :'404'
|
||||
get '/registryinfo' do
|
||||
content_type :json
|
||||
{
|
||||
host: registry_host,
|
||||
port: registry_port,
|
||||
protocol: registry_proto,
|
||||
ssl_verify: registry_ssl_verify
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Error Handlers
|
||||
error do
|
||||
File.read(File.join('public', '500.html'))
|
||||
end
|
||||
|
||||
not_found do
|
||||
status 404
|
||||
erb :'404'
|
||||
File.read(File.join('public', '404.html'))
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
<h1>404</h1>
|
||||
<hr>
|
||||
oops... the info you're looking for cannot be found. Perhaps it fell off the boat?
|
||||
|
||||
<br/><br/>
|
||||
<img src="/404.png"/>
|
|
@ -1,5 +0,0 @@
|
|||
<h1>About</h1>
|
||||
|
||||
Just as a crane operator in a shipyard can see where all the containers are, the Crane Operator does the same for your private registry.
|
||||
<br/><br/>
|
||||
<img src="/crane.jpg"/>
|
|
@ -1,10 +0,0 @@
|
|||
<h1><%= @name %></h1>
|
||||
<hr>
|
||||
<h3>Tags</h3>
|
||||
<% if @container_tags.empty? %>
|
||||
No Tags Found
|
||||
<% else %>
|
||||
<% for tag in @container_tags do %>
|
||||
<a href="/container/<%=@name%>/<%=tag%>"><%=tag%></a>
|
||||
<%end%>
|
||||
<%end%>
|
|
@ -1,7 +0,0 @@
|
|||
<h1>Containers</h1>
|
||||
<hr>
|
||||
<ul>
|
||||
<% for container in @containers %>
|
||||
<li><b><a href="/container/<%=container%>"><%=container%></a></b></li>
|
||||
<%end%>
|
||||
</ul>
|
|
@ -1,62 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css" />
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
|
||||
<script type="text/javascript" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
|
||||
<title>Crane Operator</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">Crane Operator</a>
|
||||
</div>
|
||||
<div id="navbar" class="collapse navbar-collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</div><!--/.nav-collapse -->
|
||||
</div>
|
||||
</nav>
|
||||
<br/><br/>
|
||||
<div id="main">
|
||||
<div class="container">
|
||||
|
||||
<%= yield %>
|
||||
|
||||
</div><!-- /.container -->
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
<hr>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-9"><b>Crane Operator is Open Source</b></div>
|
||||
<div class="col-md-3"><a href="http://github.com/parabuzzle/craneoperator">GitHub Project</a></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.4/angular.min.js"></script>
|
||||
<script type="application/javascript" src="app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<h1><a href="/container/<%=@name%>"><%=@name%></a>:<%=@tag%></h1>
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<b>Architecture</b>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<%= @container_info['architecture'] %>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<b>Tags</b>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<% for tag in @container_tags do %>
|
||||
<a href="/container/<%=@name%>/<%=tag%>"><%=tag%></a><br/>
|
||||
<%end%>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<b>Layers</b>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<% for layer in @container_info['fsLayers'] do %>
|
||||
<%=layer['blobSum'].gsub('sha256:', '')%><br/>
|
||||
<%end%>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
entry: "./app/App.js",
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
path: __dirname + '/public',
|
||||
publicPath: "/"
|
||||
},
|
||||
devServer: {
|
||||
inline: true,
|
||||
port: 4000
|
||||
},
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
loader: 'babel',
|
||||
query: {
|
||||
presets: ['react', 'es2015']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|