Add amphora statistics to the admin API
This patch adds an admin API for getting per-amphora statistics. Change-Id: Ib57b2136dbb41067d6b8949ee42f946f109616e7
This commit is contained in:
parent
9bb4c5c1c4
commit
66298f9a48
@ -143,6 +143,13 @@ amphora-role:
|
||||
in: body
|
||||
required: true
|
||||
type: string
|
||||
amphora-stats:
|
||||
description: |
|
||||
A list of amphora statistics objects, one per listener.
|
||||
in: body
|
||||
min_version: 2.3
|
||||
required: true
|
||||
type: array
|
||||
amphora-status:
|
||||
description: |
|
||||
The status of the amphora. One of: ``BOOTING``, ``ALLOCATED``, ``READY``,
|
||||
|
@ -79,7 +79,7 @@ Response Example
|
||||
:language: javascript
|
||||
|
||||
Show Amphora details
|
||||
===========================
|
||||
====================
|
||||
|
||||
.. rest_method:: GET /v2/octavia/amphorae/{amphora_id}
|
||||
|
||||
@ -153,6 +153,68 @@ Response Example
|
||||
.. literalinclude:: examples/amphora-show-response.json
|
||||
:language: javascript
|
||||
|
||||
Show Amphora Statistics
|
||||
=======================
|
||||
|
||||
.. rest_method:: GET /v2/octavia/amphorae/{amphora_id}/stats
|
||||
|
||||
Show the statistics for an amphora.
|
||||
|
||||
If you are not an administrative user, the service returns the HTTP
|
||||
``Forbidden (403)`` response code.
|
||||
|
||||
Use the ``fields`` query parameter to control which fields are
|
||||
returned in the response body.
|
||||
|
||||
**New in version 2.3**
|
||||
|
||||
.. rest_status_code:: success ../http-status.yaml
|
||||
|
||||
- 200
|
||||
|
||||
.. rest_status_code:: error ../http-status.yaml
|
||||
|
||||
- 400
|
||||
- 401
|
||||
- 403
|
||||
- 404
|
||||
- 500
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
.. rest_parameters:: ../parameters.yaml
|
||||
|
||||
- amphora_id: path-amphora-id
|
||||
- fields: fields
|
||||
|
||||
Curl Example
|
||||
------------
|
||||
|
||||
.. literalinclude:: examples/amphora-show-stats-curl
|
||||
:language: bash
|
||||
|
||||
Response Parameters
|
||||
-------------------
|
||||
|
||||
.. rest_parameters:: ../parameters.yaml
|
||||
|
||||
- active_connections: active_connections
|
||||
- amphora_stats: amphora-stats
|
||||
- bytes_in: bytes_in
|
||||
- bytes_out: bytes_out
|
||||
- id: amphora-id
|
||||
- listener_id: listener-id
|
||||
- loadbalancer_id: loadbalancer-id
|
||||
- request_errors: request_errors
|
||||
- total_connections: total_connections
|
||||
|
||||
Response Example
|
||||
----------------
|
||||
|
||||
.. literalinclude:: examples/amphora-show-stats-response.json
|
||||
:language: javascript
|
||||
|
||||
Failover Amphora
|
||||
================
|
||||
|
||||
|
1
api-ref/source/v2/examples/amphora-show-stats-curl
Normal file
1
api-ref/source/v2/examples/amphora-show-stats-curl
Normal file
@ -0,0 +1 @@
|
||||
curl -X GET -H "X-Auth-Token: <token>" http://198.51.100.10:9876/v2/octavia/amphorae/63d8349e-c4d7-4156-bc94-29260607b04f/stats
|
24
api-ref/source/v2/examples/amphora-show-stats-response.json
Normal file
24
api-ref/source/v2/examples/amphora-show-stats-response.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"amphora_stats": [
|
||||
{
|
||||
"active_connections": 48629,
|
||||
"bytes_in": 65671420,
|
||||
"bytes_out": 774771186,
|
||||
"id": "63d8349e-c4d7-4156-bc94-29260607b04f",
|
||||
"listener_id": "bbe44114-cda2-4fe0-b192-d9e24ce661db",
|
||||
"loadbalancer_id": "65b5a7c3-1437-4909-84cf-cec9f7e371ea",
|
||||
"request_errors": 0,
|
||||
"total_connections": 26189172
|
||||
},
|
||||
{
|
||||
"active_connections": 0,
|
||||
"bytes_in": 5,
|
||||
"bytes_out": 100,
|
||||
"id": "63d8349e-c4d7-4156-bc94-29260607b04f",
|
||||
"listener_id": "af45a658-4eeb-4ce9-8b7e-16b0e5676f87",
|
||||
"loadbalancer_id": "65b5a7c3-1437-4909-84cf-cec9f7e371ea",
|
||||
"request_errors": 0,
|
||||
"total_connections": 1
|
||||
}
|
||||
]
|
||||
}
|
@ -77,6 +77,8 @@ class RootController(rest.RestController):
|
||||
'2018-04-20T00:00:00Z', host_url)
|
||||
self._add_a_version(versions, 'v2.2', 'v2', 'SUPPORTED',
|
||||
'2018-07-31T00:00:00Z', host_url)
|
||||
self._add_a_version(versions, 'v2.3', 'v2', 'CURRENT',
|
||||
self._add_a_version(versions, 'v2.3', 'v2', 'SUPPORTED',
|
||||
'2018-12-18T00:00:00Z', host_url)
|
||||
self._add_a_version(versions, 'v2.4', 'v2', 'CURRENT',
|
||||
'2018-12-19T00:00:00Z', host_url)
|
||||
return {'versions': versions}
|
||||
|
@ -24,6 +24,7 @@ from wsmeext import pecan as wsme_pecan
|
||||
from octavia.api.v2.controllers import base
|
||||
from octavia.api.v2.types import amphora as amp_types
|
||||
from octavia.common import constants
|
||||
from octavia.common import exceptions
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
@ -84,6 +85,8 @@ class AmphoraController(base.BaseController):
|
||||
remainder = remainder[1:]
|
||||
if controller == 'failover':
|
||||
return FailoverController(amp_id=amphora_id), remainder
|
||||
if controller == 'stats':
|
||||
return AmphoraStatsController(amp_id=amphora_id), remainder
|
||||
return None
|
||||
|
||||
|
||||
@ -131,3 +134,30 @@ class FailoverController(base.BaseController):
|
||||
self.repositories.load_balancer.update(
|
||||
context.session, db_amp.load_balancer.id,
|
||||
provisioning_status=constants.ERROR)
|
||||
|
||||
|
||||
class AmphoraStatsController(base.BaseController):
|
||||
RBAC_TYPE = constants.RBAC_AMPHORA
|
||||
|
||||
def __init__(self, amp_id):
|
||||
super(AmphoraStatsController, self).__init__()
|
||||
self.amp_id = amp_id
|
||||
|
||||
@wsme_pecan.wsexpose(amp_types.StatisticsRootResponse, wtypes.text,
|
||||
status_code=200)
|
||||
def get(self):
|
||||
context = pecan.request.context.get('octavia_context')
|
||||
|
||||
self._auth_validate_action(context, context.project_id,
|
||||
constants.RBAC_GET_STATS)
|
||||
|
||||
stats = self.repositories.get_amphora_stats(context.session,
|
||||
self.amp_id)
|
||||
if stats == []:
|
||||
raise exceptions.NotFound(resource='Amphora stats for',
|
||||
id=self.amp_id)
|
||||
|
||||
wsme_stats = []
|
||||
for stat in stats:
|
||||
wsme_stats.append(amp_types.AmphoraStatisticsResponse(**stat))
|
||||
return amp_types.StatisticsRootResponse(amphora_stats=wsme_stats)
|
||||
|
@ -60,3 +60,19 @@ class AmphoraRootResponse(types.BaseType):
|
||||
class AmphoraeRootResponse(types.BaseType):
|
||||
amphorae = wtypes.wsattr([AmphoraResponse])
|
||||
amphorae_links = wtypes.wsattr([types.PageType])
|
||||
|
||||
|
||||
class AmphoraStatisticsResponse(BaseAmphoraType):
|
||||
"""Defines which attributes are to show on stats response."""
|
||||
active_connections = wtypes.wsattr(wtypes.IntegerType())
|
||||
bytes_in = wtypes.wsattr(wtypes.IntegerType())
|
||||
bytes_out = wtypes.wsattr(wtypes.IntegerType())
|
||||
id = wtypes.wsattr(wtypes.UuidType())
|
||||
listener_id = wtypes.wsattr(wtypes.UuidType())
|
||||
loadbalancer_id = wtypes.wsattr(wtypes.UuidType())
|
||||
request_errors = wtypes.wsattr(wtypes.IntegerType())
|
||||
total_connections = wtypes.wsattr(wtypes.IntegerType())
|
||||
|
||||
|
||||
class StatisticsRootResponse(types.BaseType):
|
||||
amphora_stats = wtypes.wsattr([AmphoraStatisticsResponse])
|
||||
|
@ -258,6 +258,11 @@ REQ_CONN_TIMEOUT = 'req_conn_timeout'
|
||||
REQ_READ_TIMEOUT = 'req_read_timeout'
|
||||
CONN_MAX_RETRIES = 'conn_max_retries'
|
||||
CONN_RETRY_INTERVAL = 'conn_retry_interval'
|
||||
ACTIVE_CONNECTIONS = 'active_connections'
|
||||
BYTES_IN = 'bytes_in'
|
||||
BYTES_OUT = 'bytes_out'
|
||||
REQUEST_ERRORS = 'request_errors'
|
||||
TOTAL_CONNECTIONS = 'total_connections'
|
||||
|
||||
CERT_ROTATE_AMPHORA_FLOW = 'octavia-cert-rotate-amphora-flow'
|
||||
CREATE_AMPHORA_FLOW = 'octavia-create-amphora-flow'
|
||||
|
@ -677,6 +677,34 @@ class Repositories(object):
|
||||
session.expire_all()
|
||||
return self.load_balancer.get(session, id=lb_dm.id)
|
||||
|
||||
def get_amphora_stats(self, session, amp_id):
|
||||
"""Gets the statistics for all listeners on an amphora.
|
||||
|
||||
:param session: A Sql Alchemy database session.
|
||||
:param amp_id: The amphora ID to query.
|
||||
:returns: An amphora stats dictionary
|
||||
"""
|
||||
with session.begin(subtransactions=True):
|
||||
columns = (models.ListenerStatistics.__table__.columns +
|
||||
[models.Amphora.load_balancer_id])
|
||||
amp_records = (
|
||||
session.query(*columns)
|
||||
.filter(models.ListenerStatistics.amphora_id == amp_id)
|
||||
.filter(models.ListenerStatistics.amphora_id ==
|
||||
models.Amphora.id).all())
|
||||
amp_stats = []
|
||||
for amp in amp_records:
|
||||
amp_stat = {consts.LOADBALANCER_ID: amp.load_balancer_id,
|
||||
consts.LISTENER_ID: amp.listener_id,
|
||||
'id': amp.amphora_id,
|
||||
consts.ACTIVE_CONNECTIONS: amp.active_connections,
|
||||
consts.BYTES_IN: amp.bytes_in,
|
||||
consts.BYTES_OUT: amp.bytes_out,
|
||||
consts.REQUEST_ERRORS: amp.request_errors,
|
||||
consts.TOTAL_CONNECTIONS: amp.total_connections}
|
||||
amp_stats.append(amp_stat)
|
||||
return amp_stats
|
||||
|
||||
|
||||
class LoadBalancerRepository(BaseRepository):
|
||||
model_class = models.LoadBalancer
|
||||
|
@ -38,6 +38,13 @@ rules = [
|
||||
[{'method': 'PUT',
|
||||
'path': '/v2/octavia/amphorae/{amphora_id}/failover'}]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
'{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AMPHORA,
|
||||
action=constants.RBAC_GET_STATS),
|
||||
constants.RULE_API_ADMIN,
|
||||
"Show Amphora statistics",
|
||||
[{'method': 'GET', 'path': '/v2/octavia/amphorae/{amphora_id}/stats'}]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
@ -46,12 +46,13 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
|
||||
versions = self._get_versions_with_config(
|
||||
api_v1_enabled=True, api_v2_enabled=True)
|
||||
version_ids = tuple(v.get('id') for v in versions)
|
||||
self.assertEqual(5, len(version_ids))
|
||||
self.assertEqual(6, len(version_ids))
|
||||
self.assertIn('v1', version_ids)
|
||||
self.assertIn('v2.0', version_ids)
|
||||
self.assertIn('v2.1', version_ids)
|
||||
self.assertIn('v2.2', version_ids)
|
||||
self.assertIn('v2.3', version_ids)
|
||||
self.assertIn('v2.4', version_ids)
|
||||
|
||||
# Each version should have a 'self' 'href' to the API version URL
|
||||
# [{u'rel': u'self', u'href': u'http://localhost/v2'}]
|
||||
@ -71,11 +72,12 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
|
||||
def test_api_v1_disabled(self):
|
||||
versions = self._get_versions_with_config(
|
||||
api_v1_enabled=False, api_v2_enabled=True)
|
||||
self.assertEqual(4, len(versions))
|
||||
self.assertEqual(5, len(versions))
|
||||
self.assertEqual('v2.0', versions[0].get('id'))
|
||||
self.assertEqual('v2.1', versions[1].get('id'))
|
||||
self.assertEqual('v2.2', versions[2].get('id'))
|
||||
self.assertEqual('v2.3', versions[3].get('id'))
|
||||
self.assertEqual('v2.4', versions[4].get('id'))
|
||||
|
||||
def test_api_v2_disabled(self):
|
||||
versions = self._get_versions_with_config(
|
||||
|
@ -68,6 +68,7 @@ class BaseAPITest(base_db_test.OctaviaDBTestBase):
|
||||
AMPHORAE_PATH = '/octavia/amphorae'
|
||||
AMPHORA_PATH = AMPHORAE_PATH + '/{amphora_id}'
|
||||
AMPHORA_FAILOVER_PATH = AMPHORA_PATH + '/failover'
|
||||
AMPHORA_STATS_PATH = AMPHORA_PATH + '/stats'
|
||||
|
||||
PROVIDERS_PATH = '/lbaas/providers'
|
||||
|
||||
|
@ -29,6 +29,7 @@ class TestAmphora(base.BaseAPITest):
|
||||
root_tag = 'amphora'
|
||||
root_tag_list = 'amphorae'
|
||||
root_tag_links = 'amphorae_links'
|
||||
root_tag_stats = 'amphora_stats'
|
||||
|
||||
def setUp(self):
|
||||
super(TestAmphora, self).setUp()
|
||||
@ -61,6 +62,34 @@ class TestAmphora(base.BaseAPITest):
|
||||
self.amp = self.amphora_repo.create(self.session, **self.amp_args)
|
||||
self.amp_id = self.amp.id
|
||||
self.amp_args['id'] = self.amp_id
|
||||
self.listener1_id = uuidutils.generate_uuid()
|
||||
self.create_listener_stats_dynamic(self.listener1_id, self.amp_id,
|
||||
bytes_in=1, bytes_out=2,
|
||||
active_connections=3,
|
||||
total_connections=4,
|
||||
request_errors=5)
|
||||
self.listener2_id = uuidutils.generate_uuid()
|
||||
self.create_listener_stats_dynamic(self.listener2_id, self.amp_id,
|
||||
bytes_in=6, bytes_out=7,
|
||||
active_connections=8,
|
||||
total_connections=9,
|
||||
request_errors=10)
|
||||
self.listener1_amp_stats = {'active_connections': 3,
|
||||
'bytes_in': 1, 'bytes_out': 2,
|
||||
'id': self.amp_id,
|
||||
'listener_id': self.listener1_id,
|
||||
'loadbalancer_id': self.lb_id,
|
||||
'request_errors': 5,
|
||||
'total_connections': 4}
|
||||
self.listener2_amp_stats = {'active_connections': 8,
|
||||
'bytes_in': 6, 'bytes_out': 7,
|
||||
'id': self.amp_id,
|
||||
'listener_id': self.listener2_id,
|
||||
'loadbalancer_id': self.lb_id,
|
||||
'request_errors': 10,
|
||||
'total_connections': 9}
|
||||
self.ref_amp_stats = [self.listener1_amp_stats,
|
||||
self.listener2_amp_stats]
|
||||
|
||||
def _create_additional_amp(self):
|
||||
amp_args = {
|
||||
@ -398,3 +427,77 @@ class TestAmphora(base.BaseAPITest):
|
||||
response = self.get(self.AMPHORAE_PATH).json.get(self.root_tag_list)
|
||||
self.assertIsInstance(response, list)
|
||||
self.assertEqual(0, len(response))
|
||||
|
||||
def test_get_stats_authorized(self):
|
||||
self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
|
||||
auth_strategy = self.conf.conf.api_settings.get('auth_strategy')
|
||||
self.conf.config(group='api_settings', auth_strategy=constants.TESTING)
|
||||
with mock.patch.object(octavia.common.context.Context, 'project_id',
|
||||
self.project_id):
|
||||
override_credentials = {
|
||||
'service_user_id': None,
|
||||
'user_domain_id': None,
|
||||
'is_admin_project': True,
|
||||
'service_project_domain_id': None,
|
||||
'service_project_id': None,
|
||||
'roles': ['load-balancer_member'],
|
||||
'user_id': None,
|
||||
'is_admin': True,
|
||||
'service_user_domain_id': None,
|
||||
'project_domain_id': None,
|
||||
'service_roles': [],
|
||||
'project_id': self.project_id}
|
||||
with mock.patch(
|
||||
"oslo_context.context.RequestContext.to_policy_values",
|
||||
return_value=override_credentials):
|
||||
response = self.get(self.AMPHORA_STATS_PATH.format(
|
||||
amphora_id=self.amp_id)).json.get(self.root_tag_stats)
|
||||
# Reset api auth setting
|
||||
self.conf.config(group='api_settings', auth_strategy=auth_strategy)
|
||||
self.assertEqual(self.ref_amp_stats, response)
|
||||
|
||||
def test_get_stats_not_authorized(self):
|
||||
self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
|
||||
auth_strategy = self.conf.conf.api_settings.get('auth_strategy')
|
||||
self.conf.config(group='api_settings', auth_strategy=constants.TESTING)
|
||||
with mock.patch.object(octavia.common.context.Context, 'project_id',
|
||||
uuidutils.generate_uuid()):
|
||||
response = self.get(self.AMPHORA_STATS_PATH.format(
|
||||
amphora_id=self.amp_id), status=403)
|
||||
# Reset api auth setting
|
||||
self.conf.config(group='api_settings', auth_strategy=auth_strategy)
|
||||
self.assertEqual(self.NOT_AUTHORIZED_BODY, response.json)
|
||||
|
||||
def test_get_stats_bad_amp_id(self):
|
||||
self.get(self.AMPHORA_STATS_PATH.format(
|
||||
amphora_id='bogus_id'), status=404)
|
||||
|
||||
def test_get_stats_no_listeners(self):
|
||||
self.lb2 = self.create_load_balancer(
|
||||
uuidutils.generate_uuid()).get('loadbalancer')
|
||||
self.lb2_id = self.lb2.get('id')
|
||||
self.set_lb_status(self.lb2_id)
|
||||
self.amp2_args = {
|
||||
'load_balancer_id': self.lb2_id,
|
||||
'compute_id': uuidutils.generate_uuid(),
|
||||
'lb_network_ip': '192.168.1.20',
|
||||
'vrrp_ip': '192.168.1.5',
|
||||
'ha_ip': '192.168.1.100',
|
||||
'vrrp_port_id': uuidutils.generate_uuid(),
|
||||
'ha_port_id': uuidutils.generate_uuid(),
|
||||
'cert_expiration': datetime.datetime.now(),
|
||||
'cert_busy': False,
|
||||
'role': constants.ROLE_STANDALONE,
|
||||
'status': constants.AMPHORA_ALLOCATED,
|
||||
'vrrp_interface': 'eth1',
|
||||
'vrrp_id': 1,
|
||||
'vrrp_priority': 100,
|
||||
'cached_zone': None,
|
||||
'created_at': datetime.datetime.now(),
|
||||
'updated_at': datetime.datetime.now(),
|
||||
'image_id': uuidutils.generate_uuid(),
|
||||
}
|
||||
self.amp2 = self.amphora_repo.create(self.session, **self.amp2_args)
|
||||
self.amp2_id = self.amp2.id
|
||||
self.get(self.AMPHORA_STATS_PATH.format(
|
||||
amphora_id=self.amp2_id), status=404)
|
||||
|
@ -82,6 +82,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
|
||||
|
||||
FAKE_UUID_1 = uuidutils.generate_uuid()
|
||||
FAKE_UUID_2 = uuidutils.generate_uuid()
|
||||
FAKE_UUID_3 = uuidutils.generate_uuid()
|
||||
FAKE_IP = '192.0.2.44'
|
||||
|
||||
def setUp(self):
|
||||
super(AllRepositoriesTest, self).setUp()
|
||||
@ -96,6 +98,11 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
|
||||
enabled=True, provisioning_status=constants.ACTIVE,
|
||||
operating_status=constants.ONLINE,
|
||||
load_balancer_id=self.load_balancer.id)
|
||||
self.amphora = self.repos.amphora.create(
|
||||
self.session, id=uuidutils.generate_uuid(),
|
||||
load_balancer_id=self.load_balancer.id,
|
||||
compute_id=self.FAKE_UUID_3, status=constants.ACTIVE,
|
||||
vrrp_ip=self.FAKE_IP, lb_network_ip=self.FAKE_IP)
|
||||
|
||||
def test_all_repos_has_correct_repos(self):
|
||||
repo_attr_names = ('load_balancer', 'vip', 'health_monitor',
|
||||
@ -1837,6 +1844,40 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
|
||||
self.session, project_id=project_id).in_use_member)
|
||||
conf.config(group='api_settings', auth_strategy=constants.TESTING)
|
||||
|
||||
def test_get_amphora_stats(self):
|
||||
listener2_id = uuidutils.generate_uuid()
|
||||
self.repos.listener_stats.create(
|
||||
self.session, listener_id=self.listener.id,
|
||||
amphora_id=self.amphora.id, bytes_in=1, bytes_out=2,
|
||||
active_connections=3, total_connections=4, request_errors=5)
|
||||
self.repos.listener_stats.create(
|
||||
self.session, listener_id=listener2_id,
|
||||
amphora_id=self.amphora.id, bytes_in=6, bytes_out=7,
|
||||
active_connections=8, total_connections=9, request_errors=10)
|
||||
amp_stats = self.repos.get_amphora_stats(self.session, self.amphora.id)
|
||||
self.assertEqual(2, len(amp_stats))
|
||||
for stats in amp_stats:
|
||||
if stats['listener_id'] == self.listener.id:
|
||||
self.assertEqual(self.load_balancer.id,
|
||||
stats['loadbalancer_id'])
|
||||
self.assertEqual(self.listener.id, stats['listener_id'])
|
||||
self.assertEqual(self.amphora.id, stats['id'])
|
||||
self.assertEqual(1, stats['bytes_in'])
|
||||
self.assertEqual(2, stats['bytes_out'])
|
||||
self.assertEqual(3, stats['active_connections'])
|
||||
self.assertEqual(4, stats['total_connections'])
|
||||
self.assertEqual(5, stats['request_errors'])
|
||||
else:
|
||||
self.assertEqual(self.load_balancer.id,
|
||||
stats['loadbalancer_id'])
|
||||
self.assertEqual(listener2_id, stats['listener_id'])
|
||||
self.assertEqual(self.amphora.id, stats['id'])
|
||||
self.assertEqual(6, stats['bytes_in'])
|
||||
self.assertEqual(7, stats['bytes_out'])
|
||||
self.assertEqual(8, stats['active_connections'])
|
||||
self.assertEqual(9, stats['total_connections'])
|
||||
self.assertEqual(10, stats['request_errors'])
|
||||
|
||||
|
||||
class PoolRepositoryTest(BaseRepositoryTest):
|
||||
|
||||
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Adds an administrator API to access per-amphora statistics.
|
Loading…
Reference in New Issue
Block a user