diff --git a/.gitignore b/.gitignore index 9787d1c..9f911b8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ vendor/ goconserver congo build +frontend/package-lock.json +frontend/node_modules diff --git a/Makefile b/Makefile index 394e5af..f17d144 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ export PATH GITHUB_DIR=${GOPATH}/src/github.com/xcat2/ REPO_DIR=${GOPATH}/src/github.com/xcat2/goconserver CURRENT_DIR=$(shell pwd) +FRONTEND_DIR=${CURRENT_DIR}/frontend REPO_DIR_LINK=$(shell readlink -f ${REPO_DIR}) SERVER_CONF_FILE=/etc/goconserver/server.conf CLIENT_CONF_FILE=~/congo.sh @@ -20,7 +21,7 @@ endif ifeq ($(PLATFORM), Linux) PLATFORM=linux endif -VERSION=0.2.2 +VERSION=0.3.0 BUILD_TIME=`date +%FT%T%z` LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT}" @@ -50,6 +51,12 @@ build: link go build ${LDFLAGS} -o ${CLIENT_BINARY} cmd/congo.go; \ cd - +frontend: + cd ${FRONTEND_DIR}; \ + npm install --unsafe-perm --save-dev; \ + gulp build; \ + cd - + install: build cp ${SERVER_BINARY} /usr/local/bin/${SERVER_BINARY} cp ${CLIENT_BINARY} /usr/local/bin/${CLIENT_BINARY} @@ -86,4 +93,4 @@ clean: rm -rf build rm -rf bin pkg -.PHONY: binary deps fmt build clean link tar deb rpm +.PHONY: binary deps fmt frontend build clean link tar deb rpm diff --git a/README.md b/README.md index 33b80b3..8b32c53 100644 --- a/README.md +++ b/README.md @@ -37,46 +37,48 @@ api interface. - file: Store the host information in a json file. - etcd: Support goconserver cluster [experimental]. +### Multiple client types + +- terminal: Get console session via TCP(or with TLS) connection. +- web: Get console session from web terminal. + +![preview](/goconserver2.gif) + ### Design Structure -`goconserver` can be divided into two parts: -- daemon part: `goconserver`, expose rest api interface to define and control - the session node. +`goconserver` can be divided into three parts: +- daemon part: `goconserver`, expose REST api interface to define and control + the session host. -- client part: `congo`, a command line tool to define session or connect to the - session. Multiple client sessions could be shared. +- client part: `congo`, a command line tool to manage the configuration of + session hosts. A tty console client is also provided and multiple clients + could share the same session. + +- frontend part: A web page is provided to list the session status and expose + a web terminal for the selected node. The tty client from `congo` can share + the same session from web browser. ## Setup goconserver from release -### Setup goconserver from binary -Download the binary tarball for release from +### Setup +Download binary or RPM tarball from [goconserver](https://github.com/xcat2/goconserver/releases) ``` -tar xvfz goconserver_linux_amd64.tar.gz -cd goconserver_linux_amd64 -./setup.sh +yum install +systemctl start goconserver.service ``` -Modify the congiguration file `/etc/goconserver/server.conf` based on your -environment, then run `goconserver` to start the daemon service. To support a -large amount of sessions, please use `ulimit -n ` command to set the -number of open files. -``` -goconserver [--congi-file ] -``` +### Configuration +For the server side, modify the congiguration file +`/etc/goconserver/server.conf` based on your environment, then restart +goconserver service. + +For client, modify the the environment variables in `/etc/profile.d/congo.sh` +based on your environment, then try the `congo` command. For example: -Modify the the environment variables in `/etc/profile.d/congo.sh` based on your -environment, then try the `congo` command. ``` source /etc/profile.d/congo.sh congo list ``` -### Setup goconserver from rpm or deb - -``` -tar xvfz -yum install -dpkg -i -``` ## Development @@ -94,10 +96,29 @@ make deps make install ``` -### Setup SSL (optional) +### Setup SSL/TLS (optional) Please refer to [ssl](/scripts/ssl/) +### Web Interface (ongoing) + +Setup nodejs(9.0+) and npm(5.6.0+) toolkit at first. An example steps could be +found at [node env](/frontend/). Then follow the steps below: + +``` +npm install -g gulp webpack webpack-cli +make frontend +``` + +The frontend content is generated at `build/dist` directory. To enable it, +modify the configuration in `/etc/gconserver/server.conf` like below, then +restart `goconserver` service. The web url is available on +`http(s)://:/`. +``` +api: + dist_dir : "" +``` + ## Command Example ### Start service diff --git a/api/web.go b/api/web.go new file mode 100644 index 0000000..271537e --- /dev/null +++ b/api/web.go @@ -0,0 +1,58 @@ +package api + +import ( + "compress/gzip" + "fmt" + "github.com/gorilla/mux" + "github.com/xcat2/goconserver/console" + "golang.org/x/net/websocket" + "io" + "net/http" + "strings" +) + +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +func MakeGzipHandler(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the client can accept the gzip encoding. + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + handler.ServeHTTP(w, r) + return + } + + // Set the HTTP header indicating encoding. + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + defer gz.Close() + gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} + handler.ServeHTTP(gzw, r) + }) +} + +func WebHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + plog.Info(fmt.Sprintf("Receive %s request %s from %s.", r.Method, r.URL.Path, r.RemoteAddr)) + if r.URL.EscapedPath() == "/" || r.URL.EscapedPath() == "/index.html" { + w.Header().Add("Cache-Control", "no-store") + } + http.FileServer(http.Dir(serverConfig.API.DistDir)).ServeHTTP(w, r) + }) +} + +func RegisterBackendHandler(router *mux.Router) { + router.PathPrefix("/session").Handler(websocket.Handler(websocketHandler)) + router.PathPrefix("/").Handler(MakeGzipHandler(WebHandler())) +} + +func websocketHandler(ws *websocket.Conn) { + plog.Info(fmt.Sprintf("Recieve websocket request from %s\n", ws.RemoteAddr().String())) + console.AcceptWesocketClient(ws) +} diff --git a/common/conf.go b/common/conf.go index 70c6ced..cc8b5b2 100644 --- a/common/conf.go +++ b/common/conf.go @@ -98,6 +98,7 @@ type ServerConfig struct { API struct { Port string `yaml:"port"` HttpTimeout int `yaml:"http_timeout"` + DistDir string `yaml:"dist_dir"` } Console struct { Port string `yaml:"port"` @@ -128,6 +129,7 @@ func InitServerConfig(confFile string) (*ServerConfig, error) { serverConfig.Global.StorageType = "file" serverConfig.API.Port = "12429" serverConfig.API.HttpTimeout = 10 + serverConfig.API.DistDir = "" serverConfig.Console.Port = "12430" serverConfig.Console.DataDir = "/var/lib/goconserver/" serverConfig.Console.LogTimestamp = true diff --git a/common/errors.go b/common/errors.go index 48cad24..754fb11 100644 --- a/common/errors.go +++ b/common/errors.go @@ -9,6 +9,8 @@ const ( STORAGE_NOT_EXIST TASK_NOT_EXIST + NULL_OBJECT + INVALID_PARAMETER LOCKED CONNECTION_ERROR @@ -34,6 +36,7 @@ var ( ErrCommandNotExist = NewErr(COMMAND_NOT_EXIST, "Command not exist") ErrStorageNotExist = NewErr(STORAGE_NOT_EXIST, "Storage not exist") ErrTaskNotExist = NewErr(TASK_NOT_EXIST, "Task not exist") + ErrNullObject = NewErr(NULL_OBJECT, "Null object") ErrInvalidParameter = NewErr(INVALID_PARAMETER, "Invalid parameter") ErrLocked = NewErr(LOCKED, "Locked") diff --git a/common/network.go b/common/network.go index ad90831..4206ba1 100644 --- a/common/network.go +++ b/common/network.go @@ -4,6 +4,8 @@ import ( "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" + "golang.org/x/net/websocket" "io" "io/ioutil" "net" @@ -123,6 +125,12 @@ func (self *network) ResetWriteTimeout(conn net.Conn) error { func (self *network) SendBytes(conn net.Conn, b []byte) error { n := 0 + // TODO(chenglch): A workaround to solve 1006 error from websocket at + // frontend side due to UTF8 encoding problem. + if _, ok := conn.(*websocket.Conn); ok { + s := base64.StdEncoding.EncodeToString(b) + b = []byte(s) + } for n < len(b) { tmp, err := conn.Write(b[n:]) if err != nil { diff --git a/console/console.go b/console/console.go index 28e42c6..d0e52bd 100644 --- a/console/console.go +++ b/console/console.go @@ -54,7 +54,7 @@ func (self *Console) Accept(conn net.Conn) { self.bufConn[conn] = make(chan []byte) self.mutex.Unlock() go self.writeTarget(conn) - go self.writeClient(conn) + self.writeClient(conn) } // Disconnect from client diff --git a/console/server.go b/console/server.go index 04ccdcf..fa6eb2e 100644 --- a/console/server.go +++ b/console/server.go @@ -9,6 +9,7 @@ import ( pl "github.com/xcat2/goconserver/console/pipeline" "github.com/xcat2/goconserver/plugins" "github.com/xcat2/goconserver/storage" + "golang.org/x/net/websocket" "net" "net/http" "os" @@ -32,10 +33,11 @@ const ( ) var ( - plog = common.GetLogger("github.com/xcat2/goconserver/console") - nodeManager *NodeManager - serverConfig = common.GetServerConfig() - STATUS_MAP = map[int]string{ + plog = common.GetLogger("github.com/xcat2/goconserver/console") + nodeManager *NodeManager + consoleServer *ConsoleServer + serverConfig = common.GetServerConfig() + STATUS_MAP = map[int]string{ STATUS_AVAIABLE: "avaiable", STATUS_ENROLL: "enroll", STATUS_CONNECTED: "connected", @@ -124,6 +126,8 @@ func (self *Node) restartMonitor() { plog.DebugNode(self.StorageNode.Name, "Exit reconnect goroutine") return } + // before start console, both request from client and reconnecting monitor try to get the lock at first + // so that only one startConsole goroutine is running for the node. if err = self.RequireLock(false); err != nil { plog.ErrorNode(self.StorageNode.Name, err.Error()) break @@ -274,6 +278,14 @@ func NewConsoleServer(host string, port string) *ConsoleServer { return &ConsoleServer{host: host, port: port} } +func AcceptWesocketClient(ws *websocket.Conn) error { + if consoleServer == nil { + return common.ErrNullObject + } + consoleServer.handle(ws) + return nil +} + func (self *ConsoleServer) getConnectionInfo(conn interface{}) (string, string) { var node, command string var ok bool @@ -364,6 +376,7 @@ func (self *ConsoleServer) handle(conn interface{}) { nodeManager.RWlock.RUnlock() if command == COMMAND_START_CONSOLE { if node.status != STATUS_CONNECTED { + // NOTE(chenglch): Get the lock at first, then allow to connect to the console target. if err = node.RequireLock(false); err != nil { plog.ErrorNode(node.StorageNode.Name, fmt.Sprintf("Could not start console, error: %s.", err)) err = common.Network.SendIntWithTimeout(conn.(net.Conn), STATUS_ERROR, clientTimeout) @@ -376,6 +389,8 @@ func (self *ConsoleServer) handle(conn interface{}) { if node.status == STATUS_CONNECTED { node.Release(false) } else { + // NOTE(chenglch): Already got the lock, but the console connection is not established, start + // console at the backend. go node.startConsole() if err = common.TimeoutChan(node.ready, serverConfig.Console.TargetTimeout); err != nil { plog.ErrorNode(node.StorageNode.Name, fmt.Sprintf("Could not start console, error: %s.", err)) @@ -482,7 +497,7 @@ func GetNodeManager() *NodeManager { nodeManager.hostname = hostname nodeManager.Nodes = make(map[string]*Node) nodeManager.RWlock = new(sync.RWMutex) - consoleServer := NewConsoleServer(serverConfig.Global.Host, serverConfig.Console.Port) + consoleServer = NewConsoleServer(serverConfig.Global.Host, serverConfig.Console.Port) stor, err := storage.NewStorage(serverConfig.Global.StorageType) if err != nil { panic(err) diff --git a/etc/goconserver/server.conf b/etc/goconserver/server.conf index 7fe18f0..946def3 100644 --- a/etc/goconserver/server.conf +++ b/etc/goconserver/server.conf @@ -20,6 +20,8 @@ api: port: "12429" # in second http_timeout: 5 + # dist frontend directory for the web console + dist_dir : "" console: # the console session port for client(congo) to connect. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..faf0cc0 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,9 @@ +## Setup node and npm environment +Example for amd64 system + +``` +wget https://nodejs.org/dist/v9.11.1/node-v9.11.1-linux-x64.tar.xz +xz -d node-v9.11.1-linux-x64.tar.xz +tar xvf node-v9.11.1-linux-x64.tar +export PATH=$PATH:/node-v9.11.1-linux-x64/bin +``` \ No newline at end of file diff --git a/frontend/gulpfile.js b/frontend/gulpfile.js new file mode 100644 index 0000000..947d1eb --- /dev/null +++ b/frontend/gulpfile.js @@ -0,0 +1,22 @@ +var gp = require("gulp"); +var webpack = require('webpack-stream'); + +gp.task("webpack", function() { + return gp.src([ + 'src/js/index.js', + 'src/sass/index.scss' + ]) + .pipe(webpack(require('./webpack.config.js'))) + .pipe(gp.dest('../build/dist/')) +}) + +gp.task("build", ["webpack"], function() { + gp.src(['./src/html/*.html']) + .pipe(gp.dest('../build/dist')) +}) + +gp.task("run", ["build"], function() { + gp.watch('src/*.js', function() { + gulp.run('run'); + }); +}) \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..99ef70e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "description": "", + "main": "gulpfile.js", + "dependencies": { + "jquery": "^3.3.1", + "xterm": "^3.1.0" + }, + "devDependencies": { + "css-loader": "^0.28.8", + "extract-text-webpack-plugin": "^3.0.2", + "file-loader": "^1.1.11", + "gulp": "^3.9.1", + "gulp-inline-source": "^3.1.0", + "node-sass": "^4.7.2", + "sass-loader": "^6.0.6", + "style-loader": "^0.19.1", + "webpack-stream": "^4.0.2" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/frontend/src/html/index.html b/frontend/src/html/index.html new file mode 100644 index 0000000..265d5e6 --- /dev/null +++ b/frontend/src/html/index.html @@ -0,0 +1,40 @@ + + + + + Web Termianl + + + + + +
+ +
+
+

