Home.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import React, { Component } from "react";
  2. import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
  3. import styled from "styled-components";
  4. import api from "shared/api";
  5. import { H } from "highlight.run";
  6. import { Context } from "shared/Context";
  7. import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
  8. import { ClusterType, ProjectType } from "shared/types";
  9. import ConfirmOverlay from "components/ConfirmOverlay";
  10. import Loading from "components/Loading";
  11. import ClusterDashboard from "./cluster-dashboard/ClusterDashboard";
  12. import Dashboard from "./dashboard/Dashboard";
  13. import WelcomeForm from "./WelcomeForm";
  14. import Integrations from "./integrations/Integrations";
  15. import Templates from "./launch/Launch";
  16. import Navbar from "./navbar/Navbar";
  17. import ProjectSettings from "./project-settings/ProjectSettings";
  18. import Sidebar from "./sidebar/Sidebar";
  19. import PageNotFound from "components/PageNotFound";
  20. import { fakeGuardedRoute } from "shared/auth/RouteGuard";
  21. import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
  22. import discordLogo from "../../assets/discord.svg";
  23. import Onboarding from "./onboarding/Onboarding";
  24. import ModalHandler from "./ModalHandler";
  25. import { NewProjectFC } from "./new-project/NewProject";
  26. // Guarded components
  27. const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
  28. "get",
  29. "list",
  30. "update",
  31. "create",
  32. "delete",
  33. ])(ProjectSettings);
  34. const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
  35. "get",
  36. "list",
  37. "update",
  38. "create",
  39. "delete",
  40. ])(Integrations);
  41. type PropsType = RouteComponentProps &
  42. WithAuthProps & {
  43. logOut: () => void;
  44. currentProject: ProjectType;
  45. currentCluster: ClusterType;
  46. currentRoute: PorterUrl;
  47. };
  48. type StateType = {
  49. forceSidebar: boolean;
  50. showWelcome: boolean;
  51. handleDO: boolean; // Trigger DO infra calls after oauth flow if needed
  52. ghRedirect: boolean;
  53. forceRefreshClusters: boolean; // For updating ClusterSection from modal on deletion
  54. // Track last project id for refreshing clusters on project change
  55. prevProjectId: number | null;
  56. showWelcomeForm: boolean;
  57. };
  58. // TODO: Handle cluster connected but with some failed infras (no successful set)
  59. // TODO: Set up current view / sidebar tab as dynamic Routes
  60. class Home extends Component<PropsType, StateType> {
  61. state = {
  62. forceSidebar: true,
  63. showWelcome: false,
  64. prevProjectId: null as number | null,
  65. forceRefreshClusters: false,
  66. sidebarReady: false,
  67. handleDO: false,
  68. ghRedirect: false,
  69. showWelcomeForm: true,
  70. };
  71. getMetadata = () => {
  72. api
  73. .getMetadata("<token>", {}, {})
  74. .then((res) => {
  75. this.context.setCapabilities(res.data);
  76. })
  77. .catch((err) => {
  78. console.log(err);
  79. });
  80. };
  81. getProjects = (id?: number) => {
  82. let { user, setProjects, setCurrentProject } = this.context;
  83. let { currentProject } = this.props;
  84. let queryString = window.location.search;
  85. let urlParams = new URLSearchParams(queryString);
  86. let projectId = urlParams.get("project_id");
  87. if (!projectId && currentProject?.id) {
  88. pushQueryParams(this.props, { project_id: currentProject.id.toString() });
  89. }
  90. api
  91. .getProjects("<token>", {}, { id: user.userId })
  92. .then((res) => {
  93. if (res.data) {
  94. if (res.data.length === 0) {
  95. this.redirectToNewProject();
  96. } else if (res.data.length > 0 && !currentProject) {
  97. setProjects(res.data);
  98. let foundProject = null;
  99. if (id) {
  100. res.data.forEach((project: ProjectType, i: number) => {
  101. if (project.id === id) {
  102. foundProject = project;
  103. }
  104. });
  105. setCurrentProject(foundProject || res.data[0]);
  106. }
  107. if (!foundProject) {
  108. res.data.forEach((project: ProjectType, i: number) => {
  109. if (
  110. project.id.toString() ===
  111. localStorage.getItem("currentProject")
  112. ) {
  113. foundProject = project;
  114. }
  115. });
  116. setCurrentProject(foundProject || res.data[0]);
  117. }
  118. }
  119. }
  120. })
  121. .catch(console.log);
  122. };
  123. componentDidMount() {
  124. this.checkOnboarding();
  125. let { match } = this.props;
  126. let { user } = this.context;
  127. // Initialize Highlight
  128. if (
  129. window.location.href.includes("dashboard.getporter.dev") &&
  130. !user.email.includes("@getporter.dev")
  131. ) {
  132. H.init("y2d13lgr");
  133. H.identify(user.email, { id: user.id });
  134. }
  135. // Handle redirect from DO
  136. let queryString = window.location.search;
  137. let urlParams = new URLSearchParams(queryString);
  138. let err = urlParams.get("error");
  139. if (err) {
  140. this.context.setCurrentError(err);
  141. }
  142. let defaultProjectId = parseInt(urlParams.get("project_id"));
  143. this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
  144. urlParams.delete("gh_oauth");
  145. this.getProjects(defaultProjectId);
  146. this.getMetadata();
  147. if (
  148. !this.context.hasFinishedOnboarding &&
  149. this.props.history.location.pathname &&
  150. !this.props.history.location.pathname.includes("onboarding")
  151. ) {
  152. this.context.setCurrentModal("RedirectToOnboardingModal");
  153. }
  154. }
  155. async checkIfProjectHasBilling(projectId: number) {
  156. if (!projectId) {
  157. return false;
  158. }
  159. try {
  160. const res = await api.getHasBilling(
  161. "<token>",
  162. {},
  163. { project_id: projectId }
  164. );
  165. this.context.setHasBillingEnabled(res.data?.has_billing);
  166. return res?.data?.has_billing;
  167. } catch (error) {
  168. console.log(error);
  169. }
  170. }
  171. async checkOnboarding() {
  172. try {
  173. const project_id = this.context?.currentProject?.id;
  174. if (!project_id) {
  175. return;
  176. }
  177. const res = await api.getOnboardingState("<token>", {}, { project_id });
  178. if (res.status === 404) {
  179. this.context.setHasFinishedOnboarding(true);
  180. return;
  181. }
  182. if (res?.data && res?.data.current_step !== "clean_up") {
  183. this.context.setHasFinishedOnboarding(false);
  184. } else {
  185. this.context.setHasFinishedOnboarding(true);
  186. }
  187. } catch (error) {}
  188. }
  189. // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
  190. // 1. Make sure clicking cluster in drawer shows cluster-dashboard
  191. // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
  192. // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
  193. componentDidUpdate(prevProps: PropsType) {
  194. if (
  195. !this.context.hasFinishedOnboarding &&
  196. prevProps.match.url !== this.props.match.url &&
  197. this.props.history.location.pathname &&
  198. !this.props.history.location.pathname.includes("onboarding") &&
  199. !this.props.history.location.pathname.includes("new-project") &&
  200. !this.props.history.location.pathname.includes("project-settings")
  201. ) {
  202. this.context.setCurrentModal("RedirectToOnboardingModal");
  203. }
  204. if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
  205. this.checkOnboarding();
  206. this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
  207. .then((isBillingEnabled) => {
  208. if (isBillingEnabled) {
  209. api
  210. .getUsage(
  211. "<token>",
  212. {},
  213. { project_id: this.context?.currentProject?.id }
  214. )
  215. .then((res) => {
  216. const usage = res.data;
  217. this.context.setUsage(usage);
  218. if (usage.exceeded) {
  219. this.context.setCurrentModal("UsageWarningModal", {
  220. usage,
  221. });
  222. }
  223. })
  224. .catch(console.log);
  225. }
  226. })
  227. .catch(console.log);
  228. }
  229. if (
  230. prevProps.currentProject !== this.props.currentProject ||
  231. (!prevProps.currentCluster && this.props.currentCluster)
  232. ) {
  233. this.getMetadata();
  234. }
  235. }
  236. projectOverlayCall = async () => {
  237. let { user, setProjects, setCurrentProject } = this.context;
  238. try {
  239. const res = await api.getProjects("<token>", {}, { id: user.userId });
  240. if (!res.data) {
  241. this.context.setCurrentModal(null, null);
  242. return;
  243. }
  244. setProjects(res.data);
  245. if (!res.data.length) {
  246. setCurrentProject(null, () => this.redirectToNewProject());
  247. } else {
  248. setCurrentProject(res.data[0]);
  249. }
  250. this.context.setCurrentModal(null, null);
  251. } catch (error) {
  252. /** @todo Centralize with error handler */
  253. console.log(error);
  254. }
  255. };
  256. handleDelete = async () => {
  257. let { setCurrentModal, currentProject } = this.context;
  258. localStorage.removeItem(currentProject.id + "-cluster");
  259. try {
  260. await api.deleteProject("<token>", {}, { id: currentProject?.id });
  261. this.projectOverlayCall();
  262. } catch (error) {
  263. /** @todo Centralize with error handler */
  264. console.log(error);
  265. }
  266. try {
  267. const res = await api.getClusters<
  268. {
  269. infra_id?: number;
  270. name: string;
  271. }[]
  272. >("<token>", {}, { id: currentProject?.id });
  273. const destroyInfraPromises = res.data.map((cluster) => {
  274. if (!cluster.infra_id) {
  275. return undefined;
  276. }
  277. return api.destroyInfra(
  278. "<token>",
  279. { name: cluster.name },
  280. { project_id: currentProject.id, infra_id: cluster.infra_id }
  281. );
  282. });
  283. await Promise.all(destroyInfraPromises);
  284. } catch (error) {
  285. console.log(error);
  286. }
  287. setCurrentModal(null, null);
  288. pushFiltered(this.props, "/dashboard", []);
  289. };
  290. redirectToNewProject = () => {
  291. pushFiltered(this.props, "/new-project", ["project_id"]);
  292. };
  293. redirectToOnboarding = () => {
  294. pushFiltered(this.props, "/onboarding", []);
  295. };
  296. render() {
  297. let {
  298. currentModal,
  299. setCurrentModal,
  300. currentProject,
  301. currentOverlay,
  302. projects,
  303. } = this.context;
  304. const { cluster, baseRoute } = this.props.match.params as any;
  305. return (
  306. <StyledHome>
  307. <ModalHandler
  308. setRefreshClusters={(x) => this.setState({ forceRefreshClusters: x })}
  309. />
  310. {currentOverlay && (
  311. <ConfirmOverlay
  312. show={true}
  313. message={currentOverlay.message}
  314. onYes={currentOverlay.onYes}
  315. onNo={currentOverlay.onNo}
  316. />
  317. )}
  318. {/* Render sidebar when there's at least one project */}
  319. {projects?.length > 0 && baseRoute !== "new-project" ? (
  320. <Sidebar
  321. key="sidebar"
  322. forceSidebar={this.state.forceSidebar}
  323. setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
  324. currentView={this.props.currentRoute}
  325. forceRefreshClusters={this.state.forceRefreshClusters}
  326. setRefreshClusters={(x: boolean) =>
  327. this.setState({ forceRefreshClusters: x })
  328. }
  329. />
  330. ) : (
  331. <>
  332. <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
  333. <Icon src={discordLogo} />
  334. Join Our Discord
  335. </DiscordButton>
  336. {/* This should only be shown on the first render of the app */}
  337. {this.state.showWelcomeForm &&
  338. localStorage.getItem("welcomed") != "true" &&
  339. projects?.length === 0 && (
  340. <>
  341. <WelcomeForm
  342. closeForm={() => this.setState({ showWelcomeForm: false })}
  343. />
  344. <Navbar
  345. logOut={this.props.logOut}
  346. currentView={this.props.currentRoute} // For form feedback
  347. />
  348. </>
  349. )}
  350. </>
  351. )}
  352. <ViewWrapper id="HomeViewWrapper">
  353. <Navbar
  354. logOut={this.props.logOut}
  355. currentView={this.props.currentRoute} // For form feedback
  356. />
  357. <Switch>
  358. <Route
  359. path="/new-project"
  360. render={() => {
  361. return <NewProjectFC />;
  362. }}
  363. ></Route>
  364. <Route
  365. path="/onboarding"
  366. render={() => {
  367. return <Onboarding />;
  368. }}
  369. />
  370. <Route
  371. path="/dashboard"
  372. render={() => {
  373. return (
  374. <DashboardWrapper>
  375. <Dashboard
  376. projectId={this.context.currentProject?.id}
  377. setRefreshClusters={(x: boolean) =>
  378. this.setState({ forceRefreshClusters: x })
  379. }
  380. />
  381. </DashboardWrapper>
  382. );
  383. }}
  384. />
  385. <Route
  386. path={[
  387. "/cluster-dashboard",
  388. "/applications",
  389. "/jobs",
  390. "/env-groups",
  391. ]}
  392. render={() => {
  393. let { currentCluster } = this.context;
  394. if (currentCluster?.id === -1) {
  395. return <Loading />;
  396. } else if (!currentCluster || !currentCluster.name) {
  397. return (
  398. <DashboardWrapper>
  399. <PageNotFound />
  400. </DashboardWrapper>
  401. );
  402. }
  403. return (
  404. <DashboardWrapper>
  405. <ClusterDashboard
  406. currentCluster={currentCluster}
  407. setSidebar={(x: boolean) =>
  408. this.setState({ forceSidebar: x })
  409. }
  410. currentView={this.props.currentRoute}
  411. // setCurrentView={(x: string) => this.setState({ currentView: x })}
  412. />
  413. </DashboardWrapper>
  414. );
  415. }}
  416. />
  417. <Route
  418. path={"/integrations"}
  419. render={() => <GuardedIntegrations />}
  420. />
  421. <Route
  422. path={"/project-settings"}
  423. render={() => <GuardedProjectSettings />}
  424. />
  425. <Route path={"*"} render={() => <Templates />} />
  426. </Switch>
  427. </ViewWrapper>
  428. <ConfirmOverlay
  429. show={currentModal === "UpdateProjectModal"}
  430. message={
  431. currentProject
  432. ? `Are you sure you want to delete ${currentProject.name}?`
  433. : ""
  434. }
  435. onYes={this.handleDelete}
  436. onNo={() => setCurrentModal(null, null)}
  437. />
  438. </StyledHome>
  439. );
  440. }
  441. }
  442. Home.contextType = Context;
  443. export default withRouter(withAuth(Home));
  444. const ViewWrapper = styled.div`
  445. height: 100%;
  446. width: 100vw;
  447. padding-top: 10vh;
  448. overflow-y: auto;
  449. display: flex;
  450. flex: 1;
  451. justify-content: center;
  452. background: #202227;
  453. position: relative;
  454. `;
  455. const DashboardWrapper = styled.div`
  456. width: calc(85%);
  457. min-width: 300px;
  458. `;
  459. const StyledHome = styled.div`
  460. width: 100vw;
  461. height: 100vh;
  462. position: fixed;
  463. top: 0;
  464. left: 0;
  465. margin: 0;
  466. user-select: none;
  467. display: flex;
  468. justify-content: center;
  469. @keyframes floatInModal {
  470. from {
  471. opacity: 0;
  472. transform: translateY(30px);
  473. }
  474. to {
  475. opacity: 1;
  476. transform: translateY(0px);
  477. }
  478. }
  479. `;
  480. const DiscordButton = styled.a`
  481. position: absolute;
  482. z-index: 1;
  483. text-decoration: none;
  484. bottom: 17px;
  485. display: flex;
  486. align-items: center;
  487. width: 170px;
  488. left: 15px;
  489. border: 2px solid #ffffff44;
  490. border-radius: 3px;
  491. color: #ffffff44;
  492. height: 40px;
  493. font-family: Work Sans, sans-serif;
  494. font-size: 14px;
  495. font-weight: bold;
  496. cursor: pointer;
  497. :hover {
  498. > img {
  499. opacity: 60%;
  500. }
  501. color: #ffffff88;
  502. border-color: #ffffff88;
  503. }
  504. `;
  505. const Icon = styled.img`
  506. height: 25px;
  507. width: 25px;
  508. opacity: 30%;
  509. margin-left: 7px;
  510. margin-right: 5px;
  511. `;