ui: Serf Health Check warning notice (#10194)

When the Consul serf health check is failing, this means that the health checks registered with the agent may no longer be correct. Therefore we show a notice to the user when we detect that the serf health check is failing both for the health check listing for nodes and for service instances.

There were a few little things we fixed up whilst we were here:

- We use our @replace decorator to replace an empty Type with serf in the model.
- We noticed that ServiceTags can be null, so we replace that with an empty array.
- We added docs for both our Notice component and the Consul::HealthCheck::List component. Notice now defaults to @type=info.
This commit is contained in:
John Cowen 2021-05-13 11:36:51 +01:00 committed by GitHub
parent bc9c57c566
commit 8b0314f26f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 330 additions and 67 deletions

3
.changelog/10194.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Show a message to explain that health checks may be out of date if the serf health check is in a critical state
```

View File

@ -0,0 +1,33 @@
# Consul::HealthCheck::List
A presentational component for rendering HealthChecks.
```hbs preview-template
<figure>
<figcaption>Grab some mock data...</figcaption>
<DataSource @src="/default/dc-1/node/my-node" as |source|>
<figure>
<figcaption>but only show a max of 2 items for docs purposes</figcaption>
<Consul::HealthCheck::List
@items={{slice 0 2 source.data.Checks}}
/>
</figure>
</DataSource>
</figure>
```
## Arguments
| Argument/Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `array` | | An array of HealthChecks |
## See
- [Template Source Code](./index.hbs)
---

View File

@ -25,7 +25,7 @@
<dl> <dl>
<dt>Type</dt> <dt>Type</dt>
<dd data-health-check-type> <dd data-health-check-type>
{{or item.Type 'serf'}} {{item.Type}}
{{#if item.Exposed}} {{#if item.Exposed}}
<em <em
data-test-exposed="true" data-test-exposed="true"

View File

@ -0,0 +1,49 @@
# Notice
Presentational component for informational/warning/error banners/notices.
```hbs preview-template
<Notice
@type={{this.type}}
as |notice|>
<notice.Header>
<h3>Header</h3>
</notice.Header>
<notice.Body>
<p>
Body
</p>
</notice.Body>
<notice.Footer>
<p>
<a href="">Footer link</a>
</p>
</notice.Footer>
</Notice>
<figure>
<figcaption>Provide a widget to change the <code>@type</code></figcaption>
<select
onchange={{action (mut this.type) value="target.value"}}
>
<option>info</option>
<option>warning</option>
<option>error</option>
</select>
</figure>
```
## Arguments
| Argument/Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `String` | `info` | Type of notice [info\|warning\|error] |
## See
- [Template Source Code](./index.hbs)
---

View File

@ -1,46 +0,0 @@
import { Meta, Story, Canvas } from '@storybook/addon-docs/blocks';
import { hbs } from 'ember-cli-htmlbars';
<Meta title="Components/Notice" component="Notice" />
# Notice
<Canvas>
<Story name="Basic"
argTypes={{
type: {
defaultValue: 'success',
control: {
type: 'select',
options: [
'success',
'warning',
'info',
'highlight',
]
}
}
}}
>{(args) => ({
template: hbs`<Notice
@type={{type}}
as |notice|>
<notice.Header>
<h3>Header</h3>
</notice.Header>
<notice.Body>
<p>
Body
</p>
</notice.Body>
<notice.Footer>
<p>
Footer
</p>
</notice.Footer>
</Notice>`,
context: args
})}
</Story>
</Canvas>

View File

@ -1,5 +1,5 @@
<div <div
class="notice {{@type}}" class="notice {{or @type 'info'}}"
...attributes ...attributes
> >
{{yield (hash {{yield (hash

View File

@ -2,7 +2,7 @@
* Simple replacing decorator, with the primary usecase for avoiding null API * Simple replacing decorator, with the primary usecase for avoiding null API
* errors by decorating model attributes: @replace(null, []) @attr() Tags; * errors by decorating model attributes: @replace(null, []) @attr() Tags;
*/ */
const replace = (find, replace) => (target, propertyKey, desc) => { export const replace = (find, replace) => (target, propertyKey, desc) => {
return { return {
get: function() { get: function() {
const value = desc.get.apply(this, arguments); const value = desc.get.apply(this, arguments);

View File

@ -9,7 +9,7 @@ export default {
node: (item, value) => item.Kind === value, node: (item, value) => item.Kind === value,
}, },
check: { check: {
serf: (item, value) => item.Type === '', serf: (item, value) => item.Type === value,
script: (item, value) => item.Type === value, script: (item, value) => item.Type === value,
http: (item, value) => item.Type === value, http: (item, value) => item.Type === value,
tcp: (item, value) => item.Type === value, tcp: (item, value) => item.Type === value,

View File

@ -2,27 +2,29 @@ import Fragment from 'ember-data-model-fragments/fragment';
import { array } from 'ember-data-model-fragments/attributes'; import { array } from 'ember-data-model-fragments/attributes';
import { attr } from '@ember-data/model'; import { attr } from '@ember-data/model';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { replace, nullValue } from 'consul-ui/decorators/replace';
export const schema = { export const schema = {
Status: { Status: {
allowedValues: ['passing', 'warning', 'critical'], allowedValues: ['passing', 'warning', 'critical'],
}, },
Type: { Type: {
allowedValues: ['', 'script', 'http', 'tcp', 'ttl', 'docker', 'grpc', 'alias'], allowedValues: ['serf', 'script', 'http', 'tcp', 'ttl', 'docker', 'grpc', 'alias'],
}, },
}; };
export default class HealthCheck extends Fragment { export default class HealthCheck extends Fragment {
@attr('string') Name; @attr('string') Name;
@attr('string') CheckID; @attr('string') CheckID;
@attr('string') Type; // an empty Type means its the Consul serf Check
@replace('', 'serf') @attr('string') Type;
@attr('string') Status; @attr('string') Status;
@attr('string') Notes; @attr('string') Notes;
@attr('string') Output; @attr('string') Output;
@attr('string') ServiceName; @attr('string') ServiceName;
@attr('string') ServiceID; @attr('string') ServiceID;
@attr('string') Node; @attr('string') Node;
@array('string') ServiceTags; @nullValue([]) @array('string') ServiceTags;
@attr() Definition; // {} @attr() Definition; // {}
// Exposed is only set correct if this Check is accessed via instance.MeshChecks // Exposed is only set correct if this Check is accessed via instance.MeshChecks

View File

@ -9,5 +9,5 @@ export default {
ID: item => item.Service.ID || '', ID: item => item.Service.ID || '',
Notes: item => item.Notes, Notes: item => item.Notes,
Output: item => item.Output, Output: item => item.Output,
ServiceTags: item => asArray(item.ServiceTags || []), ServiceTags: item => asArray(item.ServiceTags),
}; };

View File

@ -39,13 +39,13 @@ html.is-debug body > .brand-loader {
background-color: white; background-color: white;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
ol, > ol,
ul { > ul {
list-style-position: outside; list-style-position: outside;
margin-bottom: 1rem; margin-bottom: 1rem;
margin-left: 2rem; margin-left: 2rem;
} }
ul { > ul {
list-style-type: disc; list-style-type: disc;
} }
} }
@ -74,11 +74,18 @@ html.is-debug body > .brand-loader {
color: var(--gray-400); color: var(--gray-400);
font-style: italic; font-style: italic;
} }
figcaption code {
@extend %inline-code;
}
figure > [type='text'] { figure > [type='text'] {
border: 1px solid var(--gray-999); border: 1px solid var(--gray-999);
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
} }
figure > select {
border: 1px solid var(--gray-999);
padding: 0.5rem;
}
} }
// &__snippets__tabs__button { // &__snippets__tabs__button {
// display: none; // display: none;

View File

@ -47,6 +47,26 @@ as |route|>
@filter={{filters}} @filter={{filters}}
/> />
{{/if}} {{/if}}
{{#let (find-by "Type" "serf" items) as |serf|}}
{{#if (and serf (eq serf.Status "critical"))}}
<Notice
data-test-critical-serf-notice
@type="warning"
as |notice|>
<notice.Header>
<h2>
{{t "routes.dc.nodes.show.healthchecks.critical-serf-notice.header"}}
</h2>
</notice.Header>
<notice.Body>
{{t
"routes.dc.nodes.show.healthchecks.critical-serf-notice.body"
htmlSafe=true
}}
</notice.Body>
</Notice>
{{/if}}
{{/let}}
<DataCollection <DataCollection
@type="health-check" @type="health-check"
@sort={{sort.value}} @sort={{sort.value}}
@ -62,9 +82,10 @@ as |route|>
<collection.Empty> <collection.Empty>
<EmptyState> <EmptyState>
<BlockSlot @name="body"> <BlockSlot @name="body">
<p> {{t "routes.dc.nodes.show.healthchecks.empty"
This node has no health checks{{#if (gt items.length 0)}} matching that search{{/if}}. items=items.length
</p> htmlSafe=true
}}
</BlockSlot> </BlockSlot>
</EmptyState> </EmptyState>
</collection.Empty> </collection.Empty>

View File

@ -44,6 +44,26 @@ as |route|>
/> />
{{/if}} {{/if}}
{{#let (find-by "Type" "serf" items) as |serf|}}
{{#if (and serf (eq serf.Status "critical"))}}
<Notice
data-test-critical-serf-notice
@type="warning"
as |notice|>
<notice.Header>
<h2>
{{t "routes.dc.services.instance.healthchecks.critical-serf-notice.header"}}
</h2>
</notice.Header>
<notice.Body>
{{t
"routes.dc.services.instance.healthchecks.critical-serf-notice.body"
htmlSafe=true
}}
</notice.Body>
</Notice>
{{/if}}
{{/let}}
<DataCollection <DataCollection
@type="health-check" @type="health-check"
@sort={{sort.value}} @sort={{sort.value}}
@ -59,9 +79,10 @@ as |route|>
<collection.Empty> <collection.Empty>
<EmptyState> <EmptyState>
<BlockSlot @name="body"> <BlockSlot @name="body">
<p> {{t "routes.dc.services.instance.healthchecks.empty"
This instance has no health checks{{#if (gt items.length 0)}} matching that search{{/if}}. items=items.length
</p> htmlSafe=true
}}
</BlockSlot> </BlockSlot>
</EmptyState> </EmptyState>
</collection.Empty> </collection.Empty>

View File

@ -2,8 +2,9 @@
Feature: dc / nodes / show: Show node Feature: dc / nodes / show: Show node
Background: Background:
Given 1 datacenter model with the value "dc1" Given 1 datacenter model with the value "dc1"
# 2 nodes are required for the RTT tab to be visible
Scenario: Given 2 nodes all the tabs are visible and clickable Scenario: Given 2 nodes all the tabs are visible and clickable
Given 2 node models from yaml Given 2 node models
When I visit the node page for yaml When I visit the node page for yaml
--- ---
dc: dc1 dc: dc1

View File

@ -0,0 +1,40 @@
@setupApplicationTest
Feature: dc / nodes / show / health-checks
Background:
Given 1 datacenter model with the value "dc1"
Scenario: A failing serf check
Given 1 node model from yaml
---
ID: node-0
Checks:
- Type: ''
Name: Serf Health Status
CheckID: serfHealth
Status: critical
Output: ouch
---
When I visit the node page for yaml
---
dc: dc1
node: node-0
---
And I see healthChecksIsSelected on the tabs
And I see criticalSerfNotice on the tabs.healthChecksTab
Scenario: A passing serf check
Given 1 node model from yaml
---
ID: node-0
Checks:
- Type: ''
Name: Serf Health Status
CheckID: serfHealth
Status: passing
Output: Agent alive and reachable
---
When I visit the node page for yaml
---
dc: dc1
node: node-0
---
And I see healthChecksIsSelected on the tabs
And I don't see criticalSerfNotice on the tabs.healthChecksTab

View File

@ -0,0 +1,66 @@
@setupApplicationTest
Feature: dc / services / instances / health-checks
Background:
Given 1 datacenter model with the value "dc1"
And 1 proxy model from yaml
---
- ServiceProxy:
DestinationServiceName: service-1
DestinationServiceID: ~
---
Scenario: A failing serf check
Given 2 instance models from yaml
---
- Service:
ID: service-0-with-id
Node:
Node: node-0
- Service:
ID: service-1-with-id
Node:
Node: another-node
Checks:
- Type: ''
Name: Serf Health Status
CheckID: serfHealth
Status: critical
Output: ouch
---
When I visit the instance page for yaml
---
dc: dc1
service: service-0
node: another-node
id: service-1-with-id
---
Then the url should be /dc1/services/service-0/instances/another-node/service-1-with-id/health-checks
And I see healthChecksIsSelected on the tabs
And I see criticalSerfNotice on the tabs.healthChecksTab
Scenario: A passing serf check
Given 2 instance models from yaml
---
- Service:
ID: service-0-with-id
Node:
Node: node-0
- Service:
ID: service-1-with-id
Node:
Node: another-node
Checks:
- Type: ''
Name: Serf Health Status
CheckID: serfHealth
Status: passing
Output: Agent alive and reachable
---
When I visit the instance page for yaml
---
dc: dc1
service: service-0
node: another-node
id: service-1-with-id
---
Then the url should be /dc1/services/service-0/instances/another-node/service-1-with-id/health-checks
And I see healthChecksIsSelected on the tabs
And I don't see criticalSerfNotice on the tabs.healthChecksTab

View File

@ -0,0 +1,10 @@
import steps from '../../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from '../../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -164,6 +164,7 @@ export default {
visitable, visitable,
alias, alias,
attribute, attribute,
isPresent,
collection, collection,
text, text,
tabgroup, tabgroup,
@ -177,7 +178,9 @@ export default {
visitable, visitable,
deletable, deletable,
clickable, clickable,
alias,
attribute, attribute,
isPresent,
collection, collection,
tabgroup, tabgroup,
text, text,

View File

@ -2,13 +2,15 @@ export default function(
visitable, visitable,
deletable, deletable,
clickable, clickable,
alias,
attribute, attribute,
present,
collection, collection,
tabs, tabs,
text, text,
healthChecks healthChecks
) { ) {
return { const page = {
visit: visitable('/:dc/nodes/:node'), visit: visitable('/:dc/nodes/:node'),
tabs: tabs('tab', [ tabs: tabs('tab', [
'health-checks', 'health-checks',
@ -17,7 +19,7 @@ export default function(
'lock-sessions', 'lock-sessions',
'metadata', 'metadata',
]), ]),
healthChecks: healthChecks(), healthChecks: alias('tabs.healthChecksTab.healthChecks'),
services: collection('.consul-service-instance-list > ul > li:not(:first-child)', { services: collection('.consul-service-instance-list > ul > li:not(:first-child)', {
name: text('[data-test-service-name]'), name: text('[data-test-service-name]'),
port: attribute('data-test-service-port', '[data-test-service-port]'), port: attribute('data-test-service-port', '[data-test-service-port]'),
@ -31,4 +33,9 @@ export default function(
}), }),
metadata: collection('.consul-metadata-list [data-test-tabular-row]', {}), metadata: collection('.consul-metadata-list [data-test-tabular-row]', {}),
}; };
page.tabs.healthChecksTab = {
criticalSerfNotice: present('[data-test-critical-serf-notice]'),
healthChecks: healthChecks(),
};
return page;
} }

View File

@ -2,13 +2,14 @@ export default function(
visitable, visitable,
alias, alias,
attribute, attribute,
present,
collection, collection,
text, text,
tabs, tabs,
upstreams, upstreams,
healthChecks healthChecks
) { ) {
return { const page = {
visit: visitable('/:dc/services/:service/instances/:node/:id'), visit: visitable('/:dc/services/:service/instances/:node/:id'),
externalSource: attribute('data-test-external-source', '[data-test-external-source]', { externalSource: attribute('data-test-external-source', '[data-test-external-source]', {
scope: '.title', scope: '.title',
@ -26,4 +27,9 @@ export default function(
}), }),
metadata: collection('.metadata [data-test-tabular-row]', {}), metadata: collection('.metadata [data-test-tabular-row]', {}),
}; };
page.tabs.healthChecksTab = {
criticalSerfNotice: present('[data-test-critical-serf-notice]'),
healthChecks: healthChecks(),
};
return page;
} }

View File

@ -1,4 +1,20 @@
dc: dc:
nodes:
show:
healthchecks:
empty: |
<p>
This node has no health checks{items, select,
0 {}
other { matching that search}
}.
</p>
critical-serf-notice:
header: Failing serf check
body: |
<p>
This node has a failing serf node check. The health statuses shown on this page are the statuses as they were known before the node became unreachable.
</p>
services: services:
show: show:
upstreams: upstreams:
@ -7,6 +23,20 @@ dc:
Upstreams are services that may receive traffic from this gateway. If you are not using Consul DNS, please make sure your <code>Host:</code> header uses the correct domain name for the gateway to correctly proxy to its upstreams. Learn more about configuring gateways in our <a href="{CONSUL_DOCS_URL}/connect/ingress-gateways" target="_blank" rel="noopener noreferrer">documentation</a>. Upstreams are services that may receive traffic from this gateway. If you are not using Consul DNS, please make sure your <code>Host:</code> header uses the correct domain name for the gateway to correctly proxy to its upstreams. Learn more about configuring gateways in our <a href="{CONSUL_DOCS_URL}/connect/ingress-gateways" target="_blank" rel="noopener noreferrer">documentation</a>.
</p> </p>
instance: instance:
healthchecks:
empty: |
<p>
This instance has no health checks{items, select,
0 {}
other { matching that search}
}.
</p>
critical-serf-notice:
header: Failing serf check
body: |
<p>
This instance has a failing serf node check. The health statuses shown on this page are the statuses as they were known before the node became unreachable.
</p>
upstreams: upstreams:
tproxy-mode: tproxy-mode:
header: Transparent proxy mode header: Transparent proxy mode