Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dea75fe
PMM: Add Install PMM Client dropdown on Inventory Nodes page
theTibi Apr 14, 2026
66449f6
PMM: Install Client menu creates node install token and copies command
theTibi Apr 14, 2026
e431e89
PMM: Label Nodes action as Add Node with MySQL/MongoDB/PostgreSQL menu
theTibi Apr 14, 2026
4f2fc52
PMM: Add Valkey option to node installation menu and update command g…
theTibi Apr 16, 2026
743dab9
Merge branch 'main' into one_step_install
theTibi Apr 28, 2026
5ddc7d5
PMM: Update quick install command to use downloaded script and improv…
theTibi May 4, 2026
e71d808
Merge branch 'main' into one_step_install
theTibi May 4, 2026
0d2a462
Merge branch 'main' into one_step_install
mattiasimonato May 6, 2026
35a21a5
fix: apply Prettier to nodes tab utilities
mattiasimonato May 6, 2026
6781820
fix: add unit tests for buildQuickInstallCommand
mattiasimonato May 11, 2026
62a91be
PMM: Implement createNodeInstallToken function for Grafana service ac…
theTibi May 13, 2026
064784e
PMM: Refactor API calls to use grafanaHttpApiUrl function
theTibi May 13, 2026
6d52608
refactor: extract node install options constants and map icons by tech
Copilot May 14, 2026
71f24b7
Merge branch 'main' into one_step_install
mattiasimonato May 14, 2026
b54b974
Merge branch 'main' into one_step_install
mattiasimonato May 14, 2026
3acb5dc
fix(inventory): align quick install icons with Percona branding
mattiasimonato May 14, 2026
ccd755a
Merge branch 'main' into one_step_install
matejkubinec May 14, 2026
05ed569
Merge branch 'main' into one_step_install
ademidoff May 15, 2026
ab40df6
Merge branch 'main' into one_step_install
mattiasimonato May 19, 2026
8de4294
feat: unify design between services nodes, put more options into drop…
matejkubinec May 20, 2026
04b613d
PMM-14441 PMM Dump encryption (#897)
matejkubinec May 19, 2026
bf6ddb4
Fix grafana stack height & back port fix from Grafana (#903)
fabio-silva May 20, 2026
a4fa43e
feat: provide data testid for add node button
matejkubinec May 20, 2026
cab7cd5
Merge branch 'main'
matejkubinec May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions public/app/percona/inventory/Inventory.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions public/app/percona/inventory/Tabs/Nodes.constants.ts
Original file line number Diff line number Diff line change
@@ -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<QuickInstallTech, IconName> = {
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 },
];
192 changes: 127 additions & 65 deletions public/app/percona/inventory/Tabs/Nodes.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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(
() =>
Expand Down Expand Up @@ -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);
}
}, []);
Comment thread
mattiasimonato marked this conversation as resolved.

