注意

本文档适用于 Ceph 的开发版本。

Ceph Dashboard开发者文档

功能设计

为了促进新Ceph Dashboard功能的协作,第一步是定义设计文档。这些文档随后构成了实现范围的基础,并允许更广泛地参与Ceph Dashboard UI的演进。

设计文档

初步步骤

以下文档章节要求运行中的Ceph集群和至少一个运行中的dashboard管理器模块(少数例外)。本章介绍如何为开发设置这样的系统,而无需设置一个成熟的生产环境。本章介绍的所有选项都基于所谓的vstart环境。

注意

每个vstart环境都需要从其GitHub仓库编译Ceph,尽管Docker环境通过提供包含这些指令的shell脚本简化了这一步骤。

此规则的一个例外是ceph-dev免构建功能。更多信息请参见下文。

vstart

"vstart"实际上是Ceph仓库(src/vstart.sh) src/目录中的一个shell脚本。它用于在执行它的机器上启动一个单节点Ceph集群。当它用于启动Ceph集群时,几个必需的和一些可选的Ceph内部服务会自动启动。vstart是Ceph Dashboard中三个最常用的开发环境的基础。

您可以在部署开发集群中阅读更多关于vstart的信息。开发者附加信息也可以在开发者指南中找到。

基于宿主机的开发环境 vs 基于Docker的开发环境

本文档向您介绍了三种不同的开发环境,它们都基于vstart。它们是

  • 在宿主机系统上运行的vstart

  • 在Docker环境中运行的vstart

    除了它们独立的开发分支和有时略有不同的方法外,它们在底层操作系统方面也存在差异。

    版本

    ceph-dev-docker

    ceph-dev

    Mimic

    openSUSE Leap 15

    CentOS 7

    Nautilus

    openSUSE Leap 15

    CentOS 7

    Octopus

    openSUSE Leap 15.2

    CentOS 8

    Master

    openSUSE Tumbleweed

    CentOS 8

注意

无论您选择这些环境中的哪一个,您都需要在该环境中编译Ceph。如果您在宿主机系统上编译了Ceph,您将不得不在Docker上重新编译它才能切换到基于Docker的解决方案。反之亦然。如果您以前使用过Docker开发环境并在那里编译了Ceph,而现在想切换到宿主机系统,您也将需要重新编译Ceph(或者使用另一个单独的仓库编译Ceph)。

ceph-dev是此规则的一个例外,因为它提供的一个选项是免构建。这是通过使用RPM系统包安装Ceph实现的。您仍然可以像往常一样使用本地GitHub仓库。

宿主机系统上的开发环境

  • 无需学习或拥有Docker经验,即可立即上手。

  • 支持自动化(如Ceph编译)的脚本数量有限。

  • 没有预配置易于启动的服务(Prometheus,Grafana等)。

  • 支持的宿主机操作系统数量有限,具体取决于要使用的Ceph版本。

  • 依赖项需要安装在您的宿主机上。

  • 您可能会发现自己需要升级宿主机操作系统(例如,由于用于编译Ceph的GCC版本发生变化)。

基于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的文件,它在启动vstart集群之前或之后清理日志文件,始终启动Rados Gateway服务,设置一些Ceph Dashboard配置选项并自动运行前端代理。

有关如何使用这些环境的说明包含在其各自的仓库 README 文件中。

前端开发

在开发环境中启动仪表板之前,您需要生成前端代码,并使用已编译并运行的Ceph集群(例如由vstart.sh启动)或独立的开发Web服务器。

构建过程基于 Node.js,需要安装 Node Package Manager npm

先决条件

  • Node 20.18.1 或更高版本

  • NPM 10.5.2 或更高版本

nodeenv

在Ceph构建过程中,我们创建了一个安装了nodenpm的虚拟环境,可以作为在系统上安装node/npm的替代方案。

如果您想使用虚拟环境中安装的node,您只需要在运行任何npm命令之前激活虚拟环境。要激活它,请运行. build/src/pybind/mgr/dashboard/node-env/bin/activate

完成后,您可以简单地运行deactivate并退出虚拟环境。

Angular CLI

如果您没有全局安装Angular CLI,那么您需要在ng命令前加上npm run

包安装

在目录 src/pybind/mgr/dashboard/frontend 中运行 npm ci 来本地安装所需的包。

添加或更新包

运行以下命令来添加/更新包

npm install <PACKAGE_NAME>
npm ci

设置开发服务器

根据 proxy.conf.json.sample 创建 proxy.conf.json 文件。

运行 npm start 启动开发服务器。导航到 https://:4200/。如果您更改任何源文件,应用程序将自动重新加载。

代码脚手架

运行 ng generate component component-name 来生成一个新的组件。您也可以使用 ng generate directive|pipe|service|class|guard|interface|enum|module

构建项目

运行 npm run build 来构建项目。构建的 artifact 将存储在 dist/ 目录中。使用 --prod 标志进行生产构建(npm run build -- --prod)。导航到 https://:8443

构建代码文档

运行 npm run doc-build 以在 documentation/ 目录中生成代码文档。要使它们在本地可供网页浏览器访问,请运行 npm run doc-serve,它们将在 https://:8444 上可用。使用 npm run compodoc -- <opts>,您可以完全配置它

代码 Linting 和格式化

我们使用以下工具来对所有 TS、SCSS 和 HTML 文件中的代码进行 Linting 和格式化

我们添加了2个npm脚本来帮助运行这些工具

  • npm run lint,将检查所有linter的前端文件

  • npm run fix,将尝试修复所有检测到的linting错误

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,主题化和维护就越容易,bug 也会越少。此外,由于 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 清除 jest 的缓存

