diff --git a/clients/ui/frontend/Dockerfile b/clients/ui/frontend/Dockerfile index 2b20d06f..0e787fde 100644 --- a/clients/ui/frontend/Dockerfile +++ b/clients/ui/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18 AS build-stage +FROM node:20 AS build-stage WORKDIR /usr/src/app diff --git a/clients/ui/frontend/config/webpack.common.js b/clients/ui/frontend/config/webpack.common.js index ddbf702b..6da6aa23 100644 --- a/clients/ui/frontend/config/webpack.common.js +++ b/clients/ui/frontend/config/webpack.common.js @@ -5,8 +5,8 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const Dotenv = require('dotenv-webpack'); -const BG_IMAGES_DIRNAME = 'bgimages'; const ASSET_PATH = process.env.ASSET_PATH || '/'; +const IMAGES_DIRNAME = 'images'; const relativeDir = path.resolve(__dirname, '..'); module.exports = (env) => { return { @@ -35,42 +35,56 @@ module.exports = (env) => { path.resolve(relativeDir, 'node_modules/@patternfly/react-core/dist/styles/assets/pficon'), path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/fonts'), path.resolve(relativeDir, 'node_modules/@patternfly/patternfly/assets/pficon') - ] + ], + use: { + loader: 'file-loader', + options: { + // Limit at 50k. larger files emitted into separate files + limit: 5000, + outputPath: 'fonts', + name: '[name].[ext]', + }, + }, }, { test: /\.svg$/, - type: 'asset/inline', include: (input) => input.indexOf('background-filter.svg') > 1, use: [ { + loader: 'url-loader', options: { limit: 5000, outputPath: 'svgs', - name: '[name].[ext]' - } - } - ] + name: '[name].[ext]', + }, + }, + ], }, { test: /\.svg$/, // only process SVG modules with this loader if they live under a 'bgimages' directory // this is primarily useful when applying a CSS background using an SVG - include: (input) => input.indexOf(BG_IMAGES_DIRNAME) > -1, - type: 'asset/inline' + include: (input) => input.indexOf(IMAGES_DIRNAME) > -1, + use: { + loader: 'svg-url-loader', + options: { + limit: 10000, + }, + }, }, { test: /\.svg$/, // only process SVG modules with this loader when they don't live under a 'bgimages', // 'fonts', or 'pficon' directory, those are handled with other loaders include: (input) => - input.indexOf(BG_IMAGES_DIRNAME) === -1 && + input.indexOf(IMAGES_DIRNAME) === -1 && input.indexOf('fonts') === -1 && input.indexOf('background-filter') === -1 && input.indexOf('pficon') === -1, use: { loader: 'raw-loader', - options: {} - } + options: {}, + }, }, { test: /\.(jpg|jpeg|png|gif)$/i, @@ -103,6 +117,17 @@ module.exports = (env) => { } } ] + }, + { + test: /\.s[ac]ss$/i, + use: [ + // Creates `style` nodes from JS strings + 'style-loader', + // Translates CSS into CommonJS + 'css-loader', + // Compiles Sass to CSS + 'sass-loader', + ], } ] }, @@ -120,7 +145,7 @@ module.exports = (env) => { silent: true }), new CopyPlugin({ - patterns: [{ from: './src/favicon.png', to: 'images' }] + patterns: [{ from: './src/images', to: 'images' }] }) ], resolve: { diff --git a/clients/ui/frontend/config/webpack.dev.js b/clients/ui/frontend/config/webpack.dev.js index 311a9119..f7a6c806 100644 --- a/clients/ui/frontend/config/webpack.dev.js +++ b/clients/ui/frontend/config/webpack.dev.js @@ -20,28 +20,30 @@ module.exports = merge(common('development'), { historyApiFallback: true, open: true, static: { - directory: path.resolve(relativeDir, 'dist') + directory: path.resolve(relativeDir, 'dist'), }, client: { - overlay: true + overlay: true, }, - proxy: { - '/api': { + proxy: [ + { + context: ["/api"], target: { host: PROXY_HOST, protocol: PROXY_PROTOCOL, port: PROXY_PORT, }, + changeOrigin: true, }, - }, + ], }, module: { rules: [ { test: /\.css$/, include: [...stylePaths], - use: ['style-loader', 'css-loader'] - } - ] - } + use: ['style-loader', 'css-loader'], + }, + ], + }, }); diff --git a/clients/ui/frontend/docs/dev-setup.md b/clients/ui/frontend/docs/dev-setup.md index 4ea61f8b..327a4057 100644 --- a/clients/ui/frontend/docs/dev-setup.md +++ b/clients/ui/frontend/docs/dev-setup.md @@ -5,8 +5,8 @@ This project requires the following tools to be installed on your system: - [NodeJS and NPM](https://nodejs.org/) - - Node recommended version -> `18.16.0` - - NPM recommended version -> `9.6.7` + - Node recommended version -> `20.17.0` + - NPM recommended version -> `10.8.2` ### Additional tooling diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index 977275c7..f7d4aca9 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -15,7 +15,8 @@ "lodash-es": "^4.17.21", "npm-run-all": "^4.1.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "sass": "^1.78.0" }, "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -56,6 +57,7 @@ "react-router-dom": "^6.26.1", "regenerator-runtime": "^0.14.1", "rimraf": "^6.0.1", + "sass-loader": "^16.0.1", "serve": "^14.2.1", "style-loader": "^4.0.0", "svg-url-loader": "^8.0.0", @@ -73,7 +75,7 @@ "webpack-merge": "^6.0.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "optionalDependencies": { "@typescript-eslint/eslint-plugin": "^7.1.1", @@ -4874,7 +4876,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5504,7 +5505,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, "engines": { "node": ">=8" } @@ -6115,7 +6115,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6139,7 +6138,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -6151,7 +6149,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6163,7 +6160,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -6175,7 +6171,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -6184,7 +6179,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -6795,6 +6789,7 @@ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", "dev": true, + "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", @@ -10200,7 +10195,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -11085,6 +11079,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", @@ -11272,7 +11272,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11397,7 +11396,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -11451,7 +11449,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -15515,7 +15512,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -16389,7 +16385,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, "engines": { "node": ">=8.6" }, @@ -17463,7 +17458,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -17965,6 +17959,64 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sass": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", + "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.1.tgz", + "integrity": "sha512-xACl1ToTsKnL9Ce5yYpRxrLj9QUDCnwZNhzpC7tKiFyA8zXsd3Ap+HGVnbCgkdQcm43E+i6oKAWBsvGA6ZoiMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -18542,7 +18594,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -18997,6 +19048,7 @@ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 18.12.0" }, diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index 67e8bbbb..408bb28c 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -7,7 +7,7 @@ "license": "Apache-2.0", "private": true, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "scripts": { "build": "run-s build:prod", @@ -69,6 +69,7 @@ "react-router-dom": "^6.26.1", "regenerator-runtime": "^0.14.1", "rimraf": "^6.0.1", + "sass-loader": "^16.0.1", "serve": "^14.2.1", "style-loader": "^4.0.0", "svg-url-loader": "^8.0.0", @@ -92,7 +93,8 @@ "lodash-es": "^4.17.21", "npm-run-all": "^4.1.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "sass": "^1.78.0" }, "optionalDependencies": { "@typescript-eslint/eslint-plugin": "^7.1.1", diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/home.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/home.ts index 4ac1ddd0..4299e844 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/home.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/home.ts @@ -3,8 +3,8 @@ class Home { cy.visit(`/`); } - findButton() { - return cy.get('button:contains("Primary Action")'); + findTitle() { + cy.get(`h1`).should(`have.text`, `Model registry`); } } diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts index 40d531b3..ae7d99d3 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts @@ -8,6 +8,6 @@ describe('Application', () => { it('Home page should have primary button', () => { home.visit(); - home.findButton(); + home.findTitle(); }); }); diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index b4db3ef8..2a8a7a81 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -3,11 +3,13 @@ import '@patternfly/react-core/dist/styles/base.css'; import './app.css'; import { Alert, + Brand, Bullseye, Button, - Flex, Masthead, + MastheadBrand, MastheadContent, + MastheadMain, MastheadToggle, Page, PageSection, @@ -15,7 +17,6 @@ import { Spinner, Stack, StackItem, - Title, } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; import NavSidebar from './NavSidebar'; @@ -76,19 +77,22 @@ const App: React.FC = () => { const masthead = ( - - - - - + + + + + + + + + + - - - - Kubeflow Model Registry UI - - - + {/* TODO: Change this into a component for Header Tools */} ); diff --git a/clients/ui/frontend/src/app/AppRoutes.tsx b/clients/ui/frontend/src/app/AppRoutes.tsx index 015a897f..10051e6d 100644 --- a/clients/ui/frontend/src/app/AppRoutes.tsx +++ b/clients/ui/frontend/src/app/AppRoutes.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import { Route, Routes } from 'react-router-dom'; -import { Dashboard } from './Dashboard/Dashboard'; -import { Support } from './Support/Support'; -import { NotFound } from './NotFound/NotFound'; -import { Admin } from './Settings/Admin'; +import { NotFound } from './pages/notFound/NotFound'; +import ModelRegistrySettingsRoutes from './pages/settings/ModelRegistrySettingsRoutes'; +import ModelRegistryRoutes from './pages/modelRegistry/ModelRegistryRoutes'; export const isNavDataGroup = (navItem: NavDataItem): navItem is NavDataGroup => 'children' in navItem; @@ -35,24 +34,16 @@ export const useAdminSettings = (): NavDataItem[] => { return [ { label: 'Settings', - children: [ - { label: 'Setting 1', path: '/admin' }, - { label: 'Setting 2', path: '/admin' }, - { label: 'Setting 3', path: '/admin' }, - ], + children: [{ label: 'Model Registry', path: '/settings' }], }, ]; }; export const useNavData = (): NavDataItem[] => [ { - label: 'Dashboard', + label: 'Model Registry', path: '/', }, - { - label: 'Support', - path: '/support', - }, ...useAdminSettings(), ]; @@ -61,13 +52,12 @@ const AppRoutes: React.FC = () => { return ( - } /> - } /> + } /> } /> { // TODO: Remove the linter skip when we implement authentication // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - isAdmin && } /> + isAdmin && } /> } ); diff --git a/clients/ui/frontend/src/app/Dashboard/Dashboard.tsx b/clients/ui/frontend/src/app/Dashboard/Dashboard.tsx deleted file mode 100644 index 3fc01718..00000000 --- a/clients/ui/frontend/src/app/Dashboard/Dashboard.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { - Button, - Content, - ContentVariants, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - EmptyStateVariant, - PageSection, -} from '@patternfly/react-core'; -import { CubesIcon } from '@patternfly/react-icons'; - -const Dashboard: React.FunctionComponent = () => ( - - - - - This represents an the empty state pattern in Patternfly 6. Hopefully it's simple - enough to use but flexible enough to meet a variety of needs. - - - - - - - -); - -export { Dashboard }; diff --git a/clients/ui/frontend/src/app/Settings/Admin.tsx b/clients/ui/frontend/src/app/Settings/Admin.tsx deleted file mode 100644 index 6b1931f1..00000000 --- a/clients/ui/frontend/src/app/Settings/Admin.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { CubesIcon } from '@patternfly/react-icons'; -import { - Button, - Content, - ContentVariants, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - EmptyStateVariant, - PageSection, -} from '@patternfly/react-core'; - -const Admin: React.FunctionComponent = () => ( - - - - - This represents an the empty state pattern in Patternfly 6. Hopefully it's simple - enough to use but flexible enough to meet a variety of needs. - - - - - - - -); - -export { Admin }; diff --git a/clients/ui/frontend/src/app/Support/Support.tsx b/clients/ui/frontend/src/app/Support/Support.tsx deleted file mode 100644 index 000f2745..00000000 --- a/clients/ui/frontend/src/app/Support/Support.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { CubesIcon } from '@patternfly/react-icons'; -import { - Button, - Content, - ContentVariants, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - EmptyStateVariant, - PageSection, -} from '@patternfly/react-core'; - -const Support: React.FunctionComponent = () => ( - - - - - This represents an the empty state pattern in Patternfly 6. Hopefully it's simple - enough to use but flexible enough to meet a variety of needs. - - - - - - - -); - -export { Support }; diff --git a/clients/ui/frontend/src/app/bgimages/.gitkeep b/clients/ui/frontend/src/app/bgimages/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/clients/ui/frontend/src/app/components/ApplicationsPage.tsx b/clients/ui/frontend/src/app/components/ApplicationsPage.tsx new file mode 100644 index 00000000..346e207f --- /dev/null +++ b/clients/ui/frontend/src/app/components/ApplicationsPage.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { ExclamationCircleIcon, QuestionCircleIcon } from '@patternfly/react-icons'; +import { + PageSection, + Content, + EmptyState, + EmptyStateVariant, + Spinner, + EmptyStateBody, + PageBreadcrumb, + StackItem, + Stack, + Flex, +} from '@patternfly/react-core'; + +type ApplicationsPageProps = { + title?: React.ReactNode; + breadcrumb?: React.ReactNode; + description?: React.ReactNode; + loaded: boolean; + empty: boolean; + loadError?: Error; + children?: React.ReactNode; + errorMessage?: string; + emptyMessage?: string; + emptyStatePage?: React.ReactNode; + headerAction?: React.ReactNode; + headerContent?: React.ReactNode; + provideChildrenPadding?: boolean; + removeChildrenTopPadding?: boolean; + subtext?: React.ReactNode; + loadingContent?: React.ReactNode; + noHeader?: boolean; +}; + +const ApplicationsPage: React.FC = ({ + title, + breadcrumb, + description, + loaded, + empty, + loadError, + children, + errorMessage, + emptyMessage, + emptyStatePage, + headerAction, + headerContent, + provideChildrenPadding, + removeChildrenTopPadding, + subtext, + loadingContent, + noHeader, +}) => { + const renderHeader = () => ( + + + + + + + {title} + + + {subtext && {subtext}} + {description && {description}} + + + {headerAction} + + + {headerContent && {headerContent}} + + + ); + + const renderContents = () => { + if (loadError) { + return ( + + + {loadError.message} + + + ); + } + + if (!loaded) { + return ( + loadingContent || ( + + + + + + ) + ); + } + + if (empty) { + return !emptyStatePage ? ( + + + + ) : ( + emptyStatePage + ); + } + + if (provideChildrenPadding) { + return ( + + {children} + + ); + } + + return children; + }; + + return ( + <> + {breadcrumb && {breadcrumb}} + {!noHeader && renderHeader()} + {renderContents()} + + ); +}; + +export default ApplicationsPage; diff --git a/clients/ui/frontend/src/app/components/design/CollapsibleSection.tsx b/clients/ui/frontend/src/app/components/design/CollapsibleSection.tsx new file mode 100644 index 00000000..0228052f --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/CollapsibleSection.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Button, Flex, FlexItem, Content, ContentVariants } from '@patternfly/react-core'; +import { AngleDownIcon, AngleRightIcon } from '@patternfly/react-icons'; + +interface CollapsibleSectionProps { + open?: boolean; + setOpen?: (update: boolean) => void; + title: string; + titleVariant?: ContentVariants.h1 | ContentVariants.h2; + children?: React.ReactNode; + id?: string; + showChildrenWhenClosed?: boolean; +} + +const CollapsibleSection: React.FC = ({ + open, + setOpen, + title, + titleVariant = ContentVariants.h2, + children, + id, + showChildrenWhenClosed, +}) => { + const [innerOpen, setInnerOpen] = React.useState(true); + const localId = id || title.replace(/ /g, '-'); + const titleId = `${localId}-title`; + + return ( + <> + + + + + ) : null} + + +); + +export default DividedGallery; diff --git a/clients/ui/frontend/src/app/components/design/DividedGalleryItem.tsx b/clients/ui/frontend/src/app/components/design/DividedGalleryItem.tsx new file mode 100644 index 00000000..af82c87e --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/DividedGalleryItem.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { GalleryItemProps } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; + +import './DividedGallery.scss'; + +const DividedGalleryItem: React.FC = ({ className, ...rest }) => ( +
+); + +export default DividedGalleryItem; diff --git a/clients/ui/frontend/src/app/components/design/HeaderIcon.tsx b/clients/ui/frontend/src/app/components/design/HeaderIcon.tsx new file mode 100644 index 00000000..e0e8ebf0 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/HeaderIcon.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + sectionTypeBackgroundColor, + typedBackgroundColor, + typedObjectImage, + ProjectObjectType, + SectionType, +} from '~/app/components/design/utils'; + +interface HeaderIconProps { + size?: number; + padding?: number; + image?: string; + type: ProjectObjectType; + sectionType?: SectionType; +} + +const HeaderIcon: React.FC = ({ + size = 40, + padding = 2, + image, + type, + sectionType, +}) => ( +
+ +
+); + +export default HeaderIcon; diff --git a/clients/ui/frontend/src/app/components/design/InfoGalleryItem.tsx b/clients/ui/frontend/src/app/components/design/InfoGalleryItem.tsx new file mode 100644 index 00000000..3ce5f3f1 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/InfoGalleryItem.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { + Button, + ButtonVariant, + Flex, + FlexItem, + GalleryItemProps, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { SectionType, sectionTypeBackgroundColor } from '~/app/components/design/utils'; +import DividedGalleryItem from '~/app/components/design/DividedGalleryItem'; + +const HEADER_ICON_SIZE = 40; +const HEADER_ICON_PADDING = 2; + +type InfoGalleryItemProps = { + title: string; + imgSrc: string; + sectionType: SectionType; + description: React.ReactNode; + isOpen: boolean; + onClick?: () => void; + testId?: string; +} & GalleryItemProps; + +const InfoGalleryItem: React.FC = ({ + title, + imgSrc, + sectionType, + description, + isOpen, + onClick, + testId, + ...rest +}) => ( + + + + + + + + {onClick ? ( + + ) : ( + {title} + )} + + + {isOpen ? {description} : null} + + +); + +export default InfoGalleryItem; diff --git a/clients/ui/frontend/src/app/components/design/ScrolledGallery.tsx b/clients/ui/frontend/src/app/components/design/ScrolledGallery.tsx new file mode 100644 index 00000000..3d619970 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/ScrolledGallery.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +type ScrolledGalleryProps = { + count: number; + childWidth: string; +} & React.HTMLProps; + +const ScrolledGallery: React.FC = ({ + children, + count, + childWidth, + ...rest +}) => { + let gridTemplateColumns = childWidth; + for (let i = 1; i < count; i++) { + gridTemplateColumns = `${gridTemplateColumns} ${childWidth}`; + } + return ( +
+ {children} +
+ ); +}; + +export default ScrolledGallery; diff --git a/clients/ui/frontend/src/app/components/design/TitleWithIcon.tsx b/clients/ui/frontend/src/app/components/design/TitleWithIcon.tsx new file mode 100644 index 00000000..02893ae0 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/TitleWithIcon.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { + ProjectObjectType, + typedBackgroundColor, + typedObjectImage, +} from '~/app/components/design/utils'; + +interface TitleWithIconProps { + title: React.ReactNode; + objectType: ProjectObjectType; + iconSize?: number; + padding?: number; +} + +const TitleWithIcon: React.FC = ({ + title, + objectType, + iconSize = 40, + padding = 4, +}) => ( + + +
+ +
+
+ {title} +
+); + +export default TitleWithIcon; diff --git a/clients/ui/frontend/src/app/components/design/TypeBorderCard.scss b/clients/ui/frontend/src/app/components/design/TypeBorderCard.scss new file mode 100644 index 00000000..8edc0f3b --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/TypeBorderCard.scss @@ -0,0 +1,141 @@ +.odh-type-bordered-card { + position: relative; + border-radius: 16px; + border: 1px solid var(--pf-v5-global--BorderColor--100); + padding: 1px; + + &:after { + position: absolute; + width: 48px; + height: 4px; + background: transparent; + top: 0; + left: calc(50% - 24px); + border-radius: 0 0 4px 4px; + content: ' '; + } + &.project { + &:after { + background: var(--ai-project--BorderColor); + } + } + &.notebook { + &:after { + background: var(--ai-notebook--BorderColor); + } + } + &.pipeline { + &:after { + background: var(--ai-pipeline--BorderColor); + } + } + &.pipeline-run { + &:after { + background: var(--ai-pipeline--BorderColor); + } + } + &.cluster-storage { + &:after { + background: var(--ai-cluster-storage--BorderColor); + } + } + &.model-server { + &:after { + background: var(--ai-model-server--BorderColor); + } + } + &.data-connection { + &:after { + background: var(--ai-data-connection--BorderColor); + } + } + &.user { + &:after { + background: var(--ai-user--BorderColor); + } + } + &.group { + &:after { + background: var(--ai-group--BorderColor); + } + } + &.set-up { + &:after { + background: var(--ai-set-up--BorderColor); + } + } + &.organize { + &:after { + background: var(--ai-organize--BorderColor); + } + } + &.training { + &:after { + background: var(--ai-training--BorderColor); + } + } + &.serving { + &:after { + background: var(--ai-serving--BorderColor); + } + } + &.m-is-selectable { + &:after { + display: none; + top: unset; + bottom: 0; + transform: rotate( -180deg ); + } + &:hover { + cursor: pointer; + } + &:hover, &.m-is-selected { + &:after { + display: block + } + &.project { + border-color: var(--ai-project--BorderColor); + } + &.notebook { + border-color: var(--ai-notebook--BorderColor); + } + &.pipeline { + border-color: var(--ai-pipeline--BorderColor); + } + &.pipeline-run { + border-color: var(--ai-pipeline--BorderColor); + } + &.cluster-storage { + border-color: var(--ai-cluster-storage--BorderColor); + } + &.model-server { + border-color: var(--ai-model-server--BorderColor); + } + &.data-connection { + border-color: var(--ai-data-connection--BorderColor); + } + &.user { + border-color: var(--ai-user--BorderColor); + } + &.group { + border-color: var(--ai-group--BorderColor); + } + &.set-up { + border-color: var(--ai-set-up--BorderColor); + } + &.organize { + border-color: var(--ai-organize--BorderColor); + } + &.training { + border-color: var(--ai-training--BorderColor); + } + &.serving { + border-color: var(--ai-serving--BorderColor); + } + } + &.m-is-selected { + border-width: 2px; + padding: 0; + } + } +} diff --git a/clients/ui/frontend/src/app/components/design/TypeBorderedCard.tsx b/clients/ui/frontend/src/app/components/design/TypeBorderedCard.tsx new file mode 100644 index 00000000..9567a400 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/TypeBorderedCard.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import { Card, CardProps } from '@patternfly/react-core'; +import { ProjectObjectType, SectionType } from '~/app/components/design/utils'; + +import './TypeBorderCard.scss'; + +type TypeBorderedCardProps = CardProps & { + objectType?: ProjectObjectType; + sectionType?: SectionType; + selectable?: boolean; + selected?: boolean; +}; +const TypeBorderedCard: React.FC = ({ + objectType, + sectionType, + className, + selectable, + selected, + ...rest +}) => ( + +); + +export default TypeBorderedCard; diff --git a/clients/ui/frontend/src/app/components/design/utils.ts b/clients/ui/frontend/src/app/components/design/utils.ts new file mode 100644 index 00000000..ef4f4114 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/utils.ts @@ -0,0 +1,34 @@ +import registerModelImg from '~/images/UI_icon-Cubes-RGB.svg'; +import modelRegistryEmptyStateImg from '~/images/empty-state-model-registries.svg'; +import './vars.scss'; + +export enum ProjectObjectType { + registeredModels = 'registered-models', +} + +export const typedBackgroundColor = (objectType: ProjectObjectType): string => { + switch (objectType) { + case ProjectObjectType.registeredModels: + return 'var(--ai-model-server--BackgroundColor)'; + default: + return ''; + } +}; + +export const typedObjectImage = (objectType: ProjectObjectType): string => { + switch (objectType) { + case ProjectObjectType.registeredModels: + return registerModelImg; + default: + return ''; + } +}; + +export const typedEmptyImage = (objectType: ProjectObjectType): string => { + switch (objectType) { + case ProjectObjectType.registeredModels: + return modelRegistryEmptyStateImg; + default: + return ''; + } +}; diff --git a/clients/ui/frontend/src/app/components/design/vars.scss b/clients/ui/frontend/src/app/components/design/vars.scss new file mode 100644 index 00000000..bfff3860 --- /dev/null +++ b/clients/ui/frontend/src/app/components/design/vars.scss @@ -0,0 +1,31 @@ +:root { + --ai-set-up--BackgroundColor: #ffe8cc; + --ai-organize--BackgroundColor: #ffe8cc; + --ai-training--BackgroundColor: #daf2f2; + --ai-serving--BackgroundColor: #e0f0ff; + + --ai-set-up--BorderColor: #f8ae54; + --ai-organize--BorderColor: #f8ae54; + --ai-training--BorderColor: #9ad8d8; + --ai-serving--BorderColor: #92c5f9; + + --ai-project--BackgroundColor: var(--ai-organize--BackgroundColor); + --ai-project--BorderColor: var(--ai-organize--BorderColor); + --ai-data-connection--BackgroundColor: var(--ai-organize--BackgroundColor); + --ai-data-connection--BorderColor: var(--ai-organize--BorderColor); + --ai-cluster-storage--BackgroundColor: var(--ai-organize--BackgroundColor); + --ai-cluster-storage--BorderColor: var(--ai-organize--BorderColor); + + --ai-notebook--BackgroundColor: var(--ai-training--BackgroundColor); + --ai-notebook--BorderColor: var(--ai-training--BorderColor); + --ai-pipeline--BackgroundColor: var(--ai-training--BackgroundColor); + --ai-pipeline--BorderColor: var(--ai-training--BorderColor); + + --ai-model-server--BackgroundColor: var(--ai-serving--BackgroundColor); + --ai-model-server--BorderColor: var(--ai-serving--BorderColor); + + --ai-user--BackgroundColor: var(--ai-set-up--BackgroundColor); + --ai-user--BorderColor: var(--ai-set-up--BorderColor); + --ai-group--BackgroundColor: var(--ai-set-up--BackgroundColor); + --ai-group--BorderColor: var(--ai-set-up--BorderColor); +} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistry.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistry.tsx new file mode 100644 index 00000000..a9edd66c --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistry.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import TitleWithIcon from '~/app/components/design/TitleWithIcon'; +import { ProjectObjectType } from '~/app/components/design/utils'; + +type ModelRegistryProps = Omit< + React.ComponentProps, + | 'title' + | 'description' + | 'loadError' + | 'loaded' + | 'provideChildrenPadding' + | 'removeChildrenTopPadding' + | 'headerContent' +>; + +const ModelRegistry: React.FC = ({ ...pageProps }) => { + const [loaded, loadError] = [true, undefined]; // TODO: change with real usage + + return ( + + } + description="Select a model registry to view and manage your registered models. Model registries provide a structured and organized way to store, share, version, deploy, and track models." + loadError={loadError} + loaded={loaded} + provideChildrenPadding + removeChildrenTopPadding + /> + ); +}; + +export default ModelRegistry; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx new file mode 100644 index 00000000..1d3e4c0c --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import ModelRegistry from './ModelRegistry'; + +const ModelRegistryRoutes: React.FC = () => ( + + } /> + +); + +export default ModelRegistryRoutes; diff --git a/clients/ui/frontend/src/app/NotFound/NotFound.tsx b/clients/ui/frontend/src/app/pages/notFound/NotFound.tsx similarity index 100% rename from clients/ui/frontend/src/app/NotFound/NotFound.tsx rename to clients/ui/frontend/src/app/pages/notFound/NotFound.tsx diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx new file mode 100644 index 00000000..f48f0bdf --- /dev/null +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettings.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { EmptyState, EmptyStateBody, EmptyStateVariant } from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; + +const ModelRegistrySettings: React.FC = () => { + const [modelRegistries, loaded, loadError] = [[], true, undefined]; // TODO: change to real values + return ( + <> + + To get started, create a model registry. + + } + provideChildrenPadding + > + TODO: Add model registry settings + + + ); +}; + +export default ModelRegistrySettings; diff --git a/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettingsRoutes.tsx b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettingsRoutes.tsx new file mode 100644 index 00000000..8e6b7500 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/settings/ModelRegistrySettingsRoutes.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Navigate, Routes, Route } from 'react-router-dom'; +import ModelRegistrySettings from './ModelRegistrySettings'; + +const ModelRegistrySettingsRoutes: React.FC = () => ( + + } /> + } /> + +); + +export default ModelRegistrySettingsRoutes; diff --git a/clients/ui/frontend/src/favicon.png b/clients/ui/frontend/src/favicon.png deleted file mode 100644 index 28998a8c..00000000 Binary files a/clients/ui/frontend/src/favicon.png and /dev/null differ diff --git a/clients/ui/frontend/src/images/UI_icon-Cubes-RGB.svg b/clients/ui/frontend/src/images/UI_icon-Cubes-RGB.svg new file mode 100644 index 00000000..b1c8c577 --- /dev/null +++ b/clients/ui/frontend/src/images/UI_icon-Cubes-RGB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/ui/frontend/src/images/empty-state-model-registries.svg b/clients/ui/frontend/src/images/empty-state-model-registries.svg new file mode 100644 index 00000000..87b06da4 --- /dev/null +++ b/clients/ui/frontend/src/images/empty-state-model-registries.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/clients/ui/frontend/src/images/favicon.ico b/clients/ui/frontend/src/images/favicon.ico new file mode 100644 index 00000000..5351309a Binary files /dev/null and b/clients/ui/frontend/src/images/favicon.ico differ diff --git a/clients/ui/frontend/src/images/logo.svg b/clients/ui/frontend/src/images/logo.svg new file mode 100644 index 00000000..fb06122e --- /dev/null +++ b/clients/ui/frontend/src/images/logo.svg @@ -0,0 +1,43 @@ + + + + Kubeflow Logo + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/ui/frontend/src/index.html b/clients/ui/frontend/src/index.html index 5138a747..5abb9a45 100644 --- a/clients/ui/frontend/src/index.html +++ b/clients/ui/frontend/src/index.html @@ -3,10 +3,10 @@ - Model Registry UI - + Model Registry + - +