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"