运行端到端 (E2E) 测试

我们使用 Cypress 运行我们的前端 E2E 测试。

E2E先决条件

您需要事先构建前端。

在某些环境中,根据您的用户权限和 CYPRESS_CACHE_FOLDER,您可能需要使用 --unsafe-perm 标志运行 npm ci

您可能需要安装额外的包才能运行Cypress。请运行npx cypress verify进行验证。

run-frontend-e2e-tests.sh

我们的run-frontend-e2e-tests.sh脚本是您想要进行全面e2e运行的首选解决方案。它将验证是否已安装所有必需项,启动一个新的vstart集群并运行完整的测试套件。

启动所有前端 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集群。如果您想在ceph环境之外运行测试,则需要手动使用-r定义仪表板URL,并可选地定义凭据(-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 作为 Orchestrator 后端是否正常工作。

前提条件:您需要在本地机器上安装 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
注意

如果需要一个准备好部署服务的集群(一个在不同主机上分布了足够多的 monitor daemon 和足够多的 OSD 的集群),请添加 --expanded

运行以下命令测试您的更改

$ ./src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh

通过运行以下命令关闭集群

$ kcli delete plan -y ceph $ # 在开发模式下,也杀死 npm 构建监视进程(例如,pkill -f “ng build”)

其他运行选项

在积极开发期间,不建议运行上一个脚本,因为它不适用于不断的文件更改。相反,您应该使用以下命令之一

  • npm run e2e - 这将运行 ng serve 并打开 Cypress Test Runner。

  • npm run e2e:ci - 这将运行 ng serve 并运行 Cypress Test Runner 一次。

  • npx cypress run - 这直接调用cypress并运行Cypress Test Runner。您需要有一个正在运行的前端服务器。

  • npx cypress open - 这直接调用cypress并打开Cypress Test Runner。您需要有一个正在运行的前端服务器。

直接调用 Cypress 的优势在于您可以使用任何可用的标志来自定义您的测试运行,并且您无需每次都启动前端服务器。

使用其中一个open命令将打开一个cypress应用程序,您可以在其中查看所有测试文件并单独运行它们。这将以监视模式运行,因此如果您对测试文件进行任何更改,它将重新触发测试运行。这不能在docker内部使用,因为它需要X11环境才能打开。

默认情况下,Cypress 将在 https://:4200/ 查找网页。如果您在不同的 URL 提供服务,则需要通过导出环境变量 CYPRESS_BASE_URL 并使用新值进行配置。例如:CYPRESS_BASE_URL=https://: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 时,它将返回到默认目录。建议您使用固定目录导出 CYPRESS_CACHE_FOLDER 环境变量,以便无论您使用哪个命令,始终使用相同的目录。

编写端到端测试

PagerHelper类

PageHelper 类旨在用于可在各种页面或套件上使用的通用代码。

例如

  • navigateTo() - 导航到特定页面并等待其加载

  • getFirstTableCell() - 返回第一个表格单元格。您还可以传入一个带有所需内容的字符串,它将返回包含该内容的第一个单元格。

  • getTabsCount() - 返回标签页的数量

任何在多个页面上可能有用的方法都属于此处。此外,增强 PageHelper 派生类的方法也属于此处。这种情况下一个很好的例子是 restrictTo() 装饰器。它确保 PageHelper 子类中实现的方法在正确的页面上被调用。如果不是这种情况,它还会显示一个对开发人员友好的警告。

PageHelper的子类

帮助方法

为了使特定套件的代码可重用,请确保将其放入 PageHelper 的派生类中。例如,当谈到池套件时,此类方法将是 create()exist()delete()。这些方法是池特有的,但对其他套件很有用。

返回只能在特定页面上找到的 HTML 元素的方法,应在 PageHelper 子类的辅助方法中实现,或作为 PageHelper 子类自己的方法实现。

使用PageHelpers

在任何套件中,都应实例化并直接调用特定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() vs it()

describe()it() 都是函数块,这意味着测试所需的任何可执行代码都可以包含在任一块中。然而,Typescript 作用域规则仍然适用,因此在 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' () => ...)it('image edit test' () => ...)。如所示,第一个例子在it()作为前缀时在语法上是合理的,而第二个消息则不然。it()应该描述单个测试正在做什么以及它期望发生什么。

视觉回归测试

对于视觉回归测试,我们使用 Applitools Eyes,这是一个由 AI 驱动的自动化视觉回归测试工具。Applitools 集成了我们现有的 Cypress E2E 测试。测试目前位于: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中的视觉回归测试

目前,所有视觉回归测试都在 Jenkins 作业中的 ceph dashboard tests GitHub 检查下运行。

接受或拒绝差异

目前,只有ceph dashboard团队对applitools测试运行器具有读写访问权限。如果测试报告了任何差异,并且您想接受它们并更新基线截图,或者差异是由于真正的回归,您可以使其失败。要执行上述操作,请遵循指南。

调试回归

如果您在本地运行测试并报告了回归,您可以利用 Applitools 的根本原因分析功能来查找回归的原因。

前端单元测试与端到端 (E2E) 测试的区别 / 常见问题

关于测试和 E2E/单元测试的一般介绍

E2E/单元测试旨在做什么?

E2E测试

它需要一个功能齐全的系统,并测试应用程序所有组件(Ceph、后端、前端)的交互。E2E 测试旨在模拟用户与应用程序交互时的行为——例如,在创建/编辑/删除项目的流程中。此外,测试应验证是否显示某些项目,就像用户在点击 UI 时看到的那样(例如,菜单项或在测试期间创建的池,并且该池及其属性应显示在表格中)。

Angular 单元测试

单元测试,顾名思义,是针对代码较小单元的测试。这些测试旨在测试各种Angular组件(例如服务、管道等)。它们不需要连接到后端,因此这些测试独立于后端。后端预期的数据在前端被模拟,通过使用这些数据,可以测试前端的功能,而无需来自后端的真实数据。如前所述,数据要么被模拟,要么在简单情况下包含静态输入、函数调用和预期的静态输出。更复杂的示例包括组件的状态(组件类的属性),它们定义了输出如何根据给定输入而改变。

哪些E2E/单元测试被认为是有效的?

这并不容易回答,但以与现有仪表板测试相同的方式编写的新测试通常应被视为有效。单元测试应侧重于要测试的组件。这可以是 Angular 组件、指令、服务、管道等。

E2E测试应侧重于测试整个应用程序的功能。大约三分之一的 E2E 测试应验证用户可见元素的正确性。

E2E/单元测试应该是什么样子?

单元测试应专注于所描述的目的,不应试图在同一个 it 块中测试其他事物。

E2E测试应包含一个描述,要么验证用户可见元素的正确性,要么验证一个完整的流程,例如池的创建/验证/删除。

E2E/单元测试应该覆盖什么?

E2E测试应主要但不完全覆盖与后端的交互。通过这种方式,与后端的交互被用于编写集成测试。

单元测试应主要覆盖组件(Angular组件、服务、管道、指令等)的关键或复杂功能。

E2E/单元测试不应该覆盖什么?

避免重复测试:不要为前端单元测试已覆盖的内容编写 E2E 测试,反之亦然。完全避免重叠可能是不可能的。

单元测试不应用于广泛地点击组件,E2E测试不应用于广泛地测试 Angular 的单个组件。

最佳实践/指南

作为一般准则,我们尝试遵循 70/20/10 方法——70% 单元测试,20% 集成测试,10% 端到端测试。更多信息请参阅本文档和其中包含的“测试金字塔”。

进一步帮助

要获取关于 Angular CLI 的更多帮助,请使用 ng help 或查看 Angular CLI README

生成器示例

# 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、外部或内部的。

示例

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

帮助器

此组件应提供额外信息给用户。

示例

<cd-helper>
  Some <strong>helper</strong> html text
</cd-helper>

术语和措辞

建议使用逻辑/通用名称(Block 优于 RBD,Filesystem 优于 CephFS,Object 优于 RGW),而不是使用 Ceph 组件名称。尽管如此,由于 Ceph-Dashboard 无法完全隐藏 Ceph 内部细节,某些 Ceph 特定的名称可能仍然可见。

关于操作标签和其他文本元素(表单标题、按钮等)的措辞,所选择的方法是遵循这些准则。一般而言,“创建”和“删除”是大多数表单的正确措辞,而不是“添加”和“移除”,除非是将已创建的项目添加或从一组项目中移除(例如:“向用户添加权限”与“创建(新)权限”)。

为了强制使用此措辞,已创建了一个服务 ActionLabelsI18n,它为 UI 元素提供了翻译标签。

前端品牌

每个供应商都可以根据自己的需求定制“Ceph Dashboard”。无论是徽标、HTML 模板还是 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使用glob表达式。只要该功能尚未实现,您就必须手动将文件替换添加到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 库。

避免代码重复。通过尽可能重复使用现有的 SCSS 声明,与现有 UI 保持一致。

始终检查是否存在与您要编写的代码相似的现有代码。您应该始终尝试保持与现有代码相同的外观和感觉。

颜色

Ceph Dashboard UI 中使用的所有颜色都列在 frontend/src/styles/defaults/_bootstrap-defaults.scss 中。如果使用新颜色,始终在 _bootstrap-defaults.scss 中定义颜色变量,并使用变量而不是硬编码的颜色值,以便对颜色的更改反映在类似的 UI 元素中。

Ceph Dashboard 的主色是 $primary。主色用于导航组件,并作为表单输入组件的 $border-color

辅助颜色是 $secondary,是 Ceph Dashboard 的背景色。

按钮

按钮用于执行以下操作:“提交”、“编辑”、“创建”和“更新”。

表单:在仪表板中任何地方提交表单时,主操作按钮应使用cd-submit-button组件,辅助按钮应使用cd-back-button组件。操作按钮上的文本应与表单标题相同,并遵循标题大小写。辅助按钮上的文本应为Cancel执行操作按钮应始终在右侧,而取消按钮应始终在左侧。

模态框:主要操作按钮应使用cd-submit-button组件,次要按钮应使用cd-back-button组件。主要操作按钮上的文本应遵循标题大小写并与要执行的操作相对应。次要按钮上的文本应为关闭

披露按钮:披露按钮应用于允许用户在界面中显示和隐藏额外内容。

操作按钮:使用操作按钮执行编辑或更新组件等操作。所有操作按钮都应具有与其执行的操作相对应的图标,按钮文本应遵循标题大小写。按钮颜色应与表单主按钮颜色相同。

下拉按钮:使用下拉按钮显示预定义的操作列表。所有下拉按钮都有与其执行的操作相对应的图标。

表单

用红色轮廓标记无效表单字段,并显示有意义的错误消息。消息使用红色字体颜色,并尽可能具体。此字段为必填项。应为必填字段的精确错误消息。用绿色轮廓和表单末尾的绿色勾号标记有效表单。节的标题不应大于父级标题。

模态框

模糊背景中的任何界面元素,以使模态内容成为焦点。模态框的标题应反映其可执行的操作,并应在模态框顶部清晰提及。使用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 中找到。要报告新错误,您只需在 error.ts 文件中扩展 DashboardError 类,并为新错误添加特定的标题和消息。一些通用错误类已经存在,例如 DashboardNotFoundErrorDashboardForbiddenError,可以在不同的场景中调用和重用。

例如 - throw new DashboardNotFoundError()

国际化 (i18n)

如何从源代码中提取消息?

要从模板和TypeScript文件中提取I18N消息,只需在src/pybind/mgr/dashboard/frontend中运行以下命令

$ npm run i18n:extract

这将首先从 HTML 模板中提取所有标记的消息,然后将 TypeScript 文件中所有标记的字符串添加到翻译模板中。由于 Angular 本身尚不支持从 TypeScript 文件中提取,我们使用 ngx-translator 提取器来解析 TypeScript 文件。

命令成功运行后,应该会创建或更新文件 src/locale/messages.xlf

该文件未被 git 跟踪,您只需使用它即可开始离线翻译或在 transifex 上添加/更新资源文件。

支持的语言

我们所有支持的语言都应在 supported-languages.enum.ts 的两个导出中注册,并在 language-selector.component.spec.ts 中具有相应的测试。

SupportedLanguages 枚举将提供默认语言选择的列表。

翻译流程

为了方便仪表板的翻译过程,我们正在使用一个名为 transifex 的网络工具。

如果您希望帮助翻译成任何语言,只需前往我们的transifex项目页面,加入项目,即可立即开始翻译。

所有翻译都将经过审查,然后推送到上游。

更新翻译消息

  1. 下载 transifex CLI 工具

  2. 创建 API token

  3. 推送翻译

$ tx push -s

这将把源文件推送到transifex。

  1. 拉取翻译

$ 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

为:: resource_name = <Release-name>

例如,Tentacle 发布的资源定义:resource_name = Tentacle

并替换:: [o:ceph:p:ceph-dashboard:r:main]

为:: [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 级可访问性一致性指南。通过实施可访问性最佳实践,您可以改善 Ceph Dashboard 对盲人和视障用户的可用性。

摘要

在引入新的代码更改之前,您应该检查以下几点

  1. 为可操作的 HTML 元素添加 ARIA 标签和描述

  2. 不要忘记为 ARIA 标签/描述或任何用户可读文本添加翻译标签 (i18n-title, i18n-aria-label…)。

  3. 添加 ARIA 角色 以标记行为与预期行为不同的 HTML 元素(行为像 <buttons> 的 <a> 标签)或提供扩展行为(角色)的 HTML 元素。

  4. 在样式化组件时,避免选择糟糕的 颜色对比度(前景色-背景色)。这里有一些您可以使用的工具

  5. 测试菜单或下拉菜单时,请务必在打开和关闭状态下使用辅助功能检查器进行扫描。有时菜单关闭时问题会隐藏。

辅助功能检查器

在开发过程中,您可以使用以下工具之一测试您功能的辅助功能合规性

使用两种或更多工具进行测试可以大大提高辅助功能违规的检测率。

颜色对比度检查器

添加新颜色时,确保它们可访问也很重要。以下是一些有助于颜色对比度测试的工具

辅助功能 linter

如果您使用 VSCode,您可以安装 axe 辅助功能 linter,它可以在开发过程中帮助您发现并修复潜在问题。

辅助功能测试

我们的 e2e 测试套件基于 Cypress,支持使用 axe-corecypress-axe 添加辅助功能测试。自定义 Cypress 命令 cy.checkAccessibility 也可以直接使用。这是防止高影响组件出现辅助功能回归的好方法。

测试可以在 Dashboard 的 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 模式来实现辅助功能修复,可以使用 patternfly 指南。

后端开发

此模块的 Python 后端代码需要安装多个 Python 模块。它们列在文件 requirements.txt 中。使用 pip,您可以通过在目录 src/pybind/mgr/dashboard 中发出 pip install -r requirements.txt 来安装所有必需的依赖项。

如果您正在使用 ceph-dev-docker 开发环境,只需从顶层目录运行 ./install_deps.sh 即可安装它们。

单元测试

在仪表板中,我们有两种不同类型的后端测试

  1. 基于 tox 的单元测试

  2. 基于 Teuthology 的 API 测试。

基于 tox 的单元测试

我们包含了一个 tox 配置文件,它将在 Python 3 下运行单元测试,以及用于保证代码统一性的 linting 工具。

在运行之前,您需要安装 toxcoverage。要在您的系统中安装这些包,可以通过操作系统的包管理工具进行安装,例如,在 Fedora Linux 上运行 dnf install python-tox python-coverage

或者,您可以使用 Python 的原生包安装方法

$ pip install tox
$ pip install coverage

要运行测试,请在 Dashboard 目录(tox.ini 所在位置)中运行 src/script/run_tox.sh

## 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 文件可以使用 run_tox.sh --tox-env fixtox -e fix 根据 PEP8 标准自动修复和格式化。

当您运行测试时,我们还会从后端代码收集覆盖率信息。您可以检查 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 目录中现有的测试类之一。

第二种方法是,例如,如果您正在创建一个新的控制器,则添加您自己的 API 测试模块。为此,您只需将包含新测试类的文件添加到 qa/tasks/mgr/dashboard 目录并在此处实现所有测试。

注意

别忘了将新创建的模块的路径添加到 qa/suites/rados/mgr/tasks/dashboard.yaml 文件中的 modules 部分。

简短示例:假设您创建了一个名为 my_new_controller.py 的新控制器以及相关的测试模块 test_my_new_controller.py。您需要将 tasks.mgr.dashboard.test_my_new_controller 添加到 dashboard.yaml 文件中的 modules 部分。

此外,如果您要删除测试模块,请记住删除相关部分。否则,Teuthology 测试运行将失败。

请在提交拉取请求之前在您的开发环境(如上所述)上运行您的 API 测试。此外,请确保在合并之前,Teuthology/sepia 实验室中(基于您的更改)的完整 QA 运行已成功完成。您不需要自己安排 QA 运行,只需在您认为拉取请求准备好合并时(例如,检查成功,拉取请求已批准,所有评论都已解决)向其添加“needs-qa”标签。有权访问 Teuthology/sepia 实验室的开发人员之一将负责处理并向您报告结果。

如何添加新的控制器?

控制器是一个 Python 类,它继承自 BaseController 类,并使用 @Controller@ApiController@UiApiController 装饰器进行装饰。Python 类必须存储在 controllers 目录下的 Python 文件中。Dashboard 模块将在启动时自动加载您的新控制器。

@ApiController@UiApiController 都是 @Controller 装饰器的特化。

@ApiController 应该用于提供类似 API REST 接口的控制器,而 @UiApiController 应该用于由 UI 消费但不属于“公共”API 的端点。对于任何其他类型的控制器,应该使用 @Controller 装饰器。

控制器关联有一个在控制器装饰器中指定的 URL 前缀路径,并且控制器公开的所有端点将共享相同的 URL 前缀路径。

控制器的端点通过实现控制器类中用 @Endpoint 装饰器装饰的方法来公开。

例如,在 controllers 目录下创建一个名为 ping.py 的文件,其代码如下

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"}

Ping 控制器的 hello 端点可以通过以下 URL 访问:https://mgr_hostname:8443/ping/hello,使用 HTTP GET 请求。如您所见,控制器 URL 路径 /ping 连接到方法名 hello 以生成端点的 URL。

ApiPing 控制器的情况下,hello 端点可以通过以下 URL 访问:https://mgr_hostname:8443/api/ping/hello,使用 HTTP GET 请求。API 控制器 URL 路径 /ping/api 路径为前缀,然后连接到方法名 hello 以生成端点的 URL。在内部,@ApiController 实际上通过传递一个名为 base_url 的附加装饰器参数来调用 @Controller 装饰器

@ApiController('/ping') <=> @Controller('/ping', base_url="/api")

UiApiPing 的工作方式与 ApiPing 类似,但 URL 将以 /ui-api 为前缀:https://mgr_hostname:8443/ui-api/ping/helloUiApiPing 也是 @Controller 的扩展

@UiApiController('/ping') <=> @Controller('/ping', base_url="/ui-api")

@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 方法,方法参数可能被视为路径参数、查询参数或请求体参数。

对于 GETDELETE 方法,方法的非可选参数默认被视为路径参数。可选参数被视为查询参数。通过在端点装饰器中指定 query_parameters,可以将非可选参数设置为查询参数。

对于 POSTPUT 方法,所有方法参数默认被视为请求体参数。要覆盖此默认值,可以使用 path_paramsquery_params 分别指定哪些方法参数是路径参数和查询参数。请求体参数从请求体中解码,可以是表单格式,也可以是 JSON 格式的字典。

让我们用一个例子更好地理解自定义端点的可能方式

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 设置为 "/" 以生成可通过控制器根 URL 访问的端点。

另一种生成仅可通过控制器路径 URL 访问的端点的方法是使用 __call__ 方法,如我们在上面的示例中所示。

从第三种方法可以看出,路径参数是通过解析 URL 路径 /ping(对于 index 方法)和 /ping/post(对于 post 方法)之后以斜杠 / 分隔的值列表来从 URL 收集的。

使用 Python 方法参数在端点 URL 中定义路径参数非常容易,但对于这些参数在 URL 结构中的位置仍然有点严格。有时我们可能希望明确定义包含与 URL 静态部分混合的路径参数的 URL 方案。我们的控制器基础设施还支持在控制器级别和方法级别声明具有显式路径参数的 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},以及 latency 方法中的路径参数 {date}。然后可以通过 URL 访问 latency 方法的端点:https://mgr_hostname:8443/ping/{node}/stats/{date}/latency 。

有关如何使用 @Endpoint 装饰器的完整示例集,请查看单元测试文件:tests/test_controllers.py。在那里您将找到许多自定义端点方法的示例。

实现代理控制器

有时您可能需要将一些来自 Dashboard 前端的请求直接转发到外部服务。为此,我们提供了一个名为 @Proxy 的装饰器。(作为一个具体示例,请查看 controllers/rgw.py 文件,我们在其中实现了一个 RGW 管理操作代理。)

@Proxy 装饰器是 @Endpoint 装饰器的一个包装器,它已经自定义了端点以作为代理工作。代理端点通过捕获控制器 URL 前缀路径后面的 URL 路径来工作,并且不进行任何请求体的解码。

示例

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。

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 方法自动用于对 api/ping 的所有请求,其中未给出额外参数且请求类型为 GET。如果请求给出了一个额外参数(在我们的例子中是 ID),它将不再映射到 list,而是映射到 get 并返回具有给定 ID 的元素(假设 self.objects 之前已填充)。这同样适用于其他请求类型

请求类型

参数

方法

状态码

GET

列出

200

PUT

bulk_set

200

POST

create

201

DELETE

bulk_delete

204

GET

get

200

PUT

set

200

DELETE

delete

204

要为上述方法使用自定义端点,可以使用 @RESTController.MethodMap

import cherrypy
from ..tools import ApiController, RESTController

  @RESTController.MethodMap(version='0.1')
  def create(self):
    return {"msg": "Hello"}

此装饰器支持三个参数来自定义端点

  • resource":资源 ID。

  • status=200:设置 HTTP 状态响应码

  • version:版本

如何在 RESTController 中使用自定义 API 端点?

如果您没有任何访问限制,可以使用 @Endpoint。如果您已设置权限范围以限制对端点的访问,则 @Endpoint 将失败,因为它不知道应该使用哪个权限属性。要在受限制的 RESTController 中使用自定义端点,请改用 @RESTController.Collection。如果您已在 RESTController 类中设置了 RESOURCE_ID,您也可以选择 @RESTController.Resource

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 添加到控制器装饰器中。

示例

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 控制器类。现在您可以使用 API 控制器的所有方法。

示例

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]

