diff --git a/ui/app/adapters/task-state.js b/ui/app/adapters/task-state.js new file mode 100644 index 00000000000..f9f98ca6271 --- /dev/null +++ b/ui/app/adapters/task-state.js @@ -0,0 +1,39 @@ +import ApplicationAdapter from './application'; +import { inject as service } from '@ember/service'; + +export default ApplicationAdapter.extend({ + token: service(), + + ls(model, path) { + return this.token + .authorizedRequest(`/v1/client/fs/ls/${model.allocation.id}?path=${encodeURIComponent(path)}`) + .then(handleFSResponse); + }, + + stat(model, path) { + return this.token + .authorizedRequest( + `/v1/client/fs/stat/${model.allocation.id}?path=${encodeURIComponent(path)}` + ) + .then(handleFSResponse); + }, +}); + +async function handleFSResponse(response) { + if (response.ok) { + return response.json(); + } else { + const body = await response.text(); + + // TODO update this if/when endpoint returns 404 as expected + const statusIs500 = response.status === 500; + const bodyIncludes404Text = body.includes('no such file or directory'); + + const translatedCode = statusIs500 && bodyIncludes404Text ? 404 : response.status; + + throw { + code: translatedCode, + toString: () => body, + }; + } +} diff --git a/ui/app/components/fs-breadcrumbs.js b/ui/app/components/fs-breadcrumbs.js new file mode 100644 index 00000000000..f613a0c4933 --- /dev/null +++ b/ui/app/components/fs-breadcrumbs.js @@ -0,0 +1,42 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { isEmpty } from '@ember/utils'; + +export default Component.extend({ + tagName: 'nav', + classNames: ['breadcrumb'], + + 'data-test-fs-breadcrumbs': true, + + task: null, + path: null, + + breadcrumbs: computed('path', function() { + const breadcrumbs = this.path + .split('/') + .reject(isEmpty) + .reduce((breadcrumbs, pathSegment, index) => { + let breadcrumbPath; + + if (index > 0) { + const lastBreadcrumb = breadcrumbs[index - 1]; + breadcrumbPath = `${lastBreadcrumb.path}/${pathSegment}`; + } else { + breadcrumbPath = pathSegment; + } + + breadcrumbs.push({ + name: pathSegment, + path: breadcrumbPath, + }); + + return breadcrumbs; + }, []); + + if (breadcrumbs.length) { + breadcrumbs[breadcrumbs.length - 1].isLast = true; + } + + return breadcrumbs; + }), +}); diff --git a/ui/app/components/fs-directory-entry.js b/ui/app/components/fs-directory-entry.js new file mode 100644 index 00000000000..f50bb84560d --- /dev/null +++ b/ui/app/components/fs-directory-entry.js @@ -0,0 +1,18 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { isEmpty } from '@ember/utils'; + +export default Component.extend({ + tagName: '', + + pathToEntry: computed('path', 'entry.Name', function() { + const pathWithNoLeadingSlash = this.get('path').replace(/^\//, ''); + const name = encodeURIComponent(this.get('entry.Name')); + + if (isEmpty(pathWithNoLeadingSlash)) { + return name; + } else { + return `${pathWithNoLeadingSlash}/${name}`; + } + }), +}); diff --git a/ui/app/components/image-file.js b/ui/app/components/image-file.js new file mode 100644 index 00000000000..fe28201ba02 --- /dev/null +++ b/ui/app/components/image-file.js @@ -0,0 +1,29 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + tagName: 'figure', + classNames: 'image-file', + 'data-test-image-file': true, + + src: null, + alt: null, + size: null, + + // Set by updateImageMeta + width: 0, + height: 0, + + fileName: computed('src', function() { + if (!this.src) return; + return this.src.includes('/') ? this.src.match(/^.*\/(.*)$/)[1] : this.src; + }), + + updateImageMeta(event) { + const img = event.target; + this.setProperties({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + }, +}); diff --git a/ui/app/components/streaming-file.js b/ui/app/components/streaming-file.js new file mode 100644 index 00000000000..9a85d41a19f --- /dev/null +++ b/ui/app/components/streaming-file.js @@ -0,0 +1,96 @@ +import Component from '@ember/component'; +import { run } from '@ember/runloop'; +import { task } from 'ember-concurrency'; +import WindowResizable from 'nomad-ui/mixins/window-resizable'; + +export default Component.extend(WindowResizable, { + tagName: 'pre', + classNames: ['cli-window'], + 'data-test-log-cli': true, + + mode: 'streaming', // head, tail, streaming + isStreaming: true, + logger: null, + + didReceiveAttrs() { + if (!this.logger) { + return; + } + + run.scheduleOnce('actions', () => { + switch (this.mode) { + case 'head': + this.head.perform(); + break; + case 'tail': + this.tail.perform(); + break; + case 'streaming': + if (this.isStreaming) { + this.stream.perform(); + } else { + this.logger.stop(); + } + break; + } + }); + }, + + didInsertElement() { + this.fillAvailableHeight(); + }, + + windowResizeHandler() { + run.once(this, this.fillAvailableHeight); + }, + + fillAvailableHeight() { + // This math is arbitrary and far from bulletproof, but the UX + // of having the log window fill available height is worth the hack. + const margins = 30; // Account for padding and margin on either side of the CLI + const cliWindow = this.element; + cliWindow.style.height = `${window.innerHeight - cliWindow.offsetTop - margins}px`; + }, + + head: task(function*() { + yield this.get('logger.gotoHead').perform(); + run.scheduleOnce('afterRender', () => { + this.element.scrollTop = 0; + }); + }), + + tail: task(function*() { + yield this.get('logger.gotoTail').perform(); + run.scheduleOnce('afterRender', () => { + const cliWindow = this.element; + cliWindow.scrollTop = cliWindow.scrollHeight; + }); + }), + + synchronizeScrollPosition(force = false) { + const cliWindow = this.element; + if (cliWindow.scrollHeight - cliWindow.scrollTop < 10 || force) { + // If the window is approximately scrolled to the bottom, follow the log + cliWindow.scrollTop = cliWindow.scrollHeight; + } + }, + + stream: task(function*() { + // Force the scroll position to the bottom of the window when starting streaming + this.logger.one('tick', () => { + run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition(true)); + }); + + // Follow the log if the scroll position is near the bottom of the cli window + this.logger.on('tick', () => { + run.scheduleOnce('afterRender', () => this.synchronizeScrollPosition()); + }); + + yield this.logger.startStreaming(); + this.logger.off('tick'); + }), + + willDestroy() { + this.logger.stop(); + }, +}); diff --git a/ui/app/components/task-file.js b/ui/app/components/task-file.js new file mode 100644 index 00000000000..03f4e52c4e0 --- /dev/null +++ b/ui/app/components/task-file.js @@ -0,0 +1,148 @@ +import { inject as service } from '@ember/service'; +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { gt } from '@ember/object/computed'; +import { equal } from '@ember/object/computed'; +import RSVP from 'rsvp'; +import Log from 'nomad-ui/utils/classes/log'; +import timeout from 'nomad-ui/utils/timeout'; + +export default Component.extend({ + token: service(), + + classNames: ['boxed-section', 'task-log'], + + 'data-test-file-viewer': true, + + allocation: null, + task: null, + file: null, + stat: null, // { Name, IsDir, Size, FileMode, ModTime, ContentType } + + // When true, request logs from the server agent + useServer: false, + + // When true, logs cannot be fetched from either the client or the server + noConnection: false, + + clientTimeout: 1000, + serverTimeout: 5000, + + mode: 'head', + + fileComponent: computed('stat.ContentType', function() { + const contentType = this.stat.ContentType || ''; + + if (contentType.startsWith('image/')) { + return 'image'; + } else if (contentType.startsWith('text/') || contentType.startsWith('application/json')) { + return 'stream'; + } else { + return 'unknown'; + } + }), + + isLarge: gt('stat.Size', 50000), + + fileTypeIsUnknown: equal('fileComponent', 'unknown'), + isStreamable: equal('fileComponent', 'stream'), + isStreaming: false, + + catUrl: computed('allocation.id', 'task.name', 'file', function() { + const encodedPath = encodeURIComponent(`${this.task.name}/${this.file}`); + return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`; + }), + + fetchMode: computed('isLarge', 'mode', function() { + if (this.mode === 'streaming') { + return 'stream'; + } + + if (!this.isLarge) { + return 'cat'; + } else if (this.mode === 'head' || this.mode === 'tail') { + return 'readat'; + } + }), + + fileUrl: computed( + 'allocation.id', + 'allocation.node.httpAddr', + 'fetchMode', + 'useServer', + function() { + const address = this.get('allocation.node.httpAddr'); + const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`; + return this.useServer ? url : `//${address}${url}`; + } + ), + + fileParams: computed('task.name', 'file', 'mode', function() { + // The Log class handles encoding query params + const path = `${this.task.name}/${this.file}`; + + switch (this.mode) { + case 'head': + return { path, offset: 0, limit: 50000 }; + case 'tail': + return { path, offset: this.stat.Size - 50000, limit: 50000 }; + case 'streaming': + return { path, offset: 50000, origin: 'end' }; + default: + return { path }; + } + }), + + logger: computed('fileUrl', 'fileParams', 'mode', function() { + // The cat and readat APIs are in plainText while the stream API is always encoded. + const plainText = this.mode === 'head' || this.mode === 'tail'; + + // If the file request can't settle in one second, the client + // must be unavailable and the server should be used instead + const timing = this.useServer ? this.serverTimeout : this.clientTimeout; + const logFetch = url => + RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then( + response => { + if (!response || !response.ok) { + this.nextErrorState(response); + } + return response; + }, + error => this.nextErrorState(error) + ); + + return Log.create({ + logFetch, + plainText, + params: this.fileParams, + url: this.fileUrl, + }); + }), + + nextErrorState(error) { + if (this.useServer) { + this.set('noConnection', true); + } else { + this.send('failoverToServer'); + } + throw error; + }, + + actions: { + toggleStream() { + this.set('mode', 'streaming'); + this.toggleProperty('isStreaming'); + }, + gotoHead() { + this.set('mode', 'head'); + this.set('isStreaming', false); + }, + gotoTail() { + this.set('mode', 'tail'); + this.set('isStreaming', false); + }, + failoverToServer() { + this.set('useServer', true); + }, + }, +}); diff --git a/ui/app/components/task-log.js b/ui/app/components/task-log.js index c40e4a28409..b5cda3eed3a 100644 --- a/ui/app/components/task-log.js +++ b/ui/app/components/task-log.js @@ -1,14 +1,11 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; -import { run } from '@ember/runloop'; import RSVP from 'rsvp'; -import { task } from 'ember-concurrency'; import { logger } from 'nomad-ui/utils/classes/log'; -import WindowResizable from 'nomad-ui/mixins/window-resizable'; import timeout from 'nomad-ui/utils/timeout'; -export default Component.extend(WindowResizable, { +export default Component.extend({ token: service(), classNames: ['boxed-section', 'task-log'], @@ -25,26 +22,8 @@ export default Component.extend(WindowResizable, { clientTimeout: 1000, serverTimeout: 5000, - didReceiveAttrs() { - if (this.allocation && this.task) { - this.send('toggleStream'); - } - }, - - didInsertElement() { - this.fillAvailableHeight(); - }, - - windowResizeHandler() { - run.once(this, this.fillAvailableHeight); - }, - - fillAvailableHeight() { - // This math is arbitrary and far from bulletproof, but the UX - // of having the log window fill available height is worth the hack. - const cliWindow = this.$('.cli-window'); - cliWindow.height(window.innerHeight - cliWindow.offset().top - 25); - }, + isStreaming: true, + streamMode: 'streaming', mode: 'stdout', @@ -75,56 +54,28 @@ export default Component.extend(WindowResizable, { this.set('noConnection', true); } else { this.send('failoverToServer'); - this.stream.perform(); } throw error; } ); }), - head: task(function*() { - yield this.get('logger.gotoHead').perform(); - run.scheduleOnce('afterRender', () => { - this.$('.cli-window').scrollTop(0); - }); - }), - - tail: task(function*() { - yield this.get('logger.gotoTail').perform(); - run.scheduleOnce('afterRender', () => { - const cliWindow = this.$('.cli-window'); - cliWindow.scrollTop(cliWindow[0].scrollHeight); - }); - }), - - stream: task(function*() { - this.logger.on('tick', () => { - run.scheduleOnce('afterRender', () => { - const cliWindow = this.$('.cli-window'); - cliWindow.scrollTop(cliWindow[0].scrollHeight); - }); - }); - - yield this.logger.startStreaming(); - this.logger.off('tick'); - }), - - willDestroy() { - this.logger.stop(); - }, - actions: { setMode(mode) { this.logger.stop(); this.set('mode', mode); - this.stream.perform(); }, toggleStream() { - if (this.get('logger.isStreaming')) { - this.logger.stop(); - } else { - this.stream.perform(); - } + this.set('streamMode', 'streaming'); + this.toggleProperty('isStreaming'); + }, + gotoHead() { + this.set('streamMode', 'head'); + this.set('isStreaming', false); + }, + gotoTail() { + this.set('streamMode', 'tail'); + this.set('isStreaming', false); }, failoverToServer() { this.set('useServer', true); diff --git a/ui/app/components/task-subnav.js b/ui/app/components/task-subnav.js new file mode 100644 index 00000000000..0aab1e6f459 --- /dev/null +++ b/ui/app/components/task-subnav.js @@ -0,0 +1,14 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { equal, or } from '@ember/object/computed'; + +export default Component.extend({ + router: service(), + + tagName: '', + + fsIsActive: equal('router.currentRouteName', 'allocations.allocation.task.fs'), + fsRootIsActive: equal('router.currentRouteName', 'allocations.allocation.task.fs-root'), + + filesLinkActive: or('fsIsActive', 'fsRootIsActive'), +}); diff --git a/ui/app/controllers/allocations/allocation/task/fs-root.js b/ui/app/controllers/allocations/allocation/task/fs-root.js new file mode 100644 index 00000000000..2297a800ea1 --- /dev/null +++ b/ui/app/controllers/allocations/allocation/task/fs-root.js @@ -0,0 +1,3 @@ +import FSController from './fs'; + +export default FSController.extend(); diff --git a/ui/app/controllers/allocations/allocation/task/fs.js b/ui/app/controllers/allocations/allocation/task/fs.js new file mode 100644 index 00000000000..bfece091338 --- /dev/null +++ b/ui/app/controllers/allocations/allocation/task/fs.js @@ -0,0 +1,54 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import { filterBy } from '@ember/object/computed'; + +export default Controller.extend({ + queryParams: { + sortProperty: 'sort', + sortDescending: 'desc', + }, + + sortProperty: 'Name', + sortDescending: false, + + path: null, + task: null, + directoryEntries: null, + isFile: null, + stat: null, + + directories: filterBy('directoryEntries', 'IsDir'), + files: filterBy('directoryEntries', 'IsDir', false), + + pathWithLeadingSlash: computed('path', function() { + const path = this.path; + + if (path.startsWith('/')) { + return path; + } else { + return `/${path}`; + } + }), + + sortedDirectoryEntries: computed( + 'directoryEntries.[]', + 'sortProperty', + 'sortDescending', + function() { + const sortProperty = this.sortProperty; + + const directorySortProperty = sortProperty === 'Size' ? 'Name' : sortProperty; + + const sortedDirectories = this.directories.sortBy(directorySortProperty); + const sortedFiles = this.files.sortBy(sortProperty); + + const sortedDirectoryEntries = sortedDirectories.concat(sortedFiles); + + if (this.sortDescending) { + return sortedDirectoryEntries.reverse(); + } else { + return sortedDirectoryEntries; + } + } + ), +}); diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js index 66ef8fbe303..754a68f61e2 100644 --- a/ui/app/models/task-state.js +++ b/ui/app/models/task-state.js @@ -47,4 +47,12 @@ export default Fragment.extend({ restart() { return this.allocation.restart(this.name); }, + + ls(path) { + return this.store.adapterFor('task-state').ls(this, path); + }, + + stat(path) { + return this.store.adapterFor('task-state').stat(this, path); + }, }); diff --git a/ui/app/router.js b/ui/app/router.js index 7a4015c28e2..d5ade3e838d 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -31,6 +31,8 @@ Router.map(function() { this.route('allocation', { path: '/:allocation_id' }, function() { this.route('task', { path: '/:name' }, function() { this.route('logs'); + this.route('fs-root', { path: '/fs' }); + this.route('fs', { path: '/fs/*path' }); }); }); }); diff --git a/ui/app/routes/allocations/allocation/task/fs-root.js b/ui/app/routes/allocations/allocation/task/fs-root.js new file mode 100644 index 00000000000..81f1c1ee653 --- /dev/null +++ b/ui/app/routes/allocations/allocation/task/fs-root.js @@ -0,0 +1,5 @@ +import FSRoute from './fs'; + +export default FSRoute.extend({ + templateName: 'allocations/allocation/task/fs', +}); diff --git a/ui/app/routes/allocations/allocation/task/fs.js b/ui/app/routes/allocations/allocation/task/fs.js new file mode 100644 index 00000000000..cc0b6213ae2 --- /dev/null +++ b/ui/app/routes/allocations/allocation/task/fs.js @@ -0,0 +1,37 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; +import notifyError from 'nomad-ui/utils/notify-error'; + +export default Route.extend({ + model({ path = '/' }) { + const decodedPath = decodeURIComponent(path); + const task = this.modelFor('allocations.allocation.task'); + + const pathWithTaskName = `${task.name}${decodedPath.startsWith('/') ? '' : '/'}${decodedPath}`; + + return RSVP.all([task.stat(pathWithTaskName), task.get('allocation.node')]) + .then(([statJson]) => { + if (statJson.IsDir) { + return RSVP.hash({ + path: decodedPath, + task, + directoryEntries: task.ls(pathWithTaskName).catch(notifyError(this)), + isFile: false, + }); + } else { + return { + path: decodedPath, + task, + isFile: true, + stat: statJson, + }; + } + }) + .catch(notifyError(this)); + }, + + setupController(controller, { path, task, directoryEntries, isFile, stat } = {}) { + this._super(...arguments); + controller.setProperties({ path, task, directoryEntries, isFile, stat }); + }, +}); diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index aa74aea6236..6941c244323 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -8,8 +8,10 @@ @import './components/ember-power-select'; @import './components/empty-message'; @import './components/error-container'; +@import './components/fs-explorer'; @import './components/gutter'; @import './components/gutter-toggle'; +@import './components/image-file.scss'; @import './components/inline-definitions'; @import './components/job-diff'; @import './components/loading-spinner'; diff --git a/ui/app/styles/components/boxed-section.scss b/ui/app/styles/components/boxed-section.scss index dacc562f5f1..51469686853 100644 --- a/ui/app/styles/components/boxed-section.scss +++ b/ui/app/styles/components/boxed-section.scss @@ -18,6 +18,10 @@ .pull-right { margin-left: auto; } + + &.is-compact { + padding: 0.75em; + } } .boxed-section-head { diff --git a/ui/app/styles/components/empty-message.scss b/ui/app/styles/components/empty-message.scss index 3f00021ebdc..944a92011da 100644 --- a/ui/app/styles/components/empty-message.scss +++ b/ui/app/styles/components/empty-message.scss @@ -18,4 +18,8 @@ color: $grey; } } + + &.is-hollow { + background: transparent; + } } diff --git a/ui/app/styles/components/fs-explorer.scss b/ui/app/styles/components/fs-explorer.scss new file mode 100644 index 00000000000..3816471ad0d --- /dev/null +++ b/ui/app/styles/components/fs-explorer.scss @@ -0,0 +1,74 @@ +.fs-explorer { + width: 100%; + + .table.boxed-section-body.is-full-bleed { + border: 1px solid $grey-blue; + } + + tbody { + a { + text-decoration: none; + color: inherit; + + &:hover { + .name { + text-decoration: underline; + } + } + } + } + + .breadcrumb a, + tbody a { + position: relative; + + // This is adapted from Bulma’s .button.is-loading::after + &.ember-transitioning-in::after { + animation: spinAround 500ms infinite linear; + border: 2px solid $grey-light; + border-radius: 290486px; + border-right-color: transparent; + border-top-color: transparent; + content: ''; + display: block; + height: 1em; + width: 1em; + position: absolute; + right: -1.5em; + top: calc(50% - (1em / 2)); + } + } + + .breadcrumb { + margin: 0; + + li::before { + color: $grey-light; + } + + a { + padding-top: 0; + padding-bottom: 0; + color: $blue; + opacity: 1; + font-weight: $weight-bold; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &.ember-transitioning-in { + margin-right: 1.5em; + + &::after { + right: -1em; + } + } + } + + .is-active a { + color: $black; + } + } +} diff --git a/ui/app/styles/components/image-file.scss b/ui/app/styles/components/image-file.scss new file mode 100644 index 00000000000..a9b6d59201f --- /dev/null +++ b/ui/app/styles/components/image-file.scss @@ -0,0 +1,20 @@ +.image-file { + width: 100%; + height: 100%; + background: $white; + text-align: center; + color: $text; + + .image-file-image { + margin: auto; + } + + .image-file-caption { + margin-top: 0.5em; + } + + .image-file-caption-primary { + display: block; + color: $grey; + } +} diff --git a/ui/app/styles/components/page-layout.scss b/ui/app/styles/components/page-layout.scss index b654dfb1645..fa69c11f1da 100644 --- a/ui/app/styles/components/page-layout.scss +++ b/ui/app/styles/components/page-layout.scss @@ -36,6 +36,7 @@ &.is-right { margin-left: $gutter-width; + width: calc(100% - #{$gutter-width}); } @media #{$mq-hidden-gutter} { @@ -51,6 +52,7 @@ &.is-right { margin-left: 0; + width: 100%; } } } diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index bad307ee8c2..41a90d585e1 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -30,7 +30,7 @@ $button-box-shadow-standard: 0 2px 0 0 rgba($grey, 0.2); &.is-compact { padding: 0.25em 0.75em; - margin: -0.25em -0.25em -0.25em 0; + margin: -0.25em 0; &.pull-right { margin-right: -1em; diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index f0355dffa00..181230637aa 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -33,6 +33,10 @@ td { padding: 0.75em; } + + .is-selectable a { + padding: 0.75em; + } } &.is-darkened { @@ -149,33 +153,50 @@ padding: 0.75em 1.5em; width: 100%; text-decoration: none; + position: relative; &:hover { background-color: darken($white-ter, 5%); } + + &::before, + &::after { + position: absolute; + pointer-events: none; + color: $grey-light; + } + + &::after { + transform: translateX(6px); + } + + &::before { + transform: translateX(-20px); + } } } &.is-active { - position: relative; - - &::after { - content: ''; - width: 10px; - right: 1.5em; - top: 0.75em; - bottom: 0.75em; - position: absolute; - display: block; - pointer-events: none; + &.desc a::after { + content: '⬆'; } - &.asc::after { + &.asc a::after { content: '⬇'; } - &.desc::after { - content: '⬆'; + &.has-text-right { + a::after { + content: none; + } + + &.desc a::before { + content: '⬆'; + } + + &.asc a::before { + content: '⬇'; + } } } diff --git a/ui/app/styles/core/tabs.scss b/ui/app/styles/core/tabs.scss index f7fa0a84419..283f1252709 100644 --- a/ui/app/styles/core/tabs.scss +++ b/ui/app/styles/core/tabs.scss @@ -41,6 +41,10 @@ + * { margin-top: 5em; + + &.is-closer { + margin-top: calc(3.5em + 1px); + } } @media #{$mq-hidden-gutter} { diff --git a/ui/app/templates/allocations/allocation/task/fs.hbs b/ui/app/templates/allocations/allocation/task/fs.hbs new file mode 100644 index 00000000000..45a6b437efa --- /dev/null +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -0,0 +1,49 @@ +{{title pathWithLeadingSlash " - Task " task.name " filesystem"}} +{{task-subnav task=task}} +
+ {{#if task.isRunning}} + {{#if isFile}} + {{#task-file allocation=task.allocation task=task file=path stat=stat class="fs-explorer"}} + {{fs-breadcrumbs task=task path=path}} + {{/task-file}} + {{else}} +
+
+ {{fs-breadcrumbs task=task path=path}} +
+ {{#if directoryEntries}} + {{#list-table + source=sortedDirectoryEntries + sortProperty=sortProperty + sortDescending=sortDescending + class="boxed-section-body is-full-bleed is-compact" as |t|}} + {{#t.head}} + {{#t.sort-by prop="Name" class="is-two-thirds"}}Name{{/t.sort-by}} + {{#t.sort-by prop="Size" class="has-text-right"}}File Size{{/t.sort-by}} + {{#t.sort-by prop="ModTime" class="has-text-right"}}Last Modified{{/t.sort-by}} + {{/t.head}} + {{#t.body as |row|}} + {{fs-directory-entry path=path task=task entry=row.model}} + {{/t.body}} + {{/list-table}} + {{else}} +
+
+

No Files

+

+ Directory is currently empty. +

+
+
+ {{/if}} +
+ {{/if}} + {{else}} +
+

Task is not Running

+

+ Cannot access files of a task that is not running. +

+
+ {{/if}} +
diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index ee711f6fe65..7ecc65e2700 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,5 +1,5 @@ {{title "Task " model.name}} -{{partial "allocations/allocation/task/subnav"}} +{{task-subnav task=model}}
{{#if error}}
diff --git a/ui/app/templates/allocations/allocation/task/logs.hbs b/ui/app/templates/allocations/allocation/task/logs.hbs index 4b1e74d5272..d52253254e2 100644 --- a/ui/app/templates/allocations/allocation/task/logs.hbs +++ b/ui/app/templates/allocations/allocation/task/logs.hbs @@ -1,5 +1,5 @@ {{title "Task " model.name " logs"}} -{{partial "allocations/allocation/task/subnav"}} +{{task-subnav task=model}}
{{task-log data-test-task-log allocation=model.allocation task=model.name}}
diff --git a/ui/app/templates/allocations/allocation/task/subnav.hbs b/ui/app/templates/allocations/allocation/task/subnav.hbs deleted file mode 100644 index 5bacd994071..00000000000 --- a/ui/app/templates/allocations/allocation/task/subnav.hbs +++ /dev/null @@ -1,6 +0,0 @@ -
-
    -
  • {{#link-to "allocations.allocation.task.index" model.allocation model activeClass="is-active"}}Overview{{/link-to}}
  • -
  • {{#link-to "allocations.allocation.task.logs" model.allocation model activeClass="is-active"}}Logs{{/link-to}}
  • -
-
diff --git a/ui/app/templates/components/fs-breadcrumbs.hbs b/ui/app/templates/components/fs-breadcrumbs.hbs new file mode 100644 index 00000000000..97aba6398a5 --- /dev/null +++ b/ui/app/templates/components/fs-breadcrumbs.hbs @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/ui/app/templates/components/fs-directory-entry.hbs b/ui/app/templates/components/fs-directory-entry.hbs new file mode 100644 index 00000000000..cc55d30ff6f --- /dev/null +++ b/ui/app/templates/components/fs-directory-entry.hbs @@ -0,0 +1,15 @@ + + + {{#link-to "allocations.allocation.task.fs" task.allocation task pathToEntry activeClass="is-active"}} + {{#if entry.IsDir}} + {{x-icon "folder-outline"}} + {{else}} + {{x-icon "file-outline"}} + {{/if}} + + {{entry.Name}} + {{/link-to}} + + {{#unless entry.IsDir}}{{format-bytes entry.Size}}{{/unless}} + {{moment-from entry.ModTime interval=1000}} + diff --git a/ui/app/templates/components/image-file.hbs b/ui/app/templates/components/image-file.hbs new file mode 100644 index 00000000000..9228740d487 --- /dev/null +++ b/ui/app/templates/components/image-file.hbs @@ -0,0 +1,11 @@ + + {{or + +
+ + {{fileName}} + {{#if (and width height)}} + ({{width}}px × {{height}}px{{#if size}}, {{format-bytes size}}{{/if}}) + {{/if}} + +
\ No newline at end of file diff --git a/ui/app/templates/components/streaming-file.hbs b/ui/app/templates/components/streaming-file.hbs new file mode 100644 index 00000000000..fa97a815e2e --- /dev/null +++ b/ui/app/templates/components/streaming-file.hbs @@ -0,0 +1 @@ +{{logger.output}} \ No newline at end of file diff --git a/ui/app/templates/components/task-file.hbs b/ui/app/templates/components/task-file.hbs new file mode 100644 index 00000000000..bed5c27620f --- /dev/null +++ b/ui/app/templates/components/task-file.hbs @@ -0,0 +1,39 @@ +{{#if noConnection}} +
+

Cannot fetch file

+

The files for this task are inaccessible. Check the condition of the client the allocation is on.

+
+{{/if}} +
+ {{yield}} + + + {{#if (not fileTypeIsUnknown)}} + View Raw File + {{/if}} + {{#if (and isLarge isStreamable)}} + + + {{/if}} + {{#if isStreamable}} + + {{/if}} + +
+
+ {{#if (eq fileComponent "stream")}} + {{streaming-file logger=logger mode=mode isStreaming=isStreaming}} + {{else if (eq fileComponent "image")}} + {{image-file src=catUrl alt=stat.Name size=stat.Size}} + {{else}} +
+

Unsupported File Type

+

The Nomad UI could not render this file, but you can still view the file directly.

+

+ View Raw File +

+
+ {{/if}} +
diff --git a/ui/app/templates/components/task-log.hbs b/ui/app/templates/components/task-log.hbs index 6c3991a359c..89a363747ae 100644 --- a/ui/app/templates/components/task-log.hbs +++ b/ui/app/templates/components/task-log.hbs @@ -10,13 +10,13 @@ - - + +
-
{{logger.output}}
+ {{streaming-file logger=logger mode=streamMode isStreaming=isStreaming}}
diff --git a/ui/app/templates/components/task-subnav.hbs b/ui/app/templates/components/task-subnav.hbs new file mode 100644 index 00000000000..4872d032e30 --- /dev/null +++ b/ui/app/templates/components/task-subnav.hbs @@ -0,0 +1,7 @@ +
+ +
diff --git a/ui/app/utils/classes/log.js b/ui/app/utils/classes/log.js index 82f96ebc3a9..597dfdc880a 100644 --- a/ui/app/utils/classes/log.js +++ b/ui/app/utils/classes/log.js @@ -8,6 +8,7 @@ import queryString from 'query-string'; import { task } from 'ember-concurrency'; import StreamLogger from 'nomad-ui/utils/classes/stream-logger'; import PollLogger from 'nomad-ui/utils/classes/poll-logger'; +import { decode } from 'nomad-ui/utils/stream-frames'; import Anser from 'anser'; const MAX_OUTPUT_LENGTH = 50000; @@ -20,6 +21,7 @@ const Log = EmberObject.extend(Evented, { url: '', params: computed(() => ({})), + plainText: false, logFetch() { assert('Log objects need a logFetch method, which should have an interface like window.fetch'); }, @@ -40,6 +42,7 @@ const Log = EmberObject.extend(Evented, { // the logPointer is pointed at head or tail output: computed('logPointer', 'head', 'tail', function() { let logs = this.logPointer === 'head' ? this.head : this.tail; + logs = logs.replace(//g, '>'); let colouredLogs = Anser.ansiToHtml(logs); return htmlSafe(colouredLogs); }), @@ -72,16 +75,19 @@ const Log = EmberObject.extend(Evented, { gotoHead: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - plain: true, - origin: 'start', - offset: 0, - }) + assign( + { + origin: 'start', + offset: 0, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; this.stop(); - let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + let text = this.plainText ? response : decode(response).message; if (text && text.length > MAX_OUTPUT_LENGTH) { text = text.substr(0, MAX_OUTPUT_LENGTH); @@ -94,16 +100,19 @@ const Log = EmberObject.extend(Evented, { gotoTail: task(function*() { const logFetch = this.logFetch; const queryParams = queryString.stringify( - assign(this.params, { - plain: true, - origin: 'end', - offset: MAX_OUTPUT_LENGTH, - }) + assign( + { + origin: 'end', + offset: MAX_OUTPUT_LENGTH, + }, + this.params + ) ); const url = `${this.url}?${queryParams}`; this.stop(); - let text = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + const response = yield logFetch(url).then(res => res.text(), fetchFailure(url)); + let text = this.plainText ? response : decode(response).message; this.set('tail', text); this.set('logPointer', 'tail'); diff --git a/ui/app/utils/classes/poll-logger.js b/ui/app/utils/classes/poll-logger.js index 2c18e3d1463..ce42fb8dc96 100644 --- a/ui/app/utils/classes/poll-logger.js +++ b/ui/app/utils/classes/poll-logger.js @@ -1,5 +1,6 @@ import EmberObject from '@ember/object'; import { task, timeout } from 'ember-concurrency'; +import { decode } from 'nomad-ui/utils/stream-frames'; import AbstractLogger from './abstract-logger'; import { fetchFailure } from './log'; @@ -7,9 +8,7 @@ export default EmberObject.extend(AbstractLogger, { interval: 1000, start() { - return this.poll - .linked() - .perform(); + return this.poll.linked().perform(); }, stop() { @@ -29,15 +28,10 @@ export default EmberObject.extend(AbstractLogger, { let text = yield response.text(); if (text) { - const lines = text.replace(/\}\{/g, '}\n{').split('\n'); - const frames = lines - .map(line => JSON.parse(line)) - .filter(frame => frame.Data != null && frame.Offset != null); - - if (frames.length) { - frames.forEach(frame => (frame.Data = window.atob(frame.Data))); - this.set('endOffset', frames[frames.length - 1].Offset); - this.write(frames.mapBy('Data').join('')); + const { offset, message } = decode(text); + if (message) { + this.set('endOffset', offset); + this.write(message); } } diff --git a/ui/app/utils/classes/stream-logger.js b/ui/app/utils/classes/stream-logger.js index aad60a4f904..986d230a219 100644 --- a/ui/app/utils/classes/stream-logger.js +++ b/ui/app/utils/classes/stream-logger.js @@ -1,6 +1,7 @@ import EmberObject, { computed } from '@ember/object'; import { task } from 'ember-concurrency'; import TextDecoder from 'nomad-ui/utils/classes/text-decoder'; +import { decode } from 'nomad-ui/utils/stream-frames'; import AbstractLogger from './abstract-logger'; import { fetchFailure } from './log'; @@ -60,13 +61,10 @@ export default EmberObject.extend(AbstractLogger, { // Assuming the logs endpoint never returns nested JSON (it shouldn't), at this // point chunk is a series of valid JSON objects with no delimiter. - const lines = chunk.replace(/\}\{/g, '}\n{').split('\n'); - const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data); - - if (frames.length) { - frames.forEach(frame => (frame.Data = window.atob(frame.Data))); - this.set('endOffset', frames[frames.length - 1].Offset); - this.write(frames.mapBy('Data').join('')); + const { offset, message } = decode(chunk); + if (message) { + this.set('endOffset', offset); + this.write(message); } } }); diff --git a/ui/app/utils/stream-frames.js b/ui/app/utils/stream-frames.js new file mode 100644 index 00000000000..bd425340655 --- /dev/null +++ b/ui/app/utils/stream-frames.js @@ -0,0 +1,26 @@ +/** + * + * @param {string} chunk + * Chunk is an undelimited string of valid JSON objects as returned by a streaming endpoint. + * Each JSON object in a chunk contains two properties: + * Offset {number} The index from the beginning of the stream at which this JSON object starts + * Data {string} A base64 encoded string representing the contents of the stream this JSON + * object represents. + */ +export function decode(chunk) { + const lines = chunk + .replace(/\}\{/g, '}\n{') + .split('\n') + .without(''); + const frames = lines.map(line => JSON.parse(line)).filter(frame => frame.Data); + + if (frames.length) { + frames.forEach(frame => (frame.Data = window.atob(frame.Data))); + return { + offset: frames[frames.length - 1].Offset, + message: frames.mapBy('Data').join(''), + }; + } + + return {}; +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 24ddf66618f..9098e85fb98 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -11,6 +11,15 @@ export function findLeader(schema) { return `${agent.address}:${agent.tags.port}`; } +export function filesForPath(allocFiles, filterPath) { + return allocFiles.where( + file => + (!filterPath || file.path.startsWith(filterPath)) && + file.path.length > filterPath.length && + !file.path.substr(filterPath.length + 1).includes('/') + ); +} + export default function() { this.timing = 0; // delay for each request, automatically set to 0 during testing @@ -304,6 +313,70 @@ export default function() { return logEncode(logFrames, logFrames.length - 1); }; + const clientAllocationFSLsHandler = function({ allocFiles }, { queryParams }) { + // Ignore the task name at the beginning of the path + const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1); + const files = filesForPath(allocFiles, filterPath); + return this.serialize(files); + }; + + const clientAllocationFSStatHandler = function({ allocFiles }, { queryParams }) { + // Ignore the task name at the beginning of the path + const filterPath = queryParams.path.substr(queryParams.path.indexOf('/') + 1); + + // Root path + if (!filterPath) { + return this.serialize({ + IsDir: true, + ModTime: new Date(), + }); + } + + // Either a file or a nested directory + const file = allocFiles.where({ path: filterPath }).models[0]; + return this.serialize(file); + }; + + const clientAllocationCatHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + return file.body; + }; + + const clientAllocationStreamHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + + // Pretender, and therefore Mirage, doesn't support streaming responses. + return file.body; + }; + + const clientAllocationReadAtHandler = function({ allocFiles }, { queryParams }) { + const [file, err] = fileOrError(allocFiles, queryParams.path); + + if (err) return err; + return file.body.substr(queryParams.offset || 0, queryParams.limit); + }; + + const fileOrError = function(allocFiles, path, message = 'Operation not allowed on a directory') { + // Ignore the task name at the beginning of the path + const filterPath = path.substr(path.indexOf('/') + 1); + + // Root path + if (!filterPath) { + return [null, new Response(400, {}, message)]; + } + + const file = allocFiles.where({ path: filterPath }).models[0]; + if (file.isDir) { + return [null, new Response(400, {}, message)]; + } + + return [file, null]; + }; + // Client requests are available on the server and the client this.put('/client/allocation/:id/restart', function() { return new Response(204, {}, ''); @@ -312,6 +385,12 @@ export default function() { this.get('/client/allocation/:id/stats', clientAllocationStatsHandler); this.get('/client/fs/logs/:allocation_id', clientAllocationLog); + this.get('/client/fs/ls/:allocation_id', clientAllocationFSLsHandler); + this.get('/client/fs/stat/:allocation_id', clientAllocationFSStatHandler); + this.get('/client/fs/cat/:allocation_id', clientAllocationCatHandler); + this.get('/client/fs/stream/:allocation_id', clientAllocationStreamHandler); + this.get('/client/fs/readat/:allocation_id', clientAllocationReadAtHandler); + this.get('/client/stats', function({ clientStats }, { queryParams }) { const seed = Math.random(); if (seed > 0.8) { @@ -332,6 +411,12 @@ export default function() { this.get(`http://${host}/v1/client/allocation/:id/stats`, clientAllocationStatsHandler); this.get(`http://${host}/v1/client/fs/logs/:allocation_id`, clientAllocationLog); + this.get(`http://${host}/v1/client/fs/ls/:allocation_id`, clientAllocationFSLsHandler); + this.get(`http://${host}/v1/client/stat/ls/:allocation_id`, clientAllocationFSStatHandler); + this.get(`http://${host}/v1/client/fs/cat/:allocation_id`, clientAllocationCatHandler); + this.get(`http://${host}/v1/client/fs/stream/:allocation_id`, clientAllocationStreamHandler); + this.get(`http://${host}/v1/client/fs/readat/:allocation_id`, clientAllocationReadAtHandler); + this.get(`http://${host}/v1/client/stats`, function({ clientStats }) { return this.serialize(clientStats.find(host)); }); diff --git a/ui/mirage/factories/alloc-file.js b/ui/mirage/factories/alloc-file.js new file mode 100644 index 00000000000..db9f4a9261f --- /dev/null +++ b/ui/mirage/factories/alloc-file.js @@ -0,0 +1,113 @@ +import { Factory, faker, trait } from 'ember-cli-mirage'; +import { pickOne } from '../utils'; + +const REF_TIME = new Date(); +const TROUBLESOME_CHARACTERS = '🏆 💃 🤩 🙌🏿 🖨 ? ; %'.split(' '); +const makeWord = () => Math.round(Math.random() * 10000000 + 50000).toString(36); +const makeSentence = (count = 10) => + new Array(count) + .fill(null) + .map(makeWord) + .join(' '); + +const fileTypeMapping = { + svg: 'image/svg', + txt: 'text/plain', + json: 'application/json', + app: 'application/octet-stream', + exe: 'application/octet-stream', +}; + +const fileBodyMapping = { + svg: () => ` + + + + + + + + + `, + txt: () => + new Array(3000) + .fill(null) + .map((_, i) => { + const date = new Date(2019, 6, 23); + date.setSeconds(i * 5); + return `${date.toISOString()} ${makeSentence(Math.round(Math.random() * 5 + 7))}`; + }) + .join('\n'), + json: () => + JSON.stringify({ + key: 'value', + array: [1, 'two', [3]], + deep: { + ly: { + nest: 'ed', + }, + }, + }), +}; + +export default Factory.extend({ + id: i => i, + + isDir: faker.random.boolean, + + // Depth is used to recursively create nested directories. + depth: 0, + parent: null, + + fileType() { + if (this.isDir) return 'dir'; + return pickOne(['svg', 'txt', 'json', 'app', 'exe']); + }, + + contentType() { + return fileTypeMapping[this.fileType] || null; + }, + + path() { + if (this.parent) { + return `${this.parent.path}/${this.name}`; + } + + return this.name; + }, + + name() { + return `${faker.hacker.noun().dasherize()}-${pickOne(TROUBLESOME_CHARACTERS)}${ + this.isDir ? '' : `.${this.fileType}` + }`; + }, + + body() { + const strategy = fileBodyMapping[this.fileType]; + return strategy ? strategy() : ''; + }, + + size() { + return this.body.length; + }, + + modTime: () => faker.date.past(2 / 365, REF_TIME), + + dir: trait({ + isDir: true, + afterCreate(allocFile, server) { + // create files for the directory + if (allocFile.depth > 0) { + server.create('allocFile', 'dir', { parent: allocFile, depth: allocFile.depth - 1 }); + } + + server.createList('allocFile', faker.random.number({ min: 1, max: 3 }), 'file', { + parent: allocFile, + }); + }, + }), + + file: trait({ + isDir: false, + }), +}); diff --git a/ui/mirage/models/alloc-file.js b/ui/mirage/models/alloc-file.js new file mode 100644 index 00000000000..23677eb033e --- /dev/null +++ b/ui/mirage/models/alloc-file.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + parent: belongsTo('alloc-file'), +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 62c4c427c3c..0a7269415bb 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -39,6 +39,8 @@ function smallCluster(server) { server.createList('agent', 3); server.createList('node', 5); server.createList('job', 5); + server.createList('allocFile', 5); + server.create('allocFile', 'dir', { depth: 2 }); } function mediumCluster(server) { diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js new file mode 100644 index 00000000000..0a098ba1ef8 --- /dev/null +++ b/ui/tests/acceptance/task-fs-test.js @@ -0,0 +1,385 @@ +import { currentURL, visit } from '@ember/test-helpers'; +import { Promise } from 'rsvp'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import moment from 'moment'; + +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import Response from 'ember-cli-mirage/response'; + +import { formatBytes } from 'nomad-ui/helpers/format-bytes'; +import { filesForPath } from 'nomad-ui/mirage/config'; + +import FS from 'nomad-ui/tests/pages/allocations/task/fs'; + +let allocation; +let task; +let files; + +const fileSort = (prop, files) => { + let dir = []; + let file = []; + files.forEach(f => { + if (f.isDir) { + dir.push(f); + } else { + file.push(f); + } + }); + + return dir.sortBy(prop).concat(file.sortBy(prop)); +}; + +module('Acceptance | task fs', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function() { + server.create('agent'); + server.create('node', 'forceIPv4'); + const job = server.create('job', { createAllocations: false }); + + allocation = server.create('allocation', { jobId: job.id, clientStatus: 'running' }); + task = server.schema.taskStates.where({ allocationId: allocation.id }).models[0]; + task.name = 'task-name'; + task.save(); + + // Reset files + files = []; + + // Nested files + files.push(server.create('allocFile', { isDir: true, name: 'directory' })); + files.push(server.create('allocFile', { isDir: true, name: 'another', parent: files[0] })); + files.push( + server.create('allocFile', 'file', { + name: 'something.txt', + fileType: 'txt', + parent: files[1], + }) + ); + + files.push(server.create('allocFile', { isDir: true, name: 'empty-directory' })); + files.push(server.create('allocFile', 'file', { fileType: 'txt' })); + files.push(server.create('allocFile', 'file', { fileType: 'txt' })); + }); + + test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) { + await FS.visit({ id: allocation.id, name: task.name }); + assert.equal(currentURL(), `/allocations/${allocation.id}/${task.name}/fs`, 'No redirect'); + }); + + test('when the task is not running, an empty state is shown', async function(assert) { + task.update({ + finishedAt: new Date(), + }); + + await FS.visit({ id: allocation.id, name: task.name }); + assert.ok(FS.hasEmptyState, 'Non-running task has no files'); + assert.ok( + FS.emptyState.headline.includes('Task is not Running'), + 'Empty state explains the condition' + ); + }); + + test('visiting /allocations/:allocation_id/:task_name/fs/:path', async function(assert) { + const paths = ['some-file.log', 'a/deep/path/to/a/file.log', '/', 'Unicode™®']; + + const testPath = async filePath => { + let pathWithLeadingSlash = filePath; + + if (!pathWithLeadingSlash.startsWith('/')) { + pathWithLeadingSlash = `/${filePath}`; + } + + await FS.visitPath({ id: allocation.id, name: task.name, path: filePath }); + assert.equal( + currentURL(), + `/allocations/${allocation.id}/${task.name}/fs/${encodeURIComponent(filePath)}`, + 'No redirect' + ); + assert.equal( + document.title, + `${pathWithLeadingSlash} - Task ${task.name} filesystem - Nomad` + ); + assert.equal(FS.breadcrumbsText, `${task.name} ${filePath.replace(/\//g, ' ')}`.trim()); + }; + + await paths.reduce(async (prev, filePath) => { + await prev; + return testPath(filePath); + }, Promise.resolve()); + }); + + test('navigating allocation filesystem', async function(assert) { + await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); + + const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models); + + assert.ok(FS.fileViewer.isHidden); + + assert.equal(FS.directoryEntries.length, 4); + + assert.equal(FS.breadcrumbsText, task.name); + + assert.equal(FS.breadcrumbs.length, 1); + assert.ok(FS.breadcrumbs[0].isActive); + assert.equal(FS.breadcrumbs[0].text, 'task-name'); + + FS.directoryEntries[0].as(directory => { + const fileRecord = sortedFiles[0]; + assert.equal(directory.name, fileRecord.name, 'directories should come first'); + assert.ok(directory.isDirectory); + assert.equal(directory.size, '', 'directory sizes are hidden'); + assert.equal(directory.lastModified, moment(fileRecord.modTime).fromNow()); + assert.notOk(directory.path.includes('//'), 'paths shouldn’t have redundant separators'); + }); + + FS.directoryEntries[2].as(file => { + const fileRecord = sortedFiles[2]; + assert.equal(file.name, fileRecord.name); + assert.ok(file.isFile); + assert.equal(file.size, formatBytes([fileRecord.size])); + assert.equal(file.lastModified, moment(fileRecord.modTime).fromNow()); + }); + + await FS.directoryEntries[0].visit(); + + assert.equal(FS.directoryEntries.length, 1); + + assert.equal(FS.breadcrumbs.length, 2); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`); + + assert.notOk(FS.breadcrumbs[0].isActive); + + assert.equal(FS.breadcrumbs[1].text, files[0].name); + assert.ok(FS.breadcrumbs[1].isActive); + + await FS.directoryEntries[0].visit(); + + assert.equal(FS.directoryEntries.length, 1); + assert.notOk( + FS.directoryEntries[0].path.includes('//'), + 'paths shouldn’t have redundant separators' + ); + + assert.equal(FS.breadcrumbs.length, 3); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name} ${files[1].name}`); + assert.equal(FS.breadcrumbs[2].text, files[1].name); + + assert.notOk( + FS.breadcrumbs[0].path.includes('//'), + 'paths shouldn’t have redundant separators' + ); + assert.notOk( + FS.breadcrumbs[1].path.includes('//'), + 'paths shouldn’t have redundant separators' + ); + + await FS.breadcrumbs[1].visit(); + assert.equal(FS.breadcrumbsText, `${task.name} ${files[0].name}`); + assert.equal(FS.breadcrumbs.length, 2); + }); + + test('sorting allocation filesystem directory', async function(assert) { + this.server.get('/client/fs/ls/:allocation_id', () => { + return [ + { + Name: 'aaa-big-old-file', + IsDir: false, + Size: 19190000, + ModTime: moment() + .subtract(1, 'year') + .format(), + }, + { + Name: 'mmm-small-mid-file', + IsDir: false, + Size: 1919, + ModTime: moment() + .subtract(6, 'month') + .format(), + }, + { + Name: 'zzz-med-new-file', + IsDir: false, + Size: 191900, + ModTime: moment().format(), + }, + { + Name: 'aaa-big-old-directory', + IsDir: true, + Size: 19190000, + ModTime: moment() + .subtract(1, 'year') + .format(), + }, + { + Name: 'mmm-small-mid-directory', + IsDir: true, + Size: 1919, + ModTime: moment() + .subtract(6, 'month') + .format(), + }, + { + Name: 'zzz-med-new-directory', + IsDir: true, + Size: 191900, + ModTime: moment().format(), + }, + ]; + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); + + assert.deepEqual(FS.directoryEntryNames(), [ + 'aaa-big-old-directory', + 'mmm-small-mid-directory', + 'zzz-med-new-directory', + 'aaa-big-old-file', + 'mmm-small-mid-file', + 'zzz-med-new-file', + ]); + + await FS.sortBy('Name'); + + assert.deepEqual(FS.directoryEntryNames(), [ + 'zzz-med-new-file', + 'mmm-small-mid-file', + 'aaa-big-old-file', + 'zzz-med-new-directory', + 'mmm-small-mid-directory', + 'aaa-big-old-directory', + ]); + + await FS.sortBy('ModTime'); + + assert.deepEqual(FS.directoryEntryNames(), [ + 'zzz-med-new-file', + 'mmm-small-mid-file', + 'aaa-big-old-file', + 'zzz-med-new-directory', + 'mmm-small-mid-directory', + 'aaa-big-old-directory', + ]); + + await FS.sortBy('ModTime'); + + assert.deepEqual(FS.directoryEntryNames(), [ + 'aaa-big-old-directory', + 'mmm-small-mid-directory', + 'zzz-med-new-directory', + 'aaa-big-old-file', + 'mmm-small-mid-file', + 'zzz-med-new-file', + ]); + + await FS.sortBy('Size'); + + assert.deepEqual( + FS.directoryEntryNames(), + [ + 'aaa-big-old-file', + 'zzz-med-new-file', + 'mmm-small-mid-file', + 'zzz-med-new-directory', + 'mmm-small-mid-directory', + 'aaa-big-old-directory', + ], + 'expected files to be sorted by descending size and directories to be sorted by descending name' + ); + + await FS.sortBy('Size'); + + assert.deepEqual( + FS.directoryEntryNames(), + [ + 'aaa-big-old-directory', + 'mmm-small-mid-directory', + 'zzz-med-new-directory', + 'mmm-small-mid-file', + 'zzz-med-new-file', + 'aaa-big-old-file', + ], + 'expected directories to be sorted by name and files to be sorted by ascending size' + ); + }); + + test('viewing a file', async function(assert) { + const node = server.db.nodes.find(allocation.nodeId); + + server.get(`http://${node.httpAddr}/v1/client/fs/readat/:allocation_id`, function() { + return new Response(500); + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); + + const sortedFiles = fileSort('name', filesForPath(this.server.schema.allocFiles, '').models); + const fileRecord = sortedFiles.find(f => !f.isDir); + const fileIndex = sortedFiles.indexOf(fileRecord); + + await FS.directoryEntries[fileIndex].visit(); + + assert.equal(FS.breadcrumbsText, `${task.name} ${fileRecord.name}`); + + assert.ok(FS.fileViewer.isPresent); + + const requests = this.server.pretender.handledRequests; + const secondAttempt = requests.pop(); + const firstAttempt = requests.pop(); + + assert.equal( + firstAttempt.url.split('?')[0], + `//${node.httpAddr}/v1/client/fs/readat/${allocation.id}`, + 'Client is hit first' + ); + assert.equal(firstAttempt.status, 500, 'Client request fails'); + assert.equal( + secondAttempt.url.split('?')[0], + `/v1/client/fs/readat/${allocation.id}`, + 'Server is hit second' + ); + }); + + test('viewing an empty directory', async function(assert) { + await FS.visitPath({ id: allocation.id, name: task.name, path: '/empty-directory' }); + + assert.ok(FS.isEmptyDirectory); + }); + + test('viewing paths that produce stat API errors', async function(assert) { + this.server.get('/client/fs/stat/:allocation_id', () => { + return new Response(500, {}, 'no such file or directory'); + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' }); + assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404'); + + await visit('/'); + + this.server.get('/client/fs/stat/:allocation_id', () => { + return new Response(999); + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: '/what-is-this' }); + assert.equal(FS.error.title, 'Error', 'other statuses are passed through'); + }); + + test('viewing paths that produce ls API errors', async function(assert) { + this.server.get('/client/fs/ls/:allocation_id', () => { + return new Response(500, {}, 'no such file or directory'); + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name }); + assert.equal(FS.error.title, 'Not Found', '500 is interpreted as 404'); + + await visit('/'); + + this.server.get('/client/fs/ls/:allocation_id', () => { + return new Response(999); + }); + + await FS.visitPath({ id: allocation.id, name: task.name, path: files[0].name }); + assert.equal(FS.error.title, 'Error', 'other statuses are passed through'); + }); +}); diff --git a/ui/tests/integration/image-file-test.js b/ui/tests/integration/image-file-test.js new file mode 100644 index 00000000000..7557cc954e7 --- /dev/null +++ b/ui/tests/integration/image-file-test.js @@ -0,0 +1,106 @@ +import { find, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import RSVP from 'rsvp'; +import { formatBytes } from 'nomad-ui/helpers/format-bytes'; + +module('Integration | Component | image file', function(hooks) { + setupRenderingTest(hooks); + + const commonTemplate = hbs` + {{image-file src=src alt=alt size=size}} + `; + + const commonProperties = { + src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + alt: 'This is the alt text', + size: 123456, + }; + + test('component displays the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + + assert.ok(find('img'), 'Image is in the DOM'); + assert.equal( + find('img').getAttribute('src'), + commonProperties.src, + `src is ${commonProperties.src}` + ); + }); + + test('the image is wrapped in an anchor that links directly to the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + + assert.ok(find('a'), 'Anchor'); + assert.ok(find('a > img'), 'Image in anchor'); + assert.equal( + find('a').getAttribute('href'), + commonProperties.src, + `href is ${commonProperties.src}` + ); + assert.equal(find('a').getAttribute('target'), '_blank', 'Anchor opens to a new tab'); + assert.equal( + find('a').getAttribute('rel'), + 'noopener noreferrer', + 'Anchor rel correctly bars openers and referrers' + ); + }); + + test('component updates image meta when the image loads', async function(assert) { + const { spy, wrapper, notifier } = notifyingSpy(); + + this.setProperties(commonProperties); + this.set('spy', wrapper); + + this.render(hbs` + {{image-file src=src alt=alt size=size updateImageMeta=spy}} + `); + + await notifier; + assert.ok(spy.calledOnce); + }); + + test('component shows the width, height, and size of the image', async function(assert) { + this.setProperties(commonProperties); + + await this.render(commonTemplate); + await settled(); + + const statsEl = find('[data-test-file-stats]'); + assert.ok( + /\d+px \u00d7 \d+px/.test(statsEl.textContent), + 'Width and height are formatted correctly' + ); + assert.ok( + statsEl.textContent.trim().endsWith(formatBytes([commonProperties.size]) + ')'), + 'Human-formatted size is included' + ); + }); +}); + +function notifyingSpy() { + // The notifier must resolve when the spy wrapper is called + let dispatch; + const notifier = new RSVP.Promise(resolve => { + dispatch = resolve; + }); + + const spy = sinon.spy(); + + // The spy wrapper must call the spy, passing all arguments through, and it must + // call dispatch in order to resolve the promise. + const wrapper = (...args) => { + spy(...args); + dispatch(); + }; + + // All three pieces are required to wire up a component, pause test execution, and + // write assertions. + return { spy, wrapper, notifier }; +} diff --git a/ui/tests/integration/streaming-file-test.js b/ui/tests/integration/streaming-file-test.js new file mode 100644 index 00000000000..f281ffed6db --- /dev/null +++ b/ui/tests/integration/streaming-file-test.js @@ -0,0 +1,111 @@ +import { run } from '@ember/runloop'; +import { find, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import Pretender from 'pretender'; +import { logEncode } from '../../mirage/data/logs'; +import fetch from 'nomad-ui/utils/fetch'; +import Log from 'nomad-ui/utils/classes/log'; + +const { assign } = Object; + +const stringifyValues = obj => + Object.keys(obj).reduce((newObj, key) => { + newObj[key] = obj[key].toString(); + return newObj; + }, {}); + +const makeLogger = (url, params) => + Log.create({ + url, + params, + plainText: true, + logFetch: url => fetch(url).then(res => res), + }); + +module('Integration | Component | streaming file', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.server = new Pretender(function() { + this.get('/file/endpoint', () => [200, {}, 'Hello World']); + this.get('/file/stream', () => [200, {}, logEncode(['Hello World'], 0)]); + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + const commonTemplate = hbs` + {{streaming-file logger=logger mode=mode isStreaming=isStreaming}} + `; + + test('when mode is `head`, the logger signals head', async function(assert) { + const url = '/file/endpoint'; + const params = { path: 'hello/world.txt', offset: 0, limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'head', + isStreaming: false, + }); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(this.server.handledRequests.length, 1, 'One request made'); + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.deepEqual( + request.queryParams, + stringifyValues(assign({ origin: 'start' }, params)), + 'Query params are correct' + ); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); + + test('when mode is `tail`, the logger signals tail', async function(assert) { + const url = '/file/endpoint'; + const params = { path: 'hello/world.txt', limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'tail', + isStreaming: false, + }); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(this.server.handledRequests.length, 1, 'One request made'); + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.deepEqual( + request.queryParams, + stringifyValues(assign({ origin: 'end', offset: 50000 }, params)), + 'Query params are correct' + ); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); + + test('when mode is `streaming` and `isStreaming` is true, streaming starts', async function(assert) { + const url = '/file/stream'; + const params = { path: 'hello/world.txt', limit: 50000 }; + this.setProperties({ + logger: makeLogger(url, params), + mode: 'streaming', + isStreaming: true, + }); + + assert.ok(true); + + run.later(run, run.cancelTimers, 500); + + await this.render(commonTemplate); + await settled(); + + const request = this.server.handledRequests[0]; + assert.equal(request.url.split('?')[0], url, `URL is ${url}`); + assert.equal(find('[data-test-output]').textContent, 'Hello World'); + }); +}); diff --git a/ui/tests/integration/task-file-test.js b/ui/tests/integration/task-file-test.js new file mode 100644 index 00000000000..05f944579dc --- /dev/null +++ b/ui/tests/integration/task-file-test.js @@ -0,0 +1,228 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import { find } from 'ember-native-dom-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import Pretender from 'pretender'; +import { logEncode } from '../../mirage/data/logs'; + +const { assign } = Object; +const HOST = '1.1.1.1:1111'; + +module('Integration | Component | task file', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.server = new Pretender(function() { + this.get('/v1/regions', () => [200, {}, JSON.stringify(['default'])]); + this.get('/v1/client/fs/stream/:alloc_id', () => [200, {}, logEncode(['Hello World'], 0)]); + this.get('/v1/client/fs/cat/:alloc_id', () => [200, {}, 'Hello World']); + this.get('/v1/client/fs/readat/:alloc_id', () => [200, {}, 'Hello World']); + }); + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + const commonTemplate = hbs` + {{task-file allocation=allocation task=task file=file stat=stat}} + `; + + const fileStat = (type, size = 0) => ({ + stat: { + Size: size, + ContentType: type, + }, + }); + const makeProps = (props = {}) => + assign( + {}, + { + allocation: { + id: 'alloc-1', + node: { + httpAddr: HOST, + }, + }, + task: { + name: 'task-name', + }, + file: 'path/to/file', + stat: { + Size: 12345, + ContentType: 'text/plain', + }, + }, + props + ); + + test('When a file is text-based, the file mode is streaming', async function(assert) { + const props = makeProps(fileStat('text/plain', 500)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was not rendered' + ); + }); + + test('When a file is an image, the file mode is image', async function(assert) { + const props = makeProps(fileStat('image/png', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was not rendered' + ); + }); + + test('When the file is neither text-based or an image, the unsupported file type empty state is shown', async function(assert) { + const props = makeProps(fileStat('wat/ohno', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk( + find('[data-test-file-box] [data-test-image-file]'), + 'The image file component was not rendered' + ); + assert.notOk( + find('[data-test-file-box] [data-test-log-cli]'), + 'The streaming file component was not rendered' + ); + assert.ok(find('[data-test-unsupported-type]'), 'Unsupported file type message is shown'); + }); + + test('The unsupported file type empty state includes a link to the raw file', async function(assert) { + const props = makeProps(fileStat('wat/ohno', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + assert.ok( + find('[data-test-unsupported-type] [data-test-log-action="raw"]'), + 'Unsupported file type message includes a link to the raw file' + ); + + assert.notOk( + find('[data-test-header] [data-test-log-action="raw"]'), + 'Raw link is no longer in the header' + ); + }); + + test('The view raw button goes to the correct API url', async function(assert) { + const props = makeProps(fileStat('image/png', 1234)); + this.setProperties(props); + + await render(commonTemplate); + + const rawLink = find('[data-test-log-action="raw"]'); + assert.equal( + rawLink.getAttribute('href'), + `/v1/client/fs/cat/${props.allocation.id}?path=${encodeURIComponent( + `${props.task.name}/${props.file}` + )}`, + 'Raw link href is correct' + ); + + assert.equal(rawLink.getAttribute('target'), '_blank', 'Raw link opens in a new tab'); + assert.equal( + rawLink.getAttribute('rel'), + 'noopener noreferrer', + 'Raw link rel correctly bars openers and referrers' + ); + }); + + test('The head and tail buttons are not shown when the file is small', async function(assert) { + const props = makeProps(fileStat('application/json', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk(find('[data-test-log-action="head"]'), 'No head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button'); + + this.set('stat.Size', 100000); + + await settled(); + + assert.ok(find('[data-test-log-action="head"]'), 'Head button is shown'); + assert.ok(find('[data-test-log-action="tail"]'), 'Tail button is shown'); + }); + + test('The head and tail buttons are not shown for an image file', async function(assert) { + const props = makeProps(fileStat('image/svg', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + assert.notOk(find('[data-test-log-action="head"]'), 'No head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'No tail button'); + + this.set('stat.Size', 100000); + + await settled(); + + assert.notOk(find('[data-test-log-action="head"]'), 'Still no head button'); + assert.notOk(find('[data-test-log-action="tail"]'), 'Still no tail button'); + }); + + test('Yielded content goes in the top-left header area', async function(assert) { + const props = makeProps(fileStat('image/svg', 5000)); + this.setProperties(props); + + await render(hbs` + {{#task-file allocation=allocation task=task file=file stat=stat}} +
Yielded content
+ {{/task-file}} + `); + + assert.ok( + find('[data-test-header] [data-test-yield-spy]'), + 'Yielded content shows up in the header' + ); + }); + + test('The body is full-bleed and dark when the file is streaming', async function(assert) { + const props = makeProps(fileStat('application/json', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + const classes = Array.from(find('[data-test-file-box]').classList); + assert.ok(classes.includes('is-dark'), 'Body is dark'); + assert.ok(classes.includes('is-full-bleed'), 'Body is full-bleed'); + }); + + test('The body has padding and a light background when the file is not streaming', async function(assert) { + const props = makeProps(fileStat('image/jpeg', 5000)); + this.setProperties(props); + + await render(commonTemplate); + + let classes = Array.from(find('[data-test-file-box]').classList); + assert.notOk(classes.includes('is-dark'), 'Body is not dark'); + assert.notOk(classes.includes('is-full-bleed'), 'Body is not full-bleed'); + + this.set('stat.ContentType', 'something/unknown'); + + await settled(); + + classes = Array.from(find('[data-test-file-box]').classList); + assert.notOk(classes.includes('is-dark'), 'Body is still not dark'); + assert.notOk(classes.includes('is-full-bleed'), 'Body is still not full-bleed'); + }); +}); diff --git a/ui/tests/integration/task-log-test.js b/ui/tests/integration/task-log-test.js index 4f2474cae87..67f66786eb7 100644 --- a/ui/tests/integration/task-log-test.js +++ b/ui/tests/integration/task-log-test.js @@ -21,24 +21,23 @@ const commonProps = { serverTimeout: allowedConnectionTime, }; -const logHead = ['HEAD']; -const logTail = ['TAIL']; +const logHead = [logEncode(['HEAD'], 0)]; +const logTail = [logEncode(['TAIL'], 0)]; const streamFrames = ['one\n', 'two\n', 'three\n', 'four\n', 'five\n']; let streamPointer = 0; +let logMode = null; module('Integration | Component | task log', function(hooks) { setupRenderingTest(hooks); hooks.beforeEach(function() { const handler = ({ queryParams }) => { - const { origin, offset, plain, follow } = queryParams; - let frames; let data; - if (origin === 'start' && offset === '0' && plain && !follow) { + if (logMode === 'head') { frames = logHead; - } else if (origin === 'end' && plain && !follow) { + } else if (logMode === 'tail') { frames = logTail; } else { frames = streamFrames; @@ -64,6 +63,7 @@ module('Integration | Component | task log', function(hooks) { hooks.afterEach(function() { this.server.shutdown(); streamPointer = 0; + logMode = null; }); test('Basic appearance', async function(assert) { @@ -107,6 +107,7 @@ module('Integration | Component | task log', function(hooks) { }); test('Clicking Head loads the log head', async function(assert) { + logMode = 'head'; run.later(run, run.cancelTimers, commonProps.interval); this.setProperties(commonProps); @@ -117,7 +118,7 @@ module('Integration | Component | task log', function(hooks) { await settled(); assert.ok( this.server.handledRequests.find( - ({ queryParams: qp }) => qp.origin === 'start' && qp.plain === 'true' && qp.offset === '0' + ({ queryParams: qp }) => qp.origin === 'start' && qp.offset === '0' ), 'Log head request was made' ); @@ -125,6 +126,7 @@ module('Integration | Component | task log', function(hooks) { }); test('Clicking Tail loads the log tail', async function(assert) { + logMode = 'tail'; run.later(run, run.cancelTimers, commonProps.interval); this.setProperties(commonProps); @@ -134,9 +136,7 @@ module('Integration | Component | task log', function(hooks) { await settled(); assert.ok( - this.server.handledRequests.find( - ({ queryParams: qp }) => qp.origin === 'end' && qp.plain === 'true' - ), + this.server.handledRequests.find(({ queryParams: qp }) => qp.origin === 'end'), 'Log tail request was made' ); assert.equal(find('[data-test-log-cli]').textContent, logTail[0], 'Tail of the log is shown'); diff --git a/ui/tests/pages/allocations/task/fs.js b/ui/tests/pages/allocations/task/fs.js new file mode 100644 index 00000000000..e6b6155b8cc --- /dev/null +++ b/ui/tests/pages/allocations/task/fs.js @@ -0,0 +1,67 @@ +import { + attribute, + collection, + clickable, + create, + hasClass, + isPresent, + text, + visitable, +} from 'ember-cli-page-object'; + +export default create({ + visit: visitable('/allocations/:id/:name/fs'), + visitPath: visitable('/allocations/:id/:name/fs/:path'), + + fileViewer: { + scope: '[data-test-file-viewer]', + }, + + breadcrumbsText: text('[data-test-fs-breadcrumbs]'), + + breadcrumbs: collection('[data-test-fs-breadcrumbs] li', { + visit: clickable('a'), + path: attribute('href', 'a'), + isActive: hasClass('is-active'), + }), + + sortOptions: collection('[data-test-sort-by]', { + id: attribute('data-test-sort-by'), + sort: clickable(), + }), + + sortBy(id) { + return this.sortOptions + .toArray() + .findBy('id', id) + .sort(); + }, + + directoryEntries: collection('[data-test-entry]', { + name: text('[data-test-name]'), + + isFile: isPresent('.icon-is-file-outline'), + isDirectory: isPresent('.icon-is-folder-outline'), + + size: text('[data-test-size]'), + lastModified: text('[data-test-last-modified]'), + + visit: clickable('a'), + path: attribute('href', 'a'), + }), + + isEmptyDirectory: isPresent('[data-test-empty-directory]'), + + directoryEntryNames() { + return this.directoryEntries.toArray().mapBy('name'); + }, + + hasEmptyState: isPresent('[data-test-not-running]'), + emptyState: { + headline: text('[data-test-not-running-headline]'), + }, + + error: { + title: text('[data-test-error-title]'), + }, +}); diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js index 908fbf33b98..9faa709b05c 100644 --- a/ui/tests/unit/adapters/node-test.js +++ b/ui/tests/unit/adapters/node-test.js @@ -20,7 +20,6 @@ module('Unit | Adapter | Node', function(hooks) { this.server.create('allocation', { id: 'node-1-2', nodeId: 'node-1' }); this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' }); this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' }); - this.server.logging = true; }); hooks.afterEach(function() { diff --git a/ui/tests/unit/utils/log-test.js b/ui/tests/unit/utils/log-test.js index b3be1e18b9f..e69b8c10580 100644 --- a/ui/tests/unit/utils/log-test.js +++ b/ui/tests/unit/utils/log-test.js @@ -76,7 +76,7 @@ module('Unit | Util | Log', function(hooks) { test('gotoHead builds the correct URL', async function(assert) { const mocks = makeMocks(''); - const expectedUrl = `${mocks.url}?a=param&another=one&offset=0&origin=start&plain=true`; + const expectedUrl = `${mocks.url}?a=param&another=one&offset=0&origin=start`; const log = Log.create(mocks); run(() => { @@ -89,10 +89,11 @@ module('Unit | Util | Log', function(hooks) { const longLog = Array(50001) .fill('a') .join(''); + const encodedLongLog = `{"Offset":0,"Data":"${window.btoa(longLog)}"}`; const truncationMessage = '\n\n---------- TRUNCATED: Click "tail" to view the bottom of the log ----------'; - const mocks = makeMocks(longLog); + const mocks = makeMocks(encodedLongLog); const log = Log.create(mocks); run(() => { @@ -100,7 +101,13 @@ module('Unit | Util | Log', function(hooks) { }); await settled(); - assert.ok(log.get('output').toString().endsWith(truncationMessage), 'Truncation message is shown'); + assert.ok( + log + .get('output') + .toString() + .endsWith(truncationMessage), + 'Truncation message is shown' + ); assert.equal( log.get('output').toString().length, 50000 + truncationMessage.length, @@ -110,7 +117,7 @@ module('Unit | Util | Log', function(hooks) { test('gotoTail builds the correct URL', async function(assert) { const mocks = makeMocks(''); - const expectedUrl = `${mocks.url}?a=param&another=one&offset=50000&origin=end&plain=true`; + const expectedUrl = `${mocks.url}?a=param&another=one&offset=50000&origin=end`; const log = Log.create(mocks); run(() => { diff --git a/ui/tests/unit/utils/stream-frames-test.js b/ui/tests/unit/utils/stream-frames-test.js new file mode 100644 index 00000000000..0a185eb5bbc --- /dev/null +++ b/ui/tests/unit/utils/stream-frames-test.js @@ -0,0 +1,41 @@ +import { module, test } from 'qunit'; +import { decode } from 'nomad-ui/utils/stream-frames'; + +module('Unit | Util | stream-frames', function() { + const { btoa } = window; + const decodeTestCases = [ + { + name: 'Single frame', + in: `{"Offset":100,"Data":"${btoa('Hello World')}"}`, + out: { + offset: 100, + message: 'Hello World', + }, + }, + { + name: 'Multiple frames', + // prettier-ignore + in: `{"Offset":1,"Data":"${btoa('One fish,')}"}{"Offset":2,"Data":"${btoa( ' Two fish.')}"}{"Offset":3,"Data":"${btoa(' Red fish, ')}"}{"Offset":4,"Data":"${btoa('Blue fish.')}"}`, + out: { + offset: 4, + message: 'One fish, Two fish. Red fish, Blue fish.', + }, + }, + { + name: 'Empty frames', + in: '{}{}{}', + out: {}, + }, + { + name: 'Empty string', + in: '', + out: {}, + }, + ]; + + decodeTestCases.forEach(testCase => { + test(`decode: ${testCase.name}`, function(assert) { + assert.deepEqual(decode(testCase.in), testCase.out); + }); + }); +});