Merge pull request #199 from hashicorp/feature-sessions-api

Feature sessions api
This commit is contained in:
Susmitha Girumala authored and GitHub committed 2020-09-21 14:07:40 -04:00
commit f5dc90f399
35 files changed
+537 -6

No files matched your search

@@ -0,0 +1,36 @@
import BaseModel from '../../models/base';
import { attr } from '@ember-data/model';
export default class GeneratedSessionModel extends BaseModel {
// =attributes
@attr('string') user_id;
@attr('string') target_id;
@attr('string') host_id;
@attr('string') host_set_id;
@attr('string', {
description: 'The name of the current status of this session.',
readOnly: true,
})
status;
@attr('date', {
description: 'The time this resource was created\nOutput only.',
readOnly: true,
})
created_time;
@attr('date', {
description: 'The time this resource was last updated\nOutput only.',
readOnly: true,
})
updated_time;
@attr('number', {
description: 'Current version number of this resource.',
})
version;
}
+27
View File
@@ -0,0 +1,27 @@
import GeneratedSessionModel from '../generated/models/session';
export default class SessionModel extends GeneratedSessionModel {
// =methods
/**
* Cancels the session via the `cancel` method.
* See serializer and adapter for more information.
* @param {object} options
* @param {object} options.adapterOptions
* @return {Promise}
*/
cancelSession(options = { adapterOptions: {} }) {
const defaultAdapterOptions = {
method: 'cancel',
};
return this.save({
...options,
adapterOptions: {
...defaultAdapterOptions,
...options.adapterOptions,
},
});
}
}
+31
View File
@@ -0,0 +1,31 @@
import ApplicationSerializer from './application';
export default class SessionSerializer extends ApplicationSerializer {
// =methods
/**
* If `adapterOptions.method` is `cancel`, the serialization should
* include the version field only.
* @override
* @param {Snapshot} snapshot
* @return {object}
*/
serialize(snapshot) {
let serialized = super.serialize(...arguments);
if (snapshot?.adapterOptions?.method === 'cancel') {
serialized = this.serializeForCancel(snapshot);
}
return serialized;
}
/**
* Returns a payload containing only version
* @param {Snapshot} snapshot
* @return {object}
*/
serializeForCancel(snapshot) {
return {
version: snapshot.attr('version'),
};
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/models/session';
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/serializers/session';
@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Model | session', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('session', {});
assert.ok(model);
});
});
@@ -0,0 +1,31 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | session', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('session');
assert.ok(serializer);
});
test('it serializes only version when `adapterOptions.method` is `cancel`', function (assert) {
assert.expect(1);
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('session');
const record = store.createRecord('session', {
version: 1,
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {
method: 'cancel',
};
const serializedRecord = serializer.serialize(snapshot);
assert.deepEqual(serializedRecord, {
version: 1,
});
});
});
@@ -54,3 +54,7 @@ p {
a {
color: var(--action);
}
time {
font-variant-numeric: tabular-nums;
}
@@ -34,4 +34,8 @@
padding: sizing.rems(l) sizing.rems(s);
}
}
[class*="rose-button"] {
margin-bottom: 0;
}
}
+9
View File
@@ -7,6 +7,15 @@ export default {
},
},
date: {
default: {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
},
hhmmss: {
hour: 'numeric',
minute: 'numeric',
@@ -0,0 +1,7 @@
import { helper } from '@ember/component/helper';
export default helper(function formatDateIsoHuman(params/*, hash*/) {
return params[0].toISOString()
.replace('T', ' ')
.replace(/\.\d*Z/, '');
});
+5
View File
@@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
export default helper(function formatDateIso(params/*, hash*/) {
return params[0].toISOString();
});
+1
View File
@@ -52,6 +52,7 @@ Router.map(function () {
});
this.route('new');
});
this.route('sessions', function() {});
});
this.route('new');
});
@@ -0,0 +1,85 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { all, hash } from 'rsvp';
import { task, timeout } from 'ember-concurrency';
const POLL_TIMEOUT_SECONDS = 2.5;
export default class ScopesScopeProjectsProjectSessionsRoute extends Route {
// =services
@service intl;
@service notify;
// =attributes
/**
* A simple Ember Concurrency-based polling task that refreshes the route
* every POLL_TIMEOUT_SECONDS seconds. This is necessary to display changes
* to session `status` that may occur.
*
* NOTE: tasks are sort of attributes and sort of methods, but they are not
* language-level constructs. Thus we annotate this task as if it
* is an attribute.
* @type {Task}
*/
@task(function * () {
while(true) {
yield timeout(POLL_TIMEOUT_SECONDS * 1000);
yield this.refresh();
}
}).drop() poller;
// =methods
/**
* Loads all sessions under the current scope and encapsulates them into
* an array of objects containing their associated users and targets.
* @return {Promise{[{session: SessionModel, user: UserModel, target: TargetModel}]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope.projects.project');
const sessions = await this.store.query('session', { scope_id });
return all(
sessions.map(session => hash({
session,
user: this.store.findRecord('user', session.user_id),
target: this.store.findRecord('target', session.target_id),
}))
);
}
/**
* When this route is activated (entered), begin polling for changes.
*/
activate() {
this.poller.perform();
}
/**
* When this route is deactivated (exited), stop polling for changes.
*/
deactivate() {
this.poller.cancelAll();
}
// =actions
/**
* Cancels the specified session and notifies user of success or error.
* @param {SessionModel}
*/
@action
async cancelSession(session) {
try {
await session.cancelSession();
this.notify.success(this.intl.t('notify.session-canceled'));
} catch (error) {
// TODO: replace with translated strings
this.notify.error(error.message, { closeAfter: null });
}
}
}
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class ScopesScopeProjectsProjectSessionsIndexRoute extends Route {
}
@@ -7,7 +7,7 @@
<header.row as |row|>
<row.headerCell>{{t 'form.label.name'}}</row.headerCell>
<row.headerCell>{{t 'form.label.id'}}</row.headerCell>
<row.headerCell>{{t "form.label.actions"}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
@@ -1,4 +1,7 @@
<Rose::Nav::Sidebar @title={{t "titles.resources"}} as |nav|>
<nav.link @route="scopes.scope.projects.project.sessions">
{{t "resources.sessions"}}
</nav.link>
<nav.link @route="scopes.scope.projects.project.targets">
{{t "resources.targets"}}
</nav.link>
@@ -6,7 +6,7 @@
<row.headerCell>{{t "form.label.name"}}</row.headerCell>
<row.headerCell>{{t "form.label.type"}}</row.headerCell>
<row.headerCell>{{t "form.label.id"}}</row.headerCell>
<row.headerCell>{{t "form.label.actions"}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
@@ -0,0 +1,3 @@
{{title (t "resources.sessions")}}
{{outlet}}
@@ -0,0 +1,66 @@
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.sessions"}}</h2>
</page.header>
<page.body>
{{#if @model}}
<Rose::Table as |table|>
<table.header as |header|>
<header.row as |row|>
<row.headerCell>{{t "form.label.id"}}</row.headerCell>
<row.headerCell>{{t "resources.user"}}</row.headerCell>
<row.headerCell>{{t "resources.target"}}</row.headerCell>
<row.headerCell>{{t "form.label.started"}}</row.headerCell>
<row.headerCell>{{t "form.label.status"}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
{{#each @model as |sessionAggregate|}}
<body.row as |row|>
<row.headerCell>
<code>{{sessionAggregate.session.id}}</code>
</row.headerCell>
<row.cell>{{sessionAggregate.user.displayName}}</row.cell>
<row.cell>{{sessionAggregate.target.displayName}}</row.cell>
<row.cell>
<time datetime={{format-date-iso sessionAggregate.session.created_time}}>
{{format-date-iso-human sessionAggregate.session.created_time}}
</time>
</row.cell>
<row.cell>
<Rose::Badge>{{sessionAggregate.session.status}}</Rose::Badge>
</row.cell>
<row.cell>
<Rose::Button @style="secondary" {{on "click" (route-action "cancelSession" sessionAggregate.session)}}>
{{t "actions.cancel"}}
</Rose::Button>
</row.cell>
</body.row>
{{/each}}
</table.body>
</Rose::Table>
{{else}}
<Rose::Layout::Centered>
<Rose::Message @title={{t "titles.no-sessions"}} as |message|>
<message.description>
{{t "descriptions.no-sessions"}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -8,7 +8,7 @@
<row.headerCell>{{t "form.label.name"}}</row.headerCell>
<row.headerCell>{{t "form.label.type"}}</row.headerCell>
<row.headerCell>{{t "form.label.id"}}</row.headerCell>
<row.headerCell>{{t "form.label.actions"}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
@@ -8,7 +8,7 @@
<row.headerCell>{{t 'form.label.name'}}</row.headerCell>
<row.headerCell>{{t 'form.label.type'}}</row.headerCell>
<row.headerCell>{{t 'form.label.id'}}</row.headerCell>
<row.headerCell>{{t "form.label.actions"}}</row.headerCell>
<row.headerCell>{{t "titles.actions"}}</row.headerCell>
</header.row>
</table.header>
<table.body as |body|>
+34 -2
View File
@@ -1,7 +1,7 @@
import config from '../config/environment';
import { authHandler, deauthHandler } from './route-handlers/auth';
// Uncomment to use in simulating error responses
// import { Response } from 'miragejs';
import { pickRandomStatusString } from './factories/session';
//import { Response } from 'miragejs';
export default function() {
@@ -294,6 +294,38 @@ export default function() {
return target.update(updatedAttrs);
});
// session
// To simulate changes to `session.status` that may occur in the backend,
// we quietly randomize the value of the field on GET.
this.get('/sessions', function ({ sessions }, { queryParams: { scope_id } }) {
sessions.where(session => session.scopeId === scope_id)
.models
.forEach(session => session.update({
status: pickRandomStatusString()
}));
return sessions.where(session => session.scopeId === scope_id)
});
this.get('/sessions/:id', function ({ sessions }, { params: { id } }) {
const session = sessions.find(id);
return session.update({
status: pickRandomStatusString()
});
});
this.post('/sessions/:idMethod', function ({ sessions }, { params: { idMethod } }) {
const attrs = this.normalizedRequestAttrs();
const id = idMethod.split(':')[0];
const method = idMethod.split(':')[1];
const session = sessions.find(id);
const updatedAttrs = {
version: attrs.version
};
if (method === 'cancel') {
updatedAttrs.status = 'canceling';
}
return session.update(updatedAttrs);
});
/* Uncomment the following line and the Response import above
* Then change the response code to simulate error responses.
* this.get('/scopes', () => new Response(505));
+46
View File
@@ -0,0 +1,46 @@
import factory from '../generated/factories/target';
import { trait } from 'ember-cli-mirage';
import { random } from 'faker';
const statusStrings = [
'pending',
'active',
'canceling',
'terminated'
];
export const pickRandomStatusString = () => random.arrayElement(statusStrings);
export default factory.extend({
id: (i) => `session-id-${i}`,
status: pickRandomStatusString,
/**
* Sessions generated with this trait will automatically form
* associations to user, target, host set, and host (if available) within
* the current scope.
*/
withAssociations: trait({
afterCreate(record, server) {
const scopeId = record.scopeId;
if (record.scope.type === 'project') {
const orgScopeId = record.scope?.scope.id;
const user =
server.schema.users.where({ scopeId: orgScopeId }).models[0];
if (user) record.update({ user });
}
const target = server.schema.targets.where({ scopeId }).models[0];
if (target) {
record.update({ target });
const hostSet = target.hostSets.models[0];
if (hostSet) {
record.update({ hostSet });
const host = hostSet.hosts.models[0];
if (host) record.update({ host });
}
}
}
})
});
@@ -0,0 +1,14 @@
import { Factory } from 'ember-cli-mirage';
import { random, date } from 'faker';
/**
* GeneratedSessionModelFactory
*/
export default Factory.extend({
type: 'tcp',
created_time: () => date.recent(),
updated_time: () => date.recent(),
version: () => random.number(),
});
+11
View File
@@ -0,0 +1,11 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
scope: belongsTo(),
user: belongsTo(),
target: belongsTo(),
host: belongsTo(),
hostSet: belongsTo(),
});
+1
View File
@@ -26,6 +26,7 @@ export default function(server) {
server.schema.scopes.where({type: 'project'}).models.forEach(scope => {
server.createList('host-catalog', 2, { scope }, 'withChildren');
server.createList('target', 2, { scope }, 'withRandomHostSets');
server.createList('session', 4, { scope }, 'withAssociations');
});
}
+22
View File
@@ -0,0 +1,22 @@
import ApplicationSerializer from './application';
import { underscore } from '@ember/string';
export default ApplicationSerializer.extend({
modelName: 'session',
keyForRelationshipId(relationshipName) {
return `${this._container.inflector.singularize(
underscore(relationshipName)
)}_id`;
},
_hashForModel(model) {
const json = ApplicationSerializer.prototype._hashForModel.apply(this, arguments);
json.user_id = model.userId;
json.target_id = model.targetId;
json.host_set_id = model.hostSetId;
json.host_id = model.hostId;
return json;
}
});
+1
View File
@@ -54,6 +54,7 @@
"ember-cli-sri": "^2.1.1",
"ember-cli-template-lint": "^1.0.0-beta.3",
"ember-cli-uglify": "^3.0.0",
"ember-concurrency": "^1.3.0",
"ember-data": "~3.16.0",
"ember-data-model-fragments": "5.0.0-beta.0",
"ember-exam": "^5.0.1",
@@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | format-date-iso-human', function(hooks) {
setupRenderingTest(hooks);
// Replace this with your real tests.
test('it renders', async function(assert) {
this.set('inputValue', new Date('2020-01-01T00:00:00.999Z'));
await render(hbs`{{format-date-iso-human inputValue}}`);
assert.equal(this.element.textContent.trim(), '2020-01-01 00:00:00');
});
});
@@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | format-date-iso', function(hooks) {
setupRenderingTest(hooks);
// Replace this with your real tests.
test('it renders', async function(assert) {
this.set('inputValue', new Date('2020-01-01T00:00:00.999Z'));
await render(hbs`{{format-date-iso inputValue}}`);
assert.equal(this.element.textContent.trim(), '2020-01-01T00:00:00.999Z');
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/project/sessions', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/project/sessions');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/project/sessions/index', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/project/sessions/index');
assert.ok(route);
});
});
+8
View File
@@ -77,6 +77,8 @@ resources:
targets: Targets
accounts: Accounts
account: Account
sessions: Sessions
session: Session
titles:
iam: IAM
new: New
@@ -114,6 +116,8 @@ titles:
accounts: Accounts
account: Account
accounts-welcome: Welcome to Accounts
no-sessions: No Active Sessions
actions: Actions
descriptions:
host-catalogs: Overview of collections of hosts and host sets.
empty-set: There are no items to display yet. You may be able to add items or try back later.
@@ -136,6 +140,7 @@ descriptions:
no-available-users: No users are available to add to this group.
account-set-password: Set a password for the account.
accounts-welcome: No accounts are available in this auth method.
no-sessions: There are no active sessions at this time.
states:
info: Info
success: Success
@@ -157,6 +162,8 @@ form:
default_port: Default Port
current_password: Current Password
new_password: New Password
started: Started
status: Status
errors:
401:
title: You are not signed in
@@ -180,3 +187,4 @@ notify:
create-success: Created succesfully.
save-success: Saved succesfully.
delete-success: Deleted successfully.
session-canceled: Session canceled.
+9
View File
@@ -8918,6 +8918,15 @@ ember-compatibility-helpers@^1.1.1, ember-compatibility-helpers@^1.1.2, ember-co
ember-cli-version-checker "^2.1.1"
semver "^5.4.1"
ember-concurrency@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/ember-concurrency/-/ember-concurrency-1.3.0.tgz#66f90fb792687470bcee1172adc0ebf33f5e8b9c"
integrity sha512-DwGlfWFpYyAkTwsedlEtK4t1DznJSculAW6Vq5S1C0shVPc5b6tTpHB2FFYisannSYkm+wpm1f1Pd40qiNPtOQ==
dependencies:
ember-cli-babel "^7.7.3"
ember-compatibility-helpers "^1.2.0"
ember-maybe-import-regenerator "^0.1.6"
ember-cookies@^0.5.0, ember-cookies@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/ember-cookies/-/ember-cookies-0.5.2.tgz#06e33463f2f83254fefaf224cc944dec3fb9d3ba"