From 619e6e1462e7b4b601397fe0fc4357b8fb10057a Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Thu, 2 Apr 2026 08:10:12 +0200 Subject: [PATCH] refactor: agent installation instructions page - Updated the AgentInstallInstructionsPage to use React Query for fetching installation instructions and controllers. - Introduced a new hook `useGetInstallInstructions` for fetching installation instructions based on the selected controller. - Added a new `CopyToClipboard` component for copying commands to the clipboard with visual feedback. - Created `InstructionsSectionRenderer` to render different types of instruction sections dynamically. - Added styles for instruction sections and commands. - Implemented error handling and loading states for better user experience. - Added tests for the `CopyToClipboard` component to ensure functionality and reliability. --- package-lock.json | 57 +++-- public/locales/gsa-de.json | 2 +- public/locales/gsa-en.json | 2 +- public/locales/gsa-zh_CN.json | 2 +- public/locales/gsa-zh_TW.json | 2 +- .../clipboard/CopyToClipboard.test.tsx | 111 +++++++++ .../components/clipboard/CopyToClipboard.tsx | 70 ++++++ src/web/components/icon/index.tsx | 6 + .../use-query/agent-install-instructions.ts | 43 ++++ .../AgentInstallInstructionsPage.tsx | 222 +++++------------- .../InstructionsSectionRenderer.tsx | 205 ++++++++++++++++ .../instructions-section-renderer-styles.ts | 86 +++++++ src/web/pages/agent-remote-installer/types.ts | 143 +++++++++++ 13 files changed, 764 insertions(+), 187 deletions(-) create mode 100644 src/web/components/clipboard/CopyToClipboard.test.tsx create mode 100644 src/web/components/clipboard/CopyToClipboard.tsx create mode 100644 src/web/hooks/use-query/agent-install-instructions.ts create mode 100644 src/web/pages/agent-remote-installer/InstructionsSectionRenderer.tsx create mode 100644 src/web/pages/agent-remote-installer/instructions-section-renderer-styles.ts create mode 100644 src/web/pages/agent-remote-installer/types.ts diff --git a/package-lock.json b/package-lock.json index b327bd7635..a363a8e71d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsa", - "version": "26.15.3-dev1", + "version": "26.16.1-dev1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsa", - "version": "26.15.3-dev1", + "version": "26.16.1-dev1", "license": "AGPL-3.0+", "dependencies": { "@dnd-kit/accessibility": "^3.1.1", @@ -225,6 +225,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1854,6 +1855,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1877,6 +1879,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3228,7 +3231,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3255,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -3290,6 +3293,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -4399,8 +4403,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4607,6 +4610,7 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4626,6 +4630,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4637,6 +4642,7 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4724,6 +4730,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -5336,6 +5343,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5384,7 +5392,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -5732,6 +5739,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5767,8 +5775,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/call-bind": { "version": "1.0.8", @@ -6380,7 +6387,8 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -6491,8 +6499,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6808,6 +6815,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6868,6 +6876,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7860,6 +7869,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6" }, @@ -8578,6 +8588,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8812,7 +8823,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9526,6 +9536,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9554,7 +9565,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9569,7 +9579,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9581,8 +9590,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -9649,6 +9657,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9660,6 +9669,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9732,6 +9742,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9960,7 +9971,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-logger": { "version": "3.0.6", @@ -10153,6 +10165,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10504,7 +10517,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -10515,7 +10527,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10888,8 +10899,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/tiny-case": { "version": "1.0.3", @@ -10959,6 +10969,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11177,6 +11188,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11456,6 +11468,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11595,6 +11608,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11637,6 +11651,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index 08a2d53b8e..21597f6e7f 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -496,8 +496,8 @@ "Content Type": "Inhaltstyp", "Contents": "Inhalte", "Copied!": "Kopiert", - "Copy": "Kopieren", "Copy Agent Installer checksum to clipboard": "Agent-Installationsprogramm-Prüfsumme in die Zwischenablage kopieren", + "Copy to clipboard": "In die Zwischenablage kopieren", "Corresponding Performance": "Zugehörige Leistungsdaten", "Corresponding Report": "Zugehöriger Bericht", "Corresponding Results": "Zugehörige Ergebnisse", diff --git a/public/locales/gsa-en.json b/public/locales/gsa-en.json index 069d40f8cc..2ec044d475 100644 --- a/public/locales/gsa-en.json +++ b/public/locales/gsa-en.json @@ -496,8 +496,8 @@ "Content Type": "Content Type", "Contents": "Contents", "Copied!": "", - "Copy": "", "Copy Agent Installer checksum to clipboard": "Copy Agent Installer checksum to clipboard", + "Copy to clipboard": "", "Corresponding Performance": "Corresponding Performance", "Corresponding Report": "Corresponding Report", "Corresponding Results": "Corresponding Results", diff --git a/public/locales/gsa-zh_CN.json b/public/locales/gsa-zh_CN.json index f7fe2b0978..510ad23a59 100644 --- a/public/locales/gsa-zh_CN.json +++ b/public/locales/gsa-zh_CN.json @@ -496,8 +496,8 @@ "Content Type": "内容类型", "Contents": "内容", "Copied!": "已复制!", - "Copy": "复制", "Copy Agent Installer checksum to clipboard": "复制代理安装程序校验和到剪贴板", + "Copy to clipboard": "", "Corresponding Performance": "此报告的性能", "Corresponding Report": "所属报告", "Corresponding Results": "此报告的扫描结果", diff --git a/public/locales/gsa-zh_TW.json b/public/locales/gsa-zh_TW.json index 330e236eae..75ac76994f 100644 --- a/public/locales/gsa-zh_TW.json +++ b/public/locales/gsa-zh_TW.json @@ -496,8 +496,8 @@ "Content Type": "內容類型", "Contents": "", "Copied!": "已複製!", - "Copy": "複製", "Copy Agent Installer checksum to clipboard": "", + "Copy to clipboard": "", "Corresponding Performance": "", "Corresponding Report": "", "Corresponding Results": "", diff --git a/src/web/components/clipboard/CopyToClipboard.test.tsx b/src/web/components/clipboard/CopyToClipboard.test.tsx new file mode 100644 index 0000000000..75611b8797 --- /dev/null +++ b/src/web/components/clipboard/CopyToClipboard.test.tsx @@ -0,0 +1,111 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + afterEach, + beforeEach, + describe, + expect, + test, + testing, +} from '@gsa/testing'; +import {act, fireEvent, render, screen} from 'web/testing'; +import CopyToClipboard from 'web/components/clipboard/CopyToClipboard'; + +const writeText = testing.fn(); + +describe('CopyToClipboard', () => { + beforeEach(() => { + testing.useFakeTimers(); + Object.defineProperty(navigator, 'clipboard', { + value: {writeText}, + writable: true, + configurable: true, + }); + writeText.mockResolvedValue(undefined); + }); + + afterEach(() => { + testing.useRealTimers(); + testing.clearAllMocks(); + }); + + test('renders a button', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test('copies the value to clipboard on click', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(writeText).toHaveBeenCalledWith('hello world'); + }); + + test('calls writeText exactly once per click', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + }); + + test('falls back to execCommand when clipboard API fails', async () => { + writeText.mockRejectedValue(new Error('Clipboard not available')); + const execCommandSpy = testing + .spyOn(document, 'execCommand') + .mockReturnValue(true); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(execCommandSpy).toHaveBeenCalledWith('copy'); + }); + + test('resets copied state after 2 seconds', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + await act(async () => { + testing.advanceTimersByTime(2000); + }); + + // Button still rendered and clickable after reset — clipboard can be called again + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(writeText).toHaveBeenCalledTimes(2); + }); + + test('does not reset copied state before 2 seconds have elapsed', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + await act(async () => { + testing.advanceTimersByTime(1999); + }); + + // Button is still in copied state — a second click is a no-op (no extra write) + // The component prevents further clipboard writes while $copied is true + // We verify writeText was called exactly once (copied state active) + expect(writeText).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web/components/clipboard/CopyToClipboard.tsx b/src/web/components/clipboard/CopyToClipboard.tsx new file mode 100644 index 0000000000..288369c475 --- /dev/null +++ b/src/web/components/clipboard/CopyToClipboard.tsx @@ -0,0 +1,70 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import {Tooltip} from '@mantine/core'; +import styled from 'styled-components'; +import {CheckIcon, CopyIcon} from 'web/components/icon'; +import useTranslation from 'web/hooks/useTranslation'; +import Theme from 'web/utils/Theme'; + +interface CopyToClipboardProps { + value: string; + tooltip?: string; + successTooltip?: string; +} + +const CopyIconButton = styled.button<{$copied: boolean}>` + position: absolute; + top: 8px; + right: 8px; + background: transparent; + border: 1px solid + ${({$copied}) => ($copied ? Theme.green : Theme.mediumDarkGray)}; + border-radius: 4px; + color: ${({$copied}) => ($copied ? Theme.green : Theme.lightGray)}; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: ${Theme.mediumDarkGray}; + } +`; + +const CopyToClipboard = ({ + value, + tooltip, + successTooltip, +}: CopyToClipboardProps) => { + const [_] = useTranslation(); + const [copied, setCopied] = useState(false); + + const tooltipText = tooltip ?? _('Copy to clipboard'); + const successTooltipText = successTooltip ?? _('Copied!'); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default CopyToClipboard; diff --git a/src/web/components/icon/index.tsx b/src/web/components/icon/index.tsx index c3c030567b..e629c4b9bf 100644 --- a/src/web/components/icon/index.tsx +++ b/src/web/components/icon/index.tsx @@ -11,6 +11,7 @@ import { ArrowUpDown, BarChart3, Calendar, + Check, ChevronFirst, ChevronLast, ChevronLeft, @@ -20,6 +21,7 @@ import { CircleX, ClipboardCheck, Clock3, + Copy, Diff, Download, Equal, @@ -206,6 +208,8 @@ const getIcons = (): Record => { ), ArrowUp: createIcon(ArrowUp, 'arrow-up-icon', 'Arrow Up Icon'), Calendar: createIcon(Calendar, 'calendar-icon', 'Calendar Icon'), + Check: createIcon(Check, 'check-icon', 'Check Icon'), + Copy: createIcon(Copy, 'copy-icon', 'Copy Icon'), KeyRound: createIcon(KeyRound, 'credential-icon', 'Credential Icon'), BarChart3: createIcon(BarChart3, 'dashboard-icon', 'Dashboard Icon'), Trash2: createIcon(Trash2, 'trashcan-icon', 'Delete Icon'), @@ -451,6 +455,8 @@ export const ArrowUpDownIcon = getIcons().ArrowUpDown; export const AuditIcon = getIcons().Audit; export const DashboardIcon = getIcons().BarChart3; export const CalendarIcon = getIcons().Calendar; +export const CheckIcon = getIcons().Check; +export const CopyIcon = getIcons().Copy; export const CertBundAdvIcon = getIcons().CertBundAdv; export const FirstIcon = getIcons().ChevronFirst; export const LastIcon = getIcons().ChevronLast; diff --git a/src/web/hooks/use-query/agent-install-instructions.ts b/src/web/hooks/use-query/agent-install-instructions.ts new file mode 100644 index 0000000000..5866ca3018 --- /dev/null +++ b/src/web/hooks/use-query/agent-install-instructions.ts @@ -0,0 +1,43 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useQuery} from '@tanstack/react-query'; +import useLanguage from 'web/hooks/useLanguage'; +import {type InstallInstructionsData} from 'web/pages/agent-remote-installer/types'; + +type UseGetInstallInstructionsParams = { + enabled?: boolean; +}; + +const getInstructionsUrl = (langCode: string): string => { + const encodedLang = encodeURIComponent(langCode); + return `http://dev.agent-control.greenbone.io:8080/api/v1/install-instructions?lang=${encodedLang}`; +}; + +const useGetInstallInstructions = ({ + enabled = true, +}: UseGetInstallInstructionsParams = {}) => { + const [language] = useLanguage(); + const langCode = language.split(/[-_]/)[0] || 'en'; + const url = getInstructionsUrl(langCode); + + return useQuery({ + queryKey: ['install-instructions', url], + queryFn: async () => { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json() as Promise; + }, + enabled, + }); +}; + +export default useGetInstallInstructions; diff --git a/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx index 42cb75e8ed..c502570138 100644 --- a/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx +++ b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx @@ -1,10 +1,10 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG +/* SPDX-FileCopyrightText: 2026 Greenbone AG * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useState, useEffect, useCallback, useRef} from 'react'; -import DOMPurify from 'dompurify'; +import {useMemo, useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; import styled from 'styled-components'; import Filter from 'gmp/models/filter'; import { @@ -18,198 +18,89 @@ import PageTitle from 'web/components/layout/PageTitle'; import Row from 'web/components/layout/Row'; import Loading from 'web/components/loading/Loading'; import Section from 'web/components/section/Section'; +import useGetInstallInstructions from 'web/hooks/use-query/agent-install-instructions'; import useGmp from 'web/hooks/useGmp'; -import useLanguage from 'web/hooks/useLanguage'; import useTranslation from 'web/hooks/useTranslation'; - -const extractStyles = (html: string): string => { - const match = html.match(/]*>([\s\S]*?)<\/style>/i); - return match ? `` : ''; -}; - -const extractBody = (html: string): string => { - const match = html.match(/]*>([\s\S]*)<\/body>/i); - return match ? match[1] : html; -}; - -const InstructionsContainer = styled.div` - padding: 16px; - background: white; - border-radius: 8px; - width: 100%; -`; +import InstructionsSectionRenderer from 'web/pages/agent-remote-installer/InstructionsSectionRenderer'; const SelectorLabel = styled.label` font-weight: 500; white-space: nowrap; `; -const getInstructionsUrl = ( - langCode: string, - host: string | undefined, - port: number | undefined, -): string => { - const encodedLang = encodeURIComponent(langCode); - if (host && port) { - // Proxy through nginx: /agent-proxy/{host}/{port}/api/v1/... - // Encode host to handle IPv6 addresses and special characters safely - const encodedHost = encodeURIComponent(host); - return `/agent-proxy/${encodedHost}/${port}/api/v1/install-instructions?lang=${encodedLang}`; - } - // Fallback to local agent-control - return `/api/v1/install-instructions?lang=${encodedLang}`; -}; +const InstructionsTitle = styled.h1` + margin: 12px 0 16px 0; +`; const AgentInstallInstructionsPage = () => { const [_] = useTranslation(); - const [language] = useLanguage(); const gmp = useGmp(); - const [instructionsHtml, setInstructionsHtml] = useState(''); - const [instructionsLoading, setInstructionsLoading] = useState(false); - const [controllersLoading, setControllersLoading] = useState(false); - const [controllers, setControllers] = useState([]); - const [error, setError] = useState(undefined); const [selectedController, setSelectedController] = useState< string | undefined >(undefined); - const instructionsContainerRef = useRef(null); - - const fetchInstructions = useCallback( - async (lang: string, controller: Scanner | undefined) => { - const langCode = lang.split(/[-_]/)[0] || 'en'; - const url = getInstructionsUrl( - langCode, - controller?.host, - controller?.port, - ); - - try { - setInstructionsLoading(true); - setError(undefined); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - const html = await response.text(); - const styles = extractStyles(html); - const body = extractBody(html); - // Sanitize only the body HTML to prevent XSS attacks - // Styles are kept as-is (CSS injection is low risk compared to HTML/JS) - const sanitizedBody = DOMPurify.sanitize(body, { - ADD_ATTR: ['class', 'data-clipboard-text'], - FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style'], - FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'], - }); - // Combine styles with sanitized body - setInstructionsHtml(styles + sanitizedBody); - } catch (err) { - setError(err as Error); - } finally { - setInstructionsLoading(false); - } - }, - [], - ); - - const fetchControllers = useCallback(async () => { - try { - setControllersLoading(true); + const { + data: controllersData, + isLoading: controllersLoading, + error: controllersError, + } = useQuery({ + queryKey: ['agent-controllers'], + queryFn: async () => { const response = await gmp.scanners.getAll({ filter: Filter.fromString(`type=${AGENT_CONTROLLER_SCANNER_TYPE}`), }); const scanners = response?.data ?? []; - const agentControllers = scanners.sort((a, b) => { - // Local agent-control first (matches docker hostname) + return scanners.sort((a: Scanner, b: Scanner) => { const aLocal = a.host === 'agentcontrol' ? 0 : 1; const bLocal = b.host === 'agentcontrol' ? 0 : 1; if (aLocal !== bLocal) return aLocal - bLocal; return a?.name?.localeCompare(b?.name ?? '') ?? 0; }); - setControllers(agentControllers); - const firstController = agentControllers?.[0]; - setSelectedController(firstController?.id); - void fetchInstructions(language, firstController); - } catch (err) { - // If scanner fetch fails, fall back to local agent-control - setControllers([]); - setError(err as Error); - } finally { - setControllersLoading(false); - } - }, [gmp, fetchInstructions, language]); - - // Fetch agent-controller scanners on mount - useEffect(() => { - void fetchControllers(); - }, [fetchControllers]); - - // Attach click handlers to copy buttons after HTML is rendered - // Scoped to the instructions container to avoid global event handling - useEffect(() => { - const container = instructionsContainerRef.current; - if (!instructionsHtml || instructionsLoading || !container) return; - - const copyToClipboard = async (btn: HTMLButtonElement) => { - const pre = btn.previousElementSibling; - if (!pre) return; - - const text = pre.textContent || ''; - const originalText = btn.textContent || _('Copy'); - const copiedText = _('Copied!'); + }, + }); - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback for older browsers - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.cssText = 'position:fixed;left:-9999px'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } + const controllers = useMemo(() => controllersData ?? [], [controllersData]); - btn.textContent = copiedText; - btn.classList.add('copied'); - setTimeout(() => { - btn.textContent = originalText; - btn.classList.remove('copied'); - }, 2000); - }; + const activeControllerId = selectedController ?? controllers[0]?.id; + const activeController = controllers.find(c => c.id === activeControllerId); - const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (target.classList.contains('copy-btn')) { - void copyToClipboard(target as HTMLButtonElement); - } - }; + const { + data: instructions, + isLoading: instructionsLoading, + error: instructionsError, + refetch: refetchInstructions, + } = useGetInstallInstructions({ + host: activeController?.host, + port: activeController?.port, + enabled: !controllersLoading, + }); - container.addEventListener('click', handleClick); - return () => container.removeEventListener('click', handleClick); - }, [_, instructionsHtml, instructionsLoading]); + const error = controllersError ?? instructionsError; const handleControllerChange = (value: string) => { setSelectedController(value); - const controller = controllers.find(c => c.id === value); - void fetchInstructions(language, controller); }; return ( <> + {!instructionsLoading && + !controllersLoading && + !error && + instructions && ( + {instructions.title} + )}
{error && !instructionsLoading && ( - + )} @@ -231,7 +122,7 @@ const AgentInstallInstructionsPage = () => { label: `${controller.name} (${controller.host}:${controller.port})`, value: controller.id as string, }))} - value={selectedController} + value={activeControllerId} onChange={handleControllerChange} /> @@ -239,14 +130,21 @@ const AgentInstallInstructionsPage = () => { {!controllersLoading && controllers.length === 0 && (

{_('No agent controllers available')}

)} - {instructionsLoading && } - - {!instructionsLoading && !controllersLoading && !error && ( - - )} + {(instructionsLoading || controllersLoading) && } + + {!instructionsLoading && + !controllersLoading && + !error && + instructions && ( + <> + {instructions.sections.map(section => ( + + ))} + + )}
); diff --git a/src/web/pages/agent-remote-installer/InstructionsSectionRenderer.tsx b/src/web/pages/agent-remote-installer/InstructionsSectionRenderer.tsx new file mode 100644 index 0000000000..18f4e8cf6c --- /dev/null +++ b/src/web/pages/agent-remote-installer/InstructionsSectionRenderer.tsx @@ -0,0 +1,205 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type ReactNode} from 'react'; +import { + AccordionItem, + AccordionWrapperMultiple, + Alert, + Button, + EStatus, +} from '@greenbone/ui-lib'; +import CopyToClipboard from 'web/components/clipboard/CopyToClipboard'; +import Column from 'web/components/layout/Column'; +import Row from 'web/components/layout/Row'; +import BlankLink from 'web/components/link/BlankLink'; +import StripedTable from 'web/components/table/StripedTable'; +import TableBody from 'web/components/table/TableBody'; +import TableData from 'web/components/table/TableData'; +import TableHead from 'web/components/table/TableHead'; +import TableHeader from 'web/components/table/TableHeader'; +import TableRow from 'web/components/table/TableRow'; +import { + BulletList, + ChecksumBox, + CommandWrapper, + ConfigValue, + InfoParagraph, + OsCard, + Pre, + StepsList, +} from 'web/pages/agent-remote-installer/instructions-section-renderer-styles'; +import { + SectionId, + SectionType, + type InstructionsChecksum, + type InstructionsCollapsible, + type InstructionsOsCommand, + type InstructionsSection, + type InstructionsTable, +} from 'web/pages/agent-remote-installer/types'; + +const CopyableCommand = ({command, os}: {command: string; os: string}) => { + return ( + +
+        {command}
+      
+ +
+ ); +}; + +const OsCommandSection = ({section}: {section: InstructionsOsCommand}) => { + const handleDownload = (url: string, filename: string) => { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + }; + + const {download} = section; + + if (!download) { + return ; + } + + return ( + + + + + + + ); +}; + +const CollapsibleSection = ({section}: {section: InstructionsCollapsible}) => ( + + + + {section.children.map(child => ( + + ))} + + + +); + +const InstructionsTableSection = ({section}: {section: InstructionsTable}) => ( + + + + {section.headers.map(header => ( + + ))} + + + + {section.rows.map(row => ( + + {row.map(cell => ( + + + {renderCellContent( + cell, + section.id === SectionId.PACKAGES_TABLE, + )} + + + ))} + + ))} + + +); + +const URL_PATTERN = /https?:\/\/[^\s,]+/g; + +const renderCellContent = (cell: string, shouldLinkify: boolean): ReactNode => { + if (!cell) return '—'; + if (!shouldLinkify) return cell; + + const parts: ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + URL_PATTERN.lastIndex = 0; + while ((match = URL_PATTERN.exec(cell)) !== null) { + if (match.index > lastIndex) { + parts.push(cell.slice(lastIndex, match.index)); + } + parts.push( + + {match[0]} + , + ); + lastIndex = match.index + match[0].length; + } + if (lastIndex < cell.length) parts.push(cell.slice(lastIndex)); + return parts.length > 0 ? parts : cell; +}; + +const ChecksumSection = ({section}: {section: InstructionsChecksum}) => ( +
+ {section.label}: + {section.value} +
+); + +const InstructionsSectionRenderer = ({ + section, +}: { + section: InstructionsSection; +}) => { + switch (section.type) { + case SectionType.HEADING: { + if (section.level === 2) return

{section.text}

; + if (section.level === 3) return

{section.text}

; + return

{section.text}

; + } + case SectionType.PARAGRAPH: + return {section.text}; + case SectionType.OS_COMMAND: + return ; + case SectionType.WARNING: + return ( + + + + ); + case SectionType.COLLAPSIBLE: + return ; + case SectionType.TABLE: + return ; + case SectionType.ORDERED_LIST: + return ( + + {section.items.map(item => ( +
  • {item}
  • + ))} +
    + ); + case SectionType.UNORDERED_LIST: + return ( + + {section.items.map(item => ( +
  • {item}
  • + ))} +
    + ); + case SectionType.CHECKSUM: + return ; + default: + return null; + } +}; + +export default InstructionsSectionRenderer; diff --git a/src/web/pages/agent-remote-installer/instructions-section-renderer-styles.ts b/src/web/pages/agent-remote-installer/instructions-section-renderer-styles.ts new file mode 100644 index 0000000000..9a0122e7b7 --- /dev/null +++ b/src/web/pages/agent-remote-installer/instructions-section-renderer-styles.ts @@ -0,0 +1,86 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import styled from 'styled-components'; +import Theme from 'web/utils/Theme'; + +export const CommandWrapper = styled.div` + position: relative; + margin: 8px 0; +`; + +export const Pre = styled.pre` + background: ${Theme.black}; + color: ${Theme.lightGray}; + padding: 16px 44px 16px 16px; + border-radius: 8px; + overflow-x: auto; + margin: 0; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.9em; + white-space: pre-wrap; + word-wrap: break-word; +`; + +export const OsCard = styled.div` + background: ${Theme.white}; + border: 1px solid ${Theme.inputBorderGray}; + border-radius: 8px; + padding: 16px; + margin: 8px 0 20px 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +`; + +export const ChecksumBox = styled.div` + background: ${Theme.dialogGray}; + border: 1px solid ${Theme.mediumDarkGray}; + border-radius: 4px; + padding: 8px 12px; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.85em; + word-break: break-all; + margin: 8px 0; +`; + +export const ConfigValue = styled.span` + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.9em; + word-break: break-all; +`; + +export const StepsList = styled.ol` + background: ${Theme.dialogGray}; + border: 1px solid ${Theme.lightGray}; + border-radius: 8px; + padding: 16px 16px 16px 40px; + margin: 8px 0; + + li { + padding: 6px 0; + line-height: 1.6; + } +`; + +export const BulletList = styled.ul` + background: ${Theme.dialogGray}; + border: 1px solid ${Theme.lightGray}; + border-radius: 8px; + padding: 16px 16px 16px 36px; + margin: 8px 0; + + li { + padding: 4px 0; + line-height: 1.6; + } +`; + +export const InfoParagraph = styled.p` + margin: 8px 0 16px 0; + padding: 8px 12px; + border-left: 3px solid ${Theme.mediumDarkGray}; + background: ${Theme.dialogGray}; + color: ${Theme.darkGray}; + line-height: 1.4; +`; diff --git a/src/web/pages/agent-remote-installer/types.ts b/src/web/pages/agent-remote-installer/types.ts new file mode 100644 index 0000000000..52a65d90cd --- /dev/null +++ b/src/web/pages/agent-remote-installer/types.ts @@ -0,0 +1,143 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const SectionType = { + HEADING: 'heading', + PARAGRAPH: 'paragraph', + OS_COMMAND: 'os-command', + WARNING: 'warning', + COLLAPSIBLE: 'collapsible', + TABLE: 'table', + ORDERED_LIST: 'ordered-list', + UNORDERED_LIST: 'unordered-list', + CHECKSUM: 'checksum', +} as const; + +export const SectionId = { + QUICK_INSTALL: 'quick-install', + QUICK_INSTALL_LINUX: 'quick-install-linux', + QUICK_INSTALL_LINUX_DESC: 'quick-install-linux-desc', + QUICK_INSTALL_LINUX_CMD: 'quick-install-linux-cmd', + QUICK_INSTALL_WINDOWS: 'quick-install-windows', + QUICK_INSTALL_WINDOWS_DESC: 'quick-install-windows-desc', + QUICK_INSTALL_WINDOWS_CMD: 'quick-install-windows-cmd', + SELF_SIGNED: 'self-signed', + SELF_SIGNED_WARNING: 'self-signed-warning', + SELF_SIGNED_LINUX: 'self-signed-linux', + SELF_SIGNED_LINUX_CMD: 'self-signed-linux-cmd', + SELF_SIGNED_WINDOWS: 'self-signed-windows', + SELF_SIGNED_WINDOWS_CMD: 'self-signed-windows-cmd', + VERIFIED_INSTALL: 'verified-install', + VERIFIED_NOTE: 'verified-note', + VERIFIED_LINUX: 'verified-linux', + VERIFIED_LINUX_CMD: 'verified-linux-cmd', + VERIFIED_WINDOWS: 'verified-windows', + VERIFIED_WINDOWS_CMD: 'verified-windows-cmd', + SCRIPT_CHECKSUMS: 'script-checksums', + CHECKSUM_NOTE: 'checksum-note', + CHECKSUM_LINUX: 'checksum-linux', + CHECKSUM_LINUX_VAL: 'checksum-linux-val', + CHECKSUM_LINUX_VERIFY: 'checksum-linux-verify', + CHECKSUM_LINUX_CMD: 'checksum-linux-cmd', + CHECKSUM_WINDOWS: 'checksum-windows', + CHECKSUM_WINDOWS_VAL: 'checksum-windows-val', + CHECKSUM_WINDOWS_VERIFY: 'checksum-windows-verify', + CHECKSUM_WINDOWS_CMD: 'checksum-windows-cmd', + CONFIGURATION: 'configuration', + CONFIG_NOTE: 'config-note', + CONFIG_TABLE: 'config-table', + ENDPOINT_NOTE: 'endpoint-note', + PACKAGES: 'packages', + PACKAGES_TABLE: 'packages-table', + WHAT_SCRIPTS_DO: 'what-scripts-do', + STEPS: 'steps', + REQUIREMENTS: 'requirements', + REQUIREMENTS_LINUX: 'requirements-linux', + REQUIREMENTS_LINUX_LIST: 'requirements-linux-list', + REQUIREMENTS_WINDOWS: 'requirements-windows', + REQUIREMENTS_WINDOWS_LIST: 'requirements-windows-list', +} as const; + +export interface InstructionsHeading { + id: string; + type: typeof SectionType.HEADING; + level: 2 | 3 | 4; + text: string; +} + +export interface InstructionsParagraph { + id: string; + type: typeof SectionType.PARAGRAPH; + text: string; +} + +export interface InstructionsOsCommand { + id: string; + type: typeof SectionType.OS_COMMAND; + os: 'linux' | 'windows'; + command: string; + download?: { + url: string; + filename: string; + label: string; + }; +} + +export interface InstructionsWarning { + id: string; + type: typeof SectionType.WARNING; + text: string; +} + +export interface InstructionsCollapsible { + id: string; + type: typeof SectionType.COLLAPSIBLE; + summary: string; + children: InstructionsSection[]; +} + +export interface InstructionsTable { + id: string; + type: typeof SectionType.TABLE; + headers: string[]; + rows: string[][]; +} + +export interface InstructionsOrderedList { + id: string; + type: typeof SectionType.ORDERED_LIST; + items: string[]; +} + +export interface InstructionsUnorderedList { + id: string; + type: typeof SectionType.UNORDERED_LIST; + items: string[]; +} + +export interface InstructionsChecksum { + id: string; + type: typeof SectionType.CHECKSUM; + label: string; + value: string; +} + +export type InstructionsSection = + | InstructionsHeading + | InstructionsParagraph + | InstructionsOsCommand + | InstructionsWarning + | InstructionsCollapsible + | InstructionsTable + | InstructionsOrderedList + | InstructionsUnorderedList + | InstructionsChecksum; + +export interface InstallInstructionsData { + _version: string; + lang: string; + title: string; + sections: InstructionsSection[]; +}