如何从控制器访问管理器模块实例?

我们提供管理器模块实例作为一个全局变量,可以在任何模块中导入。

示例

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 控制器编写单元测试,请在 tests 目录下创建一个 test_ping.py 文件,其中包含以下代码

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'})

ControllerTestCase 类通过初始化 CherryPy Web 服务器开始。然后它将调用 setup_test() 类方法,我们可以在其中显式加载要测试的控制器。在上面的示例中,我们只加载 Ping 控制器。我们还可以提供 cp_config 以更新控制器的 cherrypy 配置(例如,如示例所示启用身份验证)。

如何在 Grafana 中更新或创建新的仪表板?

我们正在使用 jsonnetgrafonnet-lib 来编写 Grafana 仪表板的代码。所有仪表板都写入监控/grafana/dashboards/jsonnet 目录下的 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)

要添加图形面板,我们可以在本地函数中指定图形 esquema,例如 -

local graphPanelSchema(title, nullPointMode, stack, formatY1, formatY2, labelY1, labelY2, min, fill, datasource)

然后将这些函数用于仪表板定义,如下所示:

{
    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, .., .., ..)
    ])
}

有效的 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

如您所见,NotificationQueue 类提供了一个注册方法,该方法将函数作为其第一个参数,并将“通知类型”作为第二个参数。您可以省略 register 方法的第二个参数,在这种情况下,您将注册监听任何类型的所有通知。

