summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorOmri Bar-Zik <omri@bar-zik.com>2021-06-19 07:55:45 +0300
committerGitHub <noreply@github.com>2021-06-19 07:55:45 +0300
commit834236e075c5481b326ef16e7980d9723176ad38 (patch)
treedef05d7c7dca4e926a697a8226389579ae5b6277
parent8e44a85346b9f0ec745abdacb713d3239b71e672 (diff)
feat(images): add image view (#167)v3.20.0
-rw-r--r--README.md2
-rw-r--r--hooks/images.hook.js107
-rw-r--r--lib/modes.js3
-rw-r--r--src/dockerUtil.js15
-rw-r--r--src/screen.js17
-rw-r--r--src/widgetsTemplates/help.widget.template.js10
-rw-r--r--widgets/images/imageInfo.widget.js19
-rw-r--r--widgets/images/imageList.widget.js116
-rw-r--r--widgets/images/imageUtilization.widget.js77
-rw-r--r--widgets/toolbar.widget.js38
10 files changed, 385 insertions, 19 deletions
diff --git a/README.md b/README.md
index dac5b36..ae0d627 100644
--- a/README.md
+++ b/README.md
@@ -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,