注意
本文档适用于 Ceph 开发版本。
Ceph Dashboard 开发者文档
功能设计
为了促进新 Ceph Dashboard 功能的合作,第一步是定义设计文档。这些文档然后成为实现范围的基础,并允许更广泛地参与 Ceph Dashboard UI 的发展。
设计文档:
初步步骤
以下文档章节期望一个正在运行的 Ceph 集群和至少一个正在运行的dashboard
管理器模块(有少数例外)。本章介绍了如何设置此类系统进行开发,而无需设置一个完整的生产环境。本章中介绍的所有选项都是基于一个所谓的vstart
环境。
Note
每个vstart
环境需要 Ceph从其 GitHub from its GitHub
repository, though Docker environments simplify that step by providing a
shell script that contains those instructions.
本规则的例外是build-freeceph-devceph-dev的
vstart
“vstart” 实际上是 Cephsrc/
存储库目录 (src/vstart.sh
) 中的一个 shell 脚本。它用于在执行它的机器上启动一个单节点 Ceph 集群。当它用于启动 Ceph 集群时,会自动启动几个必需的和一些可选的 Ceph 内部服务。vstart 是 Ceph Dashboard 中三个最常用的开发环境的基础。
基于主机与基于 Docker 的开发环境
本文档介绍了三种不同的开发环境,它们都基于 vstart。这些是:
在您的主机系统上运行的 vstart
在 Docker 环境中运行的 vstart
除了它们独立的开发分支和有时略有不同的方法之外,它们在底层操作系统方面也有所不同。
发布
ceph-dev-docker
ceph-dev
模拟
openSUSE Leap 15
CentOS 7
Nautilus
openSUSE Leap 15
CentOS 7
Octopus
openSUSE Leap 15.2
CentOS 8
主
openSUSE Tumbleweed
CentOS 8
Note
无论您选择哪个环境,您都需要在该环境中编译 Ceph。如果您在主机系统上编译了 Ceph,那么为了能够切换到基于 Docker 的解决方案,您需要在 Docker 上重新编译它。反之亦然。如果您之前使用 Docker 开发环境并在那里编译了 Ceph,而现在您想要切换到主机系统,您也需要重新编译 Ceph(或使用另一个单独的存储库进行编译)。
ceph-dev是此规则的一个例外,因为它提供的一个选项是build-free。这是通过使用 RPM 系统软件包进行的 Ceph 安装来实现的。您仍然可以使用像往常一样使用的本地 GitHub 存储库进行工作。
主机系统上的开发环境
无需学习或具有 Docker 的经验,立即开始。
支持自动化(如 Ceph 编译)的脚本数量有限。
没有预配置的易于启动的服务(Prometheus、Grafana 等)。
支持的主机操作系统数量有限,具体取决于预期的 Ceph 版本。
依赖项需要在您的主机上安装。
您可能会发现自己需要升级主机操作系统(例如,由于 GCC 版本的变化)。
基于 Docker 的开发环境
如果您还不熟悉 Docker,那么学习 Docker 可能会有一些开销。
两个 Docker 项目都为您提供了一些脚本来帮助您开始并自动化重复的任务。
两个 Docker 环境都带有部分预配置的外部服务,这些服务可用于附加到或补充 Ceph Dashboard 功能,例如
Prometheus
Grafana
Node-Exporter
Shibboleth
HAProxy
它独立于您在主机上使用的操作系统。
在主机系统上运行 vstart
vstart 脚本通常从您的build/目录调用,如下所示:
../src/vstart.sh -n -d
在这种情况下-n
确保创建一个新的 vstart 集群,并且不会重用可能之前创建的集群。-d
启用日志文件中的调试消息。有几种其他选项可供选择。您可以使用--help
参数中指定不同的百分比。
获取列表。vstart 输出的末尾应该有关仪表板及其 URL 的信息:
vstart cluster complete. Use stop.sh to stop. See out/* (e.g. 'tail -f out/????') for debug output.
dashboard urls: https://192.168.178.84:41259, https://192.168.178.84:43259, https://192.168.178.84:45259
w/ user/pass: admin / admin
在开发过程中(尤其是在后端开发中),您还希望偶尔检查仪表板管理器模块是否仍在运行。为此,您可以调用./bin/ceph mgr services手动。它将列出所有成功启用服务的 URL。仅列出可通过 HTTP(S) 可用的服务的 URL。Ceph Dashboard 是其中之一。它应该类似于以下输出:
$ ./bin/ceph mgr services
{
"dashboard": "https://home:41931/"
}
默认情况下,此环境使用随机选择的端口用于 Ceph Dashboard,您需要使用此命令来找出它变成了哪个端口。
Docker
Docker 开发环境通常附带许多有用的脚本。ceph-dev-docker
例如start-ceph.sh,它清理日志文件,始终启动一个 Rados Gateway 服务,设置一些 Ceph Dashboard 配置选项,并在启动您的 vstart 集群之前或之后自动运行前端代理。
如何使用这些环境的说明包含在其各自的存储库 README 文件中。
前端开发
在您可以在开发环境中从仪表板启动之前,您需要生成前端代码,并使用一个编译好的正在运行的 Ceph 集群(例如,由vstart.sh
启动)或独立的开发 Web 服务器。
构建过程基于Node.js并需要Node Package Manager npm
已安装。
前提条件
Node 20.13.1 或更高版本
NPM 10.5.2 或更高版本
- nodeenv:
在 Ceph 的构建过程中,我们创建了一个带有
node
和npm
的虚拟环境,该虚拟环境可以用作在您的系统上安装 node/npm 的替代方案。如果您想使用虚拟环境中安装的 node,则在运行任何 npm 命令之前需要激活虚拟环境。要激活它,请运行
. build/src/pybind/mgr/dashboard/node-env/bin/activate
.一旦完成,您只需运行
deactivate
并退出虚拟环境。- Angular CLI:
如果您没有全局安装Angular CLI,则您需要执行
ng
命令,并在它之前添加npm run
。
软件包安装
在你克隆仓库的目录中运行npm ci
在目录src/pybind/mgr/dashboard/frontend
目录中安装所需的软件包。
添加或更新软件包
运行以下命令以添加/更新软件包:
npm install <PACKAGE_NAME>
npm ci
设置开发服务器
创建proxy.conf.json
基于proxy.conf.json.sample
.
在你克隆仓库的目录中运行npm start
的 dev 服务器文件。导航到http://localhost:4200/
。如果您更改任何源文件,应用程序将自动重新加载。
代码脚手架
在你克隆仓库的目录中运行ng generate component component-name
生成一个新组件。您也可以使用ng generate directive|pipe|service|class|guard|interface|enum|module
.
构建项目
在你克隆仓库的目录中运行npm run build
构建项目。构建产物将存储在dist/
目录中。使用--prod
标志进行生产构建 (npm run build -- --prod
)。导航到https://localhost:8443
.
构建代码文档
在你克隆仓库的目录中运行npm run doc-build
生成代码文档documentation/
目录中的代码文档。要使它们在本地对 Web 浏览器可用,请运行npm run doc-serve
,它们将在http://localhost:8444
中可用。npm run compodoc -- <opts>
你可以完全配置它.
代码检查和格式化
我们使用以下工具来检查和格式化所有我们的 TS、SCSS 和 HTML 文件中的代码:
我们添加了 2 个 npm 脚本来帮助运行这些工具:
npm run lint
,将前端文件与所有检查器进行比对npm run fix
,将尝试修复所有检测到的检查错误
Ceph Dashboard 和 Bootstrap
目前我们在 Ceph Dashboard 中使用 Bootstrap 作为 CSS 框架。这意味着我们的大部分 SCSS 和 HTML 代码都可以利用 Bootstrap 提供的所有实用程序和其他优势。在过去,我们经常使用自己的自定义样式,这导致了越来越多的单个使用和双重定义的变量,有时会忘记删除它们,或者由于人们忘记更改颜色或调整自定义 SCSS 类而导致样式不一致。
要获取 Ceph 中使用的当前 Bootstrap 版本,请参阅package.json
并搜索:
bootstrap
: 用于 Bootstrap 版本。@ng-bootstrap
: 用于我们使用的 Angular 绑定版本。
所以对于未来,当您访问组件时,请执行以下操作:
这个 HTML/SCSS 代码使用自定义代码吗? - 如果是:它需要吗? --> 在更改您想要修复或更改的内容之前,先清理它。
如果您正在创建一个新组件:请尽可能多地使用 Bootstrap!不要试图重新发明轮子。
如果可能,请查找 Bootstrap 是否有关于如何正确扩展它的指南来实现您想要实现的目标。
我们代码越像 Bootstrap,就越容易定制、维护,并且出现的错误越少。此外,由于 Bootstrap 是一个试图考虑可用性和用户体验的框架,我们极大地提高了这两点。所有这些的最大好处是,我们维护的代码更少,这使得它对初学者更容易阅读,对已经熟悉代码的人来说更容易。
编写单元测试
为了最高效地编写单元测试,我们有一小组工具,我们使用它们在测试套件中。
这些工具可以在src/pybind/mgr/dashboard/frontend/src/testing/
下找到,特别是查看unit-test-helper.ts
.
您将能够在那里找到:
configureTestBed
替换初始TestBed
方法。它接受与TestBed.configureTestingModule
相同的参数。true
作为第二个参数传递。
PermissionHelper
帮助确定基于当前权限和列表中的选择是否显示正确的操作。
FormHelper
使表单测试变得非常简单
运行单元测试
在你克隆仓库的目录中运行npm run test
通过Jest.
如果您在所有测试中遇到错误,那可能是因为Jest或其他东西已更新。您可以尝试几种方法来解决此问题:
删除所有带有
rm -rf dist node_modules
的模块并再次运行npm install
以重新安装它们通过运行
npx jest --clearCache
运行端到端 (E2E) 测试
我们使用Cypress来运行我们的前端 E2E 测试。
E2E 前提条件
您需要事先构建前端。
在某些环境中,根据您的用户权限和 CYPRESS_CACHE_FOLDER,您可能需要运行npm ci
使用--unsafe-perm
标志指示 cephadm 移除主机以及 CRUSH 桶。
您可能需要安装额外的软件包才能运行 Cypress。请运行npx cypress verify
以验证它。
run-frontend-e2e-tests.sh
我们的run-frontend-e2e-tests.sh
脚本是当您希望进行全面的 E2E 运行时的首选解决方案。
使用以下命令启动所有前端 E2E 测试:
$ cd src/pybind/mgr/dashboard
$ ./run-frontend-e2e-tests.sh
- 报告:
您可以在终端上跟随 E2E 报告,并且您可以在打开以下目录找到失败测试用例的屏幕截图:
src/pybind/mgr/dashboard/frontend/cypress/screenshots/
- 设备:
您可以强制脚本使用特定设备
-d
标志:$ ./run-frontend-e2e-tests.sh -d <chrome|chromium|electron|docker>
- 远程:
默认情况下,此脚本将停止并启动一个新的 vstart 集群。
-r
和,可选地,凭证-u
,-p
):$ ./run-frontend-e2e-tests.sh -r <DASHBOARD_URL> -u <E2E_LOGIN_USER> -p <E2E_LOGIN_PWD>
- 注意:
当使用 docker 作为您的设备时,您可能需要以 sudo 权限运行脚本。
run-cephadm-e2e-tests.sh
run-cephadm-e2e-tests.sh
运行 E2E 测试的子集以验证 Dashboard 和 cephadm 作为
前提条件:您需要在您的本地机器上安装KCLI和 Node.js。
配置 KCLI 计划要求:
$ sudo chown -R $(id -un) /var/lib/libvirt/images
$ mkdir -p /var/lib/libvirt/images/ceph-dashboard
$ kcli create pool -p /var/lib/libvirt/images/ceph-dashboard ceph-dashboard
$ kcli create network -c 192.168.100.0/24 ceph-dashboard
- 注意:
此脚本旨在作为 jenkins 作业运行,因此清理仅在 jenkins 环境中触发。在本地,用户将在需要时关闭集群(例如,在调试后)。
通过运行以下命令启动 E2E 测试:
$ cd <your/ceph/repo/dir>
$ sudo chown -R $(id -un) src/pybind/mgr/dashboard/frontend/{dist,node_modules,src/environments}
$ ./src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh
- 注意:
在 fedora 35 中,当尝试挂载 shared_folders 时可能会出现权限错误。这可以通过运行
$ sudo setfacl -R -m u:qemu:rwx <abs-path-to-your-user-home>
或通过为您的 $HOME 目录设置适当的权限来修复。
您也可以通过运行以下命令启动开发模式的集群(因此前端构建以监视模式启动,并且您只需重新加载页面即可反映更改):
$ ./src/pybind/mgr/dashboard/ci/cephadm/start-cluster.sh --dev-mode
- 注意:
将
--expanded
如果您需要一个集群来准备部署服务(一个具有足够监控
通过运行以下命令测试您的更改:
$ ./src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh
通过运行以下命令关闭集群:
$ kcli delete plan -y ceph
其他运行选项
在积极开发期间,不建议运行前面的脚本,因为它没有准备好应对不断的文件更改。
npm run e2e
- 这将运行ng serve
并打开 Cypress 测试运行器。npm run e2e:ci
- 这将运行ng serve
并运行 Cypress 测试运行器一次。npx cypress run
- 这将直接调用 cypress 并运行 Cypress 测试运行器。npx cypress open
- 这将直接调用 cypress 并打开 Cypress 测试运行器。
直接调用 Cypress 的优点是您可以使用任何可用的flags来定制您的测试运行,并且您不需要每次都启动前端服务器。
使用open
命令,将打开一个 cypress 应用程序,您可以在其中看到所有您拥有的测试文件并单独运行每个测试。
默认情况下 Cypress 将在https://localhost:4200/
搜索网页。CYPRESS_BASE_URL=https://localhost:41076/ npx cypress open
CYPRESS_CACHE_FOLDER
当通过 npm 安装 cypress 时,cypress 应用程序的二进制文件也将被下载并存储在缓存文件夹中。npm ci
或甚至在使用 cypress 的单独项目中使用它时下载它的需要。
默认情况下 Cypress 使用 ~/.cache 存储二进制文件。/ceph/build/src/pybind/mgr/dashboard/cypress
,因此当您构建 ceph 或运行run-frontend-e2e-tests.sh
时,这是 Cypress 将使用的目录。
当使用任何其他命令来安装或运行 cypress 时,
编写端到端测试
PagerHelper 类
The PageHelper
类用于通用代码,可以在各种页面或套件中使用。
示例是
navigateTo()
- 导航到特定页面并等待它加载getFirstTableCell()
- 返回第一个表格单元。您也可以传递一个包含所需内容的字符串,它将返回包含它的第一个单元。getTabsCount()
- 返回标签的数量
每个可能在多个页面上有用的方法都属于这里。此外,增强 PageHelper 派生类的方法也属于这里。一个很好的例子是restrictTo()
装饰器。它确保在 PageHelper 的子类中实现的方法在正确的页面上被调用。如果情况不是这样,它还会显示一个对开发人员友好的警告。
PageHelper 的子类
辅助方法
为了使特定于某个套件的代码可重用,请确保将其放在PageHelper
的派生类中。例如,当谈论池套件时,这样的方法将是create()
, exist()
和delete()
。这些方法特定于池,但对其他套件很有用。
返回只能在任何特定页面上找到的 HTML 元素的方法,应该要么在 PageHelper 的子类辅助方法中实现,要么作为 PageHelper 的子类自己的方法实现。
使用 PageHelper
在任何套件中,都应该实例化特定Helper
类的实例并直接调用它。
const pools = new PoolPageHelper();
it('should create a pool', () => {
pools.exist(poolName, false);
pools.navigateTo('create');
pools.create(poolName, 8);
pools.exist(poolName, true);
});
代码风格
请参阅官方Cypress 核心概念以更好地了解如何编写和结构测试。
describe()
vsit()
双describe()
和it()
是功能块,这意味着任何对测试必要的可执行代码都可以包含在任一块中。describe
中声明的变量都可用于其内部的it()
块。
describe()
通常用于测试的容器,允许您将测试分解成多个部分。同样,任何在您的测试运行之前必须进行的设置都可以在describe()
块中初始化。以下是一个示例:
describe('create, edit & delete image test', () => {
const poolName = 'e2e_images_pool';
before(() => {
cy.login();
pools.navigateTo('create');
pools.create(poolName, 8, 'rbd');
pools.exist(poolName, true);
});
beforeEach(() => {
cy.login();
images.navigateTo();
});
//...
});
如上所示,我们可以初始化变量poolName
以及在我们的测试套件开始之前运行命令(创建池)。describe()
块消息应包括测试套件的名称。
it()
块通常是一个涵盖测试的整体部分的组成部分。它们包含测试套件的功能,每个部分执行单独的角色。
describe('create, edit & delete image test', () => {
//...
it('should create image', () => {
images.createImage(imageName, poolName, '1');
images.getFirstTableCell(imageName).should('exist');
});
it('should edit image', () => {
images.editImage(imageName, poolName, newImageName, '2');
images.getFirstTableCell(newImageName).should('exist');
});
//...
});
如前面的示例所示,我们的describe()
测试套件是创建、编辑和删除图像。因此,每个it()
完成这些步骤中的一个,一个用于创建,一个用于编辑,等等。同样,每个it()
块消息应该用小写写,并且应该写得尽可能长,以便“it”可以是消息的前缀。例如,it('edits the test image' () => ...)
vs.it('image edit test' () => ...)
. 如上所示,第一个示例在it()
作为前缀时具有语法意义,而第二个消息则没有。it()
应该描述单个测试正在做什么以及它期望发生。
可视回归测试
对于可视回归测试,我们使用Applitools Eyes一个由人工智能驱动的自动化可视回归测试工具。ceph/src/pybind/mgr/dashboard/frontend/cypress/integration/visualTests
和<component-name>.vrt-spec.ts
.
在本地运行可视回归测试
要在本地运行测试,您需要 Applitools API 密钥,如果您还没有,可以注册一个免费帐户。获得 API 密钥后,将其作为环境变量导出:APPLITOOLS_API_KEY
.
现在您可以使用正常的 cypress E2E 测试运行测试,使用npx cypress open
或以无头模式通过运行npx cypress run
.
捕获屏幕截图
基准屏幕截图是与检查点屏幕截图(或您的功能分支中的屏幕截图)进行比较的屏幕截图。
要捕获基准屏幕截图,您可以运行针对主分支的测试,然后切换到您的功能分支并再次运行测试以捕获检查点屏幕截图。
现在要查看您的屏幕截图,登录到 applitools.com,在着陆页上您将看到 applitools eyes 测试运行器,您可以在其中看到所有您的屏幕截图。如果您的基准屏幕截图和检查点屏幕截图之间存在任何视觉回归或差异(diff),它们将使用掩码突出显示差异。
编写更多可视回归测试
请参阅Applitools 的官方 cypress sdk 文档来编写更多测试。
Jenkins 中的可视回归测试
目前,所有可视回归测试都在ceph dashboard testsGitHub check in the Jenkins 作业中运行。
接受或拒绝差异
目前,只有 ceph dashboard 团队才有对 applitools 测试运行器的读写访问权限。如果测试报告了任何差异,并且您希望接受它们并更新基准屏幕截图,或者如果差异是由于真实的回归,您可以让它们失败。为了执行上述操作,请按照这指南进行操作。
调试回归
如果您在本地运行测试并且报告了回归,您可以使用Applitools 的根本原因分析功能找到回归的原因。
前端单元测试与端到端 (E2E) 测试之间的差异 / 常见问题解答
关于测试和 E2E/unit 测试的一般介绍
E2E/unit 测试的目的是什么?
E2E 测试:
它需要一个完全功能的系统并测试应用程序的所有组件(Ceph、后端、前端)的交互。
Angular 单元测试:
单元测试,如其名称所示,是代码较小单位的测试。
哪些 E2E/unit 测试被认为是有效的?
这不容易回答,但新编写的测试与现有仪表板测试以相同的方式编写,通常应被认为是有效的。
E2E 测试应专注于测试整个应用程序的功能。
E2E/unit 测试应该是什么样的?
单元测试应专注于描述的目的it块中测试其他东西。
E2E 测试应包含一个描述,要么验证用户可见元素的正确性,要么是一个完整的过程
E2E/unit 测试应该涵盖什么?
E2E 测试应主要但不限于涵盖与后端的交互。
单元测试应主要涵盖组件的关键或复杂功能(Angular 组件、服务、管道、指令等)。
E2E/unit 测试不应该涵盖什么?
避免重复测试:不要为已经作为前端单元测试涵盖的内容编写 E2E 测试,反之亦然。
单元测试不应用于广泛地点击通过组件,E2E 测试不应用于广泛地测试 Angular 的单个组件。
最佳实践/指南
作为一般指南,我们尝试遵循 70/20/10 方法 - 70% 单元测试,20% 集成测试和 10% 端到端测试。此文档和包含的“测试金字塔”。
更多帮助
要获得有关 Angular CLI 的更多帮助,请使用ng help
或查看Angular CLI.
生成器示例
# Create module 'Core'
src/app> ng generate module core -m=app --routing
# Create module 'Auth' under module 'Core'
src/app/core> ng generate module auth -m=core --routing
or, alternatively:
src/app> ng generate module core/auth -m=core --routing
# Create component 'Login' under module 'Auth'
src/app/core/auth> ng generate component login -m=core/auth
or, alternatively:
src/app> ng generate component core/auth/login -m=core/auth
前端 TypeScript 代码风格指南建议
根据其来源对导入进行分组,并用空行分隔。
来源组可以是 Angular、外部或内部。
Example:
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { ToastrManager } from 'ngx-toastr';
import { Credentials } from '../../../shared/models/credentials.model';
import { HostService } from './services/host.service';
前端组件
有几个组件可以在不同的页面上重复使用。src/pybind/mgr/dashboard/frontend/src/app/shared/components.
辅助工具
此组件应用于向用户提供附加信息。
Example:
<cd-helper>
Some <strong>helper</strong> html text
</cd-helper>
术语和措辞
不要使用 Ceph 组件名称,建议的方法是使用逻辑/通用名称(Block over RBD,Filesystem over CephFS,Object over RGW)。尽管 Ceph-Dashboard 无法完全隐藏 Ceph 内部,但一些 Ceph 特定名称可能会仍然可见。
关于操作标签和其他文本元素(表单标题、按钮等)的措辞,采用的方法是遵循这些指南。
为了强制使用这种措辞,已经创建了一个服务ActionLabelsI18n
,它提供用于 UI 元素的翻译标签。
前端品牌
每个供应商都可以根据其需求定制“Ceph dashboard”。无论标志、HTML-Template 还是 TypeScript,前端文件夹中的每个文件都可以被替换。
要替换文件,请打开./frontend/angular.json
并滚动到生产配置中的fileReplacements
部分。在这里您可以添加您希望定制的文件。我们建议将定制的文件版本放在与原始文件相同的目录中,并在文件名的前面添加.brand
作为文件扩展名的前缀。一个fileReplacement
可以例如这样:
{
"replace": "src/app/core/auth/login/login.component.html",
"with": "src/app/core/auth/login/login.component.brand.html"
}
要提供或构建定制的用户界面,请运行:
$ npm run start -- --prod
或
$ npm run build -- --prod
不幸的是,目前无法同时使用多个配置来提供或构建 UI。这意味着一个仅用于定制的配置fileReplacements
不是一个选项,因为您无论如何都要使用生产配置https://github.com/angular/angular-cli/issues/10612)。fileReplacements
。只要功能尚未实现,您就必须手动将文件替换添加到 angular.json 文件中https://github.com/angular/angular-cli/issues/12354).
尽管如此,您应该坚持建议的命名方案,因为它使您在将来 glob 表达式支持的情况下更容易使用。
要更改变量默认值或添加您自己的值,您可以在./frontend/src/styles/vendor/_variables.scss
中覆盖它们。$color-primary: teal;
要覆盖或扩展默认 CSS,您可以在./frontend/src/styles/vendor/_style-overrides.scss
.
UI 风格指南
风格指南的目的是记录 Ceph Dashboard 标准并保持项目的一致性。这是一个努力,旨在使贡献者更容易处理设计和决定 Dashboard 的模型和设计。
Ceph Dashboard 的开发环境启用了实时重新加载,因此您在 UI 中做出的任何更改都会反映在打开的浏览器窗口中。Ceph Dashboard 使用 Bootstrap 作为主要的第三方 CSS 库。
避免代码重复。与现有的 UI 保持一致,尽可能多地重用现有的 SCSS 声明。
始终检查与您想要编写的类似代码。
颜色
Ceph Dashboard UI 中使用的所有颜色都列在frontend/src/styles/defaults/_bootstrap-defaults.scss中。如果使用新颜色,请始终在_bootstrap-defaults.scss中定义颜色变量,并使用变量而不是硬编码的颜色值,以便颜色更改反映在类似的 UI 元素中。
Ceph Dashboard 的主颜色是$primary。主颜色用于导航组件,并作为表单的$border-color输入组件。
次要颜色是$secondary,它是 Ceph Dashboard 的背景颜色。
链接
使用文本超链接作为导航,引导用户到应用程序中的新页面或锚定用户到页面内的某个部分。超链接的颜色应为$primary.
表单
使用红色轮廓标记无效的表单字段,并显示有意义的错误消息。此字段是必需的。应该是必需字段的 exact 错误消息。
模态框
模糊背景中的任何界面元素以突出显示模态内容。模态的标题应反映它可以执行的操作,并应在模态顶部清晰标明。使用cd-back-button组件在页脚中关闭模态。
图标
我们使用Fork Awesome类用于图标。src/app/shared/enum/icons.enum.ts,这些应该在 HTML 中引用,以便以后更容易更改它们。当图标与文本相邻时,它们应水平居中对齐。如果图标堆叠,它们也应垂直居中对齐。使用小图标与按钮。对于通知,使用大图标。
提示和通知
默认通知应具有text-info颜色。成功通知应具有text-success颜色。失败通知应具有text-danger颜色。
错误处理
对于处理前端错误,有一个通用的错误组件,它可以在./src/pybind/mgr/dashboard/frontend/src/app/core/error
中找到。要报告新错误,您可以简单地扩展DashboardError
类error.ts
文件中并添加特定标题和消息以用于新错误。一些通用错误类已经就位,例如DashboardNotFoundError
和DashboardForbiddenError
,它可以在不同的场景中调用和重用。
例如 -throw new DashboardNotFoundError()
.
国际化 (i18n)
如何从源代码中提取消息?
要从模板和 TypeScript 文件中提取 I18N 消息,只需在src/pybind/mgr/dashboard/frontend
:
$ npm run i18n:extract
中运行以下命令:ngx-translator提取器来解析 TypeScript 文件。
命令运行成功后,它应该创建了或更新了文件src/locale/messages.xlf
.
该文件没有被 git 跟踪,您可以仅使用它来离线开始翻译或添加/更新资源文件到 transifex。
支持的语言
我们支持的所有语言都应该在supported-languages.enum.ts
中的两个导出中注册,并且应该具有相应的测试在language-selector.component.spec.ts
.
The SupportedLanguages
枚举将提供默认语言选择的列表。
翻译过程
为了促进仪表板的翻译过程,我们使用了一个名为transifex.
的网络工具。transifex,加入项目,您就可以立即开始翻译。
所有翻译都将被审查,然后稍后推送到上游。
更新翻译后的消息
创建一个API 令牌.
推送翻译:
$ tx push -s
这将推送 transifex 中的源文件。
拉取翻译:
$ tx pull -r ceph-dashboard.<resource_slug> -f
例如tx pull -r ceph-dashboard.main
这将拉取资源的所有翻译。
向 transifex 添加新的发布资源
为了组织翻译,我们为每个 Ceph 发布创建了一个transifex 资源。这意味着,一旦发布了新版本,就需要在发布分支上更新src/pybind/mgr/dashboard/frontend/.tx/config
。
请替换::resource_name = Main
by::resource_name = <Release-name>
例如,Tentacle 发布的资源定义::resource_name = Tentacle
And replace::[o:ceph:p:ceph-dashboard:r:main]
by::[o:ceph:p:ceph-dashboard:r:<release-name>
例如,Tentacle 发布的资源定义::[o:ceph:p:ceph-dashboard:r:tentacle]
- 完成后推送翻译::
$ tx push -s
- 注意:
<Release-name> 是大写的。
建议
字符串需要与元素在同一行开始和结束:
<!-- avoid -->
<span i18n>
Foo
</span>
<!-- recommended -->
<span i18n>Foo</span>
<!-- avoid -->
<span i18n>
Foo bar baz.
Foo bar baz.
</span>
<!-- recommended -->
<span i18n>Foo bar baz.
Foo bar baz.</span>
孤立插值不应翻译:
<!-- avoid -->
<span i18n>{{ foo }}</span>
<!-- recommended -->
<span>{{ foo }}</span>
句子中使用的插值应保留在翻译中:
<!-- recommended -->
<span i18n>There are {{ x }} OSDs.</span>
移除上下文翻译之外的元素:
<!-- avoid -->
<label i18n>
Profile
<span class="required"></span>
</label>
<!-- recommended -->
<label>
<ng-container i18n>Profile<ng-container>
<span class="required"></span>
</label>
保持影响句子的元素:
<!-- recommended -->
<span i18n>Profile <b>foo</b> will be removed.</span>
可访问性
Ceph Dashboard 的许多部分都是基于Web 内容无障碍指南 (WCAG) 2.1级别 A 无障碍符合性指南。
总结
在引入新的代码更改之前,您应该检查以下几件事:
将ARIA 标签和描述对可操作的 HTML 元素。
不要忘记标记 ARIA 标签/描述或任何用户可读文本用于翻译 (i18n-title, i18n-aria-label…).
将ARIA 角色标记行为与其预期行为不同的 HTML 元素(例如,作为 <buttons> 的 <a> 标签)或提供扩展行为(角色)。
当测试菜单或下拉列表时,请确保使用无障碍检查器在打开和关闭状态下的两者都进行扫描。有时问题在菜单关闭时是隐藏的。
无障碍检查器
在开发过程中,您可以使用以下工具之一来测试您功能的无障碍符合性:
使用两个或多个这些工具可以大大提高检测无障碍违规的检测率。
颜色对比检查器
添加新颜色时,确保它们也是可访问的也很重要。这里有一些可以帮助您进行颜色对比测试的工具:
无障碍检查器
如果您使用 VSCode,您可以安装axe 无障碍检查器,它可以帮助您在开发过程中捕获和修复潜在问题。
无障碍测试
我们的基于 Cypress 的 e2e 测试套件支持使用axe-core和cypress-axe添加无障碍测试。cy.checkAccessibility, can also be used directly. This is a great way to prevent accessibility regressions on high impact components.
测试可以在a11y 文件夹在仪表板中找到。
describe('Navigation accessibility', { retries: 0 }, () => {
const shared = new NavigationPageHelper();
beforeEach(() => {
cy.login();
shared.navigateTo();
});
it('top-nav should have no accessibility violations', () => {
cy.injectAxe();
cy.checkAccessibility('.cd-navbar-top');
});
it('sidebar should have no accessibility violations', () => {
cy.injectAxe();
cy.checkAccessibility('nav[id=sidebar]');
});
});
其他指南
如果您不确定应遵循哪种 UI 模式来实现无障碍修复,ecd5a6: patternflypatternfly指南可以用于。
后端开发
此模块的 Python 后端代码需要安装一些 Python 模块。它们列在文件requirements.txt
中。使用pip您可以通过发出pip install -r requirements.txt
在目录src/pybind/mgr/dashboard
.
中安装所有必需的依赖项。ceph-dev-docker 开发环境,只需从顶级目录运行./install_deps.sh
。
单元测试
在仪表板中,我们有两种不同类型的后端测试:
基于 tox 的单元测试
tox
基于 Teuthology 的 API 测试
基于 tox 的单元测试
我们包含了一个tox
配置文件,它将在 Python 3 下运行单元测试,以及用于保证代码一致性的检查工具。
您需要安装tox
和coverage
在运行它之前。dnf install python-tox python-coverage
在 Fedora Linux 上。
或者,您可以使用 Python 的原生软件包安装方法:
$ pip install tox
$ pip install coverage
To run the tests, runsrc/script/run_tox.sh
在仪表板目录(其中tox.ini
位于):
## Run Python 3 tests+lint commands:
$ ../../../script/run_tox.sh --tox-env py3,lint,check
## Run Python 3 arbitrary command (e.g. 1 single test):
$ ../../../script/run_tox.sh --tox-env py3 "" tests/test_rgw_client.py::RgwClientTest::test_ssl_verify
您也可以运行 tox 代替run_tox.sh
:
## Run Python 3 tests command:
$ tox -e py3
## Run Python 3 arbitrary command (e.g. 1 single test):
$ tox -e py3 tests/test_rgw_client.py::RgwClientTest::test_ssl_verify
Python 文件可以根据 PEP8 标准自动修复和格式化,方法是使用run_tox.sh --tox-env fix
或tox -e fix
.
我们还从后端代码中收集覆盖率信息,当您运行测试时。您可以通过运行以下命令检查 tox 输出提供的覆盖率信息,或运行 tox 成功完成后运行以下命令:
$ coverage html
此命令将创建一个目录htmlcov
带有代码覆盖率的 HTML 表示。
基于 Teuthology 的 API 测试
- 如何运行现有的 API 测试:
要运行针对真实 Ceph 集群的 API 测试,我们利用 Teuthology 框架。这具有捕获源自内部 Ceph 代码更改的错误的优点。
我们的
run-backend-api-tests.sh
脚本在运行 Teuthology 测试之前将启动一个vstart
Ceph 集群,然后在测试运行后停止集群。当然,这意味着您之前需要先编译/编译 Ceph。通过运行以下命令启动所有仪表板测试:
$ ./run-backend-api-tests.sh
或,通过指定测试名称启动一个或多个特定测试:
$ ./run-backend-api-tests.sh tasks.mgr.dashboard.test_pool.PoolTest
或,
source
脚本并手动运行测试:$ source run-backend-api-tests.sh $ run_teuthology_tests [tests]... $ cleanup_teuthology
- 如何编写您自己的测试:
有两种可能的方法来编写您自己的 API 测试:
第一种是通过扩展
qa/tasks/mgr/dashboard
目录中运行此命令。中的现有测试类。
qa/tasks/mgr/dashboard
目录并在此处实现所有您的测试。Note
不要忘记将新创建的模块的路径添加到
modules
部分qa/suites/rados/mgr/tasks/dashboard.yaml
.短示例:假设您创建了一个名为
my_new_controller.py
的新控制器,以及相关的测试模块test_my_new_controller.py
。您需要将tasks.mgr.dashboard.test_my_new_controller
传递给modules
部分添加到dashboard.yaml
文件。Also, 如果您正在删除测试模块,请记住删除相关的部分。否则,Teuthology 测试运行将失败。
请在提交拉取请求之前在您的开发环境中运行您的 API 测试(如上所述)
如何添加新的控制器?
控制器是一个扩展自BaseController
类的 Python 类,并装饰有@Controller
, @ApiController
或@UiApiController
装饰器。Python 类必须存储在位于controllers
目录下的 Python 文件中。仪表板模块将在启动时自动加载您的新控制器。
@ApiController
和@UiApiController
是@Controller
装饰器的方法来暴露的。
The @ApiController
应用于提供 API 类似 REST 接口的控制器。@UiApiController
应用于由 UI 消费但不是“公共”API 部分的端点。@Controller
装饰器。
控制器有一个与控制器装饰器中指定的 URL 前缀路径相关联,所有控制器暴露的端点将共享相同的 URL 前缀路径。
控制器的端点是通过在控制器类上实现一个装饰有@Endpoint
装饰器的方法来暴露的。
例如创建一个文件ping.py
在controllers
目录下,包含以下代码:
from ..tools import Controller, ApiController, UiApiController, BaseController, Endpoint
@Controller('/ping')
class Ping(BaseController):
@Endpoint()
def hello(self):
return {'msg': "Hello"}
@ApiController('/ping')
class ApiPing(BaseController):
@Endpoint()
def hello(self):
return {'msg': "Hello"}
@UiApiController('/ping')
class UiApiPing(BaseController):
@Endpoint()
def hello(self):
return {'msg': "Hello"}
The hello
endpoint of thePing
控制器可以通过以下 URL 访问:https://mgr_hostname:8443/ping/hello使用 HTTP GET 请求。/ping
是连接到hello
来生成端点 URL 的方法名。
在ApiPing
控制器的hello
endpoint 可以通过以下 URL 访问:https://mgr_hostname:8443/api/ping/hello使用 HTTP GET 请求。/ping
由/api
路径前缀,然后连接到hello
来生成端点 URL。@ApiController
实际上是调用@Controller
装饰器,并通过传递一个额外的装饰器参数base_url
:
@ApiController('/ping') <=> @Controller('/ping', base_url="/api")
UiApiPing
来工作,它类似于ApiPing
,但 URL 将由/ui-api
: https://mgr_hostname:8443/ui-api/ping/hello. UiApiPing
也是@Controller
扩展:
@UiApiController('/ping') <=> @Controller('/ping', base_url="/ui-api")
The @Endpoint
装饰器也支持许多参数来定制端点:
method="GET"
: 允许访问此端点的 HTTP 方法。path="/<method_name>"
: 端点的 URL 路径,不包括控制器 URL 路径前缀。path_params=[]
: 对应于 URL 路径参数的方法参数名称列表。只能用于method in ['POST', 'PUT']
.query_params=[]
: 对应于 URL 查询参数的方法参数名称列表。json_response=True
: 指示端点响应是否应以 JSON 格式序列化。proxy=False
: 指示端点是否应作为代理使用。
端点方法可以声明参数。根据为端点定义的 HTTP 方法,方法参数可能被视为路径参数、查询参数或正文参数。
对于GET
和DELETE
methods, 方法的不可选参数默认被视为路径参数。可选参数被视为查询参数。通过在端点装饰器中指定query_parameters
,可以使非可选参数成为查询参数。
对于POST
和PUT
methods, 所有方法参数默认被视为正文参数。要覆盖此默认值,可以使用path_params
和query_params
来指定哪些方法参数是路径和查询参数。
让我们使用一个示例来更好地理解自定义端点 URL 的可能方法:
from ..tools import Controller, BaseController, Endpoint
@Controller('/ping')
class Ping(BaseController):
# URL: /ping/{key}?opt1=...&opt2=...
@Endpoint(path="/", query_params=['opt1'])
def index(self, key, opt1, opt2=None):
"""..."""
# URL: /ping/{key}?opt1=...&opt2=...
@Endpoint(query_params=['opt1'])
def __call__(self, key, opt1, opt2=None):
"""..."""
# URL: /ping/post/{key1}/{key2}
@Endpoint('POST', path_params=['key1', 'key2'])
def post(self, key1, key2, data1, data2=None):
"""..."""
在上面的示例中,我们看到了如何使用path
选项可以用来覆盖生成的端点 URL,以便不使用方法名在 URL 中。在index
方法中,我们设置了path
to"/"
来生成一个可以通过控制器根 URL 访问的端点。
作为生成可通过控制器路径 URL 访问的端点的另一种方法,是使用__call__
方法,如上例所示。
从第三个方法中,我们可以看到路径参数是从 URL 中收集的,通过解析/
后 URL 路径/ping
forindex
方法的情况,以及/ping/post
对于post
方法的情况。
在端点的 URL 中使用 python 方法参数定义路径参数非常容易,但它仍然在 URL 结构中参数的位置方面有点严格。
考虑以下示例:
from ..tools import Controller, BaseController, Endpoint
@Controller('/ping/{node}/stats')
class Ping(BaseController):
# URL: /ping/{node}/stats/{date}/latency?unit=...
@Endpoint(path="/{date}/latency")
def latency(self, node, date, unit="ms"):
""" ..."""
在此示例中,我们在控制器 URL 路径中明确声明了一个路径参数{node}
,在{date}
。Cephadm 还支持使用latency
方法中声明了一个路径参数latency
端点的 URL 然后可通过以下 URL 访问:https://mgr_hostname:8443/ping/{node}/stats/{date}/latency .
对于如何使用@Endpoint
装饰器的一组完整示例,请检查单元测试文件:tests/test_controllers.py
。
实现 Proxy 控制器
有时您可能需要将一些请求从 Dashboard 前端直接转发到外部服务。@Proxy
的装饰器。controllers/rgw.py
文件,我们在其中实现了一个 RGW Admin Ops 代理。)
The @Proxy
装饰器是@Endpoint
装饰器的包装,它已经定制了端点以作为代理工作。
Example:
from ..tools import Controller, BaseController, Proxy
@Controller('/foo/proxy')
class FooServiceProxy(BaseController):
@Proxy()
def proxy(self, path, **params):
"""
if requested URL is "/foo/proxy/access/service?opt=1"
then path is "access/service" and params is {'opt': '1'}
"""
RESTController 是如何工作的?
我们还提供了一个简单的机制来使用RESTController
类创建基于 REST 的控制器。任何从RESTController
继承的类将默认返回 JSON。
The RESTController
是基本上是一个额外的抽象层,它简化了与集合的工作。集合只是一个具有特定类型的对象数组。RESTController
启用一些默认映射,将请求类型和给定参数映射到特定方法名。这听起来可能很复杂,但实际上相当简单。让我们看一下以下示例:
import cherrypy
from ..tools import ApiController, RESTController
@ApiController('ping')
class Ping(RESTController):
def list(self):
return {"msg": "Hello"}
def get(self, id):
return self.objects[id]
在这种情况下,list
method 是自动用于所有请求到api/ping
其中没有给出额外的参数并且请求类型是GET
。如果请求给出额外的参数,我们的情况,它不会映射到list
anymore but toget
并返回具有给定 ID 的元素(假设self.objects
has been filled before)。The
请求类型 |
参数 |
方法 |
状态代码 |
---|---|---|---|
GET |
否 |
列表 |
200 |
PUT |
否 |
bulk_set |
200 |
POST |
否 |
创建 |
201 |
DELETE |
否 |
bulk_delete |
204 |
GET |
是 |
get |
200 |
PUT |
是 |
设置 |
200 |
DELETE |
是 |
删除 |
204 |
要使用上面列出的方法的自定义端点,您可以使用@RESTController.MethodMap
import cherrypy
from ..tools import ApiController, RESTController
@RESTController.MethodMap(version='0.1')
def create(self):
return {"msg": "Hello"}
This decorator supports three parameters来定制端点:
resource"
: 资源 ID。status=200
: 设置 HTTP 状态响应代码version
: 版本
如何在 RESTController 中使用自定义 API 端点?
如果您没有任何访问限制,您可以使用@Endpoint
。如果您设置了权限范围来限制对端点的访问,@Endpoint
将失败,因为它不知道应该使用哪个权限属性。要在受限RESTController
使用@RESTController.Collection
中使用自定义端点,请使用@RESTController.Resource
。RESOURCE_ID
,也可以选择RESTController
类中设置了
import cherrypy
from ..tools import ApiController, RESTController
@ApiController('ping', Scope.Ping)
class Ping(RESTController):
RESOURCE_ID = 'ping'
@RESTController.Resource('GET')
def some_get_endpoint(self):
return {"msg": "Hello"}
@RESTController.Collection('POST')
def some_post_endpoint(self, **data):
return {"msg": data}
两个装饰器也支持五个参数来定制端点:
method="GET"
: 允许访问此端点的 HTTP 方法。path="/<method_name>"
: 端点的 URL 路径,不包括控制器 URL 路径前缀。status=200
: 设置 HTTP 状态响应代码query_params=[]
: 对应于 URL 查询参数的方法参数名称列表。version
: 版本
如何限制对控制器的访问?
所有控制器默认都需要身份验证。secure=False
。
Example:
import cherrypy
from . import ApiController, RESTController
@ApiController('ping', secure=False)
class Ping(RESTController):
def list(self):
return {"msg": "Hello"}
如何创建一个使用“公共”API 的专用 UI 端点?
有时我们希望将多个调用组合成一个单独的调用,以节省带宽或出于其他性能原因。@UiApiController
用于由 UI 消费但不是“公共”API 部分的端点。让 ui 类继承自 REST 控制器类。
Example:
import cherrypy
from . import UiApiController, ApiController, RESTController
@ApiController('ping', secure=False) # /api/ping
class Ping(RESTController):
def list(self):
return self._list()
def _list(self): # To not get in conflict with the JSON wrapper
return [1,2,3]
@UiApiController('ping', secure=False) # /ui-api/ping
class PingUi(Ping):
def list(self):
return self._list() + [4, 5, 6]
如何从控制器访问管理器模块实例?
我们将管理器模块实例作为全局变量提供,可以在任何模块中导入。
Example:
import logging
import cherrypy
from .. import mgr
from ..tools import ApiController, RESTController
logger = logging.getLogger(__name__)
@ApiController('servers')
class Servers(RESTController):
def list(self):
logger.debug('Listing available servers')
return {'servers': mgr.list_servers()}
如何为控制器编写单元测试?
我们提供了一个测试辅助类ControllerTestCase
以轻松地为您的控制器创建单元测试。
如果我们想要为上述Ping
控制器编写单元测试,请创建一个test_ping.py
文件在tests
目录下,包含以下代码:
from .helper import ControllerTestCase
from .controllers.ping import Ping
class PingTest(ControllerTestCase):
@classmethod
def setup_test(cls):
cp_config = {'tools.authenticate.on': True}
cls.setup_controllers([Ping], cp_config=cp_config)
def test_ping(self):
self._get("/api/ping")
self.assertStatus(200)
self.assertJsonBody({'msg': 'Hello'})
The ControllerTestCase
class starts by initializing a CherryPy webserver.setup_test()
class方法,我们可以显式加载我们想要测试的控制器。在上面的示例中,我们只加载了Ping
控制器。我们还可以提供cp_config
以更新控制器的 cherrypy 配置(例如,启用身份验证,如示例中所示)。
如何在 grafana 中更新或创建新的仪表板?
我们使用jsonnet
和grafonnet-lib
为 grafana 仪表板编写代码。grafana_dashboards.jsonnet
文件中,位于
我们通过在 grafana/dashboards 目录中运行以下命令直接从这个 jsonnet 文件生成仪表板 json 文件:jsonnet -m . jsonnet/grafana_dashboards.jsonnet
.jsonnet
软件包安装和grafonnet-lib
目录克隆到我们的机器。请参考 -https://grafana.github.io/grafonnet-lib/getting-started/
在您遇到一些问题时。)
要更新现有的 grafana 仪表板或创建一个新的,我们需要更新grafana_dashboards.jsonnet
文件并使用上述命令生成新的/更新的 json 文件。对于不熟悉 grafonnet 或 jsonnet 实现的人来说,可以遵循此文档 -https://grafana.github.io/grafonnet-lib/
.
jsonnet 格式的示例 grafana 仪表板:
要指定 grafana 仪表板属性,例如标题、uid 等,我们可以创建一个本地函数 -
local dashboardSchema(title, uid, time_from, refresh, schemaVersion, tags,timezone, timepicker)
To add a graph panel we can specify the graph schema in a local function such as -
local graphPanelSchema(title, nullPointMode, stack, formatY1, formatY2, labelY1, labelY2, min, fill, datasource)
and then use these functions inside the dashboard definition like -
{
radosgw-sync-overview.json: //json file name to be generated
dashboardSchema(
'RGW Sync Overview', 'rgw-sync-overview', 'now-1h', '15s', .., .., ..
)
.addPanels([
graphPanelSchema(
'Replication (throughput) from Source Zone', 'Bps', null, .., .., ..)
])
}
The valid grafonnet-lib 属性可以在这里找到 -https://grafana.github.io/grafonnet-lib/api-docs/
.
如何在控制器中监听管理器通知?
管理器通知模块关于几种类型的集群事件,例如集群日志事件等…
每个模块都有一个“全局”处理函数称为notify
,管理器调用它来通知模块。但这个处理函数不得阻塞或花费太多时间处理事件通知。
以下示例代表一个实现非常简单的实时日志查看页面的控制器:
import collections
import cherrypy
from ..tools import ApiController, BaseController, NotificationQueue
@ApiController('livelog')
class LiveLog(BaseController):
log_buffer = collections.deque(maxlen=1000)
def __init__(self):
super(LiveLog, self).__init__()
NotificationQueue.register(self.log, 'clog')
def log(self, log_struct):
self.log_buffer.appendleft(log_struct)
@cherrypy.expose
def default(self):
ret = '<html><meta http-equiv="refresh" content="2" /><body>'
for l in self.log_buffer:
ret += "{}<br>".format(l)
ret += "</body></html>"
return ret
如上所示,theNotificationQueue
类提供了一个注册方法,该方法接收函数作为其第一个参数,并接收第二个参数“通知类型”。register
方法的第二个参数中省略
以下是可用的通知类型(这些可能会在未来更改)列表,可以用于:
clog
: 集群日志通知command
: 由MgrModule.send_command
发出的命令完成的通知perf_schema_update
: 性能计数器模式更新mon_map
: 监控地图更新fs_map
: cephfs 地图更新osd_map
: OSD 地图更新service_map
: 服务(RGW, RBD-Mirror, 等)地图更新mon_status
: 监控状态定期更新health
: 健康状态定期更新pg_summary
: 定期更新 PG 状态信息
当控制器访问 Ceph 模块时如何编写单元测试?
考虑以下示例,它实现了一个控制器,检索rbd
池的 RBD 图像列表:
import rbd
from .. import mgr
from ..tools import ApiController, RESTController
@ApiController('rbdimages')
class RbdImages(RESTController):
def __init__(self):
self.ioctx = mgr.rados.open_ioctx('rbd')
self.rbd = rbd.RBD()
def list(self):
return [{'name': n} for n in self.rbd.list(self.ioctx)]
在上面的示例中,我们想要模拟rbd.list
函数的返回值,以便我们可以测试控制器的 JSON 响应。
单元测试代码将如下所示:
import mock
from .helper import ControllerTestCase
class RbdImagesTest(ControllerTestCase):
@mock.patch('rbd.RBD.list')
def test_list(self, rbd_list_mock):
rbd_list_mock.return_value = ['img1', 'img2']
self._get('/api/rbdimages')
self.assertJsonBody([{'name': 'img1'}, {'name': 'img2'}])
如何添加新的配置设置?
如果您需要为新的功能存储一些配置设置,我们已经提供了一个简单的机制来指定/使用新的配置设置。
例如,如果您想添加一个新的配置设置来保存仪表板管理员的电子邮件地址,只需将设置名称作为类属性添加到Options
类中settings.py
文件:
# ...
class Options(object):
# ...
ADMIN_EMAIL_ADDRESS = ('admin@admin.com', str)
The value of the class attribute is a pair composed by the default value for that
通过声明ADMIN_EMAIL_ADDRESS
类属性,当您重新启动仪表板模块时,您将自动获得两个额外的 CLI 命令来获取和设置该设置:
$ ceph dashboard get-admin-email-address
$ ceph dashboard set-admin-email-address <value>
要从 Python 代码访问或修改配置设置值,无论是控制器内部还是任何其他地方,您只需导入Settings
类并像这样访问它:
from settings import Settings
# ...
tmp_var = Settings.ADMIN_EMAIL_ADDRESS
# ....
Settings.ADMIN_EMAIL_ADDRESS = 'myemail@admin.com'
设置管理实现将确保如果您从 Python 代码更改设置值,您将在从 CLI 访问该设置时看到该更改,反之亦然。
如何异步运行控制器读写操作?
一些控制器可能需要执行改变 Ceph 集群状态的操作。这些操作可能需要一些时间来执行,为了在 Web UI 中维护良好的用户体验,我们需要异步运行这些操作并立即返回前端一些信息,这些信息表明操作正在后台运行。
为了帮助开发上述场景,我们添加了对异步任务的支持。要触发异步任务的执行,我们必须使用TaskManager
类的以下类方法:
from ..tools import TaskManager
# ...
TaskManager.run(name, metadata, func, args, kwargs)
name
是一个字符串,可用于对任务进行分组。例如,对于 RBD 图像创建任务,我们可以指定"rbd/create"
作为"rbd/remove"
对于 RBD 图像删除任务,类似地metadata
是一个字典,我们可以存储描述任务的键值对。例如,当为创建 RBD 图像创建任务时,我们可以指定{'pool_name': "rbd", image_name': "test-img"}
.func
作为args
和kwargs
是位置和命名参数,将传递给func
当任务管理器开始执行时。
The TaskManager.run
方法触发函数func
的异步执行并返回一个Task
对象。Task
提供了公共方法Task.wait(timeout)
,可以使用它等待任务完成,直到定义的秒数超时,作为参数。如果没有提供参数,该方法将阻塞直到任务完成。wait
method
blocks until the task is finished.
The Task.wait
对于通常快速执行但有时可能需要很长时间运行的任务,这非常有用。Task.wait
方法是一个对(state, value)
其中state
是一个字符串,具有以下可能值:
VALUE_DONE = "done"
VALUE_EXECUTING = "executing"
The value
将函数func
如果state == VALUE_DONE
的执行结果存储在state == VALUE_EXECUTING
然后value == None
.
中。如果(name, metadata)
应该明确标识正在运行的任务,这意味着如果尝试触发与当前运行的任务具有相同(name, metadata)
的对,则不会创建新任务,您将获得当前运行的任务的任务对象。
For instance, consider the following example:
task1 = TaskManager.run("dummy/task", {'attr': 2}, func)
task2 = TaskManager.run("dummy/task", {'attr': 2}, func)
如果第二个调用TaskManager.run
在第一个任务仍在执行时执行,它将返回相同的任务对象:assert task1 == task2
.
如何获取正在执行和已完成的异步任务列表?
正在执行和已完成的任务列表包含在Summary
控制器中,控制器已经每 5 秒被仪表板前端轮询。但我们还提供了一个专门的控制器来获取相同的执行和已完成任务列表。
The Task
控制器公开了/api/task
端点,该端点返回执行和已完成任务的列表。此端点接受name
参数,该参数接受 glob 表达式作为其值。/api/task?name=rbd/*
将返回所有执行和已完成任务,其名称以rbd/
.
开头的列表。
每个执行任务由以下字典表示:
{
'name': "name", # str
'metadata': { }, # dict
'begin_time': "2018-03-14T15:31:38.423605Z", # str (ISO 8601 format)
'progress': 0 # int (percentage)
}
每个已完成任务由以下字典表示:
{
'name': "name", # str
'metadata': { }, # dict
'begin_time': "2018-03-14T15:31:38.423605Z", # str (ISO 8601 format)
'end_time': "2018-03-14T15:31:39.423605Z", # str (ISO 8601 format)
'duration': 0.0, # float
'progress': 0 # int (percentage)
'success': True, # bool
'ret_value': None, # object, populated only if 'success' == True
'exception': None, # str, populated only if 'success' == False
}
如何使用异步 API 与异步任务?
The TaskManager.run
方法,如前一节中所述,非常适合调用阻塞函数,因为它在创建新线程中运行函数。但有时我们想要调用一些已经异步存在的 API 的函数。
对于这些情况,我们想要避免为运行非阻塞函数创建新线程,并想要利用函数的异步性质。TheTaskManager.run
已经准备就绪,可以通过传递一个类型为TaskExecutor
的对象作为附加参数 calledexecutor
来使用非阻塞函数。TaskManager.run
:
TaskManager.run(name, metadata, func, args=None, kwargs=None, executor=None)
The TaskExecutor
类负责执行给定任务函数的代码,并定义三个可以被子类覆盖的方法:
def init(self, task)
def start(self)
def finish(self, ret_value, exception)
The init
方法在运行任务函数之前被调用,并接收任务对象(类Task
).
The start
方法运行任务函数。默认实现是当前线程上下文中运行任务函数。
The finish
方法应在任务函数完成时调用,无论是ret_value
填充了执行结果,还是当执行引发异常时具有异常对象。
为了利用非阻塞函数的异步性质,开发人员应该通过创建TaskExecutor
类的子类并提供一个自定义执行器类实例作为executor
参数来实施自定义执行器。TaskManager.run
.
To better understand the expressive power of executors, we write a full example
of use a custom executor to execute the MgrModule.send_command
asynchronous
function:
import json
from mgr_module import CommandResult
from .. import mgr
from ..tools import ApiController, RESTController, NotificationQueue, \
TaskManager, TaskExecutor
class SendCommandExecutor(TaskExecutor):
def __init__(self):
super(SendCommandExecutor, self).__init__()
self.tag = None
self.result = None
def init(self, task):
super(SendCommandExecutor, self).init(task)
# we need to listen for 'command' events to know when the command
# finishes
NotificationQueue.register(self._handler, 'command')
# store the CommandResult object to retrieve the results
self.result = self.task.fn_args[0]
if len(self.task.fn_args) > 4:
# the user specified a tag for the command, so let's use it
self.tag = self.task.fn_args[4]
else:
# let's generate a unique tag for the command
self.tag = 'send_command_{}'.format(id(self))
self.task.fn_args.append(self.tag)
def _handler(self, data):
if data == self.tag:
# the command has finished, notifying the task with the result
self.finish(self.result.wait(), None)
# deregister listener to avoid memory leaks
NotificationQueue.deregister(self._handler, 'command')
@ApiController('test')
class Test(RESTController):
def _run_task(self, osd_id):
task = TaskManager.run("test/task", {}, mgr.send_command,
[CommandResult(''), 'osd', osd_id,
json.dumps({'prefix': 'perf histogram dump'})],
executor=SendCommandExecutor())
return task.wait(1.0)
def get(self, osd_id):
status, value = self._run_task(osd_id)
return {'status': status, 'value': value}
The above SendCommandExecutor
executor class can be used for any call to
MgrModule.send_command
. This means that we should need just one custom
executor class implementation for each non-blocking API that we use in our
controllers.
默认执行器,当没有执行器对象传递给TaskManager.run
时使用,是ThreadedExecutor
。您可以检查它的实现tools.py
文件。
如何更新异步任务的执行进度?
异步任务基础设施提供了支持更新正在执行的任务的执行进度。
要在任务代码中从内部更新进度,theTaskManager
类提供了一个方法来检索当前任务对象:
TaskManager.current_task()
上述方法仅在使用默认执行器ThreadedExecutor
执行任务时可用。current_task()
方法返回当前Task
对象。TheTask
对象提供两个公共方法来更新执行进度值:theset_progress(percentage)
和inc_progress(delta)
methods.
The set_progress
方法接收作为参数一个表示我们想要设置为任务绝对百分比的整数值。
The inc_progress
方法接收作为参数一个表示我们想要增量增加当前执行进度百分比的整数值。
以下示例是一个控制器触发新任务并更新其进度的控制器示例:
import random
import time
import cherrypy
from ..tools import TaskManager, ApiController, BaseController
@ApiController('dummy_task')
class DummyTask(BaseController):
def _dummy(self):
top = random.randrange(100)
for i in range(top):
TaskManager.current_task().set_progress(i*100/top)
# or TaskManager.current_task().inc_progress(100/top)
time.sleep(1)
return "finished"
@cherrypy.expose
@cherrypy.tools.json_out()
def default(self):
task = TaskManager.run("dummy/task", {}, self._dummy)
return task.wait(5) # wait for five seconds
如何在前端处理异步任务?
所有执行和最近完成的异步任务都显示在“Background-Tasks”上,如果已完成则显示在“Recent-Notifications”上在菜单栏中。TaskManagerMessageService.messages
来实现一致性,所有任务和状态之间。
- 操作对象
确保所有任务之间的一致性。它由每个不同状态的三种动词组成 f.e.
{running: 'Creating', failure: 'create', success: 'Created'}
.
Put running operations in present participle f.e.
'Updating'
.Failed messages always start with
'Failed to '
and should be continued'update'
.Put successful operations in past tense f.e.
'Updated'
.
- Involves Function
确保所有任务的消息之间的一致性,它类似于谁参与由操作。
"RBD 'somePool/someImage'"
.
Both combined create the following messages:
Failure =>
"Failed to create RBD 'somePool/someImage'"
Running =>
"Creating RBD 'somePool/someImage'"
Success =>
"Created RBD 'somePool/someImage'"
对于自动任务处理,使用TaskWrapperService.wrapTaskAroundCall
.
如果对于某种原因wrapTaskAroundCall
不工作,您必须通过TaskManagerService.subscribe
手动订阅您的异步任务,并提供一个回调,在成功的情况下通知用户。一个通知可以使用NotificationService.notifyTask
. 它将使用TaskManagerMessageService.messages
来显示基于任务状态的消息。
API 错误ApiInterceptorService
.
Usage example:
export class TaskManagerMessageService {
// ...
messages = {
// Messages for task 'rbd/create'
'rbd/create': new TaskManagerMessage(
// Message prefixes
['create', 'Creating', 'Created'],
// Message suffix
(metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'`,
(metadata) => ({
// Error code and description
'17': `Name is already used by RBD '${metadata.pool_name}/${
metadata.image_name}'.`
})
),
// ...
};
// ...
}
export class RBDFormComponent {
// ...
createAction() {
const request = this.createRequest();
// Subscribes to 'call' with submitted 'task' and handles notifications
return this.taskWrapper.wrapTaskAroundCall({
task: new FinishedTask('rbd/create', {
pool_name: request.pool_name,
image_name: request.name
}),
call: this.rbdService.create(request)
});
}
// ...
}
REST API 文档
Ceph-Dashboard provides two types of documentation for the Ceph RESTful API:
Static documentation: available at Ceph RESTful API. This comes from a versioned specification located at
src/pybind/mgr/dashboard/openapi.yaml
.Interactive documentation: available from a running Ceph-Dashboard instance (top-right
?
icon > API Docs).
If changes are made to the controllers/
directory, it’s very likely that
they will result in changes to the generated OpenAPI specification. For that
reason, a checker has been implemented to block unintended changes. This check
is automatically triggered by the Pull Request CI (make check
) and can be
also manually invoked: tox -e openapi-check
.
If that checker failed, it means that the current Pull Request is modifying the Ceph API and therefore:
The versioned OpenAPI specification should be updated explicitly:
tox -e openapi-fix
.The team @ceph/api will be requested for reviews (this is automated via GitHub CODEOWNERS), in order to assess the impact of changes.
Additionally, Sphinx documentation can be generated from the OpenAPI
specification with tox -e openapi-doc
.
The Ceph RESTful OpenAPI specification is dynamically generated from the
Controllers
in controllers/
directory. However, by default it is not
very detailed, so there are two decorators that can and should be used to add
more information:
@EndpointDoc()
for documentation of endpoints. It has four optional arguments (explained below):description
,group
,parameters
和responses
.@ControllerDoc()
for documentation of controller or group associated with the endpoints. It only takes the two first arguments:description
和group
.
description
: A a string with a short (1-2 sentences) description of the object.
group
: By default, an endpoint is grouped together with other endpoints
within the same controller class. group
is a string that can be used to
assign an endpoint or all endpoints in a class to another controller or a
conceived group name.
parameters
: A dict used to describe path, query or request body parameters.
By default, all parameters for an endpoint are listed on the Swagger UI page,
including information of whether the parameter is optional/required and default
values. However, there will be no description of the parameter and the parameter
type will only be displayed in some cases.
When adding information, each parameters should be described as in the example
below. Note that the parameter type should be expressed as a built-in python
type and not as a string. Allowed values are str
, int
, bool
, float
.
@EndpointDoc(parameters={'my_string': (str, 'Description of my_string')})
def method(my_string): pass
For body parameters, more complex cases are possible. If the parameter is a
dictionary, the type should be replaced with a dict
containing its nested
parameters. When describing nested parameters, the same format as other
parameters is used. However, all nested parameters are set as required by default.
If the nested parameter is optional this must be specified as for item2
in
the example below. If a nested parameters is set to optional, it is also
possible to specify the default value (this will not be provided automatically
for nested parameters).
@EndpointDoc(parameters={
'my_dictionary': ({
'item1': (str, 'Description of item1'),
'item2': (str, 'Description of item2', True), # item2 is optional
'item3': (str, 'Description of item3', True, 'foo'), # item3 is optional with 'foo' as default value
}, 'Description of my_dictionary')})
def method(my_dictionary): pass
If the parameter is a list
of primitive types, the type should be
surrounded with square brackets.
@EndpointDoc(parameters={'my_list': ([int], 'Description of my_list')})
def method(my_list): pass
If the parameter is a list
with nested parameters, the nested parameters
should be placed in a dictionary and surrounded with square brackets.
@EndpointDoc(parameters={
'my_list': ([{
'list_item': (str, 'Description of list_item'),
'list_item2': (str, 'Description of list_item2')
}], 'Description of my_list')})
def method(my_list): pass
responses
: A dict used for describing responses. Rules for describing
responses are the same as for request body parameters, with one difference:
responses also needs to be assigned to the related response code as in the
example below:
@EndpointDoc(responses={
'400':{'my_response': (str, 'Description of my_response')}})
def method(): pass
Python 中的错误处理
Good error handling is a key requirement in creating a good user experience and providing a good API.
Dashboard code should not duplicate C++ code. Thus, if error handling in C++ is sufficient to provide good feedback, a new wrapper to catch these errors is not necessary. On the other hand, input validation is the best place to catch errors and generate the best error messages. If required, generate errors as soon as possible.
The backend provides few standard ways of returning errors.
First, there is a generic Internal Server Error:
Status Code: 500
{
"version": <cherrypy version, e.g. 13.1.0>,
"detail": "The server encountered an unexpected condition which prevented it from fulfilling the request.",
}
For errors generated by the backend, we provide a standard error format:
Status Code: 400
{
"detail": str(e), # E.g. "[errno -42] <some error message>"
"component": "rbd", # this can be null to represent a global error code
"code": "3", # Or a error name, e.g. "code": "some_error_key"
}
In case, the API Endpoints uses @ViewCache to temporarily cache results, the error looks like so:
Status Code 400
{
"detail": str(e), # E.g. "[errno -42] <some error message>"
"component": "rbd", # this can be null to represent a global error code
"code": "3", # Or a error name, e.g. "code": "some_error_key"
'status': 3, # Indicating the @ViewCache error status
}
In case, the API Endpoints uses a task the error looks like so:
Status Code 400
{
"detail": str(e), # E.g. "[errno -42] <some error message>"
"component": "rbd", # this can be null to represent a global error code
"code": "3", # Or a error name, e.g. "code": "some_error_key"
"task": { # Information about the task itself
"name": "taskname",
"metadata": {...}
}
}
Our WebUI should show errors generated by the API to the user. Especially field-related errors in wizards and dialogs or show non-intrusive notifications.
Handling exceptions in Python should be an exception. In general, we
should have few exception handlers in our project. Per default, propagate
errors to the API, as it will take care of all exceptions anyway. In general,
log the exception by adding logger.exception()
with a description to the
handler.
We need to distinguish between user errors from internal errors and programming errors. Using different exception types will ease the task for the API layer and for the user interface:
Standard Python errors, like SystemError
, ValueError
或KeyError
will end up as internal server errors in the API.
In general, do not return
error responses in the REST API. They will be
returned by the error handler. Instead, raise the appropriate exception.
插件
New functionality can be provided by means of a plug-in architecture. Among the benefits this approach brings in, loosely coupled development is one of the most notable. As the Ceph Dashboard grows in feature richness, its code-base becomes more and more complex. The hook-based nature of a plug-in architecture allows to extend functionality in a controlled manner, and isolate the scope of the changes.
Ceph Dashboard relies on Pluggy to provide for plug-ing support. On top of pluggy, an interface-based approach has been implemented, with some safety checks (method override and abstract method checks).
In order to create a new plugin, the following steps are required:
Add a new file under
src/pybind/mgr/dashboard/plugins
.Import the
PLUGIN_MANAGER
instance and theInterfaces
.Create a class extending the desired interfaces. The plug-in library will check if all the methods of the interfaces have been properly overridden.
Register the plugin in the
PLUGIN_MANAGER
instance.Import the plug-in from within the Ceph Dashboard
module.py
(currently no dynamic loading is implemented).
The available Mixins (helpers) are:
CanMgr
: provides the plug-in with access to themgr
instance underself.mgr
.
The available Interfaces are:
Initializable
: requires overridinginit()
hook. This method is run at the very beginning of the dashboard module, right after all imports have been performed.Setupable
: requires overridingsetup()
hook. This method is run in the Ceph Dashboardserve()
method, right after CherryPy has been configured, but before it is started. It’s a placeholder for the plug-in initialization logic.HasOptions
: requires overridingget_options()
hook by returning a list ofOptions()
. The options returned here are added to theMODULE_OPTIONS
.HasCommands
: requires overridingregister_commands()
hook by defining the commands the plug-in can handle and decorating them with@CLICommand
. The commands can be optionally returned, so that they can be invoked externally (which makes unit testing easier).HasControllers
: requires overridingget_controllers()
hook by defining and returning the controllers as usual.FilterRequest.BeforeHandler
: requires overridingfilter_request_before_handler()
hook. This method receives acherrypy.request
object for processing. A usual implementation of this method will allow some requests to pass or will raise acherrypy.HTTPError
based on therequest
metadata and other conditions.
New interfaces and hooks should be added as soon as they are required to implement new functionality. The above list only comprises the hooks needed for the existing plugins.
A sample plugin implementation would look like this:
# src/pybind/mgr/dashboard/plugins/mute.py
from . import PLUGIN_MANAGER as PM
from . import interfaces as I
from mgr_module import CLICommand, Option
import cherrypy
@PM.add_plugin
class Mute(I.CanMgr, I.Setupable, I.HasOptions, I.HasCommands,
I.FilterRequest.BeforeHandler, I.HasControllers):
@PM.add_hook
def get_options(self):
return [Option('mute', default=False, type='bool')]
@PM.add_hook
def setup(self):
self.mute = self.mgr.get_module_option('mute')
@PM.add_hook
def register_commands(self):
@CLICommand("dashboard mute")
def _(mgr):
self.mute = True
self.mgr.set_module_option('mute', True)
return 0
@PM.add_hook
def filter_request_before_handler(self, request):
if self.mute:
raise cherrypy.HTTPError(500, "I'm muted :-x")
@PM.add_hook
def get_controllers(self):
from ..controllers import ApiController, RESTController
@ApiController('/mute')
class MuteController(RESTController):
def get(_):
return self.mute
return [MuteController]
Additionally, a helper for creating plugins SimplePlugin
is provided. It
facilitates the basic tasks (Options, Commands, and common Mixins). The previous
plugin could be rewritten like this:
from . import PLUGIN_MANAGER as PM
from . import interfaces as I
from .plugin import SimplePlugin as SP
import cherrypy
@PM.add_plugin
class Mute(SP, I.Setupable, I.FilterRequest.BeforeHandler, I.HasControllers):
OPTIONS = [
SP.Option('mute', default=False, type='bool')
]
def shut_up(self):
self.set_option('mute', True)
self.mute = True
return 0
COMMANDS = [
SP.Command("dashboard mute", handler=shut_up)
]
@PM.add_hook
def setup(self):
self.mute = self.get_option('mute')
@PM.add_hook
def filter_request_before_handler(self, request):
if self.mute:
raise cherrypy.HTTPError(500, "I'm muted :-x")
@PM.add_hook
def get_controllers(self):
from ..controllers import ApiController, RESTController
@ApiController('/mute')
class MuteController(RESTController):
def get(_):
return self.mute
return [MuteController]
由 Ceph 基金会带给您
Ceph 文档是一个社区资源,由非盈利的 Ceph 基金会资助和托管Ceph Foundation. 如果您想支持这一点和我们的其他工作,请考虑加入现在加入.