以下是可用的通知类型列表(这些在将来可能会更改)

  • clog:集群日志通知

  • commandMgrModule.send_command 发出的命令完成时的通知

  • perf_schema_update:性能计数器 schema 更新

  • 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'}])

如何添加新的配置设置?

如果您需要为新功能存储一些配置设置,我们已经提供了一种简单的机制供您指定/使用新的配置设置。

例如,如果您想添加一个新配置设置来保存仪表板管理员的电子邮件地址,只需将设置名称作为类属性添加到 settings.py 文件中的 Options 类中

# ...
class Options(object):
  # ...

  ADMIN_EMAIL_ADDRESS = ('admin@admin.com', str)

类属性的值是一个由该设置的默认值和值的 Python 类型组成的对。

通过声明 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 镜像删除任务,指定 "rbd/remove"

  • metadata 是一个字典,我们可以在其中存储描述任务的键值对。例如,在创建用于创建 RBD 镜像的任务时,我们可以将 metadata 参数指定为 {'pool_name': "rbd", image_name': "test-img"}

  • func 是实现操作代码的 Python 函数,它将异步执行。

  • argskwargs 是在任务管理器开始执行时将传递给 func 的位置参数和命名参数。

TaskManager.run 方法触发函数 func 的异步执行并返回一个 Task 对象。Task 提供公共方法 Task.wait(timeout),该方法可用于等待任务完成,最长等待时间由作为参数提供的秒数定义。如果未提供参数,wait 方法将阻塞直到任务完成。

Task.wait 对于通常执行快速但有时可能需要很长时间才能运行的任务非常有用。Task.wait 方法的返回值是一个对 (state, value),其中 state 是一个具有以下可能值的字符串

  • VALUE_DONE = "done"

  • VALUE_EXECUTING = "executing"

如果 state == VALUE_DONE,则 value 将存储函数 func 的执行结果。如果 state == VALUE_EXECUTING,则 value == None

(name, metadata) 应明确标识正在运行的任务,这意味着如果您尝试触发一个与当前正在运行的任务的 (name, metadata) 对匹配的新任务,则不会创建新任务,并且您将获得当前正在运行的任务的任务对象。

例如,考虑以下示例

task1 = TaskManager.run("dummy/task", {'attr': 2}, func)
task2 = TaskManager.run("dummy/task", {'attr': 2}, func)

如果对 TaskManager.run 的第二次调用在第一个任务仍在执行时执行,则它将返回相同的任务对象:assert task1 == task2

如何获取正在执行和已完成的异步任务列表?

正在执行和已完成的任务列表包含在 Summary 控制器中,该控制器已由仪表板前端每 5 秒轮询一次。但我们还提供了一个专用控制器来获取相同的正在执行和已完成任务列表。

Task 控制器公开了 /api/task 端点,该端点返回正在执行和已完成的任务列表。此端点接受 name 参数,该参数接受 glob 表达式作为其值。例如,URL /api/task?name=rbd/* 的 HTTP GET 请求将返回所有名称以 rbd/ 开头的正在执行和已完成的任务。

为了防止已完成任务列表无限增长,我们将始终保留最近的 10 个已完成任务,其余较旧的已完成任务将在达到 1 分钟的 TTL 时被删除。TTL 是使用任务完成执行的时间戳计算的。一分钟后,当通过摘要控制器或任务控制器检索到已完成任务信息时,它将自动从列表中删除,并且不再包含在未来的任务查询中。

每个正在执行的任务由以下字典表示

{
  '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 与异步任务一起使用?

如前一节所述,TaskManager.run 方法非常适合调用阻塞函数,因为它在新创建的线程中运行该函数。但有时我们想调用 API 中某个函数,该函数本质上已经是异步的。

对于这些情况,我们希望避免为仅运行非阻塞函数而创建新线程,并希望利用函数的异步性质。TaskManager.run 已准备好通过传递 TaskExecutor 类型的对象作为名为 executor 的附加参数来与非阻塞函数一起使用。TaskManager.run 的完整方法签名

TaskManager.run(name, metadata, func, args=None, kwargs=None, executor=None)

TaskExecutor 类负责执行给定任务函数的代码,并定义了三个可由子类重写的方法

def init(self, task)
def start(self)
def finish(self, ret_value, exception)

init 方法在运行任务函数之前调用,并接收任务对象(Task 类)。

start 方法运行任务函数。默认实现是在当前线程上下文中运行任务函数。

finish 方法应该在任务函数完成时调用,ret_value 填充执行结果,或者在执行引发异常的情况下填充异常对象。

为了利用非阻塞函数的异步性质,开发人员应该通过创建 TaskExecutor 类的子类来实现自定义执行器,并将自定义执行器类的实例作为 TaskManager.runexecutor 参数提供。

为了更好地理解执行器的表达能力,我们编写了一个使用自定义执行器执行 MgrModule.send_command 异步函数的完整示例

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}

上述 SendCommandExecutor 执行器类可用于对 MgrModule.send_command 的任何调用。这意味着我们每个控制器中使用的非阻塞 API 只需要一个自定义执行器类实现。

当没有将执行器对象传递给 TaskManager.run 时使用的默认执行器是 ThreadedExecutor。您可以在 tools.py 文件中查看其实现。

如何更新异步任务的执行进度?

异步任务基础设施支持更新正在执行任务的执行进度。进度可以在任务正在执行的代码中更新,这通常是我们获得进度信息的地方。

为了在任务代码中更新进度,TaskManager 类提供了一种检索当前任务对象的方法

TaskManager.current_task()

上述方法仅在使用默认执行器 ThreadedExecutor 执行任务时可用。current_task() 方法返回当前的 Task 对象。Task 对象提供了两个公共方法来更新执行进度值:set_progress(percentage)inc_progress(delta) 方法。

set_progress 方法接收一个整数值作为参数,表示我们想要为任务设置的绝对百分比。

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

如何处理前端的异步任务?

所有正在执行和最近完成的异步任务都显示在菜单栏的“后台任务”中,如果已完成,则显示在“最近通知”中。对于每个任务,必须提供三个状态(运行中、成功和失败)的操作名称、一个说明涉及人员的函数以及错误描述(如果有)。这可以通过附加 TaskManagerMessageService.messages 来实现。这样做是为了确保所有任务和状态之间的一致性。

操作对象

确保所有任务之间的一致性。它由每个不同状态的三个动词组成,例如 {running: 'Creating', failure: 'create', success: 'Created'}

  1. 将正在进行的操作使用现在分词形式,例如 'Updating'

  2. 失败消息总是以 'Failed to ' 开头,并应继续使用动词的现在时形式,例如 'update'

  3. 将成功操作用过去时态表示,例如 'Updated'

涉及函数

确保任务所有消息的一致性,它类似于操作涉及的对象。它是一个返回字符串的函数,该字符串从任务的元数据中获取并返回,例如 "RBD 'somePool/someImage'"

两者结合起来创建以下消息

  • 失败 => "Failed to create RBD 'somePool/someImage'"

  • 运行中 => "Creating RBD 'somePool/someImage'"

  • 成功 => "Created RBD 'somePool/someImage'"

对于自动任务处理,请使用 TaskWrapperService.wrapTaskAroundCall

如果由于某种原因 wrapTaskAroundCall 对您不起作用,您必须通过 TaskManagerService.subscribe 手动订阅您的异步任务,并为其提供回调函数,以便在成功时通知用户。通知可以使用 NotificationService.notifyTask 触发。它将使用 TaskManagerMessageService.messages 根据任务的状态显示消息。

API 错误的通知由 ApiInterceptorService 处理。

使用示例

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 提供两种类型的 Ceph RESTful API 文档

  • 静态文档:可在 Ceph RESTful API 获得。这来自位于 src/pybind/mgr/dashboard/openapi.yaml 的版本化规范。

  • 交互式文档:可从正在运行的 Ceph-Dashboard 实例中获取(右上角 ? 图标 > API 文档)。

如果对 controllers/ 目录进行了更改,则很可能会导致生成的 OpenAPI 规范发生更改。因此,已实施一个检查器来阻止意外更改。此检查由 Pull Request CI (make check) 自动触发,也可以手动调用:tox -e openapi-check

如果该检查失败,则表示当前的 Pull Request 正在修改 Ceph API,因此

  1. 版本化的 OpenAPI 规范应明确更新:tox -e openapi-fix

  2. @ceph/api 团队将被要求进行审查(这通过 GitHub CODEOWNERS 自动完成),以评估更改的影响。

此外,Sphinx 文档可以从 OpenAPI 规范通过 tox -e openapi-doc 生成。

Ceph RESTful OpenAPI 规范是根据 controllers/ 目录中的 Controllers 动态生成的。但是,默认情况下它不是很详细,因此可以使用并且应该使用两个装饰器来添加更多信息

  • @EndpointDoc() 用于端点文档。它有四个可选参数(如下所述):descriptiongroupparametersresponses

  • @ControllerDoc() 用于控制器或与端点关联的组的文档。它只接受前两个参数:descriptiongroup

description:一个字符串,包含对象的简短(1-2 句话)描述。

group:默认情况下,端点与其他端点分组在同一个控制器类中。group 是一个字符串,可用于将一个端点或类中的所有端点分配给另一个控制器或构想的组名。

parameters:一个字典,用于描述路径、查询或请求体参数。默认情况下,端点的所有参数都列在 Swagger UI 页面上,包括参数是否可选/必需和默认值的信息。但是,不会有参数的描述,并且参数类型只会在某些情况下显示。添加信息时,每个参数都应如下例所示进行描述。请注意,参数类型应表示为内置的 python 类型,而不是字符串。允许的值为 strintboolfloat

@EndpointDoc(parameters={'my_string': (str, 'Description of my_string')})
def method(my_string): pass

对于请求体参数,可能存在更复杂的情况。如果参数是字典,则类型应替换为包含其嵌套参数的 dict。描述嵌套参数时,使用与其他参数相同的格式。但是,所有嵌套参数默认设置为必需。如果嵌套参数是可选的,则必须像下面的 item2 那样指定。如果嵌套参数设置为可选,还可以指定默认值(这不会为嵌套参数自动提供)。

@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

如果参数是基本类型的 list,则类型应使用方括号括起来。

@EndpointDoc(parameters={'my_list': ([int], 'Description of my_list')})
def method(my_list): pass

如果参数是带有嵌套参数的 list,则嵌套参数应放置在字典中并用方括号括起来。

@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:一个用于描述响应的字典。描述响应的规则与请求体参数相同,只有一个区别:响应还需要分配给相关的响应代码,如下例所示

@EndpointDoc(responses={
  '400':{'my_response': (str, 'Description of my_response')}})
def method(): pass

Python 中的错误处理

良好的错误处理是创建良好用户体验和提供良好 API 的关键要求。

仪表板代码不应重复 C++ 代码。因此,如果 C++ 中的错误处理足以提供良好的反馈,则不需要新的包装器来捕获这些错误。另一方面,输入验证是捕获错误并生成最佳错误消息的最佳位置。如果需要,请尽快生成错误。

后端提供了一些标准的返回错误的方式。

首先,有一个通用的内部服务器错误

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.",
}

对于后端生成的错误,我们提供标准错误格式

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"
}

如果 API 端点使用 @ViewCache 临时缓存结果,则错误如下所示

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
}

如果 API 端点使用任务,则错误如下所示

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": {...}
    }
}

我们的 WebUI 应该向用户显示 API 生成的错误。特别是向导和对话框中与字段相关的错误,或显示非侵入式通知。

在 Python 中处理异常应该是一种例外。通常,我们的项目中应该只有少数异常处理程序。默认情况下,将错误传播到 API,因为它无论如何都会处理所有异常。通常,通过在处理程序中添加 logger.exception() 并附带描述来记录异常。

我们需要区分用户错误、内部错误和编程错误。使用不同的异常类型将简化 API 层和用户界面的任务

标准 Python 错误,例如 SystemErrorValueErrorKeyError 将在 API 中作为内部服务器错误结束。

通常,不要在 REST API 中 return 错误响应。它们将由错误处理程序返回。相反,引发适当的异常。

插件

新功能可以通过插件架构提供。这种方法带来的好处中,松耦合开发是最显著的之一。随着 Ceph Dashboard 功能越来越丰富,其代码库也变得越来越复杂。插件架构基于钩子的特性允许以受控的方式扩展功能,并隔离更改的范围。

Ceph Dashboard 依赖 Pluggy 来提供插件支持。在 Pluggy 的基础上,已经实现了一种基于接口的方法,并带有一些安全检查(方法覆盖和抽象方法检查)。

为了创建一个新插件,需要以下步骤

  1. src/pybind/mgr/dashboard/plugins 下添加一个新文件。

  2. 导入 PLUGIN_MANAGER 实例和 Interfaces

  3. 创建一个继承自所需接口的类。插件库将检查接口的所有方法是否已正确重写。

  4. PLUGIN_MANAGER 实例中注册插件。

  5. 从 Ceph Dashboard module.py 中导入插件(目前未实现动态加载)。

可用的 Mixins(辅助类)是

  • CanMgr:为插件提供通过 self.mgr 访问 mgr 实例的权限。

可用的接口是

  • Initializable:需要重写 init() 钩子。此方法在仪表板模块的最初运行,紧接着所有导入完成后。

  • Setupable:需要重写 setup() 钩子。此方法在 Ceph Dashboard serve() 方法中运行,紧接着 CherryPy 配置完成后,但在其启动之前。它是插件初始化逻辑的占位符。

  • HasOptions:需要通过返回 Options() 列表来重写 get_options() 钩子。此处返回的选项将添加到 MODULE_OPTIONS 中。

  • HasCommands:要求通过定义插件可以处理的命令并使用 @CLICommand 装饰它们来重写 register_commands() 钩子。这些命令可以选择返回,以便它们可以被外部调用(这使得单元测试更容易)。

  • HasControllers:需要通过像往常一样定义和返回控制器来重写 get_controllers() 钩子。

  • FilterRequest.BeforeHandler:需要重写 filter_request_before_handler() 钩子。此方法接收一个 cherrypy.request 对象进行处理。此方法的通常实现将允许一些请求通过或根据 request 元数据和其他条件引发 cherrypy.HTTPError

一旦需要实现新功能,就应该添加新的接口和钩子。上面的列表仅包含现有插件所需的钩子。

一个示例插件实现将如下所示

# 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]

此外,还提供了一个用于创建插件的辅助工具 SimplePlugin。它简化了基本任务(选项、命令和常用 Mixin)。之前的插件可以这样重写

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 基金会 资助和托管的社区资源。如果您希望支持这项工作和我们的其他努力,请考虑 立即加入