diff --git a/ui/app/components/global-search/control.js b/ui/app/components/global-search/control.js
new file mode 100644
index 00000000000..1a061ba8943
--- /dev/null
+++ b/ui/app/components/global-search/control.js
@@ -0,0 +1,172 @@
+import Component from '@ember/component';
+import { tagName } from '@ember-decorators/component';
+import { task } from 'ember-concurrency';
+import EmberObject, { action, computed, set } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import { inject as service } from '@ember/service';
+import { run } from '@ember/runloop';
+import Searchable from 'nomad-ui/mixins/searchable';
+import classic from 'ember-classic-decorator';
+
+const SLASH_KEY = 191;
+
+@tagName('')
+export default class GlobalSearchControl extends Component {
+ @service dataCaches;
+ @service router;
+ @service store;
+
+ searchString = null;
+
+ constructor() {
+ super(...arguments);
+
+ this.jobSearch = JobSearch.create({
+ dataSource: this,
+ });
+
+ this.nodeSearch = NodeSearch.create({
+ dataSource: this,
+ });
+ }
+
+ keyDownHandler(e) {
+ const targetElementName = e.target.nodeName.toLowerCase();
+
+ if (targetElementName != 'input' && targetElementName != 'textarea') {
+ if (e.keyCode === SLASH_KEY) {
+ e.preventDefault();
+ this.open();
+ }
+ }
+ }
+
+ didInsertElement() {
+ this.set('_keyDownHandler', this.keyDownHandler.bind(this));
+ document.addEventListener('keydown', this._keyDownHandler);
+ }
+
+ willDestroyElement() {
+ document.removeEventListener('keydown', this._keyDownHandler);
+ }
+
+ @task(function*(string) {
+ try {
+ set(this, 'searchString', string);
+
+ const jobs = yield this.dataCaches.fetch('job');
+ const nodes = yield this.dataCaches.fetch('node');
+
+ set(this, 'jobs', jobs.toArray());
+ set(this, 'nodes', nodes.toArray());
+
+ const jobResults = this.jobSearch.listSearched;
+ const nodeResults = this.nodeSearch.listSearched;
+
+ return [
+ {
+ groupName: `Jobs (${jobResults.length})`,
+ options: jobResults,
+ },
+ {
+ groupName: `Clients (${nodeResults.length})`,
+ options: nodeResults,
+ },
+ ];
+ } catch (e) {
+ // eslint-disable-next-line
+ console.log('exception searching', e);
+ }
+ })
+ search;
+
+ @action
+ open() {
+ if (this.select) {
+ this.select.actions.open();
+ }
+ }
+
+ @action
+ selectOption(model) {
+ const itemModelName = model.constructor.modelName;
+
+ if (itemModelName === 'job') {
+ this.router.transitionTo('jobs.job', model.name, {
+ queryParams: { namespace: model.get('namespace.name') },
+ });
+ } else if (itemModelName === 'node') {
+ this.router.transitionTo('clients.client', model.id);
+ }
+ }
+
+ @action
+ storeSelect(select) {
+ if (select) {
+ this.select = select;
+ }
+ }
+
+ @action
+ openOnClickOrTab(select, { target }) {
+ // Bypass having to press enter to access search after clicking/tabbing
+ const targetClassList = target.classList;
+ const targetIsTrigger = targetClassList.contains('ember-power-select-trigger');
+
+ // Allow tabbing out of search
+ const triggerIsNotActive = !targetClassList.contains('ember-power-select-trigger--active');
+
+ if (targetIsTrigger && triggerIsNotActive) {
+ run.next(() => {
+ select.actions.open();
+ });
+ }
+ }
+
+ calculatePosition(trigger) {
+ const { top, left, width } = trigger.getBoundingClientRect();
+ return {
+ style: {
+ left,
+ width,
+ top,
+ },
+ };
+ }
+}
+
+@classic
+class JobSearch extends EmberObject.extend(Searchable) {
+ @computed
+ get searchProps() {
+ return ['id', 'name'];
+ }
+
+ @computed
+ get fuzzySearchProps() {
+ return ['name'];
+ }
+
+ @alias('dataSource.jobs') listToSearch;
+ @alias('dataSource.searchString') searchTerm;
+
+ fuzzySearchEnabled = true;
+}
+
+@classic
+class NodeSearch extends EmberObject.extend(Searchable) {
+ @computed
+ get searchProps() {
+ return ['id', 'name'];
+ }
+
+ @computed
+ get fuzzySearchProps() {
+ return ['name'];
+ }
+
+ @alias('dataSource.nodes') listToSearch;
+ @alias('dataSource.searchString') searchTerm;
+
+ fuzzySearchEnabled = true;
+}
diff --git a/ui/app/services/data-caches.js b/ui/app/services/data-caches.js
new file mode 100644
index 00000000000..7617c61c583
--- /dev/null
+++ b/ui/app/services/data-caches.js
@@ -0,0 +1,33 @@
+import Service, { inject as service } from '@ember/service';
+
+export const COLLECTION_CACHE_DURATION = 60000; // one minute
+
+export default class DataCachesService extends Service {
+ @service router;
+ @service store;
+ @service system;
+
+ collectionLastFetched = {};
+
+ async fetch(modelName) {
+ const modelNameToRoute = {
+ job: 'jobs',
+ node: 'clients',
+ };
+
+ const route = modelNameToRoute[modelName];
+ const lastFetched = this.collectionLastFetched[modelName];
+ const now = Date.now();
+
+ if (this.router.isActive(route)) {
+ // TODO Incorrect because it’s constantly being fetched by watchers, shouldn’t be marked as last fetched only on search
+ this.collectionLastFetched[modelName] = now;
+ return this.store.peekAll(modelName);
+ } else if (lastFetched && now - lastFetched < COLLECTION_CACHE_DURATION) {
+ return this.store.peekAll(modelName);
+ } else {
+ this.collectionLastFetched[modelName] = now;
+ return this.store.findAll(modelName);
+ }
+ }
+}
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 1bfd76d9b42..c0f81710ec7 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -11,6 +11,8 @@
@import './components/exec-button';
@import './components/exec-window';
@import './components/fs-explorer';
+@import './components/global-search-control';
+@import './components/global-search-dropdown';
@import './components/gutter';
@import './components/gutter-toggle';
@import './components/image-file.scss';
diff --git a/ui/app/styles/components/global-search-control.scss b/ui/app/styles/components/global-search-control.scss
new file mode 100644
index 00000000000..e42243b5021
--- /dev/null
+++ b/ui/app/styles/components/global-search-control.scss
@@ -0,0 +1,37 @@
+.global-search {
+ width: 30em;
+
+ .ember-power-select-trigger {
+ background: $nomad-green-darker;
+
+ .icon {
+ margin-top: 1px;
+ margin-left: 2px;
+
+ fill: white;
+ opacity: 0.7;
+ }
+
+ .placeholder {
+ opacity: 0.7;
+ display: inline-block;
+ padding-left: 2px;
+ transform: translateY(-1px);
+ }
+
+ &.ember-power-select-trigger--active {
+ background: white;
+
+ .icon {
+ fill: black;
+ opacity: 1;
+ }
+ }
+ }
+
+ .ember-basic-dropdown-content-wormhole-origin {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ }
+}
diff --git a/ui/app/styles/components/global-search-dropdown.scss b/ui/app/styles/components/global-search-dropdown.scss
new file mode 100644
index 00000000000..4e9c2f5ef3e
--- /dev/null
+++ b/ui/app/styles/components/global-search-dropdown.scss
@@ -0,0 +1,45 @@
+.global-search-dropdown {
+ background: transparent;
+ border: 0;
+ position: fixed;
+
+ .ember-power-select-search {
+ margin-left: $icon-dimensions;
+ border: 0;
+ }
+
+ input,
+ input:focus {
+ background: transparent;
+ border: 0;
+ outline: 0;
+ }
+
+ .ember-power-select-options {
+ background: white;
+ padding: 0.35rem;
+
+ &[role='listbox'] {
+ border: 1px solid $grey-blue;
+ box-shadow: 0 6px 8px -2px rgba($black, 0.05), 0 8px 4px -4px rgba($black, 0.1);
+ }
+
+ .ember-power-select-option {
+ padding: 0.2rem 0.4rem;
+ border-radius: $radius;
+
+ &[aria-current='true'] {
+ background: transparentize($blue, 0.8);
+ color: $blue;
+ }
+ }
+ }
+
+ .ember-power-select-group-name {
+ text-transform: uppercase;
+ display: inline;
+ color: darken($grey-blue, 20%);
+ font-size: $size-7;
+ font-weight: $weight-semibold;
+ }
+}
diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss
index 48a2a065d76..9018db8afb8 100644
--- a/ui/app/styles/core/navbar.scss
+++ b/ui/app/styles/core/navbar.scss
@@ -8,6 +8,8 @@
padding-left: 20px;
padding-right: 20px;
overflow: hidden;
+ align-items: center;
+ justify-content: space-between;
.navbar-item {
color: rgba($primary-invert, 0.8);
@@ -35,7 +37,6 @@
display: block;
position: absolute;
left: 0px;
- top: 1.25em;
}
}
}
@@ -44,7 +45,7 @@
display: flex;
align-items: stretch;
justify-content: flex-end;
- margin-left: auto;
+ margin-left: inherit;
}
.navbar-end > a.navbar-item {
@@ -100,7 +101,7 @@
display: flex;
align-items: center;
justify-content: flex-end;
- margin-left: auto;
+ margin-left: inherit;
}
.navbar-end > a.navbar-item {
diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs
index 23c5aa05ccf..e129a42c321 100644
--- a/ui/app/templates/components/global-header.hbs
+++ b/ui/app/templates/components/global-header.hbs
@@ -7,6 +7,9 @@
{{partial "partials/nomad-logo"}}
+ {{#unless (media "isMobile")}}
+