diff --git a/cmd/app/main.go b/cmd/app/main.go index a2e3905121..407fc53810 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -57,6 +57,7 @@ func main() { &models.ClusterCandidate{}, &models.ClusterResolver{}, &models.Infra{}, + &models.Invite{}, &ints.KubeIntegration{}, &ints.BasicIntegration{}, &ints.OIDCIntegration{}, diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index bd7c945353..7d2ca9c86a 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -37,6 +37,7 @@ func main() { &models.ClusterCandidate{}, &models.ClusterResolver{}, &models.Infra{}, + &models.Invite{}, &ints.KubeIntegration{}, &ints.BasicIntegration{}, &ints.OIDCIntegration{}, diff --git a/dashboard/src/assets/loading-dots.gif b/dashboard/src/assets/loading-dots.gif new file mode 100644 index 0000000000..140b786400 Binary files /dev/null and b/dashboard/src/assets/loading-dots.gif differ diff --git a/dashboard/src/assets/settings.svg b/dashboard/src/assets/settings.svg new file mode 100644 index 0000000000..f2927cd5e4 --- /dev/null +++ b/dashboard/src/assets/settings.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/dashboard/src/components/values-form/CheckboxList.tsx b/dashboard/src/components/values-form/CheckboxList.tsx new file mode 100644 index 0000000000..599f5f0b6b --- /dev/null +++ b/dashboard/src/components/values-form/CheckboxList.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import styled from 'styled-components'; + +type PropsType = { + label?: string, + options: { disabled?: boolean, value: string, label: string }[], + selected: { value: string, label: string }[], + setSelected: (x: { value: string, label: string }[]) => void, +}; + +const CheckboxList = ({ + label, options, selected, setSelected, +}: PropsType) => { + let onSelectOption = (option: { value: string, label: string }) => { + if (!selected.includes(option)) { + selected.push(option); + setSelected(selected); + } else { + selected.splice(selected.indexOf(option), 1); + setSelected(selected); + } + } + + return ( + + {label && } + {options.map((option: { value: string, label: string }, i: number) => { + return ( + onSelectOption(option)} + key={i} + > + + done + + {option.label} + + ); + })} + + ); +} +export default CheckboxList; + +const Checkbox = styled.div` + width: 16px; + height: 16px; + border: 1px solid #ffffff55; + margin: 1px 15px 0px 1px; + border-radius: 3px; + background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : '#ffffff11'}; + display: flex; + align-items: center; + justify-content: center; + + > i { + font-size: 12px; + padding-left: 0px; + display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'}; + } +`; + +const CheckboxOption = styled.div<{ isLast: boolean }>` + width: 100%; + height: 35px; + padding-left: 10px; + display: flex; + cursor: pointer; + align-items: center; + border-bottom: ${props => props.isLast ? '' : '1px solid #ffffff22'}; + font-size: 13px; + + :hover { + background: #ffffff18; + } +`; + +const Label = styled.div` + color: #ffffff; + margin-bottom: 10px; +`; + +const StyledCheckboxList = styled.div` + border-radius: 3px; + border: 1px solid #ffffff55; + padding: 0; + background: #ffffff11; + margin-bottom: 15px; + margin-top: 20px; +`; \ No newline at end of file diff --git a/dashboard/src/components/values-form/Heading.tsx b/dashboard/src/components/values-form/Heading.tsx index 0ea6b6d886..2e5543956a 100644 --- a/dashboard/src/components/values-form/Heading.tsx +++ b/dashboard/src/components/values-form/Heading.tsx @@ -1,15 +1,15 @@ import React from 'react'; import styled from 'styled-components'; -export default function Heading(props: { children: any }) { - return {props.children}; +export default function Heading(props: { isAtTop?: boolean, children: any }) { + return {props.children}; } -const StyledHeading = styled.div` +const StyledHeading = styled.div<{ isAtTop: boolean }>` color: white; font-weight: 500; font-size: 16px; - margin-top: 30px; + margin-top: ${props => props.isAtTop ? '0': '30px'}; margin-bottom: 5px; display: flex; align-items: center; diff --git a/dashboard/src/main/CurrentError.tsx b/dashboard/src/main/CurrentError.tsx index 5442482ece..620fac30de 100644 --- a/dashboard/src/main/CurrentError.tsx +++ b/dashboard/src/main/CurrentError.tsx @@ -5,6 +5,7 @@ import close from '../assets/close.png'; import { Context } from '../shared/Context'; type PropsType = { + currentError: string, }; type StateType = { @@ -12,15 +13,24 @@ type StateType = { export default class CurrentError extends Component { state = { - expanded: false + expanded: false, + } + + componentDidUpdate(prevProps: PropsType) { + if ( + prevProps.currentError !== this.props.currentError + && this.props.currentError === 'Provisioning failed. Check your credentials and try again.' + ) { + this.setState({ expanded: true }); + } } render() { - if (this.context.currentError) { + if (this.props.currentError) { if (!this.state.expanded) { return ( this.setState({ expanded: true })}> - Error: {this.context.currentError} + Error: {this.props.currentError} { this.context.setCurrentError(null); e.stopPropagation(); @@ -33,7 +43,7 @@ export default class CurrentError extends Component { return ( this.setState({ expanded: false })}> - Error: {this.context.currentError} + Error: {this.props.currentError} this.context.setCurrentError(null)}> @@ -80,9 +90,9 @@ const ErrorText = styled.div` const StyledCurrentError = styled.div` position: fixed; - bottom: 20px; + bottom: 22px; width: 300px; - left: 17px; + left: 100px; padding: 15px; padding-right: 0px; font-family: 'Work Sans', sans-serif; diff --git a/dashboard/src/main/Login.tsx b/dashboard/src/main/Login.tsx index ba2ea8a351..595b164f47 100644 --- a/dashboard/src/main/Login.tsx +++ b/dashboard/src/main/Login.tsx @@ -43,7 +43,7 @@ export default class Login extends Component { handleLogin = (): void => { let { email, password } = this.state; let { authenticate } = this.props; - let { setCurrentError, setUser } = this.context; + let { setUser } = this.context; // Check for valid input if (!emailRegex.test(email)) { @@ -55,8 +55,9 @@ export default class Login extends Component { password: password }, {}, (err: any, res: any) => { // TODO: case and set credential error + console.log(res.data); setUser(res?.data?.id, res?.data?.email) - err ? setCurrentError(err.response.data.errors[0]) : authenticate(); + err ? console.log(err) : authenticate(); }); } } diff --git a/dashboard/src/main/Main.tsx b/dashboard/src/main/Main.tsx index 0e95e694c2..4ed83c75f9 100644 --- a/dashboard/src/main/Main.tsx +++ b/dashboard/src/main/Main.tsx @@ -32,7 +32,6 @@ export default class Main extends Component { componentDidMount() { let { setUser } = this.context; api.checkAuth('', {}, {}, (err: any, res: any) => { - console.log(err) if (err && err.response?.status == 403) { this.setState({ isLoggedIn: false, loading: false }) } @@ -56,6 +55,9 @@ export default class Main extends Component { } handleLogOut = () => { + // Clears local storage for proper rendering of clusters + localStorage.clear(); + this.context.clearContext(); this.setState({ isLoggedIn: false, initialized: true }); } @@ -85,7 +87,13 @@ export default class Main extends Component { { if (this.state.isLoggedIn && this.state.initialized) { - return + return ( + + ); } else { return } @@ -111,7 +119,7 @@ export default class Main extends Component { {this.renderMain()} - + ); } diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index aecc8e5605..a186e402dd 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -1,10 +1,13 @@ import React, { Component } from 'react'; +import posthog from 'posthog-js'; import styled from 'styled-components'; import ReactModal from 'react-modal'; +import * as FullStory from '@fullstory/browser'; import { Context } from '../../shared/Context'; import api from '../../shared/api'; -import { InfraType } from '../../shared/types'; +import { ClusterType, ProjectType } from '../../shared/types'; +import { includesCompletedInfraSet } from '../../shared/common'; import Sidebar from './sidebar/Sidebar'; import Dashboard from './dashboard/Dashboard'; @@ -12,26 +15,26 @@ import ClusterDashboard from './cluster-dashboard/ClusterDashboard'; import Loading from '../../components/Loading'; import Templates from './templates/Templates'; import Integrations from "./integrations/Integrations"; -import UpdateProjectModal from './modals/UpdateProjectModal'; import UpdateClusterModal from './modals/UpdateClusterModal'; import ClusterInstructionsModal from './modals/ClusterInstructionsModal'; import IntegrationsModal from './modals/IntegrationsModal'; import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal'; import NewProject from './new-project/NewProject'; import Navbar from './navbar/Navbar'; -import Provisioner from './new-project/Provisioner'; -import posthog from 'posthog-js'; -import * as FullStory from '@fullstory/browser'; +import ProvisionerStatus from './provisioner/ProvisionerStatus'; +import ProjectSettings from './project-settings/ProjectSettings'; +import ConfirmOverlay from '../../components/ConfirmOverlay'; type PropsType = { - logOut: () => void + logOut: () => void, + currentProject: ProjectType, + currentCluster: ClusterType, }; type StateType = { forceSidebar: boolean, showWelcome: boolean, currentView: string, - viewData: any[], forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion // Track last project id for refreshing clusters on project change @@ -39,54 +42,49 @@ type StateType = { sidebarReady: boolean, // Fixes error where ~1/3 times reloading to provisioner fails }; +// TODO: Handle cluster connected but with some failed infras (no successful set) export default class Home extends Component { state = { forceSidebar: true, showWelcome: false, currentView: 'dashboard', prevProjectId: null as number | null, - viewData: null as any, forceRefreshClusters: false, sidebarReady: false, } - // Possibly consolidate into context (w/ ProjectSection + NewProject) + initializeView = () => { + let { currentCluster } = this.context; + let { currentProject } = this.props; + // Check if current project is provisioning + api.getInfra('', {}, { project_id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + return; + } + console.log(currentCluster); + if (!currentCluster && !includesCompletedInfraSet(res.data)) { + this.setState({ currentView: 'provisioner', sidebarReady: true, }); + } else { + this.setState({ currentView: 'dashboard', sidebarReady: true }); + } + }); + } + getProjects = () => { - let { user, currentProject, projects, setProjects } = this.context; + let { user, setProjects } = this.context; + let { currentProject } = this.props; api.getProjects('', {}, { id: user.userId }, (err: any, res: any) => { if (err) { console.log(err); } else if (res.data) { - setProjects(res.data); - if (res.data.length > 0 && !currentProject) { + if (res.data.length === 0) { + this.setState({ currentView: 'new-project', sidebarReady: true, }); + } else if (res.data.length > 0 && !currentProject) { + setProjects(res.data); this.context.setCurrentProject(res.data[0]); - // Check if current project is provisioning - api.getInfra('', {}, { project_id: res.data[0].id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else if (res.data) { - - let viewData = [] as any[] - // TODO: separately handle non meta-provisioning case - res.data.forEach((el: InfraType) => { - if (el.status === 'creating') { - viewData.push({ - infra_id: el.id, - kind: el.kind, - }); - } - }); - - if (viewData.length > 0) { - this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, }); - } else { - this.setState({ sidebarReady: true }); - } - } - }); - } else if (res.data.length === 0) { - this.setState({ currentView: 'new-project', sidebarReady: true, }); + this.initializeView(); } } }); @@ -101,20 +99,15 @@ export default class Home extends Component { }) FullStory.identify(user.email) - this.getProjects(); } componentDidUpdate(prevProps: PropsType) { - if (prevProps !== this.props && this.context.currentProject) { - - // Set view to dashboard on project change - if (this.state.prevProjectId && this.state.prevProjectId !== this.context.currentProject.id) { - this.setState({ - prevProjectId: this.context.currentProject.id, - currentView: 'dashboard' - }); - } + if ( + prevProps.currentProject !== this.props.currentProject + || prevProps.currentCluster !== this.props.currentCluster + ) { + this.initializeView(); } } @@ -158,22 +151,28 @@ export default class Home extends Component { } else if (currentView === 'dashboard') { return ( - this.setState({ currentView: x })} /> + this.setState({ currentView: x })} + projectId={this.context.currentProject?.id} + /> ); } else if (currentView === 'integrations') { return ; } else if (currentView === 'new-project') { return ( - this.setState({ currentView: x, viewData: data })} /> + this.setState({ currentView: x })} /> ); } else if (currentView === 'provisioner') { return ( - this.setState({ currentView: x })} - viewData={this.state.viewData} /> ); + } else if (currentView === 'project-settings') { + return ( + this.setState({ currentView: x })} /> + ) } return ( @@ -183,11 +182,11 @@ export default class Home extends Component { ); } - setCurrentView = (x: string, viewData?: any) => { - if (!viewData) { - this.setState({ currentView: x }); + setCurrentView = (x: string) => { + if (x === 'dashboard') { + this.initializeView(); } else { - this.setState({ currentView: x, viewData }); + this.setState({ currentView: x }); } } @@ -197,7 +196,7 @@ export default class Home extends Component { // Force sidebar closed on first provision if (this.state.currentView === 'provisioner' && this.state.forceSidebar) { this.setState({ forceSidebar: false }); - } else if (this.state.sidebarReady) { + } else { return ( { } } + projectOverlayCall = () => { + let { user, setProjects } = this.context; + api.getProjects('', {}, { id: user.userId }, (err: any, res: any) => { + if (err) { + console.log(err) + } else if (res.data) { + setProjects(res.data); + if (res.data.length > 0) { + this.context.setCurrentProject(res.data[0]); + } else { + this.context.currentModalData.setCurrentView('new-project'); + } + this.context.setCurrentModal(null, null); + } + }); + } + + handleDelete = () => { + let { setCurrentModal, currentProject } = this.context; + api.deleteProject('', {}, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + // console.log(err) + } else { + this.projectOverlayCall(); + } + }); + + // Loop through and delete infra of all clusters we've provisioned + api.getClusters('', {}, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + res.data.forEach((cluster: ClusterType) => { + + // Handle destroying infra we've provisioned + if (cluster.infra_id) { + console.log('destroying provisioned infra...', cluster.infra_id); + api.destroyCluster('', { eks_name: cluster.name }, { + project_id: currentProject.id, + infra_id: cluster.infra_id, + }, (err: any, res: any) => { + if (err) { + console.log(err) + } else { + console.log('destroyed provisioned infra:', cluster.infra_id); + } + }); + } + }); + } + }); + setCurrentModal(null, null) + this.setState({ currentView: 'dashboard' }); + } + render() { let { currentModal, setCurrentModal, currentProject } = this.context; return ( @@ -224,14 +278,6 @@ export default class Home extends Component { > - setCurrentModal(null, null)} - style={ProjectModalStyles} - ariaHideApp={false} - > - - setCurrentModal(null, null)} @@ -268,6 +314,13 @@ export default class Home extends Component { /> {this.renderContents()} + + setCurrentModal(null, null)} + /> ); } diff --git a/dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx b/dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx index 766285587b..e37d947223 100644 --- a/dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx +++ b/dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx @@ -34,7 +34,9 @@ export default class ClusterDashboard extends Component { localStorage.setItem("SortType", this.state.sortType); // Reset namespace filter and close expanded chart on cluster change if (prevProps.currentCluster !== this.props.currentCluster) { - this.setState({ namespace: 'default', sortType: 'Newest', currentChart: null }); + this.setState({ namespace: 'default', sortType: ( + localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest' + ), currentChart: null }); } } diff --git a/dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx b/dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx index 01ab5c2d24..a7f8869c68 100644 --- a/dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx +++ b/dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx @@ -59,7 +59,7 @@ export default class ChartList extends Component { } else if (this.props.sortType == "Oldest") { charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? 1 : -1); } else if (this.props.sortType == "Alphabetical") { - charts.sort((a: any, b: any) => (a.name > b.name) ? 1: -1); + charts.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1); } this.setState({ charts }, () => { this.setState({ loading: false, error: false }); diff --git a/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx b/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx index 7a0099b610..f97f8c8288 100644 --- a/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx +++ b/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx @@ -6,6 +6,7 @@ import { ResourceType, NodeType, EdgeType, ChartType } from '../../../../../shar import Node from './Node'; import Edge from './Edge'; import InfoPanel from './InfoPanel'; +import ZoomPanel from './ZoomPanel'; import SelectRegion from './SelectRegion'; const zoomConstant = 0.01; @@ -41,6 +42,7 @@ type StateType = { preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down relocateAllowed: boolean, // Suppress movement of selected when drawing select region scale: number, + btnZooming: boolean, showKindLabels: boolean, isExpanded: boolean, currentNode: NodeType | null, @@ -73,6 +75,7 @@ export default class GraphDisplay extends Component { preventBgDrag: false, relocateAllowed: false, scale: 0.5, + btnZooming: false, showKindLabels: true, isExpanded: false, currentNode: null as (NodeType | null), @@ -344,6 +347,7 @@ export default class GraphDisplay extends Component { // Handle pan XOR zoom (two-finger gestures count as onWheel) handleWheel = (e: any) => { + this.setState({ btnZooming: false }); // Prevent nav gestures if mouse is over InfoPanel or ButtonSection if (!this.state.suppressDisplay) { @@ -363,6 +367,14 @@ export default class GraphDisplay extends Component { } }; + btnZoomIn = () => { + this.setState({ scale: 1.24, btnZooming: true}); + } + + btnZoomOut = () => { + this.setState({ scale: 0.76, btnZooming: true }); + } + toggleExpanded = () => { this.setState({ isExpanded: !this.state.isExpanded }, () => { this.props.setSidebar(!this.state.isExpanded); @@ -385,8 +397,21 @@ export default class GraphDisplay extends Component { renderNodes = () => { let { activeIds, originX, originY, cursorX, cursorY, scale, panX, panY, anchorX, anchorY, relocateAllowed } = this.state; - return this.state.nodes.map((node: NodeType, i: number) => { + let minX = 0; + let maxX = 0; + let minY = 0; + let maxY = 0; + this.state.nodes.map((node: NodeType, i: number) => { + if (node.x < minX) + minX = (node.x < minX) ? node.x : minX; + maxX = (node.x > maxX) ? node.x : maxX; + minY = (node.y < minY) ? node.y : minY; + maxY = (node.y > maxY) ? node.y : maxY; + }); + let midX = (minX + maxX)/2; + let midY = (minY + maxY)/2; + return this.state.nodes.map((node: NodeType, i: number) => { // Update position if not highlighting and active if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) { node.x = cursorX + node.toCursorX; @@ -401,8 +426,14 @@ export default class GraphDisplay extends Component { // Apply cursor-centered zoom if (this.state.scale !== 1) { - node.x = cursorX + scale * (node.x - cursorX); - node.y = cursorY + scale * (node.y - cursorY); + if (!this.state.btnZooming) { + node.x = cursorX + scale * (node.x - cursorX); + node.y = cursorY + scale * (node.y - cursorY); + } else { + console.log('hi') + node.x = midX + scale * (node.x - midX); + node.y = midY + scale * (node.y - midY); + } } // Apply pan @@ -510,6 +541,10 @@ export default class GraphDisplay extends Component { isExpanded={this.state.isExpanded} showRevisions={this.props.showRevisions} /> + ); } diff --git a/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx b/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx new file mode 100644 index 0000000000..16000d945e --- /dev/null +++ b/dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx @@ -0,0 +1,95 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +type PropsType = { + btnZoomIn: () => void, + btnZoomOut: () => void, +}; + +type StateType = { + wrapperHeight: number +}; + +export default class ZoomPanel extends Component { + state = { + wrapperHeight: 0 + } + + wrapperRef: any = React.createRef(); + + componentDidMount() { + this.setState({ wrapperHeight: this.wrapperRef.offsetHeight }); + } + + renderContents = () => { + return ( +
+ + add + + + + remove + +
+ ) + } + + render() { + return ( + + {this.renderContents()} + + ); + } +} + +const Div = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + height: calc(100% - 7px); +`; + +const IconWrapper = styled.div` + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + margin-top: -4px; + margin-bottom: -4px; + cursor: pointer; + + > i { + font-size: 16px; + color: #ffffff; + } +`; + +const StyledZoomer = styled.div` + position: absolute; + left: 15px; + bottom: 15px; + color: #ffffff; + height: 64px; + width: 36px; + background: #34373Cdf; + border-radius: 3px; + padding-left: 11px; + display: inline-block; + z-index: 999; + padding-top: 7px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-right: 11px; + cursor: default; +`; + +const ZoomBreaker = styled.div` + background: #ffffff20; + height: 1px; + width: 22px; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/dashboard/Dashboard.tsx b/dashboard/src/main/home/dashboard/Dashboard.tsx index e20a14395c..977792682f 100644 --- a/dashboard/src/main/home/dashboard/Dashboard.tsx +++ b/dashboard/src/main/home/dashboard/Dashboard.tsx @@ -1,68 +1,111 @@ +import { render } from '@testing-library/react'; import React, { Component } from 'react'; import styled from 'styled-components'; -import gradient from '../../../assets/gradient.jpg'; +import gradient from '../../../assets/gradient.jpg'; import { Context } from '../../../shared/Context'; -import StatusPlaceholderContainer from './StatusPlaceholderContainer'; +import { InfraType } from '../../../shared/types'; +import api from '../../../shared/api'; + +import ProvisionerSettings from '../provisioner/ProvisionerSettings'; type PropsType = { setCurrentView: (x: string) => void, + projectId: number | null, }; type StateType = { + infras: InfraType[], }; export default class Dashboard extends Component { - renderDashboardIcon = () => { - let { currentProject } = this.context; - return ( - - - {currentProject && currentProject.name[0].toUpperCase()} - - ); + state = { + infras: [] as InfraType[], } - renderContents = () => { - let { currentProject } = this.context; - if (currentProject) { - return ( -
- - {this.renderDashboardIcon()} - {currentProject && currentProject.name} - this.context.setCurrentModal('UpdateProjectModal', { - currentProject: currentProject, - setCurrentView: this.props.setCurrentView, - })} - > - more_vert - - - - - - - info Info - - - Project overview for {currentProject && currentProject.name}. - - - - - -
- ); + refreshInfras = () => { + if (this.props.projectId) { + api.getInfra('', {}, { + project_id: this.props.projectId, + }, (err: any, res: any) => { + if (err) { + console.log(err); + return; + } + this.setState({ infras: res.data }); + }); } } + + componentDidMount() { + this.refreshInfras(); + } + + componentDidUpdate(prevProps: PropsType) { + if (this.props.projectId && prevProps.projectId !== this.props.projectId) { + this.refreshInfras(); + } + } + + onShowProjectSettings = () => { + let { currentProject, setCurrentModal } = this.context; + let { setCurrentView } = this.props; + setCurrentModal('UpdateProjectModal', { + currentProject: currentProject, + setCurrentView: setCurrentView, + }); + } render() { + let { currentProject, currentCluster } = this.context; + let { setCurrentView } = this.props; + let { infras } = this.state; + let { onShowProjectSettings } = this; return ( <> - {this.renderContents()} + {currentProject && ( + + + + + + {currentProject && currentProject.name[0].toUpperCase()} + + + {currentProject && currentProject.name} + + more_vert + + + + + + + info Info + + + + Project overview for {currentProject && currentProject.name}. + + + + + + {!currentCluster && ( + + error_outline + This project currently has no clusters connected. + + )} + + + )} ); } @@ -70,20 +113,24 @@ export default class Dashboard extends Component { Dashboard.contextType = Context; -const Placeholder = styled.div` +const DashboardWrapper = styled.div` + padding-bottom: 100px; +`; + +const Banner = styled.div` + height: 40px; width: 100%; - height: calc(100vh - 380px); - margin-top: 30px; + margin: 10px 0 30px; + font-size: 13px; display: flex; - padding-bottom: 20px; - align-items: center; - justify-content: center; - color: #aaaabb; border-radius: 5px; - text-align: center; - font-size: 13px; - background: #ffffff08; - font-family: 'Work Sans', sans-serif; + padding-left: 15px; + align-items: center; + background: #616FEEcc; + > i { + margin-right: 10px; + font-size: 18px; + } `; const TopRow = styled.div` @@ -119,56 +166,6 @@ const InfoSection = styled.div` margin-bottom: 35px; `; -const Button = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - font-size: 13px; - cursor: pointer; - font-family: 'Work Sans', sans-serif; - border-radius: 20px; - color: white; - height: 30px; - padding: 0px 8px; - padding-bottom: 1px; - margin-right: 10px; - font-weight: 500; - padding-right: 15px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - box-shadow: 0 5px 8px 0px #00000010; - cursor: not-allowed; - - background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'}; - :hover { - background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'}; - } - - > i { - color: white; - width: 18px; - height: 18px; - font-size: 12px; - border-radius: 20px; - display: flex; - align-items: center; - margin-right: 5px; - justify-content: center; - } -`; - -const ButtonAlt = styled(Button)` - min-width: 150px; - max-width: 150px; - background: #7A838Fdd; - - :hover { - background: #69727eee; - } -`; - const LineBreak = styled.div` width: calc(100% - 0px); height: 2px; diff --git a/dashboard/src/main/home/integrations/IntegrationList.tsx b/dashboard/src/main/home/integrations/IntegrationList.tsx index 87b7e5aea4..0adf35730b 100644 --- a/dashboard/src/main/home/integrations/IntegrationList.tsx +++ b/dashboard/src/main/home/integrations/IntegrationList.tsx @@ -8,6 +8,7 @@ import api from '../../../shared/api'; type PropsType = { setCurrent: (x: any) => void, integrations: string[], + titles?: string[], isCategory?: boolean }; @@ -16,8 +17,32 @@ type StateType = { export default class IntegrationList extends Component { renderContents = () => { - let { integrations, setCurrent, isCategory } = this.props; - if (integrations && integrations.length > 0) { + let { integrations, titles, setCurrent, isCategory } = this.props; + if (titles && titles.length > 0) { + return integrations.map((integration: string, i: number) => { + let icon = integrationList[integration] && integrationList[integration].icon; + let subtitle = integrationList[integration] && integrationList[integration].label; + let label = titles[i]; + let disabled = integration === 'repo' || integration === 'kubernetes'; + return ( + disabled ? null : setCurrent(integration)} + isCategory={isCategory} + disabled={disabled} + > + + + + + {subtitle} + + + {isCategory ? 'launch' : 'more_vert'} + + ); + }); + } else if (integrations && integrations.length > 0) { return integrations.map((integration: string, i: number) => { let icon = integrationList[integration] && integrationList[integration].icon; let label = integrationList[integration] && integrationList[integration].label; @@ -90,12 +115,27 @@ const Integration = styled.div` } `; +const Description = styled.div` + display: flex; + flex-direction: column; + margin: 0; + padding: 0; +`; + const Label = styled.div` color: #ffffff; font-size: 14px; font-weight: 500; `; +const Subtitle = styled.div` + color: #aaaabb; + font-size: 13px; + display: flex; + align-items: center; + padding-top: 5px; +`; + const Icon = styled.img` width: 30px; margin-right: 18px; diff --git a/dashboard/src/main/home/integrations/Integrations.tsx b/dashboard/src/main/home/integrations/Integrations.tsx index d84147244c..1bfdac3552 100644 --- a/dashboard/src/main/home/integrations/Integrations.tsx +++ b/dashboard/src/main/home/integrations/Integrations.tsx @@ -16,6 +16,7 @@ type StateType = { currentCategory: string | null, currentIntegration: string | null, currentOptions: any[], + currentTitles: any[], currentIntegrationData: any[], }; @@ -24,6 +25,7 @@ export default class Integrations extends Component { currentCategory: null as string | null, currentIntegration: null as string | null, currentOptions: [] as any[], + currentTitles: [] as any[], currentIntegrationData: [] as any[], } @@ -45,11 +47,25 @@ export default class Integrations extends Component { if (err) { console.log(err); } else { + // Sort res.data into service type and sort each service's registry alphabetically + let grouped: any = {} + let final: any = []; + for (let i = 0; i < res.data.length; i++) { + let p = res.data[i].service; + if (!grouped[p]) { grouped[p] = []; } + grouped[p].push(res.data[i]); + } + Object.values(grouped).forEach((val: any) => { + final = final.concat(val.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1)); + }); + let currentOptions = [] as string[]; - res.data.forEach((integration: any, i: number) => { - currentOptions.includes(integration.service) ? null : currentOptions.push(integration.service); + let currentTitles = [] as string[]; + final.forEach((integration: any, i: number) => { + currentOptions.push(integration.service); + currentTitles.push(integration.name); }); - this.setState({ currentOptions, currentIntegrationData: res.data }); + this.setState({ currentOptions, currentTitles, currentIntegrationData: res.data }); } }); break; @@ -150,8 +166,11 @@ export default class Integrations extends Component { + + this.setState({ currentIntegration: x })} /> @@ -293,4 +312,11 @@ const StyledIntegrations = styled.div` width: calc(90% - 150px); min-width: 300px; padding-top: 45px; +`; + +const LineBreak = styled.div` + width: calc(100% - 0px); + height: 2px; + background: #ffffff20; + margin: 32px 0px 24px; `; \ No newline at end of file diff --git a/dashboard/src/main/home/modals/UpdateProjectModal.tsx b/dashboard/src/main/home/modals/UpdateProjectModal.tsx deleted file mode 100644 index 50fa19d197..0000000000 --- a/dashboard/src/main/home/modals/UpdateProjectModal.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import React, { Component } from 'react'; -import styled from 'styled-components'; -import close from '../../../assets/close.png'; -import gradient from '../../../assets/gradient.jpg'; - -import api from '../../../shared/api'; -import { Context } from '../../../shared/Context'; -import { ClusterType } from '../../../shared/types'; - -import SaveButton from '../../../components/SaveButton'; -import InputRow from '../../../components/values-form/InputRow'; -import ConfirmOverlay from '../../../components/ConfirmOverlay'; - -type PropsType = { -}; - -type StateType = { - projectName: string, - status: string | null, - showDeleteOverlay: boolean -}; - -export default class UpdateProjectModal extends Component { - state = { - projectName: this.context.currentModalData.currentProject.name, - status: null as string | null, - showDeleteOverlay: false, - }; - - // Possibly consolidate into context (w/ ProjectSection + NewProject) - getProjects = () => { - let { user, currentProject, projects, setProjects } = this.context; - api.getProjects('', {}, { id: user.userId }, (err: any, res: any) => { - if (err) { - console.log(err) - } else if (res.data) { - setProjects(res.data); - if (res.data.length > 0) { - this.context.setCurrentProject(res.data[0]); - } else { - this.context.currentModalData.setCurrentView('new-project'); - } - this.context.setCurrentModal(null, null); - } - }); - } - - // TODO: Handle update to unmounted component - handleDelete = () => { - let { currentProject } = this.context; - this.setState({ status: 'loading' }); - api.deleteProject('', {}, { id: currentProject.id }, (err: any, res: any) => { - if (err) { - this.setState({ status: 'error' }); - // console.log(err) - } else { - this.getProjects(); - this.setState({ status: 'successful', showDeleteOverlay: false }); - } - }); - - // Loop through and delete infra of all clusters we've provisioned - api.getClusters('', {}, { id: currentProject.id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else { - res.data.forEach((cluster: ClusterType) => { - - // Handle destroying infra we've provisioned - if (cluster.infra_id) { - console.log('destroying provisioned infra...', cluster.infra_id); - api.destroyCluster('', { eks_name: cluster.name }, { - project_id: currentProject.id, - infra_id: cluster.infra_id, - }, (err: any, res: any) => { - if (err) { - this.setState({ status: 'error' }); - console.log(err) - } else { - console.log('destroyed provisioned infra:', cluster.infra_id); - } - }); - } - }); - } - }); - } - - render() { - return ( - - { - this.context.setCurrentModal(null, null); - }}> - - - - Project Settings - - Project name - - - - - - {this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'} - - this.setState({ projectName: x })} - placeholder='ex: perspective-vortex' - width='470px' - /> - - - - ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed. - - - help_outline Help - - - this.setState({ showDeleteOverlay: true })} - status={this.state.status} - /> - - this.setState({ showDeleteOverlay: false })} - /> - - ); - } -} - -UpdateProjectModal.contextType = Context; - -const Help = styled.a` - position: absolute; - left: 31px; - bottom: 35px; - display: flex; - align-items: center; - justify-content: center; - color: #ffffff55; - font-size: 13px; - :hover { - color: #ffffff; - } - - > i { - margin-right: 9px; - font-size: 16px; - } -`; - -const Warning = styled.div` - font-size: 13px; - display: flex; - border-radius: 3px; - width: calc(100%); - margin-top: 10px; - margin-left: 2px; - line-height: 1.4em; - align-items: center; - color: white; - > i { - margin-right: 10px; - font-size: 18px; - } - color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''}; -`; - -const Letter = styled.div` - height: 100%; - width: 100%; - position: absolute; - background: #00000028; - top: 0; - left: 0; - display: flex; - color: white; - align-items: center; - justify-content: center; -`; - -const ProjectImage = styled.img` - width: 100%; - height: 100%; -`; - -const ProjectIcon = styled.div` - width: 25px; - min-width: 25px; - height: 25px; - border-radius: 3px; - overflow: hidden; - position: relative; - margin-right: 10px; - font-weight: 400; - margin-top: 14px; -`; - -const InputWrapper = styled.div` - display: flex; - align-items: center; -`; - -const Subtitle = styled.div` - margin-top: 23px; - font-family: 'Work Sans', sans-serif; - font-size: 13px; - color: #aaaabb; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin-bottom: -10px; -`; - -const ModalTitle = styled.div` - margin: 0px 0px 13px; - display: flex; - flex: 1; - font-family: 'Assistant'; - font-size: 18px; - color: #ffffff; - user-select: none; - font-weight: 700; - align-items: center; - position: relative; - white-space: nowrap; - text-overflow: ellipsis; -`; - -const CloseButton = styled.div` - position: absolute; - display: block; - width: 40px; - height: 40px; - padding: 13px 0 12px 0; - z-index: 1; - text-align: center; - border-radius: 50%; - right: 15px; - top: 12px; - cursor: pointer; - :hover { - background-color: #ffffff11; - } -`; - -const CloseButtonImg = styled.img` - width: 14px; - margin: 0 auto; -`; - -const StyledUpdateProjectModal= styled.div` - width: 100%; - position: absolute; - left: 0; - top: 0; - height: 100%; - padding: 25px 32px; - overflow: hidden; - border-radius: 6px; - background: #202227; -`; \ No newline at end of file diff --git a/dashboard/src/main/home/new-project/NewProject.tsx b/dashboard/src/main/home/new-project/NewProject.tsx index 9ca19e5573..0cd1f81518 100644 --- a/dashboard/src/main/home/new-project/NewProject.tsx +++ b/dashboard/src/main/home/new-project/NewProject.tsx @@ -1,466 +1,40 @@ import React, { Component } from 'react'; import styled from 'styled-components'; -import gradient from '../../../assets/gradient.jpg'; -import close from '../../../assets/close.png'; -import api from '../../../shared/api'; +import gradient from '../../../assets/gradient.jpg'; import { Context } from '../../../shared/Context'; -import { integrationList } from '../../../shared/common'; -import { ProjectType } from '../../../shared/types'; +import { isAlphanumeric } from '../../../shared/common'; import InputRow from '../../../components/values-form/InputRow'; import Helper from '../../../components/values-form/Helper'; -import Heading from '../../../components/values-form/Heading'; -import SaveButton from '../../../components/SaveButton'; - -const providers = ['aws', 'gcp', 'do',]; +import ProvisionerSettings from '../provisioner/ProvisionerSettings'; type PropsType = { setCurrentView: (x: string, data?: any) => void, }; type StateType = { - projectExists: boolean, projectName: string, selectedProvider: string | null, - awsRegion: string | null, - awsAccessId: string | null, - awsSecretKey: string | null, - gcpRegion: string | null, - gcpProjectId: string | null, - gcpKeyData: string | null, - status: string | null, }; export default class NewProject extends Component { state = { - projectExists: false, projectName: '', selectedProvider: null as string | null, - awsRegion: '' as string | null, - awsAccessId: '' as string | null, - awsSecretKey: '' as string | null, - gcpRegion: '' as string | null, - gcpProjectId: '' as string | null, - gcpKeyData: '' as string | null, - status: null as string | null, - } - - isAlphanumeric = (x: string) => { - let re = /^[a-z0-9-]+$/; - if (x.length == 0 || x.search(re) === -1) { - return false; - } - return true; - } - - handleSelectProvider = (provider: string) => { - this.setState({ selectedProvider: provider }); - } - - renderProviderList = () => { - return providers.map((provider: string, i: number) => { - let providerInfo = integrationList[provider]; - return ( - this.handleSelectProvider(provider)} - > - - - {providerInfo.label} - - - Hosted in your own cloud. - - - ) - }); - } - - // TODO: split this out into a separate component - renderProvisioners = () => { - if (this.state.selectedProvider === 'aws') { - - return ( - - { - this.setState({ selectedProvider: null }); - }}> - - - - - AWS Credentials - - help - Guide - - - this.setState({ awsRegion: x })} - label='📍 AWS Region' - placeholder='ex: mars-north-12' - width='100%' - isRequired={true} - /> - this.setState({ awsAccessId: x })} - label='👤 AWS Access ID' - placeholder='ex: AKIAIOSFODNN7EXAMPLE' - width='100%' - isRequired={true} - /> - this.setState({ awsSecretKey: x })} - label='🔒 AWS Secret Key' - placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○' - width='100%' - isRequired={true} - /> - - ); - } else if (this.state.selectedProvider === 'gcp') { - return ( - - { - this.setState({ selectedProvider: null }); - }}> - - - - - GCP Credentials - - help - Guide - - - this.setState({ gcpRegion: x })} - label='📍 GCP Region' - placeholder='ex: us-central1-a' - width='100%' - isRequired={true} - /> - this.setState({ gcpProjectId: x })} - label='🏷️ GCP Project ID' - placeholder='ex: pale-moon-24601' - width='100%' - isRequired={true} - /> - this.setState({ gcpKeyData: x })} - label='🔒 GCP Key Data' - placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○' - width='100%' - isRequired={true} - /> - - ); - } else if (this.state.selectedProvider === 'do') { - return ( - - { - this.setState({ selectedProvider: null }); - }}> - - - - DigitalOcean support is in closed beta. If you would like to run Porter in your own DO account, email contact@getporter.dev. - - - ); - } - - return ( - - {this.renderProviderList()} - - ); - } - - renderHostingSection = () => { - if (this.state.selectedProvider === 'skipped') { - return ( - <> - Select your hosting backend: - - You can manually link to an existing cluster once this project has been created. - - - Don't have a Kubernetes cluster? - this.setState({ selectedProvider: null })}> - Provision through Porter - - - - ) - } - - return ( - <> - - Select your hosting backend: * - - {this.renderProvisioners()} - - Already have a Kubernetes cluster? - { - if (this.state.projectExists) { - this.props.setCurrentView('dashboard'); - } else { - this.setState({ selectedProvider: 'skipped' }); - } - }}> - Skip - - - - ) - } - - validateForm = () => { - let { - projectName, - selectedProvider, - awsAccessId, - awsSecretKey, - awsRegion, - gcpRegion, - gcpKeyData, - gcpProjectId, - } = this.state; - if (!this.isAlphanumeric(projectName) || projectName === '') { - return false; - } else if (selectedProvider === 'aws') { - return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== ''; - } else if (selectedProvider === 'gcp') { - return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== ''; - } else if (selectedProvider === 'skipped') { - return true; - } - return false; } - provisionECR = (proj: ProjectType, callback: (proj: ProjectType, ecr: any) => void) => { - let { awsAccessId, awsSecretKey, awsRegion } = this.state; - - api.createAWSIntegration('', { - aws_region: awsRegion, - aws_access_key_id: awsAccessId, - aws_secret_access_key: awsSecretKey, - }, { id: proj.id }, (err: any, res: any) => { - if (err) { - console.log(err); - return; - } - - api.provisionECR('', { - aws_integration_id: res.data.id, - ecr_name: `${proj.name}-registry` - }, {id: proj.id}, (err: any, ecr:any) => { - if (err) { - this.setState({ - projectExists: true, - status: 'Please provide valid credentials.', - }); - return; - } - - callback(proj, ecr); - }) - - }); - } - - provisionEKS = (proj: ProjectType, ecr: any) => { - let { awsAccessId, awsSecretKey, awsRegion } = this.state; - let clusterName = `${proj.name}-cluster` - - api.createAWSIntegration('', { - aws_region: awsRegion, - aws_access_key_id: awsAccessId, - aws_secret_access_key: awsSecretKey, - aws_cluster_id: clusterName, - }, { id: proj.id }, (err: any, res: any) => { - if (err) { - console.log(err); - return; - } - - api.provisionEKS('', { - aws_integration_id: res.data.id, - eks_name: clusterName, - }, { id: proj.id}, (err: any, eks: any) => { - if (err) { - this.setState({ - projectExists: true, - status: 'Please provide valid credentials.', - }); - return; - } - - this.props.setCurrentView('provisioner', [ - { infra_id: ecr?.data?.id, kind: ecr?.data?.kind }, - { infra_id: eks?.data?.id, kind: eks?.data?.kind }, - ]); - }) - }) - } - - provisionGKE = (proj: ProjectType, id: number) => { - let clusterName = `${proj.name}-cluster` - console.log('provisioning gke...'); - api.createGKE('', { - gke_name: clusterName, - gcp_integration_id: id, - }, { project_id: proj.id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else if (res?.data) { - - // TODO: set to provisioner - alert('success'); - } - }); - } - - provisionGCR = (proj: ProjectType, id: number) => { - console.log('provisioning gcr...'); - api.createGCR('', { - gcp_integration_id: id, - }, { project_id: proj.id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else if (res?.data) { - console.log('gcr provisioned with response: ', res.data); - this.provisionGKE(proj, id); - } - }); - } - - provisionGCP = (proj: ProjectType) => { - this.setState({ status: 'loading' }); - - let { gcpRegion, gcpKeyData, gcpProjectId } = this.state; - console.log('provisioning gcp...'); - api.createGCPIntegration('', { - gcp_region: gcpRegion, - gcp_key_data: gcpKeyData, - gcp_project_id: gcpProjectId, - }, { project_id: proj.id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else if (res?.data) { - console.log('gcp provisioned with response: ', res.data); - let { id } = res.data; - this.provisionGCR(proj, id); - } - }); - } - - createProject = () => { - this.setState({ status: 'loading' }); - api.createProject('', { - name: this.state.projectName - }, {}, (err: any, res: any) => { - if (err) { - console.log(err); - } else { - let { user } = this.context; - api.getProjects('', {}, { id: user.userId }, (err: any, res: any) => { - if (err) { - console.log(err) - } else if (res.data) { - this.context.setProjects(res.data); - if (res.data.length > 0) { - let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName); - this.context.setCurrentProject(proj); - - if (this.state.selectedProvider === 'aws') { - this.provisionECR(proj, this.provisionEKS); - } else if (this.state.selectedProvider === 'gcp') { - this.provisionGCP(proj); - } else { - this.props.setCurrentView('dashboard', null); - } - } - } - }); - } - }); - } - - createInfra = () => { - this.setState({ status: 'loading' }); - let { user } = this.context; - api.getProjects('', {}, { id: user.userId }, (err: any, res: any) => { - if (err) { - console.log(err) - } else if (res.data) { - this.context.setProjects(res.data); - if (res.data.length > 0) { - let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName); - this.context.setCurrentProject(proj); - if (this.state.selectedProvider === 'aws') { - this.provisionECR(proj, this.provisionEKS) - - } else { - this.props.setCurrentView('dashboard', null); - } - } - } - }); - } - - renderHeaderSection = () => { - if (this.state.projectExists) { - return ( - <> - - Configure Hosting - - - - There was an issue configuring your cloud provider. - - - - You can refer to our docs for instructions on - - creating AWS credentials for Porter - . - -
- - ); - } - + render() { + let { setCurrentView } = this.props; + let { projectName } = this.state; return ( - <> + New Project Project name - + (lowercase letters, numbers, and "-" only) * @@ -478,43 +52,12 @@ export default class NewProject extends Component { width='470px' /> - - ); - } - - renderButton = () => { - if (this.state.projectExists) { - return ( - - ); - } - - return ( - - ); - } - - render() { - let { selectedProvider } = this.state; - return ( - - {this.renderHeaderSection()} - {this.renderHostingSection()} - {this.renderButton()} +
); } @@ -522,6 +65,11 @@ export default class NewProject extends Component { NewProject.contextType = Context; +const Br = styled.div` + width: 100%; + height: 100px; +`; + const Link = styled.a` cursor: pointer; margin-left: 5px; @@ -797,8 +345,7 @@ const TitleSection = styled.div` const StyledNewProject = styled.div` width: calc(90% - 150px); min-width: 300px; - height: ${(props: { height: string }) => props.height}; position: relative; padding-top: 50px; - margin-top: ${(props: { height: string }) => props.height === '600px' ? 'calc(50vh - 350px)' : 'calc(50vh - 400px)'}; + margin-top: calc(50vh - 340px); `; \ No newline at end of file diff --git a/dashboard/src/main/home/project-settings/InviteList.tsx b/dashboard/src/main/home/project-settings/InviteList.tsx new file mode 100644 index 0000000000..471ad78e12 --- /dev/null +++ b/dashboard/src/main/home/project-settings/InviteList.tsx @@ -0,0 +1,388 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import { InviteType } from '../../../shared/types'; +import Loading from '../../../components/Loading'; +import api from '../../../shared/api'; +import InputRow from '../../../components/values-form/InputRow'; + +import { Context } from '../../../shared/Context'; + +type PropsType = { +} + +type StateType = { + loading: boolean, + invites: InviteType[], + email: string, + invalidEmail: boolean, +} + +export default class InviteList extends Component { + state = { + loading: true, + invites: [] as InviteType[], + email: '', + invalidEmail: false, + } + + componentDidMount() { + this.getInviteData(); + } + + getInviteData = () => { + let { currentProject } = this.context; + + this.setState({ loading: true }) + api.getInvites('', {}, { + id: currentProject.id + }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + this.setState({ invites: res.data, loading: false }, () => { + for (let i = this.state.invites.length - 1; i >= 0; i--) { + if (this.state.invites[i].expired && !this.state.invites[i].accepted) { + api.deleteInvite('', {}, { + id: currentProject.id, invId: this.state.invites[i].id + }, (err: any, res: any) => { + if (err) { + console.log(`Error deleting invite: ${err}`); + } else { + this.state.invites.splice(i, 1); + } + }) + } + } + }); + } + }); + } + + validateEmail = () => { + var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + if (regex.test(this.state.email.toLowerCase())) { + this.setState({ invalidEmail: false }); + this.createInvite(); + } else { + this.setState({ invalidEmail: true }); + } + } + + createInvite = () => { + let { currentProject } = this.context; + api.createInvite('', { email: this.state.email }, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + this.getInviteData(); + this.setState({ email: '' }); + } + }) + } + + deleteInvite = (index: number) => { + let { currentProject } = this.context; + api.deleteInvite('', {}, { + id: currentProject.id, invId: this.state.invites[index].id + }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + this.getInviteData(); + } + }) + } + + replaceInvite = (index: number) => { + let { currentProject } = this.context; + api.createInvite('', { email: this.state.invites[index].email }, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + api.deleteInvite('', {}, { + id: currentProject.id, invId: this.state.invites[index].id + }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + this.getInviteData(); + } + }) + } + }) + } + + copyToClip = (index: number) => { + let { currentProject } = this.context; + navigator.clipboard.writeText( + `${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}` + ).then(function() { + }, function() { + console.log("couldn't copy link to clipboard"); + }) + } + + renderInvitations = () => { + let { currentProject } = this.context; + if (this.state.loading) { + return ( + + ) + } else { + var invContent: any[] = []; + for (let i = 0; i < this.state.invites.length; i++) { + if (this.state.invites[i].accepted) { + invContent.push( + + + {this.state.invites[i].email} + + + + + this.deleteInvite(i)} + > + Remove + + + + ) + } else if (this.state.invites[i].expired) { + invContent.push( + + + {this.state.invites[i].email} + + + + + this.replaceInvite(i)} + > + Get New Link + + + + + this.deleteInvite(i)} + > + Delete Invite + + + + ) + } else { + invContent.push( + + + {this.state.invites[i].email} + + + + + this.copyToClip(i)} + > + Copy Link + + + + + this.deleteInvite(i)} + > + Delete Invite + + + + ) + } + } + return ( + <> + Collaborators + {invContent.length > 0 + ? {invContent}
+ : This project currently has no collaborators. + } + + ) + } + } + + render() { + return ( + <> + Manage Access + + this.setState({ email: x })} + width='324px' + placeholder='ex. mrp@getporter.dev' + /> + this.validateEmail()} + > + Invite! + + + {this.state.invalidEmail && + + Invalid Email Address. Try Again. + + } + {this.renderInvitations()} + + ) + } +} + +InviteList.contextType = Context; + +const Subtitle = styled.div` + font-size: 18px; + font-weight: 700; + font-family: 'Work Sans', sans-serif; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 24px; + margin-top: 32px; +`; + +const Subsubtitle = styled.div` + font-size: 13px; + font-family: 'Work Sans', sans-serif; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 12px; +`; + +const BodyText = styled.div` + color: #ffffff66; + font-weight: 400; + font-size: 13px; +`; + +const CopyButton = styled.div` + color: #ffffff; + font-weight: 400; + font-size: 13px; + margin-left: 12px; + float: right; + width: 128px; + padding-top: 7px; + padding-bottom: 6px; + border-radius: 5px; + border: 1px solid #ffffff20; + background-color: #ffffff10; + text-align: center; + overflow: hidden; + transition: all 0.1s ease-out; + :hover { + border: 1px solid #ffffff66; + background-color: #ffffff20; + } +`; + +const InviteButton = styled(CopyButton)` + margin-bottom: 14px; +`; + +const Rower = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +`; + +const CreateInvite = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; + margin-top: -20px; + margin-bottom: 14px; +`; + +const ShareLink = styled.input` + outline: none; + border: none; + font-size: 13px; + background: #ffffff11; + border: 1px solid #ffffff55; + width: 50%; + color: #74a5f7; + padding: 5px 10px; + height: 30px; + text-overflow: ellipsis; + border-radius: 3px; + ::placeholder, + ::-webkit-input-placeholder { + color: #fa0a26; + font-weight: 600; + } +`; + +const Spacer = styled.div` + height: 24px; +`; + +const Table = styled.table` + width: 100%; + border-spacing: 0px; + border: 1px solid #ffffff55; + border-radius: 5px; +`; + +const Td = styled.td` + white-space: nowrap; + padding: 20px 0px; + border-top: ${(props: {isTop: boolean}) => (props.isTop ? 'none' : '1px solid #ffffff55')}; + &:last-child { + padding-right: 16px; + } +`; + +const Tr = styled.tr` +`; + +const MailTd = styled(Td)` + padding-left: 16px; + max-width: 242px; + min-width: 242px; + overflow: hidden; + text-overflow: ellipsis; + color: #ffffff; + font-weight: 400; + font-size: 13px; +`; + +const LinkTd = styled(Td)` + width: 100%; +`; + +const Invalid = styled.div` + margin-top: -26px; + margin-bottom: 26px; + color: #fa0a26; + font-size: 13px; + font-family: 'Work Sans', sans-serif; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx new file mode 100644 index 0000000000..47d14ee10a --- /dev/null +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -0,0 +1,177 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import InviteList from './InviteList'; + +import { Context } from '../../../shared/Context'; + +type PropsType = { + setCurrentView: (x: string) => void, +} + +type StateType = { + projectName: string, +} + +export default class ProjectSettings extends Component { + state = { + projectName: '', + } + + componentDidMount() { + let { currentProject, user } = this.context; + this.setState({ projectName: currentProject.name }); + } + + renderTitle = () => { + let { currentProject } = this.context; + if (currentProject) { + return ( + <> + + Project Settings + + + + ); + } + } + + renderDelete = () => { + let { currentProject } = this.context; + if (currentProject) { + return ( + <> + Other Settings + + + Delete this project: + + this.context.setCurrentModal('UpdateProjectModal', { + currentProject: currentProject, + setCurrentView: this.props.setCurrentView, + })} + > + Delete + + + + ) + } + } + + renderContents = () => { + return ( + + + {this.renderDelete()} + + ) + } + + render () { + return ( + + {this.renderTitle()} + {this.renderContents()} + + ); + } +} + +ProjectSettings.contextType = Context; + +const Title = styled.div` + font-size: 24px; + font-weight: 600; + font-family: 'Work Sans', sans-serif; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const TitleSection = styled.div` + margin-bottom: 20px; + display: flex; + flex-direction: row; + align-items: center; + height: 40px; +`; + +const StyledProjectSettings = styled.div` + width: calc(90% - 150px); + min-width: 300px; + padding-top: 45px; +`; + +const LineBreak = styled.div` + width: calc(100% - 0px); + height: 2px; + background: #ffffff20; + margin: 10px 0px -20px; +`; + +const Subtitle = styled.div` + font-size: 18px; + font-weight: 700; + font-family: 'Work Sans', sans-serif; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 24px; + margin-top: 32px; +`; + +const BodyText = styled.div` + color: #ffffff; + font-weight: 400; + font-size: 13px; +`; + +const CopyButton = styled.div` + color: #ffffff; + font-weight: 400; + font-size: 13px; + margin-left: 12px; + float: right; + width: 128px; + padding-top: 8px; + padding-bottom: 8px; + border-radius: 5px; + border: 1px solid #ffffff20; + background-color: #ffffff10; + text-align: center; + overflow: hidden; + transition: all 0.1s ease-out; + :hover { + border: 1px solid #ffffff66; + background-color: #ffffff20; + } +`; + +const DeleteButton = styled(CopyButton)` + background-color: #b91133; + border: none; + width: 88px; + margin-left: 20px; + :hover { + background-color: #b91133; + filter: brightness(120%); + border: none; + } +`; + +const ContentHolder = styled.div` + min-width: 420px; + width: 100%; + margin-bottom: 55px; +`; + +const Rower = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/provisioner/AWSFormSection.tsx b/dashboard/src/main/home/provisioner/AWSFormSection.tsx new file mode 100644 index 0000000000..ab4562b226 --- /dev/null +++ b/dashboard/src/main/home/provisioner/AWSFormSection.tsx @@ -0,0 +1,392 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import close from '../../../assets/close.png'; +import { isAlphanumeric } from '../../../shared/common'; +import api from '../../../shared/api'; +import { Context } from '../../../shared/Context'; +import { ProjectType, InfraType } from '../../../shared/types'; + +import InputRow from '../../../components/values-form/InputRow'; +import Helper from '../../../components/values-form/Helper'; +import Heading from '../../../components/values-form/Heading'; +import SaveButton from '../../../components/SaveButton'; +import CheckboxList from '../../../components/values-form/CheckboxList'; + +type PropsType = { + setSelectedProvisioner: (x: string | null) => void, + handleError: () => void, + projectName: string, + setCurrentView: (x: string | null, data?: any) => void, + infras: InfraType[], +}; + +type StateType = { + awsRegion: string, + awsAccessId: string, + awsSecretKey: string, + selectedInfras: { value: string, label: string }[], + buttonStatus: string, +}; + +const provisionOptions = [ + { value: 'ecr', label: 'Elastic Container Registry (ECR)' }, + { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' }, +]; + +// TODO: Consolidate across forms w/ HOC +export default class AWSFormSection extends Component { + state = { + awsRegion: '', + awsAccessId: '', + awsSecretKey: '', + selectedInfras: [...provisionOptions], + buttonStatus: '', + } + + componentDidMount = () => { + let { infras } = this.props; + let { selectedInfras } = this.state; + + if (infras) { + + // From the dashboard, only uncheck and disable if "creating" or "created" + let filtered = selectedInfras; + infras.forEach( + (infra: InfraType, i: number) => { + let { kind, status } = infra; + if ( + kind === 'ecr' + && (status === 'creating' || status === 'created') + ) { + filtered = filtered.filter((item: any) => { + return item.value !== 'ecr'; + }); + } else if ( + kind === 'eks' + && (status === 'creating' || status === 'created') + ) { + filtered = filtered.filter((item: any) => { + return item.value !== 'eks'; + }); + } + } + ); + this.setState({ selectedInfras: filtered }); + } + } + + checkFormDisabled = () => { + let { + awsRegion, + awsAccessId, + awsSecretKey, + selectedInfras, + } = this.state; + let { projectName } = this.props; + if (projectName || projectName === '') { + return ( + !isAlphanumeric(projectName) + || !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '') + || selectedInfras.length === 0 + ); + } else { + return ( + !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '') + || selectedInfras.length === 0 + ); + } + } + + // Step 1: Create a project + createProject = (callback?: any) => { + console.log('Creating project'); + let { projectName, handleError } = this.props; + let { + user, + setProjects, + setCurrentProject, + currentProject + } = this.context; + + api.createProject('', { name: projectName }, { + }, (err: any, res: any) => { + if (err) { + console.log(err); + handleError(); + return; + } else { + api.getProjects('', {}, { + id: user.userId + }, (err: any, res: any) => { + if (err) { + console.log(err); + handleError(); + return; + } + setProjects(res.data); + if (res.data.length > 0) { + let tgtProject = res.data.find((el: ProjectType) => { + return el.name === projectName; + }); + setCurrentProject(tgtProject); + callback && callback(); + } + }); + } + }); + } + + provisionECR = (callback?: any) => { + console.log('Provisioning ECR') + let { awsAccessId, awsSecretKey, awsRegion } = this.state; + let { currentProject } = this.context; + let { handleError } = this.props; + + api.createAWSIntegration('', { + aws_region: awsRegion, + aws_access_key_id: awsAccessId, + aws_secret_access_key: awsSecretKey, + }, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + handleError(); + return; + } + + api.provisionECR('', { + aws_integration_id: res.data.id, + ecr_name: `${currentProject.name}-registry` + }, {id: currentProject.id}, (err: any, res: any) => { + if (err) { + console.log(err); + handleError(); + return; + } + callback && callback(); + }) + + }); + } + + provisionEKS = () => { + console.log('Provisioning EKS'); + let { setCurrentView, handleError } = this.props; + let { awsAccessId, awsSecretKey, awsRegion } = this.state; + let { currentProject } = this.context; + + let clusterName = `${currentProject.name}-cluster` + api.createAWSIntegration('', { + aws_region: awsRegion, + aws_access_key_id: awsAccessId, + aws_secret_access_key: awsSecretKey, + aws_cluster_id: clusterName, + }, { id: currentProject.id }, (err: any, res: any) => { + if (err) { + console.log(err); + handleError(); + return; + } + api.provisionEKS('', { + aws_integration_id: res.data.id, + eks_name: clusterName, + }, { id: currentProject.id}, (err: any, eks: any) => { + if (err) { + console.log(err); + handleError(); + return; + } + setCurrentView('provisioner'); + }) + }) + } + + // TODO: handle generically (with > 2 steps) + onCreateAWS = () => { + let { projectName, setCurrentView } = this.props; + let { selectedInfras } = this.state; + + console.log(selectedInfras); + if (!projectName) { + console.log(selectedInfras) + if (selectedInfras.length === 2) { + // Case: project exists, provision ECR + EKS + this.provisionECR(this.provisionEKS); + } else if (selectedInfras[0].value === 'ecr') { + // Case: project exists, only provision ECR + this.provisionECR(() => setCurrentView('provisioner')); + } else { + // Case: project exists, only provision EKS + this.provisionEKS(); + } + } else { + if (selectedInfras.length === 2) { + // Case: project DNE, provision ECR + EKS + this.createProject(() => this.provisionECR(this.provisionEKS)); + } else if (selectedInfras[0].value === 'ecr') { + // Case: project DNE, only provision ECR + this.createProject(() => this.provisionECR(() => { + setCurrentView('provisioner'); + })); + } else { + // Case: project DNE, only provision EKS + this.createProject(this.provisionEKS); + } + } + } + + render() { + let { setSelectedProvisioner } = this.props; + let { + awsRegion, + awsAccessId, + awsSecretKey, + selectedInfras, + } = this.state; + + return ( + + + setSelectedProvisioner(null)}> + + + + AWS Credentials + + help + Guide + + + this.setState({ awsRegion: x })} + label='📍 AWS Region' + placeholder='ex: us-east-2' + width='100%' + isRequired={true} + /> + this.setState({ awsAccessId: x })} + label='👤 AWS Access ID' + placeholder='ex: AKIAIOSFODNN7EXAMPLE' + width='100%' + isRequired={true} + /> + this.setState({ awsSecretKey: x })} + label='🔒 AWS Secret Key' + placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○' + width='100%' + isRequired={true} + /> +
+ Resources + Porter will provision the following resources + { + this.setState({ selectedInfras: x }); + }} + /> +
+ {this.props.children ? this.props.children : } + +
+ ); + } +} + +AWSFormSection.contextType = Context; + +const Padding = styled.div` + height: 15px; +`; + +const Br = styled.div` + width: 100%; + height: 2px; +`; + +const StyledAWSFormSection = styled.div` + position: relative; + padding-bottom: 35px; +`; + +const FormSection = styled.div` + background: #ffffff11; + margin-top: 25px; + background: #26282f; + border-radius: 5px; + margin-bottom: 25px; + padding: 25px; + padding-bottom: 16px; + font-size: 13px; + animation: fadeIn 0.3s 0s; + position: relative; +`; + +const CloseButton = styled.div` + position: absolute; + display: block; + width: 40px; + height: 40px; + padding: 13px 0 12px 0; + z-index: 1; + text-align: center; + border-radius: 50%; + right: 15px; + top: 12px; + cursor: pointer; + :hover { + background-color: #ffffff11; + } +`; + +const GuideButton = styled.a` + display: flex; + align-items: center; + margin-left: 20px; + color: #aaaabb; + font-size: 13px; + margin-bottom: -1px; + border: 1px solid #aaaabb; + padding: 5px 10px; + padding-left: 6px; + border-radius: 5px; + cursor: pointer; + :hover { + background: #ffffff11; + color: #ffffff; + border: 1px solid #ffffff; + + > i { + color: #ffffff; + } + } + + > i { + color: #aaaabb; + font-size: 16px; + margin-right: 6px; + } +`; + +const CloseButtonImg = styled.img` + width: 14px; + margin: 0 auto; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/provisioner/ExistingClusterSection.tsx b/dashboard/src/main/home/provisioner/ExistingClusterSection.tsx new file mode 100644 index 0000000000..318f08bfd5 --- /dev/null +++ b/dashboard/src/main/home/provisioner/ExistingClusterSection.tsx @@ -0,0 +1,101 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import api from '../../../shared/api'; +import { ProjectType } from '../../../shared/types'; +import { isAlphanumeric } from '../../../shared/common'; +import { Context } from '../../../shared/Context'; + +import SaveButton from '../../../components/SaveButton'; +import CheckboxList from '../../../components/values-form/CheckboxList'; + +type PropsType = { + projectName: string, + setCurrentView: (x: string, data?: any) => void, +}; + +type StateType = { + buttonStatus: string, +}; + +export default class ExistingClusterSection extends Component { + state = { + buttonStatus: '', + } + + onCreateProject = () => { + let { projectName, setCurrentView } = this.props; + let { user, setProjects, setCurrentProject } = this.context; + + this.setState({ buttonStatus: 'loading' }); + api.createProject('', { name: projectName }, { + }, (err: any, res: any) => { + if (err) { + console.log(err); + } else { + api.getProjects('', {}, { + id: user.userId + }, (err: any, res: any) => { + if (err) { + console.log(err) + } else if (res.data) { + setProjects(res.data); + if (res.data.length > 0) { + let proj = res.data.find((el: ProjectType) => { + return el.name === projectName; + }); + setCurrentProject(proj); + setCurrentView('dashboard', null); + } + } + }); + } + }); + } + + render() { + let { children, projectName } = this.props; + let { buttonStatus } = this.state; + return ( + + + You can manually link to an existing cluster once this project has + been created. + + {children ? children : } + + + ); + } +} + +ExistingClusterSection.contextType = Context; + +const Padding = styled.div` + height: 15px; +`; + +const StyledExistingClusterSection = styled.div` + position: relative; + padding-bottom: 35px; +`; + +const Placeholder = styled.div` + margin-top: 25px; + background: #26282f; + margin-bottom: 27px; + border-radius: 5px; + height: 170px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff44; + font-size: 13px; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/provisioner/GCPFormSection.tsx b/dashboard/src/main/home/provisioner/GCPFormSection.tsx new file mode 100644 index 0000000000..b916a284be --- /dev/null +++ b/dashboard/src/main/home/provisioner/GCPFormSection.tsx @@ -0,0 +1,182 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import close from '../../../assets/close.png'; + +import InputRow from '../../../components/values-form/InputRow'; +import Helper from '../../../components/values-form/Helper'; +import Heading from '../../../components/values-form/Heading'; +import SaveButton from '../../../components/SaveButton'; +import CheckboxList from '../../../components/values-form/CheckboxList'; + +type PropsType = { + setSelectedProvisioner: (x: string | null) => void, +}; + +type StateType = { + gcpRegion: string, + gcpProjectId: string, + gcpKeyData: string, + selectedInfras: { value: string, label: string }[], +}; + +const dummyOptions = [ + { value: 'gcr', label: 'Google Container Registry (GCR)' }, + { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' }, +]; + +export default class GCPFormSection extends Component { + state = { + gcpRegion: '', + gcpProjectId: '', + gcpKeyData: '', + selectedInfras: [] as { value: string, label: string }[], + } + + render() { + let { setSelectedProvisioner } = this.props; + let { + gcpRegion, + gcpProjectId, + gcpKeyData, + selectedInfras, + } = this.state; + + return ( + + + setSelectedProvisioner(null)}> + + + + GCP Credentials + + help + Guide + + + this.setState({ gcpRegion: x })} + label='📍 GCP Region' + placeholder='ex: us-central1-a' + width='100%' + isRequired={true} + /> + this.setState({ gcpProjectId: x })} + label='🏷️ GCP Project ID' + placeholder='ex: pale-moon-24601' + width='100%' + isRequired={true} + /> + this.setState({ gcpKeyData: x })} + label='🔒 GCP Key Data' + placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○' + width='100%' + isRequired={true} + /> +
+ Resources + Porter will provision the following resources + { + this.setState({ selectedInfras: x }); + }} + /> +
+ console.log('oolala')} + makeFlush={true} + helper='Note: Provisioning can take up to 15 minutes' + /> +
+ ); + } +} + +const Br = styled.div` + width: 100%; + height: 2px; +`; + +const StyledGCPFormSection = styled.div` + position: relative; + padding-bottom: 70px; +`; + +const FormSection = styled.div` + background: #ffffff11; + margin-top: 25px; + background: #26282f; + border-radius: 5px; + padding: 25px; + padding-bottom: 16px; + font-size: 13px; + animation: fadeIn 0.3s 0s; + position: relative; +`; + +const CloseButton = styled.div` + position: absolute; + display: block; + width: 40px; + height: 40px; + padding: 13px 0 12px 0; + z-index: 1; + text-align: center; + border-radius: 50%; + right: 15px; + top: 12px; + cursor: pointer; + :hover { + background-color: #ffffff11; + } +`; + +const GuideButton = styled.a` + display: flex; + align-items: center; + margin-left: 20px; + color: #aaaabb; + font-size: 13px; + margin-bottom: -1px; + border: 1px solid #aaaabb; + padding: 5px 10px; + padding-left: 6px; + border-radius: 5px; + cursor: pointer; + :hover { + background: #ffffff11; + color: #ffffff; + border: 1px solid #ffffff; + + > i { + color: #ffffff; + } + } + + > i { + color: #aaaabb; + font-size: 16px; + margin-right: 6px; + } +`; + +const CloseButtonImg = styled.img` + width: 14px; + margin: 0 auto; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/provisioner/InfraStatuses.tsx b/dashboard/src/main/home/provisioner/InfraStatuses.tsx new file mode 100644 index 0000000000..609f709dd1 --- /dev/null +++ b/dashboard/src/main/home/provisioner/InfraStatuses.tsx @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import loadingDots from '../../../assets/loading-dots.gif'; +import { InfraType } from '../../../shared/types'; +import { infraNames } from '../../../shared/common'; + +type PropsType = { + infras: InfraType[], +}; + +type StateType = { +}; + +export default class InfraStatuses extends Component { + state = { + } + + renderStatusIcon = (status: string) => { + if (status === 'created') { + return ; + } else if (status === 'creating') { + return + } else if (status === 'error') { + return + } + } + + render() { + return ( + + {this.props.infras.map((infra: InfraType, i: number) => { + return ( + + {this.renderStatusIcon(infra.status)} + {infraNames[infra.kind]} + + ) + })} + + ); + } +} + +const StatusIcon = styled.div<{ color?: string }>` + display: flex; + align-items: center; + justify-content: center; + width: 20px; + font-size: 16px; + color: ${props => props.color ? props.color : '#68c49c'}; + margin-right: 10px; +`; + +const InfraRow = styled.div` + width: 100%; + height: 25px; + padding-left: 2px; + margin-top: 10px; + font-size: 13px; + color: #aaaabb; + display: flex; + align-items: center; +`; + +const StyledInfraStatuses = styled.div` + margin-top: 20px; + margin-bottom: 0; +`; \ No newline at end of file diff --git a/dashboard/src/main/home/provisioner/ProvisionerSettings.tsx b/dashboard/src/main/home/provisioner/ProvisionerSettings.tsx new file mode 100644 index 0000000000..a9809a7509 --- /dev/null +++ b/dashboard/src/main/home/provisioner/ProvisionerSettings.tsx @@ -0,0 +1,269 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import { Context } from '../../../shared/Context'; +import { integrationList } from '../../../shared/common'; +import { InfraType } from '../../../shared/types'; + +import Helper from '../../../components/values-form/Helper'; +import AWSFormSection from './AWSFormSection'; +import GCPFormSection from './GCPFormSection'; +import SaveButton from '../../../components/SaveButton'; +import ExistingClusterSection from './ExistingClusterSection'; + +type PropsType = { + setCurrentView: (x: string, data?: any) => void, + isInNewProject?: boolean, + projectName?: string, + infras?: InfraType[], +}; + +type StateType = { + selectedProvider: string | null, + infras: InfraType[], +}; + +const providers = ['aws', 'gcp', 'do',]; + +export default class NewProject extends Component { + state = { + selectedProvider: null as string | null, + infras: [] as InfraType[], + } + + // Handle any submission (pre-status) error + handleError = () => { + let { setCurrentView } = this.props; + let { setCurrentError } = this.context; + setCurrentView('dashboard'); + this.setState({ selectedProvider: null }); + setCurrentError('Provisioning failed. Check your credentials and try again.'); + } + + renderSelectedProvider = () => { + let { selectedProvider } = this.state; + let { projectName, setCurrentView, infras } = this.props; + + let renderSkipHelper = () => { + return ( + <> + {selectedProvider === 'skipped' + ? ( + + Don't have a Kubernetes cluster? + this.setState({ selectedProvider: null })} + > + Provision through Porter + + + ) : ( + + + Already have a Kubernetes cluster? + this.setState({ + selectedProvider: 'skipped' + })} + > + Skip + + + + ) + } + + ); + } + + switch (selectedProvider) { + case 'aws': + return ( + { + this.setState({ selectedProvider: x }); + }} + > + {renderSkipHelper()} + + ); + case 'gcp': + return ( + { + this.setState({ selectedProvider: x }); + }} + /> + ); + case 'do': + return

