Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
1 change: 1 addition & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ coverage
out/
build
dist
*.tsbuildinfo


# Debug
Expand Down
5 changes: 5 additions & 0 deletions ui/apps/pmm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"@percona/percona-ui": "1.0.24",
"@pmm/shared": "workspace:*",
"@reactour/tour": "^3.8.0",
"@sep/api": "workspace:*",
"@sep/framework": "workspace:*",
"@sep/plugins-atw": "workspace:*",
"@tanstack/react-query": "^5.100.7",
"axios": "^1.13.5",
"axios-case-converter": "^1.1.1",
Expand All @@ -44,6 +47,7 @@
"react-markdown": "^9.0.1",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"react-syntax-highlighter": "^16.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"vite-plugin-svgr": "^5.2.0",
Expand All @@ -54,6 +58,7 @@
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react-swc": "^4.3.1",
"jsdom": "^29.1.1",
Expand Down
27 changes: 7 additions & 20 deletions ui/apps/pmm/src/components/page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
Divider,
GlobalStyles,
Link,
Stack,
Typography,
} from '@mui/material';
import { PageContainer } from '@percona/percona-ui';
import { useUser } from 'contexts/user';
import { Messages } from './Page.messages';
import { PMM_HOME_URL } from 'lib/constants';
Expand All @@ -23,13 +23,17 @@ export const Page: FC<PageProps> = ({
topBar,
footer,
children,
maxWidth,
fullWidth,
surface,
roles,
}) => {
const { user } = useUser();
updateDocumentTitle(title);
const hasAccess = !roles || roles?.some((role) => user?.orgRole === role);
// Back-compat: `fullWidth` predates `maxWidth`; treat it as `maxWidth="full"`
// unless an explicit `maxWidth` is provided.
const resolvedMaxWidth = maxWidth ?? (fullWidth ? 'full' : undefined);

return (
<>
Expand All @@ -45,24 +49,7 @@ export const Page: FC<PageProps> = ({
})}
/>
)}
<Stack
sx={{
flex: 1,
width: '100%',
maxWidth: {
lg: 1000,
},
p: {
xs: 2,
},
px: {
md: fullWidth ? 4 : undefined,
},
mx: 'auto',
gap: 2,
mt: 1,
}}
>
<PageContainer maxWidth={resolvedMaxWidth}>
{topBar}
{!!title && <Typography variant="h2">{title}</Typography>}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
Expand All @@ -86,7 +73,7 @@ export const Page: FC<PageProps> = ({
</Box>
<Divider />
{footer !== undefined ? footer : <Footer />}
</Stack>
</PageContainer>
</>
);
};
10 changes: 10 additions & 0 deletions ui/apps/pmm/src/components/page/Page.types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { PropsWithChildren, ReactNode } from 'react';
import type { PageContainerMaxWidth } from '@percona/percona-ui';
import { OrgRole } from 'types/user.types';

export interface PageProps extends PropsWithChildren {
title?: string;
footer?: ReactNode;
topBar?: ReactNode;
/**
* Max content width: a pixel number, or `'full'` for 100% width.
* @default 1000
*/
maxWidth?: PageContainerMaxWidth;
/**
* @deprecated Use `maxWidth="full"` instead. Kept as an alias that maps to
* `maxWidth="full"` when `maxWidth` is not set.
*/
fullWidth?: boolean;
surface?: 'default' | 'paper';
roles?: OrgRole[];
Expand Down
101 changes: 101 additions & 0 deletions ui/apps/pmm/src/components/syntax-highlighter/SyntaxHighlighter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import IconButton from '@mui/material/IconButton';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { useTheme } from '@mui/material/styles';
import { FC } from 'react';
import { PrismLight as ReactSyntaxHighlighter } from 'react-syntax-highlighter';
import { enqueueSnackbar } from 'notistack';
import { getSyntaxHighlighterStyle } from './SyntaxHighlighter.utils';

// Import only used languages to reduce bundle size
// @ts-ignore
import mongodb from 'react-syntax-highlighter/dist/esm/languages/prism/mongodb';
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
import { SyntaxHighlighterProps } from './SyntaxHighlighter.types';

ReactSyntaxHighlighter.registerLanguage('mongodb', mongodb);
ReactSyntaxHighlighter.registerLanguage('json', json);

const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
language,
content,
showCopyButton = false,
disableBorder = false,
maxHeight,
...props
}) => {
const theme = useTheme();
const highlighterStyle = getSyntaxHighlighterStyle(
theme,
language,
showCopyButton
);

const handleCopy = async () => {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(content);
enqueueSnackbar('Query copied to clipboard', { variant: 'success' });
} catch (error) {
enqueueSnackbar('Failed to copy query to clipboard', {
variant: 'error',
});
}
} else {
enqueueSnackbar('Clipboard is not available', { variant: 'error' });
}
};

const highlighterBlock = (
<>
{/* @ts-ignore - react-syntax-highlighter types can be incompatible with the React version */}
<ReactSyntaxHighlighter
language={language}
style={highlighterStyle}
{...props}
>
{content}
</ReactSyntaxHighlighter>
</>
);

