ui: Add license endpoint/datasource (#12506)

* ui: Add auto-pilot/state endpoint usage (merged into DC models) (#12514)

* ui: Catalog Health Overview DataSource (#12520)
This commit is contained in:
John Cowen 2022-03-09 09:03:15 +00:00 committed by GitHub
parent d8fca60bd8
commit 7c4dd75abd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 516 additions and 158 deletions

View File

@ -64,6 +64,12 @@ module.exports = {
urlSchema: 'auto', urlSchema: 'auto',
urlPrefix: 'docs/styles', urlPrefix: 'docs/styles',
}, },
{
root: path.resolve(__dirname, 'app/services/repository'),
pattern: '**/*.mdx',
urlSchema: 'auto',
urlPrefix: 'docs/repositories',
},
{ {
root: path.resolve(__dirname, 'app/modifiers'), root: path.resolve(__dirname, 'app/modifiers'),
pattern: '**/*.mdx', pattern: '**/*.mdx',

View File

@ -1,9 +0,0 @@
import Adapter from './application';
export default class DcAdapter extends Adapter {
requestForQuery(request) {
return request`
GET /v1/catalog/datacenters
`;
}
}

View File

@ -7,10 +7,10 @@ export default path => (target, propertyKey, desc) => {
runInDebug(() => { runInDebug(() => {
routes[path] = { cls: target, method: propertyKey }; routes[path] = { cls: target, method: propertyKey };
}); });
router.on(path, function(params, owner) { router.on(path, function(params, owner, request) {
const container = owner.lookup('service:container'); const container = owner.lookup('service:container');
const instance = container.get(target); const instance = container.get(target);
return configuration => desc.value.apply(instance, [params, configuration]); return configuration => desc.value.apply(instance, [params, configuration, request]);
}); });
return desc; return desc;
}; };

View File

@ -0,0 +1,9 @@
import { helper } from '@ember/component/helper';
export default helper(function(args, hash) {
try {
return JSON.stringify(...args);
} catch(e) {
return args[0].map(item => JSON.stringify(item, args[1], args[2]));
}
});

View File

@ -5,8 +5,16 @@ export const FOREIGN_KEY = 'Datacenter';
export const SLUG_KEY = 'Name'; export const SLUG_KEY = 'Name';
export default class Datacenter extends Model { export default class Datacenter extends Model {
@attr('string') uid; @attr('string') uri;
@attr('string') Name; @attr('string') Name;
// autopilot/state
@attr('boolean') Healthy;
@attr('number') FailureTolerance;
@attr('number') OptimisticFailureTolerance;
@attr('string') Leader;
@attr() Voters; // []
@attr() Servers; // [] the API uses {} but we reshape that on the frontend
//
@attr('boolean') Local; @attr('boolean') Local;
@attr('boolean') Primary; @attr('boolean') Primary;
@attr('string') DefaultACLPolicy; @attr('string') DefaultACLPolicy;

View File

@ -0,0 +1,18 @@
import Model, { attr } from '@ember-data/model';
export const PRIMARY_KEY = 'uri';
export default class License extends Model {
@attr('string') uri;
@attr('boolean') Valid;
@attr('number') SyncTime;
@attr() meta; // {}
@attr('string') Datacenter;
@attr('string') Namespace;
@attr('string') Partition;
@attr() License; // {}
// @attr() Warnings; // []
}

View File

@ -1,42 +0,0 @@
import Serializer from './application';
import { inject as service } from '@ember/service';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/dc';
import {
HEADERS_SYMBOL,
HEADERS_DEFAULT_ACL_POLICY as DEFAULT_ACL_POLICY,
} from 'consul-ui/utils/http/consul';
export default class DcSerializer extends Serializer {
@service('env') env;
primaryKey = PRIMARY_KEY;
slugKey = SLUG_KEY;
// datacenters come in as an array of plain strings. Convert to objects
// instead and collect all the other datacenter info from other places and
// add it to each datacenter object
respondForQuery(respond, query) {
return super.respondForQuery(
cb => respond((headers, body) => {
body = body.map(item => ({
Datacenter: '',
[this.slugKey]: item,
}));
body = cb(headers, body);
headers = body[HEADERS_SYMBOL];
const Local = this.env.var('CONSUL_DATACENTER_LOCAL');
const Primary = this.env.var('CONSUL_DATACENTER_PRIMARY');
const DefaultACLPolicy = headers[DEFAULT_ACL_POLICY.toLowerCase()];
return body.map(item => ({
...item,
Local: item.Name === Local,
Primary: item.Name === Primary,
DefaultACLPolicy: DefaultACLPolicy,
}));
}),
query
);
}
}

View File

@ -83,11 +83,31 @@ export default class HttpService extends Service {
@service('client/connections') connections; @service('client/connections') connections;
@service('client/transports/xhr') transport; @service('client/transports/xhr') transport;
@service('settings') settings; @service('settings') settings;
@service('encoder') encoder;
@service('store') store;
init() { init() {
super.init(...arguments); super.init(...arguments);
this._listeners = this.dom.listeners(); this._listeners = this.dom.listeners();
this.parseURL = createURL(encodeURIComponent, obj => QueryParams.stringify(this.sanitize(obj))); this.parseURL = createURL(encodeURIComponent, obj => QueryParams.stringify(this.sanitize(obj)));
const uriTag = this.encoder.uriTag();
this.cache = (data, id) => {
// interpolate the URI
data.uri = id(uriTag);
// save the time we received it for cache management purposes
data.SyncTime = new Date().getTime();
// save the data to the cache
return this.store.push(
{
data: {
id: data.uri,
// the model is encoded as the protocol in the URI
type: new URL(data.uri).protocol.slice(0, -1),
attributes: data
}
}
);
}
} }
sanitize(obj) { sanitize(obj) {
@ -197,9 +217,9 @@ export default class HttpService extends Service {
}); });
}); });
} }
request(cb) { request(cb) {
const client = this; const client = this;
const cache = this.cache;
return cb(function(strs, ...values) { return cb(function(strs, ...values) {
const params = client.requestParams(...arguments); const params = client.requestParams(...arguments);
return client.settings.findBySlug('token').then(token => { return client.settings.findBySlug('token').then(token => {
@ -236,7 +256,28 @@ export default class HttpService extends Service {
[CONSUL_PARTITION]: params.data.partition || token.Partition || 'default', [CONSUL_PARTITION]: params.data.partition || token.Partition || 'default',
}; };
const respond = function(cb) { const respond = function(cb) {
return cb(headers, e.data.response); let res = cb(headers, e.data.response, cache);
const meta = res.meta || {};
if(meta.version === 2) {
if(Array.isArray(res.body)) {
res = new Proxy(
res.body,
{
get: (target, prop) => {
switch(prop) {
case 'meta':
return meta;
}
return target[prop];
}
}
);
} else {
res = res.body;
res.meta = meta;
}
}
return res;
}; };
next(() => resolve(respond)); next(() => resolve(respond));
}, },

View File

@ -3,11 +3,17 @@ import { getOwner } from '@ember/application';
import { match } from 'consul-ui/decorators/data-source'; import { match } from 'consul-ui/decorators/data-source';
export default class HttpService extends Service { export default class HttpService extends Service {
@service('client/http') client;
@service('data-source/protocols/http/blocking') type; @service('data-source/protocols/http/blocking') type;
source(src, configuration) { source(src, configuration) {
const route = match(src); const route = match(src);
const find = route.cb(route.params, getOwner(this)); let find;
this.client.request(
request => {
find = route.cb(route.params, getOwner(this), request);
}
);
return this.type.source(find, configuration); return this.type.source(find, configuration);
} }
} }

View File

@ -1,20 +1,180 @@
import Error from '@ember/error'; import Error from '@ember/error';
import { inject as service } from '@ember/service';
import RepositoryService from 'consul-ui/services/repository'; import RepositoryService from 'consul-ui/services/repository';
import dataSource from 'consul-ui/decorators/data-source'; import dataSource from 'consul-ui/decorators/data-source';
import {
HEADERS_DEFAULT_ACL_POLICY as DEFAULT_ACL_POLICY,
} from 'consul-ui/utils/http/consul';
const SECONDS = 1000;
const MODEL_NAME = 'dc';
const zero = {
Total: 0,
Passing: 0,
Warning: 0,
Critical: 0
};
const aggregate = (prev, body, type) => {
return body[type].reduce((prev, item) => {
// for each Partitions, Namespaces
['Partition', 'Namespace'].forEach(bucket => {
// lazily initialize
let obj = prev[bucket][item[bucket]];
if(typeof obj === 'undefined') {
obj = prev[bucket][item[bucket]] = {
Name: item[bucket],
};
}
if(typeof obj[type] === 'undefined') {
obj[type] = {
...zero
};
}
//
// accumulate
obj[type].Total += item.Total;
obj[type].Passing += item.Passing;
obj[type].Warning += item.Warning;
obj[type].Critical += item.Critical;
});
// also aggregate the Datacenter, without doubling up
// for Partitions/Namespaces
prev.Datacenter[type].Total += item.Total;
prev.Datacenter[type].Passing += item.Passing;
prev.Datacenter[type].Warning += item.Warning;
prev.Datacenter[type].Critical += item.Critical;
return prev;
}, prev);
}
const modelName = 'dc';
export default class DcService extends RepositoryService { export default class DcService extends RepositoryService {
@service('env') env;
getModelName() { getModelName() {
return modelName; return MODEL_NAME;
} }
@dataSource('/:partition/:ns/:dc/datacenters') @dataSource('/:partition/:ns/:dc/datacenters')
async findAll() { async fetchAll({partition, ns, dc}, { uri }, request) {
return super.findAll(...arguments); const Local = this.env.var('CONSUL_DATACENTER_LOCAL');
const Primary = this.env.var('CONSUL_DATACENTER_PRIMARY');
return (await request`
GET /v1/catalog/datacenters
X-Request-ID: ${uri}
`)(
(headers, body, cache) => {
// TODO: Not sure nowadays whether we need to keep lowercasing everything
// I vaguely remember when I last looked it was not needed for browsers anymore
// but I also vaguely remember something about Pretender lowercasing things still
// so if we can work around Pretender I think we can remove all the header lowercasing
// For the moment we lowercase here so as to not effect the ember-data-flavoured-v1 fork
const entry = Object.entries(headers)
.find(([key, value]) => key.toLowerCase() === DEFAULT_ACL_POLICY.toLowerCase());
//
const DefaultACLPolicy = entry[1] || 'allow';
return {
meta: {
version: 2,
uri: uri,
},
body: body.map(dc => {
return cache(
{
Name: dc,
Datacenter: '',
Local: dc === Local,
Primary: dc === Primary,
DefaultACLPolicy: DefaultACLPolicy,
},
uri => uri`${MODEL_NAME}:///${''}/${''}/${dc}/datacenter`
);
})
};
});
} }
@dataSource('/:partition/:ns/:dc/datacenter/:name') @dataSource('/:partition/:ns/:dc/datacenter')
async findBySlug(params) { async fetch({partition, ns, dc}, { uri }, request) {
return (await request`
GET /v1/operator/autopilot/state?${{ dc }}
X-Request-ID: ${uri}
`)(
(headers, body, cache) => ({
meta: {
version: 2,
uri: uri,
interval: 30 * SECONDS
},
body: cache(
{
...body,
// turn servers into an array instead of a map/object
Servers: Object.values(body.Servers)
},
uri => uri`${MODEL_NAME}:///${''}/${''}/${dc}/datacenter`
)
})
);
}
@dataSource('/:partition/:ns/:dc/catalog/health')
async fetchCatalogHealth({partition, ns, dc}, { uri }, request) {
return (await request`
GET /v1/internal/ui/catalog-overview?${{ dc, stale: null }}
X-Request-ID: ${uri}
`)(
(headers, body, cache) => {
// for each Services/Nodes/Checks aggregate
const agg = ['Nodes', 'Services', 'Checks']
.reduce((prev, item) => aggregate(prev, body, item), {
Datacenter: {
Name: dc,
Nodes: {
...zero
},
Services: {
...zero
},
Checks: {
...zero
}
},
Partition: {},
Namespace: {}
});
return {
meta: {
version: 2,
uri: uri,
interval: 30 * SECONDS
},
body: {
Datacenter: agg.Datacenter,
Partitions: Object.values(agg.Partition),
Namespaces: Object.values(agg.Namespace),
...body
}
};
}
);
}
@dataSource('/:partition/:ns/:dc/datacenter-cache/:name')
async find(params) {
const items = this.store.peekAll('dc'); const items = this.store.peekAll('dc');
const item = items.findBy('Name', params.name); const item = items.findBy('Name', params.name);
if (typeof item === 'undefined') { if (typeof item === 'undefined') {
@ -26,4 +186,5 @@ export default class DcService extends RepositoryService {
} }
return item; return item;
} }
} }

View File

@ -0,0 +1,55 @@
# Dc
```hbs preview-template
<figure>
<figcaption>URI: <code>/:partition/:nspace/:dc/catalog/health</code></figcaption>
<DataSource
@src={{
uri '/${partition}/${nspace}/${dc}/catalog/health'
(hash
partition='partition'
nspace='ns'
dc='dc1'
)
}}
@onchange={{action (mut data) value="data"}}
/>
<pre><code>{{json-stringify data null 4}}</code></pre>
</figure>
```
```hbs preview-template
<figure>
<figcaption>URI: <code>/:partition/:nspace/:dc/datacenters</code></figcaption>
<DataSource
@src={{
uri '/${partition}/${nspace}/${dc}/datacenters'
(hash
partition='partition'
nspace='ns'
dc='dc1'
)
}}
@onchange={{action (mut data) value="data"}}
/>
<pre><code>{{json-stringify data null 4}}</code></pre>
</figure>
```
```hbs preview-template
<figure>
<figcaption>URI: <code>/:partition/:nspace/:dc/datacenter</code></figcaption>
<DataSource
@src={{
uri '/${partition}/${nspace}/${dc}/datacenter'
(hash
partition='partition'
nspace='ns'
dc='dc1'
)
}}
@onchange={{action (mut data) value="data"}}
/>
<pre><code>{{json-stringify data null 4}}</code></pre>
</figure>
```

View File

@ -0,0 +1,37 @@
import RepositoryService from 'consul-ui/services/repository';
import dataSource from 'consul-ui/decorators/data-source';
const MODEL_NAME = 'license';
const bucket = function(item, { dc, ns = 'default', partition = 'default' }) {
return {
...item,
Datacenter: dc,
Namespace: typeof item.Namespace === 'undefined' ? ns : item.Namespace,
Partition: typeof item.Partition === 'undefined' ? partition : item.Partition,
};
}
const SECONDS = 1000;
export default class LicenseService extends RepositoryService {
@dataSource('/:partition/:ns/:dc/license')
async find({partition, ns, dc}, { uri }, request) {
return (await request`
GET /v1/operator/license?${{ dc }}
X-Request-ID: ${uri}
`)(
(headers, body, cache) => ({
meta: {
version: 2,
uri: uri,
interval: 30 * SECONDS
},
body: cache(
bucket(body, { dc }),
uri => uri`${MODEL_NAME}:///${partition}/${ns}/${dc}/license/${body.License.license_id}`
)
})
);
}
}

View File

@ -0,0 +1,19 @@
# License
```hbs preview-template
<figure>
<figcaption>URI: <code>/:partition/:nspace/:dc/license</code></figcaption>
<DataSource
@src={{
uri '/${partition}/${nspace}/${dc}/license'
(hash
partition='partition'
nspace='ns'
dc='dc-1'
)
}}
@onchange={{action (mut data) value="data"}}
/>
<pre><code>{{json-stringify data null 4}}</code></pre>
</figure>
```

View File

@ -113,7 +113,7 @@ as |dc dcs|}}
{{! figure out our current DC and convert it to a model }} {{! figure out our current DC and convert it to a model }}
<DataSource <DataSource
@src={{uri '/${partition}/*/${dc}/datacenter/${name}' @src={{uri '/${partition}/*/${dc}/datacenter-cache/${name}'
(hash (hash
dc=dc.Name dc=dc.Name
partition=partition partition=partition

View File

@ -0,0 +1,67 @@
${[0].map(_ => {
const healthOf = num => {
return range(num).reduce((prev, _) => {
prev[fake.helpers.randomize(['Passing', 'Warning', 'Critical'])] ++;
return prev;
}, {Passing: 0, Warning: 0, Critical: 0});
}
const partitionCount = env('CONSUL_PARTITION_COUNT', Math.floor(Math.random() * 5));
const nspaceCount = env('CONSUL_NSPACE_COUNT', Math.floor(Math.random() * 10));
const nodes = env('CONSUL_NODE_COUNT', Math.floor(Math.random() * 10));
const services = env('CONSUL_SERVICE_COUNT', Math.floor(Math.random() * 10));
const checks = env('CONSUL_CHECK_COUNT', Math.floor(Math.random() * 10));
const partitions = range(partitionCount).map((_, i) => `${fake.hacker.noun()}-partition-${i}`);
const nspaces = range(nspaceCount).map((_, i) => `${fake.hacker.noun()}-nspace-${i}`);
return `
{
"Nodes": [
${partitions.map(partition => {
const health = healthOf(nodes);
return nspaces.map(nspace => `
{
"Total": ${nodes},
"Passing": ${health.Passing},
"Warning": ${health.Warning},
"Critical": ${health.Critical},
"Partition": "${partition}",
"Namespace": "${nspace}"
}
`)}).flat()}
],
"Services": [
${partitions.map((partition, i) => {
const health = healthOf(services);
return nspaces.map((nspace, j) => `
{
"Name": "${fake.hacker.noun()}-service-${i * j}",
"Total": ${services},
"Passing": ${health.Passing},
"Warning": ${health.Warning},
"Critical": ${health.Critical},
"Partition": "${partition}",
"Namespace": "${nspace}"
}
`)}).flat()}
],
"Checks": [
${partitions.map((partition, i) => {
const health = healthOf(checks);
return nspaces.map((nspace, j) => `
{
"Name": "${fake.hacker.noun()}-check-${i * j}",
"Total": ${services},
"Passing": ${health.Passing},
"Warning": ${health.Warning},
"Critical": ${health.Critical},
"Partition": "${partition}",
"Namespace": "${nspace}"
}
`)}).flat()}
]
}
`;
})}

View File

@ -0,0 +1,37 @@
${[0].map(_ => {
const servers = range(env('CONSUL_SERVER_COUNT', 3)).map(_ => fake.random.uuid());
const failureTolerance = Math.ceil(servers.length / 2);
const optimisticTolerance = failureTolerance; // <== same for now
const leader = fake.random.number({min: 0, max: servers.length - 1});
return `
{
"Healthy": true,
"FailureTolerance": ${failureTolerance},
"OptimisticFailureTolerance": ${optimisticTolerance},
"Servers": {${servers.map((item, i, items) => `
"${item}": {
"ID": "${item}",
"Name": "node-${i}",
"Address": "${fake.internet.ip()}:${fake.random.number({min: 0, max: 65535})}",
"NodeStatus": "alive",
"Version": "1.11.2",
"LastContact": "0s",
"LastTerm": 2,
"LastIndex": 91,
"Healthy": true,
"StableSince": "2022-02-02T11:59:01.0708146Z",
"ReadReplica": false,
"Status": "${i === leader ? `leader` : `voter`}",
"Meta": {
"consul-network-segment": ""
},
"NodeType": "voter"
}
`)}},
"Leader": "${servers[leader]}",
"Voters": [
${servers.map(item => `"${item}"`)}
]
}
`;
})}

View File

@ -0,0 +1,37 @@
{
"Valid": ${fake.random.boolean()},
"License": {
"license_id": "${fake.random.uuid()}",
"customer_id": "${fake.random.uuid()}",
"installation_id": "*",
"issue_time": "2021-01-13T15:25:19.052900132Z",
"start_time": "2021-01-13T00:00:00Z",
"expiration_time": "${env('CONSUL_LICENSE_EXPIRATION', '2022-01-13T23:59:59.999Z')}",
"termination_time": "${env('CONSUL_LICENSE_TERMINATION', '2022-01-13T23:59:59.999Z')}",
"product": "consul",
"flags": {
"modules": [
"global-visibility-routing-scale",
"governance-policy"
]
},
"modules": [
"Global Visibility, Routing and Scale",
"Governance and Policy"
],
"features": [
"Automated Backups",
"Automated Upgrades",
"Enhanced Read Scalability",
"Network Segments",
"Redundancy Zone",
"Advanced Network Federation",
"Namespaces",
"SSO",
"Audit Logging",
"Admin Partitions"
]
},
"Warnings": [
]
}

View File

@ -1,13 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Integration | Adapter | dc', function(hooks) {
setupTest(hooks);
test('requestForQuery returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:dc');
const client = this.owner.lookup('service:client/http');
const request = client.url.bind(client);
const expected = `GET /v1/catalog/datacenters`;
const actual = adapter.requestForQuery(request);
assert.equal(actual, expected);
});
});

View File

@ -1,43 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { get } from 'consul-ui/tests/helpers/api';
import {
HEADERS_DEFAULT_ACL_POLICY as DEFAULT_ACL_POLICY,
} from 'consul-ui/utils/http/consul';
module('Integration | Serializer | dc', function(hooks) {
setupTest(hooks);
test('respondForQuery returns the correct data for list endpoint', function(assert) {
const serializer = this.owner.lookup('serializer:dc');
let env = this.owner.lookup('service:env');
env = env.var.bind(env);
const request = {
url: `/v1/catalog/datacenters`,
};
return get(request.url).then(function(payload) {
const ALLOW = 'allow';
const expected = payload.map(item => (
{
Name: item,
Datacenter: '',
Local: item === env('CONSUL_DATACENTER_LOCAL'),
Primary: item === env('CONSUL_DATACENTER_PRIMARY'),
DefaultACLPolicy: ALLOW
}
))
const actual = serializer.respondForQuery(function(cb) {
const headers = {
[DEFAULT_ACL_POLICY]: ALLOW
};
return cb(headers, payload);
}, {
dc: '*',
});
actual.forEach((item, i) => {
assert.equal(actual[i].Name, expected[i].Name);
assert.equal(actual[i].Local, expected[i].Local);
assert.equal(actual[i].Primary, expected[i].Primary);
assert.equal(actual[i].DefaultACLPolicy, expected[i].DefaultACLPolicy);
});
});
});
});

View File

@ -1,4 +1,4 @@
import { moduleFor, test } from 'ember-qunit'; import { moduleFor } from 'ember-qunit';
import { skip } from 'qunit'; import { skip } from 'qunit';
import repo from 'consul-ui/tests/helpers/repo'; import repo from 'consul-ui/tests/helpers/repo';
const NAME = 'dc'; const NAME = 'dc';
@ -7,7 +7,7 @@ moduleFor(`service:repository/${NAME}`, `Integration | Service | ${NAME}`, {
integration: true, integration: true,
}); });
skip("findBySlug (doesn't interact with the API) but still needs an int test"); skip("findBySlug (doesn't interact with the API) but still needs an int test");
test('findAll returns the correct data for list endpoint', function(assert) { skip('findAll returns the correct data for list endpoint', function(assert) {
return repo( return repo(
'Dc', 'Dc',
'findAll', 'findAll',

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | dc', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let adapter = this.owner.lookup('adapter:dc');
assert.ok(adapter);
});
});

View File

@ -1,24 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { run } from '@ember/runloop';
module('Unit | Serializer | dc', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('dc');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = run(() => store.createRecord('dc', {}));
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});