most

; + default: + return ( + + {renderSkipHelper()} + + ); + } + } + + render() { + let { selectedProvider } = this.state; + let { isInNewProject } = this.props; + return ( + + + Need a cluster? Provision through Porter: + {isInNewProject && *} + + {!selectedProvider ? ( + + {providers.map((provider: string, i: number) => { + let providerInfo = integrationList[provider]; + return ( + { + this.setState({ selectedProvider: provider }); + }} + > + + + {providerInfo.label} + + + Hosted in your own cloud. + + + ); + })} + + ) : ( + <>{this.renderSelectedProvider()} + )} + {(isInNewProject && !selectedProvider) && ( + <> + + Already have a Kubernetes cluster? + this.setState({ selectedProvider: 'skipped' })} + > + Skip + + +
+ {}} + makeFlush={true} + helper='Note: Provisioning can take up to 15 minutes' + /> + + )} +
+ ); + } +} + +NewProject.contextType = Context; + +const Br = styled.div` + width: 100%; + height: 35px; +`; + +const StyledProvisionerSettings = styled.div` + position: relative; +`; + +const PositionWrapper = styled.div<{ selectedProvider: string | null}>` +`; + +const Highlight = styled.div` + margin-left: 5px; + color: #8590ff; + cursor: pointer; +`; + +const BlockList = styled.div` + overflow: visible; + margin-top: 25px; + margin-bottom: 27px; + display: grid; + grid-column-gap: 25px; + grid-row-gap: 25px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +`; + +const Required = styled.div` + margin-left: 8px; + color: #fc4976; +`; + +const Icon = styled.img<{ bw?: boolean }>` + height: 42px; + margin-top: 30px; + margin-bottom: 15px; + filter: ${props => props.bw ? 'grayscale(1)' : ''}; +`; + +const BlockDescription = styled.div` + margin-bottom: 12px; + color: #ffffff66; + text-align: center; + font-weight: default; + font-size: 13px; + padding: 0px 25px; + height: 2.4em; + font-size: 12px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const BlockTitle = styled.div` + margin-bottom: 12px; + width: 80%; + text-align: center; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Block = styled.div<{ disabled?: boolean }>` + align-items: center; + user-select: none; + border-radius: 5px; + display: flex; + font-size: 13px; + overflow: hidden; + font-weight: 500; + padding: 3px 0px 5px; + flex-direction: column; + align-item: center; + justify-content: space-between; + height: 170px; + cursor: ${props => props.disabled ? '' : 'pointer'}; + color: #ffffff; + position: relative; + background: #26282f; + box-shadow: 0 3px 5px 0px #00000022; + :hover { + background: ${props => props.disabled ? '' : '#ffffff11'}; + } + + animation: fadeIn 0.3s 0s; + @keyframes fadeIn { + from { opacity: 0 } + to { opacity: 1 } + } +`; \ No newline at end of file diff --git a/dashboard/src/main/home/new-project/Provisioner.tsx b/dashboard/src/main/home/provisioner/ProvisionerStatus.tsx similarity index 69% rename from dashboard/src/main/home/new-project/Provisioner.tsx rename to dashboard/src/main/home/provisioner/ProvisionerStatus.tsx index ff6398e205..a5c469ae6f 100644 --- a/dashboard/src/main/home/new-project/Provisioner.tsx +++ b/dashboard/src/main/home/provisioner/ProvisionerStatus.tsx @@ -6,13 +6,13 @@ import { Context } from '../../../shared/Context'; import ansiparse from '../../../shared/ansiparser' import loading from '../../../assets/loading.gif'; import warning from '../../../assets/warning.png'; +import { InfraType } from '../../../shared/types'; +import { filterOldInfras } from '../../../shared/common'; import Helper from '../../../components/values-form/Helper'; -import { eventNames } from 'process'; -import { inflateRaw, inflateRawSync } from 'zlib'; +import InfraStatuses from './InfraStatuses'; type PropsType = { - viewData: any, setCurrentView: (x: string) => void, } @@ -23,8 +23,17 @@ type StateType = { maxStep : Record, currentStep: Record, triggerEnd: boolean, + infras: InfraType[], }; +const dummyInfras = [ + { kind: 'ecr', status: 'creating', id: 5, project_id: 1 }, + { kind: 'eks', status: 'error', id: 3, project_id: 1 }, + { kind: 'eks', status: 'error', id: 1, project_id: 1 }, + { kind: 'eks', status: 'error', id: 4, project_id: 1 }, + { kind: 'ecr', status: 'created', id: 2, project_id: 1 }, +]; + export default class Provisioner extends Component { state = { error: false, @@ -33,6 +42,44 @@ export default class Provisioner extends Component { maxStep: {} as Record, currentStep: {} as Record, triggerEnd: false, + infras: [] as InfraType[], + } + + componentDidMount() { + let { currentProject } = this.context; + let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws' + + // Check if current project is provisioning + api.getInfra('', {}, { + project_id: currentProject.id + }, (err: any, res: any) => { + if (err) { + console.log(err); + } + let infras = filterOldInfras(res.data); + let error = false; + infras.forEach((infra: InfraType, i: number) => { + if (infra.status === 'error') { + error = true; + } + }); + + // Filter historical infras list for most current instances of each + let websockets = infras.map((infra: any) => { + let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`) + return this.setupWebsocket(ws, infra) + }); + + this.setState({ error, infras, websockets, logs: ["Provisioning resources..."] }); + }); + } + + componentWillUnmount() { + if (!this.state.websockets) { return; } + + this.state.websockets.forEach((ws: any) => { + ws.close() + }) } scrollToBottom = () => { @@ -127,69 +174,19 @@ export default class Provisioner extends Component { return ws } - componentDidMount() { - let { currentProject } = this.context; - let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws' - let viewData = this.props.viewData || [] - - let websockets = viewData.map((infra: any) => { - let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`) - return this.setupWebsocket(ws, infra) - }); - - this.setState({ websockets, logs: ["Provisioning EKS cluster and ECR registry..."] }); - } - - componentWillUnmount() { - if (!this.state.websockets) { return; } - - this.state.websockets.forEach((ws: any) => { - ws.close() - }) - } - scrollRef = React.createRef(); renderLogs = () => { return this.state.logs.map((log, i) => { - return {log} + return {log}; }); } - renderHeadingSection = () => { - if (this.state.error) { - return ( - <> - - <img src={warning} /> Provisioning Error - - - - Porter encountered an error while provisioning. - this.props.setCurrentView('dashboard')}> - Exit to dashboard - - to try again with new credentials. - - - ); - } - - return ( - <> - - <img src={loading} /> Setting Up Porter - - - Porter is currently being provisioned to your AWS account: - - - ) - } - onEnd = () => { let myInterval = setInterval(() => { - api.getClusters('', {}, { id: this.context.currentProject.id }, (err: any, res: any) => { + api.getClusters('', {}, { + id: this.context.currentProject.id + }, (err: any, res: any) => { if (err) { console.log(err); } else if (res.data) { @@ -204,6 +201,9 @@ export default class Provisioner extends Component { } render() { + let { error, triggerEnd, infras } = this.state; + let { setCurrentView } = this.props; + let maxStep = 0; let currentStep = 0; @@ -219,23 +219,55 @@ export default class Provisioner extends Component { } } - if (maxStep !== 0 && currentStep === maxStep && !this.state.triggerEnd) { + if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) { this.onEnd() this.setState({ triggerEnd: true }); } return ( - {this.renderHeadingSection()} - + {error + ? ( + <> + + <img src={warning} /> Provisioning Error + + + + Porter encountered an error while provisioning. + setCurrentView('dashboard')}> + Exit to dashboard + + to try again with new credentials. + + + ) : ( + <> + + <img src={loading} /> Setting Up Porter + + + Porter is currently provisioning resources in your cloud provider: + + + ) + } + - + + - - {this.renderLogs()} - + {this.renderLogs()} @@ -273,7 +305,7 @@ const Log = styled.div` const LogStream = styled.div` height: 300px; - margin-top: 30px; + margin-top: 20px; font-size: 13px; border: 2px solid #ffffff55; border-radius: 10px; @@ -292,8 +324,8 @@ const Message = styled.div` font-size: 13px; `; -const Loaded = styled.div` - width: ${(props: { progress: string }) => props.progress}; +const Loaded = styled.div<{ progress: string }>` + width: ${props => props.progress}; height: 100%; background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff); background-size: 400% 400%; diff --git a/dashboard/src/main/home/sidebar/ClusterSection.tsx b/dashboard/src/main/home/sidebar/ClusterSection.tsx index 5821ba6cf2..f0dbbf145b 100644 --- a/dashboard/src/main/home/sidebar/ClusterSection.tsx +++ b/dashboard/src/main/home/sidebar/ClusterSection.tsx @@ -49,14 +49,31 @@ export default class ClusterSection extends Component { this.props.setWelcome(true); } else { this.props.setWelcome(false); - // TODO: handle uninitialized kubeconfig if (res.data) { let clusters = res.data; + clusters.sort((a: any, b: any) => a.id - b.id); if (clusters.length > 0) { this.setState({ clusters }); - setCurrentCluster(clusters[0]); - } else if (this.props.currentView !== 'provisioner') { + let saved = JSON.parse(localStorage.getItem('currentCluster')); + if (localStorage.getItem('currentCluster') !== 'null') { + setCurrentCluster(clusters[0]); + for (let i = 0; i < clusters.length; i++) { + if (clusters[i].id = saved.id + && clusters[i].project_id === saved.project_id + && clusters[i].name === saved.name + ) { + setCurrentCluster(clusters[i]); + break; + } + } + } else { + setCurrentCluster(clusters[0]); + } + } else if ( + this.props.currentView !== 'provisioner' + && this.props.currentView !== 'new-project' + ) { this.setState({ clusters: [] }); setCurrentCluster(null); this.props.setCurrentView('dashboard'); diff --git a/dashboard/src/main/home/sidebar/Drawer.tsx b/dashboard/src/main/home/sidebar/Drawer.tsx index 5bc52a109f..79d0e0506e 100644 --- a/dashboard/src/main/home/sidebar/Drawer.tsx +++ b/dashboard/src/main/home/sidebar/Drawer.tsx @@ -22,6 +22,8 @@ export default class Drawer extends Component { let { currentCluster, setCurrentCluster } = this.context; if (clusters.length > 0 && currentCluster) { + clusters.sort((a, b) => a.id - b.id); + return clusters.map((cluster: ClusterType, i: number) => { /* let active = this.context.activeProject && diff --git a/dashboard/src/main/home/sidebar/ProjectSection.tsx b/dashboard/src/main/home/sidebar/ProjectSection.tsx index 9e4be97dfd..f22cc0a56e 100644 --- a/dashboard/src/main/home/sidebar/ProjectSection.tsx +++ b/dashboard/src/main/home/sidebar/ProjectSection.tsx @@ -2,13 +2,12 @@ import React, { Component } from 'react'; import styled from 'styled-components'; import gradient from '../../../assets/gradient.jpg'; -import api from '../../../shared/api'; import { Context } from '../../../shared/Context'; import { ProjectType, InfraType } from '../../../shared/types'; type PropsType = { currentProject: ProjectType, - setCurrentView: (x: string, viewData?: any) => void, + setCurrentView: (x: string) => void, projects: ProjectType[], }; @@ -21,40 +20,15 @@ export default class ProjectSection extends Component { expanded: false, }; - handleSelectProject = (project: ProjectType) => { - this.context.setCurrentProject(project); - - api.getInfra('', {}, { project_id: project.id }, (err: any, res: any) => { - if (err) { - console.log(err); - } else if (res.data) { - - let viewData = [] as any[] - res.data.forEach((el: InfraType) => { - if (el.status === 'creating') { - viewData.push({ - infra_id: el.id, - kind: el.kind, - }); - } - }); - - if (viewData.length > 0) { - this.props.setCurrentView('provisioner', viewData); - } else { - this.props.setCurrentView('dashboard'); - } - } - }); - } - renderOptionList = () => { + let { setCurrentProject } = this.context; + return this.props.projects.map((project: ProjectType, i: number) => { return (