ui: Peer Deletion (#13665)

* ui: Peer Deletion (#13665)
* ui: Add sorting peer listing by State (#13684)
* ui: Add filtering peer listing by State (#13685)
This commit is contained in:
John Cowen 2022-07-07 18:23:26 +01:00 committed by GitHub
parent 8d275ac186
commit 8c0da8fdfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 495 additions and 112 deletions

View File

@ -0,0 +1,76 @@
%pill-pending::before,
%pill-establishing::before,
%pill-active::before,
%pill-failing::before,
%pill-terminated::before,
%pill-deleting::before {
--icon-size: icon-000;
content: '';
}
%pill-pending,
%pill-establishing,
%pill-active,
%pill-failing,
%pill-terminated,
%pill-deleting {
font-weight: var(--typo-weight-medium);
font-size: var(--typo-size-700);
}
%pill-pending::before {
--icon-name: icon-running;
--icon-color: rgb(var(--tone-gray-800));
}
%pill-pending {
background-color: rgb(var(--tone-strawberry-050));
color: rgb(var(--tone-strawberry-500));
}
%pill-establishing::before {
--icon-name: icon-running;
--icon-color: rgb(var(--tone-gray-800));
}
%pill-establishing {
background-color: rgb(var(--tone-blue-050));
color: rgb(var(--tone-blue-500));
}
%pill-active::before {
--icon-name: icon-check;
--icon-color: rgb(var(--tone-green-800));
}
%pill-active {
background-color: rgb(var(--tone-green-050));
color: rgb(var(--tone-green-600));
}
%pill-failing::before {
--icon-name: icon-x;
--icon-color: rgb(var(--tone-red-500));
}
%pill-failing {
background-color: rgb(var(--tone-red-050));
color: rgb(var(--tone-red-500));
}
%pill-terminated::before {
--icon-name: icon-x-square;
--icon-color: rgb(var(--tone-gray-800));
}
%pill-terminated {
background-color: rgb(var(--tone-gray-150));
color: rgb(var(--tone-gray-800));
}
%pill-deleting::before {
--icon-name: icon-loading;
--icon-color: rgb(var(--tone-green-800));
}
%pill-deleting {
background-color: rgb(var(--tone-yellow-050));
color: rgb(var(--tone-yellow-800));
}

View File

@ -0,0 +1,4 @@
@import './components';
@import './search-bar';

View File

@ -0,0 +1,26 @@
# Consul::Peer::List
A presentational component for rendering Consul Peers
```hbs preview-template
<DataSource @src={{uri '/partition/default/dc-1/peers'}} as |source|>
<Consul::Peer::List
@items={{source.data}}
@ondelete={{noop}}
/>
</DataSource>
```
## Arguments
| Argument/Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `array` | | An array of Peers |
| `ondelete` | `function` | | An action to execute when the `Delete` action is clicked |
## See
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,93 @@
<ListCollection
class="consul-peer-list"
...attributes
@items={{@items}}
@linkable="linkable peer"
as |item index|>
<BlockSlot @name="header">
{{#if (can 'delete peer' item=item)}}
<a
data-test-peer={{item.Name}}
href={{href-to 'dc.peers.edit' item.Name}}
>
{{item.Name}}
</a>
{{else}}
<p>
{{item.Name}}
</p>
{{/if}}
</BlockSlot>
<BlockSlot @name="details">
<div class="peers__list__peer-detail">
<Peerings::Badge @peering={{item}} />
<div
{{tooltip
(t 'routes.dc.peers.index.detail.imported.tooltip'
name=item.Name
)
}}
>
{{t 'routes.dc.peers.index.detail.imported.count'
count=(format-number item.ImportedServiceCount)
}}
</div>
<div
{{tooltip
(t 'routes.dc.peers.index.detail.exported.tooltip'
name=item.Name
)
}}
>
{{t 'routes.dc.peers.index.detail.exported.count'
count=(format-number item.ExportedServiceCount)
}}
</div>
</div>
</BlockSlot>
<BlockSlot @name="actions" as |Actions|>
{{#if (can 'delete peer' item=item)}}
<Actions as |Action|>
<Action
data-test-edit-action
@href={{href-to 'dc.peers.edit' item.Name}}
>
<BlockSlot @name="label">
View
</BlockSlot>
</Action>
<Action
data-test-delete-action
@onclick={{fn @ondelete item}}
class="dangerous"
>
<BlockSlot @name="label">
Delete
</BlockSlot>
<BlockSlot @name="confirmation" as |Confirmation|>
<Confirmation class="warning">
<BlockSlot @name="header">
Confirm delete
</BlockSlot>
<BlockSlot @name="body">
<p>
Are you sure you want to delete this peer?
</p>
</BlockSlot>
<BlockSlot @name="confirm" as |Confirm|>
<Confirm>
Delete
</Confirm>
</BlockSlot>
</Confirmation>
</BlockSlot>
</Action>
</Actions>
{{/if}}
</BlockSlot>
</ListCollection>

View File

@ -0,0 +1,19 @@
# Consul::Peer::Notifications
A Notification component specifically for Peers. This is only a component as we currently use this in two places and if we need to add more types we can do so in one place.
We currently one have one 'remove' type due to the fact that Peers can't use the default 'delete' notification as they get 'marked for deletion' instead.
```hbs preview-template
<Consul::Peer::Notifications
@type={{'remove'}}
/>
```
## See
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,16 @@
{{#if (eq @type 'remove')}}
<Notice
class="notification-delete"
@type="success"
...attributes
as |notice|>
<notice.Header>
<strong>Success!</strong>
</notice.Header>
<notice.Body>
<p>
Your Peer has been marked for deletion.
</p>
</notice.Body>
</Notice>
{{/if}}

View File

@ -64,6 +64,42 @@ as |key value|}}
</search.Select> </search.Select>
</search.Search> </search.Search>
</:search> </:search>
<:filter as |search|>
<search.Select
class="type-state"
@position="left"
@onchange={{action @filter.state.change}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "components.consul.peer.search-bar.state.name"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each
(get
(require '/models/peer'
path='schema'
from='/components/consul/peer/search-bar'
)
'State.allowedValues'
) as |upperState|}}
{{#let
(string-to-lower-case upperState)
as |state|}}
<Option class="value-{{state}}" @value={{state}} @selected={{includes state @filter.state.value}}>
<span>
{{t (concat "components.consul.peer.search-bar.state.options." state)}}
</span>
</Option>
{{/let}}
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
</:filter>
<:sort as |search|> <:sort as |search|>
<search.Select <search.Select
class="type-sort" class="type-sort"
@ -78,6 +114,8 @@ as |key value|}}
{{#let (from-entries (array {{#let (from-entries (array
(array "Name:asc" (t "common.sort.alpha.asc")) (array "Name:asc" (t "common.sort.alpha.asc"))
(array "Name:desc" (t "common.sort.alpha.desc")) (array "Name:desc" (t "common.sort.alpha.desc"))
(array "State:asc" (t "components.consul.peer.search-bar.sort.state.asc"))
(array "State:desc" (t "components.consul.peer.search-bar.sort.state.desc"))
)) ))
as |selectable| as |selectable|
}} }}
@ -87,6 +125,10 @@ as |key value|}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="options"> <BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}} {{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t "components.consul.peer.search-bar.sort.state.name"}}>
<Option @value="State:asc" @selected={{eq "State:asc" @sort.value}}>{{t "components.consul.peer.search-bar.sort.state.asc"}}</Option>
<Option @value="State:desc" @selected={{eq "State:desc" @sort.value}}>{{t "components.consul.peer.search-bar.sort.state.desc"}}</Option>
</Optgroup>
<Optgroup @label={{t "common.consul.name"}}> <Optgroup @label={{t "common.consul.name"}}>
<Option @value="Name:asc" @selected={{eq "Name:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option> <Option @value="Name:asc" @selected={{eq "Name:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option>
<Option @value="Name:desc" @selected={{eq "Name:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option> <Option @value="Name:desc" @selected={{eq "Name:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option>

View File

@ -0,0 +1,24 @@
.consul-peer-search-bar {
li button span {
@extend %pill-500;
}
.value-pending span {
@extend %pill-pending;
}
.value-establishing span {
@extend %pill-establishing;
}
.value-active span {
@extend %pill-active;
}
.value-failing span {
@extend %pill-failing;
}
.value-terminated span {
@extend %pill-terminated;
}
.value-deleting span {
@extend %pill-deleting;
}
}

View File

@ -0,0 +1,10 @@
export default {
state: {
pending: (item, value) => item.State.toLowerCase() === value,
establishing: (item, value) => item.State.toLowerCase() === value,
active: (item, value) => item.State.toLowerCase() === value,
failing: (item, value) => item.State.toLowerCase() === value,
terminated: (item, value) => item.State.toLowerCase() === value,
deleting: (item, value) => item.State.toLowerCase() === value,
},
};

View File

@ -12,12 +12,15 @@ const container = new Map();
// `css` already has a caching mechanism under the hood so rely on that, plus // `css` already has a caching mechanism under the hood so rely on that, plus
// we get the advantage of laziness here, i.e. we only call css as and when we // we get the advantage of laziness here, i.e. we only call css as and when we
// need to // need to
export default helper(([path = ''], { from }) => { export default helper(([path = ''], options) => {
const fullPath = resolve(`${appName}${from}`, path); let fullPath = resolve(`${appName}${options.from}`, path);
if(path.charAt(0) === '/') {
fullPath = `${appName}${fullPath}`;
}
let module; let module;
if(require.has(fullPath)) { if(require.has(fullPath)) {
module = require(fullPath).default; module = require(fullPath)[options.export || 'default'];
} else { } else {
throw new Error(`Unable to resolve '${fullPath}' does the file exist?`) throw new Error(`Unable to resolve '${fullPath}' does the file exist?`)
} }
@ -27,7 +30,7 @@ export default helper(([path = ''], { from }) => {
return module(css); return module(css);
case fullPath.endsWith('.xstate'): case fullPath.endsWith('.xstate'):
return module; return module;
default: { case fullPath.endsWith('.element'): {
if(container.has(fullPath)) { if(container.has(fullPath)) {
return container.get(fullPath); return container.get(fullPath);
} }
@ -35,5 +38,7 @@ export default helper(([path = ''], { from }) => {
container.set(fullPath, component); container.set(fullPath, component);
return component; return component;
} }
default:
return module;
} }
}); });

View File

@ -1,5 +1,18 @@
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
export const schema = {
State: {
defaultValue: 'PENDING',
allowedValues: [
'PENDING',
'ESTABLISHING',
'ACTIVE',
'FAILING',
'TERMINATED',
'DELETING'
],
},
};
export default class Peer extends Model { export default class Peer extends Model {
@attr('string') uri; @attr('string') uri;
@attr() meta; @attr() meta;

View File

@ -3,6 +3,7 @@ import Route from 'consul-ui/routing/route';
export default class PeersRoute extends Route { export default class PeersRoute extends Route {
queryParams = { queryParams = {
sortBy: 'sort', sortBy: 'sort',
state: 'state',
searchproperty: { searchproperty: {
as: 'searchproperty', as: 'searchproperty',
empty: [['Name']], empty: [['Name']],

View File

@ -2,11 +2,14 @@ import Service, { inject as service } from '@ember/service';
import { setProperties } from '@ember/object'; import { setProperties } from '@ember/object';
export default class HttpService extends Service { export default class HttpService extends Service {
@service('client/http') client;
@service('settings') settings; @service('settings') settings;
@service('repository/intention') intention; @service('repository/intention') intention;
@service('repository/kv') kv; @service('repository/kv') kv;
@service('repository/nspace') nspace; @service('repository/nspace') nspace;
@service('repository/partition') partition; @service('repository/partition') partition;
@service('repository/peer') peer;
@service('repository/session') session; @service('repository/session') session;
prepare(sink, data, instance) { prepare(sink, data, instance) {
@ -24,12 +27,16 @@ export default class HttpService extends Service {
persist(sink, instance) { persist(sink, instance) {
const [, , , , model] = sink.split('/'); const [, , , , model] = sink.split('/');
const repo = this[model]; const repo = this[model];
return repo.persist(instance); return this.client.request(
request => repo.persist(instance, request)
);
} }
remove(sink, instance) { remove(sink, instance) {
const [, , , , model] = sink.split('/'); const [, , , , model] = sink.split('/');
const repo = this[model]; const repo = this[model];
return repo.remove(instance); return this.client.request(
request => repo.remove(instance, request)
);
} }
} }

View File

@ -10,6 +10,7 @@ import intention from 'consul-ui/filter/predicates/intention';
import token from 'consul-ui/filter/predicates/token'; import token from 'consul-ui/filter/predicates/token';
import policy from 'consul-ui/filter/predicates/policy'; import policy from 'consul-ui/filter/predicates/policy';
import authMethod from 'consul-ui/filter/predicates/auth-method'; import authMethod from 'consul-ui/filter/predicates/auth-method';
import peer from 'consul-ui/filter/predicates/peer';
const predicates = { const predicates = {
service: andOr(service), service: andOr(service),
@ -21,6 +22,7 @@ const predicates = {
intention: andOr(intention), intention: andOr(intention),
token: andOr(token), token: andOr(token),
policy: andOr(policy), policy: andOr(policy),
peer: andOr(peer),
}; };
export default class FilterService extends Service { export default class FilterService extends Service {

View File

@ -70,4 +70,30 @@ export default class PeerService extends RepositoryService {
}; };
}); });
} }
async remove(item, request) {
// soft delete
// we just return the item we want to delete
// but mark it as DELETING ourselves as the request is successfull
// and we don't have blocking queries here to get immediate updates
return (await request`
DELETE /v1/peering/${item.Name}
`)((headers, body, cache) => {
const partition = item.Partition;
const ns = item.Namespace;
const dc = item.Datacenter;
return {
meta: {
version: 2,
},
body: cache(
{
...item,
State: 'DELETING'
},
uri => uri`peer:///${partition}/${ns}/${dc}/peer/${item.Name}`
)
};
});
}
} }

View File

@ -1,3 +1,26 @@
export default ({ properties }) => key => { import { schema } from 'consul-ui/models/peer';
export default ({ properties }) => (key = 'State:asc') => {
if (key.startsWith('State:')) {
return function(itemA, itemB) {
const [, dir] = key.split(':');
let a, b;
if (dir === 'asc') {
b = itemA;
a = itemB;
} else {
a = itemA;
b = itemB;
}
switch (true) {
case schema.State.allowedValues.indexOf(a.State) < schema.State.allowedValues.indexOf(b.State):
return 1;
case schema.State.allowedValues.indexOf(a.State) > schema.State.allowedValues.indexOf(b.State):
return -1;
case schema.State.allowedValues.indexOf(a.State) === schema.State.allowedValues.indexOf(b.State):
return 0;
}
};
}
return properties(['Name'])(key); return properties(['Name'])(key);
}; };

View File

@ -501,7 +501,7 @@
// @import './rotate-ccw/index.scss'; // @import './rotate-ccw/index.scss';
// @import './rotate-cw/index.scss'; // @import './rotate-cw/index.scss';
// @import './rss/index.scss'; // @import './rss/index.scss';
// @import './running/index.scss'; @import './running/index.scss';
// @import './save/index.scss'; // @import './save/index.scss';
// @import './scissors/index.scss'; // @import './scissors/index.scss';
// @import './search/index.scss'; // @import './search/index.scss';
@ -620,7 +620,7 @@
// @import './x-diamond-fill/index.scss'; // @import './x-diamond-fill/index.scss';
// @import './x-hexagon/index.scss'; // @import './x-hexagon/index.scss';
// @import './x-hexagon-fill/index.scss'; // @import './x-hexagon-fill/index.scss';
// @import './x-square/index.scss'; @import './x-square/index.scss';
// @import './x-square-fill/index.scss'; // @import './x-square-fill/index.scss';
// @import './youtube/index.scss'; // @import './youtube/index.scss';
// @import './youtube-color/index.scss'; // @import './youtube-color/index.scss';

View File

@ -103,7 +103,8 @@
@import 'consul-ui/components/topology-metrics/series'; @import 'consul-ui/components/topology-metrics/series';
@import 'consul-ui/components/topology-metrics/stats'; @import 'consul-ui/components/topology-metrics/stats';
@import 'consul-ui/components/topology-metrics/status'; @import 'consul-ui/components/topology-metrics/status';
@import 'consul-ui/components/consul/intention/list/table';
@import 'consul-ui/components/consul/peer';
@import 'consul-ui/components/peerings/badge'; @import 'consul-ui/components/peerings/badge';
@import 'consul-ui/components/consul/node/peer-info'; @import 'consul-ui/components/consul/node/peer-info';
@import 'consul-ui/components/consul/intention/list/table';
@import 'consul-ui/components/consul/service/peer-info'; @import 'consul-ui/components/consul/service/peer-info';

View File

@ -20,11 +20,15 @@
{{#let {{#let
(hash (hash
value=(or sortBy "Name:asc") value=(or sortBy "State:asc")
change=(action (mut sortBy) value="target.selected") change=(action (mut sortBy) value="target.selected")
) )
(hash (hash
state=(hash
value=(if state (split state ',') undefined)
change=(action (mut state) value="target.selectedItems")
)
searchproperty=(hash searchproperty=(hash
value=(if (not-eq searchproperty undefined) value=(if (not-eq searchproperty undefined)
(split searchproperty ',') (split searchproperty ',')
@ -58,111 +62,86 @@ as |sort filters items|}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
<DataCollection <DataWriter
@sink={{uri '/${partition}/${dc}/${nspace}/peer/'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
)
}}
@type="peer" @type="peer"
@sort={{sort.value}} @label="Peer"
@filters={{filters}} @ondelete={{refresh-route}}
@search={{search}} as |writer|>
@items={{items}} <BlockSlot @name="removed" as |after|>
as |collection|> <Consul::Peer::Notifications
<collection.Collection> {{notification
after=(action after)
}}
@type="remove"
/>
</BlockSlot>
<BlockSlot @name="content">
<DataCollection
@type="peer"
@sort={{sort.value}}
@filters={{filters}}
@search={{search}}
@items={{items}}
as |collection|>
<collection.Collection>
<ListCollection <Consul::Peer::List
@items={{collection.items}} @items={{collection.items}}
@linkable="linkable peer" @onedit={{this.edit.open}}
as |item index|> @ondelete={{writer.delete}}
<BlockSlot @name="header"> />
<p>{{item.Name}}</p>
</BlockSlot>
<BlockSlot @name="details">
<div class="peers__list__peer-detail">
<Peerings::Badge @peering={{item}} />
<div </collection.Collection>
{{tooltip <collection.Empty>
(t 'routes.dc.peers.index.detail.imported.tooltip' {{!-- TODO: do we need to check permissions here or will we receive an error automatically? --}}
name=item.Name <EmptyState
) @login={{route.model.app.login.open}}
}} >
> <BlockSlot @name="header">
{{t 'routes.dc.peers.index.detail.imported.count' <h2>
count=(format-number item.ImportedServiceCount) {{#if (gt items.length 0)}}
}} No peers found
</div> {{else}}
Welcome to Peers
<div {{/if}}
{{tooltip </h2>
(t 'routes.dc.peers.index.detail.exported.tooltip' </BlockSlot>
name=item.Name <BlockSlot @name="body">
) {{#if (gt items.length 0)}}
}} No peers where found matching that search, or you may not have access to view the peers you are searching for.
> {{else}}
{{t 'routes.dc.peers.index.detail.exported.count' Peering allows an admin partition in one datacenter to communicate with a partition in a different
count=(format-number item.ExportedServiceCount) datacenter. There don't seem to be any peers for this admin partition, or you may not have
}} <code>peering:read</code> permissions to
</div> access this view.
{{/if}}
</div> </BlockSlot>
</BlockSlot> <BlockSlot @name="actions">
<BlockSlot @name="actions" as |Actions|> <li class="docs-link">
{{#if (can 'delete peer' item=item)}} {{!-- what's the docs for peering?--}}
<a href="https://www.consul.io/docs/agent/kv" rel="noopener noreferrer" target="_blank">
<Actions as |Action|> Documentation on Peers
{{#if true}} </a>
<Action data-test-edit-action @href={{href-to 'dc.peers.edit' item.Name}}> </li>
<BlockSlot @name="label"> <li class="learn-link">
View <a href="https://learn.hashicorp.com/consul/getting-started/kv" rel="noopener noreferrer" target="_blank">
</BlockSlot> Take the tutorial
</Action> </a>
{{/if}} </li>
</Actions> </BlockSlot>
{{/if}} </EmptyState>
</BlockSlot> </collection.Empty>
</ListCollection> </DataCollection>
</BlockSlot>
</collection.Collection> </DataWriter>
<collection.Empty>
{{!-- TODO: do we need to check permissions here or will we receive an error automatically? --}}
<EmptyState
@login={{route.model.app.login.open}}
>
<BlockSlot @name="header">
<h2>
{{#if (gt items.length 0)}}
No peers found
{{else}}
Welcome to Peers
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
{{#if (gt items.length 0)}}
No peers where found matching that search, or you may not have access to view the peers you are searching for.
{{else}}
Peering allows an admin partition in one datacenter to communicate with a partition in a different
datacenter. There don't seem to be any peers for this admin partition, or you may not have
<code>peering:read</code> permissions to
access this view.
{{/if}}
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
{{!-- what's the docs for peering?--}}
<a href="https://www.consul.io/docs/agent/kv" rel="noopener noreferrer" target="_blank">
Documentation on Peers
</a>
</li>
<li class="learn-link">
<a href="https://learn.hashicorp.com/consul/getting-started/kv" rel="noopener noreferrer" target="_blank">
Take the tutorial
</a>
</li>
</BlockSlot>
</EmptyState>
</collection.Empty>
</DataCollection>
</BlockSlot> </BlockSlot>
</AppView> </AppView>
{{/let}} {{/let}}
</BlockSlot> </BlockSlot>

View File

@ -1,3 +1,19 @@
peer:
search-bar:
state:
name: Status
options:
pending: Pending
establishing: Establishing
active: Active
failing: Failing
terminated: Terminated
deleting: Deleting
sort:
state:
name: Status
asc: Pending to Deleting
desc: Deleting to Pending
service: service:
search-bar: search-bar:
kind: Service Type kind: Service Type