diff --git a/.changelog/14592.txt b/.changelog/14592.txt
new file mode 100644
index 000000000000..5a0f2d7f5e64
--- /dev/null
+++ b/.changelog/14592.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: allow deep-dive clicks to tasks from client, job, and task group routes.
+```
diff --git a/ui/app/components/job-page/parts/recent-allocations.js b/ui/app/components/job-page/parts/recent-allocations.js
index 3afd9f37d7bb..5f40c434a932 100644
--- a/ui/app/components/job-page/parts/recent-allocations.js
+++ b/ui/app/components/job-page/parts/recent-allocations.js
@@ -4,6 +4,7 @@ import { inject as service } from '@ember/service';
import PromiseArray from 'nomad-ui/utils/classes/promise-array';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
@classic
@classNames('boxed-section')
@@ -13,6 +14,14 @@ export default class RecentAllocations extends Component {
sortProperty = 'modifyIndex';
sortDescending = true;
+ @localStorageProperty('nomadShowSubTasks', true) showSubTasks;
+
+ @action
+ toggleShowSubTasks(e) {
+ e.preventDefault();
+ this.set('showSubTasks', !this.get('showSubTasks'));
+ }
+
@computed('job.allocations.@each.modifyIndex')
get sortedAllocations() {
return PromiseArray.create({
diff --git a/ui/app/components/task-sub-row.hbs b/ui/app/components/task-sub-row.hbs
new file mode 100644
index 000000000000..203fd6555e57
--- /dev/null
+++ b/ui/app/components/task-sub-row.hbs
@@ -0,0 +1,79 @@
+
+
+ /
+
+ {{this.task.name}}
+
+ {{!-- TODO: in-page logs --}}
+ {{!-- --}}
+ |
+
+ {{#if this.task.isRunning}}
+ {{#if (and (not this.cpu) this.fetchStats.isRunning)}}
+ ...
+ {{else if this.statsError}}
+
+ {{x-icon "alert-triangle" class="is-warning"}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+ |
+
+ {{#if this.task.isRunning}}
+ {{#if (and (not this.memory) this.fetchStats.isRunning)}}
+ ...
+ {{else if this.statsError}}
+
+ {{x-icon "alert-triangle" class="is-warning"}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+ |
+
+
+{{yield}}
\ No newline at end of file
diff --git a/ui/app/components/task-sub-row.js b/ui/app/components/task-sub-row.js
new file mode 100644
index 000000000000..1499c2edf126
--- /dev/null
+++ b/ui/app/components/task-sub-row.js
@@ -0,0 +1,74 @@
+import Ember from 'ember';
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import { task, timeout } from 'ember-concurrency';
+import { tracked } from '@glimmer/tracking';
+
+export default class TaskSubRowComponent extends Component {
+ @service store;
+ @service router;
+ @service('stats-trackers-registry') statsTrackersRegistry;
+
+ constructor() {
+ super(...arguments);
+ // Kick off stats polling
+ const allocation = this.task.allocation;
+ if (allocation) {
+ this.fetchStats.perform();
+ } else {
+ this.fetchStats.cancelAll();
+ }
+ }
+
+ @alias('args.taskState') task;
+
+ @action
+ gotoTask(allocation, task) {
+ this.router.transitionTo('allocations.allocation.task', allocation, task);
+ }
+
+ // Since all tasks for an allocation share the same tracker, use the registry
+ @computed('task.{allocation,isRunning}')
+ get stats() {
+ if (!this.task.isRunning) return undefined;
+
+ return this.statsTrackersRegistry.getTracker(this.task.allocation);
+ }
+
+ // Internal state
+ @tracked statsError = false;
+
+ @computed
+ get enablePolling() {
+ return !Ember.testing;
+ }
+
+ @computed('task.name', 'stats.tasks.[]')
+ get taskStats() {
+ if (!this.stats) return undefined;
+
+ return this.stats.tasks.findBy('task', this.task.name);
+ }
+
+ @alias('taskStats.cpu.lastObject') cpu;
+ @alias('taskStats.memory.lastObject') memory;
+
+ @(task(function* () {
+ do {
+ if (this.stats) {
+ try {
+ yield this.stats.poll.linked().perform();
+ this.statsError = false;
+ } catch (error) {
+ this.statsError = true;
+ }
+ }
+
+ yield timeout(500);
+ } while (this.enablePolling);
+ }).drop())
+ fetchStats;
+}
diff --git a/ui/app/controllers/clients/client/index.js b/ui/app/controllers/clients/client/index.js
index 05d9ce5c9662..1775a07270db 100644
--- a/ui/app/controllers/clients/client/index.js
+++ b/ui/app/controllers/clients/client/index.js
@@ -15,6 +15,7 @@ import {
deserializedQueryParam as selection,
} from 'nomad-ui/utils/qp-serialize';
import classic from 'ember-classic-decorator';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
@classic
export default class ClientController extends Controller.extend(
@@ -60,6 +61,14 @@ export default class ClientController extends Controller.extend(
sortProperty = 'modifyIndex';
sortDescending = true;
+ @localStorageProperty('nomadShowSubTasks', false) showSubTasks;
+
+ @action
+ toggleShowSubTasks(e) {
+ e.preventDefault();
+ this.set('showSubTasks', !this.get('showSubTasks'));
+ }
+
@computed()
get searchProps() {
return ['shortId', 'name'];
diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js
index fccb0f40cf88..763fa5b3d8d9 100644
--- a/ui/app/controllers/jobs/job/task-group.js
+++ b/ui/app/controllers/jobs/job/task-group.js
@@ -13,6 +13,7 @@ import {
deserializedQueryParam as selection,
} from 'nomad-ui/utils/qp-serialize';
import classic from 'ember-classic-decorator';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
@classic
export default class TaskGroupController extends Controller.extend(
@@ -57,6 +58,14 @@ export default class TaskGroupController extends Controller.extend(
return ['shortId', 'name'];
}
+ @localStorageProperty('nomadShowSubTasks', true) showSubTasks;
+
+ @action
+ toggleShowSubTasks(e) {
+ e.preventDefault();
+ this.set('showSubTasks', !this.get('showSubTasks'));
+ }
+
@computed('model.allocations.[]')
get allocations() {
return this.get('model.allocations') || [];
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index ef5f3fbc1257..960c9c6d1e96 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -49,3 +49,4 @@
@import './components/variables';
@import './components/keyboard-shortcuts-modal';
@import './components/services';
+@import './components/task-sub-row';
diff --git a/ui/app/styles/components/task-sub-row.scss b/ui/app/styles/components/task-sub-row.scss
new file mode 100644
index 000000000000..e8334b03d579
--- /dev/null
+++ b/ui/app/styles/components/task-sub-row.scss
@@ -0,0 +1,16 @@
+table tbody .task-sub-row {
+ td {
+ border-top: 2px solid white;
+ }
+ td:nth-child(1) {
+ padding-left: 4rem;
+ a {
+ margin-right: 1rem;
+ }
+
+ svg.flight-icon {
+ position: relative;
+ top: 3px;
+ }
+ }
+}
diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss
index 17ea8b56afaa..2e2f1d673a57 100644
--- a/ui/app/styles/core/table.scss
+++ b/ui/app/styles/core/table.scss
@@ -41,6 +41,10 @@
}
}
+ &.with-collapsed-borders {
+ border-collapse: collapse;
+ }
+
&.is-darkened {
tbody tr:not(.is-selected) {
background-color: $white-bis;
diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs
index 20a8f3506b53..22056e0b2818 100644
--- a/ui/app/templates/clients/client/index.hbs
+++ b/ui/app/templates/clients/client/index.hbs
@@ -496,6 +496,17 @@
@inputClass="is-compact"
@class="is-padded"
/>
+
+
+
+ Show Tasks
+
+
|
@@ -555,6 +566,11 @@
@onClick={{action "gotoAllocation" row.model}}
@data-test-allocation={{row.model.id}}
/>
+ {{#if this.showSubTasks}}
+ {{#each row.model.states as |task|}}
+
+ {{/each}}
+ {{/if}}