diff options
author | Omri Bar-Zik <omri@bar-zik.com> | 2021-06-19 07:55:45 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-06-19 07:55:45 +0300 |
commit | 834236e075c5481b326ef16e7980d9723176ad38 (patch) | |
tree | def05d7c7dca4e926a697a8226389579ae5b6277 | |
parent | 8e44a85346b9f0ec745abdacb713d3239b71e672 (diff) |
feat(images): add image view (#167)v3.20.0
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | hooks/images.hook.js | 107 | ||||
-rw-r--r-- | lib/modes.js | 3 | ||||
-rw-r--r-- | src/dockerUtil.js | 15 | ||||
-rw-r--r-- | src/screen.js | 17 | ||||
-rw-r--r-- | src/widgetsTemplates/help.widget.template.js | 10 | ||||
-rw-r--r-- | widgets/images/imageInfo.widget.js | 19 | ||||
-rw-r--r-- | widgets/images/imageList.widget.js | 116 | ||||
-rw-r--r-- | widgets/images/imageUtilization.widget.js | 77 | ||||
-rw-r--r-- | widgets/toolbar.widget.js | 38 |
10 files changed, 385 insertions, 19 deletions
@@ -2,7 +2,7 @@ <br> <img width="200" src="https://user-images.githubusercontent.com/316371/28937414-67ee5ffa-7893-11e7-95f9-5059cacf9170.png"> <br> - Immersive terminal interface for managing docker containers and services + Immersive terminal interface for managing docker containers, services and images </p> diff --git a/hooks/images.hook.js b/hooks/images.hook.js new file mode 100644 index 0000000..cfb4625 --- /dev/null +++ b/hooks/images.hook.js @@ -0,0 +1,107 @@ +'use strict' + +const EventEmitter = require('events') +const baseWidget = require('../src/baseWidget') +const clipboardy = require('clipboardy') + +class hook extends baseWidget(EventEmitter) { + init () { + if (!this.widgetsRepo.has('toolbar')) { + return null + } + + this.notifyOnImageUpdate() + + const toolbar = this.widgetsRepo.get('toolbar') + toolbar.on('key', (keyString) => { + // on refresh keypress, update all containers and images information + + if (keyString === 'r') { + this.removeImage() + } + + if (keyString === 'c') { + this.copyImageIdToClipboard() + } + }) + } + + copyImageIdToClipboard () { + if (this.widgetsRepo && this.widgetsRepo.has('imageList')) { + const imageId = this.widgetsRepo.get('imageList').getSelectedImage() + if (imageId) { + clipboardy.writeSync(imageId) + + const actionStatus = this.widgetsRepo.get('actionStatus') + const message = `Image Id ${imageId} was copied to the clipboard` + + actionStatus.emit('message', { + message: message + }) + } + } + } + + notifyOnImageUpdate () { + setInterval(() => { + if (this.widgetsRepo && this.widgetsRepo.has('imageList')) { + // Update on Docker Info + this.utilsRepo.get('docker').systemDf((data) => { + if (!data.Images) { + return + } + + const UseImages = [] + const UnuseImages = [] + + data.Images.forEach(image => { + if (image.Containers > 0) { + UseImages.push(image) + } else { + UnuseImages.push(image) + } + }) + + data.UseImages = UseImages + data.UnuseImages = UnuseImages + + this.emit('imagesUtilization', data) + this.emit('imageSize', data) + }) + } + }, 1000) + } + + removeImage () { + if (this.widgetsRepo && this.widgetsRepo.has('imageList')) { + const imageId = this.widgetsRepo.get('imageList').getSelectedImage() + if (imageId && imageId !== 0 && imageId !== false) { + const actionStatus = this.widgetsRepo.get('actionStatus') + + const title = 'Removing image' + let message = 'Removing image...' + + actionStatus.emit('message', { + title: title, + message: message + }) + + this.utilsRepo.get('docker').removeImage(imageId, (err, data) => { + if (err) { + message = err.json.message + } else { + message = 'Removed image successfully' + this.widgetsRepo.get('imageList').refreshList() + } + + actionStatus.emit('message', { + title: title, + message: message + }) + }) + } + } + } +} + +module.exports = hook diff --git a/lib/modes.js b/lib/modes.js index 01765f2..be96229 100644 --- a/lib/modes.js +++ b/lib/modes.js @@ -2,5 +2,6 @@ module.exports = { container: 'containers', - service: 'services' + service: 'services', + image: 'images' } diff --git a/src/dockerUtil.js b/src/dockerUtil.js index 3506c2e..74d0f84 100644 --- a/src/dockerUtil.js +++ b/src/dockerUtil.js @@ -51,6 +51,16 @@ class Util { }) } + systemDf (cb) { + this.dockerCon.df((err, data) => { + if (err) { + return cb(err, {}) + } + + return cb(data) + }) + } + listContainers (cb) { this.dockerCon.listContainers({ 'all': true, @@ -151,6 +161,11 @@ class Util { return service.inspect(cb) } + getImage (imageId, cb) { + const image = this.dockerCon.getImage(imageId) + image.inspect(cb) + } + restartContainer (containerId, cb) { const container = this.dockerCon.getContainer(containerId) container.restart(cb) diff --git a/src/screen.js b/src/screen.js index 349b674..10b836e 100644 --- a/src/screen.js +++ b/src/screen.js @@ -36,6 +36,21 @@ const SERVICES_GRID_LAYOUT = { 'toolbar': [11, 0, 1, 12] } +const IMAGES_GRID_LAYOUT = { + 'imageInfo': [2, 2, 8, 8], + 'imageList': [0, 0, 6, 10], + 'searchInput': [11, 0, 1, 12], + 'actionStatus': [6, 0, 1, 10], + 'help': [4, 4, 4, 4], + 'toolbar': [11, 0, 1, 12], + 'imageUtilization': [0, 10, 2, 2] +} + +const GRID_LAYOUT = {} +GRID_LAYOUT[MODES.container] = CONTAINERS_GRID_LAYOUT +GRID_LAYOUT[MODES.service] = SERVICES_GRID_LAYOUT +GRID_LAYOUT[MODES.image] = IMAGES_GRID_LAYOUT + class screen { constructor (utils = new Map()) { this.mode = MODES.container @@ -105,7 +120,7 @@ class screen { } initWidgets () { - const layout = this.mode === MODES.container ? CONTAINERS_GRID_LAYOUT : SERVICES_GRID_LAYOUT + const layout = GRID_LAYOUT[this.mode] for (let [widgetName, WidgetObject] of this.assets.get('widgets').entries()) { if (layout[widgetName]) { let widget = new WidgetObject({ diff --git a/src/widgetsTemplates/help.widget.template.js b/src/widgetsTemplates/help.widget.template.js index df51368..091bf0a 100644 --- a/src/widgetsTemplates/help.widget.template.js +++ b/src/widgetsTemplates/help.widget.template.js @@ -104,11 +104,11 @@ class myWidget extends baseWidget() { ▸ h: Show/hide this window ▸ <space>: Refresh the current view - ▸ /: Search the containers list view - ▸ i: Display information dialog about the selected container or service + ▸ /: Search the current list view + ▸ i: Display information dialog about the selected container, service, or images ▸ ⏎: Show the logs of the current container or service ▸ c: Copy id to the clipboard - ▸ v: Toggle between Containers and Services view + ▸ v: Toggle between Containers, Services, and images view ▸ q: Quit dockly The following commands are only available in Container view: @@ -118,6 +118,10 @@ class myWidget extends baseWidget() { ▸ s: Stop the selected container ▸ m: Show a menu with additional actions + The following commands are only available in Image view: + + ▸ r: Remove the selected image + Thanks for using dockly! ` } diff --git a/widgets/images/imageInfo.widget.js b/widgets/images/imageInfo.widget.js new file mode 100644 index 0000000..0bc091c --- /dev/null +++ b/widgets/images/imageInfo.widget.js @@ -0,0 +1,19 @@ +'use strict' + +const InfoWidget = require('../../src/widgetsTemplates/info.widget.template') + +class myWidget extends InfoWidget { + getLabel () { + return 'Image Info' + } + + getSelectedItemId () { + return this.widgetsRepo.get('imageList').getSelectedImage() + } + + getItemById (itemId, cb) { + return this.utilsRepo.get('docker').getImage(itemId, cb) + } +} + +module.exports = myWidget diff --git a/widgets/images/imageList.widget.js b/widgets/images/imageList.widget.js new file mode 100644 index 0000000..2b1cd14 --- /dev/null +++ b/widgets/images/imageList.widget.js @@ -0,0 +1,116 @@ +'use strict' + +const ListWidget = require('../../src/widgetsTemplates/list.widget.template') + +class myWidget extends ListWidget { + constructor ({ blessed = {}, contrib = {}, screen = {}, grid = {} }) { + super({ blessed, contrib, screen, grid }) + this.imagesListData = [] + } + + getLabel () { + return 'Images' + } + + getListItems (cb) { + this.utilsRepo.get('docker').listImages(cb) + } + + filterList (data) { + let imageTitleList = this.imagesListData[0] + let imageList = this.imagesListData.slice(1) + let filteredimages = [] + + if (data) { + filteredimages = imageList.filter((container, index, containerItems) => { + const imageName = container[1] + const imageTag = container[2] + + if ((imageName.indexOf(data) !== -1) || (imageTag.indexOf(data) !== -1)) { + return true + } + }) + } + + if (filteredimages.length > 0) { + filteredimages.unshift(imageTitleList) + this.update(filteredimages) + } else { + this.update(this.imagesListData) + } + } + + formatList (images) { + const imageList = [] + + if (images) { + images.forEach((image) => { + const getTag = (tag, part) => tag ? tag[0].split(':')[part] : 'none' + + imageList.push([ + image.Id.substring(7, 12), + image.RepoDigests ? image.RepoDigests[0].split('@')[0] : getTag(image[2], 0), + getTag(image.RepoTags, 1), + this.timeDifference(image.Created), + this.formatBytes(image.Size) + ]) + }) + } + + imageList.unshift(['Id', 'Name', 'Tag', 'Created', 'Size']) + + this.imagesListData = imageList + + return imageList + } + + /** + * Format raw bytes into human readable size. + * + * @param {number} bytes - number of bytes. + * @returns {string} human readable size. + */ + formatBytes (bytes, decimals) { + if (bytes === 0) return '0 Bytes' + let k = 1000 + + let sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + let i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + /** + * Convert number of seceond to + * @param {number} creationDate Images creation date in unix time. + * @returns + */ + timeDifference (creationDate) { + const msPerMinute = 60 * 1000 + const msPerHour = msPerMinute * 60 + const msPerDay = msPerHour * 24 + const msPerMonth = msPerDay * 30 + const msPerYear = msPerDay * 365 + + const elapsed = Date.now() - (creationDate * 1000) + + const cleanReturn = (number, format) => `${Math.round(number)} ${format}${Math.round(number) === 1 ? '' : 's'} ago` + + if (elapsed < msPerMinute) { return cleanReturn(elapsed / 1000, 'second') } + if (elapsed < msPerHour) { return cleanReturn(elapsed / msPerMinute, 'minute') } + if (elapsed < msPerDay) { return cleanReturn(elapsed / msPerHour, 'hour') } + if (elapsed < msPerMonth) { return cleanReturn(elapsed / msPerDay, 'day') } + if (elapsed < msPerYear) { return cleanReturn(elapsed / msPerMonth, 'month') } + return cleanReturn(elapsed / msPerYear, 'year') + } + + /** + * returns a selected container from the containers listbox + * @return {string} container id + */ + getSelectedImage () { + return this.widget.getItem(this.widget.selected).getContent().toString().trim().split(' ').shift() + } +} + +module.exports = myWidget diff --git a/widgets/images/imageUtilization.widget.js b/widgets/images/imageUtilization.widget.js new file mode 100644 index 0000000..8cbbd1f --- /dev/null +++ b/widgets/images/imageUtilization.widget.js @@ -0,0 +1,77 @@ +'use strict' + +const baseWidget = require('../../src/baseWidget') + +class myWidget extends baseWidget() { + constructor ({ blessed = {}, contrib = {}, screen = {}, grid = {} }) { + super() + this.blessed = blessed + this.contrib = contrib + this.screen = screen + this.grid = grid + + this.label = 'Image Utilization' + this.color = { + 'ImageInUse': 'green', + 'ImageNotInUse': 'red' + } + + this.widget = this.getWidget() + } + + init () { + if (!this.widgetsRepo.has('images')) { + return null + } + + const dockerHook = this.widgetsRepo.get('images') + dockerHook.on('imagesUtilization', (data) => { + this.update(data) + }) + } + + getWidget () { + return this.grid.gridObj.set(...this.grid.gridLayout, this.contrib.gauge, { + label: this.label, + style: this.getWidgetStyle({ fg: 'blue' }), + border: { + type: 'line', + fg: '#00ff00' + }, + hover: { + bg: 'blue' + }, + width: '20%', + height: '18%', + top: '0', + left: '80%' + }) + } + + update (data) { + if (!data || (typeof data !== 'object')) { + return + } + + const stack = [] + + if (data.UseImages.length !== 0) { + stack.push({ + percent: Math.round((data.UseImages.length / data.Images.length) * 100), + stroke: this.color['ImageInUse'] + }) + } + + if (data.UnuseImages.length !== 0) { + stack.push({ + percent: Math.round((data.UnuseImages.length / data.Images.length) * 100), + stroke: this.color['ImageNotInUse'] + }) + } + + this.widget.setStack(stack) + this.screen.render() + } +} + +module.exports = myWidget diff --git a/widgets/toolbar.widget.js b/widgets/toolbar.widget.js index ec70061..27b0b53 100644 --- a/widgets/toolbar.widget.js +++ b/widgets/toolbar.widget.js @@ -36,14 +36,6 @@ class myWidget extends baseWidget(EventEmitter) { keys: ['i'], callback: () => { this.emit('key', 'i') } }, - 'logs': { - keys: ['[RETURN]'], - callback: () => { this.emit('key', '[RETURN]') } - }, - 'expand logs': { - keys: ['-'], - callback: () => { this.emit('key', '-') } - }, 'copy id': { keys: ['c'], callback: () => { this.emit('key', 'c') } @@ -54,6 +46,17 @@ class myWidget extends baseWidget(EventEmitter) { } } + const logCommands = { + 'logs': { + keys: ['[RETURN]'], + callback: () => { this.emit('key', '[RETURN]') } + }, + 'expand logs': { + keys: ['-'], + callback: () => { this.emit('key', '-') } + } + } + const containerCommands = { 'shell': { keys: ['l'], @@ -70,14 +73,23 @@ class myWidget extends baseWidget(EventEmitter) { 'menu': { keys: ['m'], callback: () => { this.emit('key', 'm') } - }, - 'search': { - keys: ['/'], - callback: () => { this.emit('key', '/') } } } - const commands = this.mode === MODES.container ? Object.assign({}, baseCommands, containerCommands) : baseCommands + const imageCommands = { + 'remove': { + keys: ['r'], + callback: () => { this.emit('key', 'r') } + } + } + + const commandExtension = {} + + commandExtension[MODES.container] = Object.assign({}, containerCommands, logCommands) + commandExtension[MODES.service] = Object.assign({}, logCommands) + commandExtension[MODES.image] = imageCommands + + const commands = Object.assign({}, baseCommands, commandExtension[this.mode]) return this.grid.gridObj.set(...this.grid.gridLayout, this.blessed.listbar, { keys: false, |