Console Nodes

+ + + + + + +
NODEHOSTSTATE
+
+
+

 

+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/frontend/src/img/console.png b/frontend/src/img/console.png new file mode 100644 index 0000000..a0294c5 Binary files /dev/null and b/frontend/src/img/console.png differ diff --git a/frontend/src/js/gocons.js b/frontend/src/js/gocons.js new file mode 100644 index 0000000..3e35eb1 --- /dev/null +++ b/frontend/src/js/gocons.js @@ -0,0 +1,237 @@ +const STATUS_CONNECTED = 2; +const Terminal = require('xterm').Terminal; +Terminal.applyAddon(require('xterm/lib/addons/fit')); +const host = window.location.host; +const utils = require("./utils.js"); + +class TLVBuf { + constructor() { + this.buf = new Buffer(""); + this.n = 0; + } +} + +class ConsoleSession { + constructor(node, termBox, windowDiv, usersDiv) { + if (!$.trim(node) || !termBox) { + throw new Error("Parameter node or termBox could not be null"); + } + this.node = $.trim(node); + this.termBox = termBox; + this.windowDiv = windowDiv; + this.usersDiv = usersDiv; + this.url = (window.location.protocol === "https:" ? 'wss://' : 'ws://') + window.location.host + window.location.pathname + "session"; + this.ws = new WebSocket(this.url, ['tty']); + this.state = "unconnected"; + this.term = this.openTerm(); + this.tlv = new TLVBuf(); + if (this.windowDiv) { + $("#" + windowDiv).html("Console Window For " + node); + } + this.ws.onopen = function(event) { + if (this.ws.readyState === WebSocket.OPEN) { + let msg = JSON.stringify({ + name: this.node, + command: "start_console" + }); + this.ws.send(utils.int32toBytes(msg.length) + msg); + this.getUser(); + this.timer = setInterval(this.getUser, 5000, this); + } + }.bind(this); + + this.ws.onclose = function(event) { + console.log('Websocket connection closed with code: ' + event.code); + this.disable(); + }.bind(this); + + this.ws.onmessage = (event) => { + if (!event.data) { + return; + } + if (this.ws.readyState == 3) { + this.disable(); + return; + } + // a work around to convert the data info base64 format to avoid of utf-8 error from websocket + let data = Buffer.from(event.data, 'base64'); + if (this.state == "unconnected") { + this.state = utils.bytesToInt32(data); + if (this.state != STATUS_CONNECTED) { + console.log("Failed to start console, status=" + this.state) + this.close(); + } + } else { + let msg = this.getMessage(data); + if (msg) { + this.term.write(msg); + } + } + }; + this.term.on('data', (data) => { + if (this.state != STATUS_CONNECTED) { + return; + } + if (this.ws.readyState == 3) { + this.disable(); + return; + } + if (data) { + this.ws.send(utils.int32toBytes(data.length) + data); + } + }); + } + openTerm() { + let terminalContainer = document.getElementById(this.termBox); + let term = new Terminal({ + cursorBlink: true + }); + term.open(terminalContainer); + term.fit(); + window.addEventListener('resize', function() { + clearTimeout(window.resizedFinished); + window.resizedFinished = setTimeout(function() { + term.fit(); + }, 250); + }); + return term; + } + getUser(s) { + if (!s) { + return; + } + if (s.usersDiv) { + new Users(s.node, s.usersDiv); + } + } + getMessage(data) { + let msg = ""; + this.tlv.buf = Buffer.concat([this.tlv.buf, data]); + while (1) { + if (this.tlv.n == 0) { + if (this.tlv.buf.length < 4) { + break; + } + this.tlv.n = utils.bytesToInt32(this.tlv.buf); + this.tlv.buf = this.tlv.buf.slice(4, this.tlv.buf.length); + } + if (this.tlv.buf.length < this.tlv.n) { + break; + } + msg += this.tlv.buf.slice(0, this.tlv.n); + this.tlv.buf = this.tlv.buf.slice(this.tlv.n, this.tlv.buf.length); + this.tlv.n = 0; + } + return msg; + } + + disable() { + this.ws.close(); + this.term.write("\r\nSession has been closed.\r\n"); + this.term.setOption('disableStdin', true); + if (this.windowDiv) { + let node = $.urlParam("node"); + if (this.node != node) { + return; + } + $("#" + this.windowDiv).html("Console Window For " + this.node + " (closed)"); + } + } + + close() { + if (this.timer) { + clearInterval(this.timer); + } + this.ws.close(); + this.term.destroy(); + if (this.windowDiv) { + $("#" + this.windowDiv).html(" "); + } + } +} + +class Users { + constructor(node, usersDiv) { + if (!$.trim(node) || !usersDiv) { + throw new Error("Parameter node or usersDiv could not be null"); + } + this.header = $("#" + usersDiv).html(); + this.node = node; + this.usersDiv = usersDiv + this.initUsers(); + } + initUsers() { + $.ajax({ + url: "command/user/" + this.node, + type: "GET", + dataType: "json" + }).then(function(data) { + this.users = data["users"]; + this.renderUsers(); + }.bind(this), function() { + console.log("Faled to get response form /command/user/.") + }) + } + renderUsers() { + let text = ""; + this.users.sort(); + for (let i in this.users) { + let line = '
  • ' + this.users[i] + '
  • '; + text += line; + } + $("#" + this.usersDiv).html(text); + } +} + +class Nodes { + constructor(nodeDiv) { + if (!nodeDiv) { + throw new Error("Parameter nodeDiv could not be null.") + } + this.header = 'NODEHOSTSTATE'; + this.nodeDiv = nodeDiv; + this.initNodes(); + } + initNodes() { + $.ajax({ + url: "nodes", + type: "GET", + dataType: "json" + }).then(function(data) { + console.log(data); + this.nodes = data["nodes"]; + this.sortNodes(); + this.renderNodes(); + + }.bind(this), function() { + console.log("Faled to get response form /nodes.") + }) + } + + sortNodes() { + this.nodes.sort(function(a, b) { + if (a["name"] > b["name"]) { + return 1; + } else if (a["name"] == b["name"]) { + return 0; + } + return -1; + }); + } + + renderNodes() { + let text = this.header; + for (let i in this.nodes) { + let node = this.nodes[i]; + let line = '' + node["name"] + '' + + node["host"] + '' + + node["state"] + ''; + text += line; + } + $("#" + this.nodeDiv).html(text); + } +} + +exports.ConsoleSession = ConsoleSession; +exports.Users = Users; +exports.Nodes = Nodes; \ No newline at end of file diff --git a/frontend/src/js/index.js b/frontend/src/js/index.js new file mode 100644 index 0000000..1ca22bd --- /dev/null +++ b/frontend/src/js/index.js @@ -0,0 +1,44 @@ +import img from '../img/console.png'; +const utils = require("./utils.js"); +const gocons = require("./gocons.js"); +let session; + +function loadNodes() { + try { + new gocons.Nodes("nodes"); + } catch (e) { + console.log("Failed to load nodes data, error: " + e.message); + } + +} + +function hashChange() { + if (session) { + session.close(); + session = null; + } + loadNodes(); + let node = $.urlParam("node"); + if ($.trim(node)) { + try { + session = new gocons.ConsoleSession(node, "term-box", "console_window", "users"); + } catch (e) { + console.log("Could not create session object, error: " + e.message); + } + } +} + +$(function() { + loadNodes(); + setInterval(loadNodes, 5000); + let node = $.urlParam("node"); + $("#middle").height($("#middle").width()); + window.onhashchange = hashChange; + if ($.trim(node)) { + try { + session = new gocons.ConsoleSession(node, "term-box", "console_window", "users"); + } catch (e) { + console.log("Could not create session object, error: " + e.message); + } + } +}); \ No newline at end of file diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js new file mode 100644 index 0000000..c1f2af6 --- /dev/null +++ b/frontend/src/js/utils.js @@ -0,0 +1,22 @@ +$.urlParam = function(name) { + var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href); + if (results == null) { + return null; + } else { + return decodeURI(results[1]) || 0; + } +} + +let int32toBytes = function(num) { + let buf = new Buffer(4); + buf.writeUInt32BE(num, 0); + return buf; +}; + +let bytesToInt32 = function(numString) { + let buf = Buffer.from(numString); + return buf.readInt32BE(0); +}; + +exports.int32toBytes = int32toBytes; +exports.bytesToInt32 = bytesToInt32; \ No newline at end of file diff --git a/frontend/src/sass/index.scss b/frontend/src/sass/index.scss new file mode 100644 index 0000000..7bda828 --- /dev/null +++ b/frontend/src/sass/index.scss @@ -0,0 +1,94 @@ +@import "~xterm/src/xterm.css"; + +html, body { + height: 100%; + min-height: 100%; + margin: 0; + overflow: hidden; + color: #373a3c; +} + +#wrap { + background: #fff none repeat scroll 0 0; +} +#banner { + width: 100%; + height: 76px; + box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.1); + border-bottom: 1px solid #919191; +} + +.container { + margin-left: auto; + margin-right: auto; + box-sizing: border-box; + background: #fff none repeat scroll 0 0; + border: 1px solid #aaa; + border-radius: 3px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + table-layout: fixed; + display: table; + width: 90%; + height: 1180px; +} + +.container h3 { + border-bottom: 1px solid #919191; + color: #014c8c; + display: block; +} + +#banner h1 { + text-align: center; + color: #014c8c; +} + +#left { + float: left; + padding: 15px 10px 15px 20px; + width: 20%; + display:block; + margin: 0 auto; + max-width: 1180px; + border-right: 1px solid #919191; + height: 100%; +} + +#left table { + text-align: center; + padding: 2px; +} + +#right ul { + text-align: left; + padding: 3px 2px 2px 3px; + list-style: none; +} + +#right ul li { + padding: 1px; +} + +#middle { + padding: 15px 10px 15px 20px; + width: 60%; + float: left; + display: block; + margin: 0 auto; + height: 100%; +} + +#term-box { + background: #3333338a; + height: 60%; +} + +#right { + padding: 15px 20px 15px 20px; + width: 10%; + float: left; + margin: 0 auto; + display:block; + border-left: 1px solid #919191; + height: 100%; +} \ No newline at end of file diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js new file mode 100644 index 0000000..7056b0e --- /dev/null +++ b/frontend/webpack.config.js @@ -0,0 +1,41 @@ +const ExtractTextPlugin = require("extract-text-webpack-plugin"); +const webpack = require('webpack'); + +module.exports = { + output: { + path: __dirname + '/build/dist/js', + filename: 'index.js' + }, + module: { + rules: [{ + test: /\.scss$/, + use: ExtractTextPlugin.extract({ + use: [{ + loader: "css-loader" + }, { + loader: "sass-loader" + }], + fallback: "style-loader" + }) + }, + { + test: /\.(png|jpe?g|gif)(\?\S*)?$/, + use: [{ + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: '/' + } + }] + } + ] + }, + plugins: [ + new ExtractTextPlugin({ + filename: 'index.css', + }), + new webpack.ProvidePlugin({ + $: 'jquery' + }) + ], +} \ No newline at end of file diff --git a/goconserver.go b/goconserver.go index 7ccf5cd..b95ec40 100644 --- a/goconserver.go +++ b/goconserver.go @@ -78,6 +78,9 @@ func main() { api.Router = mux.NewRouter().StrictSlash(true) api.NewNodeApi(api.Router) api.NewCommandApi(api.Router) + if serverConfig.API.DistDir != "" { + api.RegisterBackendHandler(api.Router) + } httpServer := &http.Server{ ReadTimeout: time.Duration(serverConfig.API.HttpTimeout) * time.Second, WriteTimeout: time.Duration(serverConfig.API.HttpTimeout) * time.Second, diff --git a/goconserver2.gif b/goconserver2.gif new file mode 100644 index 0000000..8741aa7 Binary files /dev/null and b/goconserver2.gif differ