ExpandedJobChart.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. import React, { Component } from "react";
  2. import styled from "styled-components";
  3. import yaml from "js-yaml";
  4. import close from "assets/close.png";
  5. import _ from "lodash";
  6. import loading from "assets/loading.gif";
  7. import { ChartType, StorageType, ClusterType } from "shared/types";
  8. import { Context } from "shared/Context";
  9. import api from "shared/api";
  10. import SaveButton from "components/SaveButton";
  11. import ConfirmOverlay from "components/ConfirmOverlay";
  12. import Loading from "components/Loading";
  13. import TabRegion from "components/TabRegion";
  14. import JobList from "./jobs/JobList";
  15. import SettingsSection from "./SettingsSection";
  16. import FormWrapper from "components/values-form/FormWrapper";
  17. import { PlaceHolder } from "brace";
  18. type PropsType = {
  19. namespace: string;
  20. currentChart: ChartType;
  21. currentCluster: ClusterType;
  22. closeChart: () => void;
  23. setSidebar: (x: boolean) => void;
  24. };
  25. type StateType = {
  26. currentChart: ChartType;
  27. imageIsPlaceholder: boolean;
  28. loading: boolean;
  29. jobs: any[];
  30. tabOptions: any[];
  31. tabContents: any;
  32. currentTab: string | null;
  33. websockets: Record<string, any>;
  34. showDeleteOverlay: boolean;
  35. deleting: boolean;
  36. saveValuesStatus: string | null;
  37. formData: any;
  38. valuesToOverride: any;
  39. };
  40. export default class ExpandedJobChart extends Component<PropsType, StateType> {
  41. state = {
  42. currentChart: this.props.currentChart,
  43. imageIsPlaceholder: false,
  44. loading: true,
  45. jobs: [] as any[],
  46. tabOptions: [] as any[],
  47. tabContents: [] as any,
  48. currentTab: null as string | null,
  49. websockets: {} as Record<string, any>,
  50. showDeleteOverlay: false,
  51. deleting: false,
  52. saveValuesStatus: null as string | null,
  53. formData: {} as any,
  54. valuesToOverride: {} as any,
  55. };
  56. // Retrieve full chart data (includes form and values)
  57. getChartData = (chart: ChartType) => {
  58. let { currentProject } = this.context;
  59. let { currentCluster, currentChart } = this.props;
  60. this.setState({ loading: true });
  61. api
  62. .getChart(
  63. "<token>",
  64. {
  65. namespace: currentChart.namespace,
  66. cluster_id: currentCluster.id,
  67. storage: StorageType.Secret,
  68. },
  69. {
  70. name: chart.name,
  71. revision: chart.version,
  72. id: currentProject.id,
  73. }
  74. )
  75. .then((res) => {
  76. let image = res.data?.config?.image?.repository;
  77. if (image === "porterdev/hello-porter-job") {
  78. this.setState(
  79. {
  80. currentChart: res.data,
  81. loading: false,
  82. imageIsPlaceholder: true,
  83. },
  84. () => {
  85. this.updateTabs();
  86. }
  87. );
  88. } else {
  89. this.setState({ currentChart: res.data, loading: false }, () => {
  90. this.updateTabs();
  91. });
  92. }
  93. })
  94. .catch(console.log);
  95. };
  96. refreshChart = () => this.getChartData(this.state.currentChart);
  97. mergeNewJob = (newJob: any) => {
  98. console.log("newJob", newJob);
  99. console.log("image?", newJob.values?.image?.repository);
  100. let jobs = this.state.jobs;
  101. let exists = false;
  102. jobs.forEach((job: any, i: number, self: any[]) => {
  103. if (
  104. job.metadata?.name == newJob.metadata?.name &&
  105. job.metadata?.namespace == newJob.metadata?.namespace
  106. ) {
  107. self[i] = newJob;
  108. exists = true;
  109. }
  110. });
  111. if (!exists) {
  112. jobs.push(newJob);
  113. }
  114. this.sortJobsAndSave(jobs);
  115. };
  116. setupJobWebsocket = (chart: ChartType) => {
  117. let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
  118. let { currentCluster, currentProject } = this.context;
  119. let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
  120. let ws = new WebSocket(
  121. `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
  122. );
  123. ws.onopen = () => {
  124. console.log("connected to websocket");
  125. };
  126. ws.onmessage = (evt: MessageEvent) => {
  127. let event = JSON.parse(evt.data);
  128. let object = event.Object;
  129. object.metadata.kind = event.Kind;
  130. // if event type is add or update, merge with existing jobs
  131. if (event.event_type == "ADD" || event.event_type == "UPDATE") {
  132. // filter job belonging to chart
  133. let chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
  134. let releaseLabel =
  135. event.Object?.metadata?.labels["meta.helm.sh/release-name"];
  136. if (
  137. chartLabel &&
  138. releaseLabel &&
  139. chartLabel == chartVersion &&
  140. releaseLabel == chart.name
  141. ) {
  142. this.mergeNewJob(event.Object);
  143. }
  144. }
  145. };
  146. ws.onclose = () => {
  147. console.log("closing websocket");
  148. };
  149. ws.onerror = (err: ErrorEvent) => {
  150. console.log(err);
  151. ws.close();
  152. };
  153. return ws;
  154. };
  155. handleSaveValues = (config?: any) => {
  156. let { currentCluster, setCurrentError, currentProject } = this.context;
  157. this.setState({ saveValuesStatus: "loading" });
  158. let conf: string;
  159. if (!config) {
  160. conf = yaml.dump({
  161. ...this.state.currentChart.config,
  162. });
  163. } else {
  164. // Convert dotted keys to nested objects
  165. let values = {};
  166. for (let key in config) {
  167. _.set(values, key, config[key]);
  168. }
  169. // Weave in preexisting values and convert to yaml
  170. conf = yaml.dump({
  171. ...(this.state.currentChart.config as Object),
  172. ...values,
  173. });
  174. }
  175. api
  176. .upgradeChartValues(
  177. "<token>",
  178. {
  179. namespace: this.state.currentChart.namespace,
  180. storage: StorageType.Secret,
  181. values: conf,
  182. },
  183. {
  184. id: currentProject.id,
  185. name: this.state.currentChart.name,
  186. cluster_id: currentCluster.id,
  187. }
  188. )
  189. .then((res) => {
  190. this.setState({ saveValuesStatus: "successful" });
  191. this.refreshChart();
  192. })
  193. .catch((err) => {
  194. console.log(err);
  195. this.setState({ saveValuesStatus: "error" });
  196. setCurrentError(JSON.stringify(err));
  197. });
  198. };
  199. getJobs = async (chart: ChartType) => {
  200. let { currentCluster, currentProject, setCurrentError } = this.context;
  201. api
  202. .getJobs(
  203. "<token>",
  204. {
  205. cluster_id: currentCluster.id,
  206. },
  207. {
  208. id: currentProject.id,
  209. chart: `${chart.chart.metadata.name}-${chart.chart.metadata.version}`,
  210. namespace: chart.namespace,
  211. release_name: chart.name,
  212. }
  213. )
  214. .then((res) => {
  215. // sort jobs by started timestamp
  216. this.sortJobsAndSave(res.data);
  217. })
  218. .catch((err) => setCurrentError(err));
  219. };
  220. sortJobsAndSave = (jobs: any[]) => {
  221. jobs.sort((job1, job2) => {
  222. let date1: Date = new Date(job1.status?.startTime);
  223. let date2: Date = new Date(job2.status?.startTime);
  224. return date2.getTime() - date1.getTime();
  225. });
  226. this.setState({ jobs });
  227. };
  228. renderTabContents = (currentTab: string) => {
  229. switch (currentTab) {
  230. case "jobs":
  231. if (this.state.imageIsPlaceholder) {
  232. return (
  233. <Placeholder>
  234. <TextWrap>
  235. <Header>
  236. <Spinner src={loading} /> This job is currently being deployed
  237. </Header>
  238. Navigate to the "Actions" tab of your GitHub repo to view live
  239. build logs.
  240. </TextWrap>
  241. </Placeholder>
  242. );
  243. }
  244. return (
  245. <TabWrapper>
  246. <JobList jobs={this.state.jobs} />
  247. <SaveButton
  248. text="Rerun Job"
  249. onClick={() => this.handleSaveValues()}
  250. status={this.state.saveValuesStatus}
  251. makeFlush={true}
  252. />
  253. </TabWrapper>
  254. );
  255. case "settings":
  256. return (
  257. <SettingsSection
  258. currentChart={this.state.currentChart}
  259. refreshChart={this.refreshChart}
  260. setShowDeleteOverlay={(x: boolean) =>
  261. this.setState({ showDeleteOverlay: x })
  262. }
  263. />
  264. );
  265. default:
  266. }
  267. };
  268. updateTabs() {
  269. let formData = this.state.currentChart.form;
  270. if (formData) {
  271. this.setState(
  272. {
  273. formData,
  274. },
  275. () =>
  276. this.setState({
  277. // TODO: handle passing in override values at same time as formData
  278. valuesToOverride: {
  279. showCronToggle: { value: false },
  280. },
  281. })
  282. );
  283. }
  284. let tabOptions = [] as any[];
  285. // Append universal tabs
  286. tabOptions.push({ label: "Jobs", value: "jobs" });
  287. if (formData) {
  288. formData.tabs.map((tab: any, i: number) => {
  289. tabOptions.push({
  290. value: tab.name,
  291. label: tab.label,
  292. sections: tab.sections,
  293. context: tab.context,
  294. });
  295. });
  296. }
  297. tabOptions.push({ label: "Settings", value: "settings" });
  298. // Filter tabs if previewing an old revision
  299. this.setState({ tabOptions });
  300. }
  301. renderIcon = () => {
  302. let { currentChart } = this.state;
  303. if (
  304. currentChart.chart.metadata.icon &&
  305. currentChart.chart.metadata.icon !== ""
  306. ) {
  307. return <Icon src={currentChart.chart.metadata.icon} />;
  308. } else {
  309. return <i className="material-icons">tonality</i>;
  310. }
  311. };
  312. readableDate = (s: string) => {
  313. let ts = new Date(s);
  314. let date = ts.toLocaleDateString();
  315. let time = ts.toLocaleTimeString([], {
  316. hour: "numeric",
  317. minute: "2-digit",
  318. });
  319. return `${time} on ${date}`;
  320. };
  321. componentDidMount() {
  322. let { currentChart } = this.state;
  323. window.analytics.track("Opened Chart", {
  324. chart: currentChart.name,
  325. });
  326. this.getChartData(currentChart);
  327. this.getJobs(currentChart);
  328. this.setupJobWebsocket(currentChart);
  329. }
  330. handleUninstallChart = () => {
  331. let { currentProject, currentCluster } = this.context;
  332. let { currentChart } = this.state;
  333. this.setState({ deleting: true });
  334. api
  335. .uninstallTemplate(
  336. "<token>",
  337. {},
  338. {
  339. namespace: currentChart.namespace,
  340. storage: StorageType.Secret,
  341. name: currentChart.name,
  342. id: currentProject.id,
  343. cluster_id: currentCluster.id,
  344. }
  345. )
  346. .then((res) => {
  347. this.setState({ showDeleteOverlay: false });
  348. this.props.closeChart();
  349. })
  350. .catch(console.log);
  351. };
  352. renderDeleteOverlay = () => {
  353. if (this.state.deleting) {
  354. return (
  355. <DeleteOverlay>
  356. <Loading />
  357. </DeleteOverlay>
  358. );
  359. }
  360. };
  361. render() {
  362. let { closeChart } = this.props;
  363. let { currentChart } = this.state;
  364. let chart = currentChart;
  365. return (
  366. <>
  367. <CloseOverlay onClick={closeChart} />
  368. <StyledExpandedChart>
  369. <ConfirmOverlay
  370. show={this.state.showDeleteOverlay}
  371. message={`Are you sure you want to delete ${currentChart.name}?`}
  372. onYes={this.handleUninstallChart}
  373. onNo={() => this.setState({ showDeleteOverlay: false })}
  374. />
  375. {this.renderDeleteOverlay()}
  376. <HeaderWrapper>
  377. <TitleSection>
  378. <Title>
  379. <IconWrapper>{this.renderIcon()}</IconWrapper>
  380. {chart.name}
  381. </Title>
  382. <InfoWrapper>
  383. <LastDeployed>
  384. Run {this.state.jobs.length} times <Dot>•</Dot>Last run
  385. {" " + this.readableDate(chart.info.last_deployed)}
  386. </LastDeployed>
  387. </InfoWrapper>
  388. <TagWrapper>
  389. Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
  390. </TagWrapper>
  391. </TitleSection>
  392. <CloseButton onClick={closeChart}>
  393. <CloseButtonImg src={close} />
  394. </CloseButton>
  395. </HeaderWrapper>
  396. <BodyWrapper>
  397. <FormWrapper
  398. isReadOnly={this.state.imageIsPlaceholder}
  399. valuesToOverride={this.state.valuesToOverride}
  400. clearValuesToOverride={() =>
  401. this.setState({ valuesToOverride: {} })
  402. }
  403. formData={this.state.formData}
  404. tabOptions={this.state.tabOptions}
  405. isInModal={true}
  406. renderTabContents={this.renderTabContents}
  407. tabOptionsOnly={true}
  408. onSubmit={this.handleSaveValues}
  409. saveValuesStatus={this.state.saveValuesStatus}
  410. />
  411. </BodyWrapper>
  412. </StyledExpandedChart>
  413. </>
  414. );
  415. }
  416. }
  417. ExpandedJobChart.contextType = Context;
  418. const TextWrap = styled.div``;
  419. const Header = styled.div`
  420. font-weight: 500;
  421. color: #aaaabb;
  422. font-size: 16px;
  423. margin-bottom: 15px;
  424. `;
  425. const Placeholder = styled.div`
  426. height: 100%;
  427. padding: 30px;
  428. padding-bottom: 70px;
  429. font-size: 13px;
  430. color: #ffffff44;
  431. width: 100%;
  432. display: flex;
  433. align-items: center;
  434. justify-content: center;
  435. `;
  436. const Spinner = styled.img`
  437. width: 15px;
  438. height: 15px;
  439. margin-right: 12px;
  440. margin-bottom: -2px;
  441. `;
  442. const BodyWrapper = styled.div`
  443. width: 100%;
  444. height: 100%;
  445. overflow: hidden;
  446. `;
  447. const TabWrapper = styled.div`
  448. height: 100%;
  449. width: 100%;
  450. overflow: hidden;
  451. `;
  452. const DeleteOverlay = styled.div`
  453. position: absolute;
  454. top: 0px;
  455. opacity: 100%;
  456. left: 0px;
  457. width: 100%;
  458. height: 100%;
  459. z-index: 999;
  460. display: flex;
  461. padding-bottom: 30px;
  462. align-items: center;
  463. justify-content: center;
  464. font-family: "Work Sans", sans-serif;
  465. font-size: 18px;
  466. font-weight: 500;
  467. color: white;
  468. flex-direction: column;
  469. background: rgb(0, 0, 0, 0.73);
  470. opacity: 0;
  471. animation: lindEnter 0.2s;
  472. animation-fill-mode: forwards;
  473. @keyframes lindEnter {
  474. from {
  475. opacity: 0;
  476. }
  477. to {
  478. opacity: 1;
  479. }
  480. }
  481. `;
  482. const CloseOverlay = styled.div`
  483. position: absolute;
  484. top: 0;
  485. left: 0;
  486. width: 100%;
  487. height: 100%;
  488. background: #202227;
  489. animation: fadeIn 0.2s 0s;
  490. opacity: 0;
  491. animation-fill-mode: forwards;
  492. @keyframes fadeIn {
  493. from {
  494. opacity: 0;
  495. }
  496. to {
  497. opacity: 1;
  498. }
  499. }
  500. `;
  501. const HeaderWrapper = styled.div``;
  502. const Dot = styled.div`
  503. margin-right: 9px;
  504. margin-left: 9px;
  505. `;
  506. const InfoWrapper = styled.div`
  507. display: flex;
  508. align-items: center;
  509. margin: 24px 0px 17px 0px;
  510. height: 20px;
  511. `;
  512. const LastDeployed = styled.div`
  513. font-size: 13px;
  514. margin-left: 0;
  515. margin-top: -1px;
  516. display: flex;
  517. align-items: center;
  518. color: #aaaabb66;
  519. `;
  520. const TagWrapper = styled.div`
  521. position: absolute;
  522. right: 0px;
  523. bottom: 0px;
  524. height: 20px;
  525. font-size: 12px;
  526. display: flex;
  527. align-items: center;
  528. justify-content: center;
  529. color: #ffffff44;
  530. border: 1px solid #ffffff44;
  531. border-radius: 3px;
  532. padding-left: 5px;
  533. background: #26282e;
  534. `;
  535. const NamespaceTag = styled.div`
  536. height: 20px;
  537. margin-left: 6px;
  538. color: #aaaabb;
  539. background: #43454a;
  540. border-radius: 3px;
  541. font-size: 12px;
  542. display: flex;
  543. align-items: center;
  544. justify-content: center;
  545. padding: 0px 6px;
  546. padding-left: 7px;
  547. border-top-left-radius: 0px;
  548. border-bottom-left-radius: 0px;
  549. `;
  550. const Icon = styled.img`
  551. width: 100%;
  552. `;
  553. const IconWrapper = styled.div`
  554. color: #efefef;
  555. font-size: 16px;
  556. height: 20px;
  557. width: 20px;
  558. display: flex;
  559. justify-content: center;
  560. align-items: center;
  561. border-radius: 3px;
  562. margin-right: 12px;
  563. > i {
  564. font-size: 20px;
  565. }
  566. `;
  567. const Title = styled.div`
  568. font-size: 18px;
  569. font-weight: 500;
  570. display: flex;
  571. align-items: center;
  572. `;
  573. const TitleSection = styled.div`
  574. width: 100%;
  575. position: relative;
  576. `;
  577. const CloseButton = styled.div`
  578. position: absolute;
  579. display: block;
  580. width: 40px;
  581. height: 40px;
  582. padding: 13px 0 12px 0;
  583. text-align: center;
  584. border-radius: 50%;
  585. right: 15px;
  586. top: 12px;
  587. cursor: pointer;
  588. :hover {
  589. background-color: #ffffff11;
  590. }
  591. `;
  592. const CloseButtonImg = styled.img`
  593. width: 14px;
  594. margin: 0 auto;
  595. `;
  596. const StyledExpandedChart = styled.div`
  597. width: calc(100% - 50px);
  598. height: calc(100% - 50px);
  599. z-index: 0;
  600. position: absolute;
  601. top: 25px;
  602. left: 25px;
  603. border-radius: 10px;
  604. background: #26272f;
  605. box-shadow: 0 5px 12px 4px #00000033;
  606. animation: floatIn 0.3s;
  607. animation-timing-function: ease-out;
  608. animation-fill-mode: forwards;
  609. padding: 25px;
  610. display: flex;
  611. overflow: hidden;
  612. flex-direction: column;
  613. @keyframes floatIn {
  614. from {
  615. opacity: 0;
  616. transform: translateY(30px);
  617. }
  618. to {
  619. opacity: 1;
  620. transform: translateY(0px);
  621. }
  622. }
  623. `;