return (
<Stack
sx={{
overflow: 'hidden',
...(!disableBorder && {
borderWidth: 1,
borderStyle: 'solid',
borderColor: theme.palette.divider,
borderRadius: Number(theme.shape.borderRadius) / 2,
}),
backgroundColor: theme.palette.surfaces?.high || 'transparent',
position: 'relative',
}}
>
{maxHeight != null ? (
<Box sx={{ maxHeight, overflow: 'auto', minHeight: 0 }}>
{highlighterBlock}
</Box>
) : (
highlighterBlock
)}
{showCopyButton && (
<IconButton
sx={{
position: 'absolute',
top: theme.spacing(1.5),
right: theme.spacing(1.8),
padding: 0,
}}
onClick={handleCopy}
>
<ContentCopyIcon sx={{ width: 18, height: 18 }} color="disabled" />
</IconButton>
)}
</Stack>
);
};

export default SyntaxHighlighter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { SyntaxHighlighterProps as ReactSyntaxHighlighterProps } from 'react-syntax-highlighter';
import { CodeLanguage } from 'types/util.types';

export interface SyntaxHighlighterProps extends Omit<
ReactSyntaxHighlighterProps,
'children'
> {
language: CodeLanguage;
content: string;
showCopyButton?: boolean;
disableBorder?: boolean;
/** When set, the code area scrolls inside the bordered box (e.g. "70vh") */
maxHeight?: string | number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import vscDarkPlus from 'react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus';
import { Theme } from '@mui/material/styles';
import { semanticTokensLight, semanticTokensDark } from '@percona/percona-ui';
import { CodeLanguage } from 'types/util.types';

export const getSyntaxHighlighterStyle = (
theme: Theme,
language: CodeLanguage,
showCopyButton = false
) => {
const accents =
theme.palette.mode === 'light'
? semanticTokensLight.text
: semanticTokensDark.text;

const tokens = {
fontFamily: 'Roboto Mono, monospace',
background: 'transparent',
base: language === 'text' ? theme.palette.text.primary : accents.accent1,
attrValue: accents.accent3,
string: accents.accent3,
number: theme.palette.text.primary,
property: accents.accent2,
function: accents.accent2,
operator: accents.accent3,
punctuation: theme.palette.text.secondary,
};

// Define your custom styles to override the base VSC Dark Plus colors
const customStyle = {
...vscDarkPlus,
'pre[class*="language-"]': {
margin: 0,
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(showCopyButton ? 3.5 : 2),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
background: tokens.background,
fontFamily: tokens.fontFamily,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: tokens.background,
color: tokens.base,
fontFamily: tokens.fontFamily,
},
'attr-value': {
color: tokens.attrValue,
},
function: {
color: tokens.function,
},
property: {
color: tokens.property,
},
string: {
color: tokens.string,
},
number: {
color: tokens.number,
},
operator: {
color: tokens.operator,
},
punctuation: {
color: tokens.punctuation,
},
};

return customStyle;
};
1 change: 1 addition & 0 deletions ui/apps/pmm/src/components/syntax-highlighter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as SyntaxHighlighter } from './SyntaxHighlighter';
5 changes: 5 additions & 0 deletions ui/apps/pmm/src/contexts/navigation/navigation.provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
addHighAvailability,
addUsersAndAccess,
addHomePage,
addSepApps,
} from './navigation.utils';
import { useUser } from 'contexts/user';
import { useAdvisors } from 'hooks/api/useAdvisors';
Expand Down Expand Up @@ -89,6 +90,10 @@ export const NavigationProvider: FC<PropsWithChildren> = ({ children }) => {

items.push(NAV_INVENTORY);

// SEP apps mounted as native routes (migration). Shown once the session
// is established; role/flag gating comes with real auth (Option B).
items.push(...addSepApps());

if (settings.backupManagementEnabled) {
items.push(NAV_BACKUPS);
}
Expand Down
22 changes: 22 additions & 0 deletions ui/apps/pmm/src/contexts/navigation/navigation.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import { MySqlIcon } from '@percona/percona-ui';
import { NavItem } from 'types/navigation.types';
import { ServiceType } from 'types/services.types';
import { User, UserPreferences } from 'types/user.types';
Expand Down Expand Up @@ -291,3 +293,23 @@ export const addHomePage = (preferences?: UserPreferences): NavItem => {

return NAV_HOME_PAGE;
};

// SEP apps mounted as native PMM routes (migration). Metadata (icons/labels/routes)
// is lifted from SEP's appNavConfig as data only — no SEP nav component is used.
// Role/flag gating arrives with real auth (Option B).
export const addSepApps = (): NavItem[] => [
{
id: 'sep-atw',
text: 'Collect Diagnostic Data',
icon: MonitorHeartIcon,
url: '/sep/atw',
matches: ['/sep/atw'],
},
{
id: 'sep-mysql-backups',
text: 'MySQL Backups',
icon: MySqlIcon,
url: '/sep/mysql-backups',
matches: ['/sep/mysql-backups'],
},
];
3 changes: 3 additions & 0 deletions ui/apps/pmm/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { initSepAuth } from './sep/bootstrap';

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
Expand All @@ -13,6 +14,8 @@ import '@fontsource/poppins/600.css';

import '@fontsource/roboto-mono';

initSepAuth();

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
Expand Down
7 changes: 1 addition & 6 deletions ui/apps/pmm/src/pages/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,7 @@ export const Settings: FC = () => {
const setTab = (value: TabValue) => navigate(`/settings/${value}`);

return (
<Page
title={Messages.title}
fullWidth
surface="paper"
roles={[OrgRole.Admin]}
>
<Page title={Messages.title} surface="paper" roles={[OrgRole.Admin]}>
<Stack gap={3} sx={{ flex: 1 }}>
<Tabs
data-testid="settings-tabs"
Expand Down
Loading
Loading