return (
<TabbedPage navModel={navModel} isLoading={isLoading}>
<TabbedPageContents>
<FeatureLoader>
<div className={styles.actionPanel}>
<Button
size="md"
disabled={selected.length === 0}
onClick={() => {
setModalVisible(!modalVisible);
}}
icon="trash-alt"
variant="destructive"
>
{Messages.delete}
</Button>
</div>
<Modal
ariaLabel={Messages.confirmAction}
title={
<div className="modal-header-title">
<span className="p-l-1">{Messages.confirmAction}</span>
</div>
}
isOpen={modalVisible}
onDismiss={() => setModalVisible(false)}
>
<Form
onSubmit={proceed}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<>
<h4 className={styles.confirmationText}>{deletionMsg}</h4>
<FormElement
dataTestId="form-field-force"
label={Messages.forceMode}
element={<CheckboxField name="force" label={Messages.nodes.forceConfirmation} />}
<Stack direction="column">
<Stack direction="row" justifyContent="flex-end" alignItems="center">
<Button
size="md"
disabled={selected.length === 0}
onClick={() => {
setModalVisible(!modalVisible);
}}
icon="trash-alt"
variant="destructive"
>
{Messages.delete}
</Button>
<Dropdown
overlay={
<Menu>
{QUICK_INSTALL_OPTIONS.map(({ tech, label, icon }) => (
<Menu.Item
key={tech}
label={label}
icon={icon}
disabled={installTokenLoading}
onClick={() => {
copyQuickInstallWithToken(tech);
}}
/>
))}
<Menu.Divider />
<Menu.Item
key="more-options"
onClick={() => navigate('/pmm-ui/install-client')}
label={Messages.nodes.addNodeMoreOptions}
/>
<Stack direction="row" justifyContent="space-between">
<Button variant="secondary" size="md" onClick={() => setModalVisible(false)}>
{Messages.cancel}
</Button>
<Button type="submit" size="md" variant="destructive">
{Messages.proceed}
</Button>
</Stack>
</>
</form>
)}
</Menu>
}
placement="bottom-start"
>
<Button
data-testid="add-node-button"
variant="primary"
size="md"
icon="angle-down"
disabled={installTokenLoading}
>
{Messages.nodes.addNodeButton}
</Button>
</Dropdown>
</Stack>
<Modal
ariaLabel={Messages.confirmAction}
title={
<div className="modal-header-title">
<span className="p-l-1">{Messages.confirmAction}</span>
</div>
}
isOpen={modalVisible}
onDismiss={() => setModalVisible(false)}
>
<Form
onSubmit={proceed}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<>
<h4 className={styles.confirmationText}>{deletionMsg}</h4>
<FormElement
dataTestId="form-field-force"
label={Messages.forceMode}
element={<CheckboxField name="force" label={Messages.nodes.forceConfirmation} />}
/>
<Stack direction="row" justifyContent="space-between">
<Button variant="secondary" size="md" onClick={() => setModalVisible(false)}>
{Messages.cancel}
</Button>
<Button type="submit" size="md" variant="destructive">
{Messages.proceed}
</Button>
</Stack>
</>
</form>
)}
/>
</Modal>
<Table
columns={columns}
data={mappedNodes}
totalItems={mappedNodes.length}
rowSelection
autoResetSelectedRows={false}
autoResetExpanded={false}
autoResetPage={false}
onRowSelection={handleSelectionChange}
showPagination
pageSize={25}
allRowsSelectionMode="page"
emptyMessage={Messages.nodes.emptyTable}
pendingRequest={isLoading}
overlayClassName={styles.overlay}
renderExpandedRow={renderSelectedSubRow}
getRowId={useCallback((row: FlattenNode) => row.nodeId, [])}
showFilter
/>
</Modal>
<Table
columns={columns}
data={mappedNodes}
totalItems={mappedNodes.length}
rowSelection
autoResetSelectedRows={false}
autoResetExpanded={false}
autoResetPage={false}
onRowSelection={handleSelectionChange}
showPagination
pageSize={25}
allRowsSelectionMode="page"
emptyMessage={Messages.nodes.emptyTable}
pendingRequest={isLoading}
overlayClassName={styles.overlay}
renderExpandedRow={renderSelectedSubRow}
getRowId={useCallback((row: FlattenNode) => row.nodeId, [])}
showFilter
/>
</Stack>
</FeatureLoader>
</TabbedPageContents>
</TabbedPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <TOKEN> placeholder when no token is provided', () => {
const cmd = buildQuickInstallCommand('mysql');
expect(cmd).toContain('service_token:<TOKEN>@');
});

it.each<QuickInstallTech>(['mysql', 'postgresql', 'mongodb', 'valkey'])(
'includes the selected tech in the --tech flag (%s)',
(tech) => {
const cmd = buildQuickInstallCommand(tech);
expect(cmd).toContain(`--tech '${tech}'`);
}
);
});
37 changes: 37 additions & 0 deletions public/app/percona/inventory/Tabs/NodesInstallCommand.utils.ts
Original file line number Diff line number Diff line change
@@ -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') {
Comment thread
mattiasimonato marked this conversation as resolved.
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) : '<TOKEN>';
const serverUrl = `https://service_token:${tokenForUrl}@${host}:${port}`;
Comment thread
mattiasimonato marked this conversation as resolved.

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');
}
5 changes: 4 additions & 1 deletion public/app/percona/inventory/Tabs/Tabs.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading
Loading