diff --git a/ui/app/adapters/task-state.js b/ui/app/adapters/task-state.js new file mode 100644 index 00000000000..e4a08e40124 --- /dev/null +++ b/ui/app/adapters/task-state.js @@ -0,0 +1,37 @@ +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=${path}`) + .then(handleFSResponse); + }, + + stat(model, path) { + return this.token + .authorizedRequest(`/v1/client/fs/stat/${model.allocation.id}?path=${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-directory-entry.js b/ui/app/components/fs-directory-entry.js new file mode 100644 index 00000000000..73a52742154 --- /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 = this.get('entry.Name'); + + if (isEmpty(pathWithNoLeadingSlash)) { + return name; + } else { + return `${pathWithNoLeadingSlash}/${name}`; + } + }), +}); 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..849a778152b --- /dev/null +++ b/ui/app/controllers/allocations/allocation/task/fs.js @@ -0,0 +1,43 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import { filterBy } from '@ember/object/computed'; +import { isEmpty } from '@ember/utils'; + +export default Controller.extend({ + path: null, + task: null, + directoryEntries: null, + isFile: null, + + directories: filterBy('directoryEntries', 'IsDir'), + files: filterBy('directoryEntries', 'IsDir', false), + + 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/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 c27cd4c2dcd..a8bfd1dd318 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -32,9 +32,8 @@ Router.map(function() { this.route('allocation', { path: '/:allocation_id' }, function() { this.route('task', { path: '/:name' }, function() { this.route('logs'); - this.route('fs', function() { - this.route('path', { path: '/*path' }); - }); + 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..f592e77a0df --- /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 task + .stat(pathWithTaskName) + .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, + }; + } + }) + .catch(notifyError(this)); + }, + + setupController(controller, { path, task, directoryEntries, isFile } = {}) { + this._super(...arguments); + controller.setProperties({ path, task, directoryEntries, isFile }); + }, +}); diff --git a/ui/app/routes/allocations/allocation/task/fs/path.js b/ui/app/routes/allocations/allocation/task/fs/path.js deleted file mode 100644 index b18931215c6..00000000000 --- a/ui/app/routes/allocations/allocation/task/fs/path.js +++ /dev/null @@ -1,15 +0,0 @@ -import Route from '@ember/routing/route'; - -export default Route.extend({ - model({ path }) { - return { - path: decodeURIComponent(path), - task: this.modelFor('allocations.allocation.task'), - }; - }, - - setupController(controller, { path, task }) { - this._super(...arguments); - controller.setProperties({ path, model: task }); - }, -}); diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 031f1b886ab..7196135b171 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -7,6 +7,7 @@ @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/inline-definitions'; diff --git a/ui/app/styles/components/fs-explorer.scss b/ui/app/styles/components/fs-explorer.scss new file mode 100644 index 00000000000..36bd35e5b37 --- /dev/null +++ b/ui/app/styles/components/fs-explorer.scss @@ -0,0 +1,73 @@ +.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; + } + } + } + } + + 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/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 index e2147cab02d..8239b30a100 100644 --- a/ui/app/templates/allocations/allocation/task/fs.hbs +++ b/ui/app/templates/allocations/allocation/task/fs.hbs @@ -1 +1,65 @@ -{{outlet}} \ No newline at end of file +{{task-subnav task=task}} +
+ {{#if task.isRunning}} +
+
+ +
+ + {{#if isFile}} +
+
placeholder file viewer
+
+ {{else}} + {{! template-lint-disable table-groups }} + + {{#if directoryEntries}} + + + + + + + + + {{#each (append directories files) as |entry|}} + {{fs-directory-entry path=path task=task entry=entry}} + {{/each}} + + {{else}} + + + + + + {{/if}} +
NameFile SizeLast Modified
+ {{x-icon "alert-circle-outline"}} + Directory is empty +
+ {{/if}} +
+ {{else}} +
+

Task is not Running

+

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

+
+ {{/if}} +
\ No newline at end of file diff --git a/ui/app/templates/allocations/allocation/task/fs/index.hbs b/ui/app/templates/allocations/allocation/task/fs/index.hbs deleted file mode 100644 index ba5a3401b5f..00000000000 --- a/ui/app/templates/allocations/allocation/task/fs/index.hbs +++ /dev/null @@ -1,14 +0,0 @@ -{{partial "allocations/allocation/task/subnav"}} -
-

Index fs route

- {{#if model.isRunning}} - Task is running, show file explorer. - {{else}} -
-

Task is not Running

-

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

-
- {{/if}} -
\ No newline at end of file diff --git a/ui/app/templates/allocations/allocation/task/fs/path.hbs b/ui/app/templates/allocations/allocation/task/fs/path.hbs deleted file mode 100644 index ce1b3a010f0..00000000000 --- a/ui/app/templates/allocations/allocation/task/fs/path.hbs +++ /dev/null @@ -1,4 +0,0 @@ -{{partial "allocations/allocation/task/subnav"}} -
-

Path fs route {{path}}

-
\ No newline at end of file diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 2e1d56fb0fb..19ddb6fe284 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,4 +1,4 @@ -{{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 bbbff1ccf9f..3c7bac6dd37 100644 --- a/ui/app/templates/allocations/allocation/task/logs.hbs +++ b/ui/app/templates/allocations/allocation/task/logs.hbs @@ -1,4 +1,4 @@ -{{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 d2f78cd6d8d..00000000000 --- a/ui/app/templates/allocations/allocation/task/subnav.hbs +++ /dev/null @@ -1,7 +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}}
  • -
  • {{#link-to "allocations.allocation.task.fs" model.allocation model activeClass="is-active"}}Files{{/link-to}}
  • -
-
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..11c4df03208 --- /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/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 @@ +
+
    +
  • {{#link-to "allocations.allocation.task.index" task.allocation task activeClass="is-active"}}Overview{{/link-to}}
  • +
  • {{#link-to "allocations.allocation.task.logs" task.allocation task activeClass="is-active"}}Logs{{/link-to}}
  • +
  • {{#link-to "allocations.allocation.task.fs-root" task.allocation task class=(if filesLinkActive "is-active")}}Files{{/link-to}}
  • +
+
diff --git a/ui/config/environment.js b/ui/config/environment.js index 626a14295d7..eae1b1b72c2 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -22,7 +22,7 @@ module.exports = function(environment) { APP: { blockingQueries: true, - mirageScenario: 'smallCluster', + mirageScenario: 'allocationFileExplorer', // FIXME for stable preview links only mirageWithNamespaces: true, mirageWithTokens: true, mirageWithRegions: true, diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 6e2fb01c596..cfa0fb99667 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -11,7 +11,7 @@ module.exports = function(defaults) { blacklist: isProd ? ['ember-freestyle'] : [], }, svg: { - paths: ['public/images/icons'], + paths: ['node_modules/@hashicorp/structure-icons/dist', 'public/images/icons'], optimize: { plugins: [{ removeViewBox: false }], }, diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 24ddf66618f..777124d7b97 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -5,6 +5,7 @@ import { logFrames, logEncode } from './data/logs'; import { generateDiff } from './factories/job-version'; import { generateTaskGroupFailures } from './factories/evaluation'; import { copy } from 'ember-copy'; +import moment from 'moment'; export function findLeader(schema) { const agent = schema.agents.first(); @@ -304,6 +305,65 @@ export default function() { return logEncode(logFrames, logFrames.length - 1); }; + const clientAllocationFSLsHandler = function(schema, { queryParams }) { + if (queryParams.path.endsWith('empty-directory')) { + return []; + } else if (queryParams.path.endsWith('directory')) { + return [ + { + Name: 'another', + IsDir: true, + ModTime: moment().format(), + }, + ]; + } else if (queryParams.path.endsWith('another')) { + return [ + { + Name: 'something.txt', + IsDir: false, + ModTime: moment().format(), + }, + ]; + } else { + return [ + { + Name: '🤩.txt', + IsDir: false, + Size: 1919, + ModTime: moment() + .subtract(2, 'day') + .format(), + }, + { + Name: '🙌🏿.txt', + IsDir: false, + ModTime: moment() + .subtract(2, 'minute') + .format(), + }, + { + Name: 'directory', + IsDir: true, + Size: 3682561, + ModTime: moment() + .subtract(1, 'year') + .format(), + }, + { + Name: 'empty-directory', + IsDir: true, + ModTime: moment().format(), + }, + ]; + } + }; + + const clientAllocationFSStatHandler = function(schema, { queryParams }) { + return { + IsDir: !queryParams.path.endsWith('.txt'), + }; + }; + // Client requests are available on the server and the client this.put('/client/allocation/:id/restart', function() { return new Response(204, {}, ''); @@ -312,6 +372,9 @@ 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/stats', function({ clientStats }, { queryParams }) { const seed = Math.random(); if (seed > 0.8) { @@ -332,6 +395,9 @@ 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/stats`, function({ clientStats }) { return this.serialize(clientStats.find(host)); }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 62c4c427c3c..e8aa6a24268 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -13,6 +13,7 @@ const allScenarios = { allNodeTypes, everyFeature, emptyCluster, + allocationFileExplorer, // FIXME for stable preview links only }; const scenario = getConfigValue('mirageScenario', 'emptyCluster'); @@ -115,6 +116,33 @@ function emptyCluster(server) { server.create('node'); } +function allocationFileExplorer(server) { + server.create('node'); + + const job = server.create('job', { + id: 'a-job', + type: 'service', + activeDeployment: true, + namespaceId: 'default', + createAllocations: false, + }); + + const taskGroup = server.create('task-group', { + name: 'task-group', + createAllocations: false, + shallow: true, + jobId: job.id, + }); + server.create('task', { name: 'task', taskGroup: taskGroup }); + server.create('allocation', { + clientStatus: 'running', + desiredStatus: 'run', + id: '12345', + jobId: job.id, + taskGroup: taskGroup.name, + }); +} + // Behaviors function createTokens(server) { diff --git a/ui/package.json b/ui/package.json index 9f7e9ee36e5..fd5eaf14c39 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,6 +29,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.4.3", "@ember/jquery": "^0.6.0", "@ember/optional-features": "^0.7.0", + "@hashicorp/structure-icons": "^1.3.0", "broccoli-asset-rev": "^3.0.0", "bulma": "0.6.1", "core-js": "^2.4.1", diff --git a/ui/tests/acceptance/task-fs-path-test.js b/ui/tests/acceptance/task-fs-path-test.js deleted file mode 100644 index 5d32d4196bc..00000000000 --- a/ui/tests/acceptance/task-fs-path-test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { currentURL } from '@ember/test-helpers'; -import { Promise } from 'rsvp'; -import { module, test } from 'qunit'; -import { setupApplicationTest } from 'ember-qunit'; -import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; -import Path from 'nomad-ui/tests/pages/allocations/task/fs/path'; - -let allocation; -let task; - -module('Acceptance | task fs path', 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]; - }); - - 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 => { - await Path.visit({ id: allocation.id, name: task.name, path: filePath }); - assert.equal( - currentURL(), - `/allocations/${allocation.id}/${task.name}/fs/${encodeURIComponent(filePath)}`, - 'No redirect' - ); - assert.ok(Path.tempTitle.includes(filePath), `Temp title includes path, ${filePath}`); - }; - - await paths.reduce(async (prev, filePath) => { - await prev; - return testPath(filePath); - }, Promise.resolve()); - }); -}); diff --git a/ui/tests/acceptance/task-fs-test.js b/ui/tests/acceptance/task-fs-test.js index 7fcc5f730de..5e42a2eb3c1 100644 --- a/ui/tests/acceptance/task-fs-test.js +++ b/ui/tests/acceptance/task-fs-test.js @@ -1,7 +1,11 @@ -import { currentURL } from '@ember/test-helpers'; +import { currentURL, visit } from '@ember/test-helpers'; +import { Promise } from 'rsvp'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; + import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import Response from 'ember-cli-mirage/response'; + import FS from 'nomad-ui/tests/pages/allocations/task/fs'; let allocation; @@ -18,6 +22,8 @@ module('Acceptance | task fs', function(hooks) { 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(); }); test('visiting /allocations/:allocation_id/:task_name/fs', async function(assert) { @@ -37,4 +43,141 @@ module('Acceptance | task fs', function(hooks) { '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 => { + 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(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: '/' }); + + 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 => { + assert.equal(directory.name, 'directory', 'directories should come first'); + assert.ok(directory.isDirectory); + assert.equal(directory.size, '', 'directory sizes are hidden'); + assert.equal(directory.lastModified, 'a year ago'); + assert.notOk(directory.path.includes('//'), 'paths shouldn’t have redundant separators'); + }); + + FS.directoryEntries[2].as(file => { + assert.equal(file.name, '🤩.txt'); + assert.ok(file.isFile); + assert.equal(file.size, '1 KiB'); + assert.equal(file.lastModified, '2 days ago'); + }); + + await FS.directoryEntries[0].visit(); + + assert.equal(FS.directoryEntries.length, 1); + + assert.equal(FS.breadcrumbs.length, 2); + assert.equal(FS.breadcrumbsText, 'task-name directory'); + + assert.notOk(FS.breadcrumbs[0].isActive); + + assert.equal(FS.breadcrumbs[1].text, 'directory'); + 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 directory another'); + assert.equal(FS.breadcrumbs[2].text, 'another'); + + 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 directory'); + assert.equal(FS.breadcrumbs.length, 2); + }); + + test('viewing a file', async function(assert) { + await FS.visitPath({ id: allocation.id, name: task.name, path: '/' }); + await FS.directoryEntries[2].visit(); + + assert.equal(FS.breadcrumbsText, 'task-name 🤩.txt'); + + assert.ok(FS.fileViewer.isPresent); + }); + + test('viewing an empty directory', async function(assert) { + await FS.visitPath({ id: allocation.id, name: task.name, path: '/empty-directory' }); + + assert.equal(FS.directoryEntries.length, 1); + assert.ok(FS.directoryEntries[0].isEmpty); + }); + + 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: '/what-is-this' }); + 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: '/what-is-this' }); + assert.equal(FS.error.title, 'Error', 'other statuses are passed through'); + }); }); diff --git a/ui/tests/pages/allocations/task/fs.js b/ui/tests/pages/allocations/task/fs.js index d133d6824e8..e924d7ce145 100644 --- a/ui/tests/pages/allocations/task/fs.js +++ b/ui/tests/pages/allocations/task/fs.js @@ -1,12 +1,50 @@ -import { create, isPresent, text, visitable } from 'ember-cli-page-object'; +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'), - hasFiles: isPresent('[data-test-file-explorer]'), + 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'), + }), + + directoryEntries: collection('[data-test-entry]', { + name: text('[data-test-name]'), + + isFile: isPresent('[data-test-file-icon]'), + isDirectory: isPresent('[data-test-directory-icon]'), + isEmpty: isPresent('[data-test-empty-icon]'), + + size: text('[data-test-size]'), + lastModified: text('[data-test-last-modified]'), + + visit: clickable('a'), + path: attribute('href', 'a'), + }), 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/pages/allocations/task/fs/path.js b/ui/tests/pages/allocations/task/fs/path.js deleted file mode 100644 index d4b7097d26a..00000000000 --- a/ui/tests/pages/allocations/task/fs/path.js +++ /dev/null @@ -1,7 +0,0 @@ -import { create, text, visitable } from 'ember-cli-page-object'; - -export default create({ - visit: visitable('/allocations/:id/:name/fs/:path'), - - tempTitle: text('h1.title'), -}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 8d682c2b483..55d0c6cdf95 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -766,6 +766,11 @@ dependencies: "@glimmer/util" "^0.38.1" +"@hashicorp/structure-icons@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.3.0.tgz#1c7c1cb43a1c1aa92b073a7aa7956495ae14c3e0" + integrity sha512-wTKpdaAPphEY2kg5QbQTSUlhqLTpBBR1+1dXp4LYTN0PtMSpetyDDDhcSyvKE8i4h2nwPJBRRfeFlE1snaHd7w== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"