Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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