[ui] task logs in sidebar (#14612)

* button styles

* Further styles including global toggle adjustment

* sidebar funcs and header

* Functioning task logs in high-level sidebars

* same-lineify the show tasks toggle

* Changelog

* Full-height sidebar calc in css, plz drop soon container queries

* Active status and query params for allocations page

* Reactive shouldShowLogs getter and added to client and task group pages

* Higher order func passing, thanks @DingoEatingFuzz

* Non-service job types get allocation params passed

* Keyframe animation for task log sidebar

* Acceptance test

* A few more sub-row tests

* Lintfix
This commit is contained in:
Phil Renaud 2022-09-22 10:58:52 -04:00 committed by GitHub
parent c29c4bd66c
commit eca0e7bf56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 342 additions and 44 deletions

3
.changelog/14612.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: adds a sidebar to show in-page logs for a given task, accessible via job, client, or task group routes
```

View File

@ -10,7 +10,7 @@ export default class AllocationServiceSidebarComponent extends Component {
}
keyCommands = [
{
label: 'Close Evaluations Sidebar',
label: 'Close Service Sidebar',
pattern: ['Escape'],
action: () => this.args.fns.closeSidebar(),
},

View File

@ -22,6 +22,7 @@ export default class StreamingFile extends Component.extend(WindowResizable) {
isStreaming = true;
logger = null;
follow = true;
shouldFillHeight = true;
// Internal bookkeeping to avoid multiple scroll events on one frame
requestFrame = true;
@ -89,7 +90,9 @@ export default class StreamingFile extends Component.extend(WindowResizable) {
didInsertElement() {
super.didInsertElement(...arguments);
if (this.shouldFillHeight) {
this.fillAvailableHeight();
}
this.set('_scrollHandler', this.scrollHandler.bind(this));
this.element.addEventListener('scroll', this._scrollHandler);
@ -105,8 +108,10 @@ export default class StreamingFile extends Component.extend(WindowResizable) {
}
windowResizeHandler() {
if (this.shouldFillHeight) {
once(this, this.fillAvailableHeight);
}
}
fillAvailableHeight() {
// This math is arbitrary and far from bulletproof, but the UX

View File

@ -0,0 +1,44 @@
<Portal @target="log-sidebar-portal">
<div
class="sidebar task-context-sidebar has-subnav {{if this.isSideBarOpen "open"}}"
{{on-click-outside
@fns.closeSidebar
capture=true
}}
>
{{#if @task}}
{{keyboard-commands this.keyCommands}}
<header>
<h1 class="title">
{{@task.name}}
<span class="state {{@task.state}}">
{{@task.state}}
</span>
</h1>
<LinkTo
class="link"
title={{@task.name}}
@route="allocations.allocation.task"
@models={{array @task.allocation @task}}
>
Go to Task page
</LinkTo>
<button
class="button close is-borderless"
type="button"
{{on "click" @fns.closeSidebar}}
>
{{x-icon "cancel"}}
</button>
</header>
<TaskLog
@allocation={{@task.allocation}}
@task={{@task.name}}
@shouldFillHeight={{false}}
/>
{{/if}}
</div>
</Portal>

View File

@ -0,0 +1,16 @@
// @ts-check
import Component from '@glimmer/component';
export default class TaskContextSidebarComponent extends Component {
get isSideBarOpen() {
return !!this.args.task;
}
keyCommands = [
{
label: 'Close Task Logs Sidebar',
pattern: ['Escape'],
action: () => this.args.fns.closeSidebar(),
},
];
}

View File

@ -35,6 +35,8 @@ export default class TaskLog extends Component {
isStreaming = true;
streamMode = 'streaming';
shouldFillHeight = true;
@alias('userSettings.logMode') mode;
@computed('allocation.{id,node.httpAddr}', 'useServer')

View File

@ -1,16 +1,16 @@
<tr class="task-sub-row"
<tr class="task-sub-row {{if @active "is-active"}}"
{{keyboard-shortcut
enumerated=true
action=(action "gotoTask" this.task.allocation this.task)
}}
>
<td colspan={{@namespan}}>
/
<LinkTo @route="allocations.allocation.task" @models={{array this.task.allocation this.task}}>
{{this.task.name}}
</LinkTo>
{{!-- TODO: in-page logs --}}
{{!-- <FlightIcon @name="logs" /> --}}
<div class="name-grid">
<LinkTo title={{this.task.name}} class="task-name" @route="allocations.allocation.task" @models={{array this.task.allocation this.task}}>{{this.task.name}}</LinkTo>
<button type="button" class="logs-sidebar-trigger button is-borderless is-inline is-compact" onclick={{action "handleTaskLogsClick" this.task}}>
<FlightIcon @name="logs" />View Logs
</button>
</div>
</td>
<td data-test-cpu class="is-1 has-text-centered">
{{#if this.task.isRunning}}
@ -77,3 +77,12 @@
</tr>
{{yield}}
{{#if this.shouldShowLogs}}
<TaskContextSidebar
@task={{this.task}}
@fns={{hash
closeSidebar=this.closeSidebar
}}
/>
{{/if}}

View File

@ -71,4 +71,22 @@ export default class TaskSubRowComponent extends Component {
} while (this.enablePolling);
}).drop())
fetchStats;
//#region Logs Sidebar
@alias('args.active') shouldShowLogs;
@action handleTaskLogsClick(task) {
if (this.args.onSetActiveTask) {
this.args.onSetActiveTask(task);
}
}
@action closeSidebar() {
if (this.args.onSetActiveTask) {
this.args.onSetActiveTask(null);
}
}
//#endregion Logs Sidebar
}

View File

@ -47,6 +47,7 @@ export default class ClientController extends Controller.extend(
{
qpStatus: 'status',
},
'activeTask',
];
// Set in the route
@ -57,6 +58,7 @@ export default class ClientController extends Controller.extend(
qpStatus = '';
currentPage = 1;
pageSize = 8;
activeTask = null;
sortProperty = 'modifyIndex';
sortDescending = true;
@ -266,4 +268,13 @@ export default class ClientController extends Controller.extend(
setFacetQueryParam(queryParam, selection) {
this.set(queryParam, serialize(selection));
}
@action
setActiveTaskQueryParam(task) {
if (task) {
this.set('activeTask', `${task.allocation.id}-${task.name}`);
} else {
this.set('activeTask', null);
}
}
}

View File

@ -41,6 +41,7 @@ export default class AllocationsController extends Controller.extend(
{
qpTaskGroup: 'taskGroup',
},
'activeTask',
];
qpStatus = '';
@ -48,6 +49,7 @@ export default class AllocationsController extends Controller.extend(
qpTaskGroup = '';
currentPage = 1;
pageSize = 25;
activeTask = null;
sortProperty = 'modifyIndex';
sortDescending = true;
@ -159,4 +161,13 @@ export default class AllocationsController extends Controller.extend(
setFacetQueryParam(queryParam, selection) {
this.set(queryParam, serialize(selection));
}
@action
setActiveTaskQueryParam(task) {
if (task) {
this.set('activeTask', `${task.allocation.id}-${task.name}`);
} else {
this.set('activeTask', null);
}
}
}

View File

@ -3,7 +3,7 @@ import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
import classic from 'ember-classic-decorator';
import { action } from '@ember/object';
@classic
export default class IndexController extends Controller.extend(
WithNamespaceResetting
@ -20,6 +20,7 @@ export default class IndexController extends Controller.extend(
{
sortDescending: 'desc',
},
'activeTask',
];
currentPage = 1;
@ -28,4 +29,14 @@ export default class IndexController extends Controller.extend(
sortProperty = 'name';
sortDescending = false;
activeTask = null;
@action
setActiveTaskQueryParam(task) {
if (task) {
this.set('activeTask', `${task.allocation.id}-${task.name}`);
} else {
this.set('activeTask', null);
}
}
}

View File

@ -43,6 +43,7 @@ export default class TaskGroupController extends Controller.extend(
{
qpClient: 'client',
},
'activeTask',
];
currentPage = 1;
@ -52,6 +53,7 @@ export default class TaskGroupController extends Controller.extend(
qpClient = '';
sortProperty = 'modifyIndex';
sortDescending = true;
activeTask = null;
@computed
get searchProps() {
@ -186,4 +188,13 @@ export default class TaskGroupController extends Controller.extend(
args: ['jobs.job.task-group', job, name],
};
}
@action
setActiveTaskQueryParam(task) {
if (task) {
this.set('activeTask', `${task.allocation.id}-${task.name}`);
} else {
this.set('activeTask', null);
}
}
}

View File

@ -26,6 +26,9 @@
.is-padded {
padding: 0em 0em 0em 1em;
}
.is-one-line {
white-space: nowrap;
}
}
.is-fixed-width {

View File

@ -56,3 +56,78 @@ $subNavOffset: 49px;
padding: 10px;
width: 100px;
}
.task-context-sidebar {
animation-name: slideFromRight;
animation-duration: 150ms;
animation-fill-mode: both;
header {
display: grid;
justify-content: left;
grid-template-columns: 1fr auto auto;
gap: 2rem;
border-bottom: 1px solid $grey-blue;
padding-bottom: 1rem;
margin-bottom: 24px;
height: 50px;
.title {
margin-bottom: unset;
}
.link {
align-self: center;
}
.state {
font-size: 1rem;
font-weight: normal;
margin-left: 1rem;
text-transform: capitalize;
&:before {
content: '';
display: inline-block;
height: 1rem;
width: 1rem;
margin-right: 5px;
border-radius: 4px;
position: relative;
top: 2px;
}
&.running:before {
background-color: $green;
}
&.dead:before {
background-color: $red;
}
&.pending:before {
background-color: $grey-lighter;
}
}
}
// Instead of trying to calculate on the fly with JS, let's use vh and offset nav and headers above.
// We can make this a LOT more streamlined when CSS Container Queries are available.
$sidebarTopOffset: 161px;
$sidebarInnerPadding: 48px;
$sidebarHeaderOffset: 74px;
$cliHeaderOffset: 54.5px;
.cli-window {
height: calc(
100vh - $sidebarTopOffset - $sidebarInnerPadding - $sidebarHeaderOffset -
$cliHeaderOffset
);
}
}
@keyframes slideFromRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}

View File

@ -1,16 +1,39 @@
table tbody .task-sub-row {
td {
border-top: 2px solid white;
.name-grid {
display: inline-grid;
grid-template-columns: auto 1fr;
margin-left: 4rem;
gap: 1rem;
.task-name {
display: block;
width: 150px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&:before {
color: black;
content: '/';
display: inline-block;
margin-right: 0.5rem;
text-decoration: none;
}
td:nth-child(1) {
padding-left: 4rem;
a {
margin-right: 1rem;
}
svg.flight-icon {
.logs-sidebar-trigger {
color: $blue;
text-decoration: underline;
font-weight: normal;
svg {
color: black;
margin-right: 5px;
position: relative;
top: 3px;
}
}
}
}
}

View File

@ -31,7 +31,9 @@ $size: 12px;
.toggler {
display: inline-block;
position: relative;
vertical-align: middle;
vertical-align: baseline;
position: relative;
top: 1px;
width: $size * 2;
height: $size;
border-radius: $size;

View File

@ -26,6 +26,8 @@
<KeyboardShortcutsModal />
<PortalTarget @name="log-sidebar-portal" />
{{#if this.error}}
<div class="error-container">
<div data-test-error class="error-message">

View File

@ -497,9 +497,8 @@
@class="is-padded"
/>
<span class="is-padded">
<span class="is-padded is-one-line">
<Toggle
class="button is-borderless is-inline"
@isActive={{this.showSubTasks}}
@onToggle={{this.toggleShowSubTasks}}
title="Show tasks of allocations"
@ -568,7 +567,7 @@
/>
{{#if this.showSubTasks}}
{{#each row.model.states as |task|}}
<TaskSubRow @namespan="8" @taskState={{task}} />
<TaskSubRow @namespan="8" @taskState={{task}} @active={{eq this.activeTask (concat task.allocation.id "-" task.name)}} @onSetActiveTask={{action 'setActiveTaskQueryParam'}} />
{{/each}}
{{/if}}
</t.body>

View File

@ -19,7 +19,7 @@
"job-page/parts/latest-deployment" job=@job handleError=this.handleError
)
TaskGroups=(component "job-page/parts/task-groups" job=@job)
RecentAllocations=(component "job-page/parts/recent-allocations" job=@job)
RecentAllocations=(component "job-page/parts/recent-allocations" job=@job activeTask=@activeTask setActiveTaskQueryParam=@setActiveTaskQueryParam)
Meta=(component "job-page/parts/meta" job=@job)
DasRecommendations=(component
"job-page/parts/das-recommendations" job=@job

View File

@ -6,7 +6,7 @@
<jobPage.ui.Summary />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<jobPage.ui.Meta />
</jobPage.ui.Body>
</JobPage>

View File

@ -21,7 +21,7 @@
<jobPage.ui.Summary @forceCollapsed={{@job.hasClientStatus}} />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<div class="boxed-section">
{{#if @job.meta}}
<jobPage.ui.Meta />

View File

@ -3,7 +3,6 @@
Recent Allocations
<span class="pull-right is-padded">
<Toggle
class="button is-borderless is-inline"
@isActive={{this.showSubTasks}}
@onToggle={{this.toggleShowSubTasks}}
title="Show tasks of allocations"
@ -69,7 +68,7 @@
{{#if this.showSubTasks}}
{{#each row.model.states as |task|}}
<TaskSubRow @namespan="9" @taskState={{task}} />
<TaskSubRow @namespan="9" @taskState={{task}} @active={{eq @activeTask (concat task.allocation.id "-" task.name)}} @onSetActiveTask={{@setActiveTaskQueryParam}} />
{{/each}}
{{/if}}
</t.body>

View File

@ -21,7 +21,7 @@
<jobPage.ui.Summary @forceCollapsed={{@job.hasClientStatus}} />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<jobPage.ui.Meta />
</jobPage.ui.Body>
</JobPage>

View File

@ -8,7 +8,7 @@
<jobPage.ui.PlacementFailures />
<jobPage.ui.LatestDeployment />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<jobPage.ui.Meta />
</jobPage.ui.Body>
</JobPage>

View File

@ -7,7 +7,7 @@
<jobPage.ui.Summary @forceCollapsed="true" />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<jobPage.ui.Meta />
</jobPage.ui.Body>
</JobPage>

View File

@ -8,7 +8,7 @@
<jobPage.ui.Summary @forceCollapsed="true" />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />
<jobPage.ui.Meta />
</jobPage.ui.Body>
</JobPage>

View File

@ -25,5 +25,5 @@
</span>
</div>
<div data-test-log-box class="boxed-section-body is-dark is-full-bleed">
<StreamingFile @logger={{this.logger}} @mode={{this.streamMode}} @isStreaming={{this.isStreaming}} />
<StreamingFile @logger={{this.logger}} @mode={{this.streamMode}} @isStreaming={{this.isStreaming}} @shouldFillHeight={{this.shouldFillHeight}} />
</div>

View File

@ -71,7 +71,7 @@
@context="job"
@onClick={{action "gotoAllocation" row.model}} />
{{#each row.model.states as |task|}}
<TaskSubRow @namespan="9" @taskState={{task}} />
<TaskSubRow @active={{eq this.activeTask (concat task.allocation.id "-" task.name)}} @onSetActiveTask={{action 'setActiveTaskQueryParam'}} @namespan="9" @taskState={{task}} />
{{/each}}
</t.body>

View File

@ -5,4 +5,6 @@
sortProperty=this.sortProperty
sortDescending=this.sortDescending
currentPage=this.currentPage
activeTask=this.activeTask
setActiveTaskQueryParam=this.setActiveTaskQueryParam
}}

View File

@ -149,9 +149,8 @@
@class="is-padded"
@inputClass="is-compact"
/>
<span class="is-padded">
<span class="is-padded is-one-line">
<Toggle
class="button is-borderless is-inline"
@isActive={{this.showSubTasks}}
@onToggle={{this.toggleShowSubTasks}}
title="Show tasks of allocations"
@ -218,7 +217,7 @@
/>
{{#if this.showSubTasks}}
{{#each row.model.states as |task|}}
<TaskSubRow @namespan="8" @taskState={{task}} />
<TaskSubRow @namespan="8" @taskState={{task}} @active={{eq this.activeTask (concat task.allocation.id "-" task.name)}} @onSetActiveTask={{action 'setActiveTaskQueryParam'}} />
{{/each}}
{{/if}}
</t.body>

View File

@ -1,23 +1,27 @@
/* eslint-disable qunit/require-expect */
import { currentURL } from '@ember/test-helpers';
import { click, currentURL } from '@ember/test-helpers';
import { run } from '@ember/runloop';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import TaskLogs from 'nomad-ui/tests/pages/allocations/task/logs';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
let allocation;
let task;
let job;
module('Acceptance | task logs', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
faker.seed(1);
server.create('agent');
server.create('node', 'forceIPv4');
const job = server.create('job', { createAllocations: false });
job = server.create('job', { createAllocations: false });
allocation = server.create('allocation', {
jobId: job.id,
@ -26,14 +30,15 @@ module('Acceptance | task logs', function (hooks) {
task = server.db.taskStates.where({ allocationId: allocation.id })[0];
run.later(run, run.cancelTimers, 1000);
await TaskLogs.visit({ id: allocation.id, name: task.name });
});
test('it passes an accessibility audit', async function (assert) {
await TaskLogs.visit({ id: allocation.id, name: task.name });
await a11yAudit(assert);
});
test('/allocation/:id/:task_name/logs should have a log component', async function (assert) {
await TaskLogs.visit({ id: allocation.id, name: task.name });
assert.equal(
currentURL(),
`/allocations/${allocation.id}/${task.name}/logs`,
@ -44,6 +49,7 @@ module('Acceptance | task logs', function (hooks) {
});
test('the stdout log immediately starts streaming', async function (assert) {
await TaskLogs.visit({ id: allocation.id, name: task.name });
const node = server.db.nodes.find(allocation.nodeId);
const logUrlRegex = new RegExp(
`${node.httpAddr}/v1/client/fs/logs/${allocation.id}`
@ -55,4 +61,31 @@ module('Acceptance | task logs', function (hooks) {
'Log requests were made'
);
});
test('logs are accessible in a sidebar context', async function (assert) {
await TaskLogs.visitParentJob({
id: job.id,
allocationId: allocation.id,
name: task.name,
});
assert.notOk(TaskLogs.sidebarIsPresent, 'Sidebar is not present');
run.later(() => {
run.cancelTimers();
}, 500);
await click('button.logs-sidebar-trigger');
assert.ok(TaskLogs.sidebarIsPresent, 'Sidebar is present');
assert
.dom('.task-context-sidebar h1.title')
.includesText(task.name, 'Sidebar title is correct');
assert
.dom('.task-context-sidebar h1.title')
.includesText(task.state, 'Task state is correctly displayed');
await percySnapshot(assert);
await click('.sidebar button.close');
assert.notOk(TaskLogs.sidebarIsPresent, 'Sidebar is not present');
});
});

View File

@ -51,10 +51,28 @@ const mockTask = {
module('Integration | Component | task-sub-row', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
assert.expect(2);
assert.expect(6);
this.set('task', mockTask);
await render(hbs`<TaskSubRow @taskState={{this.task}} />`);
assert.dom(this.element).hasText(`/ ${mockTask.name}`);
assert.ok(
this.element.textContent.includes(`${mockTask.name}`),
'Task name is rendered'
);
assert.dom('.task-sub-row').doesNotHaveClass('is-active');
await render(hbs`<TaskSubRow @taskState={{this.task}} @active={{true}} />`);
assert.dom('.task-sub-row').hasClass('is-active');
await render(
hbs`<TaskSubRow @taskState={{this.task}} @active={{true}} @namespan={{5}} />`
);
assert.dom('.task-sub-row td:nth-child(1)').hasAttribute('colspan', '5');
await render(
hbs`<TaskSubRow @taskState={{this.task}} @active={{true}} @namespan={{9}} />`
);
assert.dom('.task-sub-row td:nth-child(1)').hasAttribute('colspan', '9');
await componentA11yAudit(this.element, assert);
});
});

View File

@ -2,6 +2,8 @@ import { create, isPresent, visitable } from 'ember-cli-page-object';
export default create({
visit: visitable('/allocations/:id/:name/logs'),
visitParentJob: visitable('/jobs/:id/allocations'),
hasTaskLog: isPresent('[data-test-task-log]'),
sidebarIsPresent: isPresent('.sidebar.task-context-sidebar'),
});