diff --git a/public/app/percona/inventory/Inventory.messages.ts b/public/app/percona/inventory/Inventory.messages.ts index c60d615e16734..5515b47c12df6 100644 --- a/public/app/percona/inventory/Inventory.messages.ts +++ b/public/app/percona/inventory/Inventory.messages.ts @@ -81,6 +81,15 @@ export const Messages = { nodeId: 'Node ID', serviceNames: 'Service Names', }, + addNodeButton: 'Add Node', + addNodeMySQL: 'MySQL', + addNodePostgreSQL: 'PostgreSQL', + addNodeMongoDB: 'MongoDB', + addNodeValkey: 'Valkey', + addNodeMoreOptions: 'More options', + addNodeCommandCopied: 'Command copied to clipboard — run it on the host you want to add', + addNodeCommandCopyFailed: 'Could not copy command to clipboard', + addNodeTokenFailed: 'Could not create install token', }, delete: 'Delete', edit: 'Edit', diff --git a/public/app/percona/inventory/Tabs/Nodes.constants.ts b/public/app/percona/inventory/Tabs/Nodes.constants.ts new file mode 100644 index 0000000000000..46ccd6a5f0068 --- /dev/null +++ b/public/app/percona/inventory/Tabs/Nodes.constants.ts @@ -0,0 +1,19 @@ +import { IconName } from '@grafana/data'; + +import { Messages } from '../Inventory.messages'; + +import { QuickInstallTech } from './NodesInstallCommand.utils'; + +export const QUICK_INSTALL_ICON_MAP: Record = { + mysql: 'percona-database-mysql', + postgresql: 'percona-database-postgresql', + mongodb: 'percona-database-mongodb', + valkey: 'percona-database-valkey', +}; + +export const QUICK_INSTALL_OPTIONS: Array<{ tech: QuickInstallTech; label: string; icon: IconName }> = [ + { tech: 'mysql', label: Messages.nodes.addNodeMySQL, icon: QUICK_INSTALL_ICON_MAP.mysql }, + { tech: 'postgresql', label: Messages.nodes.addNodePostgreSQL, icon: QUICK_INSTALL_ICON_MAP.postgresql }, + { tech: 'mongodb', label: Messages.nodes.addNodeMongoDB, icon: QUICK_INSTALL_ICON_MAP.mongodb }, + { tech: 'valkey', label: Messages.nodes.addNodeValkey, icon: QUICK_INSTALL_ICON_MAP.valkey }, +]; diff --git a/public/app/percona/inventory/Tabs/Nodes.tsx b/public/app/percona/inventory/Tabs/Nodes.tsx index 145a04014ce2c..b1553c6827b54 100644 --- a/public/app/percona/inventory/Tabs/Nodes.tsx +++ b/public/app/percona/inventory/Tabs/Nodes.tsx @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions,@typescript-eslint/no-explicit-any */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Form } from 'react-final-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; import { Row } from 'react-table'; import { AppEvents } from '@grafana/data'; -import { Badge, Button, Icon, Link, Modal, Stack, TagList, useStyles2 } from '@grafana/ui'; +import { Badge, Button, Dropdown, Icon, Link, Menu, Modal, Stack, TagList, useStyles2 } from '@grafana/ui'; import { CheckboxField } from 'app/percona/shared/components/Elements/Checkbox'; import { DetailsRow } from 'app/percona/shared/components/Elements/DetailsRow/DetailsRow'; import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; @@ -38,9 +39,12 @@ import { Messages } from '../Inventory.messages'; import { FlattenNode, MonitoringStatus, Node } from '../Inventory.types'; import { StatusBadge } from '../components/StatusBadge/StatusBadge'; import { StatusLink } from '../components/StatusLink/StatusLink'; +import { createNodeInstallToken } from '../installToken'; +import { QUICK_INSTALL_OPTIONS } from './Nodes.constants'; import { InventoryNode } from './Nodes.types'; import { getHaRoleBadgeText, getServiceLink, mapNodesToInventoryNodes } from './Nodes.utils'; +import { buildQuickInstallCommand, QuickInstallTech } from './NodesInstallCommand.utils'; import { getBadgeColorForServiceStatus, getBadgeIconForServiceStatus, @@ -58,9 +62,11 @@ export const NodesTab = () => { const navModel = usePerconaNavModel('inventory-nodes'); const [triggerTimeout, , stopTimeout] = useRecurringCall(); const [generateToken] = useCancelToken(); + const [installTokenLoading, setInstallTokenLoading] = useState(false); const styles = useStyles2(getStyles); const dispatch = useAppDispatch(); const { nodes: highAvailabilityNodes, isEnabled: isHighAvailabilityEnabled } = useSelector(getHighAvailability); + const navigate = useNavigate(); const mappedNodes = useMemo( () => @@ -348,76 +354,132 @@ export const NodesTab = () => { setSelectedRows(rows); }, []); + const copyQuickInstallWithToken = useCallback(async (tech: QuickInstallTech) => { + setInstallTokenLoading(true); + try { + const res = await createNodeInstallToken(tech); + const cmd = buildQuickInstallCommand(tech, res.token); + try { + await navigator.clipboard.writeText(cmd); + appEvents.emit(AppEvents.alertSuccess, [Messages.nodes.addNodeCommandCopied]); + } catch (clipErr) { + logger.error(clipErr); + appEvents.emit(AppEvents.alertError, [Messages.nodes.addNodeCommandCopyFailed]); + } + } catch (e) { + logger.error(e); + appEvents.emit(AppEvents.alertError, [Messages.nodes.addNodeTokenFailed]); + } finally { + setInstallTokenLoading(false); + } + }, []); + return ( -
- -
- - {Messages.confirmAction} - - } - isOpen={modalVisible} - onDismiss={() => setModalVisible(false)} - > -
( - - <> -

{deletionMsg}

- } + + + + + {QUICK_INSTALL_OPTIONS.map(({ tech, label, icon }) => ( + { + copyQuickInstallWithToken(tech); + }} + /> + ))} + + navigate('/pmm-ui/install-client')} + label={Messages.nodes.addNodeMoreOptions} /> - - - - - - - )} + + } + placement="bottom-start" + > + + + + + {Messages.confirmAction} + + } + isOpen={modalVisible} + onDismiss={() => setModalVisible(false)} + > +
( + + <> +

{deletionMsg}

+ } + /> + + + + + + + )} + /> +
+ row.nodeId, [])} + showFilter /> - -
row.nodeId, [])} - showFilter - /> + diff --git a/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.test.ts b/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.test.ts new file mode 100644 index 0000000000000..0f5c4bbc582ae --- /dev/null +++ b/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.test.ts @@ -0,0 +1,26 @@ +import { buildQuickInstallCommand, QuickInstallTech } from './NodesInstallCommand.utils'; + +describe('buildQuickInstallCommand', () => { + it('wraps the downloaded script path in single quotes', () => { + const cmd = buildQuickInstallCommand('mysql'); + expect(cmd).toContain("'/tmp/install-pmm-client.sh'"); + }); + + it('URL-encodes the token before embedding it in --pmm-server-url', () => { + const cmd = buildQuickInstallCommand('mysql', 'abc:def@ghi/'); + expect(cmd).toContain('service_token:abc%3Adef%40ghi%2F@'); + }); + + it('emits placeholder when no token is provided', () => { + const cmd = buildQuickInstallCommand('mysql'); + expect(cmd).toContain('service_token:@'); + }); + + it.each(['mysql', 'postgresql', 'mongodb', 'valkey'])( + 'includes the selected tech in the --tech flag (%s)', + (tech) => { + const cmd = buildQuickInstallCommand(tech); + expect(cmd).toContain(`--tech '${tech}'`); + } + ); +}); diff --git a/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.ts b/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.ts new file mode 100644 index 0000000000000..45d33bb13dfb5 --- /dev/null +++ b/public/app/percona/inventory/Tabs/NodesInstallCommand.utils.ts @@ -0,0 +1,37 @@ +export type QuickInstallTech = 'mysql' | 'postgresql' | 'mongodb' | 'valkey'; + +const shellEscape = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; + +/** Same path as PMM UI "Prompt on node" / install-pmm-client.sh docs. */ +const DOWNLOADED_SCRIPT_PATH = '/tmp/install-pmm-client.sh'; + +/** + * Minimal install command: install pmm-client, configure pmm-agent, add selected DB with defaults. + * Uses download-then-run (not curl|bash) so install-pmm-client.sh can prompt for DB credentials on a TTY. + * If DB_USER / DB_PASSWORD (or per-tech MYSQL_* / …) are already exported, `sudo -E` preserves them and prompts are skipped. + * + * Aligns with PMM Install PMM Client "Prompt on node" command shape. + */ +export function buildQuickInstallCommand(tech: QuickInstallTech, token?: string): string { + if (typeof window === 'undefined') { + return ''; + } + + const protocol = window.location.protocol; + const host = window.location.hostname; + const port = window.location.port || (protocol === 'https:' ? '443' : '80'); + const origin = `${protocol}//${window.location.host}`; + const scriptUrl = `${origin}/pmm-static/install-pmm-client.sh`; + const tokenForUrl = token ? encodeURIComponent(token) : ''; + const serverUrl = `https://service_token:${tokenForUrl}@${host}:${port}`; + + const curl = `curl -fsSLk -o ${shellEscape(DOWNLOADED_SCRIPT_PATH)} ${shellEscape(scriptUrl)}`; + + const flags = [ + `--pmm-server-url ${shellEscape(serverUrl)}`, + `--tech ${shellEscape(tech)}`, + '--pmm-server-insecure-tls', + ]; + + return [curl, `sudo -E bash ${shellEscape(DOWNLOADED_SCRIPT_PATH)} \\`, ` ${flags.join(' \\\n ')}`].join('\n'); +} diff --git a/public/app/percona/inventory/Tabs/Tabs.styles.ts b/public/app/percona/inventory/Tabs/Tabs.styles.ts index d92e2a61c19da..43cc87826bc03 100644 --- a/public/app/percona/inventory/Tabs/Tabs.styles.ts +++ b/public/app/percona/inventory/Tabs/Tabs.styles.ts @@ -17,7 +17,10 @@ export const getStyles = ({ colors, spacing }: GrafanaTheme2) => ({ `, actionPanel: css` display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: ${spacing(1)}; margin-bottom: 5px; `, confirmationText: css` diff --git a/public/app/percona/inventory/installToken.ts b/public/app/percona/inventory/installToken.ts new file mode 100644 index 0000000000000..fa3c2d84b20a0 --- /dev/null +++ b/public/app/percona/inventory/installToken.ts @@ -0,0 +1,132 @@ +import { config } from '@grafana/runtime'; + +import { api } from 'app/percona/shared/helpers/api'; + +/** + * Grafana HTTP API path when `serve_from_sub_path` is true (PMM sets + * `root_url = https://…/graph`). Browser requests must use `/graph/api/…`, + * not `/api/…` — the latter hit the host root where nginx does not proxy + * to Grafana, so service-account calls would fail with 404. + * + * @param restPath Path after `/api`, e.g. `/serviceaccounts/search`. + */ +function grafanaHttpApiUrl(restPath: string): string { + const p = restPath.startsWith('/') ? restPath : `/${restPath}`; + return `${config.appSubUrl ?? ''}/api${p}`; +} + +/** + * Mints a short-lived Grafana service-account token used by the "Add Node" + * quick-install command (one-step `install-pmm-client.sh` flow). + * + * Implementation note: this used to POST to a PMM backend endpoint + * (/v1/management/nodes:installToken). That endpoint was removed because + * the same job can be done by calling Grafana's serviceaccounts API + * directly — we're already running inside Grafana with the user's session + * cookie, so there is no extra trust boundary to add. Grafana enforces + * Admin-only access to `{appSubUrl}/api/serviceaccounts/*`, which is the + * same gate the removed backend had. + * + * Lifecycle mirrors what the old backend did: + * - One shared service account per technology ("pmm-install-sa-"), + * created lazily on first use; we don't want one SA per token because + * they would accumulate in Grafana forever. + * - Each call mints a brand-new token on that SA with a UUID-suffixed + * name so concurrent calls don't collide on Grafana's unique-name + * constraint per SA. + * - Tokens are short-lived (15 min hard cap, matching the previous + * server-side cap). Re-run the action to get a fresh one. + */ +export interface CreateNodeInstallTokenResponse { + token: string; + expiresAt: string; +} + +const MAX_TTL_SECONDS = 15 * 60; +const DEFAULT_TTL_SECONDS = 15 * 60; + +const SUPPORTED_TECHNOLOGIES = new Set(['mysql', 'postgresql', 'mongodb', 'valkey']); + +const SA_NAME_PREFIX = 'pmm-install-sa'; +const TOKEN_NAME_PREFIX = 'pmm-install-st'; + +interface GrafanaServiceAccount { + id: number; + name: string; +} + +interface GrafanaServiceAccountSearch { + totalCount: number; + serviceAccounts: GrafanaServiceAccount[]; +} + +interface GrafanaTokenResponse { + id: number; + name: string; + key: string; +} + +export async function createNodeInstallToken( + technology: string, + ttlSeconds = 0 +): Promise { + if (!SUPPORTED_TECHNOLOGIES.has(technology)) { + throw new Error(`unsupported technology "${technology}"`); + } + + let ttl = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS; + if (ttl > MAX_TTL_SECONDS) { + ttl = MAX_TTL_SECONDS; + } + + const saName = `${SA_NAME_PREFIX}-${technology}`; + + let saId = await findServiceAccountIdByName(saName); + if (saId === null) { + saId = await createServiceAccount(saName); + } + + const tokenName = `${TOKEN_NAME_PREFIX}-${technology}-${crypto.randomUUID()}`; + const key = await mintToken(saId, tokenName, ttl); + + return { + token: key, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), + }; +} + +async function findServiceAccountIdByName(name: string): Promise { + // Pass disableNotifications=true on all three calls so the single friendly + // "Could not create install token" toast in the caller is the only one the + // user sees on failure (api.post would otherwise emit its own toast). + const res = await api.get( + grafanaHttpApiUrl('/serviceaccounts/search'), + true, + { params: { query: name } } + ); + const match = res.serviceAccounts?.find((sa) => sa.name === name) ?? null; + return match ? match.id : null; +} + +async function createServiceAccount(name: string): Promise { + // Admin role required — `pmm-admin config` / inventory writes need it in + // real PMM setups, and Grafana itself only lets Admins POST to this route. + const res = await api.post( + grafanaHttpApiUrl('/serviceaccounts'), + { name, role: 'Admin', isDisabled: false }, + true + ); + return res.id; +} + +async function mintToken(serviceAccountId: number, tokenName: string, ttlSeconds: number): Promise { + // Only name + secondsToLive — passing extra fields (e.g. role) has been + // observed to make some Grafana versions ignore secondsToLive and fall + // back to a long default expiry. + const res = await api.post( + grafanaHttpApiUrl(`/serviceaccounts/${serviceAccountId}/tokens`), + { name: tokenName, secondsToLive: ttlSeconds }, + true + ); + return res.key; +} diff --git a/public/app/percona/redirect/RedirectPage.tsx b/public/app/percona/redirect/RedirectPage.tsx index e4deb31083dca..c2dc6d4fe7b02 100644 --- a/public/app/percona/redirect/RedirectPage.tsx +++ b/public/app/percona/redirect/RedirectPage.tsx @@ -8,7 +8,10 @@ const RedirectPage = () => { const { pathname } = useLocation(); useEffect(() => { - window.location.replace('/pmm-ui' + pathname); + // if location contains pmm-ui it should be picked up by pmm-compat plugin + if (!pathname.includes('/pmm-ui')) { + window.location.replace('/pmm-ui' + pathname); + } }, [pathname]); return ( diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index e64e9759c4557..19b4396062931 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -653,6 +653,13 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "AllChecksPage" */ 'app/percona/check/components/AllChecksTab/AllChecksTab') ), }, + { + path: '/pmm-ui/*', + // eslint-disable-next-line react/display-name + component: SafeDynamicImport( + () => import(/* webpackChunkName: "RedirectPage" */ 'app/percona/redirect/RedirectPage') + ), + }, { path: '/settings/*', // eslint-disable-next-line react/display-name