Merge pull request #14947 from hashicorp/ui/feat/peer-detail-page

ui: peer detail view
This commit is contained in:
Michael Klein 2022-10-13 17:03:57 +02:00 committed by GitHub
commit e6cce385e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1896 additions and 645 deletions

3
.changelog/14947.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Create peerings detail page
```

View File

@ -38,6 +38,5 @@ deps: clean
dist-vercel: clean
mkdir -p dist/ui && \
cd packages/consul-ui && \
CONSUL_UI_INSTALL_FLAGS=--focus \
$(MAKE) build-staging && \
mv dist/* ../../dist/ui

View File

@ -0,0 +1,71 @@
<Hds::Card::Container @level="base" @hasBorder={{true}} class="mt-6 mb-3">
<div class="flex h-24 p-6 overflow-x-scroll space-x-12">
<div class="shrink-0">
<div
class="mb-2 hds-typography-body-200 hds-font-weight-semibold text-hds-foreground-primary"
>Status</div>
<div class="flex items-center">
<Peerings::Badge @peering={{@peering}} />
</div>
</div>
<div class="shrink-0">
<div
class="mb-2 hds-typography-body-200 hds-font-weight-semibold text-hds-foreground-primary"
>Latest heartbeat</div>
<div class="flex items-center">
{{#if @peering.LastHeartbeat}}
{{#let (smart-date-format @peering.LastHeartbeat) as |smartDate|}}
<FlightIcon
@name="activity"
class="mr-0.5 text-hds-foreground-faint fill-current"
/>
{{#if smartDate.isNearDate}}
<span {{tooltip smartDate.friendly}}>{{smartDate.relative}}</span>
{{else}}
<span>{{smartDate.friendly}}</span>
{{/if}}
{{/let}}
{{else}}
<span>None yet</span>
{{/if}}
</div>
</div>
<div class="shrink-0">
<div
class="mb-2 hds-typography-body-200 hds-font-weight-semibold text-hds-foregrouny-primary"
>Latest receipt</div>
<div class="flex items-center">
{{#if @peering.LastReceive}}
{{#let (smart-date-format @peering.LastReceive) as |smartDate|}}
{{#if smartDate.isNearDate}}
<span {{tooltip smartDate.friendly}}>{{smartDate.relative}}</span>
{{else}}
<span>{{smartDate.friendly}}</span>
{{/if}}
{{/let}}
{{else}}
<span>None yet</span>
{{/if}}
</div>
</div>
<div class="shrink-0">
<div
class="mb-2 hds-typography-body-200 hds-font-weight-semibold text-hds-foreground-primary"
>Latest send</div>
<div class="flex items-center">
{{#if @peering.LastSend}}
{{#let (smart-date-format @peering.LastSend) as |smartDate|}}
{{#if smartDate.isNearDate}}
<span {{tooltip smartDate.friendly}}>{{smartDate.relative}}</span>
{{else}}
<span>{{smartDate.friendly}}</span>
{{/if}}
{{/let}}
{{else}}
<span>None yet</span>
{{/if}}
</div>
</div>
</div>
</Hds::Card::Container>

View File

@ -2,13 +2,22 @@
class="consul-peer-list"
...attributes
@items={{@items}}
as |item index|>
@linkable="linkable peers"
as |item index|
>
<BlockSlot @name="header">
<p
data-test-peer={{item.Name}}
>
{{item.Name}}
</p>
{{#if (can "delete peer" item=item)}}
<a
data-test-peer={{item.Name}}
href={{href-to "dc.peers.show" item.Name}}
>
{{item.Name}}
</a>
{{else}}
<p data-test-peer={{item.Name}}>
{{item.Name}}
</p>
{{/if}}
</BlockSlot>
<BlockSlot @name="details">
<div class="peers__list__peer-detail">
@ -16,24 +25,22 @@ as |item index|>
<div
{{tooltip
(t 'routes.dc.peers.index.detail.imported.tooltip'
name=item.Name
)
(t "routes.dc.peers.index.detail.imported.tooltip" name=item.Name)
}}
>
{{t 'routes.dc.peers.index.detail.imported.count'
{{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.tooltip" name=item.Name)
}}
>
{{t 'routes.dc.peers.index.detail.exported.count'
{{t
"routes.dc.peers.index.detail.exported.count"
count=(format-number item.ExportedServiceCount)
}}
</div>
@ -41,47 +48,51 @@ as |item index|>
</div>
</BlockSlot>
<BlockSlot @name="actions" as |Actions|>
{{#if (can 'delete peer' item=item)}}
{{#if (can "delete peer" item=item)}}
<Actions as |Action|>
{{#if (can "write peer" item=item)}}
<Actions as |Action|>
{{#if (and (can "write peer" item=item) item.isDialer)}}
<Action data-test-regenerate-action {{on "click" (fn @onedit item)}}>
<BlockSlot @name="label">
Regenerate token
</BlockSlot>
</Action>
{{/if}}
<Action
data-test-regenerate-action
{{on 'click' (fn @onedit item)}}
data-test-view-action
@href={{href-to "dc.peers.show" item.Name}}
>
<BlockSlot @name="label">
Regenerate token
View
</BlockSlot>
</Action>
{{/if}}
<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}}
<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>
</ListCollection>

View File

@ -1,19 +1,19 @@
export const selectors = {
$: '.consul-peer-list',
$: ".consul-peer-list",
collection: {
$: '[data-test-list-row]',
$: "[data-test-list-row]",
peer: {
$: 'li',
$: "li",
name: {
$: '[data-test-peer]'
}
$: "[data-test-peer]",
},
},
}
},
};
export default (collection, isPresent, attribute, actions) => () => {
return collection(`${selectors.$} ${selectors.collection.$}`, {
peer: isPresent(selectors.collection.peer.$),
name: attribute('data-test-peer', selectors.collection.peer.name.$),
...actions(['regenerate', 'delete']),
name: attribute("data-test-peer", selectors.collection.peer.name.$),
...actions(["regenerate", "delete", "view"]),
});
};

View File

@ -0,0 +1,17 @@
import Controller from "@ember/controller";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
export default class PeersEditExportedController extends Controller {
queryParams = {
search: {
as: "filter",
},
};
@tracked search = "";
@action updateSearch(value) {
this.search = value;
}
}

View File

@ -0,0 +1,11 @@
import Controller from "@ember/controller";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default class DcPeersEditIndexController extends Controller {
@service router;
@action transitionToImported() {
this.router.replaceWith("dc.peers.show.imported");
}
}

View File

@ -1,68 +0,0 @@
<Route
@name={{routeName}}
as |route|>
<DataLoader @src={{
uri '/${partition}/${nspace}/${dc}/peer/${name}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
name=route.params.name
)
}}
as |loader|>
<BlockSlot @name="error">
<AppError
@error={{loader.error}}
@login={{route.model.app.login.open}}
/>
</BlockSlot>
<BlockSlot @name="loaded">
{{#let
route.params.dc
route.params.partition
route.params.nspace
loader.data
as |dc partition nspace item|}}
<AppView>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.peers'}}>All Peers</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
<route.Title
@title={{item.Name}}
/>
</h1>
</BlockSlot>
<BlockSlot @name="content">
<TabNav @items={{
compact
(array
(hash
label="Addresses"
href=(href-to "dc.peers.edit.addresses")
selected=(is-href "dc.peers.edit.addresses")
)
)
}}/>
<Outlet
@name={{routeName}}
@model={{assign (hash
items=item.PeerServerAddresses
) route.model}}
as |o|>
{{outlet}}
</Outlet>
</BlockSlot>
</AppView>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>

View File

@ -1,7 +0,0 @@
<Route
@name={{routeName}}
as |route|>
<Consul::Peer::Address::List
@items={{route.model.items}}
/>
</Route>

View File

@ -1,6 +0,0 @@
<Route
@name={{routeName}}
as |route|>
{{did-insert (route-action 'replaceWith' 'dc.peers.edit.addresses')}}
</Route>

View File

@ -0,0 +1,59 @@
<Route @name={{routeName}} as |route|>
<DataLoader
@src={{uri
"/${partition}/${nspace}/${dc}/peer/${name}"
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
name=route.params.name
)
}}
as |loader|
>
<BlockSlot @name="error">
<AppError @error={{loader.error}} @login={{route.model.app.login.open}} />
</BlockSlot>
<BlockSlot @name="loaded">
{{#let
route.params.dc
route.params.partition
route.params.nspace
loader.data
as |dc partition nspace item|
}}
<AppView>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to "dc.peers"}}>All Peers</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
<route.Title @title={{item.Name}} />
</h1>
</BlockSlot>
<BlockSlot @name="content">
<Consul::Peer::BentoBox @peering={{item}} />
<Peerings::Provider @peer={{item}} as |peering|>
<TabNav @items={{peering.data.tabs}} />
</Peerings::Provider>
<Outlet
@name={{routeName}}
@model={{assign
(hash items=item.PeerServerAddresses peer=item)
route.model
}}
as |o|
>
{{outlet}}
</Outlet>
</BlockSlot>
</AppView>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>

View File

@ -0,0 +1,38 @@
<Route @name={{routeName}} as |route|>
{{#if (gt route.model.items.length 0)}}
<Consul::Peer::Address::List @items={{route.model.items}} />
{{else}}
<EmptyState @login={{route.model.app.login.open}} data-test-addresses-empty>
<BlockSlot @name="header">
<h2>
{{t "routes.dc.peers.show.addresses.empty.header"}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
{{t "routes.dc.peers.show.addresses.empty.body" htmlSafe=true}}
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a
href="{{env 'CONSUL_DOCS_URL'}}/connect/cluster-peering"
rel="noopener noreferrer"
target="_blank"
>
Documentation on Peers
</a>
</li>
<li class="learn-link">
<a
href="{{env
'CONSUL_DOCS_URL'
}}/connect/cluster-peering/create-manage-peering"
rel="noopener noreferrer"
target="_blank"
>
Take the tutorial
</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</Route>

View File

@ -0,0 +1,134 @@
<Route @name={{routeName}} as |route|>
<DataLoader
@src={{uri
"/${partition}/${nspace}/${dc}/exported-services/${name}"
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
name=route.model.peer.Name
)
}}
as |api|
>
{{#let
(or route.params.partition route.model.user.token.Partition "default")
api.data
as |partition items|
}}
<BlockSlot @name="error">
<AppError @error={{api.error}} @login={{route.model.app.login.open}} />
</BlockSlot>
<BlockSlot @name="loaded">
{{#if items.length}}
<div class="search-bar">
<form class="filter-bar">
<FreetextFilter
@onsearch={{pick "target.value" this.updateSearch}}
@value={{this.search}}
@placeholder="Search"
class="!w-80"
/>
</form>
</div>
{{/if}}
<Providers::Search
@items={{items}}
@search={{this.search}}
@searchProperties={{array "Name"}}
as |search|
>
<Providers::Dimension as |p|>
{{#if p.data.height}}
<div
style={{p.data.fillRemainingHeightStyle}}
class="overflow-y-scroll"
>
{{#if search.data.items.length}}
<VerticalCollection
@tagName="ul"
@estimateHeight={{p.data.height}}
@items={{search.data.items}}
as |service index|
>
<li class="px-3 h-12 border-b border-hds-border-primary">
<a
data-test-service-name
class="hds-typography-display-300 text-hds-foreground-strong hds-font-weight-semibold h-full w-full flex items-center"
href={{href-to
"dc.services.show.index"
service.Name
params=(if
(not-eq service.Partition partition)
(hash
partition=service.Partition
nspace=service.Namespace
peer=service.PeerName
)
(hash peer=service.PeerName)
)
}}
>
{{service.Name}}
</a>
</li>
</VerticalCollection>
{{else}}
<EmptyState
@login={{route.model.app.login.open}}
data-test-exported-services-empty
>
<BlockSlot @name="header">
<h2>
{{t
"routes.dc.peers.show.exported.empty.header"
name=route.model.peer.Name
}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
{{t
"routes.dc.peers.show.exported.empty.body"
items=items.length
name=route.model.peer.Name
htmlSafe=true
}}
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a
href="{{env
'CONSUL_DOCS_URL'
}}/connect/cluster-peering"
rel="noopener noreferrer"
target="_blank"
>
Documentation on Peers
</a>
</li>
<li class="learn-link">
<a
href="{{env
'CONSUL_DOCS_URL'
}}/connect/cluster-peering/create-manage-peering"
rel="noopener noreferrer"
target="_blank"
>
Take the tutorial
</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</div>
{{/if}}
</Providers::Dimension>
</Providers::Search>
</BlockSlot>
{{/let}}
</DataLoader>
</Route>

View File

@ -0,0 +1,133 @@
<Route @name={{routeName}} as |route|>
<DataLoader
@src={{uri
"/${partition}/${nspace}/${dc}/services/${peer}/${peerId}"
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
peer=route.model.peer.Name
peerId=route.model.peer.id
)
}}
as |api|
>
<BlockSlot @name="error">
<AppError @error={{api.error}} @login={{route.model.app.login.open}} />
</BlockSlot>
<BlockSlot @name="loaded">
{{#let
(hash
value=(or sortBy "Status:asc")
change=(action (mut sortBy) value="target.selected")
)
(hash
status=(hash
value=(if status (split status ",") undefined)
change=(action (mut status) value="target.selectedItems")
)
kind=(hash
value=(if kind (split kind ",") undefined)
change=(action (mut kind) value="target.selectedItems")
)
source=(hash
value=(if source (split source ",") undefined)
change=(action (mut source) value="target.selectedItems")
)
searchproperty=(hash
value=(if
(not-eq searchproperty undefined)
(split searchproperty ",")
this.searchProperties
)
change=(action (mut searchproperty) value="target.selectedItems")
default=this.searchProperties
)
)
(reject-by "Kind" "connect-proxy" api.data)
(or route.params.partition route.model.user.token.Partition "default")
(or route.params.nspace route.model.user.token.Namespace "default")
as |sort filters items partition nspace|
}}
{{#if (gt items.length 0)}}
{{#let (collection items) as |items|}}
<Consul::Service::SearchBar
@sources={{get items "ExternalSources"}}
@partitions={{get items "Partitions"}}
@partition={{partition}}
@search={{search}}
@onsearch={{action (mut search) value="target.value"}}
@sort={{sort}}
@filter={{filters}}
@peer={{route.model.peer}}
/>
{{/let}}
{{/if}}
<DataCollection
@type="service"
@sort={{sort.value}}
@filters={{filters}}
@search={{search}}
@items={{items}}
as |collection|
>
<collection.Collection>
<Consul::Service::List
@items={{collection.items}}
@partition={{partition}}
@isPeerDetail={{true}}
/>
</collection.Collection>
<collection.Empty>
<EmptyState
@login={{route.model.app.login.open}}
data-test-imported-services-empty
>
<BlockSlot @name="header">
<h2>
{{t
"routes.dc.peers.show.imported.empty.header"
name=route.model.peer.Name
}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
{{t
"routes.dc.peers.show.imported.empty.body"
items=items.length
name=route.model.peer.Name
htmlSafe=true
}}
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a
href="{{env 'CONSUL_DOCS_URL'}}/connect/cluster-peering"
rel="noopener noreferrer"
target="_blank"
>
Documentation on Peers
</a>
</li>
<li class="learn-link">
<a
href="{{env
'CONSUL_DOCS_URL'
}}/connect/cluster-peering/create-manage-peering"
rel="noopener noreferrer"
target="_blank"
>
Take the tutorial
</a>
</li>
</BlockSlot>
</EmptyState>
</collection.Empty>
</DataCollection>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>

View File

@ -0,0 +1,3 @@
<Route @name={{routeName}} as |route|>
{{did-insert this.transitionToImported}}
</Route>

View File

@ -1,30 +1,76 @@
(routes => routes({
dc: {
peers: {
_options: {
path: '/peers'
},
index: {
((routes) =>
routes({
dc: {
peers: {
_options: {
path: '/',
queryParams: {
sortBy: 'sort',
state: 'state',
searchproperty: {
as: 'searchproperty',
empty: [['Name', 'ID']],
path: "/peers",
},
index: {
_options: {
path: "/",
queryParams: {
sortBy: "sort",
state: "state",
searchproperty: {
as: "searchproperty",
empty: [["Name", "ID"]],
},
search: {
as: "filter",
replace: true,
},
},
search: {
as: 'filter',
replace: true,
},
},
show: {
_options: {
path: "/:name",
},
imported: {
_options: {
path: "/imported-services",
queryParams: {
sortBy: "sort",
status: "status",
source: "source",
kind: "kind",
searchproperty: {
as: "searchproperty",
empty: [["Name", "Tags"]],
},
search: {
as: "filter",
replace: true,
},
},
},
},
exported: {
_options: {
path: "/exported-services",
queryParams: {
search: {
as: "filter",
replace: true,
},
},
},
},
addresses: {
_options: {
path: "/addresses",
},
},
},
},
},
},
}))(
(json, data = (typeof document !== 'undefined' ? document.currentScript.dataset : module.exports)) => {
}))(
(
json,
data = typeof document !== "undefined"
? document.currentScript.dataset
: module.exports
) => {
data[`routes`] = JSON.stringify(json);
}
);

View File

@ -96,6 +96,12 @@ module.exports = {
urlSchema: 'auto',
urlPrefix: 'docs/consul',
},
{
root: path.resolve(__dirname, 'app/components/providers'),
pattern: '**/README.mdx',
urlSchema: 'auto',
urlPrefix: 'docs/providers',
},
{
root: `${path.dirname(require.resolve('consul-acls/package.json'))}/app/components`,
pattern: '**/README.mdx',
@ -129,6 +135,7 @@ module.exports = {
].concat(user.sources),
labels: {
consul: 'Consul Components',
providers: 'Provider Components',
...user.labels,
},
};

View File

@ -1,7 +1,7 @@
import Adapter from './application';
export default class ServiceAdapter extends Adapter {
requestForQuery(request, { dc, ns, partition, index, gateway, uri }) {
requestForQuery(request, { dc, ns, partition, index, gateway, uri, peer }) {
if (typeof gateway !== 'undefined') {
return request`
GET /v1/internal/ui/gateway-services-nodes/${gateway}?${{ dc }}
@ -16,13 +16,14 @@ export default class ServiceAdapter extends Adapter {
`;
} else {
return request`
GET /v1/internal/ui/services?${{ dc }}
GET /v1/internal/ui/services?${{ dc, peer }}
X-Request-ID: ${uri}
${{
ns,
partition,
index,
peer,
}}
`;
}

View File

@ -1,97 +1,89 @@
<ListCollection
class="consul-service-list"
class='consul-service-list'
...attributes
@items={{@items}}
@linkable="linkable service"
@linkable='linkable service'
as |item index|
>
<BlockSlot @name="header">
<BlockSlot @name='header'>
<dl class={{item.MeshStatus}}>
<dt>
Health
</dt>
<dd>
<Tooltip @position="top-start">
{{#if (eq 'critical' item.MeshStatus)}}
At least one health check on one instance is failing.
{{else if (eq 'warning' item.MeshStatus)}}
At least one health check on one instance has a warning.
{{else if (eq 'passing' item.MeshStatus)}}
All health checks are passing.
{{else}}
There are no health checks.
{{/if}}
</Tooltip>
</dd>
<dd {{tooltip item.healthTooltipText}}></dd>
</dl>
{{#if (gt item.InstanceCount 0)}}
<a
data-test-service-name
href={{href-to "dc.services.show.index" item.Name
params=(if (not-eq item.Partition @partition)
(hash
partition=item.Partition
nspace=item.Namespace
peer=item.PeerName
)
(hash
peer=item.PeerName
)
)
}}
>
{{item.Name}}
</a>
{{else}}
<p data-test-service-name>
{{item.Name}}
</p>
{{/if}}
{{#if (gt item.InstanceCount 0)}}
<a
data-test-service-name
href={{href-to
'dc.services.show.index'
item.Name
params=(if
(not-eq item.Partition @partition)
(hash partition=item.Partition nspace=item.Namespace peer=item.PeerName)
(hash peer=item.PeerName)
)
}}
>
{{item.Name}}
</a>
{{else}}
<p data-test-service-name>
{{item.Name}}
</p>
{{/if}}
</BlockSlot>
<BlockSlot @name="details">
<BlockSlot @name='details'>
<Consul::Kind @item={{item}} />
<Consul::ExternalSource @item={{item}} />
{{#if (and (not-eq item.InstanceCount 0) (and (not-eq item.Kind 'terminating-gateway') (not-eq item.Kind 'ingress-gateway'))) }}
<span>
{{format-number item.InstanceCount}} {{pluralize item.InstanceCount 'instance' without-count=true}}
</span>
{{/if}}
<Consul::Bucket::List
@item={{item}}
@nspace={{@nspace}}
@partition={{@partition}}
/>
{{#if (eq item.Kind 'terminating-gateway')}}
<span data-test-associated-service-count>
{{format-number item.GatewayConfig.AssociatedServiceCount}} {{pluralize item.GatewayConfig.AssociatedServiceCount 'linked service' without-count=true}}
</span>
{{else if (eq item.Kind 'ingress-gateway')}}
<span data-test-associated-service-count>
{{format-number item.GatewayConfig.AssociatedServiceCount}} {{pluralize item.GatewayConfig.AssociatedServiceCount 'upstream' without-count=true}}
</span>
{{/if}}
{{#if (or item.ConnectedWithGateway item.ConnectedWithProxy)}}
<dl class="mesh">
<dt>
<Tooltip>
This service uses a proxy for the Consul service mesh
</Tooltip>
</dt>
{{#if (and item.ConnectedWithGateway item.ConnectedWithProxy )}}
<dd data-test-mesh>
in service mesh with proxy and gateway
</dd>
{{else if item.ConnectedWithProxy}}
<dd data-test-mesh>
in service mesh with proxy
</dd>
{{else if item.ConnectedWithGateway}}
<dd data-test-mesh>
in service mesh with gateway
</dd>
{{/if}}
</dl>
{{/if}}
{{#if
(and
(not-eq item.InstanceCount 0)
(and (not-eq item.Kind 'terminating-gateway') (not-eq item.Kind 'ingress-gateway'))
)
}}
<span>
{{format-number item.InstanceCount}}
{{pluralize item.InstanceCount 'instance' without-count=true}}
</span>
{{/if}}
{{! we are displaying imported-services - don't show bucket-list }}
{{#unless @isPeerDetail}}
<Consul::Bucket::List @item={{item}} @nspace={{@nspace}} @partition={{@partition}} />
{{/unless}}
{{#if (eq item.Kind 'terminating-gateway')}}
<span data-test-associated-service-count>
{{format-number item.GatewayConfig.AssociatedServiceCount}}
{{pluralize item.GatewayConfig.AssociatedServiceCount 'linked service' without-count=true}}
</span>
{{else if (eq item.Kind 'ingress-gateway')}}
<span data-test-associated-service-count>
{{format-number item.GatewayConfig.AssociatedServiceCount}}
{{pluralize item.GatewayConfig.AssociatedServiceCount 'upstream' without-count=true}}
</span>
{{/if}}
{{#if (or item.ConnectedWithGateway item.ConnectedWithProxy)}}
<dl class='mesh'>
<dt>
<Tooltip>
This service uses a proxy for the Consul service mesh
</Tooltip>
</dt>
{{#if (and item.ConnectedWithGateway item.ConnectedWithProxy)}}
<dd data-test-mesh>
in service mesh with proxy and gateway
</dd>
{{else if item.ConnectedWithProxy}}
<dd data-test-mesh>
in service mesh with proxy
</dd>
{{else if item.ConnectedWithGateway}}
<dd data-test-mesh>
in service mesh with gateway
</dd>
{{/if}}
</dl>
{{/if}}
<TagList @item={{item}} />
</BlockSlot>
</ListCollection>

View File

@ -1,195 +1,208 @@
<SearchBar
class="consul-service-search-bar"
...attributes
@filter={{@filter}}
>
<:status as |search|>
<SearchBar class='consul-service-search-bar' ...attributes @filter={{@filter}}>
<:status as |search|>
{{#let
(t (concat "components.consul.service.search-bar." search.status.key)
default=(array
(concat "common.search." search.status.key)
(concat "common.consul." search.status.key)
{{#let
(t
(concat 'components.consul.service.search-bar.' search.status.key)
default=(array
(concat 'common.search.' search.status.key) (concat 'common.consul.' search.status.key)
)
)
)
(t (concat "components.consul.service.search-bar." search.status.value)
default=(array
(concat "common.search." search.status.value)
(concat "common.consul." search.status.value)
(concat "common.brand." search.status.value)
(t
(concat 'components.consul.service.search-bar.' search.status.value)
default=(array
(concat 'common.search.' search.status.value)
(concat 'common.consul.' search.status.value)
(concat 'common.brand.' search.status.value)
)
)
)
as |key value|}}
<search.RemoveFilter
aria-label={{t "common.ui.remove" item=(concat key " " value)}}
>
as |key value|
}}
<search.RemoveFilter aria-label={{t 'common.ui.remove' item=(concat key ' ' value)}}>
<dl>
<dt>{{key}}</dt>
<dd>{{value}}</dd>
</dl>
</search.RemoveFilter>
{{/let}}
{{/let}}
</:status>
<:search as |search|>
<search.Search
@onsearch={{action @onsearch}}
@value={{@search}}
@placeholder={{t "common.search.search"}}
</:status>
<:search as |search|>
<search.Search
@onsearch={{action @onsearch}}
@value={{@search}}
@placeholder={{t 'common.search.search'}}
>
<search.Select
class='type-search-properties'
@position='right'
@onchange={{action @filter.searchproperty.change}}
@multiple={{true}}
@required={{true}}
as |components|
>
<search.Select
class="type-search-properties"
@position="right"
@onchange={{action @filter.searchproperty.change}}
@multiple={{true}}
@required={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "common.search.searchproperty"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<BlockSlot @name='selected'>
<span>
{{t 'common.search.searchproperty'}}
</span>
</BlockSlot>
<BlockSlot @name='options'>
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @filter.searchproperty.default as |prop|}}
<Option @value={{prop}} @selected={{includes prop @filter.searchproperty.value}}>
{{t (concat "common.consul." (lowercase prop))}}
{{t (concat 'common.consul.' (lowercase prop))}}
</Option>
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
</search.Search>
</:search>
<:filter as |search|>
<search.Select
class="type-status"
@position="left"
@onchange={{action @filter.status.change}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "common.consul.status"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each (array "passing" "warning" "critical" "empty") as |state|}}
<Option class="value-{{state}}" @value={{state}} @selected={{includes state @filter.status.value}}>
{{t (concat "common.consul." state)
default=(array
(concat "common.search." state)
)
}}
</Option>
{{/each}}
{{/let}}
{{/let}}
</BlockSlot>
</search.Select>
<search.Select
@position="left"
@onchange={{action @filter.kind.change}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "components.consul.service.search-bar.kind"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Option @value="service" @selected={{includes 'service' @filter.kind.value}}>
{{t "common.consul.service"}}
</search.Search>
</:search>
<:filter as |search|>
<search.Select
class='type-status'
@position='left'
@onchange={{action @filter.status.change}}
@multiple={{true}}
as |components|
>
<BlockSlot @name='selected'>
<span>
{{t 'common.consul.status'}}
</span>
</BlockSlot>
<BlockSlot @name='options'>
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each this.healthStates as |state|}}
<Option
class='value-{{state}}'
@value={{state}}
@selected={{includes state @filter.status.value}}
>
{{t (concat 'common.consul.' state) default=(array (concat 'common.search.' state))}}
</Option>
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
<search.Select
@position='left'
@onchange={{action @filter.kind.change}}
@multiple={{true}}
as |components|
>
<BlockSlot @name='selected'>
<span>
{{t 'components.consul.service.search-bar.kind'}}
</span>
</BlockSlot>
<BlockSlot @name='options'>
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Option @value='service' @selected={{includes 'service' @filter.kind.value}}>
{{t 'common.consul.service'}}
</Option>
<Optgroup
@label={{t "common.consul.gateway"}}
>
{{#each (array "ingress-gateway" "terminating-gateway" "mesh-gateway") as |kind|}}
<Option @value={{kind}} @selected={{includes kind @filter.kind.value}}>
{{t (concat "common.consul." kind)}}
</Option>
{{/each}}
<Optgroup @label={{t 'common.consul.gateway'}}>
{{#each (array 'ingress-gateway' 'terminating-gateway' 'mesh-gateway') as |kind|}}
<Option @value={{kind}} @selected={{includes kind @filter.kind.value}}>
{{t (concat 'common.consul.' kind)}}
</Option>
{{/each}}
</Optgroup>
<Optgroup
@label={{t "common.consul.mesh"}}
>
{{#each (array "in-mesh" "not-in-mesh") as |state|}}
<Option @value={{state}} @selected={{includes state @filter.kind.value}}>
{{t (concat "common.search." state)}}
</Option>
{{/each}}
<Optgroup @label={{t 'common.consul.mesh'}}>
{{#each (array 'in-mesh' 'not-in-mesh') as |state|}}
<Option @value={{state}} @selected={{includes state @filter.kind.value}}>
{{t (concat 'common.search.' state)}}
</Option>
{{/each}}
</Optgroup>
{{/let}}
</BlockSlot>
</search.Select>
{{#if (gt @sources.length 0)}}
{{/let}}
</BlockSlot>
</search.Select>
{{#if (gt @sources.length 0)}}
<search.Select
class="type-source"
@position="left"
class='type-source'
@position='left'
@onchange={{action @filter.source.change}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
as |components|
>
<BlockSlot @name='selected'>
<span>
{{t "common.search.source"}}
{{t 'common.search.source'}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Option as |Option|}}
{{#if (gt @sources.length 0)}}
{{#each @sources as |source|}}
<Option class={{source}} @value={{source}} @selected={{includes source @filter.source.value}}>
{{t (concat "common.brand." source)}}
</Option>
{{/each}}
<Option class="consul" @value='consul' @selected={{includes 'consul' @filter.source.value}}>
{{t 'common.brand.consul'}}
</Option>
{{/if}}
{{/let}}
<BlockSlot @name='options'>
{{#let components.Option as |Option|}}
{{#if (gt @sources.length 0)}}
{{#each @sources as |source|}}
<Option
class={{source}}
@value={{source}}
@selected={{includes source @filter.source.value}}
>
{{t (concat 'common.brand.' source)}}
</Option>
{{/each}}
<Option
class='consul'
@value='consul'
@selected={{includes 'consul' @filter.source.value}}
>
{{t 'common.brand.consul'}}
</Option>
{{/if}}
{{/let}}
</BlockSlot>
</search.Select>
{{/if}}
</:filter>
<:sort as |search|>
<search.Select
class="type-sort"
data-test-sort-control
@position="right"
@onchange={{action @sort.change}}
@multiple={{false}}
@required={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{#let (from-entries (array
(array "Name:asc" (t "common.sort.alpha.asc"))
(array "Name:desc" (t "common.sort.alpha.desc"))
(array "Status:asc" (t "common.sort.status.asc"))
(array "Status:desc" (t "common.sort.status.desc"))
))
as |selectable|
}}
{{get selectable @sort.value}}
{{/let}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t "common.consul.status"}}>
<Option @value="Status:asc" @selected={{eq "Status:asc" @sort.value}}>{{t "common.sort.status.asc"}}</Option>
<Option @value="Status:desc" @selected={{eq "Status:desc" @sort.value}}>{{t "common.sort.status.desc"}}</Option>
{{/if}}
</:filter>
<:sort as |search|>
<search.Select
class='type-sort'
data-test-sort-control
@position='right'
@onchange={{action @sort.change}}
@multiple={{false}}
@required={{true}}
as |components|
>
<BlockSlot @name='selected'>
<span>
{{#let
(from-entries
(array
(array 'Name:asc' (t 'common.sort.alpha.asc'))
(array 'Name:desc' (t 'common.sort.alpha.desc'))
(array 'Status:asc' (t 'common.sort.status.asc'))
(array 'Status:desc' (t 'common.sort.status.desc'))
)
)
as |selectable|
}}
{{get selectable @sort.value}}
{{/let}}
</span>
</BlockSlot>
<BlockSlot @name='options'>
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t 'common.consul.status'}}>
<Option @value='Status:asc' @selected={{eq 'Status:asc' @sort.value}}>{{t
'common.sort.status.asc'
}}</Option>
<Option @value='Status:desc' @selected={{eq 'Status:desc' @sort.value}}>{{t
'common.sort.status.desc'
}}</Option>
</Optgroup>
<Optgroup @label={{t "common.consul.service-name"}}>
<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>
<Optgroup @label={{t 'common.consul.service-name'}}>
<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>
</Optgroup>
{{/let}}
</BlockSlot>
</search.Select>
</:sort>
</SearchBar>
{{/let}}
</BlockSlot>
</search.Select>
</:sort>
</SearchBar>

View File

@ -0,0 +1,11 @@
import Component from '@glimmer/component';
export default class ConsulServiceSearchBar extends Component {
get healthStates() {
if (this.args.peer) {
return ['passing', 'warning', 'critical', 'unknown', 'empty'];
} else {
return ['passing', 'warning', 'critical', 'empty'];
}
}
}

View File

@ -25,6 +25,11 @@
@extend %with-minus-square-fill-mask, %as-pseudo;
color: rgb(var(--tone-gray-500));
}
%icon-definition.unknown dt::before,
%composite-row-header .unknown dd::before {
@extend %with-help-circle-outline-mask, %as-pseudo;
color: rgb(var(--tone-gray-500));
}
%composite-row-header [rel='me'] dd::before {
@extend %with-check-circle-fill-mask, %as-pseudo;

View File

@ -79,10 +79,14 @@ export default class Outlet extends Component {
this.previousState = this.state;
this.state = new State('loading');
this.endTransition = this.routlet.transition();
// if we have no transition-duration set immediately end the transition
const duration = window
.getComputedStyle(this.element)
.getPropertyValue('transition-duration');
let duration;
if (this.element) {
// if we have no transition-duration set immediately end the transition
duration = window.getComputedStyle(this.element).getPropertyValue('transition-duration');
} else {
duration = 0;
}
if (parseFloat(duration) === 0) {
this.endTransition();
}

View File

@ -0,0 +1 @@
{{yield (hash data=this.data)}}

View File

@ -0,0 +1,41 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { Tab } from 'consul-ui/components/tab-nav';
export default class PeeringsProvider extends Component {
@service router;
@service intl;
get data() {
return {
tabs: this.tabs,
};
}
get tabs() {
const { peer } = this.args;
const { router } = this;
const owner = getOwner(this);
const { isReceiver, Name: name } = peer;
let tabs = [
{
label: 'Imported Services',
route: 'dc.peers.show.imported',
tooltip: this.intl.t('routes.dc.peers.index.detail.imported.tab-tooltip', { name }),
},
{
label: 'Exported Services',
route: 'dc.peers.show.exported',
tooltip: this.intl.t('routes.dc.peers.index.detail.exported.tab-tooltip', { name }),
},
];
if (isReceiver) {
tabs = [...tabs, { label: 'Addresses', route: 'dc.peers.show.addresses' }];
}
return tabs.map((tab) => new Tab({ ...tab, currentRouteName: router.currentRouteName, owner }));
}
}

View File

@ -42,6 +42,10 @@
@extend %with-minus-square-fill-mask, %as-pseudo;
color: rgb(var(--tone-gray-400));
}
%popover-select .value-unknown button::before {
@extend %with-help-circle-outline-mask, %as-pseudo;
color: rgb(var(--tone-gray-500));
}
%popover-select.type-source li:not(.partition) button {
text-transform: capitalize;
}

View File

@ -0,0 +1,43 @@
# Providers::Dimension
A provider component that helps you to make a container fill the remaining space of the viewport.
Usually, you would **use flexbox** to do so but for scenarios where this isn't possible you
can use this component to make it easy to take up the remaining space.
```hbs
<Providers::Dimension as |p|>
<div style={{p.data.fillRemainingHeightStyle}}>
Fill remaining space
</div>
</Providers::Dimension>
```
By default, this component will take up the remaining viewport space by taking the
top of itself as the top-boundary and the `footer[role="contentinfo"]` as the
bottom-boundary. In terms of Consul-UI this means _take up the entire space but
stop at the footer that holds the consul version info at the bottom of the screen_.
You can pass a different `bottomBoundary` if need be:
```hbs preview-template
<div class='h-48 relative flex border border-hds-consul-foreground'>
<div class='h-full w-24 relative'>
<div
class='absolute bottom-0 w-full h-12 bg-hds-consul-gradient-primary-start flex items-center justify-center'
id='bottom-boundary'
>
Boundary
</div>
</div>
<div class='flex-1'>
<Providers::Dimension @bottomBoundary='#bottom-boundary' as |p|>
<div
style={{p.data.fillRemainingHeightStyle}}
class='bg-hds-consul-surface flex items-center justify-center'
>
We could use flexbox here instead
</div>
</Providers::Dimension>
</div>
</div>
```

View File

@ -0,0 +1,4 @@
<div {{create-ref 'element'}} {{did-insert this.measureDimensions}}>
{{on-window 'resize' this.handleWindowResize}}
{{yield (hash data=this.data)}}
</div>

View File

@ -0,0 +1,44 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { ref } from 'ember-ref-bucket';
import { htmlSafe } from '@ember/template';
export default class DimensionsProvider extends Component {
@ref('element') element;
@tracked height;
get data() {
const { height, fillRemainingHeightStyle } = this;
return {
height,
fillRemainingHeightStyle,
};
}
get fillRemainingHeightStyle() {
return htmlSafe(`height: ${this.height}px;`);
}
get bottomBoundary() {
return document.querySelector(this.args.bottomBoundary) || this.footer;
}
get footer() {
return document.querySelector('footer[role="contentinfo"]');
}
@action measureDimensions(element) {
const bb = this.bottomBoundary.getBoundingClientRect();
const e = element.getBoundingClientRect();
this.height = bb.top + bb.height - e.top;
}
@action handleWindowResize() {
const { element } = this;
this.measureDimensions(element);
}
}

View File

@ -0,0 +1 @@
{{yield (hash data=this.data)}}

View File

@ -0,0 +1,36 @@
import Component from '@glimmer/component';
export default class SearchProvider extends Component {
// custom base route / router abstraction is doing weird things
get _search() {
return this.args.search || '';
}
get items() {
const { items, searchProperties } = this.args;
const { _search: search } = this;
if (search.length > 0) {
return items.filter((item) => {
const matchesInSearchProperties = searchProperties.reduce((acc, searchProperty) => {
const match = item[searchProperty].indexOf(search) !== -1;
if (match) {
return [...acc, match];
} else {
return acc;
}
}, []);
return matchesInSearchProperties.length > 0;
});
} else {
return items;
}
}
get data() {
const { items } = this;
return {
items,
};
}
}

View File

@ -1,47 +1,50 @@
{{#let
(dom-position (set this 'style') offset=true)
"tab"
as |select name|}}
<nav
style={{{if this.style (concat
'--selected-width:' this.style.width ';'
'--selected-left:' this.style.left ';'
'--selected-height:' this.style.height ';'
'--selected-top:' this.style.top
)
undefined
}}}
aria-label="Secondary"
class={{concat 'tab-nav' ' animatable'}}
...attributes
>
<ul>
{{#each @items as |item|}}
<li
{{on 'click' (fn select)}}
{{did-upsert
(if item.selected
(fn select)
(noop)
{{#let (dom-position (set this 'style') offset=true) 'tab' as |select name|}}
<nav
style={{{if
this.style
(concat
'--selected-width:'
this.style.width
';'
'--selected-left:'
this.style.left
';'
'--selected-height:'
this.style.height
';'
'--selected-top:'
this.style.top
)
@items.length
}}
data-test-tab={{concat name '_' (if item.label (slugify item.label) (slugify item))}}
class={{if (or item.selected (eq selected (if item.label (slugify item.label) (slugify item)))) 'selected'}}
undefined
}}}
aria-label='Secondary'
class={{concat 'tab-nav' ' animatable'}}
...attributes
>
<Action
{{on 'click'
(fn this.onClick (uppercase item.label))
}}
{{on 'click'
(fn this.onTabClicked item)
}}
@href={{item.href}}
>
{{item.label}}
</Action>
</li>
{{/each}}
</ul>
</nav>
<ul>
{{#each @items as |item|}}
<li
{{on 'click' (fn select)}}
{{did-upsert (if item.selected (fn select) (noop)) @items.length}}
data-test-tab={{concat name '_' (if item.label (slugify item.label) (slugify item))}}
class={{if
(or item.selected (eq selected (if item.label (slugify item.label) (slugify item))))
'selected'
}}
>
<Action
{{on 'click' (fn this.onClick (uppercase item.label))}}
{{on 'click' (fn this.onTabClicked item)}}
@href={{item.href}}
>
{{#if item.tooltip}}
<span {{tooltip item.tooltip}}>{{item.label}}</span>
{{else}}
{{item.label}}
{{/if}}
</Action>
</li>
{{/each}}
</ul>
</nav>
{{/let}}

View File

@ -1,4 +1,76 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { hrefTo } from 'consul-ui/helpers/href-to';
/**
* A class that encapsulates the data abstraction that we expect the TabNav to
* be passed as `@items`.
*
* You can use this class when you want to create tab-nav from javascript.
*
* Instead of doing this in the template layer:
*
* ```handlebars
* <TabNav @items={{array
* (hash
* label="First Tab"
* href=(href-to "some.route")
* selected=(is-href "some.route")
* )
* (hash
* label="Second Tab"
* href=(href-to "some.route")
* selected=(is-href "some.route")
* )
* }}
* ```
*
* You can do the following in a js-file:
*
* ```javascript
* export default class WootComponent extends Component {
* // ...
* get tabs() {
* const { router } = this;
* const owner = getOwner(this);
* return [
* new Tab({
* label: 'First Tab',
* route: 'some.route',
* currentRouteName: router.currentRouteName,
* owner
* }),
* // ...
* ];
* }
* }
* ```
*
*/
export class Tab {
@tracked route;
@tracked label;
@tracked tooltip;
@tracked currentRouteName;
constructor(opts) {
const { currentRouteName, route, label, tooltip, owner } = opts;
this.currentRouteName = currentRouteName;
this.owner = owner;
this.route = route;
this.label = label;
this.tooltip = tooltip;
}
get selected() {
return this.currentRouteName === this.route;
}
get href() {
return hrefTo(this.owner, [this.route]);
}
}
function noop() {}
export default class TabNav extends Component {

View File

@ -14,6 +14,7 @@ export default {
warning: (item, value) => item.MeshStatus === value,
critical: (item, value) => item.MeshStatus === value,
empty: (item, value) => item.MeshChecksTotal === 0,
unknown: (item) => item.peerIsFailing || item.isZeroCountButPeered,
},
instance: {
registered: (item, value) => item.InstanceCount > 0,

View File

@ -0,0 +1,38 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24;
const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7;
/**
* A function that returns if a date is within a week of the current time
* @param {*} date - the date to check
*
*/
function isNearDate(date) {
const now = new Date();
const aWeekAgo = +now - MILLISECONDS_IN_WEEK;
const aWeekInFuture = +now + MILLISECONDS_IN_WEEK;
return date >= aWeekAgo && date <= aWeekInFuture;
}
export default class SmartDateFormat extends Helper {
@service temporal;
@service intl;
compute([value], hash) {
return {
isNearDate: isNearDate(value),
relative: `${this.temporal.format(value)} ago`,
friendly: this.intl.formatTime(value, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: 'h24',
}),
};
}
}

View File

@ -188,10 +188,14 @@ export default class FSMWithOptionalLocation {
/**
* Turns a routeName into a full URL string for anchor hrefs etc.
*/
hrefTo(routeName, params, hash) {
hrefTo(routeName, params, _hash) {
// copy to always work with a new hash even when helper persists hash
const hash = { ..._hash };
if (typeof hash.dc !== 'undefined') {
delete hash.dc;
}
if (typeof hash.nspace !== 'undefined') {
hash.nspace = `~${hash.nspace}`;
}

View File

@ -17,17 +17,34 @@ export default class Peer extends Model {
@attr('string') Name;
@attr('string') State;
@attr('string') ID;
// only the side that establishes will hold this property
@attr('string') PeerID;
@attr() PeerServerAddresses;
// StreamStatus
@nullValue([]) @attr() ImportedServices;
@nullValue([]) @attr() ExportedServices;
@attr('date') LastHeartbeat;
@attr('date') LastReceive;
@attr('date') LastSend;
@attr() PeerServerAddresses;
get ImportedServiceCount() {
return this.ImportedServices.length;
}
get ExportedServiceCount() {
return this.ExportedServices.length;
}
// if we receive a PeerID we know that we are dealing with the side that
// established the peering
get isReceiver() {
return this.PeerID;
}
get isDialer() {
return !this.isReceiver;
}
}

View File

@ -1,4 +1,4 @@
import Model, { attr } from '@ember-data/model';
import Model, { attr, belongsTo } from '@ember-data/model';
import { computed } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { fragment } from 'ember-data-model-fragments/attributes';
@ -58,6 +58,18 @@ export default class Service extends Model {
@attr() meta; // {}
@belongsTo({ async: false }) peer;
@computed('peer', 'InstanceCount')
get isZeroCountButPeered() {
return this.peer && this.InstanceCount === 0;
}
@computed('peer.State')
get peerIsFailing() {
return this.peer && this.peer.State === 'FAILING';
}
@computed('ChecksPassing', 'ChecksWarning', 'ChecksCritical')
get ChecksTotal() {
return this.ChecksPassing + this.ChecksWarning + this.ChecksCritical;
@ -79,9 +91,19 @@ export default class Service extends Model {
return this.MeshEnabled || (this.Kind || '').length > 0;
}
@computed('MeshChecksPassing', 'MeshChecksWarning', 'MeshChecksCritical')
@computed(
'MeshChecksPassing',
'MeshChecksWarning',
'MeshChecksCritical',
'isZeroCountButPeered',
'peerIsFailing'
)
get MeshStatus() {
switch (true) {
case this.isZeroCountButPeered:
return 'unknown';
case this.peerIsFailing:
return 'unknown';
case this.MeshChecksCritical !== 0:
return 'critical';
case this.MeshChecksWarning !== 0:
@ -93,6 +115,27 @@ export default class Service extends Model {
}
}
@computed('isZeroCountButPeered', 'peerIsFailing', 'MeshStatus')
get healthTooltipText() {
const { MeshStatus, isZeroCountButPeered, peerIsFailing } = this;
if (isZeroCountButPeered) {
return 'This service currently has 0 instances. Check with the operator of its peer to make sure this is expected behavior.';
}
if (peerIsFailing) {
return 'This peer is out of sync, so the current health statuses of its services are unknown.';
}
if (MeshStatus === 'critical') {
return 'At least one health check on one instance is failing.';
}
if (MeshStatus === 'warning') {
return 'At least one health check on one instance has a warning.';
}
if (MeshStatus == 'passing') {
return 'All health checks are passing.';
}
return 'There are no health checks';
}
@computed('ChecksPassing', 'Proxy.ChecksPassing')
get MeshChecksPassing() {
let proxyCount = 0;

View File

@ -1,6 +1,10 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/service';
import { get } from '@ember/object';
import {
HEADERS_NAMESPACE as HTTP_HEADERS_NAMESPACE,
HEADERS_PARTITION as HTTP_HEADERS_PARTITION,
} from 'consul-ui/utils/http/consul';
export default class ServiceSerializer extends Serializer {
primaryKey = PRIMARY_KEY;
@ -13,31 +17,7 @@ export default class ServiceSerializer extends Serializer {
// Services and proxies all come together in the same list. Here we
// map the proxies to their related services on a Service.Proxy
// property for easy access later on
const services = {};
body
.filter(function (item) {
return item.Kind !== 'connect-proxy';
})
.forEach((item) => {
services[item.Name] = item;
});
body
.filter(function (item) {
return item.Kind === 'connect-proxy';
})
.forEach((item) => {
// Iterating to cover the usecase of a proxy being used by more
// than one service
if (item.ProxyFor) {
item.ProxyFor.forEach((service) => {
if (typeof services[service] !== 'undefined') {
services[service].Proxy = item;
}
});
}
});
return cb(headers, body);
return cb(headers, this._transformServicesPayload(body));
}),
query
);
@ -58,4 +38,56 @@ export default class ServiceSerializer extends Serializer {
query
);
}
createJSONApiDocumentFromServicesPayload(headers, responseBody, dc) {
const { primaryKey, slugKey, fingerprint } = this;
const transformedBody = this._transformServicesPayload(responseBody);
const attributes = transformedBody.map(
fingerprint(
primaryKey,
slugKey,
dc,
headers[HTTP_HEADERS_NAMESPACE],
headers[HTTP_HEADERS_PARTITION]
)
);
return {
data: attributes.map((attr) => {
return {
id: attr.uid,
type: 'service',
attributes: attr,
};
}),
};
}
_transformServicesPayload(body) {
const services = {};
body
.filter(function (item) {
return item.Kind !== 'connect-proxy';
})
.forEach((item) => {
services[item.Name] = item;
});
body
.filter(function (item) {
return item.Kind === 'connect-proxy';
})
.forEach((item) => {
// Iterating to cover the usecase of a proxy being used by more
// than one service
if (item.ProxyFor) {
item.ProxyFor.forEach((service) => {
if (typeof services[service] !== 'undefined') {
services[service].Proxy = item;
}
});
}
});
return body;
}
}

View File

@ -1,5 +1,6 @@
import RepositoryService from 'consul-ui/services/repository';
import dataSource from 'consul-ui/decorators/data-source';
import { inject as service } from '@ember/service';
function normalizePeerPayload(peerPayload, dc, partition) {
const {
@ -18,10 +19,31 @@ function normalizePeerPayload(peerPayload, dc, partition) {
};
}
export default class PeerService extends RepositoryService {
@service store;
getModelName() {
return 'peer';
}
@dataSource('/:partition/:ns/:ds/exported-services/:name')
async fetchExportedServices({ dc, ns, partition, name }, configuration, request) {
return (
await request`
GET /v1/internal/ui/exported-services
${{
peer: name,
}}
`
)((headers, body, cache) => {
const serviceSerializer = this.store.serializerFor('service');
return this.store.push(
serviceSerializer.createJSONApiDocumentFromServicesPayload(headers, body, dc)
);
});
}
@dataSource('/:partition/:ns/:dc/peering/token-for/:name')
async fetchToken({ dc, ns, partition, name }, configuration, request) {
return (
@ -85,6 +107,22 @@ export default class PeerService extends RepositoryService {
}}
`
)((headers, body, cache) => {
// we can't easily use fragments as we are working around the serializer
// layer
const { StreamStatus } = body;
if (StreamStatus) {
if (StreamStatus.LastHeartbeat) {
StreamStatus.LastHeartbeat = new Date(StreamStatus.LastHeartbeat);
}
if (StreamStatus.LastReceive) {
StreamStatus.LastReceive = new Date(StreamStatus.LastReceive);
}
if (StreamStatus.LastSend) {
StreamStatus.LastSend = new Date(StreamStatus.LastSend);
}
}
return {
meta: {
version: 2,

View File

@ -1,8 +1,11 @@
import RepositoryService from 'consul-ui/services/repository';
import dataSource from 'consul-ui/decorators/data-source';
import { inject as service } from '@ember/service';
const modelName = 'service';
export default class ServiceService extends RepositoryService {
@service store;
getModelName() {
return modelName;
}
@ -12,6 +15,23 @@ export default class ServiceService extends RepositoryService {
return super.findAll(...arguments);
}
@dataSource('/:partition/:ns/:dc/services/:peer/:peerId')
async findAllImportedServices(params, configuration) {
// remember peer.id so that we can add it to to the service later on to setup relationship
const { peerId } = params;
// don't send peerId with query
delete params.peerId;
// assign the peer as a belongs-to. we don't have access to any information
// we could use to do this in the serializer so we need to do it manually here
return super.findAll(params, configuration).then((services) => {
const peer = this.store.peekRecord('peer', peerId);
services.forEach((service) => (service.peer = peer));
return services;
});
}
@dataSource('/:partition/:ns/:dc/gateways/for-service/:gateway')
findGatewayBySlug(params, configuration = {}) {
if (typeof configuration.cursor !== 'undefined') {

View File

@ -119,7 +119,8 @@ html:not(.with-data-source) .data-source-debug {
/* hi */
z-index: 100000;
}
html.with-href-to [href^='console://']::before {
html.with-href-to [href^='console://']::before
{
@extend %p3;
@extend %debug-box;
content: attr(href);
@ -155,6 +156,7 @@ html.with-route-announcer .route-title {
margin-bottom: 100px;
padding-top: 0 !important;
}
li.provider-components a::before,
li.consul-components a::before,
li.components a::before {
@extend %with-logo-glimmer-color-icon, %as-pseudo;
@ -164,6 +166,7 @@ html.with-route-announcer .route-title {
li.components.css-component a::before {
@extend %with-logo-glimmer-color-icon, %as-pseudo;
}
li.provider-components.ember-component a::before,
li.consul-components.ember-component a::before,
li.components.ember-component a::before {
@extend %with-logo-ember-circle-color-icon, %as-pseudo;

View File

@ -1,36 +1,32 @@
<Route
@name={{routeName}}
as |route|>
<Route @name={{routeName}} as |route|>
<DataLoader
@src={{uri '/${partition}/${nspace}/${dc}/service-instance/${id}/${node}/${name}/${peer}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=route.params.id
node=route.params.node
name=route.params.name
peer=route.params.peer
)
}}
as |loader|>
@src={{uri
'/${partition}/${nspace}/${dc}/service-instance/${id}/${node}/${name}/${peer}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=route.params.id
node=route.params.node
name=route.params.name
peer=route.params.peer
)
}}
as |loader|
>
<BlockSlot @name="error">
<AppError
@error={{loader.error}}
@login={{route.model.app.login.open}}
/>
<BlockSlot @name='error'>
<AppError @error={{loader.error}} @login={{route.model.app.login.open}} />
</BlockSlot>
<BlockSlot @name="disconnected" as |after|>
{{#if (eq loader.error.status "404")}}
<BlockSlot @name='disconnected' as |after|>
{{#if (eq loader.error.status '404')}}
<Notice
{{notification
sticky=true
}}
class="notification-update"
@type="warning"
as |notice|>
{{notification sticky=true}}
class='notification-update'
@type='warning'
as |notice|
>
<notice.Header>
<strong>Warning!</strong>
</notice.Header>
@ -40,14 +36,8 @@ as |route|>
</p>
</notice.Body>
</Notice>
{{else if (eq loader.error.status "403")}}
<Notice
{{notification
sticky=true
}}
class="notification-update"
@type="error"
as |notice|>
{{else if (eq loader.error.status '403')}}
<Notice {{notification sticky=true}} class='notification-update' @type='error' as |notice|>
<notice.Header>
<strong>Error!</strong>
</notice.Header>
@ -58,13 +48,7 @@ as |route|>
</notice.Body>
</Notice>
{{else}}
<Notice
{{notification
sticky=true
}}
class="notification-update"
@type="error"
as |notice|>
<Notice {{notification sticky=true}} class='notification-update' @type='error' as |notice|>
<notice.Header>
<strong>Warning!</strong>
</notice.Header>
@ -77,64 +61,67 @@ as |route|>
{{/if}}
</BlockSlot>
<BlockSlot @name="loaded">
{{#let
loader.data
as |item|}}
{{#if item.IsOrigin}}
<DataSource
@src={{uri '/${partition}/${nspace}/${dc}/proxy-instance/${id}/${node}/${name}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=route.params.id
node=route.params.node
name=route.params.name
)
}}
@onchange={{action (mut meta) value="data"}}
as |meta|>
{{! We only really need meta to get the correct ServiceID }}
{{! but we may as well use the NodeName and ServiceName }}
{{! from meta also, but they should be the same as the instance }}
{{! so if we can ever get ServiceID from elsewhere we could save }}
{{! a HTTP request/long poll here }}
{{#if meta.data.ServiceID}}
{{! if we have a proxy then get the additional instance information }}
{{! for the proxy itself so if the service is called `backend` }}
{{! its likely to have a proxy service called `backend-sidecar-proxy` }}
{{! and this second request get the info for that instance and saves }}
{{! it into the `proxy` variable }}
<DataSource
@src={{uri '/${partition}/${nspace}/${dc}/service-instance/${id}/${node}/${name}/${peer}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=meta.data.ServiceID
node=meta.data.NodeName
name=meta.data.ServiceName
peer=route.params.peer
)
}}
@onchange={{action (mut proxy) value="data"}}
/>
{{/if}}
</DataSource>
{{/if}}
<AppView>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a href={{href-to 'dc.services' params=(hash peer=undefined)}}>All Services</a></li>
<li><a {{tooltip (concat "Service (" item.Service.Service ")")}} data-test-back href={{href-to 'dc.services.show'}}>
Service ({{item.Service.Service}})
</a></li>
</ol>
<BlockSlot @name='loaded'>
{{#let loader.data as |item|}}
{{#if item.IsOrigin}}
<DataSource
@src={{uri
'/${partition}/${nspace}/${dc}/proxy-instance/${id}/${node}/${name}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=route.params.id
node=route.params.node
name=route.params.name
)
}}
@onchange={{action (mut meta) value='data'}}
as |meta|
>
{{! We only really need meta to get the correct ServiceID }}
{{! but we may as well use the NodeName and ServiceName }}
{{! from meta also, but they should be the same as the instance }}
{{! so if we can ever get ServiceID from elsewhere we could save }}
{{! a HTTP request/long poll here }}
{{#if meta.data.ServiceID}}
{{! if we have a proxy then get the additional instance information }}
{{! for the proxy itself so if the service is called `backend` }}
{{! its likely to have a proxy service called `backend-sidecar-proxy` }}
{{! and this second request get the info for that instance and saves }}
{{! it into the `proxy` variable }}
<DataSource
@src={{uri
'/${partition}/${nspace}/${dc}/service-instance/${id}/${node}/${name}/${peer}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=meta.data.ServiceID
node=meta.data.NodeName
name=meta.data.ServiceName
peer=route.params.peer
)
}}
@onchange={{action (mut proxy) value='data'}}
/>
{{/if}}
</DataSource>
{{/if}}
<AppView>
<BlockSlot @name='breadcrumbs'>
<ol>
<li><a href={{href-to 'dc.services' params=(hash peer=undefined)}}>All Services</a></li>
<li><a
{{tooltip (concat 'Service (' item.Service.Service ')')}}
data-test-back
href={{href-to 'dc.services.show'}}
>
Service ({{item.Service.Service}})
</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<BlockSlot @name='header'>
<h1>
<route.Title @title={{item.Service.ID}} />
</h1>
@ -146,54 +133,92 @@ as |item|}}
<Consul::TransparentProxy />
{{/if}}
</BlockSlot>
<BlockSlot @name="nav">
<BlockSlot @name='nav'>
<dl>
<dt>Service Name</dt>
<dd><a href="{{href-to 'dc.services.show' item.Service.Service}}">{{item.Service.Service}}</a></dd>
<dd><a
href='{{href-to "dc.services.show" item.Service.Service}}'
>{{item.Service.Service}}</a></dd>
</dl>
{{#unless item.Node.Meta.synthetic-node}}
<dl>
<dt>Node Name</dt>
<dd><a data-test-service-instance-node-name href="{{href-to 'dc.nodes.show' item.Node.Node}}">{{item.Node.Node}}</a></dd>
<dd><a
data-test-service-instance-node-name
href='{{href-to "dc.nodes.show" item.Node.Node}}'
>{{item.Node.Node}}</a></dd>
</dl>
{{/unless}}
{{#if item.Service.PeerName}}
<dl>
<dt>Peer Name</dt>
<dd>{{item.Service.PeerName}}</dd>
<dd><a
data-test-service-instance-peer-name
href={{href-to
'dc.peers.show'
item.Service.PeerName
params=(hash peer=undefined)
}}
>{{item.Service.PeerName}}</a></dd>
</dl>
{{/if}}
</BlockSlot>
<BlockSlot @name="actions">
<BlockSlot @name='actions'>
{{#let (or item.Service.Address item.Node.Address) as |address|}}
<CopyButton @value={{address}} @name="Address">{{address}}</CopyButton>
<CopyButton @value={{address}} @name='Address'>{{address}}</CopyButton>
{{/let}}
</BlockSlot>
<BlockSlot @name="content">
<TabNav @items={{
compact
(array
(hash label="Health Checks" href=(href-to "dc.services.instance.healthchecks") selected=(is-href "dc.services.instance.healthchecks"))
(if (eq item.Service.Kind 'mesh-gateway') (hash label="Addresses" href=(href-to "dc.services.instance.addresses") selected=(is-href "dc.services.instance.addresses")))
(if proxy (hash label="Upstreams" href=(href-to "dc.services.instance.upstreams") selected=(is-href "dc.services.instance.upstreams")))
(if proxy (hash label="Exposed Paths" href=(href-to "dc.services.instance.exposedpaths") selected=(is-href "dc.services.instance.exposedpaths")))
(hash label="Tags & Meta" href=(href-to "dc.services.instance.metadata") selected=(is-href "dc.services.instance.metadata"))
<BlockSlot @name='content'>
<TabNav
@items={{compact
(array
(hash
label='Health Checks'
href=(href-to 'dc.services.instance.healthchecks')
selected=(is-href 'dc.services.instance.healthchecks')
)
(if
(eq item.Service.Kind 'mesh-gateway')
(hash
label='Addresses'
href=(href-to 'dc.services.instance.addresses')
selected=(is-href 'dc.services.instance.addresses')
)
)
(if
proxy
(hash
label='Upstreams'
href=(href-to 'dc.services.instance.upstreams')
selected=(is-href 'dc.services.instance.upstreams')
)
)
(if
proxy
(hash
label='Exposed Paths'
href=(href-to 'dc.services.instance.exposedpaths')
selected=(is-href 'dc.services.instance.exposedpaths')
)
)
(hash
label='Tags & Meta'
href=(href-to 'dc.services.instance.metadata')
selected=(is-href 'dc.services.instance.metadata')
)
)
}}
/>
<Outlet
@name={{routeName}}
@model={{assign (hash
proxy=proxy
meta=meta
item=item
) route.model}}
as |o|>
@model={{assign (hash proxy=proxy meta=meta item=item) route.model}}
as |o|
>
{{outlet}}
</Outlet>
</BlockSlot>
</AppView>
{{/let}}
</AppView>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>
</Route>

View File

@ -0,0 +1,31 @@
${[0].map(
() => {
let prevKind;
let name;
const gateways = ['mesh-gateway', 'ingress-gateway', 'terminating-gateway'];
return `
[
${
range(
env(
'CONSUL_SERVICE_COUNT',
Math.floor(
(
Math.random() * env('CONSUL_SERVICE_MAX', 10)
) + parseInt(env('CONSUL_SERVICE_MIN', 1))
)
)
).map(
function(item, i)
{
return `
{
"Name": "service-${i}"
}
`;
}
)
}
]
`})}

View File

@ -1,7 +1,5 @@
{
"ID": "${fake.random.uuid()}",
"ImportedServiceCount": ${fake.random.number({min: 0, max: 4000})},
"ExportedServiceCount": ${fake.random.number({min: 0, max: 4000})},
"Name": "${location.pathname.get(2)}",
"State": "${fake.helpers.randomize([
'ACTIVE',
@ -12,6 +10,13 @@
'TERMINATED',
'UNDEFINED'
])}",
"StreamStatus": {
"LastHeartbeat": "${fake.date.past(10).toISOString()}",
"LastReceive": "${fake.date.past(10).toISOString()}",
"LastSend": "${fake.date.past(10).toISOString()}",
"ExportedServices": [${range(0, Math.floor(Math.random() * 10)).map((i) => `"exported-service-${i}"`)}],
"ImportedServices": [${range(0, Math.floor(Math.random() * 10)).map((i) => `"imported-service-${i}"`)}]
},
"PeerID": "${fake.random.uuid()}",
"PeerServerName": "${fake.internet.domainName()}",
"PeerServerAddresses": [

View File

@ -22,8 +22,8 @@
"LastHeartbeat": "${fake.date.past(10).toISOString()}",
"LastReceive": "${fake.date.past(10).toISOString()}",
"LastSend": "${fake.date.past(10).toISOString()}",
"ExportedServices": ["${`export-service-${i}`}"],
"ImportedServices": ["${`import-service-${i}`}"]
"ExportedServices": [${range(0, Math.floor(Math.random() * 10)).map((i) => `"exported-service-${i}"`)}],
"ImportedServices": [${range(0, Math.floor(Math.random() * 10)).map((i) => `"imported-service-${i}"`)}]
},
"PeerID": "${id}",
"PeerServerName": "${fake.internet.domainName()}",

View File

@ -71,6 +71,7 @@
"@hashicorp/design-system-tokens": "^1.0.0",
"@hashicorp/ember-cli-api-double": "^4.0.0",
"@hashicorp/ember-flight-icons": "^2.0.12",
"@html-next/vertical-collection": "^4.0.0",
"@lit/reactive-element": "^1.2.1",
"@xstate/fsm": "^1.4.0",
"a11y-dialog": "^6.0.1",
@ -146,7 +147,7 @@
"ember-power-select": "^4.0.5",
"ember-power-select-with-create": "^0.8.0",
"ember-qunit": "^5.1.1",
"ember-ref-modifier": "^1.0.0",
"ember-ref-bucket": "^4.1.0",
"ember-render-helpers": "^0.2.0",
"ember-resolver": "^8.0.2",
"ember-route-action-helper": "^2.0.8",

View File

@ -34,7 +34,7 @@ function colorMapFromTokens(tokensPath) {
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{html,js,hbs,mdx}', './docs/**/*.{html,js,hbs,mdx}'],
content: ['../**/*.{html.js,hbs,mdx}'],
theme: {
colors: colorMapFromTokens(
'../../node_modules/@hashicorp/design-system-tokens/dist/products/css/tokens.css'

View File

@ -6,6 +6,8 @@ Feature: dc / peers / regenerate: Regenerate Peer Token
---
Name: peer-name
State: ACTIVE
# dialer does not have a PeerID
PeerID: null
---
And the url "/v1/peering/token" responds with from yaml
---
@ -19,3 +21,19 @@ Feature: dc / peers / regenerate: Regenerate Peer Token
And I click actions on the peers
And I click regenerate on the peers
Then I see the text "an-encoded-token" in ".consul-peer-form-generate code"
Scenario:
Given 1 datacenter model with the value "datacenter"
And 1 peer model from yaml
---
Name: peer-name
State: ACTIVE
# receiver holds a PeerID
PeerID: some-id
---
When I visit the peers page for yaml
---
dc: datacenter
---
And I click actions on the peers
Then I don't see regenerate on peers

View File

@ -0,0 +1,136 @@
@setupApplcationTest
Feature: dc / peers / show: Peers show
Scenario: Dialer side tabs
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
# dialer side
PeerID: null
---
When I visit the peers page for yaml
---
dc: dc-1
---
And I click actions on the peers
And I click view on the peers
Then the url should be /dc-1/peers/a-peer/imported-services
Then I see importedServicesIsVisible on the tabs
And I see exportedServicesIsVisible on the tabs
And I don't see addressesIsVisible on the tabs
Scenario: Receiver side tabs
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
# receiver side
PeerID: 'some-peer'
---
When I visit the peers page for yaml
---
dc: dc-1
---
And I click actions on the peers
And I click view on the peers
Then the url should be /dc-1/peers/a-peer/imported-services
Then I see importedServicesIsVisible on the tabs
And I see exportedServicesIsVisible on the tabs
And I see addressesIsVisible on the tabs
Scenario: Imported Services Empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
---
And 0 service models
When I visit the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I see the "[data-test-imported-services-empty]" element
Scenario: Imported Services not empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
---
And 1 service models from yaml
---
Name: 'service-for-peer-a'
---
When I visit the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I don't see the "[data-test-imported-services-empty]" element
Scenario: Exported Services Empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
---
And 0 service models
When I visitExported the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I see the "[data-test-exported-services-empty]" element
Scenario: Exported Services not empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
---
And 1 service models from yaml
---
Name: 'service-for-peer-a'
---
When I visitExported the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I don't see the "[data-test-exported-services-empty]" element
Scenario: Addresses Empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
PeerServerAddresses: null
---
When I visitAddresses the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I see the "[data-test-addresses-empty]" element
Scenario: Addresses Not Empty
And 1 datacenter model with the value "dc-1"
And 1 peer models from yaml
---
Name: a-peer
State: ACTIVE
---
When I visitAddresses the peer page for yaml
---
dc: dc-1
peer: a-peer
---
Then I don't see the "[data-test-addresses-empty]" element

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

@ -75,6 +75,7 @@ import intention from 'consul-ui/tests/pages/dc/intentions/edit';
import nspaces from 'consul-ui/tests/pages/dc/nspaces/index';
import nspace from 'consul-ui/tests/pages/dc/nspaces/edit';
import peers from 'consul-ui/tests/pages/dc/peers/index';
import peersShow from 'consul-ui/tests/pages/dc/peers/show';
// utils
const deletable = createDeletable(clickable);
@ -234,6 +235,7 @@ export default {
nspace(visitable, submitable, deletable, cancelable, policySelector, roleSelector)
),
peers: create(peers(visitable, creatable, consulPeerList, popoverSelect)),
peer: create(peersShow(visitable)),
settings: create(settings(visitable, submitable, isPresent)),
routingConfig: create(routingConfig(visitable, text)),
};

View File

@ -1,7 +1,10 @@
import tabgroup from 'consul-ui/components/tab-nav/pageobject';
export default function (visitable, creatable, items, popoverSelect) {
return creatable({
visit: visitable('/:dc/peers'),
peers: items(),
sort: popoverSelect('[data-test-sort-control]'),
tabs: tabgroup('tab', ['imported-services', 'exported-services', 'addresses']),
});
}

View File

@ -0,0 +1,8 @@
export default function (visitable) {
return {
visit: visitable('/:dc/peers/:peer'),
visitExported: visitable('/:dc/peers/:peer/exported-services'),
visitImported: visitable('/:dc/peers/:peer/imported-services'),
visitAddresses: visitable('/:dc/peers/:peer/addresses'),
};
}

View File

@ -29,5 +29,13 @@ export default function (scenario, pages, set, reset) {
// do I absolutely definitely need that all the time?
return set(pages[name]).visit(data);
}
)
.when(
['I $method the $name page for yaml\n$yaml', 'I $method the $name page for json\n$json'],
function (method, name, data) {
reset();
return set(pages[name])[method](data);
}
);
}

View File

@ -117,10 +117,10 @@ dc:
index:
empty:
header: |
{items, select,
0 {Welcome to Peers}
other {No peers found}
}
{items, select,
0 {Welcome to Peers}
other {No peers found}
}
body: |
{items, select,
0 {Cluster peering is the recommended way to connect services across or within Consul datacenters. Peering is a one-to-one relationship in which each peer is either a open-source Consul datacenter or a Consul enterprise admin partition. There don't seem to be any peers for this {canUsePartitions, select,
@ -135,12 +135,42 @@ dc:
detail:
imported:
count: |
{count} imported services
{count} imported services
tooltip: The number of services imported from {name}
tab-tooltip: Services imported from {name}
exported:
count: |
{count} exported services
{count} exported services
tooltip: The number of services exported from {name}
tab-tooltip: Services exported from {name}
addresses:
tooltip: The number of services exported from {name}
show:
imported:
empty:
header: No visible imported services from {name}
body: |
<div>
{items, select,
0 {Services must be exported from one peer to another to enable service communication across two peers. There don't seem to be any services imported from {name} yet, or you may not have <code>services:read</code> permissions to access to this view.}
other {No services where found matching that search, or you may not have access to view the services you are searching for.}
}
</div>
exported:
empty:
header: No visible exported services to {name}
body: |
<div>
{items, select,
0 {Services must be exported from one peer to another to enable service communication across two peers. There don't seem to be any services exported to {name} yet, or you may not have <code>services:read</code> permissions to access to this view.}
other {No services where found matching that search, or you may not have access to view the services you are searching for.}
}
</div>
addresses:
empty:
header: No server adddresses.
body: <div>There don't seem to be any server addresses for this peer.</div>
partitions:
index:
empty:
@ -148,7 +178,7 @@ dc:
{items, select,
0 {Welcome to Partitions}
other {No partitions found}
}
}
body: |
{items, select,
0 {There don't seem to be any partitions{canUseACLs, select,

View File

@ -3320,6 +3320,20 @@
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.10.0.tgz#24b03043bacda16e505200e6591dfef896ddacf1"
integrity sha512-jYUA0M6Tz+4RAudil+GW/fHbhZPcKCiIZZAguBDviqbLneMkMgPOBgbXWCGWsEQ1fJzP2cXbUaio8L0aQZPWQw==
"@html-next/vertical-collection@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@html-next/vertical-collection/-/vertical-collection-4.0.0.tgz#b3b3d52358e15e7ed46e028d12424dab994690ed"
integrity sha512-/c4y6ASmkMwyG+rcoXH3kx50LiK2MuPX0bsktd+j9LhOD6zkJyT4wJ73m20dCEvxjgwA/nCQ8hj3lApQlHG0CQ==
dependencies:
babel6-plugin-strip-class-callcheck "^6.0.0"
broccoli-funnel "^3.0.8"
broccoli-merge-trees "^4.2.0"
broccoli-rollup "^5.0.0"
ember-cli-babel "^7.12.0"
ember-cli-htmlbars "^6.0.0"
ember-cli-version-checker "^5.1.2"
ember-raf-scheduler "^0.3.0"
"@humanwhocodes/config-array@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
@ -3511,6 +3525,13 @@
resolved "https://registry.yarnpkg.com/@types/broccoli-plugin/-/broccoli-plugin-1.3.0.tgz#38f8462fecaebc4e09a32e4d4ed1b9808f75bbca"
integrity sha512-SLk4/hFc2kGvgwNFrpn2O1juxFOllcHAywvlo7VwxfExLzoz1GGJ0oIZCwj5fwSpvHw4AWpZjJ1fUvb62PDayQ==
"@types/broccoli-plugin@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/broccoli-plugin/-/broccoli-plugin-3.0.0.tgz#290fda2270c47a568edfd0cefab8bb840d8bb7b2"
integrity sha512-f+TcsARR2PovfFRKFdCX0kfH/QoM3ZVD2h1rl2mNvrKO0fq2uBNCBsTU3JanfU4COCt5cXpTfARyUsERlC8vIw==
dependencies:
broccoli-plugin "*"
"@types/chai-as-promised@^7.1.2":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.3.tgz#779166b90fda611963a3adbfd00b339d03b747bd"
@ -5945,6 +5966,19 @@ broccoli-persistent-filter@^3.1.2:
symlink-or-copy "^1.0.1"
sync-disk-cache "^2.0.0"
broccoli-plugin@*, broccoli-plugin@^4.0.5, broccoli-plugin@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/broccoli-plugin/-/broccoli-plugin-4.0.7.tgz#dd176a85efe915ed557d913744b181abe05047db"
integrity sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==
dependencies:
broccoli-node-api "^1.7.0"
broccoli-output-wrapper "^3.2.5"
fs-merger "^3.2.1"
promise-map-series "^0.3.0"
quick-temp "^0.1.8"
rimraf "^3.0.2"
symlink-or-copy "^1.3.1"
broccoli-plugin@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/broccoli-plugin/-/broccoli-plugin-1.1.0.tgz#73e2cfa05f8ea1e3fc1420c40c3d9e7dc724bf02"
@ -6001,19 +6035,6 @@ broccoli-plugin@^4.0.0, broccoli-plugin@^4.0.1, broccoli-plugin@^4.0.2, broccoli
rimraf "^3.0.2"
symlink-or-copy "^1.3.1"
broccoli-plugin@^4.0.5, broccoli-plugin@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/broccoli-plugin/-/broccoli-plugin-4.0.7.tgz#dd176a85efe915ed557d913744b181abe05047db"
integrity sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==
dependencies:
broccoli-node-api "^1.7.0"
broccoli-output-wrapper "^3.2.5"
fs-merger "^3.2.1"
promise-map-series "^0.3.0"
quick-temp "^0.1.8"
rimraf "^3.0.2"
symlink-or-copy "^1.3.1"
broccoli-postcss-single@^5.0.1:
version "5.0.2"
resolved "https://registry.yarnpkg.com/broccoli-postcss-single/-/broccoli-postcss-single-5.0.2.tgz#f23661b3011494d8a2dbd8ff39eb394e80313682"
@ -6052,6 +6073,21 @@ broccoli-rollup@^4.1.1:
symlink-or-copy "^1.2.0"
walk-sync "^1.1.3"
broccoli-rollup@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/broccoli-rollup/-/broccoli-rollup-5.0.0.tgz#a77b53bcef1b70e988913fee82265c0a4ca530da"
integrity sha512-QdMuXHwsdz/LOS8zu4HP91Sfi4ofimrOXoYP/lrPdRh7lJYD87Lfq4WzzUhGHsxMfzANIEvl/7qVHKD3cFJ4tA==
dependencies:
"@types/broccoli-plugin" "^3.0.0"
broccoli-plugin "^4.0.7"
fs-tree-diff "^2.0.1"
heimdalljs "^0.2.6"
node-modules-path "^1.0.1"
rollup "^2.50.0"
rollup-pluginutils "^2.8.1"
symlink-or-copy "^1.2.0"
walk-sync "^2.2.0"
broccoli-sass-source-maps@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/broccoli-sass-source-maps/-/broccoli-sass-source-maps-4.0.0.tgz#1ee4c10a810b10955b0502e28f85ab672f5961a2"
@ -7946,7 +7982,7 @@ ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.11.0,
ember-cli-version-checker "^2.1.2"
semver "^5.5.0"
ember-cli-babel@^7.13.2, ember-cli-babel@^7.26.1, ember-cli-babel@^7.26.11, ember-cli-babel@^7.26.3, ember-cli-babel@^7.26.5, ember-cli-babel@^7.4.0:
ember-cli-babel@^7.12.0, ember-cli-babel@^7.13.2, ember-cli-babel@^7.26.1, ember-cli-babel@^7.26.11, ember-cli-babel@^7.26.3, ember-cli-babel@^7.26.5, ember-cli-babel@^7.4.0:
version "7.26.11"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.26.11.tgz#50da0fe4dcd99aada499843940fec75076249a9f"
integrity sha512-JJYeYjiz/JTn34q7F5DSOjkkZqy8qwFOOxXfE6pe9yEJqWGu4qErKxlz8I22JoVEQ/aBUO+OcKTpmctvykM9YA==
@ -8881,7 +8917,7 @@ ember-modifier@^2.1.0, ember-modifier@^2.1.1:
ember-destroyable-polyfill "^2.0.2"
ember-modifier-manager-polyfill "^1.2.0"
"ember-modifier@^2.1.2 || ^3.1.0 || ^4.0.0":
"ember-modifier@^2.1.2 || ^3.1.0 || ^4.0.0", ember-modifier@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b"
integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==
@ -8981,12 +9017,21 @@ ember-qunit@^5.1.1:
silent-error "^1.1.1"
validate-peer-dependencies "^1.2.0"
ember-ref-modifier@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/ember-ref-modifier/-/ember-ref-modifier-1.0.1.tgz#aeca56798ebc0fb750f0ccd36e86d667f5b1bc44"
integrity sha512-qmEFY/4WOWWXABRWvX50jLPleH30p/LPLUXEvaSlIj41F23e3Vul91IqZ1PFdw1Rxpkb8DHWO5BRchN8vnz4+Q==
ember-raf-scheduler@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/ember-raf-scheduler/-/ember-raf-scheduler-0.3.0.tgz#7657ee5c1d54f852731e61e9d0e0750a9a22f5f4"
integrity sha512-i8JWQidNCX7n5TOTIKRDR0bnsQN9aJh/GtOJKINz2Wr+I7L7sYVhli6MFqMYNGKC9j9e6iWsznfAIxddheyEow==
dependencies:
ember-cli-babel "^7.20.5"
ember-cli-babel "^7.26.6"
ember-ref-bucket@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ember-ref-bucket/-/ember-ref-bucket-4.1.0.tgz#2a52e72a395a14033d034c834fab648f26d74baa"
integrity sha512-oEUU2mDtuYuMM039U9YEqrrOCVHH6rQfvbFOmh3WxOVEgubmLVyKEpGgU4P/6j0B/JxTqqTwM3ULTQyDto8dKg==
dependencies:
ember-cli-babel "^7.26.11"
ember-cli-htmlbars "^6.0.1"
ember-modifier "^3.2.7"
ember-render-helpers@^0.2.0:
version "0.2.0"
@ -15179,6 +15224,13 @@ rollup@^1.12.0:
"@types/node" "*"
acorn "^7.1.0"
rollup@^2.50.0:
version "2.79.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
optionalDependencies:
fsevents "~2.3.2"
route-recognizer@^0.3.3:
version "0.3.4"
resolved "https://registry.yarnpkg.com/route-recognizer/-/route-recognizer-0.3.4.tgz#39ab1ffbce1c59e6d2bdca416f0932611e4f3ca3"