diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..386f322
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,39 @@
+{
+ "comments": false,
+ "env": {
+ "test": {
+ "presets": [
+ ["env", {
+ "targets": { "node": 7 }
+ }],
+ "stage-0"
+ ],
+ "plugins": ["istanbul"]
+ },
+ "main": {
+ "presets": [
+ ["env", {
+ "targets": { "node": 7 }
+ }],
+ "stage-0"
+ ]
+ },
+ "renderer": {
+ "presets": [
+ ["env", {
+ "modules": false
+ }],
+ "stage-0"
+ ]
+ },
+ "web": {
+ "presets": [
+ ["env", {
+ "modules": false
+ }],
+ "stage-0"
+ ]
+ }
+ },
+ "plugins": ["transform-runtime"]
+}
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..2461cba
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,45 @@
+kind: pipeline
+name: release
+trigger:
+ event: push
+ branch: release
+steps:
+ - name: build-dist
+ image: node:10.8
+ commands:
+ - npm install
+ - npm run build:dist
+
+ - name: build-target-win
+ image: proalexandr/node-wine
+ environment:
+ GH_TOKEN:
+ from_secret: gh_token
+ commands:
+ - npm run build:target -- --win -p always
+ depends_on:
+ - build-dist
+
+ - name: build-target-linux
+ image: node:10.8
+ environment:
+ GH_TOKEN:
+ from_secret: gh_token
+ commands:
+ - npm run build:target -- --linux -p always
+ depends_on:
+ - build-dist
+
+# - name: upload
+# image: proalexandr/node-minio
+# environment:
+# MC_HOST_pixelpoint:
+# from_secret: mc_host
+# commands:
+# - npm run upload
+# depends_on:
+# - build-target-win
+# - build-target-linux
+# when:
+# event: push
+# branch: [master]
diff --git a/.electron-vue/build.js b/.electron-vue/build.js
new file mode 100644
index 0000000..059fae5
--- /dev/null
+++ b/.electron-vue/build.js
@@ -0,0 +1,157 @@
+'use strict'
+
+process.env.NODE_ENV = 'production'
+
+const fs = require('fs').promises
+const dateFormat = require('dateformat')
+const path = require('path')
+const mkdirp = require('mkdirp')
+const { say } = require('cfonts')
+const chalk = require('chalk')
+const del = require('del')
+const { spawn } = require('child_process')
+const webpack = require('webpack')
+const Multispinner = require('multispinner')
+const gm = require('gm').subClass({ imageMagick: true })
+
+const mainConfig = require('./webpack.main.config')
+const rendererConfig = require('./webpack.renderer.config')
+const webConfig = require('./webpack.web.config')
+const packageJson = require('../package')
+
+const doneLog = chalk.bgGreen.white(' DONE ') + ' '
+const errorLog = chalk.bgRed.white(' ERROR ') + ' '
+const okayLog = chalk.bgBlue.white(' OKAY ') + ' '
+const isCI = process.env.CI || false
+
+if (process.env.BUILD_TARGET === 'clean') clean()
+else if (process.env.BUILD_TARGET === 'web') web()
+else build()
+
+function clean () {
+ del.sync(['build/*', '!build/icons', '!build/icons/icon.*'])
+ console.log(`\n${doneLog}\n`)
+ process.exit()
+}
+
+async function build () {
+ await setBuildVersionAndNumber()
+
+ del.sync(['dist/electron/*', '!.gitkeep'])
+
+ const tasks = ['main', 'renderer', 'logo']
+ const m = new Multispinner(tasks, {
+ preText: 'building',
+ postText: 'process'
+ })
+
+ let results = ''
+
+ m.on('success', () => {
+ process.stdout.write('\x1B[2J\x1B[0f')
+ console.log(`\n\n${results}`)
+ console.log(`${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n`)
+ process.exit()
+ })
+
+ pack(mainConfig).then(result => {
+ results += result + '\n\n'
+ m.success('main')
+ }).catch(err => {
+ m.error('main')
+ console.log(`\n ${errorLog}failed to build main process`)
+ console.error(`\n${err}\n`)
+ process.exit(1)
+ })
+
+ pack(rendererConfig).then(result => {
+ results += result + '\n\n'
+ m.success('renderer')
+ }).catch(err => {
+ m.error('renderer')
+ console.log(`\n ${errorLog}failed to build renderer process`)
+ console.error(`\n${err}\n`)
+ process.exit(1)
+ })
+
+ prepareLogo().then(() => {
+ m.success('logo')
+ }).catch(err => {
+ m.error('renderer')
+ console.error(err)
+ process.exit(1)
+ })
+}
+
+async function setBuildVersionAndNumber() {
+ await mkdirp(path.resolve(__dirname, '../build'))
+ return Promise.all([
+ fs.writeFile(path.resolve(__dirname, '../build/.number'), dateFormat(new Date(), "yyyyddmm-HHMMss", true)),
+ fs.writeFile(path.resolve(__dirname, '../build/.version'), packageJson.version),
+ ])
+}
+
+function pack (config) {
+ return new Promise((resolve, reject) => {
+ config.mode = 'production'
+ webpack(config, (err, stats) => {
+ if (err) reject(err.stack || err)
+ else if (stats.hasErrors()) {
+ let err = ''
+
+ stats.toString({
+ chunks: false,
+ colors: true
+ })
+ .split(/\r?\n/)
+ .forEach(line => {
+ err += ` ${line}\n`
+ })
+
+ reject(err)
+ } else {
+ resolve(stats.toString({
+ chunks: false,
+ colors: true
+ }))
+ }
+ })
+ })
+}
+
+function web () {
+ del.sync(['dist/web/*', '!.gitkeep'])
+ webConfig.mode = 'production'
+ webpack(webConfig, (err, stats) => {
+ if (err || stats.hasErrors()) console.log(err)
+
+ console.log(stats.toString({
+ chunks: false,
+ colors: true
+ }))
+
+ process.exit()
+ })
+}
+
+async function prepareLogo() {
+ const createIconsDir = new Promise((resolve, reject) => {
+ mkdirp(path.resolve(__dirname, '../build/icons'), (err) => {
+ if (!err) resolve()
+ else reject(err)
+ })
+ })
+
+ const x256 = new Promise((resolve, reject) => {
+ gm(path.resolve(__dirname, '../src/renderer/assets/logo.svg'))
+ .background('none')
+ .rotate('none', 45)
+ .resize(512, 512)
+ .write(path.resolve(__dirname, '../build/icons/256x256.png'), function (err) {
+ if (err) reject(err)
+ else resolve()
+ });
+ })
+
+ return createIconsDir.then(() => Promise.all([x256]) )
+}
diff --git a/.electron-vue/dev-client.js b/.electron-vue/dev-client.js
new file mode 100644
index 0000000..2913ea4
--- /dev/null
+++ b/.electron-vue/dev-client.js
@@ -0,0 +1,40 @@
+const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true')
+
+hotClient.subscribe(event => {
+ /**
+ * Reload browser when HTMLWebpackPlugin emits a new index.html
+ *
+ * Currently disabled until jantimon/html-webpack-plugin#680 is resolved.
+ * https://github.com/SimulatedGREG/electron-vue/issues/437
+ * https://github.com/jantimon/html-webpack-plugin/issues/680
+ */
+ // if (event.action === 'reload') {
+ // window.location.reload()
+ // }
+
+ /**
+ * Notify `mainWindow` when `main` process is compiling,
+ * giving notice for an expected reload of the `electron` process
+ */
+ if (event.action === 'compiling') {
+ document.body.innerHTML += `
+
+
+
+ Compiling Main Process...
+
+ `
+ }
+})
diff --git a/.electron-vue/dev-runner.js b/.electron-vue/dev-runner.js
new file mode 100644
index 0000000..ecc9180
--- /dev/null
+++ b/.electron-vue/dev-runner.js
@@ -0,0 +1,170 @@
+'use strict'
+
+const chalk = require('chalk')
+const electron = require('electron')
+const path = require('path')
+const { say } = require('cfonts')
+const { spawn } = require('child_process')
+const webpack = require('webpack')
+const WebpackDevServer = require('webpack-dev-server')
+const webpackHotMiddleware = require('webpack-hot-middleware')
+
+const mainConfig = require('./webpack.main.config')
+const rendererConfig = require('./webpack.renderer.config')
+
+let electronProcess = null
+let manualRestart = false
+let hotMiddleware
+
+function logStats (proc, data) {
+ let log = ''
+
+ log += chalk.yellow.bold(`┏ ${proc} Process ${new Array((19 - proc.length) + 1).join('-')}`)
+ log += '\n\n'
+
+ if (typeof data === 'object') {
+ data.toString({
+ colors: true,
+ chunks: false
+ }).split(/\r?\n/).forEach(line => {
+ log += ' ' + line + '\n'
+ })
+ } else {
+ log += ` ${data}\n`
+ }
+
+ log += '\n' + chalk.yellow.bold(`┗ ${new Array(28 + 1).join('-')}`) + '\n'
+
+ console.log(log)
+}
+
+function startRenderer () {
+ return new Promise((resolve, reject) => {
+ rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer)
+ rendererConfig.mode = 'development'
+ const compiler = webpack(rendererConfig)
+ hotMiddleware = webpackHotMiddleware(compiler, {
+ log: false,
+ heartbeat: 2500
+ })
+
+ compiler.hooks.compilation.tap('compilation', compilation => {
+ compilation.hooks.htmlWebpackPluginAfterEmit.tapAsync('html-webpack-plugin-after-emit', (data, cb) => {
+ hotMiddleware.publish({ action: 'reload' })
+ cb()
+ })
+ })
+
+ compiler.hooks.done.tap('done', stats => {
+ logStats('Renderer', stats)
+ })
+
+ const server = new WebpackDevServer(
+ compiler,
+ {
+ contentBase: path.join(__dirname, '../'),
+ quiet: true,
+ before (app, ctx) {
+ app.use(hotMiddleware)
+ ctx.middleware.waitUntilValid(() => {
+ resolve()
+ })
+ }
+ }
+ )
+
+ server.listen(9080)
+ })
+}
+
+function startMain () {
+ return new Promise((resolve, reject) => {
+ mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main)
+ mainConfig.mode = 'development'
+ const compiler = webpack(mainConfig)
+
+ compiler.hooks.watchRun.tapAsync('watch-run', (compilation, done) => {
+ logStats('Main', chalk.white.bold('compiling...'))
+ hotMiddleware.publish({ action: 'compiling' })
+ done()
+ })
+
+ compiler.watch({}, (err, stats) => {
+ if (err) {
+ console.log(err)
+ return
+ }
+
+ logStats('Main', stats)
+
+ if (electronProcess && electronProcess.kill) {
+ manualRestart = true
+ process.kill(electronProcess.pid)
+ electronProcess = null
+ startElectron()
+
+ setTimeout(() => {
+ manualRestart = false
+ }, 5000)
+ }
+
+ resolve()
+ })
+ })
+}
+
+function startElectron () {
+ var args = [
+ '--inspect=5858',
+ path.join(__dirname, '../dist/electron/main.js')
+ ]
+
+ // detect yarn or npm and process commandline args accordingly
+ if (process.env.npm_execpath.endsWith('yarn.js')) {
+ args = args.concat(process.argv.slice(3))
+ } else if (process.env.npm_execpath.endsWith('npm-cli.js')) {
+ args = args.concat(process.argv.slice(2))
+ }
+
+ electronProcess = spawn(electron, args)
+
+ electronProcess.stdout.on('data', data => {
+ electronLog(data, 'blue')
+ })
+ electronProcess.stderr.on('data', data => {
+ electronLog(data, 'red')
+ })
+
+ electronProcess.on('close', () => {
+ if (!manualRestart) process.exit()
+ })
+}
+
+function electronLog (data, color) {
+ let log = ''
+ data = data.toString().split(/\r?\n/)
+ data.forEach(line => {
+ log += ` ${line}\n`
+ })
+ if (/[0-9A-z]+/.test(log)) {
+ console.log(
+ chalk[color].bold('┏ Electron -------------------') +
+ '\n\n' +
+ log +
+ chalk[color].bold('┗ ----------------------------') +
+ '\n'
+ )
+ }
+}
+
+function init () {
+ Promise.all([startRenderer(), startMain()])
+ .then(() => {
+ startElectron()
+ })
+ .catch(err => {
+ console.error(err)
+ })
+}
+
+init()
diff --git a/.electron-vue/webpack.main.config.js b/.electron-vue/webpack.main.config.js
new file mode 100644
index 0000000..046eabf
--- /dev/null
+++ b/.electron-vue/webpack.main.config.js
@@ -0,0 +1,83 @@
+'use strict'
+
+process.env.BABEL_ENV = 'main'
+
+const path = require('path')
+const { dependencies } = require('../package.json')
+const webpack = require('webpack')
+
+const BabiliWebpackPlugin = require('babili-webpack-plugin')
+
+let mainConfig = {
+ entry: {
+ main: path.join(__dirname, '../src/main/index.js')
+ },
+ externals: [
+ ...Object.keys(dependencies || {})
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.(js)$/,
+ // enforce: 'pre',
+ // exclude: /node_modules/,
+ // use: {
+ // loader: 'eslint-loader',
+ // options: {
+ // formatter: require('eslint-friendly-formatter')
+ // }
+ // }
+ // },
+ {
+ test: /\.js$/,
+ use: 'babel-loader',
+ exclude: /node_modules/
+ },
+ {
+ test: /\.node$/,
+ use: 'node-loader'
+ }
+ ]
+ },
+ node: {
+ __dirname: process.env.NODE_ENV !== 'production',
+ __filename: process.env.NODE_ENV !== 'production'
+ },
+ output: {
+ filename: '[name].js',
+ libraryTarget: 'commonjs2',
+ path: path.join(__dirname, '../dist/electron')
+ },
+ plugins: [
+ new webpack.NoEmitOnErrorsPlugin()
+ ],
+ resolve: {
+ extensions: ['.js', '.json', '.node']
+ },
+ target: 'electron-main'
+}
+
+/**
+ * Adjust mainConfig for development settings
+ */
+if (process.env.NODE_ENV !== 'production') {
+ mainConfig.plugins.push(
+ new webpack.DefinePlugin({
+ '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`
+ })
+ )
+}
+
+/**
+ * Adjust mainConfig for production settings
+ */
+if (process.env.NODE_ENV === 'production') {
+ mainConfig.plugins.push(
+ new BabiliWebpackPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"production"'
+ })
+ )
+}
+
+module.exports = mainConfig
diff --git a/.electron-vue/webpack.renderer.config.js b/.electron-vue/webpack.renderer.config.js
new file mode 100644
index 0000000..40e3548
--- /dev/null
+++ b/.electron-vue/webpack.renderer.config.js
@@ -0,0 +1,191 @@
+'use strict'
+
+process.env.BABEL_ENV = 'renderer'
+
+const path = require('path')
+const { dependencies } = require('../package.json')
+const webpack = require('webpack')
+
+const BabiliWebpackPlugin = require('babili-webpack-plugin')
+const CopyWebpackPlugin = require('copy-webpack-plugin')
+const MiniCssExtractPlugin = require('mini-css-extract-plugin')
+const HtmlWebpackPlugin = require('html-webpack-plugin')
+const { VueLoaderPlugin } = require('vue-loader')
+
+/**
+ * List of node_modules to include in webpack bundle
+ *
+ * Required for specific packages like Vue UI libraries
+ * that provide pure *.vue files that need compiling
+ * https://simulatedgreg.gitbooks.io/electron-vue/content/en/webpack-configurations.html#white-listing-externals
+ */
+let whiteListedModules = ['vue']
+
+let rendererConfig = {
+ devtool: 'cheap-module-eval-source-map',
+ entry: {
+ renderer: path.join(__dirname, '../src/renderer/main.js')
+ },
+ externals: [
+ ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d))
+ ],
+ module: {
+ rules: [
+ // {
+ // test: /\.(js|vue)$/,
+ // enforce: 'pre',
+ // exclude: /node_modules/,
+ // use: {
+ // loader: 'eslint-loader',
+ // options: {
+ // formatter: require('eslint-friendly-formatter')
+ // }
+ // }
+ // },
+ {
+ test: /\.scss$/,
+ use: ['vue-style-loader', 'css-loader', 'sass-loader']
+ },
+ {
+ test: /\.sass$/,
+ use: ['vue-style-loader', 'css-loader', 'sass-loader?indentedSyntax']
+ },
+ {
+ test: /\.less$/,
+ use: ['vue-style-loader', 'css-loader', 'less-loader']
+ },
+ {
+ test: /\.css$/,
+ use: ['vue-style-loader', 'css-loader']
+ },
+ {
+ test: /\.html$/,
+ use: 'vue-html-loader'
+ },
+ {
+ test: /\.js$/,
+ use: 'babel-loader',
+ exclude: /node_modules/
+ },
+ {
+ test: /\.node$/,
+ use: 'node-loader'
+ },
+ {
+ test: /\.vue$/,
+ use: {
+ loader: 'vue-loader',
+ options: {
+ extractCSS: process.env.NODE_ENV === 'production',
+ loaders: {
+ sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
+ scss: 'vue-style-loader!css-loader!sass-loader',
+ less: 'vue-style-loader!css-loader!less-loader'
+ }
+ }
+ }
+ },
+ {
+ test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
+ use: {
+ loader: 'url-loader',
+ query: {
+ limit: 10000,
+ name: 'imgs/[name]--[folder].[ext]'
+ }
+ }
+ },
+ {
+ test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
+ loader: 'url-loader',
+ options: {
+ limit: 10000,
+ name: 'media/[name]--[folder].[ext]'
+ }
+ },
+ {
+ test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
+ use: {
+ loader: 'url-loader',
+ query: {
+ limit: 10000,
+ name: 'fonts/[name]--[folder].[ext]'
+ }
+ }
+ }
+ ]
+ },
+ node: {
+ __dirname: process.env.NODE_ENV !== 'production',
+ __filename: process.env.NODE_ENV !== 'production'
+ },
+ plugins: [
+ new VueLoaderPlugin(),
+ new MiniCssExtractPlugin({filename: 'styles.css'}),
+ new HtmlWebpackPlugin({
+ filename: 'index.html',
+ template: path.resolve(__dirname, '../src/index.ejs'),
+ minify: {
+ collapseWhitespace: true,
+ removeAttributeQuotes: true,
+ removeComments: true
+ },
+ nodeModules: process.env.NODE_ENV !== 'production'
+ ? path.resolve(__dirname, '../node_modules')
+ : false
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ new webpack.NoEmitOnErrorsPlugin()
+ ],
+ output: {
+ filename: '[name].js',
+ libraryTarget: 'commonjs2',
+ path: path.join(__dirname, '../dist/electron')
+ },
+ resolve: {
+ alias: {
+ '@': path.join(__dirname, '../src/renderer'),
+ 'vue$': 'vue/dist/vue.esm.js'
+ },
+ extensions: ['.js', '.vue', '.json', '.css', '.node']
+ },
+ target: 'electron-renderer'
+}
+
+/**
+ * Adjust rendererConfig for development settings
+ */
+if (process.env.NODE_ENV !== 'production') {
+ rendererConfig.plugins.push(
+ new webpack.DefinePlugin({
+ '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`
+ })
+ )
+}
+
+/**
+ * Adjust rendererConfig for production settings
+ */
+if (process.env.NODE_ENV === 'production') {
+ rendererConfig.devtool = ''
+
+ rendererConfig.plugins.push(
+ new BabiliWebpackPlugin(),
+ new CopyWebpackPlugin([
+ {
+ from: path.join(__dirname, '../static'),
+ to: path.join(__dirname, '../dist/electron/static'),
+ ignore: ['.*']
+ }
+ ]),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"production"',
+ 'process.env.BUILD': `"${process.env.BUILD}"`
+ }),
+ new webpack.LoaderOptionsPlugin({
+ minimize: true
+ })
+ )
+}
+
+module.exports = rendererConfig
diff --git a/.electron-vue/webpack.web.config.js b/.electron-vue/webpack.web.config.js
new file mode 100644
index 0000000..bde6701
--- /dev/null
+++ b/.electron-vue/webpack.web.config.js
@@ -0,0 +1,151 @@
+'use strict'
+
+process.env.BABEL_ENV = 'web'
+
+const path = require('path')
+const webpack = require('webpack')
+
+const BabiliWebpackPlugin = require('babili-webpack-plugin')
+const CopyWebpackPlugin = require('copy-webpack-plugin')
+const MiniCssExtractPlugin = require('mini-css-extract-plugin')
+const HtmlWebpackPlugin = require('html-webpack-plugin')
+const { VueLoaderPlugin } = require('vue-loader')
+
+let webConfig = {
+ devtool: '#cheap-module-eval-source-map',
+ entry: {
+ web: path.join(__dirname, '../src/renderer/main.js')
+ },
+ module: {
+ rules: [
+ {
+ test: /\.(js|vue)$/,
+ enforce: 'pre',
+ exclude: /node_modules/,
+ use: {
+ loader: 'eslint-loader',
+ options: {
+ formatter: require('eslint-friendly-formatter')
+ }
+ }
+ },
+ {
+ test: /\.scss$/,
+ use: ['vue-style-loader', 'css-loader', 'sass-loader']
+ },
+ {
+ test: /\.sass$/,
+ use: ['vue-style-loader', 'css-loader', 'sass-loader?indentedSyntax']
+ },
+ {
+ test: /\.less$/,
+ use: ['vue-style-loader', 'css-loader', 'less-loader']
+ },
+ {
+ test: /\.css$/,
+ use: ['vue-style-loader', 'css-loader']
+ },
+ {
+ test: /\.html$/,
+ use: 'vue-html-loader'
+ },
+ {
+ test: /\.js$/,
+ use: 'babel-loader',
+ include: [ path.resolve(__dirname, '../src/renderer') ],
+ exclude: /node_modules/
+ },
+ {
+ test: /\.vue$/,
+ use: {
+ loader: 'vue-loader',
+ options: {
+ extractCSS: true,
+ loaders: {
+ sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
+ scss: 'vue-style-loader!css-loader!sass-loader',
+ less: 'vue-style-loader!css-loader!less-loader'
+ }
+ }
+ }
+ },
+ {
+ test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
+ use: {
+ loader: 'url-loader',
+ query: {
+ limit: 10000,
+ name: 'imgs/[name].[ext]'
+ }
+ }
+ },
+ {
+ test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
+ use: {
+ loader: 'url-loader',
+ query: {
+ limit: 10000,
+ name: 'fonts/[name].[ext]'
+ }
+ }
+ }
+ ]
+ },
+ plugins: [
+ new VueLoaderPlugin(),
+ new MiniCssExtractPlugin({filename: 'styles.css'}),
+ new HtmlWebpackPlugin({
+ filename: 'index.html',
+ template: path.resolve(__dirname, '../src/index.ejs'),
+ minify: {
+ collapseWhitespace: true,
+ removeAttributeQuotes: true,
+ removeComments: true
+ },
+ nodeModules: false
+ }),
+ new webpack.DefinePlugin({
+ 'process.env.IS_WEB': 'true'
+ }),
+ new webpack.HotModuleReplacementPlugin(),
+ new webpack.NoEmitOnErrorsPlugin()
+ ],
+ output: {
+ filename: '[name].js',
+ path: path.join(__dirname, '../dist/web')
+ },
+ resolve: {
+ alias: {
+ '@': path.join(__dirname, '../src/renderer'),
+ 'vue$': 'vue/dist/vue.esm.js'
+ },
+ extensions: ['.js', '.vue', '.json', '.css']
+ },
+ target: 'web'
+}
+
+/**
+ * Adjust webConfig for production settings
+ */
+if (process.env.NODE_ENV === 'production') {
+ webConfig.devtool = ''
+
+ webConfig.plugins.push(
+ new BabiliWebpackPlugin(),
+ new CopyWebpackPlugin([
+ {
+ from: path.join(__dirname, '../static'),
+ to: path.join(__dirname, '../dist/web/static'),
+ ignore: ['.*']
+ }
+ ]),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"production"'
+ }),
+ new webpack.LoaderOptionsPlugin({
+ minimize: true
+ })
+ )
+}
+
+module.exports = webConfig
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..d8b0d0a
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,3 @@
+test/unit/coverage/**
+test/unit/*.js
+test/e2e/*.js
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..dd0b8a2
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,36 @@
+module.exports = {
+ root: true,
+ parserOptions: {
+ parser: 'babel-eslint',
+ sourceType: 'module'
+ },
+ env: {
+ browser: true,
+ node: true
+ },
+ extends: [
+ 'standard',
+ 'plugin:vue/recommended'
+ ],
+ globals: {
+ __static: true
+ },
+ plugins: [
+ 'vue'
+ ],
+ 'rules': {
+ // allow paren-less arrow functions
+ 'arrow-parens': 0,
+ // allow async-await
+ 'generator-star-spacing': 0,
+ // allow debugger during development
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
+
+ 'space-before-function-paren': [2, 'never'], // was always
+
+ 'vue/require-default-prop': 2, // was 1
+ 'vue/order-in-components': 2, // was 1
+ 'vue/max-attributes-per-line': 0, // was 1
+ 'vue/singleline-html-element-content-newline': 0 // was 1
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..de4f8a7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+dist/*
+build/*
+coverage
+node_modules/
+npm-debug.log
+npm-debug.log.*
+thumbs.db
+!.gitkeep
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1274b0c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Kube Forwarder
+
+> A tool for managing port forwarding configs for kubernetes clusters
+
+#### Build Setup
+
+``` bash
+# install dependencies
+npm install
+
+# serve with hot reload at localhost:9080
+npm run dev
+
+# build electron application for production
+npm run build
+
+# run unit & end-to-end tests
+npm test
+
+
+# lint all JS/Vue component files in `src/`
+npm run lint
+
+```
+
+### Release guide
+
+1) Update the version in `package.json`.
+2) Push to `release` branch.
+3) Run `npm run release` on a Mac computer to build `.dmg` target.
+4) Go to Releases tab in the repository, test and release the created draft.
+
+Notes:
+1) `.dmg` target is added to release by your mac computer.
+`.AppImage` and `.exe` have to be added to the release by drone CI.
+2) A release tag (for example: `v1.0.3`) will be added automatically
+by Github when you release your draft.
+
+---
+
+This project was generated with [electron-vue](https://github.com/SimulatedGREG/electron-vue)@[8fae476](https://github.com/SimulatedGREG/electron-vue/tree/8fae4763e9d225d3691b627e83b9e09b56f6c935) using [vue-cli](https://github.com/vuejs/vue-cli). Documentation about the original structure can be found [here](https://simulatedgreg.gitbooks.io/electron-vue/content/index.html).
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..41de8d1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,154 @@
+{
+ "name": "kubernetes-port-forwarder",
+ "version": "0.0.1",
+ "versionString": "0.0.1",
+ "author": "Alexandr Promakh ",
+ "description": "A tool for managing port forwarding configs for kubernetes clusters",
+ "license": null,
+ "main": "./dist/electron/main.js",
+ "scripts": {
+ "build": "npm run build:dist && npm run build:target",
+ "build:dist": "node .electron-vue/build.js",
+ "build:target": "BUILD=$(cat build/.number) electron-builder",
+ "build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js",
+ "build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js",
+ "upload": "./upload.sh",
+ "release": "npm run build -- -- -p always",
+ "dev": "BUILD=DEV-VERSION node .electron-vue/dev-runner.js",
+ "e2e": "npm run pack && mocha test/e2e",
+ "lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter src test",
+ "lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix src test",
+ "pack": "npm run pack:main && npm run pack:renderer",
+ "pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js",
+ "pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js",
+ "test": "npm run unit && npm run e2e",
+ "unit": "karma start test/unit/karma.conf.js"
+ },
+ "build": {
+ "productName": "Kube Forwarder",
+ "appId": "com.pixelpoint.kube-forwarder",
+ "artifactName": "kube-forwarder-${version}.${ext}",
+ "directories": {
+ "output": "build"
+ },
+ "files": [
+ "dist/electron/**/*"
+ ],
+ "dmg": {
+ "contents": [
+ {
+ "x": 410,
+ "y": 150,
+ "type": "link",
+ "path": "/Applications"
+ },
+ {
+ "x": 130,
+ "y": 150,
+ "type": "file"
+ }
+ ]
+ },
+ "publish": {
+ "provider": "github",
+ "owner": "pixel-point",
+ "repo": "kube-forwarder"
+ },
+ "mac": {
+ "icon": "build/icons/icon.icns",
+ "target": [
+ "dmg"
+ ]
+ },
+ "win": {
+ "icon": "build/icons/icon.ico"
+ },
+ "linux": {
+ "icon": "build/icons",
+ "target": [
+ "AppImage"
+ ]
+ }
+ },
+ "dependencies": {
+ "@kubernetes/client-node": "^0.8.2",
+ "clone-deep": "^4.0.1",
+ "electron-updater": "^4.0.6",
+ "killable": "^1.0.1",
+ "lodash": "^4.17.11",
+ "promise-fs": "^2.1.0",
+ "vue": "^2.6.10",
+ "vue-click-outside": "^1.0.7",
+ "vue-electron": "^1.0.6",
+ "vue-router": "^3.0.6",
+ "vuelidate": "^0.7.4",
+ "vuex": "^3.1.1",
+ "vuex-electron": "^1.0.3",
+ "ws": "^6.1.2"
+ },
+ "devDependencies": {
+ "ajv": "^6.7.0",
+ "babel-core": "^6.26.3",
+ "babel-eslint": "^8.2.3",
+ "babel-loader": "^7.1.4",
+ "babel-plugin-istanbul": "^4.1.6",
+ "babel-plugin-transform-runtime": "^6.23.0",
+ "babel-preset-env": "^1.7.0",
+ "babel-preset-stage-0": "^6.24.1",
+ "babel-register": "^6.26.0",
+ "babili-webpack-plugin": "^0.1.2",
+ "cfonts": "^2.1.2",
+ "chai": "^4.1.2",
+ "chalk": "^2.4.1",
+ "copy-webpack-plugin": "^4.5.1",
+ "cross-env": "^5.1.6",
+ "css-loader": "^2.1.1",
+ "del": "^3.0.0",
+ "devtron": "^1.4.0",
+ "electron": "^5.0.2",
+ "electron-builder": "^20.41.0",
+ "electron-debug": "^3.0.0",
+ "electron-devtools-installer": "^2.2.4",
+ "eslint": "^5.16.0",
+ "eslint-config-standard": "^12.0.0",
+ "eslint-friendly-formatter": "^4.0.1",
+ "eslint-loader": "^2.1.2",
+ "eslint-plugin-html": "^5.0.5",
+ "eslint-plugin-import": "^2.17.2",
+ "eslint-plugin-node": "^9.1.0",
+ "eslint-plugin-promise": "^4.1.1",
+ "eslint-plugin-standard": "^4.0.0",
+ "eslint-plugin-vue": "^5.2.2",
+ "file-loader": "^1.1.11",
+ "gm": "^1.23.1",
+ "html-webpack-plugin": "^3.2.0",
+ "inject-loader": "^4.0.1",
+ "karma": "^2.0.2",
+ "karma-chai": "^0.1.0",
+ "karma-coverage": "^1.1.2",
+ "karma-electron": "^6.0.0",
+ "karma-mocha": "^1.3.0",
+ "karma-sourcemap-loader": "^0.3.7",
+ "karma-spec-reporter": "^0.0.32",
+ "karma-webpack": "^3.0.0",
+ "mini-css-extract-plugin": "0.4.0",
+ "mocha": "^5.2.0",
+ "multispinner": "^0.2.1",
+ "node-loader": "^0.6.0",
+ "node-sass": "^4.9.2",
+ "require-dir": "^1.0.0",
+ "sass-loader": "^7.0.3",
+ "spectron": "^3.8.0",
+ "style-loader": "^0.21.0",
+ "url-loader": "^1.0.1",
+ "vue-html-loader": "^1.2.4",
+ "vue-loader": "^15.2.4",
+ "vue-style-loader": "^4.1.0",
+ "vue-template-compiler": "^2.6.10",
+ "webpack": "^4.32.2",
+ "webpack-cli": "^3.3.2",
+ "webpack-dev-server": "^3.4.1",
+ "webpack-hot-middleware": "^2.25.0",
+ "webpack-merge": "^4.2.1"
+ }
+}
diff --git a/src/index.ejs b/src/index.ejs
new file mode 100644
index 0000000..8b7ba84
--- /dev/null
+++ b/src/index.ejs
@@ -0,0 +1,24 @@
+
+
+
+
+ kubernetes-port-forwarder
+ <% if (htmlWebpackPlugin.options.nodeModules) { %>
+
+
+ <% } %>
+
+
+
+
+ <% if (!process.browser) { %>
+
+ <% } %>
+
+
+
+
diff --git a/src/main/dev-app-update.yml b/src/main/dev-app-update.yml
new file mode 100644
index 0000000..75bf2a1
--- /dev/null
+++ b/src/main/dev-app-update.yml
@@ -0,0 +1,4 @@
+owner: pixel-point
+repo: kube-forwarder
+provider: github
+updaterCacheDirName: kubernetes-port-forwarder-updater
diff --git a/src/main/index.dev.js b/src/main/index.dev.js
new file mode 100644
index 0000000..3e0d769
--- /dev/null
+++ b/src/main/index.dev.js
@@ -0,0 +1,24 @@
+/**
+ * This file is used specifically and only for development. It installs
+ * `electron-debug` & `vue-devtools`. There shouldn't be any need to
+ * modify this file, but it can be used to extend your development
+ * environment.
+ */
+
+/* eslint-disable */
+
+// Install `electron-debug` with `devtron`
+require('electron-debug')({ showDevTools: true })
+
+// Install `vue-devtools`
+require('electron').app.on('ready', () => {
+ let installExtension = require('electron-devtools-installer')
+ installExtension.default(installExtension.VUEJS_DEVTOOLS)
+ .then(() => {})
+ .catch(err => {
+ console.log('Unable to install `vue-devtools`: \n', err)
+ })
+})
+
+// Require `main` process to boot app
+require('./index')
diff --git a/src/main/index.js b/src/main/index.js
new file mode 100644
index 0000000..27f4ac0
--- /dev/null
+++ b/src/main/index.js
@@ -0,0 +1,82 @@
+'use strict'
+
+import { app, BrowserWindow, Menu, dialog } from 'electron'
+import { autoUpdater } from 'electron-updater'
+import buildMenuTemplate from './menuTemplate'
+import path from 'path'
+
+/**
+ * Set `__static` path to static files in production
+ * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-static-assets.html
+ */
+if (process.env.NODE_ENV !== 'development') {
+ global.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')
+}
+
+let mainWindow
+const winURL = process.env.NODE_ENV === 'development'
+ ? `http://localhost:9080`
+ : `file://${__dirname}/index.html`
+
+function createWindow() {
+ /**
+ * Initial window options
+ */
+ mainWindow = new BrowserWindow({
+ height: 563,
+ useContentSize: true,
+ width: 1000,
+ titleBarStyle: 'hiddenInset',
+ webPreferences: {
+ nodeIntegration: true
+ }
+ })
+
+ if (process.env.NODE_ENV !== 'production') {
+ mainWindow.webContents.openDevTools({ mode: 'bottom' })
+ }
+
+ mainWindow.loadURL(winURL)
+
+ mainWindow.on('closed', () => {
+ mainWindow = null
+ })
+
+ const menuTemplate = buildMenuTemplate(app)
+ const menu = Menu.buildFromTemplate(menuTemplate)
+ Menu.setApplicationMenu(menu)
+}
+
+app.on('ready', () => {
+ createWindow()
+ // console.log('ready')
+ autoUpdater.updateConfigPath = path.join(__dirname, 'dev-app-update.yml')
+ // console.log(autoUpdater.updateConfigPath)
+ autoUpdater.checkForUpdates()
+})
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit()
+ }
+})
+
+app.on('activate', () => {
+ if (mainWindow === null) {
+ createWindow()
+ }
+})
+
+/**
+ * Auto Updater
+ *
+ * Uncomment the following code below and install `electron-updater` to
+ * support auto updating. Code Signing with a valid certificate is required.
+ * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-electron-builder.html#auto-updating
+ */
+
+autoUpdater.on('update-downloaded', () => {
+ // autoUpdater.
+ dialog.showMessageBox({ title: '123', message: 'ud' })
+ // autoUpdater.quitAndInstall()
+})
diff --git a/src/main/menuTemplate.js b/src/main/menuTemplate.js
new file mode 100644
index 0000000..02e8884
--- /dev/null
+++ b/src/main/menuTemplate.js
@@ -0,0 +1,90 @@
+export default function buildMenuTemplate(app) {
+ const template = [
+ {
+ label: 'Edit',
+ submenu: [
+ { role: 'undo' },
+ { role: 'redo' },
+ { type: 'separator' },
+ { role: 'cut' },
+ { role: 'copy' },
+ { role: 'paste' },
+ { role: 'pasteandmatchstyle' },
+ { role: 'delete' },
+ { role: 'selectall' }
+ ]
+ },
+ {
+ label: 'View',
+ submenu: [
+ { role: 'reload' },
+ { role: 'forcereload' },
+ { role: 'toggledevtools' },
+ { type: 'separator' },
+ { role: 'resetzoom' },
+ { role: 'zoomin' },
+ { role: 'zoomout' },
+ { type: 'separator' },
+ { role: 'togglefullscreen' }
+ ]
+ },
+ {
+ role: 'window',
+ submenu: [
+ { role: 'minimize' },
+ { role: 'close' }
+ ]
+ },
+ {
+ role: 'help',
+ submenu: [
+ {
+ label: 'Learn More',
+ click() {
+ require('electron').shell.openExternal('https://electronjs.org')
+ }
+ }
+ ]
+ }
+ ]
+
+ if (process.platform === 'darwin') {
+ template.unshift({
+ label: app.getName(),
+ submenu: [
+ { role: 'about' },
+ { type: 'separator' },
+ { role: 'services' },
+ { type: 'separator' },
+ { role: 'hide' },
+ { role: 'hideothers' },
+ { role: 'unhide' },
+ { type: 'separator' },
+ { role: 'quit' }
+ ]
+ })
+
+ // Edit menu
+ template[1].submenu.push(
+ { type: 'separator' },
+ {
+ label: 'Speech',
+ submenu: [
+ { role: 'startspeaking' },
+ { role: 'stopspeaking' }
+ ]
+ }
+ )
+
+ // Window menu
+ template[3].submenu = [
+ { role: 'close' },
+ { role: 'minimize' },
+ { role: 'zoom' },
+ { type: 'separator' },
+ { role: 'front' }
+ ]
+ }
+
+ return template
+}
diff --git a/src/renderer/App.vue b/src/renderer/App.vue
new file mode 100644
index 0000000..12b6732
--- /dev/null
+++ b/src/renderer/App.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/assets/logo.svg b/src/renderer/assets/logo.svg
new file mode 100644
index 0000000..e805846
--- /dev/null
+++ b/src/renderer/assets/logo.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/renderer/assets/styles/_mixins.scss b/src/renderer/assets/styles/_mixins.scss
new file mode 100644
index 0000000..6ce13e7
--- /dev/null
+++ b/src/renderer/assets/styles/_mixins.scss
@@ -0,0 +1,8 @@
+@import "./_variables";
+
+@mixin hf {
+ &:hover {
+ //&:focus {
+ @content
+ }
+}
diff --git a/src/renderer/assets/styles/_variables.scss b/src/renderer/assets/styles/_variables.scss
new file mode 100644
index 0000000..d877bb7
--- /dev/null
+++ b/src/renderer/assets/styles/_variables.scss
@@ -0,0 +1,28 @@
+$color-text: #142d55;
+$color-text-placeholder: rgba($color-text, 0.75);
+$color-text-secondary: rgba($color-text, 0.5);
+$color-text-tertiary: rgba($color-text, 0.25);
+
+$color-primary: #3273e1;
+$color-secondary: #1ecde1;
+$color-warning: #ff7d00;
+$color-danger: #eb5569;
+$color-success: #19d78c;
+$color-info: #96a5be;
+
+$bg-secondary: rgba($color-text, 0.02);
+$bg-danger: rgba($color-danger, 0.15);
+
+$font-size-small: 13px;
+$font-size-base: 14px;
+$font-size-big: 15px;
+
+$border-radius: 2px;
+$border-color: rgba($color-text, 0.1);
+$border: 1px solid $border-color;
+
+$button-outline-hover-bg-opacity: 0.1;
+
+$toolbar-height: 37px;
+
+$table-border-color: mix($color-text, #fff, 15%);
diff --git a/src/renderer/assets/styles/icon.scss b/src/renderer/assets/styles/icon.scss
new file mode 100644
index 0000000..b43c34e
--- /dev/null
+++ b/src/renderer/assets/styles/icon.scss
@@ -0,0 +1,8 @@
+.icon {
+ display: inline-block;
+
+ .dropdown > &:first-child {
+ // To avoid 3 empty pixels below
+ display: block;
+ }
+}
diff --git a/src/renderer/assets/styles/table.scss b/src/renderer/assets/styles/table.scss
new file mode 100644
index 0000000..31e4cd1
--- /dev/null
+++ b/src/renderer/assets/styles/table.scss
@@ -0,0 +1,21 @@
+@import "variables";
+
+.table {
+ width: 100%;
+ background-color: #fff;
+
+ tr {
+ height: 40px;
+ }
+
+ th {
+ border: 1px solid $table-border-color;
+ background-color: $bg-secondary;
+ font-weight: normal;
+ color: $color-text-secondary;
+ }
+
+ td {
+ border: 1px solid $table-border-color;
+ }
+}
diff --git a/src/renderer/components/ClusterEdit.vue b/src/renderer/components/ClusterEdit.vue
new file mode 100644
index 0000000..1cb7f19
--- /dev/null
+++ b/src/renderer/components/ClusterEdit.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ClusterImport.vue b/src/renderer/components/ClusterImport.vue
new file mode 100644
index 0000000..3cc93d4
--- /dev/null
+++ b/src/renderer/components/ClusterImport.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ClusterNew.vue b/src/renderer/components/ClusterNew.vue
new file mode 100644
index 0000000..e972802
--- /dev/null
+++ b/src/renderer/components/ClusterNew.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Clusters.vue b/src/renderer/components/Clusters.vue
new file mode 100644
index 0000000..7825127
--- /dev/null
+++ b/src/renderer/components/Clusters.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Not found
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Clusters/ClusterItem.vue b/src/renderer/components/Clusters/ClusterItem.vue
new file mode 100644
index 0000000..bd8f047
--- /dev/null
+++ b/src/renderer/components/Clusters/ClusterItem.vue
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
Services list is empty
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Clusters/ServiceItem.vue b/src/renderer/components/Clusters/ServiceItem.vue
new file mode 100644
index 0000000..9587d90
--- /dev/null
+++ b/src/renderer/components/Clusters/ServiceItem.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+ {{ service.alias }}
+
+ {{ service.workloadName }} from {{ service.namespace }}
+
+
+
+ Exposed to
+
+ {{ localPort }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Layout.vue b/src/renderer/components/Layout.vue
new file mode 100644
index 0000000..386e16c
--- /dev/null
+++ b/src/renderer/components/Layout.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Layout/Toolbar.vue b/src/renderer/components/Layout/Toolbar.vue
new file mode 100644
index 0000000..75996ae
--- /dev/null
+++ b/src/renderer/components/Layout/Toolbar.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ServiceClone.vue b/src/renderer/components/ServiceClone.vue
new file mode 100644
index 0000000..df41b4b
--- /dev/null
+++ b/src/renderer/components/ServiceClone.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/src/renderer/components/ServiceEdit.vue b/src/renderer/components/ServiceEdit.vue
new file mode 100644
index 0000000..50cc5e8
--- /dev/null
+++ b/src/renderer/components/ServiceEdit.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/src/renderer/components/ServiceForm.vue b/src/renderer/components/ServiceForm.vue
new file mode 100644
index 0000000..1c19bcf
--- /dev/null
+++ b/src/renderer/components/ServiceForm.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ServiceForm/ForwardsTable.vue b/src/renderer/components/ServiceForm/ForwardsTable.vue
new file mode 100644
index 0000000..6ee0af4
--- /dev/null
+++ b/src/renderer/components/ServiceForm/ForwardsTable.vue
@@ -0,0 +1,187 @@
+
+
+
+
+ Local port |
+
+
+ |
+ Destination port |
+ |
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Action.vue b/src/renderer/components/shared/Action.vue
new file mode 100644
index 0000000..eebf4d4
--- /dev/null
+++ b/src/renderer/components/shared/Action.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Alert.vue b/src/renderer/components/shared/Alert.vue
new file mode 100644
index 0000000..d3d116c
--- /dev/null
+++ b/src/renderer/components/shared/Alert.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Button.vue b/src/renderer/components/shared/Button.vue
new file mode 100644
index 0000000..64acef2
--- /dev/null
+++ b/src/renderer/components/shared/Button.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Dropdown.vue b/src/renderer/components/shared/Dropdown.vue
new file mode 100644
index 0000000..4490507
--- /dev/null
+++ b/src/renderer/components/shared/Dropdown.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Header.vue b/src/renderer/components/shared/Header.vue
new file mode 100644
index 0000000..7c9f17f
--- /dev/null
+++ b/src/renderer/components/shared/Header.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/Loader.vue b/src/renderer/components/shared/Loader.vue
new file mode 100644
index 0000000..424696d
--- /dev/null
+++ b/src/renderer/components/shared/Loader.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/src/renderer/components/shared/Logo.vue b/src/renderer/components/shared/Logo.vue
new file mode 100644
index 0000000..ce30d2b
--- /dev/null
+++ b/src/renderer/components/shared/Logo.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
diff --git a/src/renderer/components/shared/Popup.vue b/src/renderer/components/shared/Popup.vue
new file mode 100644
index 0000000..4897d9c
--- /dev/null
+++ b/src/renderer/components/shared/Popup.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/SearchInput.vue b/src/renderer/components/shared/SearchInput.vue
new file mode 100644
index 0000000..c1164a7
--- /dev/null
+++ b/src/renderer/components/shared/SearchInput.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/cluster/ClusterForm.vue b/src/renderer/components/shared/cluster/ClusterForm.vue
new file mode 100644
index 0000000..c134afe
--- /dev/null
+++ b/src/renderer/components/shared/cluster/ClusterForm.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
{{ message }}
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseCheckbox.vue b/src/renderer/components/shared/form/BaseCheckbox.vue
new file mode 100644
index 0000000..9f4a053
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseCheckbox.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseForm.vue b/src/renderer/components/shared/form/BaseForm.vue
new file mode 100644
index 0000000..c53aba4
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseForm.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseInput.vue b/src/renderer/components/shared/form/BaseInput.vue
new file mode 100644
index 0000000..2c48f91
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseInput.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseRadioButtons.vue b/src/renderer/components/shared/form/BaseRadioButtons.vue
new file mode 100644
index 0000000..be1939f
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseRadioButtons.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseSelect.vue b/src/renderer/components/shared/form/BaseSelect.vue
new file mode 100644
index 0000000..f35afa4
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseSelect.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/BaseTextArea.vue b/src/renderer/components/shared/form/BaseTextArea.vue
new file mode 100644
index 0000000..dbfe909
--- /dev/null
+++ b/src/renderer/components/shared/form/BaseTextArea.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/ControlGroup.vue b/src/renderer/components/shared/form/ControlGroup.vue
new file mode 100644
index 0000000..1e87850
--- /dev/null
+++ b/src/renderer/components/shared/form/ControlGroup.vue
@@ -0,0 +1,67 @@
+
+
+
+
{{ hint }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/form/ValidationErrors.vue b/src/renderer/components/shared/form/ValidationErrors.vue
new file mode 100644
index 0000000..27ce297
--- /dev/null
+++ b/src/renderer/components/shared/form/ValidationErrors.vue
@@ -0,0 +1,24 @@
+
+
+
Field is required.
+
Field must be an integer.
+
Field must be between specific values.
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/icons/IconArrow.vue b/src/renderer/components/shared/icons/IconArrow.vue
new file mode 100644
index 0000000..ef43fb6
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconArrow.vue
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/renderer/components/shared/icons/IconArrowDropdown.vue b/src/renderer/components/shared/icons/IconArrowDropdown.vue
new file mode 100644
index 0000000..9abe898
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconArrowDropdown.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/shared/icons/IconDotes.vue b/src/renderer/components/shared/icons/IconDotes.vue
new file mode 100644
index 0000000..005cb1a
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconDotes.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/renderer/components/shared/icons/IconMagnifier.vue b/src/renderer/components/shared/icons/IconMagnifier.vue
new file mode 100644
index 0000000..5107f42
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconMagnifier.vue
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/renderer/components/shared/icons/IconPause.vue b/src/renderer/components/shared/icons/IconPause.vue
new file mode 100644
index 0000000..b6b51ef
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconPause.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/renderer/components/shared/icons/IconPlay.vue b/src/renderer/components/shared/icons/IconPlay.vue
new file mode 100644
index 0000000..6cd7bcc
--- /dev/null
+++ b/src/renderer/components/shared/icons/IconPlay.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/renderer/lib/constants/connection-states.js b/src/renderer/lib/constants/connection-states.js
new file mode 100644
index 0000000..87d0e5c
--- /dev/null
+++ b/src/renderer/lib/constants/connection-states.js
@@ -0,0 +1,7 @@
+export const CONNECTING = 'connecting'
+export const CONNECTED = 'connected'
+
+export default [
+ CONNECTING,
+ CONNECTED
+]
diff --git a/src/renderer/lib/constants/workload-types.js b/src/renderer/lib/constants/workload-types.js
new file mode 100644
index 0000000..11406a7
--- /dev/null
+++ b/src/renderer/lib/constants/workload-types.js
@@ -0,0 +1,7 @@
+export const POD = 'pod'
+export const DEPLOYMENT = 'deployment'
+
+export default [
+ POD,
+ DEPLOYMENT
+]
diff --git a/src/renderer/lib/export.js b/src/renderer/lib/export.js
new file mode 100644
index 0000000..24090ea
--- /dev/null
+++ b/src/renderer/lib/export.js
@@ -0,0 +1,74 @@
+import { readFile, writeFile } from 'promise-fs'
+import { omit, pick, intersection } from 'lodash'
+import Ajv from 'ajv'
+
+import { serviceSchema } from '../store/modules/Services'
+import { clusterSchema } from '../store/modules/Clusters'
+
+export function saveObjectToJsonFile(object, filename) {
+ return writeFile(filename, JSON.stringify(object))
+}
+
+export async function readObjectFromJsonFile(filename) {
+ const data = await readFile(filename, { encoding: 'utf8' })
+ return JSON.parse(data)
+}
+
+const clusterFields = ['name']
+const serviceFields = ['alias', 'namespace', 'workloadType', 'workloadName', 'forwards']
+
+export function exportCluster(state, clusterId, options = {}) {
+ const { includeConfig = false } = options
+
+ const cFields = [...clusterFields]
+ if (includeConfig) cFields.push('config')
+
+ return {
+ _clusters: [
+ {
+ ...pick(state.Clusters.items[clusterId], cFields),
+ _services: Object.values(state.Services.items)
+ .filter(service => service.clusterId === clusterId)
+ .map(service => pick(service, serviceFields))
+ }
+ ]
+ }
+}
+
+const ajv = new Ajv()
+const validate = ajv.compile({
+ type: 'object',
+ required: ['_clusters'],
+ properties: {
+ _clusters: {
+ type: 'array',
+ minItems: 1,
+ maxItems: 1,
+ items: {
+ ...clusterSchema,
+ required: [...clusterFields, '_services'],
+ properties: {
+ ...clusterSchema.properties,
+ _services: {
+ type: 'array',
+ items: {
+ ...serviceSchema,
+ required: intersection(serviceSchema.required, serviceFields)
+ }
+ }
+ }
+ }
+ }
+ }
+})
+
+export function importCluster(object) {
+ const valid = validate(object)
+ if (!valid) throw new Error(`Data for import is invalid. Raw error message: ${JSON.stringify(validate.errors)}`)
+
+ const clusterObject = object._clusters[0]
+ const cluster = omit(clusterObject, '_services')
+ const services = clusterObject._services
+
+ return [cluster, services]
+}
diff --git a/src/renderer/lib/helpers/k8n-api-error.js b/src/renderer/lib/helpers/k8n-api-error.js
new file mode 100644
index 0000000..186f0e3
--- /dev/null
+++ b/src/renderer/lib/helpers/k8n-api-error.js
@@ -0,0 +1,24 @@
+const postfixes = {
+ notFound: 'not found.',
+ default: 'can\'t be fetched.'
+}
+
+function buildMessage(error, messages, messageKey) {
+ if (messages[messageKey]) return messages[messageKey]
+ if (messages._object) return `${messages._object} ${postfixes[messageKey]}`
+ if (error.body) return error.body.message
+ return postfixes[messageKey]
+}
+
+function getMessageKey(error) {
+ if (error.response && error.response.statusCode === 404) {
+ return 'notFound'
+ }
+
+ return 'default'
+}
+
+export function k8nApiPrettyError(error, messages = {}) {
+ const messageKey = getMessageKey(error)
+ return new Error(buildMessage(error, messages, messageKey))
+}
diff --git a/src/renderer/lib/helpers/net-server-error.js b/src/renderer/lib/helpers/net-server-error.js
new file mode 100644
index 0000000..a3633b8
--- /dev/null
+++ b/src/renderer/lib/helpers/net-server-error.js
@@ -0,0 +1,12 @@
+
+export function netServerPrettyError(error) {
+ if (error.code === 'EADDRINUSE') {
+ return new Error(`Port ${error.port} already in use`)
+ }
+
+ if (error.code === 'EACCES') {
+ return new Error('Application haven\'t enough privileges to use local ports below 1024.')
+ }
+
+ return error
+}
diff --git a/src/renderer/lib/helpers/ui.js b/src/renderer/lib/helpers/ui.js
new file mode 100644
index 0000000..0217a0b
--- /dev/null
+++ b/src/renderer/lib/helpers/ui.js
@@ -0,0 +1,37 @@
+const { dialog, app } = require('electron').remote
+
+export function showMessageBox(message, options = {}) {
+ return new Promise((resolve) => {
+ dialog.showMessageBox({ title: 'Message', message, ...options }, (...args) => resolve(args))
+ })
+}
+
+export async function showConfirmBox(message, options = {}) {
+ const [buttonIndex] = await showMessageBox(message, {
+ title: 'Confirm',
+ buttons: ['OK', 'Cancel'],
+ ...options
+ })
+
+ return buttonIndex === 0
+}
+
+export function showErrorBox(message, title = 'Error') {
+ dialog.showErrorBox(title, message)
+}
+
+export function showSaveDialog(options = {}) {
+ const defaultPath = options.defaultName ? `${app.getPath('documents')}/${options.defaultName}` : undefined
+
+ return new Promise(resolve => {
+ dialog.showSaveDialog({ defaultPath, ...options }, resolve)
+ })
+}
+
+export function showOpenDialog(options = {}) {
+ const defaultPath = options.defaultPath || app.getPath('documents')
+
+ return new Promise(resolve => {
+ dialog.showOpenDialog({ defaultPath, ...options }, resolve)
+ })
+}
diff --git a/src/renderer/lib/k8s-port-forwarding-patch.js b/src/renderer/lib/k8s-port-forwarding-patch.js
new file mode 100644
index 0000000..b47ac3e
--- /dev/null
+++ b/src/renderer/lib/k8s-port-forwarding-patch.js
@@ -0,0 +1,86 @@
+/* eslint-disable */
+import querystring from 'querystring'
+import { WebSocketHandler } from '@kubernetes/client-node/dist/web-socket-handler'
+import WebSocket from 'isomorphic-ws'
+
+WebSocketHandler.restartableHandleStandardInput = async function (createWS, stdin, streamNum = 0) {
+ const tryLimit = 3;
+ let queue = Promise.resolve()
+ let ws = await createWS()
+
+ async function processData(data) {
+ const buff = Buffer.alloc(data.length + 1);
+
+ buff.writeInt8(streamNum, 0);
+ if (data instanceof Buffer) {
+ data.copy(buff, 1);
+ } else {
+ buff.write(data, 1);
+ }
+
+ let i = 0;
+ for (; i < tryLimit; ++i) {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(buff);
+ break;
+ } else {
+ ws = await createWS()
+ }
+ }
+
+ if (i >= tryLimit) {
+ throw new Error("can't send data to ws")
+ }
+ }
+
+ stdin.on('data', (data) => {
+ queue = queue.then(processData(data))
+ })
+ stdin.on('end', () => {
+ ws.close();
+ });
+}
+
+export function patchForward (forward) {
+ forward.portForward = async function (namespace, podName, targetPorts, output, err, input) {
+ if (targetPorts.length === 0) {
+ throw new Error('You must provide at least one port to forward to.')
+ }
+ if (targetPorts.length > 1) {
+ throw(new Error('Only one port is currently supported for port-forward'))
+ }
+ const query = {
+ ports: targetPorts[0]
+ }
+ const queryStr = querystring.stringify(query)
+ const needsToReadPortNumber = []
+ targetPorts.forEach((value, index) => {
+ needsToReadPortNumber[index * 2] = true
+ needsToReadPortNumber[index * 2 + 1] = true
+ })
+ const path = `/api/v1/namespaces/${namespace}/pods/${podName}/portforward?${queryStr}`
+ const createWebSocket = async () => {
+ return await this.handler.connect(path, null, (streamNum, buff) => {
+ if (streamNum >= targetPorts.length * 2) {
+ return !this.disconnectOnErr
+ }
+ // First two bytes of each stream are the port number
+ if (needsToReadPortNumber[streamNum]) {
+ buff = buff.slice(2)
+ needsToReadPortNumber[streamNum] = false
+ }
+ if (streamNum % 2 === 1) {
+ if (err) {
+ err.write(buff)
+ }
+ } else {
+ output.write(buff)
+ }
+ return true
+ })
+ }
+
+ await WebSocketHandler.restartableHandleStandardInput(createWebSocket, input, 0)
+ }
+}
+/* eslint-enable */
diff --git a/src/renderer/main.js b/src/renderer/main.js
new file mode 100644
index 0000000..37fcb79
--- /dev/null
+++ b/src/renderer/main.js
@@ -0,0 +1,20 @@
+import Vue from 'vue'
+
+import App from './App'
+import router from './router'
+import store from './store'
+
+if (!process.env.IS_WEB) Vue.use(require('vue-electron'))
+Vue.config.productionTip = false
+
+const vue = new Vue({
+ components: { App },
+ router,
+ store,
+ template: ''
+}).$mount('#app')
+
+if (process.env.NODE_ENV === 'development') {
+ window.Vue = Vue
+ window.vue = vue
+}
diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js
new file mode 100644
index 0000000..6e283d8
--- /dev/null
+++ b/src/renderer/router/index.js
@@ -0,0 +1,47 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)
+
+export default new Router({
+ routes: [
+ {
+ path: '/',
+ component: require('@/components/Layout').default,
+ children: [
+ {
+ path: '',
+ component: require('@/components/Clusters').default
+ },
+ {
+ path: 'clusters/new',
+ component: require('@/components/ClusterNew').default
+ },
+ {
+ path: 'clusters/import',
+ component: require('@/components/ClusterImport').default
+ },
+ {
+ path: 'clusters/:id/edit',
+ component: require('@/components/ClusterEdit').default
+ },
+ {
+ path: 'clusters/:clusterId/services/new',
+ component: require('@/components/ServiceForm').default
+ },
+ {
+ path: 'clusters/:clusterId/services/:id/edit',
+ component: require('@/components/ServiceEdit').default
+ },
+ {
+ path: 'clusters/:clusterId/services/:id/clone',
+ component: require('@/components/ServiceClone').default
+ }
+ ]
+ },
+ {
+ path: '*',
+ redirect: '/'
+ }
+ ]
+})
diff --git a/src/renderer/store/helpers/mutations.js b/src/renderer/store/helpers/mutations.js
new file mode 100644
index 0000000..418436a
--- /dev/null
+++ b/src/renderer/store/helpers/mutations.js
@@ -0,0 +1,9 @@
+import Vue from 'vue'
+
+export function SET(state, item) {
+ Vue.set(state.items, item.id, item)
+}
+
+export function DELETE(state, id) {
+ Vue.delete(state.items, id)
+}
diff --git a/src/renderer/store/helpers/validations.js b/src/renderer/store/helpers/validations.js
new file mode 100644
index 0000000..8c56361
--- /dev/null
+++ b/src/renderer/store/helpers/validations.js
@@ -0,0 +1,38 @@
+import Ajv from 'ajv'
+
+const ajv = new Ajv()
+
+export function commitIfValid(commit, mutationName, item, validate) {
+ const valid = validate(item)
+
+ if (valid) {
+ commit(mutationName, item)
+ }
+
+ return { success: valid, item, errors: validate.errors }
+}
+
+export function createValidate(schema) {
+ return ajv.compile(schema)
+}
+
+export function createPick(schema) {
+ const keys = Object.keys(schema.properties)
+
+ return function(object) {
+ const result = {}
+
+ for (const key of keys) {
+ result[key] = object[key]
+ }
+
+ return result
+ }
+}
+
+export function createToolset(schema) {
+ return {
+ validate: createValidate(schema),
+ pick: createPick(schema)
+ }
+}
diff --git a/src/renderer/store/index.js b/src/renderer/store/index.js
new file mode 100644
index 0000000..d346ff3
--- /dev/null
+++ b/src/renderer/store/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+import { createPersistedState } from 'vuex-electron'
+
+import modules from './modules'
+
+Vue.use(Vuex)
+
+const persistedModuleNames = []
+const noPersistedModuleNames = []
+
+Object.keys(modules).forEach(moduleName => {
+ const isPersisted = modules[moduleName].persisted !== false;
+ (isPersisted ? persistedModuleNames : noPersistedModuleNames).push(moduleName)
+})
+
+const store = new Vuex.Store({
+ modules,
+ plugins: [
+ createPersistedState({ paths: persistedModuleNames })
+ // createSharedMutations()
+ ],
+ strict: process.env.NODE_ENV !== 'production',
+ mutations: {
+ CLEANUP(state) {
+ for (const moduleName of noPersistedModuleNames) {
+ state[moduleName] = modules[moduleName].state
+ }
+ }
+ }
+})
+
+store.commit('CLEANUP')
+
+export default store
diff --git a/src/renderer/store/modules/Clusters.js b/src/renderer/store/modules/Clusters.js
new file mode 100644
index 0000000..babe18c
--- /dev/null
+++ b/src/renderer/store/modules/Clusters.js
@@ -0,0 +1,56 @@
+import uuidv1 from 'uuid/v1'
+
+import { createToolset, commitIfValid } from '../helpers/validations'
+import { SET, DELETE } from '../helpers/mutations'
+
+const state = {
+ items: {}
+}
+
+export const clusterSchema = {
+ type: 'object',
+ required: ['id', 'name', 'config'],
+ additionalProperties: false,
+ properties: {
+ id: { type: 'string' },
+ name: { type: 'string' },
+ config: { type: 'string' },
+ hidden: { type: 'boolean' }
+ }
+}
+const { validate, pick } = createToolset(clusterSchema)
+
+const mutations = {
+ CREATE: SET,
+ UPDATE: SET,
+ DELETE
+}
+
+function saveItem(commit, mutation, attributes) {
+ const item = pick(attributes)
+ return commitIfValid(commit, mutation, item, validate)
+}
+
+const actions = {
+ createCluster({ commit }, attributes) {
+ return saveItem(commit, 'CREATE', { ...attributes, id: uuidv1() })
+ },
+
+ updateCluster({ state, commit }, attributes) {
+ if (!attributes.id) throw new Error('attributes.id must present')
+ const currentItem = state.items[attributes.id]
+
+ return saveItem(commit, 'UPDATE', { ...currentItem, ...attributes })
+ },
+
+ deleteCluster({ commit }, id) {
+ commit('DELETE', id)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/src/renderer/store/modules/Connections.js b/src/renderer/store/modules/Connections.js
new file mode 100644
index 0000000..e9e73c8
--- /dev/null
+++ b/src/renderer/store/modules/Connections.js
@@ -0,0 +1,226 @@
+import Vue from 'vue'
+import * as k8s from '@kubernetes/client-node'
+import * as net from 'net'
+import killable from 'killable'
+
+import { patchForward } from '../../lib/k8s-port-forwarding-patch'
+import * as workloadTypes from '../../lib/constants/workload-types'
+import { createToolset } from '../helpers/validations'
+import * as connectionStates from '../../lib/constants/connection-states'
+import { k8nApiPrettyError } from '../../lib/helpers/k8n-api-error'
+import { netServerPrettyError } from '../../lib/helpers/net-server-error'
+
+const { validate } = createToolset({
+ type: 'object',
+ required: ['port', 'serviceId', 'state'],
+ properties: {
+ port: { type: 'integer', minimum: 0, maximum: 65535 },
+ serviceId: { type: 'string' },
+ state: { type: 'string', enum: Object.values(connectionStates) }
+ }
+})
+
+const state = {
+ // : { port, serviceId, state }
+ // state - one of 'connected', 'connecting'
+}
+
+const mutations = {
+ SET(state, item) {
+ const valid = validate(item)
+ if (valid) Vue.set(state, item.port, item)
+ else throw new Error(JSON.stringify(validate.errors))
+ },
+ DELETE(state, port) {
+ if (!port) throw new Error('port must present')
+ Vue.set(state, port)
+ }
+}
+
+const servers = {}
+
+function killServer(commit, port) {
+ let called = false
+ const server = servers[port]
+
+ const onClose = () => {
+ if (!called) {
+ console.info(`Port ${port} have freed`)
+ commit('DELETE', port)
+ delete servers[port]
+ called = true
+ }
+ }
+
+ if (server) {
+ server.kill(onClose())
+
+ // if there wasn't connections, server closed immediately without emitting callback
+ // so I have to call callback manually
+ if (!server.listening) onClose()
+ } else {
+ onClose()
+ }
+}
+
+// function getPodName(kservice)
+
+async function startForward(commit, k8sForward, service, forward, podName) {
+ const listenPort = forward.localPort
+ const server = net.createServer(function(socket) {
+ k8sForward.portForward(service.namespace, podName, [forward.remotePort], socket, socket, socket, 3)
+ })
+
+ killable(server)
+ return new Promise((resolve) => {
+ server.on('error', (error) => {
+ if (server.listening) {
+ killServer(commit, listenPort)
+ } else {
+ server.kill()
+ const prettyError = netServerPrettyError(error)
+ console.info(`Error while forwarding Service ${service.name}(${service.id}): ${prettyError.message}`)
+ resolve({ success: false, error: prettyError, service, forward })
+ }
+ })
+
+ server.on('listening', () => {
+ servers[listenPort] = server
+ commit('SET', { port: listenPort, serviceId: service.id, state: connectionStates.CONNECTED })
+ console.info(`Service ${service.name}(${service.id}) is forwarding port ${listenPort} to ${podName}:${forward.remotePort}`)
+ resolve({ success: true, service, forward })
+ })
+
+ server.listen(listenPort, '127.0.0.1')
+ })
+}
+
+function prepareK8sToolsWithCluster(cluster) {
+ const kubeConfig = new k8s.KubeConfig()
+
+ try {
+ kubeConfig.loadFromString(cluster.config)
+ } catch (error) {
+ throw new Error('Cluster config is invalid.')
+ }
+
+ const k8sPortForward = new k8s.PortForward(kubeConfig)
+ patchForward(k8sPortForward)
+
+ return { k8sPortForward, kubeConfig }
+}
+
+function prepareK8sToolsWithService(rootState, service) {
+ const cluster = rootState.Clusters.items[service.clusterId]
+ if (!cluster) return { success: false, message: `Cluster(id=${service.clusterId}) doesn't exist` }
+
+ return prepareK8sToolsWithCluster(cluster)
+}
+
+async function getPodName(kubeConfig, service) {
+ const { workloadType, workloadName } = service
+
+ switch (workloadType) {
+ case workloadTypes.POD:
+ await validatePodName(kubeConfig, service.namespace, workloadName)
+ return workloadName
+ case workloadTypes.DEPLOYMENT:
+ return getPodNameFromDeployment(kubeConfig, service.namespace, workloadName)
+ default:
+ throw new Error(`Unacceptable workloadType=${workloadType}`)
+ }
+}
+
+async function validatePodName(kubeConfig, namespace, podName) {
+ const coreApi = kubeConfig.makeApiClient(k8s.Core_v1Api)
+
+ try {
+ await coreApi.readNamespacedPod(podName, namespace)
+ } catch (error) {
+ throw k8nApiPrettyError(error, { _object: `Pod "${podName}"` })
+ }
+}
+
+async function getPodNameFromDeployment(kubeConfig, namespace, deploymentName) {
+ const coreApi = kubeConfig.makeApiClient(k8s.Core_v1Api)
+ const extensionsApi = kubeConfig.makeApiClient(k8s.Extensions_v1beta1Api)
+
+ let deployment
+ try {
+ deployment = (await extensionsApi.readNamespacedDeployment(deploymentName, namespace)).body
+ } catch (error) {
+ throw k8nApiPrettyError(error, { _object: `Deployment "${deploymentName}"` })
+ }
+
+ const { matchLabels } = deployment.spec.selector
+ const matchLabelKey = Object.keys(matchLabels)[0]
+ const labelSelector = `${matchLabelKey}=${matchLabels[matchLabelKey]}`
+
+ const { body: podsBody } = await coreApi.listNamespacedPod(namespace, null, null, null, null, labelSelector)
+ const podName = podsBody.items.length && podsBody.items[0].metadata.name
+ if (!podName) throw new Error(`There are no pods in '${deploymentName}' deployment.`)
+
+ return podName
+}
+
+function createConnectingStates(commit, service) {
+ for (const forward of service.forwards) {
+ commit('SET', { port: forward.localPort, serviceId: service.id, state: connectionStates.CONNECTING })
+ }
+}
+
+function clearStates(commit, service) {
+ for (const forward of service.forwards) {
+ commit('DELETE', forward.localPort)
+ }
+}
+
+function validateThatRequiredPortsFree(state, service) {
+ for (const forward of service.forwards) {
+ if (state[forward.localPort]) {
+ throw new Error(`Port ${forward.localPort} is busy.`)
+ }
+ }
+}
+
+const actions = {
+ async createConnection({ commit, state, rootState }, service) {
+ try {
+ validateThatRequiredPortsFree(state, service)
+ createConnectingStates(commit, service)
+
+ const { kubeConfig, k8sPortForward } = prepareK8sToolsWithService(rootState, service)
+ const podName = await getPodName(kubeConfig, service)
+ const results = await Promise.all(service.forwards.map(forward =>
+ startForward(commit, k8sPortForward, service, forward, podName)
+ ))
+
+ const success = !results.find(x => !x.success)
+ if (!success) {
+ for (const result of results) {
+ killServer(commit, result.forward.localPort)
+ }
+ }
+
+ return { success, results }
+ } catch (error) {
+ console.error(error)
+ clearStates(commit, service)
+ return { success: false, message: error.message }
+ }
+ },
+
+ deleteConnection({ commit }, service) {
+ for (const forward of service.forwards) {
+ killServer(commit, forward.localPort)
+ }
+ }
+}
+
+export default {
+ persisted: false,
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/src/renderer/store/modules/Services.js b/src/renderer/store/modules/Services.js
new file mode 100644
index 0000000..fdf075d
--- /dev/null
+++ b/src/renderer/store/modules/Services.js
@@ -0,0 +1,70 @@
+import uuidv1 from 'uuid/v1'
+
+import { createToolset, commitIfValid } from '../helpers/validations'
+import workloadTypes from '../../lib/constants/workload-types'
+import { SET, DELETE } from '../helpers/mutations'
+
+const state = {
+ items: {
+ // : -
+ }
+}
+
+export const serviceSchema = {
+ type: 'object',
+ required: ['id', 'clusterId', 'namespace', 'workloadType', 'workloadName', 'forwards'],
+ properties: {
+ id: { type: 'string' },
+ clusterId: { type: 'string' },
+ alias: { type: 'string' },
+ namespace: { type: 'string' },
+ workloadType: { type: 'string', enum: workloadTypes },
+ workloadName: { type: 'string' },
+ forwards: {
+ type: 'array',
+ minItems: 1,
+ items: {
+ type: 'object',
+ required: ['id', 'localPort', 'remotePort'],
+ properties: {
+ id: { type: 'string' },
+ localPort: { type: 'integer', minimum: 0, maximum: 65535 },
+ remotePort: { type: 'integer', minimum: 0, maximum: 65535 }
+ }
+ }
+ }
+ }
+}
+const { validate, pick } = createToolset(serviceSchema)
+
+const mutations = {
+ CREATE: SET,
+ UPDATE: SET,
+ DELETE
+}
+
+function saveItem(commit, mutation, attributes) {
+ const item = pick(attributes)
+ return commitIfValid(commit, mutation, item, validate)
+}
+
+const actions = {
+ createService({ commit }, attributes) {
+ return saveItem(commit, 'CREATE', { ...attributes, id: uuidv1() })
+ },
+
+ updateService({ commit }, attributes) {
+ return saveItem(commit, 'UPDATE', attributes)
+ },
+
+ deleteService({ commit }, id) {
+ commit('DELETE', id)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/src/renderer/store/modules/index.js b/src/renderer/store/modules/index.js
new file mode 100644
index 0000000..428c6be
--- /dev/null
+++ b/src/renderer/store/modules/index.js
@@ -0,0 +1,14 @@
+/**
+ * The file enables `@/store/index.js` to import all vuex modules
+ * in a one-shot manner. There should not be any reason to edit this file.
+ */
+
+const files = require.context('.', false, /\.js$/)
+const modules = {}
+
+files.keys().forEach(key => {
+ if (key === './index.js') return
+ modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default
+})
+
+export default modules
diff --git a/static/.gitkeep b/static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/test/.eslintrc b/test/.eslintrc
new file mode 100644
index 0000000..3f26d66
--- /dev/null
+++ b/test/.eslintrc
@@ -0,0 +1,11 @@
+{
+ "env": {
+ "mocha": true
+ },
+ "globals": {
+ "assert": true,
+ "expect": true,
+ "should": true,
+ "__static": true
+ }
+}
diff --git a/test/e2e/index.js b/test/e2e/index.js
new file mode 100644
index 0000000..af4b0e7
--- /dev/null
+++ b/test/e2e/index.js
@@ -0,0 +1,18 @@
+'use strict'
+
+// Set BABEL_ENV to use proper env config
+process.env.BABEL_ENV = 'test'
+
+// Enable use of ES6+ on required files
+require('babel-register')({
+ ignore: /node_modules/
+})
+
+// Attach Chai APIs to global scope
+const { expect, should, assert } = require('chai')
+global.expect = expect
+global.should = should
+global.assert = assert
+
+// Require all JS files in `./specs` for Mocha to consume
+require('require-dir')('./specs')
diff --git a/test/e2e/specs/Launch.spec.js b/test/e2e/specs/Launch.spec.js
new file mode 100644
index 0000000..d79e9a5
--- /dev/null
+++ b/test/e2e/specs/Launch.spec.js
@@ -0,0 +1,13 @@
+import utils from '../utils'
+
+describe('Launch', function() {
+ beforeEach(utils.beforeEach)
+ afterEach(utils.afterEach)
+
+ it('shows the proper application title', function() {
+ return this.app.client.getTitle()
+ .then(title => {
+ expect(title).to.equal('kubernetes-port-forwarder')
+ })
+ })
+})
diff --git a/test/e2e/utils.js b/test/e2e/utils.js
new file mode 100644
index 0000000..7d4e0da
--- /dev/null
+++ b/test/e2e/utils.js
@@ -0,0 +1,23 @@
+import electron from 'electron'
+import { Application } from 'spectron'
+
+export default {
+ afterEach () {
+ this.timeout(10000)
+
+ if (this.app && this.app.isRunning()) {
+ return this.app.stop()
+ }
+ },
+ beforeEach () {
+ this.timeout(10000)
+ this.app = new Application({
+ path: electron,
+ args: ['dist/electron/main.js'],
+ startTimeout: 10000,
+ waitTimeout: 10000
+ })
+
+ return this.app.start()
+ }
+}
diff --git a/test/unit/index.js b/test/unit/index.js
new file mode 100644
index 0000000..f07be98
--- /dev/null
+++ b/test/unit/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue'
+Vue.config.devtools = false
+Vue.config.productionTip = false
+
+// require all test files (files that ends with .spec.js)
+const testsContext = require.context('./specs', true, /\.spec$/)
+testsContext.keys().forEach(testsContext)
+
+// require all src files except main.js for coverage.
+// you can also change this to match only the subset of files that
+// you want coverage for.
+const srcContext = require.context('../../src/renderer', true, /^\.\/(?!main(\.js)?$)/)
+srcContext.keys().forEach(srcContext)
diff --git a/test/unit/karma.conf.js b/test/unit/karma.conf.js
new file mode 100644
index 0000000..6204011
--- /dev/null
+++ b/test/unit/karma.conf.js
@@ -0,0 +1,62 @@
+'use strict'
+
+const path = require('path')
+const merge = require('webpack-merge')
+const webpack = require('webpack')
+
+const baseConfig = require('../../.electron-vue/webpack.renderer.config')
+const projectRoot = path.resolve(__dirname, '../../src/renderer')
+
+// Set BABEL_ENV to use proper preset config
+process.env.BABEL_ENV = 'test'
+
+let webpackConfig = merge(baseConfig, {
+ devtool: '#inline-source-map',
+ plugins: [
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"testing"'
+ })
+ ]
+})
+
+// don't treat dependencies as externals
+delete webpackConfig.entry
+delete webpackConfig.externals
+delete webpackConfig.output.libraryTarget
+
+// apply vue option to apply isparta-loader on js
+webpackConfig.module.rules
+ .find(rule => rule.use.loader === 'vue-loader').use.options.loaders.js = 'babel-loader'
+
+module.exports = config => {
+ config.set({
+ browsers: ['visibleElectron'],
+ client: {
+ useIframe: false
+ },
+ coverageReporter: {
+ dir: './coverage',
+ reporters: [
+ { type: 'lcov', subdir: '.' },
+ { type: 'text-summary' }
+ ]
+ },
+ customLaunchers: {
+ 'visibleElectron': {
+ base: 'Electron',
+ flags: ['--show']
+ }
+ },
+ frameworks: ['mocha', 'chai'],
+ files: ['./index.js'],
+ preprocessors: {
+ './index.js': ['webpack', 'sourcemap']
+ },
+ reporters: ['spec', 'coverage'],
+ singleRun: true,
+ webpack: webpackConfig,
+ webpackMiddleware: {
+ noInfo: true
+ }
+ })
+}
diff --git a/test/unit/specs/LandingPage.spec.js b/test/unit/specs/LandingPage.spec.js
new file mode 100644
index 0000000..7cea66f
--- /dev/null
+++ b/test/unit/specs/LandingPage.spec.js
@@ -0,0 +1,13 @@
+import Vue from 'vue'
+import LandingPage from '@/components/LandingPage'
+
+describe('LandingPage.vue', () => {
+ it('should render correct contents', () => {
+ const vm = new Vue({
+ el: document.createElement('div'),
+ render: h => h(LandingPage)
+ }).$mount()
+
+ expect(vm.$el.querySelector('.title').textContent).to.contain('Welcome to your new project!')
+ })
+})
diff --git a/upload.sh b/upload.sh
new file mode 100755
index 0000000..7296d93
--- /dev/null
+++ b/upload.sh
@@ -0,0 +1,4 @@
+for ext in dmg exe AppImage
+do
+ mc cp build/*-$(cat build/.version).$ext pixelpoint/kpf/kube-forwarder-$(cat build/.version)-$(cat build/.number).$ext
+done