diff --git a/README.md b/README.md index bb0264e56b..e08f0271a4 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@

- +
## Key Features diff --git a/docs/_website/static/img/docs/aws-resize-clutch.gif b/docs/_website/static/img/docs/aws-resize-clutch.gif deleted file mode 100644 index dab3b5750d..0000000000 Binary files a/docs/_website/static/img/docs/aws-resize-clutch.gif and /dev/null differ diff --git a/docs/_website/static/img/docs/landing-page.png b/docs/_website/static/img/docs/landing-page.png index a42fe1426b..89c789e005 100644 Binary files a/docs/_website/static/img/docs/landing-page.png and b/docs/_website/static/img/docs/landing-page.png differ diff --git a/docs/_website/static/img/docs/resolver-render-screenshot.png b/docs/_website/static/img/docs/resolver-render-screenshot.png index 7d714f99f6..871cc506c1 100644 Binary files a/docs/_website/static/img/docs/resolver-render-screenshot.png and b/docs/_website/static/img/docs/resolver-render-screenshot.png differ diff --git a/docs/about/lyft-case-study.md b/docs/about/lyft-case-study.md index 19085b7416..bd918c77e5 100644 --- a/docs/about/lyft-case-study.md +++ b/docs/about/lyft-case-study.md @@ -36,7 +36,7 @@ In the event that the cluster required a resize, developers had three main optio Lyft is all-in on infrastructure-as-code. However, resizing a cluster during an incident via GitOps is a painful experience. -In our implementation of GitOps, applying orchestration changes requires a full deploy of a service to minimize the possibility of configuration drift. It can take 10 minutes or more for CI to run tests, launch a deploy pipeline, and enact the latest declaration. In the event that the first value was not enough, the engineer would have to repeat the process. This would drastically increase the time it takes to mitigate the issue. +In our implementation of GitOps, applying orchestration changes requires a full deploy of a service to minimize the possibility of configuration drift. It can take 10 minutes or more for CI to run tests, launch a deploy pipeline, and enact the latest declaration. In the event that the first value was not enough, the engineer would have to repeat the process. This would drastically increase the time it takes to mitigate the issue. In addition, the `desired` size of a cluster is not generally controlled by orchestration code. It's a dynamic value that changes in response to average CPU usage. Committing this value would reset it on every deploy regardless of current conditions. For this reason developers would simply use minimum to enforce the desired size during an incident. Sometimes they would forget to revert their change after the incident was over costing thousands of dollars in unnecessary cloud resource usage. @@ -49,18 +49,18 @@ Developers often avoided the CLI due to the friction of performing 2FA flows on $ aws-okta exec elevated-role -- aws autoscaling update-auto-scaling-group \ --auto-scaling-group-name my-auto-scaling-group \ --min-size 1 --max-size 3 -$ +$ ``` #### UI -The console presents a large amount of functionality and information needed to perform a task which slows down operations and increases cognitive load. +The console presents a large amount of functionality and information needed to perform a task which slows down operations and increases cognitive load. {{/* TODO: this is too fuzzy, may be possible to redact information rather than pixelizing the entire gif */}} Resizing an ASG in the AWS console ### Mitigation (after) -Resizing an ASG in Clutch +Resizing an ASG in Clutch #### Preventing Accidents Next, we added rules to validate actions and prevent accidents that had occurred before with legacy tools. diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 86fd2edda5..0d2285c4ef 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -40,7 +40,7 @@ If desired, use a custom configuration with the Docker image by mapping it into ```bash docker run --rm -p 8080:8080 -it lyft/clutch:latest ``` - + @@ -49,7 +49,7 @@ docker run --rm -p 8080:8080 \ -v /host/absolute/path/to/config.yaml:/clutch-config.yaml:ro \ -it lyft/clutch:latest ``` - + @@ -59,7 +59,7 @@ docker run --rm -p 8080:8080 \ -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ -it lyft/clutch:latest ``` - + @@ -75,7 +75,7 @@ For more information on configuring Clutch, see the [Configuration Reference](/d ### Accessing Clutch :tada: Clutch should now be accessible from `localhost:8080` in the browser. -Clutch Landing Page Screenshot +Clutch Landing Page Screenshot ## Building the Container From Scratch diff --git a/docs/getting-started/local-build.md b/docs/getting-started/local-build.md index 307c09fafb..a049a2edc1 100644 --- a/docs/getting-started/local-build.md +++ b/docs/getting-started/local-build.md @@ -76,7 +76,7 @@ Launch Clutch with back-end configuration [clutch-config.yaml](https://github.co #### 4. Use :tada: Clutch should now be accessible from `localhost:8080` in the browser. -Clutch Landing Page Screenshot +Clutch Landing Page Screenshot :::info Clutch may have external dependencies, to run Clutch with mocked dependencies see [Mock Gateway](/docs/getting-started/mock-gateway). diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index b8a8fdb8b7..a194f9630c 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -2,9 +2,17 @@ module.exports = { stories: [ "../packages/**/*.stories.@(tsx|jsx)", ], + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + compilerOptions: { + allowSyntheticDefaultImports: false, + esModuleInterop: false, + }, + } + }, addons: [ - "@storybook/addon-actions", - "@storybook/addon-links", "@storybook/addon-essentials", + "@storybook/addon-links", ], } \ No newline at end of file diff --git a/frontend/.storybook/preview-head.html b/frontend/.storybook/preview-head.html new file mode 100644 index 0000000000..945068fb3e --- /dev/null +++ b/frontend/.storybook/preview-head.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/.yarnrc b/frontend/.yarnrc index 860f7e3a8b..54b2841454 100644 --- a/frontend/.yarnrc +++ b/frontend/.yarnrc @@ -2,4 +2,4 @@ # yarn lockfile v1 -yarn-path ".yarn/releases/yarn-1.22.4.js" +yarn-path ".yarn/releases/yarn-1.22.5.js" diff --git a/frontend/api/package.json b/frontend/api/package.json index 8cec52f401..5ddc2fd3f0 100644 --- a/frontend/api/package.json +++ b/frontend/api/package.json @@ -1,6 +1,6 @@ { "name": "@clutch-sh/api", - "version": "0.0.0-beta", + "version": "1.0.0-beta", "description": "Clutch API", "license": "Apache-2.0", "author": "clutch@lyft.com", diff --git a/frontend/package.json b/frontend/package.json index 487a770f8d..74474cebc5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,13 +62,13 @@ "devDependencies": { "@babel/core": "^7.9.0", "@material-ui/core": "^4.11.0", - "@storybook/addon-actions": "^6.0.21", - "@storybook/addon-essentials": "^6.0.21", - "@storybook/addon-links": "^6.0.21", - "@storybook/node-logger": "^6.0.21", + "@storybook/addon-actions": "^6.1.0", + "@storybook/addon-essentials": "^6.1.0", + "@storybook/addon-links": "^6.1.1", + "@storybook/node-logger": "^6.1.1", "@storybook/preset-typescript": "^3.0.0", - "@storybook/react": "^6.0.21", - "@storybook/theming": "^6.0.22", + "@storybook/react": "^6.1.0", + "@storybook/theming": "^6.1.1", "@typescript-eslint/eslint-plugin": "^4.8.2", "@typescript-eslint/parser": "^4.8.2", "babel-loader": "^8.1.0", diff --git a/frontend/packages/app/cypress/integration/navigation_spec.js b/frontend/packages/app/cypress/integration/navigation_spec.js index fc42b5bef5..fe2a9b6568 100644 --- a/frontend/packages/app/cypress/integration/navigation_spec.js +++ b/frontend/packages/app/cypress/integration/navigation_spec.js @@ -1,12 +1,10 @@ -const MENU_BUTTON = "menuBtn"; const DRAWER = "drawer"; const WORKFLOW_GROUP = "workflowGroup"; -const TOGGLE = "toggle"; +const WORKFLOW_GROUP_ITEM = "workflowGroupItem"; describe("Navigation drawer", () => { before(() => { cy.visit("localhost:3000"); - cy.element(MENU_BUTTON).click(); cy.element(DRAWER).should("be.visible"); }); @@ -18,62 +16,31 @@ describe("Navigation drawer", () => { it("displays and hides routes", () => { cy.element(WORKFLOW_GROUP).each((_, idx) => { - cy.element(WORKFLOW_GROUP).eq(idx).descendent(TOGGLE).children().first().click(); - cy.element(WORKFLOW_GROUP) - .eq(idx) - .find("a") - .each(link => { - cy.wrap(link).should("have.attr", "href"); - }); - cy.element(WORKFLOW_GROUP).eq(idx).descendent(TOGGLE).children().first().click(); - cy.element(WORKFLOW_GROUP).eq(idx).find("a").should("not.be.visible"); - }); - }); - - describe("routes to homepage", () => { - it("via nav icon", () => { - cy.element(DRAWER).element("logo").click(); - cy.url().should("equal", "http://localhost:3000/"); - }); - - it("via nav title", () => { - cy.element(MENU_BUTTON).click(); - cy.element(DRAWER).element("title").click(); - cy.url().should("equal", "http://localhost:3000/"); + cy.element(WORKFLOW_GROUP).eq(idx).click(); + cy.element(WORKFLOW_GROUP_ITEM).each(link => { + cy.wrap(link).should("have.attr", "href"); + }); + cy.element(WORKFLOW_GROUP).eq(idx).click(); + cy.element(WORKFLOW_GROUP).eq(idx).descendent(WORKFLOW_GROUP_ITEM).should("not.be.visible"); }); }); describe("routes to workflows", () => { - const groupItemId = "workflowGroupItem"; - beforeEach(() => { - cy.element(MENU_BUTTON).click(); - }); - it("can route correctly", () => { return cy.element(WORKFLOW_GROUP).each((_, groupIdx) => { - cy.element(WORKFLOW_GROUP).eq(groupIdx).descendent(TOGGLE).children().first().click(); - cy.element(WORKFLOW_GROUP) - .eq(groupIdx) - .descendent(groupItemId) - .each((__, itemIdx) => { - cy.element(WORKFLOW_GROUP) - .eq(groupIdx) - .descendent(groupItemId) - .eq(itemIdx) - .should("be.visible"); - cy.element(WORKFLOW_GROUP) - .eq(groupIdx) - .descendent(groupItemId) - .eq(itemIdx) - .should("have.attr", "href") - .then(href => { - cy.element(WORKFLOW_GROUP).eq(groupIdx).descendent(groupItemId).eq(itemIdx).click(); - cy.url().should("include", href); - cy.element(MENU_BUTTON).click(); - }); - - // TODO: validate header of workflow here when it's landed - }); + cy.element(WORKFLOW_GROUP).eq(groupIdx).click(); + cy.element(WORKFLOW_GROUP_ITEM).each((__, linkIdx) => { + cy.element(WORKFLOW_GROUP_ITEM).eq(linkIdx).should("be.visible"); + cy.element(WORKFLOW_GROUP_ITEM) + .eq(linkIdx) + .should("have.attr", "href") + .then(href => { + cy.element(WORKFLOW_GROUP_ITEM).eq(linkIdx).click(); + cy.url().should("include", href); + }); + cy.element(WORKFLOW_GROUP).eq(groupIdx).click(); + // TODO: validate header of workflow here when it's landed + }); }); }); }); diff --git a/frontend/packages/app/package.json b/frontend/packages/app/package.json index 750d3e9134..6e6e34faa9 100644 --- a/frontend/packages/app/package.json +++ b/frontend/packages/app/package.json @@ -18,12 +18,12 @@ "test:e2e": "cypress run" }, "dependencies": { - "@clutch-sh/core": "^0.0.0-beta", + "@clutch-sh/core": "^1.0.0-beta", "react": "^16.8.0", "react-dom": "^16.8.0" }, "devDependencies": { - "@clutch-sh/tools": "^0.0.0-beta" + "@clutch-sh/tools": "^1.0.0-beta" }, "proxy": "http://localhost:8080" } diff --git a/frontend/packages/app/public/index.html b/frontend/packages/app/public/index.html index 9eff905499..1a4e26d46f 100644 --- a/frontend/packages/app/public/index.html +++ b/frontend/packages/app/public/index.html @@ -4,6 +4,8 @@ + + ` - flex-shrink: 0; - min-width: ${drawerWidth}; - @media screen and (max-width: 800px) { - min-width: ${mobileDrawerWidth}; - } - div[class*="MuiDrawer-paper"] { - min-width: ${drawerWidth}; - background-color: ${theme.palette.secondary.main}; - padding: ${theme.spacing(2)}px; - @media screen and (max-width: 800px) { - min-width: ${mobileDrawerWidth}; - } - } - `} -`; - -const DrawerHeader = styled(Grid)` - ${({ theme }) => ` - justify: flex-start; - direction: row; - ${theme.mixins.toolbar}; - `} -`; - -const ExpandLessIcon = styled(ExpandLess)` - ${({ theme }) => ` - color: ${theme.palette.primary.main}; - `} -`; - -const ExpandMoreIcon = styled(ExpandMore)` - ${({ theme }) => ` - color: ${theme.palette.primary.main}; - `} -`; - -const GroupHeading = styled(Typography)` - ${({ theme }) => ` - color: ${theme.palette.primary.main}; - padding-top: 0.25rem; - font-weight: bolder; - `} -`; - -const TrendingIcon = styled(TrendingUpIcon)` - fontsize: 20px; - marginleft: 10px; -`; - -const NavigationLink = styled(RouterLink)` - ${({ theme }) => ` - color: ${theme.palette.text.secondary}; - `} -`; - -interface HeaderProps { - onNavigate: () => void; -} - -const Header: React.FC = ({ onNavigate }) => { - return ( - - - - - - - - - - - clutch - - Welcome {userId()}! - - - - - ); -}; - -const Divider = styled(MuiDivider)` - ${({ theme }) => ` - background-color: ${theme.palette.accent.main}; - `} -`; +// sidebar +const DrawerPanel = styled(MuiDrawer)({ + width: "100px", + ".MuiDrawer-paper": { + top: "unset", + width: "inherit", + backgroundColor: "#FFFFFF", + boxShadow: "0px 5px 15px rgba(53, 72, 212, 0.2)", + position: "relative", + display: "flex", + }, +}); + +// sidebar groupings +const GroupList = styled(List)({ + padding: "0px", +}); + +const GroupListItem = styled(ListItem)({ + flexDirection: "column", + height: "82px", + padding: "16px 8px 16px 8px", + "&:hover": { + backgroundColor: "#F5F6FD", + }, + "&:active": { + backgroundColor: "#D7DAF6", + }, + // avatar and label + "&:hover, &:active, &.Mui-selected": { + ".MuiAvatar-root": { + backgroundColor: "#3548D4", + }, + ".MuiTypography-root": { + color: "#3548D4", + }, + }, + "&.Mui-selected": { + backgroundColor: "#EBEDFB", + "&:hover": { + backgroundColor: "#F5F6FD", + }, + "&:active": { + backgroundColor: "#D7DAF6", + }, + }, +}); + +const GroupHeading = styled(Typography)({ + color: "rgba(13, 16, 48, 0.6)", + fontWeight: 500, + fontSize: "14px", + lineHeight: "18px", + flexGrow: 1, + paddingTop: "11px", +}); + +const Avatar = styled(MuiAvatar)({ + background: "rgba(13, 16, 48, 0.6)", + height: "24px", + width: "24px", + color: "#FFFFFF", + fontSize: "14px", + borderRadius: "4px", +}); + +// sidebar submenu +const Popper = styled(MuiPopper)({ + zIndex: 1201, + paddingTop: "16px", +}); + +const Paper = styled(MuiPaper)({ + minWidth: "230px", + border: "1px solid #E7E7EA", + boxShadow: "0px 10px 24px rgba(35, 48, 143, 0.3)", + // sidebar submenu groupings + ".MuiListItem-root[data-qa='workflowGroupItem']": { + backgroundColor: "#FFFFFF", + height: "48px", + "&:hover": { + backgroundColor: "#F5F6FD", + }, + "&:active": { + backgroundColor: "#D7DAF6", + }, + "&.Mui-selected": { + backgroundColor: "#FFFFFF", + "&:hover": { + backgroundColor: "#F5F6FD", + }, + "&:active": { + backgroundColor: "#D7DAF6", + }, + }, + "&:hover, &:active, &.Mui-selected": { + ".MuiTypography-root": { + color: "#3548D4", + }, + }, + }, +}); + +const LinkListItemText = styled(ListItemText)({ + ".MuiTypography-root": { + color: "rgba(13, 16, 48, 0.6)", + fontWeight: 500, + fontSize: "14px", + lineHeight: "18px", + }, +}); interface GroupProps { heading: string; open: boolean; updateOpenGroup: (heading: string) => void; - onNavigate: () => void; + closeGroup: () => void; } const Group: React.FC = ({ heading, open = false, updateOpenGroup, - onNavigate, + closeGroup, children, }) => { - const childrenList = React.Children.toArray(children); + const anchorRef = React.useRef(null); // n.b. if a Workflow Grouping has no workflows in it don't display it even if // it's not explicitly marked as hidden. - if (childrenList.length === 0) { + if (React.Children.count(children) === 0) { return null; } + // TODO (dschaller): revisit how we handle long groups once we have designs. + // n.b. this is a stop-gap solution to prevent long groups from looking unreadable. + let formattedHeading = heading; + if (heading.length > 11) { + formattedHeading = `${heading.substring(0, 10)}...`; + } + return ( - - updateOpenGroup(heading)}> - - {heading} - - - {open ? : } - - - {!open ? : null} - - - {childrenList.map((c: React.ReactElement) => { - return React.cloneElement(c, { onClick: onNavigate }); - })} - - - + + { + updateOpenGroup(heading); + }} + > + {formattedHeading.charAt(0)} + {formattedHeading} + + + + + + {children} + + + + + + + ); }; interface LinkProps { to: string; text: string; - onClick: () => void; - trending?: boolean; } -const Link: React.FC = ({ to, text, onClick, trending = false }) => { - const theme = useTheme(); +const Link: React.FC = ({ to, text }) => { const isSelected = window.location.pathname.replace("/", "") === to; - const selectedStyle = isSelected ? { color: theme.palette.accent.main } : {}; return ( - - {trending && } + {text} ); }; -interface DrawerProps { - open: boolean; - onClose: () => void; -} - -const Drawer: React.FC = ({ open, onClose }) => { +const Drawer: React.FC = () => { const { workflows } = useAppContext(); const [openGroup, setOpenGroup] = React.useState(""); @@ -205,8 +221,7 @@ const Drawer: React.FC = ({ open, onClose }) => { }; return ( - -
+ {sortedGroupings(workflows).map(grouping => { const value = routesByGrouping(workflows)[grouping]; return ( @@ -215,15 +230,13 @@ const Drawer: React.FC = ({ open, onClose }) => { heading={grouping} open={openGroup === grouping} updateOpenGroup={updateOpenGroup} - onNavigate={onClose} + closeGroup={() => setOpenGroup("")} > {value.workflows.map(workflow => ( ))} diff --git a/frontend/packages/core/src/AppLayout/header.tsx b/frontend/packages/core/src/AppLayout/header.tsx index a9da4a4f87..d593bec04c 100644 --- a/frontend/packages/core/src/AppLayout/header.tsx +++ b/frontend/packages/core/src/AppLayout/header.tsx @@ -1,90 +1,47 @@ import React from "react"; import { Link } from "react-router-dom"; -import { - AppBar as MuiAppBar, - Box, - Divider as MuiDivider, - IconButton, - Toolbar, - Typography, -} from "@material-ui/core"; -import MenuIcon from "@material-ui/icons/Menu"; -import styled from "styled-components"; +import styled from "@emotion/styled"; +import { AppBar as MuiAppBar, Box, Grid, Toolbar, Typography } from "@material-ui/core"; -import Drawer from "./drawer"; import Logo from "./logo"; +import Notifications from "./notifications"; import SearchField from "./search"; import { UserInformation } from "./user"; -const AppBar = styled(MuiAppBar)` - ${({ theme }) => ` - min-width: fit-content; - background-color: ${theme.palette.secondary.main}; - color: ${theme.palette.primary.main}; - min-width: fit-content; - `} -`; +const AppBar = styled(MuiAppBar)({ + minWidth: "fit-content", + background: "linear-gradient(90deg, #38106b 4.58%, #131c5f 89.31%)", + zIndex: 1201, + height: "64px", +}); -const MenuButton = styled(IconButton)` - padding: 12px; - margin-left: -12px; -`; - -const Title = styled(Typography)` - margin-right: 25px; - font-weight: bolder; -`; - -const Divider = styled(MuiDivider)` - ${({ theme }) => ` - background-color: ${theme.palette.primary.main}; - margin: 16px 8px; - `} -`; +const Title = styled(Typography)({ + margin: "12px 0px 12px 8px", + fontWeight: "bold", + fontSize: "20px", + color: "rgba(255, 255, 255, 0.87)", +}); const Header: React.FC = () => { - const [drawerOpen, setDrawerOpen] = React.useState(false); - - const handleKeyPress = (event: KeyboardEvent) => { - // @ts-ignore - if (event.key === "." && event.target?.nodeName !== "INPUT") { - setDrawerOpen(true); - } else if (event.key === "Escape") { - setDrawerOpen(false); - } - }; - - React.useLayoutEffect(() => { - window.addEventListener("keydown", handleKeyPress); - }, []); - - const openDrawer = () => { - setDrawerOpen(true); - }; - - const onDrawerClose = () => { - setDrawerOpen(false); - }; + const showNotifications = false; return ( <> - + - - - - - clutch - - - - + clutch + + + + + {showNotifications && } + + - ); }; diff --git a/frontend/packages/core/src/AppLayout/index.tsx b/frontend/packages/core/src/AppLayout/index.tsx index e4c0995983..d79cb751ae 100644 --- a/frontend/packages/core/src/AppLayout/index.tsx +++ b/frontend/packages/core/src/AppLayout/index.tsx @@ -1,17 +1,31 @@ import React from "react"; import { Outlet } from "react-router-dom"; +import styled from "@emotion/styled"; +import { Grid as MuiGrid } from "@material-ui/core"; +import Drawer from "./drawer"; import FeedbackButton from "./feedback"; import Header from "./header"; -const AppLayout: React.FC = ({ children }) => { +const AppGrid = styled(MuiGrid)({ + flex: 1, +}); + +const ContentGrid = styled(MuiGrid)({ + flex: 1, + overflow: "hidden", +}); + +const AppLayout: React.FC = () => { return ( - <> +
- {children} + + + + - - + ); }; diff --git a/frontend/packages/core/src/AppLayout/logo.tsx b/frontend/packages/core/src/AppLayout/logo.tsx index 8fe6a34b3b..1587498402 100644 --- a/frontend/packages/core/src/AppLayout/logo.tsx +++ b/frontend/packages/core/src/AppLayout/logo.tsx @@ -1,5 +1,6 @@ import React from "react"; -import styled, { keyframes } from "styled-components"; +import { keyframes } from "@emotion/react"; +import styled from "@emotion/styled"; const rotate = keyframes` from { @@ -10,13 +11,15 @@ const rotate = keyframes` } `; -const StyledSvg = styled.svg` - height: 40px; - width: 40px; - &:hover { - animation: ${rotate} 5s linear; - } -`; +const StyledSvg = styled.svg({ + height: "48px", + width: "48px", + padding: "8px", + "&:hover": { + animation: `${rotate} 5s linear`, + }, + verticalAlign: "middle", +}); const Logo: React.FC = () => (