├── .eslintignore ├── .gitignore ├── Procfile ├── assets ├── task_button.png ├── hotkeys_button.png └── web-ui-screen.png ├── client ├── assets │ ├── logo.png │ ├── logo.psd │ ├── logo2.png │ ├── logo2_small.png │ └── logo2_transparent.png ├── helpers │ ├── tabs.js │ ├── hotKeys.js │ ├── WindowAttached.js │ ├── taskList.js │ └── dom_utils.js ├── scripts │ ├── Api.js │ ├── ThemeSwitcher.js │ ├── WebSocket.js │ ├── Configs.js │ ├── Tabs.js │ ├── HotKeys.js │ ├── WebLogger.js │ └── TaskList.js ├── styles │ ├── fullscreen.css │ ├── hot_keys-shortcuts.css │ └── dark-theme.css ├── small-screens.css ├── medium-screens.css ├── controllers │ └── Header.js ├── index.html ├── global.js └── styles.css ├── nodemon.json ├── server ├── utils │ ├── emitter.js │ ├── mem_cache.js │ ├── colors.js │ ├── envs.js │ ├── commands.js │ └── processes.js ├── constants.js ├── ws.js ├── processWorker.js ├── config.js ├── taskfile.js └── server.js ├── postinstall.js ├── .vscode └── launch.json ├── .eslintrc.yml ├── babel.config.js ├── LICENSE ├── webpack.config.js ├── .travis.yml ├── package.json ├── flamebird.js ├── README.md └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | .flamebirdrc -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | client: nodemon --exec \"npm run client:dev\" --watch client -V 2 | -------------------------------------------------------------------------------- /assets/task_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/assets/task_button.png -------------------------------------------------------------------------------- /client/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/client/assets/logo.png -------------------------------------------------------------------------------- /client/assets/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/client/assets/logo.psd -------------------------------------------------------------------------------- /assets/hotkeys_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/assets/hotkeys_button.png -------------------------------------------------------------------------------- /assets/web-ui-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/assets/web-ui-screen.png -------------------------------------------------------------------------------- /client/assets/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/client/assets/logo2.png -------------------------------------------------------------------------------- /client/assets/logo2_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/client/assets/logo2_small.png -------------------------------------------------------------------------------- /client/assets/logo2_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acacode/flamebird/HEAD/client/assets/logo2_transparent.png -------------------------------------------------------------------------------- /client/helpers/tabs.js: -------------------------------------------------------------------------------- 1 | export const createTab = (name, isActive) => ({ 2 | name, 3 | active: !!isActive, 4 | }) 5 | -------------------------------------------------------------------------------- /client/helpers/hotKeys.js: -------------------------------------------------------------------------------- 1 | export const clearifyEvent = (e = window.event) => { 2 | e.preventDefault() 3 | e.stopPropagation() 4 | } 5 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": false, 3 | "ext": "js,css,html", 4 | "delay": "3000", 5 | "watch": ["client/**/*", "server/**/*", "flamebird.js", "webpack.config.js"], 6 | "ignore": ["node_modules"] 7 | } -------------------------------------------------------------------------------- /client/helpers/WindowAttached.js: -------------------------------------------------------------------------------- 1 | export default function WindowAttached(windowProperty) { 2 | return class WindowAttached { 3 | constructor() { 4 | window[windowProperty] = this 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /server/utils/emitter.js: -------------------------------------------------------------------------------- 1 | const events = require('events') 2 | 3 | const emitter = new events.EventEmitter() 4 | 5 | emitter.once('killall', signal => 6 | console.log('Killing all processes with signal ', signal) 7 | ) 8 | 9 | emitter.setMaxListeners(50) 10 | 11 | module.exports = emitter 12 | -------------------------------------------------------------------------------- /postinstall.js: -------------------------------------------------------------------------------- 1 | const { yellow } = require('./server/utils/colors') 2 | 3 | const strings = [] 4 | strings.push(yellow('╔═════════════════════════════════════╗')) 5 | strings.push(yellow('║ Thanks for using Flamebird.js ║')) 6 | strings.push(yellow('╚═════════════════════════════════════╝')) 7 | console.log('\r\n' + strings.join('\r\n') + '\r\n') 8 | -------------------------------------------------------------------------------- /server/utils/mem_cache.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | const memCache = {} 4 | 5 | const getCache = (key, defaultValue) => 6 | _.isUndefined(memCache[key]) ? defaultValue || null : memCache[key] 7 | 8 | const setToCache = (key, value) => (memCache[key] = value) 9 | 10 | module.exports = { 11 | get: getCache, 12 | set: setToCache, 13 | } 14 | -------------------------------------------------------------------------------- /server/utils/colors.js: -------------------------------------------------------------------------------- 1 | const specChar = char => '\x1b[' + char + 'm' 2 | 3 | const colorModifier = (colorCode, isFg = false) => str => 4 | specChar(colorCode) + str + specChar(isFg ? 39 : 49) 5 | 6 | module.exports = { 7 | yellow: colorModifier(33), 8 | red: colorModifier(31), 9 | grey: colorModifier(90), 10 | blue: colorModifier(34), 11 | cyan: colorModifier(36), 12 | magentaFg: colorModifier(45, true), 13 | } 14 | -------------------------------------------------------------------------------- /server/constants.js: -------------------------------------------------------------------------------- 1 | const MESSAGE_TYPES = { 2 | CONNECTION: 'CONNECTION', 3 | LOG: 'LOG', 4 | APPS_LIST_UPDATE: 'APPS_LIST_UPDATE', 5 | } 6 | 7 | const PATHS = { 8 | // paths will been resolved via path.resolve(__dirname) 9 | WEB_APP_ROOT: '../dist', 10 | } 11 | 12 | const TASK_RUNNERS = { 13 | NPM: 'npm run', 14 | YARN: 'yarn run', 15 | } 16 | 17 | module.exports = { 18 | MESSAGE_TYPES, 19 | PATHS, 20 | TASK_RUNNERS, 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach by Process ID", 11 | "processId": "${command:PickProcess}" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - standard 3 | - prettier 4 | - prettier/standard 5 | 6 | parser: babel-eslint 7 | 8 | plugins: 9 | - standard 10 | - prettier 11 | 12 | env: 13 | browser: true 14 | 15 | rules: 16 | # no-undef: 0 17 | prettier/prettier: 18 | - error 19 | - trailingComma: es5 20 | singleQuote: true 21 | semi: false 22 | no-var: 2 23 | prefer-const: 2 24 | 25 | # TODO: The following rules should be removed incrementally in order to fully 26 | # integrate lint rules from the Standard ESLint config and Prettier 27 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | 4 | module.exports = { 5 | "sourceMaps": true, 6 | "presets": [ 7 | [ 8 | "@babel/preset-env", 9 | { 10 | "targets": { 11 | "browsers": ["last 2 versions"] 12 | }, 13 | "modules": "commonjs" 14 | }, 15 | ] 16 | ], 17 | "ignore": [/[\/\\]core-js/, /@babel[\/\\]runtime/], 18 | "plugins": [ 19 | "@babel/plugin-transform-runtime", 20 | "@babel/plugin-syntax-dynamic-import", 21 | "@babel/plugin-proposal-class-properties", 22 | "@babel/plugin-proposal-export-default-from", 23 | "@babel/plugin-proposal-export-namespace-from", 24 | "@babel/plugin-proposal-object-rest-spread" 25 | ] 26 | } -------------------------------------------------------------------------------- /client/scripts/Api.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import kinka from 'kinka' 3 | 4 | export default new (class Api { 5 | getProjectInfo = () => kinka.get('/info') 6 | getProjectVersion = () => kinka.get('/project-version') 7 | runTask = (configId, taskId) => kinka.post(`/${configId}/${taskId}/run`) 8 | stopTask = (configId, taskId) => kinka.post(`/${configId}/${taskId}/stop`) 9 | clearLogs = (configId, taskId) => kinka.delete(`/${configId}/${taskId}/logs`) 10 | getLogs = (configId, taskId) => kinka.get(`/${configId}/${taskId}/logs`) 11 | removeConfig = configId => kinka.delete(`/${configId}`) 12 | updateEnvs = (configId, taskId, envs) => 13 | kinka.post(`/${configId}/${taskId}/envs`, _.clone(envs)) 14 | })() 15 | -------------------------------------------------------------------------------- /client/scripts/ThemeSwitcher.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import WindowAttached from '../helpers/WindowAttached' 3 | 4 | export const THEMES = { 5 | WHITE: 'white', 6 | DARK: 'dark', 7 | } 8 | 9 | export default class ThemeSwitcher extends WindowAttached('ThemeSwitcher') { 10 | theme = THEMES.WHITE 11 | 12 | onSwitchTheme = _.noop 13 | 14 | setTheme = newTheme => { 15 | localStorage.setItem('theme', newTheme) 16 | document.body.setAttribute('theme', newTheme) 17 | this.theme = newTheme 18 | 19 | this.onSwitchTheme(this.theme) 20 | } 21 | 22 | switchTheme = () => { 23 | const newTheme = this.theme === THEMES.DARK ? THEMES.WHITE : THEMES.DARK 24 | this.setTheme(newTheme) 25 | } 26 | 27 | constructor({ onSwitchTheme }) { 28 | super() 29 | this.onSwitchTheme = onSwitchTheme 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/styles/fullscreen.css: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 923px) { 2 | body[fullscreen="true"] { 3 | padding: 0.9% 0; 4 | } 5 | 6 | [fullscreen="true"] .wrapper { 7 | max-width: calc(98.2% - 40px); 8 | } 9 | 10 | [fullscreen="true"] header .title { 11 | padding-left: 49px; 12 | } 13 | 14 | [fullscreen="true"] #task-list,[fullscreen="true"] .task.new-task { 15 | width: 260px; 16 | } 17 | 18 | [fullscreen="true"] #task-logs { 19 | width: calc(100% - 260px); 20 | } 21 | [fullscreen="true"] .scrolling > .task-data { 22 | top: 48px; 23 | } 24 | 25 | [fullscreen="true"] .logo { 26 | left: 3px; 27 | z-index: 20; 28 | top: 4px; 29 | } 30 | 31 | [fullscreen="true"] .logs-container { 32 | padding: 20px 20px 27px; 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /client/scripts/WebSocket.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | const { MESSAGE_TYPES } = require('../../server/constants') 3 | 4 | export default class WebSocket { 5 | connection = null 6 | 7 | constructor(url, { onConnection, onLogUpdate, onAppListUpdate } = {}) { 8 | this.onConnection = onConnection || _.noop 9 | this.onLogUpdate = onLogUpdate || _.noop 10 | this.onAppListUpdate = onAppListUpdate || _.noop 11 | 12 | this.connection = new window.WebSocket(url) 13 | this.connection.onmessage = this.handleMessageReceive 14 | } 15 | 16 | handleMessageReceive = ({ data }) => { 17 | const { type, message } = JSON.parse(data) 18 | 19 | switch (type) { 20 | case MESSAGE_TYPES.CONNECTION: 21 | return this.onConnection(message) 22 | case MESSAGE_TYPES.LOG: 23 | return this.onLogUpdate(message) 24 | case MESSAGE_TYPES.APPS_LIST_UPDATE: 25 | return this.onAppListUpdate(message) 26 | default: 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present acacode 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/ws.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws') 2 | const _ = require('lodash') 3 | const uuid = require('short-uuid') 4 | const { MESSAGE_TYPES } = require('./constants') 5 | 6 | const sessions = {} 7 | 8 | function createWSConnection(server) { 9 | const ws = new WebSocket.Server({ server }) 10 | 11 | ws.on('connection', session => { 12 | sendMessageToSession(session, MESSAGE_TYPES.CONNECTION, { status: 'ok' }) 13 | 14 | const sessionId = uuid.generate() 15 | sessions[sessionId] = session 16 | 17 | session.on('close', () => { 18 | delete sessions[sessionId] 19 | session = null 20 | }) 21 | }) 22 | } 23 | 24 | const sendMessageToSession = (session, type, message) => 25 | session.send( 26 | JSON.stringify({ 27 | type, 28 | message, 29 | }) 30 | ) 31 | 32 | function sendMessage(type, message) { 33 | _.each(sessions, session => { 34 | if (session.readyState !== 3) { 35 | sendMessageToSession(session, type, message) 36 | } 37 | }) 38 | } 39 | 40 | module.exports = { 41 | createWSConnection: createWSConnection, 42 | MESSAGE_TYPES: MESSAGE_TYPES, 43 | sendMessage: sendMessage, 44 | } 45 | -------------------------------------------------------------------------------- /client/small-screens.css: -------------------------------------------------------------------------------- 1 | @media screen and (max-width: 545px) { 2 | header .title{ 3 | display: none; 4 | } 5 | .main-buttons { 6 | margin-left: auto; 7 | } 8 | 9 | .main-button:not(.toggle).run{ 10 | margin-left: 30px; 11 | } 12 | .main-button.toggle.hot-keys{ 13 | display: none; 14 | } 15 | 16 | .task-list-showed #task-logs{ 17 | width: 100%; 18 | } 19 | 20 | #task-list{ 21 | position: absolute; 22 | z-index: 1; 23 | height: calc(100% - 40px); 24 | background: inherit; 25 | transition: transform 289ms ease; 26 | } 27 | body #task-list,.task-list-showed #task-list{ 28 | width: 80%; 29 | } 30 | 31 | header{ 32 | height: 40px; 33 | } 34 | 35 | .tabs { 36 | transition: left 289ms ease; 37 | position: absolute; 38 | left: -32px; 39 | top: 292px; 40 | transform: rotate(90deg); 41 | z-index: 2; 42 | } 43 | 44 | .task-list-showed .tabs { 45 | left: calc(80% + 6px); 46 | } 47 | .main-button.toggle.resize { 48 | display: none; 49 | } 50 | } -------------------------------------------------------------------------------- /server/utils/envs.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const fs = require('fs') 3 | 4 | const separateEnvsFromString = str => 5 | _.reduce( 6 | str.split(' '), 7 | (task, substr, index, array) => { 8 | if (/^([a-zA-Z]{1,2}[a-zA-Z_0-9]{1,})=([a-zA-Z0-9]{1,})$/.test(substr)) { 9 | _.assign(task.envs, _.fromPairs([substr.split('=')])) 10 | } else { 11 | task.string.push(substr) 12 | } 13 | if (index === array.length - 1) { 14 | task.string = task.string.join(' ') 15 | } 16 | return task 17 | }, 18 | { 19 | string: [], 20 | envs: {}, 21 | } 22 | ) 23 | 24 | const getDataFromEnvFile = filename => { 25 | let envFile = {} 26 | try { 27 | const data = fs.readFileSync(filename) 28 | envFile = _.reduce( 29 | _.compact(data.toString().split(/[\n\r]/g)), 30 | (envFile, line) => { 31 | const variable = line.split('=') 32 | envFile[variable[0]] = variable[1] 33 | return envFile 34 | }, 35 | {} 36 | ) 37 | } catch (e) { 38 | console.warn('.ENV file not found') 39 | } 40 | return envFile 41 | } 42 | 43 | module.exports = { 44 | separateEnvsFromString, 45 | getDataFromEnvFile, 46 | } 47 | -------------------------------------------------------------------------------- /client/helpers/taskList.js: -------------------------------------------------------------------------------- 1 | import { createEl } from './dom_utils' 2 | 3 | export const createEmptyTaskList = tabs => 4 | tabs.reduce((taskList, tab) => { 5 | taskList[tab.name] = [] 6 | return taskList 7 | }, {}) 8 | 9 | export const createTaskElement = ( 10 | { isRun, isLaunching, isActive, id, name }, 11 | index, 12 | { onOpenTask, onRunTask, onStopTask } 13 | ) => { 14 | return createEl('div', { 15 | className: [ 16 | 'task', 17 | 'task-num-' + (index + 1), 18 | isRun && 'running', 19 | isLaunching && 'clicked', 20 | isActive && 'active', 21 | ].join(' '), 22 | id, 23 | onclick: onOpenTask, 24 | children: [ 25 | createEl('i', { 26 | className: 'fas fa-cog', 27 | }), 28 | createEl('span', { 29 | className: 'task-name', 30 | innerText: name, 31 | }), 32 | createEl('button', { 33 | className: 'run-task', 34 | onclick: onRunTask, 35 | children: [ 36 | createEl('i', { 37 | className: 'fas fa-play', 38 | }), 39 | ], 40 | }), 41 | createEl('button', { 42 | className: 'stop-task', 43 | onclick: onStopTask, 44 | children: [ 45 | createEl('i', { 46 | className: 'fas fa-stop', 47 | }), 48 | ], 49 | }), 50 | ], 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /server/utils/commands.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const uuid = require('short-uuid') 3 | const memCache = require('./mem_cache') 4 | const { MESSAGE_TYPES, sendMessage } = require('../ws') 5 | const { separateEnvsFromString } = require('./envs') 6 | 7 | const COMMAND_DEFAULT_DATA = { 8 | logs: [], 9 | isRun: false, 10 | } 11 | 12 | const getCommandById = (configId, taskId) => 13 | _.find( 14 | _.find(_.get(memCache.get('rc-snapshot'), 'configs', []), { 15 | id: configId, 16 | }).commands, 17 | { id: taskId } 18 | ) || {} 19 | 20 | /** 21 | * @typedef {Object} Command 22 | * @property {string} task 23 | * @property {Object} envs 24 | * @property {string} name 25 | */ 26 | 27 | const createCommand = (configId, name, commandData, type) => { 28 | const commonData = separateEnvsFromString(commandData) 29 | return { 30 | ...COMMAND_DEFAULT_DATA, 31 | configId, 32 | task: commonData.string, 33 | envs: commonData.envs, 34 | name: name, 35 | id: `c${uuid.generate()}`, 36 | type, 37 | } 38 | } 39 | 40 | const updateCommand = (command, { isRun, isLaunching, isStopping, log }) => { 41 | const message = { 42 | name: command.name, 43 | isRun: command.isRun, 44 | type: command.type, 45 | id: command.id, 46 | } 47 | 48 | if (!_.isUndefined(isLaunching)) { 49 | message.isLaunching = isLaunching 50 | } 51 | if (!_.isUndefined(isStopping)) { 52 | message.isStopping = isStopping 53 | } 54 | if (!_.isUndefined(isRun)) { 55 | message.isRun = command.isRun = isRun 56 | } 57 | if (!_.isUndefined(log)) { 58 | command.logs.push(log) 59 | message.log = log 60 | } 61 | sendMessage(MESSAGE_TYPES.LOG, message) 62 | } 63 | 64 | module.exports = { 65 | COMMAND_DEFAULT_DATA, 66 | getCommandById, 67 | createCommand, 68 | updateCommand, 69 | } 70 | -------------------------------------------------------------------------------- /server/utils/processes.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const prog = require('child_process') 3 | const emitter = require('./emitter') 4 | const kill = require('tree-kill') 5 | const isWin = require('os').platform() === 'win32' 6 | 7 | const processConfig = { 8 | file: isWin ? process.env.comspec || 'cmd.exe' : '/bin/sh', 9 | args: isWin ? ['/s', '/c'] : ['-c'], 10 | } 11 | const processes = {} 12 | const killAllListenerRefs = {} 13 | 14 | const deleteProcess = taskId => { 15 | processes[taskId].pid = null 16 | processes[taskId] = null 17 | delete processes[taskId] 18 | 19 | const emitterListeners = emitter._events.killall 20 | 21 | if (emitterListeners && emitterListeners instanceof Array) { 22 | emitterListeners.splice(killAllListenerRefs[taskId], 1) 23 | } 24 | 25 | delete killAllListenerRefs[taskId] 26 | } 27 | 28 | function killProcess(taskId) { 29 | if (processes[taskId]) { 30 | kill(processes[taskId].pid, 'SIGINT') 31 | deleteProcess(taskId) 32 | } 33 | } 34 | 35 | function attachKillListener(taskId) { 36 | emitter.once('killall', () => killProcess(taskId)) 37 | killAllListenerRefs[taskId] = emitter.listeners('killall').length - 1 38 | } 39 | 40 | function getProcessById(taskId) { 41 | return processes[taskId] 42 | } 43 | 44 | /** 45 | * @param {object} command { id, rawTask, envs } 46 | * @returns spawned process 47 | */ 48 | function createProcess({ id, rawTask, envs }, config) { 49 | return (processes[id] = prog.spawn( 50 | processConfig.file, 51 | [...processConfig.args, rawTask], 52 | { 53 | stdio: 'pipe', 54 | cwd: config.path.replace(/\\/g, '/'), 55 | windowsVerbatimArguments: isWin, 56 | env: _.assign( 57 | {}, 58 | process.env, 59 | { 60 | PWD: config.path.replace(/\\/g, '/'), 61 | }, 62 | // env variables from .env file 63 | config.envs, 64 | // env variables from command 65 | envs 66 | ), 67 | } 68 | )) 69 | } 70 | 71 | module.exports = { 72 | killProcess, 73 | attachKillListener, 74 | getProcessById, 75 | createProcess, 76 | } 77 | -------------------------------------------------------------------------------- /client/scripts/Configs.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { el, createEl } from '../helpers/dom_utils' 3 | 4 | export default class ConfigsManager { 5 | configs = [] 6 | activeConfigIndex = 0 7 | 8 | refreshConfigs = async () => { 9 | const configs = await this.getConfigs() 10 | this.configs = configs 11 | if (!this.getActiveConfig()) { 12 | this.setConfig(0) 13 | } 14 | this.refreshHtml() 15 | } 16 | 17 | refreshHtml = () => { 18 | const configsContainerEl = el(this.configsContainer) 19 | configsContainerEl.innerHTML = '' 20 | 21 | this.configs.forEach((config, index) => 22 | createEl('div', { 23 | className: 'config', 24 | children: [ 25 | createEl('span', { 26 | className: 'name', 27 | innerText: config.name, 28 | }), 29 | index && 30 | createEl('i', { 31 | className: 'fas fa-times close-icon', 32 | onclick: () => { 33 | window.event.preventDefault() 34 | this.removeConfig(index) 35 | }, 36 | }), 37 | ], 38 | onclick: () => this.setConfig(index), 39 | parent: configsContainerEl, 40 | }) 41 | ) 42 | } 43 | 44 | setConfig = (index = 0) => { 45 | this.activeConfigIndex = index 46 | 47 | this.onSetConfig(this.getActiveConfig()) 48 | } 49 | 50 | getConfigByIndex = (index = 0) => { 51 | return this.configs[index] 52 | } 53 | 54 | getConfigById = id => _.find(this.configs, { id }) 55 | 56 | removeConfig = index => { 57 | const [removedConfig] = this.configs.splice(index, 1) 58 | this.onRemoveConfig(removedConfig, index) 59 | 60 | this.refreshHtml() 61 | } 62 | 63 | getActiveConfig = () => this.configs[this.activeConfigIndex] 64 | 65 | constructor(configsContainer, { onSetConfig, getConfigs, onRemoveConfig }) { 66 | this.configsContainer = configsContainer 67 | // this.configs = configs 68 | this.onSetConfig = onSetConfig || _.noop 69 | this.onRemoveConfig = onRemoveConfig || _.noop 70 | this.getConfigs = getConfigs || _.noop 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/styles/hot_keys-shortcuts.css: -------------------------------------------------------------------------------- 1 | 2 | [hotkeys="true"] .task:after{ 3 | position: absolute; 4 | right: 6px; 5 | bottom: 6px; 6 | font-size: 11px; 7 | text-transform: uppercase; 8 | color: #828282; 9 | text-align: center; 10 | width: 10px; 11 | } 12 | [hotkeys="true"] .task-num-1:after { 13 | content:'q'; 14 | } 15 | [hotkeys="true"] .task-num-2:after { 16 | content:'w'; 17 | } 18 | [hotkeys="true"] .task-num-3:after { 19 | content:'e'; 20 | } 21 | [hotkeys="true"] .task-num-4:after { 22 | content:'r'; 23 | } 24 | [hotkeys="true"] .task-num-5:after { 25 | content:'t'; 26 | } 27 | [hotkeys="true"] .task-num-6:after { 28 | content:'y'; 29 | } 30 | [hotkeys="true"] .task-num-7:after { 31 | content:'u'; 32 | } 33 | [hotkeys="true"] .task-num-8:after { 34 | content:'i'; 35 | } 36 | [hotkeys="true"] .task-num-9:after { 37 | content:'o'; 38 | } 39 | [hotkeys="true"] .task-num-10:after { 40 | content:'p'; 41 | } 42 | [hotkeys="true"] .task-num-11:after { 43 | content:'['; 44 | } 45 | [hotkeys="true"] .task-num-12:after { 46 | content:']'; 47 | } 48 | [hotkeys="true"] .task-num-13:after { 49 | content:'a'; 50 | } 51 | [hotkeys="true"] .task-num-14:after { 52 | content:'s'; 53 | } 54 | [hotkeys="true"] .task-num-15:after { 55 | content:'d'; 56 | } 57 | [hotkeys="true"] .task-num-16:after { 58 | content:'f'; 59 | } 60 | [hotkeys="true"] .task-num-17:after { 61 | content:'g'; 62 | } 63 | [hotkeys="true"] .task-num-18:after { 64 | content:'h'; 65 | } 66 | [hotkeys="true"] .task-num-19:after { 67 | content:'j'; 68 | } 69 | [hotkeys="true"] .task-num-20:after { 70 | content:'k'; 71 | } 72 | [hotkeys="true"] .task-num-21:after { 73 | content:'l'; 74 | } 75 | [hotkeys="true"] .task-num-22:after { 76 | content:';'; 77 | } 78 | [hotkeys="true"] .task-num-23:after { 79 | content:"'"; 80 | } 81 | [hotkeys="true"] .task-num-24:after { 82 | content:'z'; 83 | } 84 | [hotkeys="true"] .task-num-25:after { 85 | content:'x'; 86 | } 87 | [hotkeys="true"] .task-num-26:after { 88 | content:'c'; 89 | } 90 | [hotkeys="true"] .task-num-27:after { 91 | content:'v'; 92 | } -------------------------------------------------------------------------------- /client/medium-screens.css: -------------------------------------------------------------------------------- 1 | @media screen and (max-width: 923px) { 2 | .wrapper { 3 | max-width: 100%; 4 | } 5 | 6 | body { 7 | padding: 0; 8 | } 9 | 10 | .tasks-button{ 11 | position: absolute; 12 | left: 0; 13 | top: 0; 14 | display: block; 15 | background: transparent; 16 | border: none; 17 | padding: 6px 10px; 18 | border-radius: 0; 19 | color: #4e4e4e; 20 | font-size: 24px; 21 | cursor:pointer; 22 | z-index: 10; 23 | } 24 | .tasks-button > .fas:before{ 25 | content: "\f0c9"; 26 | } 27 | .task-list-showed .tasks-button > .fas:before{ 28 | content: "\f00d"; 29 | } 30 | header .title { 31 | padding-left: 50px; 32 | } 33 | #task-list { 34 | transform: translateX(-100%); 35 | width: 0; 36 | } 37 | 38 | #task-logs { 39 | width: 100%; 40 | } 41 | .task-list-showed #task-list { 42 | transform: translateX(0); 43 | } 44 | .task.new-task, .task-list-showed #task-list{ 45 | width: 30%; 46 | } 47 | .active-tab-procfile .task.new-task,.active-tab-procfile .task.new-task.show{ 48 | transform: scaleY(.6) translateY(58px); 49 | } 50 | .task-list-showed .active-tab-procfile .task.new-task{ 51 | transform: none 52 | } 53 | .task-list-showed #task-logs { 54 | width: 70%; 55 | } 56 | .task-list-showed .tabs { 57 | position: absolute; 58 | left: calc(30% + 12px); 59 | top: 37px; 60 | transform: rotate(0deg); 61 | z-index: 2; 62 | } 63 | .task-list-showed .tab { 64 | border-radius: 0 0 8px 8px; 65 | } 66 | .logs-container { 67 | padding-top: 27px; 68 | } 69 | .scrolling > .task-data { 70 | top: 45px; 71 | } 72 | 73 | .main-button.toggle{ 74 | min-width: 40px; 75 | } 76 | .main-button.toggle span{ 77 | display: none; 78 | } 79 | 80 | .task-list-showed .active-tab-procfile .task.new-task.show { 81 | transform: translateY(-128px); 82 | } 83 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const TerserJSPlugin = require('terser-webpack-plugin') 3 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin') 4 | 5 | const srcFolder = process.env.SRC_DIR || path.resolve(__dirname, './client') 6 | const destFolder = process.env.DEST_DIR || path.resolve(__dirname, './dist') 7 | 8 | const isDev = process.env.NODE_ENV === 'development' 9 | 10 | module.exports = { 11 | devtool: isDev ? 'eval' : false, 12 | mode: isDev ? 'development' : 'production', 13 | target: 'web', 14 | entry: { 15 | 'index.html': path.resolve(srcFolder, './index.html'), 16 | global: path.resolve(srcFolder, './global.js'), 17 | }, 18 | output: { 19 | path: destFolder, 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.html$/, 25 | loaders: [ 26 | 'file-loader?name=[name].html', 27 | 'extract-loader', 28 | { 29 | loader: 'html-loader', 30 | options: { 31 | minimize: !isDev, 32 | root: srcFolder, 33 | attrs: ['img:src', 'link:href'], 34 | }, 35 | }, 36 | ], 37 | }, 38 | { 39 | test: /\.(png|jpg|gif)$/, 40 | loaders: [ 41 | { 42 | loader: 'url-loader', 43 | options: { 44 | limit: 8192, 45 | fallback: 'responsive-loader', 46 | quality: 75, 47 | }, 48 | }, 49 | ], 50 | }, 51 | { 52 | test: /\.js$/, 53 | loaders: [ 54 | { 55 | loader: 'babel-loader', 56 | options: { 57 | babelrc: true, 58 | }, 59 | }, 60 | ], 61 | }, 62 | { 63 | test: /\.css$/, 64 | use: ['file-loader', 'extract-loader', 'css-loader'], 65 | }, 66 | { 67 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 68 | use: [ 69 | { 70 | loader: 'file-loader', 71 | options: { 72 | name: '[name].[ext]', 73 | outputPath: 'fonts/', 74 | }, 75 | }, 76 | ], 77 | }, 78 | ], 79 | }, 80 | optimization: { 81 | minimizer: isDev 82 | ? [] 83 | : [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})], 84 | }, 85 | // plugins: [ 86 | // new HtmlWebpackPlugin({ 87 | // minify: { 88 | // html5: true, 89 | // removeComments: true, 90 | // collapseWhitespace: true, 91 | // minifyCSS: true, 92 | // minifyJS: true, 93 | // // minifyURLs: true 94 | // }, 95 | // template: entryHtmlFileAbsolutePath, 96 | // }), 97 | // ], 98 | } 99 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | 5 | cache: 6 | directories: 7 | - "node_modules" 8 | 9 | before_install: 10 | - export PACKAGE_VERSION=$(node -p "require('./package.json').version") 11 | - export NPM_REMOTE_VERSION=$(npm view flamebird version) 12 | - export PACKAGE_NAME=$(node -p "require('./package.json').name") 13 | - export BRANCH_IS_TAG=$(node -p "/^([0-9].[0-9].[0-9]+((-(alpha|beta))|))$/.test('${TRAVIS_BRANCH}')") 14 | - export PROJECT_NAME="acacode/flamebird" 15 | - export RELEASE_BODY=$(node -p "'[Click here to find current release changes](https://github.com/$PROJECT_NAME/blob/$TRAVIS_BRANCH/CHANGELOG.md)'") 16 | 17 | install: 18 | - npm ci 19 | 20 | jobs: 21 | include: 22 | - stage: pre-test(lint,audit) 23 | script: 24 | - npm audit 25 | - npm run lint 26 | - stage: test 27 | script: 28 | - npm run build 29 | - npm run test 30 | - stage: publish 31 | if: env(BRANCH_IS_TAG) != true AND branch = master AND type = push 32 | name: "Create Github Release" 33 | before_deploy: 34 | - git config --global user.email "builds@travis-ci.com" 35 | - git config --global user.name "Travis CI" 36 | - git tag $PACKAGE_VERSION -a -m "Generated tag from TravisCI for build $TRAVIS_BUILD_NUMBER from $TRAVIS_BRANCH" 37 | - git push -q https://$GITHUB_TOKEN@github.com/$PROJECT_NAME --tags 38 | - zip -r release.zip dist flamebird.js LICENSE postinstall.js README.md client server package-lock.json package.json 39 | - ls -l 40 | deploy: 41 | provider: releases 42 | name: "Release ${PACKAGE_VERSION}" 43 | body: "$RELEASE_BODY" 44 | overwrite: true 45 | skip_cleanup: true 46 | api_key: 47 | secure: ${GITHUB_TOKEN} 48 | file: 49 | - release.zip 50 | on: 51 | all_branches: true 52 | repo: $PROJECT_NAME 53 | after_deploy: 54 | - echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > .npmrc 55 | - npm publish 56 | 57 | notifications: 58 | slack: 59 | rooms: 60 | secure: oEsx4yOk0AkiYMrjhDbKzh7mJIHd/Rn1Y+C86bmxGxCbAc/VuAM1pRfk/A3o7I0QwDzFZ1XCZrHT1VsYDj4ypFaJb8pCtwOU0pv+mseGlcYOvtV5NgQMBM3HQvx9+9iF8sLXg1glzToNkd8iBkbNxEgg6EEeBdX3mD7QX04xl4Mgl259gofN1pOxP3LwHKkKcZgeS6MAEfWhBG/a8poAccvusgn75wNZ1qyI+ZPYtQ75JK4QieJzmxTHPr2gGhq1EwRWYIjjyIEA4ofQpjWyzy0U6OQKBxwPaH+GM+Vn6hhp5JOenmiIslUZmzgyXL233Iu4iMvh1AiLJvB7AIBDilA3CYvCzM5uQVyKqiNFMrkcBRZgE3FCAaYYcpfr+k+3wO3HGstvcW+xxB3EdOGfXuI6Bu31k6kQQjXTZnYCGXgH4b+s+CkknOt04VXMxwPaQNjwb7+2HZt7En0kCAcswCB2ZYekfwJ8ps+ahlKGDbPEgg/vYAPV0JqwY4gEGFOthe3MhHDzpnjm6udpItlYrlcqzV4w4b3LEOgc5AomrQRD+SPdFHwvUjtjyDjyhdPNK9QixCIPW55njCssCJOi5kwq74zr3eVvqD8ijmUdfL6ASu1113fs/4k58mX4GwEkmMicThLg01aoppd3IWTc//bsYOLC+AbC8cAm9JZZWHA= 61 | -------------------------------------------------------------------------------- /server/processWorker.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { 3 | killProcess, 4 | attachKillListener, 5 | getProcessById, 6 | createProcess, 7 | } = require('./utils/processes') 8 | const { updateCommand } = require('./utils/commands') 9 | 10 | const colors = require('colors') 11 | const kill = require('tree-kill') 12 | const { getConfig } = require('./config') 13 | 14 | colors.enabled = true 15 | 16 | function stop(command) { 17 | // if (_.isString(command)) { 18 | // command = getCommandById(command) 19 | // } 20 | 21 | killProcess(command.id) 22 | updateCommand(command, { isStopping: true }) 23 | } 24 | 25 | function run(command) { 26 | // if (_.isString(command)) { 27 | // command = getCommandById(command) 28 | // } 29 | 30 | const config = getConfig(command.configId) 31 | const taskId = command.id 32 | const isWeb = getConfig(command.configId).web 33 | const proc = createProcess(command, config) 34 | 35 | if (isWeb) { 36 | updateCommand(command, { isRun: true }) 37 | 38 | proc.stdout.on('data', rawLog => 39 | updateCommand(command, { 40 | log: rawLog.toString(), 41 | isRun: true, 42 | }) 43 | ) 44 | proc.stderr.on('data', rawLog => 45 | updateCommand(command, { 46 | log: rawLog.toString(), 47 | isRun: true, 48 | }) 49 | ) 50 | proc.on('close', code => { 51 | killProcess(taskId) 52 | updateCommand(command, { 53 | isRun: false, 54 | log: !code ? 'Exited Successfully' : 'Exited with exit code ' + code, 55 | }) 56 | }) 57 | proc.on('error', () => { 58 | killProcess(taskId) 59 | updateCommand(command, { 60 | isRun: false, 61 | log: 'Failed to execute command', 62 | }) 63 | }) 64 | 65 | attachKillListener(taskId) 66 | } else { 67 | const defaultOutputHandler = log => { 68 | process.stdout.write(log.toString()) 69 | } 70 | proc.stdout.on('data', defaultOutputHandler) 71 | proc.stdout.on('error', defaultOutputHandler) 72 | proc.stderr.on('data', defaultOutputHandler) 73 | } 74 | } 75 | 76 | function reRun(command) { 77 | const isLaunching = true 78 | const proc = getProcessById(command.id) 79 | 80 | if (proc) { 81 | kill(proc.pid, 'SIGINT', () => { 82 | updateCommand(command, { isLaunching }) 83 | setTimeout(() => run(command), 1000) 84 | }) 85 | updateCommand(command, { isLaunching }) 86 | } else { 87 | updateCommand(command, { isLaunching }) 88 | run(command) 89 | } 90 | } 91 | 92 | function runAll(commands) { 93 | _.each(commands, run) 94 | } 95 | function stopAll(commands) { 96 | _.each(commands, stop) 97 | } 98 | 99 | module.exports.runAll = runAll 100 | module.exports.stopAll = stopAll 101 | module.exports.run = run 102 | module.exports.stop = stop 103 | module.exports.reRun = reRun 104 | -------------------------------------------------------------------------------- /client/styles/dark-theme.css: -------------------------------------------------------------------------------- 1 | 2 | body[theme="dark"] { 3 | background: #212121; 4 | } 5 | 6 | [theme="dark"] header { 7 | background: #3e3e3e; 8 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.33); 9 | } 10 | 11 | [theme="dark"] .title { 12 | color: #eeeeee; 13 | background: #3e3e3e; 14 | } 15 | 16 | [theme="dark"] .wrapper { 17 | background: #424242; 18 | } 19 | 20 | 21 | [theme="dark"] .task.active { 22 | background: rgb(37, 37, 37); 23 | color: #ebebeb; 24 | } 25 | 26 | 27 | [theme="dark"] .task { 28 | color: #a5a5a5; 29 | } 30 | 31 | 32 | [theme="dark"] .task:hover { 33 | background: rgb(37, 37, 37); 34 | color: #ebebeb; 35 | } 36 | 37 | 38 | 39 | [theme="dark"] button.tab { 40 | color: #d1d1d1; 41 | background: rgb(69, 69, 69); 42 | box-shadow: 0 0px 0px 0.5px rgb(33, 33, 33); 43 | } 44 | 45 | 46 | [theme="dark"] button.tab.active, 47 | [theme="dark"] button.tab:active { 48 | background: rgb(159, 144, 55); 49 | color: #212121; 50 | } 51 | 52 | [theme="dark"] .logs-container{ 53 | color: #dcdcdc; 54 | } 55 | 56 | [theme="dark"] .ansi-red-fg { 57 | color: rgb(230, 78, 78); 58 | } 59 | 60 | [theme="dark"] .logs-container::-webkit-scrollbar-thumb { 61 | background: #c1c1c1; 62 | } 63 | 64 | [theme="dark"] .logs-container::-webkit-scrollbar-thumb:hover { 65 | background: #9e9e9e; 66 | } 67 | 68 | [theme="dark"] .logs-container{ 69 | background: #060606; 70 | } 71 | 72 | [theme="dark"] .ansi-bright-black-fg { 73 | color: #909090; 74 | } 75 | 76 | [theme="dark"] .main-button.toggle { 77 | background: #333333; 78 | color: #929292; 79 | } 80 | 81 | [theme="dark"] .main-button.toggle.active { 82 | background: #2e7b58; 83 | color: #b5e0cd; 84 | } 85 | 86 | 87 | [theme="dark"] .run{ 88 | background: #2f9a6a; 89 | } 90 | 91 | [theme="dark"] .stop{background: #8c4343;} 92 | 93 | 94 | [theme="dark"] .task.new-task .form input,[theme="dark"] .task.new-task .form textarea { 95 | background: #333; 96 | color: #dcdcdc; 97 | } 98 | 99 | [theme="dark"] .task.new-task .form button { 100 | background: #2e7b58; 101 | color: #b5e0cd; 102 | } 103 | 104 | 105 | [theme="dark"] .task.updated{ 106 | animation-name: dark-highlight; 107 | } 108 | 109 | [theme="dark"] .toggle.color .fas { 110 | transform: scaleX(-1); 111 | } 112 | 113 | [theme="dark"] .close-icon { 114 | color: #995959; 115 | } 116 | 117 | [theme="dark"] .configs-list { 118 | background: #424242; 119 | } 120 | 121 | [theme="dark"] .config { 122 | color: #aeaeae; 123 | } 124 | 125 | [theme="dark"] .config:hover { 126 | background: #2b2b2b; 127 | } 128 | 129 | [theme="dark"] .config:not(:first-child) { 130 | border-color: rgba(255, 255, 255, 0.05); 131 | } 132 | 133 | 134 | @keyframes dark-highlight{ 135 | 50%{ 136 | background: rgb(79, 80, 63); 137 | } 138 | } -------------------------------------------------------------------------------- /client/scripts/Tabs.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { el, createEl } from '../helpers/dom_utils' 3 | import { createTab } from '../helpers/tabs' 4 | 5 | const WRAPPER_ELEMENT = '.wrapper' 6 | const TABS_CONTAINER = '#tabs' 7 | const ACTIVE_TAB_CLASSNAME = 'active' 8 | 9 | export const PRIORITY_TAB = createTab('procfile', true) 10 | 11 | export const DEFAULT_TABS = [ 12 | createTab('npm'), 13 | PRIORITY_TAB, 14 | createTab('grunt'), 15 | createTab('gulp'), 16 | ] 17 | 18 | class Tabs { 19 | wrapper = null 20 | onChangeListeners = [] 21 | 22 | tabs = [...DEFAULT_TABS] 23 | 24 | activeTab = _.find(this.tabs, { active: true }) 25 | 26 | constructor(wrapperElementQuery, tabsContainerQuery) { 27 | this.tabsContainer = el(tabsContainerQuery) 28 | this.wrapper = el(wrapperElementQuery) 29 | } 30 | 31 | createTabs = tabs => { 32 | this.tabsContainer.innerHTML = '' 33 | 34 | this.tabs = tabs 35 | this.tabs.forEach(tab => { 36 | createEl('button', { 37 | className: 'tab' + (tab.active ? ` ${ACTIVE_TAB_CLASSNAME}` : ''), 38 | id: tab.name, 39 | innerText: tab.name, 40 | onclick: () => this.setActive(tab.name), 41 | parent: this.tabsContainer, 42 | }) 43 | }) 44 | } 45 | 46 | getAll = () => this.tabs 47 | 48 | getTabBy = partialTabData => _.find(this.tabs, partialTabData) 49 | 50 | getNotActiveTabs = () => this.getTabBy({ active: false }) 51 | 52 | getTab = name => this.getTabBy({ name }) 53 | 54 | getActive = () => this.activeTab 55 | 56 | setActive = name => { 57 | const prevTab = this.activeTab 58 | // if (prevTab.name !== name) { 59 | if (this.getTab(prevTab.name)) { 60 | prevTab.active = false 61 | el(`#${prevTab.name}`).classList.remove(ACTIVE_TAB_CLASSNAME) 62 | } 63 | this.activeTab = this.getTab(name) 64 | this.activeTab.active = true 65 | 66 | el(`#${this.activeTab.name}`).classList.add(ACTIVE_TAB_CLASSNAME) 67 | 68 | if (this.onChangeListeners.length) { 69 | this.onChangeListeners.forEach(listener => 70 | listener(this.activeTab.name, prevTab.name) 71 | ) 72 | } 73 | this.wrapper.className = `wrapper active-tab-${this.activeTab.name}` 74 | return this.activeTab 75 | } 76 | 77 | setNextAsActive() { 78 | const index = this.tabs.findIndex(tab => tab.name === this.activeTab.name) 79 | if (_.isUndefined(this.tabs[index + 1])) { 80 | this.setActive(this.tabs[0].name) 81 | } else { 82 | this.setActive(this.tabs[index + 1].name) 83 | } 84 | } 85 | 86 | listenChanges = callback => { 87 | this.onChangeListeners.push(callback) 88 | } 89 | 90 | removeTab = tab => { 91 | const removingTabIndex = this.tabs.findIndex( 92 | ({ name }) => tab.name === name 93 | ) 94 | if (removingTabIndex !== -1) { 95 | this.tabs.splice(removingTabIndex, 1) 96 | el(`#${tab.name}`).remove() 97 | } 98 | } 99 | } 100 | 101 | export default new Tabs(WRAPPER_ELEMENT, TABS_CONTAINER) 102 | -------------------------------------------------------------------------------- /client/controllers/Header.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import WindowAttached from '../helpers/WindowAttached' 3 | import ThemeSwitcher, { THEMES } from '../scripts/ThemeSwitcher' 4 | import { toggleClass, el as getEl } from '../helpers/dom_utils' 5 | 6 | export class Header extends WindowAttached('header') { 7 | themeSwitcher = new ThemeSwitcher({ 8 | onSwitchTheme: newTheme => { 9 | $('.toggle.color').toggleClass('active', newTheme === THEMES.DARK) 10 | }, 11 | }) 12 | 13 | deps = { 14 | hotkeys: null, 15 | } 16 | 17 | configShowed = false 18 | fullscreen = !!localStorage.getItem('fullscreen') 19 | hotKeysEnabled = !!localStorage.getItem('hotkeys') 20 | notificationsEnabled = !!localStorage.getItem('notifications') 21 | 22 | constructor({ hotkeys }) { 23 | super() 24 | 25 | this.deps.hotkeys = hotkeys 26 | 27 | $(document).ready(() => { 28 | this.themeSwitcher.setTheme(localStorage.getItem('theme') || 'white') 29 | 30 | this.updateFullscreen() 31 | this.updateHotkeys() 32 | this.updateNotifications() 33 | 34 | window.addEventListener('click', () => { 35 | if (this.configShowed) { 36 | this.handleTitleClick(false) 37 | } 38 | }) 39 | }) 40 | } 41 | 42 | handleThemeIconClick = () => { 43 | this.themeSwitcher.switchTheme() 44 | } 45 | 46 | handleResizeIconClick = () => { 47 | this.fullscreen = !this.fullscreen 48 | this.updateFullscreen() 49 | } 50 | 51 | handleNotificationsIconClick = () => { 52 | this.notificationsEnabled = !this.notificationsEnabled 53 | this.updateNotifications() 54 | } 55 | 56 | handleTitleClick = overrideFlag => { 57 | if (typeof overrideFlag === 'boolean') { 58 | this.configShowed = overrideFlag 59 | } else { 60 | this.configShowed = !this.configShowed 61 | window.event.stopPropagation() 62 | } 63 | document.body.setAttribute('config', this.configShowed ? 'show' : 'hide') 64 | } 65 | 66 | handleKeyboardIconClick = () => { 67 | this.hotKeysEnabled = !this.hotKeysEnabled 68 | this.updateHotkeys() 69 | } 70 | 71 | updateNotifications() { 72 | toggleClass( 73 | getEl('.main-button.notifications'), 74 | 'active', 75 | this.notificationsEnabled 76 | ) 77 | if (this.notificationsEnabled) { 78 | localStorage.setItem('notifications', true) 79 | } else { 80 | delete localStorage.notifications 81 | $('.task.updated').removeClass('updated') 82 | } 83 | } 84 | 85 | updateFullscreen() { 86 | toggleClass(getEl('.main-button.resize'), 'active', this.fullscreen) 87 | 88 | if (this.fullscreen) { 89 | localStorage.setItem('fullscreen', true) 90 | document.body.setAttribute('fullscreen', 'true') 91 | } else { 92 | document.body.removeAttribute('fullscreen') 93 | delete localStorage.fullscreen 94 | } 95 | } 96 | 97 | updateHotkeys() { 98 | toggleClass(getEl('.main-button.hot-keys'), 'active', this.hotKeysEnabled) 99 | this.deps.hotkeys.triggerEnabled(this.hotKeysEnabled) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | flamebird 7 | 8 | 9 | 10 | 11 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 32 | flamebird 33 | 34 | 35 |
36 |
37 | 38 |
39 | 46 | 53 | 60 | 67 | 70 | 73 |
74 |
75 | 78 | 79 |
80 |
81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | const program = require('commander') 2 | const fs = require('fs') 3 | const _ = require('lodash') 4 | const path = require('path') 5 | const uuid = require('short-uuid') 6 | 7 | const memCache = require('./utils/mem_cache') 8 | const { getDataFromEnvFile } = require('./utils/envs') 9 | const taskfile = require('./taskfile') 10 | 11 | const RC_FILE_NAME = '.flamebirdrc' 12 | const RC_FILE_PATH = path.resolve(__dirname, `../${RC_FILE_NAME}`) 13 | const DEFAULT_CONFIG = { 14 | configs: [], 15 | } 16 | 17 | const getRC = () => { 18 | let config = {} 19 | try { 20 | config = JSON.parse(fs.readFileSync(RC_FILE_PATH)) 21 | if (!config) { 22 | throw new Error("Wrong flamebird rc file. Let's create new") 23 | } 24 | } catch (e) { 25 | config = DEFAULT_CONFIG 26 | updateRC(config) 27 | } 28 | return config 29 | } 30 | 31 | const updateRC = rc => { 32 | // console.log('updateRC', rc) 33 | fs.writeFileSync(RC_FILE_PATH, JSON.stringify(rc)) 34 | } 35 | 36 | const refreshRC = () => { 37 | console.log('refreshRC') 38 | const rc = getRC() 39 | memCache.set('rc-snapshot', rc) 40 | return rc 41 | } 42 | 43 | const createConfig = ( 44 | { 45 | ignoreTrs, 46 | name, 47 | port, 48 | tasks, 49 | taskRunner, 50 | package: useOnlyPackageJson, 51 | withoutBrowser, 52 | sortByName, 53 | }, 54 | isWeb 55 | ) => { 56 | const rc = getRC() 57 | 58 | console.log('createConfig') 59 | 60 | const config = { 61 | main: !rc.configs || !rc.configs.length, 62 | id: memCache.set('id', uuid.generate()), 63 | pid: process.pid, 64 | path: path.resolve(), 65 | withoutTaskRunner: !!ignoreTrs, 66 | name: name, 67 | port: +port, 68 | tasks: _.compact(_.split(tasks, /,/g)), 69 | taskRunner: taskRunner, 70 | useOnlyPackageJson: !!useOnlyPackageJson, 71 | web: !!isWeb, 72 | withoutBrowser: !!withoutBrowser, 73 | sortByName: !!sortByName, 74 | envs: getDataFromEnvFile(program.env), 75 | } 76 | 77 | if (config.web) { 78 | const rcSnapshot = memCache.set('rc-snapshot', { 79 | configs: [ 80 | ...rc.configs, 81 | _.merge(config, { 82 | commands: taskfile.load(config, program.procfile), 83 | }), 84 | ], 85 | }) 86 | updateRC({ 87 | configs: _.map(rcSnapshot.configs, config => ({ 88 | ...config, 89 | commands: _.map(config.commands, command => 90 | _.merge(command, { logs: [] }) 91 | ), 92 | })), 93 | }) 94 | } 95 | 96 | return config 97 | } 98 | 99 | const findConfig = findHandler => 100 | _.find(_.get(memCache.get('rc-snapshot'), 'configs', []), findHandler) || null 101 | 102 | const getConfig = id => (id ? findConfig({ id }) : getMainConfig()) 103 | 104 | const getMainConfig = () => findConfig({ main: true }) 105 | 106 | const isMainConfig = () => memCache.get('config').main 107 | 108 | const removeConfig = configId => { 109 | const rcSnapshot = memCache.get('rc-snapshot') 110 | const configIndex = rcSnapshot.configs.findIndex( 111 | config => configId === config.id 112 | ) 113 | if (configIndex > -1) rcSnapshot.configs.splice(configIndex, 1) 114 | 115 | memCache.set('rc-snapshot', rcSnapshot) 116 | updateRC(rcSnapshot) 117 | } 118 | 119 | module.exports = { 120 | getRC, 121 | refreshRC, 122 | getMainConfig, 123 | getConfig, 124 | isMainConfig, 125 | createConfig, 126 | removeConfig, 127 | updateRC, 128 | } 129 | -------------------------------------------------------------------------------- /client/helpers/dom_utils.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import _ from 'lodash' 3 | 4 | export const createButton = function( 5 | classes, 6 | icon, 7 | onClick, 8 | asJqElement, 9 | innerText 10 | ) { 11 | let button 12 | if (asJqElement) { 13 | button = $('') 14 | button.addClass(classes) 15 | if (icon) { 16 | const iconEl = $('') 17 | button.html(iconEl) 18 | } 19 | if (innerText) { 20 | button.append(window.createSpan('', innerText)) 21 | } 22 | if (onClick) { 23 | button.on('click', onClick) 24 | } 25 | } else { 26 | button = 27 | '' 35 | } 36 | 37 | return button 38 | } 39 | 40 | export const createSpan = function(classes, text) { 41 | return '' + text + '' 42 | } 43 | 44 | export const funcToStr = function(func) { 45 | const args = _.slice(arguments, 1) 46 | return ( 47 | func.name + 48 | '(' + 49 | _(args) 50 | .map(function(arg) { 51 | switch (typeof arg) { 52 | case 'string': 53 | return "'" + arg + "'" 54 | default: 55 | return arg 56 | } 57 | }) 58 | .join(',') + 59 | ')' 60 | ) 61 | } 62 | 63 | export const createDiv = function( 64 | classes, 65 | innerText, 66 | icon, 67 | id, 68 | onClick, 69 | asJqElement 70 | ) { 71 | let element = 72 | '
' + 78 | (icon ? '' : '') + 79 | (innerText || '') + 80 | '
' 81 | if (asJqElement) { 82 | element = $(element) 83 | if (onClick) { 84 | element.on('click', onClick) 85 | } 86 | } 87 | return element 88 | } 89 | 90 | export const createEnvsInput = function(key, value) { 91 | return ( 92 | '' 99 | ) 100 | } 101 | 102 | export const toggleClass = (element, className, isShow) => 103 | element && element.classList[isShow ? 'add' : 'remove'](className) 104 | 105 | export const addClass = (element, className) => 106 | element && element.classList.add(className) 107 | 108 | export const removeClass = (element, className) => 109 | element && element.classList.remove(className) 110 | 111 | export const el = (query, asList) => 112 | document[`querySelector${asList ? 'All' : ''}`](query) 113 | 114 | export const createEl = (tag, options) => { 115 | const element = document.createElement(tag) 116 | _.each(options, (option, name) => { 117 | if (name !== 'parent' && name !== 'children') element[name] = option 118 | }) 119 | if (options.parent) { 120 | const parentElement = _.isString(options.parent) 121 | ? el(options.parent) 122 | : options.parent 123 | parentElement.appendChild(element) 124 | } 125 | if (options.children && options.children) { 126 | _.each(options.children, child => { 127 | if (child) 128 | element.appendChild( 129 | typeof child === 'string' ? document.createTextNode(child) : child 130 | ) 131 | }) 132 | } 133 | return element 134 | } 135 | -------------------------------------------------------------------------------- /client/scripts/HotKeys.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import $ from 'jquery' 3 | 4 | const TASK_CHAR_CODES = [ 5 | 81, 6 | 87, 7 | 69, 8 | 82, 9 | 84, 10 | 89, 11 | 85, 12 | 73, 13 | 79, 14 | 80, 15 | 219, 16 | 221, 17 | 65, 18 | 83, 19 | 68, 20 | 70, 21 | 71, 22 | 72, 23 | 74, 24 | 75, 25 | 76, 26 | 186, 27 | 222, 28 | 90, 29 | 88, 30 | 67, 31 | 86, 32 | 66, 33 | 78, 34 | 77, 35 | 188, 36 | 190, 37 | 191, 38 | ] 39 | 40 | // const TASK_LIST_ELEMENTS_QUERY = '#task-list .task' 41 | 42 | const getTaskQueryByKeyCode = keyCode => $(`.task[char-code="${keyCode}"]`) 43 | 44 | export default class HotKeys { 45 | isEnabled = false 46 | taskCharCodes = TASK_CHAR_CODES 47 | 48 | actions = { 49 | tab: _.noop, 50 | arrowUp: _.noop, 51 | arrowDown: _.noop, 52 | del: _.noop, 53 | shiftA: _.noop, 54 | shiftS: _.noop, 55 | shiftR: _.noop, 56 | shiftArrowUp: _.noop, 57 | shiftArrowDown: _.noop, 58 | } 59 | 60 | keyCodeActions = { 61 | // [shift] key is not triggered 62 | 0: { 63 | 38: 'arrowUp', 64 | 40: 'arrowDown', 65 | 46: 'del', 66 | 9: 'tab', 67 | }, 68 | // [shift] key is triggered 69 | 1: { 70 | 38: 'shiftArrowUp', 71 | 40: 'shiftArrowDown', 72 | 65: 'shiftA', 73 | 82: 'shiftR', 74 | 83: 'shiftS', 75 | }, 76 | } 77 | 78 | clearifyEvent = event => { 79 | // event.keyCode = 0 80 | // event.ctrlKey = false 81 | // event.cancelBubble = true 82 | event.preventDefault() 83 | event.stopPropagation() 84 | } 85 | 86 | onKeyClick = event => { 87 | const { 88 | shiftKey, 89 | keyCode, 90 | target: { tagName }, 91 | } = event 92 | if (_.indexOf(['INPUT', 'TEXTAREA'], tagName) === -1) { 93 | if (shiftKey) { 94 | this.clearifyEvent(event) 95 | } 96 | const actionName = this.keyCodeActions[+shiftKey][keyCode] 97 | if (actionName) { 98 | this.actions[actionName](event) 99 | } else if (!shiftKey) { 100 | getTaskQueryByKeyCode(keyCode).trigger('click') 101 | } 102 | } 103 | } 104 | 105 | triggerEnabled(isEnabled) { 106 | this.isEnabled = !!isEnabled 107 | if (this.isEnabled) { 108 | this.connect() 109 | } else { 110 | this.disconnect() 111 | } 112 | } 113 | 114 | connect() { 115 | window.addEventListener('keydown', this.onKeyClick, false) 116 | localStorage.setItem('hotkeys', true) 117 | document.body.setAttribute('hotkeys', 'true') 118 | 119 | // setTimeout(() => { 120 | // _.each( 121 | // document.querySelectorAll(TASK_LIST_ELEMENTS_QUERY), 122 | // (element, keycodeIndex) => { 123 | // element.setAttribute('char-code', this.taskCharCodes[keycodeIndex]) 124 | // } 125 | // ) 126 | // }) 127 | } 128 | 129 | disconnect() { 130 | window.removeEventListener('keydown', this.onKeyClick, false) 131 | delete localStorage.hotkeys 132 | document.body.removeAttribute('hotkeys') 133 | // setTimeout(() => { 134 | // _.each(document.querySelectorAll(TASK_LIST_ELEMENTS_QUERY), element => { 135 | // element.removeAttribute('char-code') 136 | // }) 137 | // }) 138 | } 139 | 140 | connectTaskButton = (taskEl, index) => { 141 | taskEl.setAttribute('char-code', this.taskCharCodes[index]) 142 | } 143 | 144 | constructor({ 145 | tab, 146 | arrowUp, 147 | arrowDown, 148 | del, 149 | shiftA, 150 | shiftS, 151 | shiftR, 152 | shiftArrowUp, 153 | shiftArrowDown, 154 | } = {}) { 155 | // this.triggerEnabled(true) 156 | 157 | _.each( 158 | { 159 | tab, 160 | arrowUp, 161 | arrowDown, 162 | del, 163 | shiftA, 164 | shiftS, 165 | shiftR, 166 | shiftArrowUp, 167 | shiftArrowDown, 168 | }, 169 | (handler, handlerName) => { 170 | this.actions[handlerName] = handler 171 | } 172 | ) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flamebird", 3 | "version": "3.0.0-alpha", 4 | "description": "wonderful task manager for Procfile-based and npm-based applications", 5 | "main": "flamebird.js", 6 | "scripts": { 7 | "test": "echo \"No tests\" && exit 0", 8 | "clean": "rimraf dist", 9 | "lint-js": "eslint .", 10 | "lint": "npm run lint-js", 11 | "lint:fix": "npm run lint -- --fix", 12 | "build": "npm run clean && npm run client", 13 | "postinstall": "node postinstall.js", 14 | "server": "node flamebird.js web -p 7733 -w", 15 | "server:dev": "cross-env NODE_ENV=development npm run server", 16 | "client": "webpack --config webpack.config.js -p", 17 | "client:dev": "cross-env NODE_ENV=development npm run client", 18 | "debug": "nodemon --exec \"npm run client:dev\" --watch client -V" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/acacode/flamebird.git" 23 | }, 24 | "keywords": [ 25 | "task", 26 | "tasks", 27 | "procfile", 28 | "taskmanager", 29 | "taskrunner", 30 | "foreman", 31 | "node-foreman", 32 | "npm-package", 33 | "js", 34 | "task-manager", 35 | "task-sheduler", 36 | "job-sheduler", 37 | "nodejs-utility", 38 | "task-utility", 39 | "node-process", 40 | "process-manager", 41 | "manager", 42 | "process-runner", 43 | "process-run" 44 | ], 45 | "author": "acacode", 46 | "bin": { 47 | "flamebird": "flamebird.js", 48 | "fb": "flamebird.js" 49 | }, 50 | "license": "MIT", 51 | "dependencies": { 52 | "@babel/runtime": "^7.5.5", 53 | "ansi_up": "^4.0.4", 54 | "body-parser": "^1.19.0", 55 | "colors": "^1.3.3", 56 | "commander": "^2.20.0", 57 | "express": "^4.17.1", 58 | "grunt": "^1.0.4", 59 | "grunt-available-tasks": "^0.6.3", 60 | "gulp-task-listing": "^1.1.0", 61 | "jquery": "^3.4.1", 62 | "kinka": "^2.5.6", 63 | "lodash": "^4.17.15", 64 | "moment": "^2.24.0", 65 | "opn": "^6.0.0", 66 | "redom": "^3.23.1", 67 | "short-uuid": "^3.1.1", 68 | "tree-kill": "^1.2.1", 69 | "ws": "^7.0.1" 70 | }, 71 | "devDependencies": { 72 | "@babel/core": "^7.4.5", 73 | "@babel/plugin-proposal-class-properties": "^7.5.5", 74 | "@babel/plugin-proposal-export-default-from": "^7.5.2", 75 | "@babel/plugin-proposal-export-namespace-from": "^7.5.2", 76 | "@babel/plugin-proposal-object-rest-spread": "^7.5.5", 77 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 78 | "@babel/plugin-transform-runtime": "^7.5.5", 79 | "@babel/preset-env": "^7.5.5", 80 | "@babel/preset-stage-0": "^7.0.0", 81 | "ajv": "^6.10.0", 82 | "babel-eslint": "^10.0.2", 83 | "babel-loader": "^8.0.6", 84 | "babel-plugin-import": "^1.12.0", 85 | "babel-plugin-transform-class-properties": "^6.24.1", 86 | "babel-plugin-transform-export-extensions": "^6.22.0", 87 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 88 | "babel-polyfill": "^6.26.0", 89 | "babel-runtime": "^6.26.0", 90 | "copy-webpack-plugin": "^5.0.3", 91 | "cross-env": "^5.2.0", 92 | "css-loader": "1.0.0", 93 | "dree": "^2.1.10", 94 | "eslint": "^6.2.2", 95 | "eslint-config-prettier": "^6.1.0", 96 | "eslint-config-standard": "^14.0.1", 97 | "eslint-plugin-import": "^2.18.2", 98 | "eslint-plugin-node": "^9.1.0", 99 | "eslint-plugin-prettier": "^3.1.0", 100 | "eslint-plugin-promise": "^4.2.1", 101 | "eslint-plugin-standard": "^4.0.1", 102 | "extract-loader": "^3.1.0", 103 | "file-loader": "^4.2.0", 104 | "handlebars-loader": "^1.7.1", 105 | "html-loader": "^0.5.5", 106 | "html-minifier-webpack-plugin": "^2.2.1", 107 | "html-webpack-plugin": "^3.2.0", 108 | "jimp": "^0.6.4", 109 | "mini-css-extract-plugin": "^0.7.0", 110 | "nodemon": "^1.19.1", 111 | "optimize-css-assets-webpack-plugin": "^5.0.3", 112 | "prettier": "^1.18.2", 113 | "raw-loader": "^3.1.0", 114 | "responsive-loader": "^1.2.0", 115 | "rimraf": "^2.7.1", 116 | "style-ext-html-webpack-plugin": "^4.1.2", 117 | "style-loader": "^0.23.1", 118 | "terser-webpack-plugin": "^1.4.1", 119 | "url-loader": "^2.1.0", 120 | "webpack": "^4.35.0", 121 | "webpack-cli": "^3.3.5" 122 | }, 123 | "directories": { 124 | "server": "server" 125 | }, 126 | "files": [ 127 | "dist", 128 | "flamebird.js", 129 | "LICENSE", 130 | "postinstall.js", 131 | "README.md", 132 | "server" 133 | ], 134 | "bugs": { 135 | "url": "https://github.com/acacode/flamebird/issues" 136 | }, 137 | "homepage": "https://github.com/acacode/flamebird" 138 | } 139 | -------------------------------------------------------------------------------- /server/taskfile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const resolve = require('path').resolve 3 | const _ = require('lodash') 4 | const commands = require('./utils/commands') 5 | const { TASK_RUNNERS } = require('./constants') 6 | 7 | let LIB_PATHS = null 8 | 9 | const createLibPaths = () => { 10 | const [utilFormat, utilDir] = 11 | process.platform === 'win32' 12 | ? ['.cmd', 'node_modules\\.bin\\'] 13 | : ['', 'node_modules/.bin/'] 14 | LIB_PATHS = _.reduce( 15 | fs 16 | .readdirSync(resolve('node_modules/.bin')) 17 | .filter(file => !file.match(/.*\.cmd$/)), 18 | (bin, name) => (bin[name] = `${utilDir}${name}${utilFormat}`) && bin, 19 | {} 20 | ) 21 | } 22 | 23 | /** 24 | * load task files (package.json or Procfile) 25 | * @param {object} config 26 | * @param {string} procfilePath 27 | */ 28 | function load(config, procfilePath) { 29 | const allCommands = [] 30 | if (config.web || config.useOnlyPackageJson) { 31 | try { 32 | const packageJson = JSON.parse(fs.readFileSync('package.json').toString()) 33 | config.name = packageJson.name || 'flamebird' 34 | _.forEach(packageJson.scripts, (command, taskName) => { 35 | if (!config.tasks.length || _.includes(config.tasks, taskName)) { 36 | allCommands.push( 37 | commands.createCommand(config.id, taskName, command, 'npm') 38 | ) 39 | } 40 | }) 41 | } catch (e) { 42 | console.warn('package.json not found') 43 | } 44 | } 45 | if (config.web || !config.useOnlyPackageJson) { 46 | try { 47 | const data = fs.readFileSync(procfilePath) 48 | _.each(_.compact(data.toString().split(/[\n\r]/g)), (line, i) => { 49 | if (line && line[0] !== '#') { 50 | const tuple = /^([A-Za-z0-9_-]+):\s*(.+)$/m.exec(line) 51 | const name = tuple[1].trim() 52 | const task = tuple[2].trim() 53 | if (!name || !task) { 54 | throw new Error( 55 | 'Syntax Error in Procfile, Line %d: No ' + 56 | (!tuple ? 'Procfile' : 'Command') + 57 | ' Found' 58 | ) 59 | } 60 | if (!config.tasks.length || _.includes(config.tasks, name)) { 61 | allCommands.push( 62 | commands.createCommand(config.id, name, task, 'procfile') 63 | ) 64 | } 65 | } 66 | }) 67 | } catch (e) { 68 | console.warn('Procfile not found') 69 | } 70 | } 71 | console.log('allCommands', allCommands) 72 | return updateCommands(config, allCommands) 73 | } 74 | 75 | function setAbsolutePathsToTask(command, commandsWithoutPms) { 76 | const spaceChar = ' ' 77 | const fixedTaskData = [] 78 | const words = command.task.split(spaceChar) 79 | for (let x = 0; x < words.length; ) { 80 | const word = words[x] 81 | if (word === 'npm' || word === 'yarn') { 82 | const nextWordIsRun = words[x + 1] === 'run' 83 | const updatedCommand = 84 | commandsWithoutPms[words[x + (nextWordIsRun ? 2 : 1)]] 85 | if (updatedCommand) { 86 | fixedTaskData.push(updatedCommand) 87 | x += 2 88 | } else { 89 | fixedTaskData.push(word) 90 | x++ 91 | } 92 | } else { 93 | fixedTaskData.push(LIB_PATHS[word] || word) 94 | x++ 95 | } 96 | } 97 | return (command.rawTask = fixedTaskData.join(spaceChar)) 98 | } 99 | 100 | function updateCommands(config, commands) { 101 | if (config.withoutTaskRunner) { 102 | if (LIB_PATHS === null) createLibPaths() 103 | _.reduce( 104 | _.sortBy( 105 | commands, 106 | ({ task }) => _.includes(task, 'yarn') || _.includes(task, 'npm') 107 | ), 108 | (commandsWithoutPms, command) => { 109 | // TODO: fix problem with displaying cross-env in .task property 110 | command.task = _.replace(command.task, 'cross-env ', '') 111 | commandsWithoutPms[command.name] = setAbsolutePathsToTask( 112 | command, 113 | commandsWithoutPms 114 | ) 115 | return commandsWithoutPms 116 | }, 117 | {} 118 | ) 119 | } else { 120 | const taskRunner = 121 | TASK_RUNNERS[`${config.taskRunner}`.toUpperCase()] || config.taskRunner 122 | _.each(commands, command => { 123 | command.rawTask = 124 | command.type === 'procfile' 125 | ? command.task 126 | : `${taskRunner || command.type} ${command.name}` 127 | }) 128 | } 129 | 130 | console.log('update Commands -> commands', commands) 131 | return config.sortByName ? _.sortBy(commands, 'name', 'asc') : commands 132 | } 133 | 134 | module.exports.load = load 135 | -------------------------------------------------------------------------------- /client/scripts/WebLogger.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import AnsiUp from 'ansi_up' 3 | import _ from 'lodash' 4 | import { 5 | createSpan, 6 | toggleClass, 7 | createEl, 8 | createButton, 9 | createEnvsInput, 10 | } from '../helpers/dom_utils' 11 | 12 | export default class WebLogger { 13 | watchTaskLogsScrollTop = true 14 | autoScrollButton 15 | loggerId 16 | element 17 | ansiUp 18 | 19 | scrollTo(direction, animate, scrollPixels) { 20 | const scrollTop = scrollPixels 21 | ? $(this.element).scrollTop() 22 | : direction === 'bottom' 23 | ? this.element.scrollHeight 24 | : 0 25 | if (scrollPixels) { 26 | $(this.element).scrollTop( 27 | direction === 'bottom' 28 | ? scrollTop - scrollPixels 29 | : scrollTop + scrollPixels 30 | ) 31 | } else if (animate) { 32 | $(this.element).animate({ scrollTop }, animate) 33 | } else { 34 | this.element.scrollTop = scrollTop 35 | } 36 | } 37 | 38 | createHTMLLog = logData => { 39 | const log = this.ansiUp.ansi_to_html( 40 | logData.replace(/\n/g, '
').replace(/ /g, ' ') 41 | ) 42 | if (log.includes('Warning:')) { 43 | return createSpan('ansi-yellow-fg', log) 44 | } 45 | if (log.includes('Exited Successfully')) { 46 | return createSpan('ended ok', log) 47 | } 48 | if (log.includes('Exited with exit code ')) { 49 | return createSpan('ended', log) 50 | } 51 | return log 52 | .replace(/(.<\/span>)/g, () => 53 | createSpan('ansi-bright-black-fg mocha-test', '.') 54 | ) 55 | .replace(/(!<\/span>)/g, () => 56 | createSpan('ansi-red-fg mocha-test', '!') 57 | ) 58 | } 59 | 60 | push(log, isRaw) { 61 | this.element.insertAdjacentHTML( 62 | 'beforeend', 63 | isRaw ? log : this.createHTMLLog(log) 64 | ) 65 | } 66 | 67 | clear() { 68 | while (this.element.lastChild) { 69 | this.element.removeChild(this.element.lastChild) 70 | } 71 | } 72 | 73 | updateEnvs(envs) { 74 | if (_.keys(envs).length) { 75 | const container = document.createElement('div') 76 | container.classList.add('envs-log') 77 | container.innerHTML = 78 | _.map( 79 | envs, 80 | (value, key) => 81 | `${createSpan('ansi-bright-magenta-fg', key)}=${createEnvsInput( 82 | key, 83 | value 84 | )}` 85 | ).join(', ') + 86 | createButton('logs-button', 'edit', 'global.enableEnvsForm()') + 87 | createButton('logs-button cancel', 'times', 'global.cancelEnvs()') + 88 | createButton('logs-button apply', 'check', 'global.updateEnvs()') 89 | this.element.appendChild(container) 90 | } 91 | } 92 | 93 | updateDescription(description) { 94 | const container = createEl('div', { className: 'task-data' }) 95 | const span = createEl('span', { innerText: description }) 96 | container.appendChild(span) 97 | this.element.appendChild(container) 98 | } 99 | 100 | onScroll() { 101 | toggleClass(this, 'scrolling', this.scrollTop > 70) 102 | } 103 | 104 | triggerScrollWatcher = () => { 105 | this.scrollTo('bottom', '1500') 106 | this.watchTaskLogsScrollTop = !this.watchTaskLogsScrollTop 107 | toggleClass(this.autoScrollButton, 'active', this.watchTaskLogsScrollTop) 108 | } 109 | 110 | createScrollActions(parent) { 111 | const scrollActions = createEl('div', { 112 | className: 'scroll-actions', 113 | parent, 114 | }) 115 | createEl('button', { 116 | className: 'logs-button scroll-top', 117 | onclick: () => this.scrollTo('top', '500'), 118 | innerHTML: '', 119 | parent: scrollActions, 120 | }) 121 | createEl('button', { 122 | className: 'logs-button clear', 123 | onclick: () => window.global.clearLogs(this.loggerId), 124 | innerHTML: '', 125 | parent: scrollActions, 126 | }) 127 | this.autoScrollButton = createEl('button', { 128 | className: `logs-button autoscroll ${ 129 | this.watchTaskLogsScrollTop ? 'active' : '' 130 | }`, 131 | onclick: () => this.triggerScrollWatcher(), 132 | innerHTML: '', 133 | parent: scrollActions, 134 | }) 135 | } 136 | 137 | constructor(logsContainer, id) { 138 | this.loggerId = id 139 | const wrapper = createEl('div', { 140 | className: 'logs-wrapper', 141 | parent: logsContainer, 142 | }) 143 | this.createScrollActions(wrapper) 144 | this.element = createEl('div', { 145 | className: 'logs-container', 146 | parent: wrapper, 147 | onscroll: this.onScroll, 148 | }) 149 | this.ansiUp = new AnsiUp() 150 | this.ansiUp.use_classes = true 151 | this.ansiUp.escape_for_html = false 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-path-concat */ 2 | 3 | const http = require('http') 4 | const path = require('path') 5 | const express = require('express') 6 | const bodyParser = require('body-parser') 7 | const opn = require('opn') 8 | const os = require('os') 9 | const _ = require('lodash') 10 | const memCache = require('./utils/mem_cache') 11 | const fs = require('fs') 12 | const pw = require('./processWorker') 13 | const { getCommandById } = require('./utils/commands') 14 | const { PATHS, MESSAGE_TYPES } = require('./constants') 15 | const kill = require('tree-kill') 16 | const { createWSConnection, sendMessage } = require('./ws') 17 | const { getMainConfig, getRC, updateRC, removeConfig } = require('./config') 18 | 19 | const rootPath = path.resolve(__dirname, PATHS.WEB_APP_ROOT) 20 | 21 | function start(config) { 22 | console.log('Initializing web server') 23 | 24 | const app = express() 25 | 26 | app.use(bodyParser.json()) 27 | app.use(express.static(rootPath)) 28 | 29 | app.get('/', (req, res) => res.sendFile(rootPath)) 30 | 31 | app.get('/info', (req, res) => { 32 | const rcSnapshot = memCache.get('rc-snapshot') 33 | 34 | console.log('/info -> rcSnapshot', rcSnapshot) 35 | 36 | res.send({ 37 | ...rcSnapshot, 38 | configs: _.map(rcSnapshot.configs, config => ({ 39 | ..._.omit(config, 'pid', 'path'), 40 | commands: _.map(config.commands, command => ({ 41 | ...command, 42 | logs: [], 43 | })), 44 | })), 45 | }) 46 | }) 47 | 48 | app.post('/refresh-rc-configs', (req, res) => { 49 | const freshRC = getRC() 50 | const rcSnapshot = memCache.get('rc-snapshot') 51 | const newConfig = freshRC.configs[freshRC.configs.length - 1] 52 | memCache.set('rc-snapshot', { 53 | configs: [...rcSnapshot.configs, newConfig], 54 | }) 55 | 56 | kill(newConfig.pid, 'SIGINT') 57 | sendMessage(MESSAGE_TYPES.APPS_LIST_UPDATE, { ok: true }) 58 | res.send('ok') 59 | return null 60 | }) 61 | 62 | app.post('/:configId/:taskId/envs', (req, res) => { 63 | const { taskId, configId } = req.params 64 | const envs = req.body 65 | 66 | const currentCommand = getCommandById(configId, taskId) 67 | currentCommand.logs = [] 68 | currentCommand.envs = envs 69 | 70 | pw.reRun(currentCommand) 71 | 72 | res.send('ok') 73 | }) 74 | 75 | app.delete('/:configId', (req, res) => { 76 | const { configId } = req.params 77 | removeConfig(configId) 78 | 79 | res.send('ok') 80 | }) 81 | 82 | app.delete('/:configId/:taskId/logs', (req, res) => { 83 | const { taskId, configId } = req.params 84 | 85 | getCommandById(configId, taskId).logs = [] 86 | 87 | res.send('ok') 88 | }) 89 | 90 | app.get('/:configId/:taskId/logs', (req, res) => { 91 | const { taskId, configId } = req.params 92 | 93 | res.send(getCommandById(configId, taskId).logs) 94 | }) 95 | 96 | app.get('/project-version', (req, res) => { 97 | try { 98 | const packageJson = JSON.parse(fs.readFileSync('package.json').toString()) 99 | res.send(packageJson.version || null) 100 | } catch (e) { 101 | res.send(null) 102 | } 103 | }) 104 | 105 | app.post('/:configId/:taskId/run', (req, res) => { 106 | const { taskId, configId } = req.params 107 | 108 | const command = getCommandById(configId, taskId) 109 | 110 | pw.run(command) 111 | 112 | res.send('ok') 113 | }) 114 | 115 | app.post('/:configId/:taskId/stop', (req, res) => { 116 | const { taskId, configId } = req.params 117 | 118 | const command = getCommandById(configId, taskId) 119 | 120 | pw.stop(command) 121 | 122 | res.send('ok') 123 | }) 124 | 125 | const server = http.createServer(app) 126 | 127 | createWSConnection(server) 128 | 129 | server.listen(config.port, '0.0.0.0', () => { 130 | console.log(`Flamebird launched on port ${config.port}`) 131 | if (!config.withoutBrowser) 132 | try { 133 | opn('http://localhost:' + config.port, { 134 | app: os.platform() === 'win32' ? 'chrome' : 'google-chrome', 135 | }) 136 | } catch (e) { 137 | console.error('Cannot open Google Chrome browser') 138 | } 139 | }) 140 | } 141 | 142 | const update = config => { 143 | console.log('Try to connect to main flamebird process') 144 | 145 | const setConfigAsMain = () => { 146 | console.log('setConfigAsMain') 147 | updateRC( 148 | memCache.set('rc-snapshot', { 149 | configs: [ 150 | _.merge(config, { 151 | main: true, 152 | }), 153 | ], 154 | }) 155 | ) 156 | start(config) 157 | } 158 | 159 | const mainConfig = getMainConfig() 160 | 161 | if (!mainConfig) { 162 | setConfigAsMain() 163 | return 164 | } 165 | 166 | process.once('uncaughtException', setConfigAsMain) 167 | 168 | http 169 | .request( 170 | { 171 | timeout: 5000, 172 | host: '127.0.0.1', 173 | path: `/refresh-rc-configs`, 174 | port: mainConfig.port, 175 | method: 'POST', 176 | }, 177 | res => { 178 | res.resume() 179 | res.on('error', err => { 180 | console.log('TODO', err) 181 | }) 182 | res.on('end', () => { 183 | console.log('Successfully connected') 184 | process.exit(0) 185 | }) 186 | } 187 | ) 188 | .end() 189 | } 190 | 191 | module.exports = { 192 | start, 193 | update, 194 | } 195 | -------------------------------------------------------------------------------- /flamebird.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Copyright IBM Corp. 2012,2015. All Rights Reserved. 4 | // Node module: flamebird 5 | // This file is licensed under the MIT License. 6 | // License text available at https://opensource.org/licenses/MIT 7 | 8 | const program = require('commander') 9 | const emitter = require('./server/utils/emitter') 10 | const { createConfig } = require('./server/config') 11 | const { yellow, red, grey, cyan } = require('./server/utils/colors') 12 | const processWorker = require('./server/processWorker') 13 | const server = require('./server/server') 14 | 15 | process.title = 'flamebird (nodejs task manager)' 16 | 17 | /* special properties for colorizing output logs */ 18 | process.env.FORCE_COLOR = true 19 | process.env.colors = true 20 | process.env.color = true 21 | 22 | process.once('SIGINT', function() { 23 | emitter.emit('killall', 'SIGINT') 24 | process.exit() 25 | }) 26 | 27 | // Kill All Child Processes & Exit on SIGTERM 28 | process.once('SIGTERM', function() { 29 | emitter.emit('killall', 'SIGTERM') 30 | process.exit() 31 | }) 32 | 33 | program.on('--help', function() { 34 | console.log('\r\nExamples:') 35 | console.log( 36 | ' ' + 37 | grey('$') + 38 | ' fb start -p -t start:dev,server:dev ' + 39 | '- launch commands "start:dev" and "server:dev" (-t start:dev,server:dev) from package.json (-p)\r\n' + 40 | ' ' + 41 | grey('$') + 42 | ' fb start ' + 43 | '- launch all commands from Procfile and output logs to this command line\r\n' + 44 | ' ' + 45 | grey('$') + 46 | ' fb web ' + 47 | '- launch web GUI which have contained all possible commands from package.json etc.\r\n' + 48 | ' ' + 49 | grey('$') + 50 | ' fb web -w -u yarn ' + 51 | '- launch web GUI without opening new tab in the browser (-w) and will use another package manager (-u yarn) for launching commands from package.json\r\n' + 52 | ' ' + 53 | grey('$') + 54 | ' fb web -i ' + 55 | '- launch web GUI which will launch tasks without yarn or npm (using absolute paths: webpack -> node_modules/.bin/webpack) (-i)' 56 | ) 57 | }) 58 | 59 | program.version(getLogo(true), '-v, --version') 60 | program.usage('[command] [options]') 61 | 62 | program.option('-f, --procfile ', 'load procfile from file', 'Procfile') 63 | program.option( 64 | '-e, --env ', 65 | 'load environment from file, a comma-separated list', 66 | '.env' 67 | ) 68 | 69 | program 70 | .command('start') 71 | .usage('[Options]') 72 | .option('-p, --package', 'Use package.json for managing tasks') 73 | .option( 74 | '-i, --ignore-trs', 75 | 'Allows to launch tasks without yarn or npm ( using absolute paths: webpack -> node_modules/.bin/webpack )', 76 | false 77 | ) 78 | .option( 79 | '-t, --tasks [tasks]', 80 | 'List of tasks which will be run flamebird ( example : --tasks start,start:dev,start-server )' 81 | ) 82 | .option( 83 | '-r, --task-runner ', 84 | `Allows to use another task runner for launch tasks. By default will use npm ( For example: -r yarn )`, 85 | 'npm' 86 | ) 87 | .description( 88 | 'Launch commands from Procfile/package.json and output logs in the current command line' 89 | ) 90 | .action(args => { 91 | const config = createConfig(args) 92 | processWorker.runAll(config.commands) 93 | }) 94 | 95 | program 96 | .command('web') 97 | .usage('[Options]') 98 | .option('-p, --port ', 'sets the server port', 5050) 99 | .option( 100 | '-i, --ignore-trs', 101 | 'Allows to launch tasks without yarn or npm ( use absolute paths: webpack -> node_modules/.bin/webpack )', 102 | false 103 | ) 104 | .option( 105 | '-n, --name ', 106 | 'Sets the project name. By default takes from "package.json" else "flamebird"', 107 | 'flamebird' 108 | ) 109 | .option( 110 | '-t, --tasks [tasks]', 111 | 'List of tasks which will be run flamebird ( example : --tasks start,start:dev,start-server )' 112 | ) 113 | .option( 114 | '-r, --task-runner ', 115 | `Allows to use another task runner for launch tasks. By default will use npm ( For example: -r yarn )`, 116 | 'npm' 117 | ) 118 | .option( 119 | '-w, --without-browser', 120 | 'This option disable opening the new tab in Google Chrome browser', 121 | false 122 | ) 123 | .option( 124 | '-s, --sort-by-name', 125 | 'This option using to sort all commands by name (asc)', 126 | false 127 | ) 128 | .description( 129 | 'Launch ' + 130 | cyan('web application') + 131 | ' which will help to manage all commands from package.json/Procfile/Grunt/Gulp' 132 | ) 133 | .action(args => { 134 | const config = createConfig(args, true) 135 | if (config.main) server.start(config) 136 | else server.update(config) 137 | }) 138 | 139 | program.parse(process.argv) 140 | 141 | if (!process.argv.slice(2).length) { 142 | console.log(getLogo()) 143 | program.outputHelp() 144 | } 145 | 146 | function getLogo(displayOnlyVersion) { 147 | const { version } = require('./package.json') 148 | const strings = [] 149 | if (!displayOnlyVersion) { 150 | strings.push(yellow(' ╔══╗ ╔╗ ╔══╗ ╔╗ ╔╗ ╔═══╗ ╔══╗ ╔══╗ ╔═══╗ ╔══╗ ')) 151 | strings.push( 152 | red(' ║╔═╝') + 153 | yellow(' ║║ ║╔╗║ ║║ ║║ ║╔══╝ ║╔╗║ ╚╗╔╝ ║╔═╗║ ') + 154 | red('║╔╗╚╗ ') 155 | ) 156 | strings.push( 157 | red(' ║╚═╗ ') + 158 | yellow('║║ ║╚╝║ ║╚╗╔╝║ ║╚══╗ ║╚╝╚╗ ║║ ║╚═╝║') + 159 | red(' ║║╚╗║ ') 160 | ) 161 | strings.push( 162 | red(' ║╔═╝ ║║ ║╔╗║') + 163 | yellow(' ║╔╗╔╗║ ║╔══╝ ║╔═╗║ ') + 164 | red('║║ ║╔╗╔╝ ║║ ║║ ') 165 | ) 166 | strings.push( 167 | red(' ║║ ║╚═╗ ║║║║ ') + 168 | yellow('║║╚╝║║ ║╚══╗ ║╚═╝║') + 169 | red(' ╔╝╚╗ ║║║║ ║╚═╝║ ') 170 | ) 171 | strings.push( 172 | red(' ╚╝ ╚══╝ ╚╝╚╝ ╚╝ ╚╝ ') + 173 | yellow('╚═══╝') + 174 | red(' ╚═══╝ ╚══╝ ╚╝╚╝ ╚═══╝ ') 175 | ) 176 | strings.push( 177 | ' ' + grey(' - wonderful nodejs task manager ') + ' ' 178 | ) 179 | } 180 | const v = 181 | version + 182 | new Array(version.length >= 10 ? 10 : version.length - version.length).join( 183 | ' ' 184 | ) 185 | const commonSpace = displayOnlyVersion ? ' ' : ' ' 186 | strings.push(commonSpace + red('╔═══════════════╗ ')) 187 | strings.push(commonSpace + red('║') + yellow(' v' + v + ' ') + red('║ ')) 188 | strings.push(commonSpace + red('╚═══════════════╝ ') + '\r\n') 189 | return strings.join('\r\n') 190 | } 191 | -------------------------------------------------------------------------------- /client/scripts/TaskList.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import Tabs, { PRIORITY_TAB } from './Tabs' 3 | // import HotKeys from './HotKeys' 4 | import { toggleClass, addClass, removeClass, el } from '../helpers/dom_utils' 5 | import { createTab } from '../helpers/tabs' 6 | import WindowAttached from '../helpers/WindowAttached' 7 | import { createTaskElement } from '../helpers/taskList' 8 | 9 | const TASK_CLASSES = { 10 | ACTIVE: 'active', 11 | STOPPING: 'stopping', 12 | RUNNING: 'running', 13 | LAUNCHING: 'clicked', 14 | UPDATED: 'updated', 15 | } 16 | 17 | export default class TaskList extends WindowAttached('taskList') { 18 | element 19 | taskList = {} 20 | activeTaskByTab = {} 21 | notifyTaskTimers = {} 22 | 23 | constructor( 24 | taskListContainer, 25 | tasks, 26 | { onOpenTask, onRunTask, onStopTask, onCreateTaskEl } = {} 27 | ) { 28 | super() 29 | this.onOpenTask = onOpenTask || _.noop 30 | this.onRunTask = onRunTask || _.noop 31 | this.onStopTask = onStopTask || _.noop 32 | this.onStopTask = onStopTask || _.noop 33 | this.onCreateTaskEl = onCreateTaskEl || _.noop 34 | 35 | this.element = taskListContainer 36 | 37 | Tabs.listenChanges(this.handleTabChange) 38 | this.initalizeTasks(tasks) 39 | } 40 | 41 | initalizeTasks = tasks => { 42 | this.element.innerHTML = '' 43 | 44 | this.taskList = {} 45 | this.activeTaskByTab = {} 46 | 47 | this.taskList = _.reduce( 48 | tasks, 49 | (taskList, task) => { 50 | if (!taskList[task.type]) { 51 | taskList[task.type] = [] 52 | } 53 | taskList[task.type].push(task) 54 | return taskList 55 | }, 56 | // TODO: probably we can use just this.taskList here 57 | this.taskList 58 | ) 59 | 60 | const tabs = _.map(_.keys(this.taskList), tabName => { 61 | this.activeTaskByTab[tabName] = {} 62 | return createTab(tabName, tabName === PRIORITY_TAB.name) 63 | }) 64 | 65 | Tabs.createTabs(tabs) 66 | 67 | let activeTab = null 68 | 69 | for (let x = 0; x < tabs.length; x++) { 70 | const tab = tabs[x] 71 | if (tab) { 72 | const tasks = this.getTasksByTab(tab) 73 | if (tasks && tasks.length) { 74 | this.activeTaskByTab[tab.name] = [tasks[0].id] 75 | if (!activeTab || tab.name === PRIORITY_TAB.name) { 76 | activeTab = tab 77 | } 78 | } else { 79 | delete this.taskList[tab.name] 80 | delete this.activeTaskByTab[tab.name] 81 | Tabs.removeTab(tab) 82 | x-- 83 | } 84 | } 85 | } 86 | 87 | if (activeTab) Tabs.setActive(activeTab.name) 88 | } 89 | 90 | handleTabChange = name => { 91 | this.clear() 92 | this.updateTaskList(name) 93 | 94 | setTimeout(() => { 95 | const { name: currentName } = Tabs.getActive() 96 | if (name !== currentName) { 97 | name = currentName 98 | } 99 | if (this.activeTaskByTab[name].length) { 100 | el(`#${this.activeTaskByTab[name][0]}`).click() 101 | } else { 102 | global.getLogger().clear() 103 | } 104 | }, 0) 105 | } 106 | 107 | updateTaskList(tabName) { 108 | _.forEach(this.taskList[tabName], (task, index) => { 109 | // TODO: ??? 110 | // if (HotKeys.isEnabled) { 111 | // HotKeys.connect(task, index) 112 | // } 113 | const taskEl = createTaskElement(task, index, { 114 | onOpenTask: () => this.onOpenTask(task), 115 | onRunTask: () => this.onRunTask(task), 116 | onStopTask: () => this.onStopTask(task), 117 | }) 118 | 119 | this.onCreateTaskEl(taskEl, index) 120 | 121 | this.element.appendChild(taskEl) 122 | }) 123 | } 124 | 125 | getTask = id => _.find(_.concat.apply(null, _.values(this.taskList)), { id }) 126 | 127 | updateTask(id, { isRun, isActive, isLaunching, isStopping }) { 128 | const task = this.getTask(id) 129 | 130 | // TODO: fix task as undefined 131 | 132 | const taskElem = document.getElementById(task.id) 133 | task.isRun = isRun 134 | task.isLaunching = !!isLaunching 135 | task.isStopping = !!isStopping 136 | task.isActive = !!isActive 137 | 138 | if (task.isActive) { 139 | const prevActiveTask = document.querySelector( 140 | `.task.${TASK_CLASSES.ACTIVE}` 141 | ) 142 | if (prevActiveTask) { 143 | const prevTaskId = this.activeTaskByTab[task.type][0] 144 | if (prevTaskId !== task.id) { 145 | this.getTask(prevTaskId).isActive = false 146 | removeClass(prevActiveTask, TASK_CLASSES.ACTIVE) 147 | } 148 | } 149 | this.activeTaskByTab[task.type] = [task.id] 150 | addClass(taskElem, TASK_CLASSES.ACTIVE) 151 | } 152 | toggleClass(taskElem, TASK_CLASSES.STOPPING, isStopping) 153 | toggleClass(taskElem, TASK_CLASSES.RUNNING, isRun) 154 | toggleClass(taskElem, TASK_CLASSES.LAUNCHING, isLaunching) 155 | return task 156 | } 157 | 158 | clear() { 159 | while (this.element.lastChild) 160 | this.element.removeChild(this.element.lastChild) 161 | } 162 | 163 | setActive(task, isLaunching, isStopping) { 164 | this.updateTask(task.id, { 165 | isRun: task.isRun, 166 | isActive: true, 167 | isLaunching: isLaunching === undefined ? task.isLaunching : isLaunching, 168 | isStopping: isStopping === undefined ? task.isStopping : isStopping, 169 | }) 170 | } 171 | 172 | getTasksByTab = tab => { 173 | const tasks = this.taskList[tab.name] 174 | return tasks && tasks.length && tasks 175 | } 176 | 177 | getAllFromActiveTab(filter) { 178 | const tasks = this.getTasksByTab(Tabs.getActive()) 179 | return filter ? _.filter(tasks, filter) : tasks 180 | } 181 | 182 | getActive = () => this.getTask(this.activeTaskByTab[Tabs.getActive().name][0]) 183 | 184 | notifyAboutTask(taskId, isUpdated, options) { 185 | const task = this.getTask(taskId) 186 | const activeTab = Tabs.getActive() 187 | if (!options) options = {} 188 | if (task.type === activeTab.name) 189 | el(`#${taskId}`).classList[isUpdated ? 'add' : 'remove']( 190 | TASK_CLASSES.UPDATED 191 | ) 192 | if (Notification && options.notify && isUpdated) { 193 | if (this.notifyTaskTimers[taskId]) { 194 | clearTimeout(this.notifyTaskTimers[taskId]) 195 | this.notifyTaskTimers[taskId] = null 196 | } 197 | this.notifyTaskTimers[taskId] = setTimeout(() => { 198 | const task = this.getTask(taskId) 199 | if (Notification.permission === 'granted') { 200 | const notification = new Notification( 201 | `"${task.name}" has been updated`, 202 | { 203 | icon: 'assets/logo2_small.png', 204 | body: 205 | `project: ${options.projectName}\r\n` + 206 | `taskfile: [${task.type}]\r\n` + 207 | 'latest message: \r\n' + 208 | `...${options.log 209 | .split('') 210 | .splice(-22) 211 | .join('')}`, 212 | } 213 | ) 214 | notification.onclick = () => window.focus() 215 | } else Notification.requestPermission() 216 | }, 2800) 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 | 11 |

🔥 flamebird 🔥

12 |

13 | the nodejs task manager for Procfile-based or npm-based applications 14 |

15 |
16 | 17 | ## 🚀 Installation 18 | 19 | $ npm install -g flamebird 20 | 21 | ## 📄 Usage 22 | 23 | To start Flamebird you can use `fb [command] [options]` (or longer alias `flamebird [command] [options]`).
24 | Application provides two commands: `fb start` and `fb web` (read below). 25 | 26 | Need help? Use command: 27 | 28 | $ fb --help 29 | # or simply 30 | $ fb 31 | 32 | ## 💻 Console version (`fb start`) 33 | 34 | $ fb start [options] 35 | 36 | Run tasks from `Procfile` or `package.json` 37 | 38 | Options: 39 | - `-p, --package` - using package.json for the managing tasks. (:warning: with this option the command `start` run all tasks from `package.json`, for resolving it , please use option `-t`) 40 | - `-t, --tasks [tasks]` - list of tasks which needs to async run in `fb start` ( example : `fb start --tasks start,start:dev,start-server` and then tasks are `start`,`start:dev`,`start-server` will have been runned asynchronously ) 41 | 42 | ## 💻 Web version (`fb web`) 43 | 44 | $ fb web [options] 45 | 46 | Launch web-application which is task-manager. That command has more abilities than `start`. Web-application is reading `Procfile` and `package.json` and adding ability to launch scripts inside this files together 47 | 48 | Options: 49 | - `-t, --tasks [tasks]` - list of tasks which will be managing in the `fb web` command ( example : `fb web --tasks start,start:dev,start-server` and this tasks will be showing in the web-application `start`,`start:dev`,`start-server` ) 50 | - `-p, --port ` - sets the server port, by default `5050` 51 | - `-n, --name ` - sets the project name. Display name of the project in title and header. By default using name of project inside `package.json` otherwise `flamebird` 52 | 53 |

hotkeys

54 | 55 | hotkeys works only if ![hotkeys button](./assets/hotkeys_button.png) is triggered. 56 | 57 | hotkey | action 58 | ------------ | ------------- 59 | Q,W,E...M,<,>,/ | Open task which assigned to specific key. ![example](./assets/task_button.png) 60 | SHIFT + R | Run/Stop selected task. 61 | TAB | Switch between `Procfile` and `package.json` tabs 62 | DEL | Clear logs in selected task 63 | | Partially scroll up logs in selected task 64 | | Partially scroll down logs in selected task 65 | SHIFT + | Fully scroll up logs in selected task 66 | SHIFT + | Fully scroll down logs in selected task 67 | SHIFT + A | Run all tasks 68 | SHIFT + S | Stop all tasks 69 | 70 | 71 |

How it looks:

72 | 73 | ![](assets/web-ui-screen.png) 74 | 75 | 76 | 77 | ## Contribution 78 | 79 | 80 | If you want to help this project you need to read this part of readme.md for more detail understanding of for what some things are needed. 81 | 82 | First of all take a look at project structure: 83 | 84 | - **flamebird project** 85 | - [flamebird.js](./flamebird.js) - Executable by nodejs file when call `fb start`/`fb web`. Describe information about commands `web` and `start`. 86 | - [LICENSE](./LICENSE) 87 | - [nodemon.json](./nodemon.json) - Nodemon config file. Only needed for debug flamebird. 88 | - [node_modules](./node_modules) - you know what is that :D 89 | - [package-lock.json](./package-lock.json) 90 | - [package.json](./package.json) 91 | - [postinstall.js](./postinstall.js) - Script which execute after installing flamebird dependencies 92 | - [Procfile](./Procfile) - List of scripts which execute via nodemon. Only needed for debug flamebird. 93 | - [README.md](./README.md) 94 | - [webpack.config.js](./webpack.config.js) 95 | - [babel.config.js](./babel.config.js) 96 | - [CHANGELOG.md](./CHANGELOG.md) 97 | - __assets__ 98 | - [task_button.png](./assets/task_button.png) 99 | - [hotkeys_button.png](./assets/hotkeys_button.png) 100 | - [web-ui-screen.png](./assets/web-ui-screen.png) 101 | - __client__ Client application ⚡️ 102 | - __controllers__ - folder with controllers where each one have specific UI manipulations 103 | - [Header.js](./client/controllers/Header.js) 104 | - __assets__ - folder with assets 105 | - [logo.psd](./client/assets/logo.psd) - source of the flamebird logo 106 | - [logo.png](./client/assets/logo.png) 107 | - [logo2_small.png](./client/assets/logo2_small.png) 108 | - [logo2.png](./client/assets/logo2.png) 109 | - [logo2_transparent.png](./client/assets/logo2_transparent.png) 110 | - __helpers__ - folder with helpers where helper contains specific logic. In future it will be removed or moved into modules in `scripts` folder. 111 | - [dom_utils.js](./client/helpers/dom_utils.js) 112 | - [hotKeys.js](./client/helpers/hotKeys.js) 113 | - [tabs.js](./client/helpers/tabs.js) 114 | - [taskList.js](./client/helpers/taskList.js) 115 | - [WindowAttached.js](./client/helpers/WindowAttached.js) 116 | - [global.js](./client/global.js) - global file which implement pattern Facade and combine all interactions of modules from `scripts` folder. 117 | - [index.html](./client/index.html) 118 | - [medium-screens.css](./client/medium-screens.css) 119 | - __scripts__ - Bunch of modules which needed for web application. 120 | - [Api.js](./client/scripts/Api.js) - Contains all API endpoints which needed for client. It uses `kinka` as http web client. 121 | - [Configs.js](./client/scripts/Configs.js) - Responsible for update configs list 122 | - [HotKeys.js](./client/scripts/HotKeys.js) - Hot keys 123 | - [Tabs.js](./client/scripts/Tabs.js) - Module which responsible for update tabs list. 124 | - [TaskList.js](./client/scripts/TaskList.js) - Module which responsible for update tasks list. 125 | - [ThemeSwitcher.js](./client/scripts/ThemeSwitcher.js) - Module which responsible for theme switching. 126 | - [WebLogger.js](./client/scripts/WebLogger.js) - Logger module. Output logs into the logs container 127 | - [WebSocket.js](./client/scripts/WebSocket.js) - WebSocket client connection 128 | - [small-screens.css](./client/small-screens.css) 129 | - __styles__ 130 | - [dark-theme.css](./client/styles/dark-theme.css) 131 | - [fullscreen.css](./client/styles/fullscreen.css) 132 | - [hot_keys-shortcuts.css](./client/styles/hot_keys-shortcuts.css) 133 | - [styles.css](./client/styles.css) 134 | - __dist__ - client build folder 135 | - __server__ Server application ⚡️ 136 | - [config.js](./server/config.js) - module which working with configuration file (`.flamebirdrc`) 137 | - [constants.js](./server/constants.js) - module which contains constant values. It usings on both sides (client, server) 138 | - [processWorker.js](./server/processWorker.js) - module which responsible for run/stop tasks 139 | - [server.js](./server/server.js) - [`Web version`] module which contains API endpoints. 140 | - __utils__ - bunch of utilities 😊 141 | - [colors.js](./server/utils/colors.js) - tools which colorize console output 142 | - [commands.js](./server/utils/commands.js) - tools which needed for CRUD commands 143 | - [emitter.js](./server/utils/emitter.js) - instance of `events.EventEmitter`. 144 | - [envs.js](./server/utils/envs.js) - tools which needed for parse environment variables 145 | - [mem_cache.js](./server/utils/mem_cache.js) - just simple object which save all information. Have methods `get`, `set` 146 | - [processes.js](./server/utils/processes.js) - tools which needed for create and kill processes 147 | - [ws.js](./server/ws.js) - [`Web version`] Websocket connection 148 | - [taskfile.js](./server/taskfile.js) - module which parse all commands from `Procfile` and `package.json` 149 | 150 | 151 | 152 | 153 | 154 | ## 📝 License 155 | 156 | Licensed under the [MIT License](./LICENSE). 157 | 158 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | 5 | ## [3.0.0-alpha] - {date} 6 | 7 | Improvenments: 8 | - command `fb web` will have only one instance of NodeJS application. 9 | First call `fb web -p SOME_PORT` create a server hosted on port `SOME_PORT`. 10 | But call `fb web` inside another project will not create a new server 11 | But instead of this you can see all your running projects looking at `localhost:SOME_PORT` 12 | 13 | 14 | ### Added 15 | - `web` [internal] changed webpack configuration for web client 16 | - `web` new options: 17 | - `-i, --ignore-trs` (in previous version it used by default) 18 | Allows to launch tasks without yarn or npm ( use absolute paths: webpack -> node_modules/.bin/webpack ) 19 | default: `false` 20 | - `-r, --task-runner ` 21 | Allows to use another task runner for launch tasks. By default will use npm ( For example: -r yarn ) 22 | default: `'npm'` 23 | - `-w, --without-browser` 24 | This option disable opening the new tab in Google Chrome browser 25 | default: `false` 26 | - `-s, --sort-by-name` 27 | This option using to sort all commands by name (asc) 28 | default: `false` 29 | - `cmd` new options: 30 | - `-i, --ignore-trs` (in previous version it used by default) 31 | Allows to launch tasks without yarn or npm ( use absolute paths: webpack -> node_modules/.bin/webpack ) 32 | default: `false` 33 | - `-r, --task-runner ` 34 | Allows to use another task runner for launch tasks. By default will use npm ( For example: -r yarn ) 35 | default: `'npm'` 36 | 37 | ### Changed 38 | - [internal] fully refactored code on nodeJS and web view 39 | - Option prefix from `-j, --procfile ` to `-f, --procfile ` 40 | - [web] changed API endpoints: 41 | - `POST:/run/${taskId}` -> `POST:/${configId}/${taskId}/run` 42 | - `POST:/stop/${taskId}` -> `POST:/${configId}/${taskId}/stop` 43 | - `POST:/clear-logs/${taskId}` -> `DELETE/${configId}/${taskId}/logs` 44 | - `GET:/logs/${taskId}` -> `GET:/${configId}/${taskId}/logs` 45 | - `POST:/update-envs({ id: string, envs: object})` -> `PUT:/${configId}/${taskId}/envs(envs:object)` 46 | 47 | 48 | ### Fixed 49 | - Problems linked with running flamebird without `node_modules` folder 50 | 51 | ### Removed 52 | - Removed default usage experimental converting npm/yarn run scripts to the absolute paths 53 | - [web] 54 | - `/run-all` API endpoint 55 | - `/stop-all` API endpoint 56 | 57 | 58 | ## [2.1.4] - 12/06/2018 59 | 60 | ### Added 61 | - `web` hotkey feautures 62 | - DEL Clear logs in selected task 63 | - Partially scroll up logs in selected task 64 | - Partially scroll down logs in selected task 65 | - SHIFT + A Run all tasks 66 | - SHIFT + S Stop all tasks 67 | - SHIFT + Fully scroll up logs in selected task 68 | - SHIFT + Fully scroll down logs in selected task 69 | - `web` browser notifications about updates of the running tasks 70 | - `web` mobile version 71 | 72 | ### Changed 73 | - added emojis to `README.md` 74 | - added documentation for hotkeys 75 | 76 | 77 | ### Fixed 78 | - typo fixes in `README.md` 79 | 80 | 81 | ## [2.0.0] - 12/01/2018 82 | 83 | ### Changed 84 | - `web` replaced web http-client `axios` to `kinka` 85 | 86 | ### Fixed 87 | - `web` big line height between lines 88 | - `web` unique task ids 89 | - `web` unique websocket session ids 90 | 91 | ## [1.8.6] 92 | 93 | ### Changed 94 | - `web` - [Internal] - migrated client code to ES6 95 | 96 | ### Fixed 97 | - bug - [Cannot find module 'commander'](https://github.com/acacode/flamebird/issues/49) 98 | 99 | ## [1.8.5] 100 | 101 | ### Changed 102 | - replaced `var` operator to ES6 `const`/`let` 103 | 104 | ### Fixed 105 | - text corrections in `README.md` 106 | - removed not needed dependencies and updated project version in `package-lock.json` 107 | 108 | ## [1.8.4--fix] 109 | 110 | ### Fixed 111 | - bugs with tasks using `cross-env` utility 112 | 113 | ## [1.8.3--fix] 114 | 115 | ### Fixed 116 | - `web` - bug with `Keyboard.connect is not a function` 117 | - absolute paths for npm tasks 118 | 119 | ## [1.8.02] 120 | 121 | ### Fixed 122 | - `web` - a small fixes in layout of the web page 123 | 124 | ## [1.8.0] 125 | 126 | ### Fixed 127 | - maximum `killall` event listeners. The removing of the `killall` event listener if task is completed 128 | - `web` - more than one websocket clients 129 | - `web` - restart of the task has been fixed 130 | - `start` - not all processes was launched from Procfile using `start` command 131 | 132 | 133 | ### Changed 134 | - `web` - a small UI/UX fixes. 135 | 136 | ### Removed 137 | - `web` - `blue` theme 138 | 139 | ## [1.7.91 - 1.7.94] 140 | 141 | ### Fixed 142 | - `web` - Fix displaying logs for non active task in the active task 143 | - `web` - Fix problem with changing env variables in web (previous process not killed) 144 | - Remove unsupportable spread operator 145 | 146 | ## [1.7.9] 147 | 148 | ### Fixed 149 | - `web` - Cannot switch to `npm` tab [32 issue](https://github.com/acacode/flamebird/issues/32) 150 | - `web` - Cannot read property `isActive` of undefined [30 issue](https://github.com/acacode/flamebird/issues/30) 151 | 152 | ## [1.7.8] 153 | 154 | ### Added 155 | - Running commands without `yarn`/`npm run`. Launching libraries in the commands via full path to library (util -> node_modules/.bin/util)[14 issue](https://github.com/acacode/flamebird/issues/14) & [25 issue](https://github.com/acacode/flamebird/issues/25) 156 | - Integration with Travis CI [17 issue](https://github.com/acacode/flamebird/issues/17) 157 | 158 | ### Fixed 159 | - `web` - Fixed whitespace in the logs [26 issue](https://github.com/acacode/flamebird/issues/26) 160 | 161 | ## [1.7.5] 162 | 163 | ### Added 164 | - Ability of the parsing and displaying `.env` file [20 issue](https://github.com/acacode/flamebird/issues/20) 165 | 166 | ### Changed 167 | - [Internal] FE/BE code refactoring 168 | - `web` - changes in webpack config. Add builds for `*.css` files 169 | - add post install script ( only decorations ) 170 | - `web` - Fix styles for small screens and for fullscreen mode. 171 | 172 | ### Fixed 173 | - Displaying logs not linked with opened task in web [21 issue](https://github.com/acacode/flamebird/issues/21) 174 | 175 | ## [1.7.1 - 1.7.31] 176 | 177 | ### Fixed 178 | - fixed webpack build after installing npm packages 179 | 180 | ## [1.7.0] - 13/05/2018 181 | 182 | ### Changed 183 | - refactoring code in frontend part of application 184 | - update styles for web application 185 | 186 | ### Fixed 187 | - loosing colors in logs [18 issue](https://github.com/acacode/flamebird/issues/18) 188 | - problems with running webpack-dev-server in flamebird [15 issue](https://github.com/acacode/flamebird/issues/15) 189 | 190 | ### Added 191 | - `web` - added hotkeys button. Which helps task switching or tabs switching via keyboard 192 | - `web` - added themes [default(white), dark, blue] 193 | - `web` - added fullscreen button. Changes task runner window size 194 | - `web` - webpack build 195 | - `web` - create flamebird logo [16 issue](https://github.com/acacode/flamebird/issues/16) 196 | - `web` - ability of the renaming values of the env variables [13 issue](https://github.com/acacode/flamebird/issues/13) 197 | 198 | ## [1.6.7] - 199 | 200 | ### Changed 201 | - renaming option `-P` to `-p` in the `web` command, for the setting port 202 | - update styles for web application 203 | 204 | ### Added 205 | - `web` - added autoscroll button on the right bottom side. For turning on/off autoscrolling logs to bottom 206 | - `web` - [dev only] function for colorizing specific words like so `[emitted]`, `[built]` etc. 207 | - `web` - flamebird can read and procfile and package.json together, and added ability switching between this files in the web-application 208 | 209 | ### Removed 210 | - removed option `-p, --package` 211 | 212 | ## [1.5.5] - 04/23/2018 213 | 214 | ### Changed 215 | - working status of the tasks dependent on the server's status of task 216 | - updated README.md 217 | 218 | ### Fixed 219 | - Normalized showing logs of the task 220 | - Envs isn't passing to the command [BUG](https://github.com/acacode/flamebird/issues/3) 221 | - fb web: UnhandledPromiseRejectionWarning: Error: spawn chrome ENOENT [BUG](https://github.com/acacode/flamebird/issues/2) 222 | 223 | 224 | ## [1.5.3] - 04/21/2018 225 | 226 | ### Added 227 | - option `-n, --name ` for the `web` command. Sets the name of application 228 | - added feature of the opening new tab of Google Chrome browser when we launch `web` command 229 | 230 | ### Changed 231 | - update styles for web application 232 | - route `commands` renamed to `info` and now returns object with properties `appName` and `commands` 233 | 234 | 235 | ## [1.5.0] - 04/21/2018 236 | 237 | ### Added 238 | - `fb` - additional command name for the calling flamebird 239 | - command `flamebird web` - launch webview of flamebird application and working with all processes from webview 240 | - option `-p, --package` for commands `start` and `web` which needs for using `package.json` as the managing tasks instead of `Procfile` 241 | - option `-t, --tasks [tasks]` for commands `start` and `web` which needs for setting specific tasks which needs to the working 242 | - option `-P, --port ` for `web` command. Sets the server port. By default 5050 value 243 | 244 | 245 | ## [1.0.0] - 04/18/2018 246 | 247 | ### Added 248 | - command `flamebird start` for the launching all commands in Procfile 249 | - option `-j, --procfile ` for the loading `Procfile` from `` . by default using `./Procfile` path -------------------------------------------------------------------------------- /client/global.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import $ from 'jquery' 3 | import api from './scripts/Api' 4 | import TaskList from './scripts/TaskList' 5 | import WebLogger from './scripts/WebLogger' 6 | import HotKeys from './scripts/HotKeys' 7 | import { el as getEl } from './helpers/dom_utils' 8 | import Tabs from './scripts/Tabs' 9 | import WindowAttached from './helpers/WindowAttached' 10 | import { clearifyEvent } from './helpers/hotKeys' 11 | import { Header } from './controllers/Header' 12 | import WebSocket from './scripts/WebSocket' 13 | import ConfigsManager from './scripts/Configs' 14 | 15 | export const DEFAULT_PROJECT_NAME = 'flamebird' 16 | 17 | class Global extends WindowAttached('global') { 18 | watchTaskLogsScrollTop = true 19 | setTheme 20 | previousEnvs = null 21 | projectName = DEFAULT_PROJECT_NAME 22 | pageIsNotActive = false 23 | 24 | configsManager = new ConfigsManager('.configs-list', { 25 | // TODO: fix config as undefined 26 | onSetConfig: config => this.handleConfigSet(config), 27 | getConfigs: async () => { 28 | const { 29 | data: { configs }, 30 | } = await api.getProjectInfo() 31 | return configs || [] 32 | }, 33 | onRemoveConfig: config => { 34 | const activeConfig = this.configsManager.getActiveConfig() 35 | 36 | // TODO: fix activeConfig as undefined 37 | if (activeConfig.id === config.id) { 38 | this.configsManager.setConfig(0) 39 | } 40 | this.stopAllTasks(config) 41 | api.removeConfig(config.id) 42 | }, 43 | }) 44 | 45 | logger 46 | taskList = null 47 | hotkeys = new HotKeys({ 48 | tab: event => { 49 | clearifyEvent(event) 50 | Tabs.setNextAsActive() 51 | return false 52 | }, 53 | arrowUp: () => this.logger.scrollTo('bottom', 0, 40), 54 | arrowDown: () => this.logger.scrollTo('top', 0, 40), 55 | del: () => this.clearLogs(), 56 | shiftA: () => this.runAllTasks(), 57 | shiftS: () => this.stopAllTasks(), 58 | shiftR: () => { 59 | const activeTask = this.taskList.getActive() 60 | 61 | if (!activeTask.isLaunching) { 62 | if (activeTask.isRun) { 63 | this.stopTask(activeTask) 64 | } else { 65 | this.runTask(activeTask) 66 | } 67 | } 68 | }, 69 | shiftArrowUp: () => this.logger.scrollTo('top', '1500'), 70 | shiftArrowDown: () => this.logger.scrollTo('bottom', '500'), 71 | }) 72 | 73 | header = new Header({ hotkeys: this.hotkeys }) 74 | 75 | showTaskList() { 76 | $(document.body).toggleClass('task-list-showed') 77 | } 78 | 79 | handleConfigSet = ({ commands, name }) => { 80 | this.setProjectName(name) 81 | 82 | if (this.taskList) { 83 | this.taskList.initalizeTasks(commands) 84 | } else { 85 | this.taskList = new TaskList(getEl('#task-list'), commands, { 86 | onOpenTask: this.openTask, 87 | onRunTask: this.runTask, 88 | onStopTask: this.stopTask, 89 | onCreateTaskEl: this.onCreateTaskEl, 90 | }) 91 | } 92 | } 93 | 94 | runAllTasks(config = this.configsManager.getActiveConfig()) { 95 | _.each( 96 | this.taskList.getAllFromActiveTab({ isRun: false }), 97 | ({ isRun, id, isActive }) => { 98 | this.taskList.updateTask(id, { isRun, isActive, isLaunching: true }) 99 | api.runTask(config.id, id) 100 | } 101 | ) 102 | } 103 | 104 | stopAllTasks(config = this.configsManager.getActiveConfig()) { 105 | _.each( 106 | this.taskList.getAllFromActiveTab({ isRun: true }), 107 | ({ isRun, id, isActive }) => { 108 | this.taskList.updateTask(id, { isRun, isActive, isStopping: true }) 109 | api.stopTask(config.id, id) 110 | } 111 | ) 112 | } 113 | 114 | clearLogs(activeTask) { 115 | if (!activeTask) { 116 | activeTask = this.taskList.getActive() 117 | } 118 | if (_.isString(activeTask)) { 119 | activeTask = this.taskList.getTask(activeTask) 120 | } 121 | const config = this.configsManager.getActiveConfig() 122 | api.clearLogs(config.id, activeTask.id) 123 | activeTask.logs = [] 124 | this.logger.clear() 125 | this.logger.updateDescription(activeTask.task) 126 | this.logger.updateEnvs(activeTask.envs) 127 | } 128 | 129 | triggerScrollWatcher(e) { 130 | this.logger.scrollTo('bottom', '1500') 131 | this.watchTaskLogsScrollTop = !this.watchTaskLogsScrollTop 132 | $('.logs-button.autoscroll').toggleClass( 133 | 'active', 134 | this.watchTaskLogsScrollTop 135 | ) 136 | } 137 | 138 | handleClickTab(tab) { 139 | if (tab !== Tabs.getActive().name) { 140 | Tabs.setActive(tab) 141 | } 142 | } 143 | 144 | enableEnvsForm() { 145 | getEl('.envs-log').classList.add('active') 146 | this.previousEnvs = _.clone(this.taskList.getActive().envs) 147 | } 148 | 149 | updateEnvs() { 150 | getEl('.envs-log').classList.remove('active') 151 | const activeTask = this.taskList.getActive() 152 | _.each(getEl('.envs-log > input', true), el => { 153 | const input = $(el) 154 | this.previousEnvs[input.attr('key')] = input.val() 155 | }) 156 | activeTask.envs = _.clone(this.previousEnvs) 157 | this.clearLogs(activeTask) 158 | const config = this.configsManager.getActiveConfig() 159 | api.updateEnvs(config.id, activeTask.id, this.previousEnvs) 160 | this.taskList.updateTask(activeTask.id, { 161 | isRun: true, 162 | isActive: activeTask.isActive, 163 | isLaunching: true, 164 | }) 165 | this.previousEnvs = null 166 | } 167 | 168 | cancelEnvs() { 169 | getEl('.envs-log').classList.remove('active') 170 | _.each(getEl('.envs-log > input', true), el => { 171 | const input = $(el) 172 | input.val(this.previousEnvs[input.attr('key')]) 173 | }) 174 | this.taskList.getActive().envs = _.clone(this.previousEnvs) 175 | this.previousEnvs = null 176 | } 177 | 178 | async updateTaskLogs({ task, envs, id }) { 179 | this.logger.clear() 180 | const config = this.configsManager.getActiveConfig() 181 | const { data: rawLogs } = await api.getLogs(config.id, id) 182 | this.logger.updateDescription(task) 183 | this.logger.updateEnvs(envs) 184 | const logs = _.map(rawLogs, this.logger.createHTMLLog).join('') 185 | setTimeout(() => { 186 | this.logger.push(logs, true) 187 | this.logger.scrollTo('bottom') 188 | }, 0) 189 | } 190 | 191 | openTask = task => { 192 | // const active = this.taskList.getActive() 193 | // if (!active || id !== active.id) { 194 | if (task.id) { 195 | if (this.header.notificationsEnabled) 196 | this.taskList.notifyAboutTask(task.id, false) 197 | this.taskList.setActive(task) 198 | this.updateTaskLogs(task) 199 | } 200 | // } 201 | } 202 | 203 | runTask = task => { 204 | window.event.stopPropagation() 205 | // const task = this.taskList.getTask(id) 206 | if (!task.isLaunching && !task.isRun) { 207 | const config = this.configsManager.getActiveConfig() 208 | this.taskList.setActive(task, true) 209 | this.updateTaskLogs(task) 210 | api.runTask(config.id, task.id) 211 | } 212 | } 213 | 214 | stopTask = task => { 215 | window.event.stopPropagation() 216 | // const task = this.taskList.getTask(id) 217 | if (!task.isLaunching && task.isRun) { 218 | const config = this.configsManager.getActiveConfig() 219 | this.taskList.updateTask(task.id, { 220 | isActive: task.isActive, 221 | isStopping: true, 222 | }) 223 | api.stopTask(config.id, task.id) 224 | } 225 | } 226 | 227 | onCreateTaskEl = (taskEl, index) => { 228 | if (this.hotkeys.isEnabled) { 229 | this.hotkeys.connectTaskButton(taskEl, index) 230 | } 231 | } 232 | 233 | handleOnUpdateLog = ({ name, id, isRun, isLaunching, isStopping, log }) => { 234 | if (name) { 235 | const isActive = id === this.taskList.getActive().id 236 | this.taskList.updateTask(id, { 237 | isRun, 238 | isActive, 239 | isLaunching, 240 | isStopping, 241 | }) 242 | if (log) { 243 | if (isActive) { 244 | this.logger.push(log) 245 | if (this.watchTaskLogsScrollTop) this.logger.scrollTo('bottom') 246 | if (this.header.notificationsEnabled && this.pageIsNotActive) { 247 | this.taskList.notifyAboutTask(id, true, { 248 | notify: this.header.notificationsEnabled, 249 | projectName: this.projectName, 250 | log, 251 | }) 252 | } 253 | } else if (this.header.notificationsEnabled) { 254 | this.taskList.notifyAboutTask(id, true, { 255 | notify: this.header.notificationsEnabled, 256 | log, 257 | projectName: this.projectName, 258 | }) 259 | } 260 | } 261 | } 262 | } 263 | 264 | getLogger = () => this.logger 265 | getTaskList = () => this.taskList 266 | 267 | setProjectName = name => { 268 | this.projectName = name 269 | if (this.projectName) { 270 | $('title').text(`${this.projectName} | fb`) 271 | $('.title span').text(this.projectName) 272 | } 273 | } 274 | 275 | constructor() { 276 | super() 277 | $(document).ready(async () => { 278 | $(window).focus(() => { 279 | this.pageIsNotActive = false 280 | if (this.header.notificationsEnabled) { 281 | this.taskList.notifyAboutTask(this.taskList.getActive().id, false) 282 | } 283 | }) 284 | 285 | $(window).blur(() => { 286 | this.pageIsNotActive = true 287 | }) 288 | 289 | this.configsManager 290 | .refreshConfigs() 291 | .then(() => this.configsManager.setConfig(0)) 292 | 293 | this.logger = new WebLogger(getEl('#task-logs')) 294 | this.websocket = new WebSocket(`ws://${location.host}`, { 295 | onLogUpdate: this.handleOnUpdateLog, 296 | onAppListUpdate: this.configsManager.refreshConfigs, 297 | }) 298 | }) 299 | } 300 | } 301 | 302 | export default new Global() 303 | -------------------------------------------------------------------------------- /client/styles.css: -------------------------------------------------------------------------------- 1 | body,html { 2 | width: 100%; 3 | height: 100%; 4 | font-family: sans-serif; 5 | line-height: 1.15; 6 | -webkit-text-size-adjust: 100%; 7 | -ms-text-size-adjust: 100%; 8 | -ms-overflow-style: scrollbar; 9 | -webkit-tap-highlight-color: transparent; 10 | } 11 | *, ::after, ::before { 12 | box-sizing: border-box; 13 | } 14 | body { 15 | margin: 0; 16 | font-size: 1rem; 17 | font-weight: 400; 18 | line-height: 1.5; 19 | color: #212529; 20 | text-align: left; 21 | background-color: #efefef; 22 | padding: 3% 0%; 23 | overflow: hidden; 24 | } 25 | button{ 26 | outline: none; 27 | } 28 | body * { 29 | font-family: 'Roboto', sans-serif; 30 | } 31 | 32 | header { 33 | background: #f1f1f1; 34 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.09); 35 | color: rgb(99, 99, 99); 36 | display: flex; 37 | font-size: 18px; 38 | font-weight: 900; 39 | justify-content: space-between; 40 | position: relative; 41 | width: 100%; 42 | z-index: 5; 43 | } 44 | 45 | .wrapper { 46 | background: #f1f1f1; 47 | box-shadow: 0 0px 57px 1px rgba(0, 0, 0, 0.18), 0 2px 2px rgba(0, 0, 0, 0.1); 48 | height: 100%; 49 | margin-left: auto; 50 | margin-right: auto; 51 | max-width: 876px; 52 | position: relative; 53 | } 54 | .wrapper > section{ 55 | float: left; 56 | overflow-y: auto; 57 | } 58 | 59 | #task-list { 60 | height: calc(100% - 40px); 61 | width: 28%; 62 | position: relative; 63 | } 64 | 65 | #task-list::-webkit-scrollbar { 66 | width: 10px; 67 | } 68 | #task-list::-webkit-scrollbar-track { 69 | background: #f1f1f1; 70 | } 71 | #task-list::-webkit-scrollbar-thumb { 72 | background: rgba(0, 0, 0, 0.15); 73 | } 74 | #task-list::-webkit-scrollbar-thumb:hover { 75 | background: rgba(0, 0, 0, 0.2); 76 | } 77 | 78 | #task-logs{ 79 | width: 72%; 80 | height: calc(100% - 40px); 81 | overflow: hidden; 82 | display: flex; 83 | flex-direction: column; 84 | } 85 | 86 | .logs-container{ 87 | background: #1e1e1e; 88 | word-break: break-all; 89 | text-align: justify; 90 | color: #dedede; 91 | font-size: 13px; 92 | font-family: monospace; 93 | padding: 8px 0 27px 8px; 94 | position: relative; 95 | overflow-x: hidden; 96 | height: 100%; 97 | } 98 | .logs-container::-webkit-scrollbar { 99 | width: 10px; 100 | } 101 | .logs-container::-webkit-scrollbar-thumb { 102 | background: #f1f1f1; 103 | } 104 | .logs-container::-webkit-scrollbar-thumb:hover { 105 | background: rgb(220, 220, 220); 106 | } 107 | 108 | .task { 109 | padding: 0 0 0 9px; 110 | background: rgba(119, 119, 119, 0.05); 111 | color: #7b7b7b; 112 | cursor: pointer; 113 | display: flex; 114 | position: relative; 115 | transition: all 250ms ease; 116 | z-index: 0; 117 | } 118 | 119 | .task > button{ 120 | border: none; 121 | background: none; 122 | cursor: pointer; 123 | height: 42px; 124 | font-size: 15px; 125 | width: 32px; 126 | position: absolute; 127 | left: -32px; 128 | transition: left 69ms linear; 129 | } 130 | 131 | .main-buttons { 132 | padding: 0; 133 | display: flex; 134 | overflow: hidden; 135 | } 136 | 137 | 138 | .main-button{ 139 | border: 0; 140 | color: #f1f1f1; 141 | font-size: 15px; 142 | cursor: pointer; 143 | padding: 1px 20px; 144 | min-width: 88px; 145 | line-height: 1; 146 | } 147 | 148 | 149 | .main-button:not(.toggle){ 150 | margin: 0 0 0 -20px; 151 | overflow:hidden; 152 | position:relative; 153 | -webkit-transform: skew(-20deg); 154 | -moz-transform: skew(-20deg); 155 | -o-transform: skew(-20deg); 156 | transform: skew(-20deg); 157 | } 158 | 159 | .main-button:not(.toggle) > span { 160 | -webkit-transform: skew(20deg); 161 | -moz-transform: skew(20deg); 162 | -o-transform: skew(20deg); 163 | transform: skew(20deg); 164 | position: relative; 165 | display: block; 166 | } 167 | 168 | .run{ 169 | background: #2eb97a; 170 | right: 12px; 171 | color: #d5ffec; 172 | } 173 | header > * { 174 | padding: 8px 10px 5px; 175 | } 176 | .stop{background: #b55d5d;color: #ffd5d5;right: -7px;padding-right: 27px;} 177 | .task > .stop-task { 178 | display: none; 179 | color: #a93333; 180 | background: #f38f8f; 181 | } 182 | .task > .run-task { 183 | color: rgb(9, 101, 25); 184 | background: rgb(129, 211, 144); 185 | } 186 | .task-data { 187 | min-height: 32px; 188 | padding: 9px 8px 0 0; 189 | width: auto; 190 | background: transparent; 191 | position: relative; 192 | top: -6px; 193 | display: flex; 194 | } 195 | .task-data > span { 196 | background: #ebebeb; 197 | color: #1d1d1d; 198 | padding: 0 8px; 199 | border-radius: 15px; 200 | display: block; 201 | cursor: default; 202 | transition: all 189ms linear; 203 | } 204 | .task.active { 205 | color: #1d1d1d; 206 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.55); 207 | background: rgb(241, 241, 241); 208 | z-index: 2; 209 | } 210 | .task.updated { 211 | animation: highlight cubic-bezier(0.22, 0.61, 0.36, 1) .7s infinite; 212 | } 213 | .run:active { 214 | background: #21d483; 215 | } 216 | 217 | .stop:active { 218 | background: #e65555; 219 | } 220 | 221 | .task i.fa-cog{ 222 | display:inline-block; 223 | height: 24px; 224 | position: relative; 225 | left: -5px; 226 | font-size: 24px; 227 | top: 8px; 228 | color: rgba(80, 80, 80, 0); 229 | } 230 | 231 | .task.running .run-task, .task.clicked .run-task, .task.clicked .stop-task, .task.stopping .run-task, .task.stopping .stop-task { 232 | display: none; 233 | } 234 | 235 | .task.running .stop-task { 236 | display: block; 237 | } 238 | 239 | .task.running:before { 240 | background: #75d085; 241 | } 242 | 243 | .task.clicked:before,.task.stopping:before { 244 | background: #bdbdbd; 245 | } 246 | .task.clicked i.fa-cog:before,.task.stopping i.fa-cog:before { 247 | content: "\f110" !important; 248 | } 249 | 250 | .task.running i.fa-cog, .task.clicked i.fa-cog { 251 | -webkit-animation: spin 4s linear infinite; 252 | -moz-animation: spin 4s linear infinite; 253 | animation: spin 4s linear infinite; 254 | } 255 | .task.stopping i.fa-cog { 256 | -webkit-animation: spin 2s linear reverse infinite; 257 | -moz-animation: spin 2s linear reverse infinite; 258 | animation: spin 2s linear reverse infinite; 259 | } 260 | .task.running i.fa-cog{ 261 | color: #096519; 262 | } 263 | .task.clicked i.fa-cog{ 264 | color: #5c9aad; 265 | } 266 | .task.stopping i.fa-cog{ 267 | color: #d45a5a; 268 | } 269 | 270 | .task > span { 271 | user-select: none; 272 | padding: 9px 0; 273 | transition: transform 199ms cubic-bezier(0.18, 0.89, 0.32, 1.28) !important; 274 | text-overflow: ellipsis; 275 | overflow: hidden; 276 | white-space: nowrap; 277 | } 278 | 279 | 280 | .task:before { 281 | content: ''; 282 | position: absolute; 283 | left: 0; 284 | top: 0; 285 | width: 32px; 286 | height: 100%; 287 | background: transparent; 288 | z-index: 0; 289 | opacity: .9; 290 | } 291 | .task:hover button { 292 | transition: left 199ms cubic-bezier(0.18, 0.89, 0.32, 1.28) !important; 293 | left: 0; 294 | } 295 | 296 | .task:hover { 297 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.35); 298 | background: rgba(241, 241, 241, 0.91); 299 | z-index: 1; 300 | } 301 | 302 | .task:hover:before { 303 | opacity: .3; 304 | } 305 | 306 | .task:not(:hover) > span { 307 | transform: translateX(-27px); 308 | } 309 | 310 | .task.running > span, .task.clicked > span, .task.stopping > span { 311 | transform: translateX(6px); 312 | } 313 | .task.clicked:before,.task.stopping:before { 314 | opacity: 1; 315 | } 316 | .task.clicked:before{ 317 | background: #94d6ea !important; 318 | } 319 | .task.stopping:before{ 320 | background: #ea9494 !important; 321 | } 322 | 323 | .ansi-black-fg {color: rgba(0,0,0,1) } 324 | .ansi-red-fg {color: rgb(249, 101, 92);} 325 | .ansi-green-fg {color: rgb(63, 216, 104);} 326 | .ansi-yellow-fg {color: rgb(222, 222, 89);} 327 | .ansi-blue-fg {color: rgb(108, 159, 255);} 328 | .ansi-magenta-fg {color: rgb(204, 77, 204);} 329 | .ansi-cyan-fg {color: rgb(90, 218, 218);} 330 | .ansi-white-fg {color: rgba(255,255,255,1) } 331 | .ansi-bright-black-fg {color: #cbcbcb;} 332 | .ansi-bright-red-fg {color: rgba(255,85,85,1) } 333 | .ansi-bright-green-fg {color: rgba(0,255,0,1) } 334 | .ansi-bright-yellow-fg {color: rgba(255,255,85,1) } 335 | .ansi-bright-blue-fg {color: rgb(81, 136, 197);} 336 | .ansi-bright-magenta-fg {color: rgb(236, 97, 236);} 337 | .ansi-bright-cyan-fg {color: rgba(85,255,255,1) } 338 | .ansi-bright-white-fg {color: rgba(255,255,255,1) } 339 | .tussock{color: #c6d2ff;} 340 | .mocha-test {font-size: 16px;} 341 | .bold{font-weight: bold;} 342 | 343 | .logs-container > div > span.ansi-red-fg { 344 | display: block; 345 | } 346 | 347 | .logs-container > div { 348 | padding-bottom: 3px; 349 | font-size: 14px; 350 | } 351 | 352 | span.ended { 353 | font-family: monospace; 354 | color: #85b5ff; 355 | font-weight: 100; 356 | font-size: 14px; 357 | display: block; 358 | padding: 10px 0 16px; 359 | } 360 | span.ended.ok { 361 | color: rgb(53, 212, 118); 362 | } 363 | 364 | .logs-button { 365 | position: absolute; 366 | bottom: 28px; 367 | width: 30px; 368 | height: 30px; 369 | right: 27px; 370 | font-size: 19px; 371 | border: 2px solid; 372 | border-radius: 5px; 373 | background: transparent; 374 | color: #f1f1f1; 375 | opacity: .2; 376 | cursor: pointer; 377 | transition: all 279ms ease; 378 | } 379 | 380 | .logs-button.autoscroll{ 381 | padding-top: 2px; 382 | } 383 | .logs-button.scroll-top{ 384 | bottom: 134px; 385 | transform: rotate(180deg); 386 | } 387 | .logs-button.clear{ 388 | bottom: 72px; 389 | padding-left: 3px; 390 | } 391 | 392 | .logs-button.active, .logs-button:active { 393 | border: 2px solid #f1f1f1; 394 | background: #f1f1f1; 395 | color: rgb(29, 29, 29); 396 | } 397 | 398 | button.logs-button:hover { 399 | opacity: 1; 400 | } 401 | .task.clicked i.fa-cog { 402 | animation-duration: 1s; 403 | } 404 | 405 | button.tab { 406 | /* position: absolute; */ 407 | border-top-left-radius: 8px; 408 | user-select: none; 409 | border-top-right-radius: 8px; 410 | border: none; 411 | padding: 1px 0 3px; 412 | z-index: -50; 413 | color: #595959; 414 | background: rgb(239, 239, 239); 415 | cursor: pointer; 416 | box-shadow: 0 0px 0px 0.5px rgb(171, 171, 171); 417 | transition: background 269ms cubic-bezier(0, 0, 0.2, 1); 418 | transform: rotate(-90deg); 419 | margin: 21px -3px; 420 | line-height: 10px; 421 | position: relative; 422 | width: inherit; 423 | } 424 | 425 | button.tab.active, button.tab:active { 426 | z-index: 3; 427 | background: rgb(255, 247, 204); 428 | /* font-weight: 500; */ 429 | box-shadow: 0 0px 0px 0.5px rgb(154, 137, 47); 430 | color: #212121; 431 | } 432 | 433 | .envs-log:before{ 434 | content:'envs: '; 435 | } 436 | 437 | .tabs { 438 | position: absolute; 439 | left: -46.5px; 440 | top: 0; 441 | z-index: 0; 442 | display: flex; 443 | flex-direction: column; 444 | flex: 1; 445 | height: 100%; 446 | padding-top: 100px; 447 | padding-left: 12px; 448 | width: 58px; 449 | } 450 | 451 | .envs-log { 452 | text-align: left; 453 | } 454 | .envs-log > .env-value { 455 | pointer-events: none; 456 | background: transparent; 457 | border: none; 458 | outline: none; 459 | } 460 | 461 | .envs-log > button.logs-button { 462 | position: relative; 463 | top: 0; 464 | bottom: auto; 465 | float: right; 466 | font-size: 15px; 467 | } 468 | 469 | .logs-button.apply, .logs-button.cancel, .envs-log.active .logs-button { 470 | display: none; 471 | } 472 | 473 | .envs-log.active .logs-button.apply, .envs-log.active .logs-button.cancel { 474 | display: block; 475 | } 476 | 477 | .logs-button.apply { 478 | margin-right: 8px; 479 | color: #95ff7d; 480 | } 481 | 482 | .logs-button.cancel { 483 | color: #ff8c8c; 484 | } 485 | 486 | .envs-log.active > .env-value { 487 | pointer-events: all; 488 | border: 1px solid rgb(235, 235, 235); 489 | border-radius: 6px; 490 | color: #ebebeb; 491 | } 492 | 493 | .scrolling > .task-data { 494 | position: fixed; 495 | left: auto; 496 | right: auto; 497 | top: auto; 498 | width: 546px; 499 | margin-top: -5px; 500 | } 501 | .logo{ 502 | position: absolute; 503 | width: 40px; 504 | z-index: -1; 505 | left: -27px; 506 | top: 2px; 507 | padding: 0; 508 | filter: hue-rotate(-26deg); 509 | transform: rotate(-8deg); 510 | } 511 | .title { 512 | color: #1d1d1d; 513 | font-weight: 500; 514 | padding-left: 6px; 515 | background: #f1f1f1; 516 | max-height: 40px; 517 | text-overflow: ellipsis; 518 | overflow: hidden; 519 | z-index: 10; 520 | white-space: nowrap; 521 | max-width: 180px; 522 | cursor: pointer; 523 | position: relative; 524 | } 525 | .title span { 526 | padding-right: 6px; 527 | user-select: none; 528 | } 529 | .title .fas { 530 | position: absolute; 531 | right: 0; 532 | font-size: 14px; 533 | color: #9a9a9a; 534 | line-height: 0.9; 535 | padding: 8px 0 5px; 536 | transition: transform 189ms ease; 537 | } 538 | .project-version { 539 | color: #888888; 540 | margin-left: 5px; 541 | font-size: 12px; 542 | pointer-events: none; 543 | } 544 | 545 | 546 | .task:hover > span { 547 | transform: translateX(6px); 548 | } 549 | .tasks-button{ 550 | display: none; 551 | } 552 | 553 | .main-button.toggle { 554 | margin: auto 5px; 555 | border-radius: 25px; 556 | background: #d8d8d8; 557 | color: #5d5d5d; 558 | padding: 2px 15px; 559 | } 560 | 561 | 562 | 563 | .logs-container > * { 564 | font-family: monospace; 565 | } 566 | 567 | .main-button.toggle.hot-keys { 568 | margin-right: 60px; 569 | } 570 | 571 | .main-button.toggle.active { 572 | background: #2eb97a; 573 | color: #dbffef; 574 | } 575 | 576 | .toggle.color .fas { 577 | transition: transform 239ms ease-out; 578 | } 579 | 580 | .scrolling > .task-data > span:not(:hover) { 581 | opacity: .5; 582 | background: rgba(235, 235, 235, 0.58); 583 | color: #1d1d1d; 584 | } 585 | 586 | .logs-wrapper { 587 | position: relative; 588 | flex: 1; 589 | overflow: auto; 590 | } 591 | 592 | .scroll-actions { 593 | position: absolute; 594 | right: 0; 595 | bottom: 0; 596 | z-index: 1; 597 | } 598 | 599 | .configs-list { 600 | position: absolute; 601 | left: -10px; 602 | top: calc(100% - 10px); 603 | width: 240px; 604 | min-height: 40px; 605 | background: #efefef; 606 | z-index: 99999; 607 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.17), 0 2px 10px rgba(0, 0, 0, 0.17); 608 | display: none; 609 | padding: 0; 610 | } 611 | 612 | .config { 613 | font-weight: 400; 614 | cursor: pointer; 615 | color: #212529; 616 | display: flex; 617 | justify-content: space-between; 618 | align-items: center; 619 | padding: 4px 10px 4px; 620 | } 621 | 622 | .config:hover { 623 | background: white; 624 | } 625 | 626 | .config:not(:first-child) { 627 | border-top: 1px solid rgba(0, 0, 0, 0.06); 628 | } 629 | 630 | .config:first-child { 631 | padding-top: 6px; 632 | } 633 | 634 | .config:last-child { 635 | padding-bottom: 6px; 636 | } 637 | 638 | .config .name { 639 | user-select: none; 640 | } 641 | 642 | [config="show"] .configs-list { 643 | display: block; 644 | } 645 | [config="show"] .title .fas { 646 | transform: rotate(180deg); 647 | } 648 | 649 | .close-icon { 650 | color: #b55d5d; 651 | } 652 | 653 | @-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } } 654 | @-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } } 655 | @keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } } 656 | @keyframes highlight{ 657 | 50%{ 658 | background: rgb(255, 247, 204); 659 | } 660 | } 661 | --------------------------------------------------------------------------------