From baa5bdf879fd904f7a4d6dee31b3c16e76619d77 Mon Sep 17 00:00:00 2001 From: Leopoldthecoder Date: Thu, 25 Apr 2024 13:37:09 +0800 Subject: [PATCH] feat(*): add data plane nodes page --- .ci/docker-compose.yml | 42 ++ .github/workflows/.reusable_e2e_tests_oss.yml | 23 +- package.json | 2 + src/App.vue | 16 + src/locales/en.json | 34 ++ src/pages/consumers/Detail.vue | 5 +- src/pages/data-plane-nodes/List.vue | 399 ++++++++++++++++++ src/pages/key-sets/Detail.vue | 5 +- src/pages/routes/Detail.vue | 5 +- src/pages/services/Detail.vue | 5 +- src/router.ts | 11 + src/services/apiService.ts | 12 +- src/styles/index.ts | 1 + src/utils/index.ts | 14 + tests/playwright/pages/data-plane-nodes.ts | 12 + .../01-DataPlaneNodes.spec.ts | 89 ++++ yarn.lock | 13 + 17 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 src/pages/data-plane-nodes/List.vue create mode 100644 tests/playwright/pages/data-plane-nodes.ts create mode 100644 tests/playwright/specs/data-plane-nodes/01-DataPlaneNodes.spec.ts diff --git a/.ci/docker-compose.yml b/.ci/docker-compose.yml index 806425cb..937c10e3 100644 --- a/.ci/docker-compose.yml +++ b/.ci/docker-compose.yml @@ -55,3 +55,45 @@ services: &kong-depends_on kong-db: condition: service_healthy + + kong-gen-cert: + # Using a empty string as the fallback makes this env optional but still fails if not set when used + image: "${GATEWAY_IMAGE:- }" + security_opt: + - no-new-privileges:true + command: >- + sh -c "kong hybrid gen_cert /tmp/hybrid/cluster.crt /tmp/hybrid/cluster.key" + volumes: + - "${GITHUB_WORKSPACE:-.}/hybrid:/tmp/hybrid:z" + + kong-hybrid-cp: + <<: *kong + command: >- + sh -c "kong migrations bootstrap && kong migrations up && kong migrations finish && kong start" + ports: + - "8001-8006:8001-8006" + environment: + <<: *kong-environment + KONG_PG_DATABASE: kong + KONG_ROLE: control_plane + KONG_CLUSTER_CERT: /tmp/hybrid/cluster.crt + KONG_CLUSTER_CERT_KEY: /tmp/hybrid/cluster.key + volumes: + - "${GITHUB_WORKSPACE:-.}/hybrid:/tmp/hybrid" + + kong-hybrid-dp: + <<: *kong + hostname: "" # no hostname for data planes + ports: [] # no ports for data planes + command: sh -c "kong start" + environment: + KONG_DATABASE: off + KONG_ROLE: data_plane + KONG_CLUSTER_CERT: /tmp/hybrid/cluster.crt + KONG_CLUSTER_CERT_KEY: /tmp/hybrid/cluster.key + KONG_CLUSTER_CONTROL_PLANE: kong-hybrid-cp:8005 + KONG_CLUSTER_TELEMETRY_ENDPOINT: kong-hybrid-cp:8006 + volumes: + - "${GITHUB_WORKSPACE:-.}/hybrid:/tmp/hybrid" + depends_on: + - kong-hybrid-cp # so that we can start the control plane by scaling the data planes diff --git a/.github/workflows/.reusable_e2e_tests_oss.yml b/.github/workflows/.reusable_e2e_tests_oss.yml index 4477857f..0616a9ec 100644 --- a/.github/workflows/.reusable_e2e_tests_oss.yml +++ b/.github/workflows/.reusable_e2e_tests_oss.yml @@ -25,15 +25,16 @@ jobs: - consumers - certificates - ca-certificates - - misc - - vaults + - data-plane-nodes - keys - key-sets + - misc - plugins - routes - services - snis - upstreams + - vaults fail-fast: false name: ${{ matrix.suite }} runs-on: ${{ vars.RUNS_ON }} @@ -79,7 +80,8 @@ jobs: with: current-image: ${{ inputs.gateway-image }} - - name: Start Kong + - name: Start Kong traditional + if: ${{ matrix.suite != 'data-plane-nodes' }} timeout-minutes: 10 working-directory: ${{ github.workspace }} env: @@ -90,6 +92,21 @@ jobs: docker compose -f .ci/docker-compose.yml logs exit $_compose_exit + - name: Start Kong hybrid + if: ${{ matrix.suite == 'data-plane-nodes' }} + timeout-minutes: 10 + working-directory: ${{ github.workspace }} + env: + GATEWAY_IMAGE: ${{ inputs.gateway-image }} + run: | + mkdir -p hybrid + chmod a+w ./hybrid + docker compose -f .ci/docker-compose.yml up kong-gen-cert + _compose_exit=0 + docker compose -f .ci/docker-compose.yml up -d kong-hybrid-dp --scale kong-hybrid-dp=3 --wait || _compose_exit=$? + docker compose -f .ci/docker-compose.yml logs + exit $_compose_exit + - name: Run E2E tests - OSS timeout-minutes: 10 env: diff --git a/package.json b/package.json index 3f78177b..df814e9b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@kong-ui-public/entities-certificates": "^3.0.10", "@kong-ui-public/entities-consumer-credentials": "^3.0.10", "@kong-ui-public/entities-consumers": "^3.0.10", + "@kong-ui-public/entities-data-plane-nodes": "^0.1.1-pr.1336.01d66616.0", "@kong-ui-public/entities-gateway-services": "^3.0.11", "@kong-ui-public/entities-key-sets": "^3.0.10", "@kong-ui-public/entities-keys": "^3.0.10", @@ -43,6 +44,7 @@ "@kong/kongponents": "^9.0.0-alpha.146", "@material-design-icons/font": "^0.14.9", "axios": "^1.6.0", + "dayjs": "^1.11.10", "marked": "^5.1.0", "monaco-editor": "0.21.3", "pinia": "^2.1.6", diff --git a/src/App.vue b/src/App.vue index b1f07932..3f29dbb9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,12 +16,16 @@ diff --git a/src/locales/en.json b/src/locales/en.json index 75cf045f..45c9a857 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -205,6 +205,40 @@ "detail.title": "Key Set: {name}", "create.form.title": "New Key Set", "edit.form.title": "Edit Key Set" + }, + "dp-nodes": { + "list.title": "Data Plane Nodes", + "copy.id": "Copy ID", + "copy.success": "Copied {id} to clipboard", + "not.applicable": "N/A", + "headers": { + "hostname": "Hostname", + "version": "Version", + "last.seen": "Last Seen", + "sync.status": "Sync Status", + "cert.expires.at": "Cert Expires At", + "log.level": "Log Level", + "labels": "Labels" + }, + "empty": { + "title": "No Data Plane Nodes", + "message": "There is no Data Plane Node connected to this Control Plane", + "button": "Install Plugin" + }, + "error": { + "title": "Data Plane Nodes could not be retrieved", + "fetch.fail": "Failed to fetch data planes", + "copy.fail": "Failed to copy to clipboard", + "get.log.level.fail": "Failed to fetch log level of node {id}" + }, + "bulk.actions": { + "placeholder": "{count}Bulk Actions", + "change.log.level": "Change Log Level" + }, + "tooltips": { + "revert": "Log level is scheduled to revert to {level} at {time}", + "not.supported": "Log leveling is not supported for this DP node." + } } }, "wish": { diff --git a/src/pages/consumers/Detail.vue b/src/pages/consumers/Detail.vue index 04b45f46..9f0c0b85 100644 --- a/src/pages/consumers/Detail.vue +++ b/src/pages/consumers/Detail.vue @@ -73,7 +73,10 @@ const onFetchSuccess = (entity) => { onMounted(async () => { // If the page is not loaded from the configuration tab, we need to fetch the consumer username if (route.name !== 'consumer-detail') { - const { data } = await apiService.findRecord('consumers', id.value) + const { data } = await apiService.findRecord<{ username: string, custom_id: string }>( + 'consumers', + id.value, + ) titleName.value = data.username ?? data.custom_id } diff --git a/src/pages/data-plane-nodes/List.vue b/src/pages/data-plane-nodes/List.vue new file mode 100644 index 00000000..a99ca976 --- /dev/null +++ b/src/pages/data-plane-nodes/List.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/src/pages/key-sets/Detail.vue b/src/pages/key-sets/Detail.vue index 74657543..7cebd0a9 100644 --- a/src/pages/key-sets/Detail.vue +++ b/src/pages/key-sets/Detail.vue @@ -65,7 +65,10 @@ const onFetchSuccess = (entity) => { onMounted(async () => { // If the page is not loaded from the configuration tab, we need to fetch the key set name if (route.name !== 'key-set-detail') { - const { data } = await apiService.findRecord('key-sets', id.value) + const { data } = await apiService.findRecord<{ name: string, id: string }>( + 'key-sets', + id.value, + ) titleName.value = data.name ?? data.id } diff --git a/src/pages/routes/Detail.vue b/src/pages/routes/Detail.vue index d2454ab2..60149460 100644 --- a/src/pages/routes/Detail.vue +++ b/src/pages/routes/Detail.vue @@ -81,7 +81,10 @@ const onNavigationClick = (id: string) => { onMounted(async () => { // If the page is not loaded from the configuration tab, we need to fetch the route name if (route.name !== 'route-detail') { - const { data } = await apiService.findRecord('routes', id.value) + const { data } = await apiService.findRecord<{ name: string, id: string }>( + 'routes', + id.value, + ) titleName.value = data.name ?? data.id } diff --git a/src/pages/services/Detail.vue b/src/pages/services/Detail.vue index 4a9634da..e25291d6 100644 --- a/src/pages/services/Detail.vue +++ b/src/pages/services/Detail.vue @@ -73,7 +73,10 @@ const onFetchSuccess = (entity) => { onMounted(async () => { // If the page is not loaded from the configuration tab, we need to fetch the service name if (route.name !== 'service-detail') { - const { data } = await apiService.findRecord('services', id.value) + const { data } = await apiService.findRecord<{ name: string, id: string }>( + 'services', + id.value, + ) titleName.value = data.name ?? data.id } diff --git a/src/router.ts b/src/router.ts index 2adbafd1..d94ac5f3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -385,6 +385,17 @@ const routes: Array = [ }, ], }, + + // Data plane nodes pages + { + name: 'data-plane-nodes', + path: '/data-plane-nodes', + component: () => import('@/pages/data-plane-nodes/List.vue'), + meta: { + entity: 'data-plane-node', + title: 'Data Plane Nodes', + }, + }, ] type EntityNameDefinition = { key: string, keyPlural?: string, capitalizedName?: string, capitalizedNamePlural?: string } diff --git a/src/services/apiService.ts b/src/services/apiService.ts index e4c054ad..2783f3c1 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -20,12 +20,12 @@ class ApiService { } // entity-specific methods - findAll (entity: string, params: Pick = {}) { - return this.instance.get(`${adminApiUrl}/${entity}`, { params }) + findAll (entity: string, params: Record) { + return this.instance.get(`${adminApiUrl}/${entity}`, { params }) } - findRecord (entity: string, id: string) { - return this.instance.get(`${adminApiUrl}/${entity}/${id}`) + findRecord (entity: string, id: string) { + return this.instance.get(`${adminApiUrl}/${entity}/${id}`) } createRecord (entity: string, data: Record) { @@ -41,8 +41,8 @@ class ApiService { } // generic methods - get (url = '', config: AxiosRequestConfig = {}) { - return this.instance.get(`${adminApiUrl}/${url}`, config) + get (url = '', config: AxiosRequestConfig = {}) { + return this.instance.get(`${adminApiUrl}/${url}`, config) } post (url = '', data?: Record, config: AxiosRequestConfig = {}) { diff --git a/src/styles/index.ts b/src/styles/index.ts index 2157db19..ea7d2495 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3,6 +3,7 @@ import '@kong-ui-public/app-layout/dist/style.css' import '@kong-ui-public/entities-certificates/dist/style.css' import '@kong-ui-public/entities-consumer-credentials/dist/style.css' import '@kong-ui-public/entities-consumers/dist/style.css' +import '@kong-ui-public/entities-data-plane-nodes/dist/style.css' import '@kong-ui-public/entities-gateway-services/dist/style.css' import '@kong-ui-public/entities-key-sets/dist/style.css' import '@kong-ui-public/entities-keys/dist/style.css' diff --git a/src/utils/index.ts b/src/utils/index.ts index fe4f6a09..1f90ba58 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,17 @@ +import dayjs from 'dayjs' + export const formatVersion = (version: string) => { return version.substring(0, (version.indexOf('-') > -1 && version.indexOf('-')) || version.length).split('.').slice(0, 2).join('.') } + +// Capitalize the first letter of each word in a string +export const capitalize = (str: string) => { + if (!str) return '' + + return str.replace(/(?:^|[\s-:'"])\w/g, (a) => a.toUpperCase()) +} + +// Formats a unix timestamp into a formatted date string +export const formatDate = (timestamp: number, format?: string) => { + return dayjs.unix(timestamp).format(format ?? 'MMM DD, YYYY, h:mm A') +} diff --git a/tests/playwright/pages/data-plane-nodes.ts b/tests/playwright/pages/data-plane-nodes.ts new file mode 100644 index 00000000..c2ba25fd --- /dev/null +++ b/tests/playwright/pages/data-plane-nodes.ts @@ -0,0 +1,12 @@ +import type { Page } from '@playwright/test' +import { POM } from '.' + +export class DataPlaneNodesPage extends POM { + public $ = { + ...POM.$, + } + + constructor (page: Page) { + super(page, '/data-plane-nodes') + } +} diff --git a/tests/playwright/specs/data-plane-nodes/01-DataPlaneNodes.spec.ts b/tests/playwright/specs/data-plane-nodes/01-DataPlaneNodes.spec.ts new file mode 100644 index 00000000..ac50a9de --- /dev/null +++ b/tests/playwright/specs/data-plane-nodes/01-DataPlaneNodes.spec.ts @@ -0,0 +1,89 @@ +import { expect } from '@playwright/test' +import baseTest from '@pw/base-test' +import { DataPlaneNodesPage } from '@pw/pages/data-plane-nodes' + +const test = baseTest() + +test.describe('data plane nodes', () => { + test.beforeEach(async ({ page }) => { + await new DataPlaneNodesPage(page).goto() + }) + + test('data plane node list - has correct content', async ({ page }) => { + const table = page.locator('.page-data-plane-nodes .k-table') + + await expect(table).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(3) // has 3 data planes + const headers = [ + 'selection', + 'hostname', + 'version', + 'last_seen', + 'sync_status', + 'cert_details', + 'labels', + 'actions', + ] + + headers.forEach(async (header: string) => { + await expect(table.getByTestId(`k-table-header-${header}`)).toBeVisible() + }) + }) + + test('data plane node list - selection', async ({ page }) => { + const table = page.locator('.page-data-plane-nodes .k-table') + + await expect(table).toBeVisible() + const globalCheckbox = table.locator('thead input[type="checkbox"]') + const individualCheckboxes = table.locator('tbody input[type="checkbox"]') + + await individualCheckboxes.locator('nth=0').check() // check the 1st checkbox + await expect(globalCheckbox).not.toBeChecked() + await expect(globalCheckbox).toHaveJSProperty('indeterminate', true) + + await individualCheckboxes.locator('nth=1').check() // check the 2nd checkbox + await expect(globalCheckbox).not.toBeChecked() + await expect(globalCheckbox).toHaveJSProperty('indeterminate', true) + + await individualCheckboxes.locator('nth=2').check() // check the 3rd checkbox + await expect(globalCheckbox).toBeChecked() + await expect(globalCheckbox).toHaveJSProperty('indeterminate', false) + + await globalCheckbox.uncheck() // uncheck the global checkbox + await expect(individualCheckboxes.locator('nth=0')).not.toBeChecked() + await expect(individualCheckboxes.locator('nth=1')).not.toBeChecked() + await expect(individualCheckboxes.locator('nth=2')).not.toBeChecked() + await expect(globalCheckbox).toHaveJSProperty('indeterminate', false) + + await globalCheckbox.check() // check the global checkbox + await expect(individualCheckboxes.locator('nth=0')).toBeChecked() + await expect(individualCheckboxes.locator('nth=1')).toBeChecked() + await expect(individualCheckboxes.locator('nth=2')).toBeChecked() + await expect(globalCheckbox).toHaveJSProperty('indeterminate', false) + }) + + test('data plane node list - can change log level and revert back', async ({ page }) => { + const table = page.locator('.page-data-plane-nodes .k-table') + + await expect(table).toBeVisible() + const globalCheckbox = table.locator('thead input[type="checkbox"]') + + await globalCheckbox.check() + await page.locator('.k-table-toolbar .dropdown-trigger').click() + await page.locator('.k-table-toolbar [data-testid="dropdown-item-change-log-level"]').click() + + await expect(page.locator('.k-modal .modal-title')).toContainText('3 Nodes') + await page.locator('.log-level-select input').click() + await page.locator('.log-level-select [data-testid="select-item-debug"]').click() // change log level to Debug + await page.locator('.k-modal .time input').fill('5') // revert log level after 5 seconds + await expect(page.locator('.k-modal .data-plane-node-list')).toContainText('Notice → Debug') + await page.locator('.k-modal [data-testid="modal-action-button"]').click() // confirm the change + await expect(page.locator('.k-modal .k-badge.success')).toHaveCount(3) // all log level change successful + await page.locator('.k-modal [data-testid="modal-close-icon"]').click() // close the modal + + await expect(page.locator('.node-log-level-container').getByText('Debug')).toHaveCount(3) // all nodes have log level Debug + await page.waitForTimeout(5000) // wait for the log level revert to take effect + await page.reload() + await expect(page.locator('.node-log-level-container').getByText('Notice')).toHaveCount(3) // all nodes revert to Notice + }) +}) diff --git a/yarn.lock b/yarn.lock index 185dba81..e7aa8de3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -388,6 +388,14 @@ dependencies: "@kong-ui-public/entities-shared" "^3.1.0" +"@kong-ui-public/entities-data-plane-nodes@^0.1.1-pr.1336.01d66616.0": + version "0.1.1-pr.1336.01d66616.0" + resolved "https://registry.yarnpkg.com/@kong-ui-public/entities-data-plane-nodes/-/entities-data-plane-nodes-0.1.1-pr.1336.01d66616.0.tgz#ed74ba1aeaffcc0759a7bd3f4cc441e9aa570164" + integrity sha512-vckp8itf25OvQtDCkYPqxvEKxcqRc8afi2owSIUI6XCasybfZiag1w3TJM3EAudl/vtlUPdp6wudbldPZcZy7A== + dependencies: + "@kong-ui-public/entities-shared" "^3.1.0" + "@kong/icons" "^1.9.0" + "@kong-ui-public/entities-gateway-services@^3.0.11": version "3.0.11" resolved "https://registry.yarnpkg.com/@kong-ui-public/entities-gateway-services/-/entities-gateway-services-3.0.11.tgz#cd59a5a8e2cb6d20106e53b83ce1d0b9f8873303" @@ -1590,6 +1598,11 @@ date-fns@^2.16.1, date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" +dayjs@^1.11.10: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"