Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
72 changes: 71 additions & 1 deletion public/app/percona/inventory/Tabs/Nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ import { Form } from 'react-final-form';
import { Row } from 'react-table';

import { AppEvents } from '@grafana/data';
import { Badge, Button, HorizontalGroup, Icon, Link, Modal, Stack, TagList, useStyles2 } from '@grafana/ui';
import {
Badge,
Button,
Dropdown,
HorizontalGroup,
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 @@ -46,6 +58,8 @@ import {
getBadgeTextForServiceStatus,
getTagsFromLabels,
} from './Services.utils';
import { createNodeInstallToken } from '../installToken';
import { buildQuickInstallCommand, QuickInstallTech } from './NodesInstallCommand.utils';
import { getStyles } from './Tabs.styles';
import { DATA_INTERVAL } from 'app/percona/shared/core';

Expand All @@ -58,6 +72,7 @@ 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);
Expand Down Expand Up @@ -348,11 +363,66 @@ 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.

const addNodeAdvancedHref =
typeof window !== 'undefined' ? `${window.location.origin}/pmm-ui/install-client` : '/pmm-ui/install-client';

return (
<TabbedPage navModel={navModel} isLoading={isLoading}>
<TabbedPageContents>
<FeatureLoader>
<div className={styles.actionPanel}>
<HorizontalGroup spacing="md" wrap>
<Dropdown
overlay={() => (
<Menu>
{(
[
Comment thread
matejkubinec marked this conversation as resolved.
Outdated
['mysql', Messages.nodes.addNodeMySQL],
['postgresql', Messages.nodes.addNodePostgreSQL],
['mongodb', Messages.nodes.addNodeMongoDB],
['valkey', Messages.nodes.addNodeValkey],
] as const
).map(([tech, label]) => (
<Menu.Item
key={tech}
label={label}
icon="database"
Comment thread
matejkubinec marked this conversation as resolved.
Outdated
disabled={installTokenLoading}
onClick={() => {
copyQuickInstallWithToken(tech);
}}
/>
))}
</Menu>
)}
placement="bottom-start"
>
<Button variant="primary" size="md" icon="angle-down" disabled={installTokenLoading}>
{Messages.nodes.addNodeButton}
</Button>
</Dropdown>
<Link href={addNodeAdvancedHref}>{Messages.nodes.addNodeMoreOptions}</Link>
</HorizontalGroup>
<Button
size="md"
disabled={selected.length === 0}
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
132 changes: 132 additions & 0 deletions public/app/percona/inventory/installToken.ts
Original file line number Diff line number Diff line change
@@ -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-<tech>"),
* 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<CreateNodeInstallTokenResponse> {
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<number | null> {
// 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<GrafanaServiceAccountSearch, { query: string }>(
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<number> {
// 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<GrafanaServiceAccount, { name: string; role: string; isDisabled: boolean }>(
grafanaHttpApiUrl('/serviceaccounts'),
{ name, role: 'Admin', isDisabled: false },
true
);
return res.id;
}

async function mintToken(serviceAccountId: number, tokenName: string, ttlSeconds: number): Promise<string> {
// 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<GrafanaTokenResponse, { name: string; secondsToLive: number }>(
grafanaHttpApiUrl(`/serviceaccounts/${serviceAccountId}/tokens`),
{ name: tokenName, secondsToLive: ttlSeconds },
true
);
return res.key;
}
Loading