({
+ 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[];
+}